diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..eb48f795 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1184 @@ +## 23.17.2 2023-10-16 +### PaymentsUI +* [Fixed] An issue with `STPPaymentCardTextField`, where the card params were not updated after deleting an empty sub field. +* [Fixed] Switched to Asset Catalogs and updated to the latest card brand logos. + +### Payments +* [Added] Support for MobilePay bindings. + +## 23.17.1 2023-10-09 +### PaymentSheet +* [Fixed] Fixed an issue when advancing from the country dropdown that prevented user's' from typing in their postal code. ([#2936](https://github.com/stripe/stripe-ios/issues/2936)) + +### PaymentsUI +* [Fixed] An issue with `STPPaymentCardTextField`, where the `paymentCardTextFieldDidChange` delegate method wasn't being called after deleting an empty sub field. + +## 23.17.0 2023-10-02 +### PaymentSheet +* [Fixed] Fixed an issue with selecting from lists on macOS Catalyst. Note that only macOS 11 or later is supported: We do not recommend releasing a Catalyst app targeting macOS 10.15. +* [Fixed] Fixed an issue with scanning card expiration dates. +* [Fixed] Fixed an issue where billing address collection configuration was not passed to Apple Pay. +* [Added] Support for Swish with PaymentIntents. +* [Added] Support for Bacs Direct Debit with PaymentIntents. + +### Basic Integration +* [Fixed] Fixed an issue with scanning card expiration dates. + +### Payments +* [Fixed] Fixed an issue where amounts in Serbian Dinar were displayed incorrectly. +* [Fixed] Fixed an issue where the SDK could hang in macOS Catalyst. +* [Added] Support for Swish bindings. + +## 23.16.0 2023-09-18 +### Payments +* [Added] Properties of STPConnectAccountParams are now mutable. +* [Fixed] Fixed STPConnectAccountCompanyParams.address being force unwrapped. It's now optional. +* [Added] Support for RevolutPay bindings + +### PaymentSheet +* [Added] Support for Alipay with PaymentIntents. +* [Added] Support for Cash App Pay with SetupIntents and PaymentIntents with `setup_future_usage`. +* [Added] Support for AU BECS Debit with SetupIntents. +* [Added] Support for OXXO with PaymentIntents. +* [Added] Support for Konbini with PaymentIntents. +* [Added] Support for PayNow with PaymentIntents. +* [Added] Support for PromptPay with PaymentIntents. +* [Added] Support for Boleto with PaymentIntents and SetupIntets. +* [Added] Support for External Payment Method as an invite-only private beta. +* [Added] Support for RevolutPay with SetupIntents and PaymentIntents with setup_future_usage (private beta). Note: PaymentSheet doesn't display this as a saved payment method yet. +* [Added] Support for Alma (Private Beta) with PaymentIntents. + +## 23.15.0 2023-08-28 +### PaymentSheet +* [Added] Support for AmazonPay (private beta), BLIK, and FPX with PaymentIntents. +* [Fixed] A bug where payment amounts were not displayed correctly for LAK currency. + +### StripeApplePay +* Fixed a compile-time issue with using StripeApplePay in an App Extension. ([#2853](https://github.com/stripe/stripe-ios/issues/2853)) + +### CustomerSheet +* [Added] `CustomerSheet`(https://stripe.com/docs/elements/customer-sheet?platform=ios) API, a prebuilt UI component that lets your customers manage their saved payment methods. + +## 23.14.0 2023-08-21 +### All +* Improved redirect UX when using Cash App Pay. + +### PaymentSheet +* [Added] Support for GrabPay with PaymentIntents. + +### Payments +* [Added] You can now create an STPConnectAccountParams without specifying a business type. + +### Basic Integration +* [Added] Adds `applePayLaterAvailability` to `STPPaymentContext`, a property that mirrors `PKPaymentRequest.applePayLaterAvailability`. This is useful if you need to disable Apple Pay Later. Note: iOS 17+. + + +## 23.13.0 2023-08-07 +### All +* [Fixed] Fixed compatibility with Xcode 15 beta 3. visionOS is now supported in iPadOS compatibility mode. +### PaymentSheet +* [Added] Enable bancontact and sofort for SetupIntents and PaymentIntents with setup_future_usage. Note: PaymentSheet doesn't display saved SEPA Debit payment methods yet. +### CustomerSheet +* [Added] `us_bank_account` PaymentMethod is now available in CustomerSheet + +## 23.12.0 2023-07-31 +### PaymentSheet +* [Added] Enable SEPA Debit and iDEAL for SetupIntents and PaymentIntents with setup_future_usage. Note: PaymentSheet doesn't display saved SEPA Debit payment methods yet. +* [Added] Add removeSavedPaymentMethodMessage to PaymentSheet.Configuration and CustomerSheet.Configuration. + +### Identity +* [Added] Supports [phone verification](https://stripe.com/docs/identity/phone) in Identity mobile SDK. + + +## 23.11.2 2023-07-24 +### PaymentSheet +* [Fixed] Update stp_icon_add@3x.png to 8bit color depth (Thanks @jszumski) + +### CustomerSheet +* [Fixed] Ability to removing payment method immediately after adding it. +* [Fixed] Re-init addPaymentMethodViewController after adding payment method to allow for adding another payment method + +## 23.11.1 2023-07-18 +### PaymentSheet +* [Fixed] Fixed various bugs in Link private beta. + +## 23.11.0 2023-07-17 +### CustomerSheet +* [Changed] Breaking interface change for `CustomerSheetResult`. `CustomerSheetResult.canceled` now has a nullable associated value signifying that there is no selected payment method. Please use both `.canceled(StripeOptionSelection?)` and `.selected(PaymentOptionSelection?)` to update your UI to show the latest selected payment method. + +## 23.10.0 2023-07-10 +### Payments +* [Fixed] A bug where `mandate_data` was not being properly attached to PayPal SetupIntent's. +### PaymentSheet +* [Added] You can now collect payment details before creating a PaymentIntent or SetupIntent. See [our docs](https://stripe.com/docs/payments/accept-a-payment-deferred) for more info. This integration also allows you to [confirm the Intent on the server](https://stripe.com/docs/payments/finalize-payments-on-the-server). + +## 23.9.4 2023-07-05 +### PaymentSheet +* [Added] US bank accounts are now supported when initializing with an IntentConfiguration. + +## 23.9.3 2023-06-26 +### PaymentSheet +* [Fixed] Affirm no longer requires shipping details. + +### CustomerSheet +* [Added] Added `billingDetailsCollectionConfiguration` to configure how you want to collect billing details (private beta). + +## 23.9.2 2023-06-20 +### Payments +* [Fixed] Fixed a bug causing Cash App Pay SetupIntents to incorrectly state they were canceled when they succeeded. + +### AddressElement +* [Fixed] A bug that was causing `addressViewControllerDidFinish` to return a non-nil `AddressDetails` when the user cancels out of the AddressElement when default values are provided. +* [Fixed] A bug that prevented the auto complete view from being presented when the AddressElement was created with default values. + +## 23.9.1 2023-06-12 +### PaymentSheet +* [Fixed] Fixed validating the IntentConfiguration matches the PaymentIntent/SetupIntent when it was already confirmed on the server. Note: server-side confirmation is in private beta. +### CustomerSheet +* [Fixed] Fixed bug with removing multiple saved payment methods + +## 23.9.0 2023-05-30 +### PaymentSheet +* [Changed] The private beta API for https://stripe.com/docs/payments/finalize-payments-on-the-server has changed: + * If you use `IntentConfiguration(..., confirmHandler:)`, the confirm handler now has an additional `shouldSavePaymentMethod: Bool` parameter that you should ignore. + * If you use `IntentConfiguration(..., confirmHandlerForServerSideConfirmation:)`, use `IntentConfiguration(..., confirmHandler:)` instead. Additionally, the confirm handler's first parameter is now an `STPPaymentMethod` object instead of a String id. Use `paymentMethod.stripeId` to get its id and send it to your server. +* [Fixed] Fixed PKR currency formatting. + +### CustomerSheet +* [Added] [CustomerSheet](https://stripe.com/docs/elements/customer-sheet?platform=ios) is now available (private beta) + +## 23.8.0 2023-05-08 +### Identity +* [Added] Added test mode M1 for the SDK. + +## 23.7.1 2023-05-02 +### Payments +* [Fixed] STPPaymentHandler.handleNextAction allows payment methods that are delayed or require further customer action like like SEPA Debit or OXXO. + +## 23.7.0 2023-04-24 +### PaymentSheet +* [Fixed] Fixed disabled text color, using a lower opacity version of the original color instead of the previous `.tertiaryLabel`. + +### Identity +* [Added] Added test mode for the SDK. + +## 23.6.2 2023-04-20 + +### Payments +* [Fixed] Fixed UnionPay cards appearing as invalid in some cases. + +### PaymentSheet +* [Fixed] Fixed a bug that prevents users from using SEPA Debit w/ PaymentIntents or SetupIntents and Paypal in PaymentIntent+setup_future_usage or SetupIntent. + +## 23.6.1 2023-04-17 +### All +* Xcode 13 is [no longer supported by Apple](https://developer.apple.com/news/upcoming-requirements/). Please upgrade to Xcode 14.1 or later. +### PaymentSheet +* [Fixed] Visual bug of the delete icon when deleting saved payment methods reported in [#2461](https://github.com/stripe/stripe-ios/issues/2461). + +## 23.6.0 2023-03-27 +### PaymentSheet +* [Added] Added `billingDetailsCollectionConfiguration` to configure how you want to collect billing details. See the docs [here](https://stripe.com/docs/payments/accept-a-payment?platform=ios&ui=payment-sheet#billing-details-collection). + +## 23.5.1 2023-03-20 +### Payments +* [Fixed] Fixed amounts in COP being formatted incorrectly. +* [Fixed] Fixed BLIK payment bindings not handling next actions correctly. +* [Changed] Removed usage of `UIDevice.currentDevice.name`. + +### Identity +* [Added] Added a retake photo button on selfie scanning screen. + +## 23.5.0 2023-03-13 +### Payments +* [Added] API bindings support for Cash App Pay. See the docs [here](https://stripe.com/docs/payments/cash-app-pay/accept-a-payment?platform=mobile). +* [Added] Added `STPCardValidator.possibleBrands(forCard:completion:)`, which returns the list of available networks for a card. + +### PaymentSheet +* [Added] Support for Cash App Pay in PaymentSheet. + +## 23.4.2 2023-03-06 +### Identity +* [Added] ID/Address verification. + +## 23.4.1 2023-02-27 +### PaymentSheet +* [Added] Debug logging to help identify why specific payment methods are not showing up in PaymentSheet. + +### Basic Integration +* [Fixed] Race condition reported in #2302 + +## 23.4.0 2023-02-21 +### PaymentSheet +* [Added] Adds support for setting up PayPal using a SetupIntent or a PaymentIntent w/ setup_future_usage=off_session. Note: PayPal is in beta. + +## 23.3.4 2023-02-13 +### Financial Connections +* [Changed] Polished Financial Connections UI. + +## 23.3.3 2023-01-30 +### Payments +* [Changed] Updated image asset for AFFIN bank. + +### Financial Connections +* [Fixed] Double encoding of GET parameters. + +## 23.3.2 2023-01-09 +* [Changed] Using [Tuist](https://tuist.io) to generate Xcode projects. From now on, only release versions of the SDK will include Xcode project files, in case you want to build a non release revision from source, you can follow [these instructions](https://docs.tuist.io/tutorial/get-started) to generate the project files. For Carthage users, this also means that you will only be able to depend on release versions. + +### PaymentSheet +* [Added] `PaymentSheetError` now conforms to `CustomDebugStringConvertible` and has a more useful description when no payment method types are available. +* [Changed] Customers can now re-enter the autocomplete flow of `AddressViewController` by tapping an icon in the line 1 text field. + +## 23.3.1 2022-12-12 +* [Fixed] Fixed a bug where 3 decimal place currencies were not being formatted properly. + +### PaymentSheet +* [Fixed] Fixed an issue that caused animations of the card logos in the Card input field to glitch. +* [Fixed] Fixed a layout issue in the "Save my info" checkbox. + +### CardScan +* [Fixed] Fixed UX model loading from the wrong bundle. [#2078](https://github.com/stripe/stripe-ios/issues/2078) (Thanks [nickm01](https://github.com/nickm01)) + +## 23.3.0 2022-12-05 +### PaymentSheet +* [Added] Added logos of accepted card brands on Card input field. +* [Fixed] Fixed erroneously displaying the card scan button when card scanning is not available. + +### Financial Connections +* [Changed] FinancialConnectionsSheet methods now require to be called from non-extensions. +* [Changed] BankAccountToken.bankAccount was changed to an optional. + +## 23.2.0 2022-11-14 +### PaymentSheet +* [Added] Added `AddressViewController`, a customizable view controller that collects local and international addresses for your customers. See https://stripe.com/docs/elements/address-element?platform=ios. +* [Added] Added `PaymentSheet.Configuration.allowsPaymentMethodsRequiringShippingAddress`. Previously, to allow payment methods that require a shipping address (e.g. Afterpay and Affirm) in PaymentSheet, you attached a shipping address to the PaymentIntent before initializing PaymentSheet. Now, you can instead set this property to `true` and set `PaymentSheet.Configuration.shippingDetails` to a closure that returns your customers' shipping address. The shipping address will be attached to the PaymentIntent when the customer completes the checkout. +* [Fixed] Fixed user facing error messages for card related errors. +* [Fixed] Fixed `setup_future_usage` value being set when there's no customer. + +## 23.1.1 2022-11-07 +### Payments +* [Fixed] Fixed an issue with linking the StripePayments SDK in certain configurations. + +## 23.1.0 2022-10-31 +### CardScan +* [Added] Added a README.md for the `CardScanSheet` integration. + +### PaymentSheet +* [Added] Added parameters to customize the primary button and Apple Pay button labels. They can be found under `PaymentSheet.Configuration.primaryButtonLabel` and `PaymentSheet.ApplePayConfiguration.buttonType` respectively. + +## 23.0.0 2022-10-24 +### Payments +* [Changed] Reduced the size of the SDK by splitting the `Stripe` module into `StripePaymentSheet`, `StripePayments`, and `StripePaymentsUI`. Some manual changes may be required. Migration instructions are available at [https://stripe.com/docs/mobile/ios/sdk-23-migration](https://stripe.com/docs/mobile/ios/sdk-23-migration). + +|Module|Description|Compressed|Uncompressed| +|------|-----------|----------|------------| +|StripePaymentSheet|Stripe's [prebuilt payment UI](https://stripe.com/docs/payments/accept-a-payment?platform=ios&ui=payment-sheet).|2.7MB|6.3MB| +|Stripe|Contains all the below frameworks, plus [Issuing](https://stripe.com/docs/issuing/cards/digital-wallets?platform=iOS) and [Basic Integration](/docs/mobile/ios/basic).|2.3MB|5.1MB| +|StripeApplePay|[Apple Pay support](/docs/apple-pay), including `STPApplePayContext`.|0.4MB|1.0MB| +|StripePayments|Bindings for the Stripe Payments API.|1.0MB|2.6MB| +|StripePaymentsUI|Bindings for the Stripe Payments API, [STPPaymentCardTextField](https://stripe.com/docs/payments/accept-a-payment?platform=ios&ui=custom), STPCardFormView, and other UI elements.|1.7MB|3.9MB| + +* [Changed] The minimum iOS version is now 13.0. If you'd like to deploy for iOS 12.0, please use Stripe SDK 22.8.4. +* [Changed] STPPaymentCardTextField's `cardParams` parameter has been deprecated in favor of `paymentMethodParams`, making it easier to include the postal code from the card field. If you need to access the `STPPaymentMethodCardParams`, use `.paymentMethodParams.card`. + +### PaymentSheet +* [Fixed] Fixed a validation issue where cards expiring at the end of the current month were incorrectly treated as expired. +* [Fixed] Fixed a visual bug in iOS 16 where advancing between text fields would momentarily dismiss the keyboard. + +## 22.8.4 2022-10-12 +### PaymentSheet +* [Fixed] Use `.formSheet` modal presentation in Mac Catalyst. [#2023](https://github.com/stripe/stripe-ios/issues/2023) (Thanks [sergiocampama](https://github.com/sergiocampama)!) + +## 22.8.3 2022-10-03 +### CardScan +* [Fixed] [Garbled privacy link text in Card Scan UI](https://github.com/stripe/stripe-ios/issues/2015) + +## 22.8.2 2022-09-19 +### Identity +* [Changed] Support uploading single side documents. +* [Fixed] Fixed Xcode 14 support. +### Financial Connections +* [Fixed] Fixes an issue of returning canceled result from FinancialConnections if user taps cancel on the manual entry success screen. +### CardScan +* [Added] Added a new parameter to CardScanSheet.present() to specify if the presentation should be done animated or not. Defaults to true. +* [Changed] Changed card scan ML model loading to be async. +* [Changed] Changed minimum deployment target for card scan to iOS 13. + +## 22.8.1 2022-09-12 +### PaymentSheet +* [Fixed] Fixed potential crash when using Link in Mac Catalyst. +* [Fixed] Fixed Right-to-Left (RTL) layout issues. + +### Apple Pay +* [Fixed] Fixed an issue where `applePayContext:willCompleteWithResult:authorizationResult:handler:` may not be called in Objective-C implementations of `STPApplePayContextDelegate`. + +## 22.8.0 2022-09-06 +### PaymentSheet +* [Changed] Renamed `PaymentSheet.reset()` to `PaymentSheet.resetCustomer()`. See `MIGRATING.md` for more info. +* [Added] You can now set closures in `PaymentSheet.ApplePayConfiguration.customHandlers` to configure the PKPaymentRequest and PKPaymentAuthorizationResult during a transaction. This enables you to build support for [Merchant Tokens](https://developer.apple.com/documentation/passkit/pkpaymentrequest/3916053-recurringpaymentrequest) and [Order Tracking](https://developer.apple.com/documentation/passkit/pkpaymentorderdetails) in iOS 16. + +### Apple Pay +* [Added] You can now implement the `applePayContext(_:willCompleteWithResult:handler:)` function in your `ApplePayContextDelegate` to configure the PKPaymentAuthorizationResult during a transaction. This enables you to build support for [Order Tracking](https://developer.apple.com/documentation/passkit/pkpaymentorderdetails) in iOS 16. + +## 22.7.1 2022-08-31 +* [Fixed] Fixed Mac Catalyst support in Xcode 14. [#2001](https://github.com/stripe/stripe-ios/issues/2001) + +### PaymentSheet +* [Fixed] PaymentSheet now uses configuration.apiClient for Apple Pay instead of always using STPAPIClient.shared. +* [Fixed] Fixed a layout issue with PaymentSheet in landscape. + +## 22.7.0 2022-08-15 +### PaymentSheet +* [Fixed] Fixed a layout issue on iPad. +* [Changed] Improved Link support in custom flow (`PaymentSheet.FlowController`). + +## 22.6.0 2022-07-05 +### PaymentSheet +* [Added] PaymentSheet now supports Link payment method. +* [Changed] Change behavior of Afterpay/Clearpay: Charge in 3 for GB, FR, and ES + +### STPCardFormView +* [Changed] Postal code is no longer collected for billing addresses in Japan. + +### Identity +* [Added] The ability to capture Selfie images in the native component flow. +* [Fixed] Fixed an issue where the welcome and confirmation screens were not correctly decoding non-ascii characters. +* [Fixed] Fixed an issue where, if a manually uploaded document could not be decoded on the server, there was no way to select a new image to upload. +* [Fixed] Fixed an issue where the IdentityVerificationSheet completion block was called early when manually uploading a document image instead of using auto-capture. + +## 22.5.1 2022-06-21 +* [Fixed] Fixed an issue with `STPPaymentHandler` where returning an app redirect could cause a crash. + +## 22.5.0 2022-06-13 +### PaymentSheet +* [Added] You can now use `PaymentSheet.ApplePayConfiguration.paymentSummaryItems` to directly configure the payment summary items displayed in the Apple Pay sheet. This is useful for recurring payments. + +## 22.4.0 2022-05-23 +### PaymentSheet +* [Added] The ability to customize the appearance of the PaymentSheet using `PaymentSheet.Appearance`. +* [Added] Support for collecting payments from customers in 54 additional countries within PaymentSheet. Most of these countries are located in Africa and the Middle East. +* [Added] `affirm` and `AUBECSDebit` payment methods are now available in PaymentSheet + +## 22.3.2 2022-05-18 +### CardScan +* [Added] Added privacy text to the CardImageVerification Sheet UI + +## 22.3.1 2022-05-16 +* [Fixed] Fixed an issue where ApplePayContext failed to parse an API response if the funding source was unknown. +* [Fixed] Fixed an issue where PaymentIntent confirmation could fail when the user closes the challenge window immediately after successfully completing a challenge + +### Identity +* [Fixed] Fixed an issue where the verification flow would get stuck in a document upload loop when verifying with a passport and uploading an image manually. + +## 22.3.0 2022-05-03 + +### PaymentSheet +* [Added] `us_bank_account` PaymentMethod is now available in payment sheet + +## 22.2.0 2022-04-25 + +### Connections +* [Changed] `StripeConnections` SDK has been renamed to `StripeFinancialConnections`. See `MIGRATING.md` for more info. + +### PaymentSheet +* [Fixed] Fixed an issue where `source_cancel` API requests were being made for non-3DS payment method types. +* [Fixed] Fixed an issue where certain error messages were not being localized. +* [Added] `us_bank_account` PaymentMethod is now available in PaymentSheet. + +### Identity +* [Fixed] Minor UI fixes when using `IdentityVerificationSheet` with native components +* [Changed] Improvements to native component `IdentityVerificationSheet` document detection + +## 22.1.1 2022-04-11 + +### Identity +* [Fixed] Fixes VerificationClientSecret (Thanks [Masataka-n](https://github.com/Masataka-n)!) + +## 22.1.0 2022-04-04 +* [Changed] Localization improvements. +### Identity +* [Added] `IdentityVerificationSheet` can now be used with native iOS components. + +## 22.0.0 2022-03-28 +* [Changed] The minimum iOS version is now 12.0. If you'd like to deploy for iOS 11.0, please use Stripe SDK 21.12.0. +* [Added] `us_bank_account` PaymentMethod is now available for ACH Direct Debit payments, including APIs to collect customer bank information (requires `StripeConnections`) and verify microdeposits. +* [Added] `StripeConnections` SDK can be optionally included to support ACH Direct Debit payments. + +### PaymentSheet +* [Changed] PaymentSheet now uses light and dark mode agnostic icons for payment method types. +* [Changed] Link payment method (private beta) UX improvements. + +### Identity +* [Changed] `IdentityVerificationSheet` now has an availability requirement of iOS 14.3 on its initializer instead of the `present` method. + +## 21.13.0 2022-03-15 +* [Changed] Binary framework distribution now requires Xcode 13. Carthage users using Xcode 12 need to add the `--no-use-binaries` flag. + +### PaymentSheet +* [Fixed] Fixed potential crash when using PaymentSheet custom flow with SwiftUI. +* [Fixed] Fixed being unable to cancel native 3DS2 in PaymentSheet. +* [Fixed] The payment method icons will now use the correct colors when PaymentSheet is configured with `alwaysLight` or `alwaysDark`. +* [Fixed] A race condition when setting the `primaryButtonColor` on `PaymentSheet.Configuration`. +* [Added] PaymentSheet now supports Link (private beta). + +### CardScan +* [Added] The `CardImageVerificationSheet` initializer can now take an additional `Configuration` object. + +## 21.12.0 2022-02-14 +* [Added] We now offer a 1MB Apple Pay SDK module intended for use in an App Clip. Visit [our App Clips docs](https://stripe.com/docs/apple-pay#app-clips) for details. +* `Stripe` now requires `StripeApplePay`. See `MIGRATING.md` for more info. +* [Added] Added a convenience initializer to create an STPCardParams from an STPPaymentMethodParams. + +### PaymentSheet +* [Changed] The "save this card" checkbox in PaymentSheet is now unchecked by default in non-US countries. +* [Fixed] Fixes issue that could cause symbol name collisions when using Objective-C +* [Fixed] Fixes potential crash when using PaymentSheet with SwiftUI + +## 21.11.1 2022-01-10 +* Fixes a build warning in SPM caused by an invalid Package.swift file. + +## 21.11.0 2022-01-04 +* [Changed] The maximum `identity_document` file upload size has been increased, improving the quality of compressed images. See https://stripe.com/docs/file-upload +* [Fixed] The maximum `dispute_evidence` file upload size has been decreased to match server requirements, preventing the server from rejecting uploads that exceeded 5MB. See https://stripe.com/docs/file-upload +* [Added] PaymentSheet now supports Afterpay / Clearpay, EPS, Giropay, Klarna, Paypal (private beta), and P24. + +## 21.10.0 2021-12-14 +* Added API bindings for Klarna +* `StripeIdentity` now requires `StripeCameraCore`. See `MIGRATING.md` for more info. +* Releasing `StripeCardScan` Beta iOS SDK +* Fixes a bug where the text field would cause a crash when typing a space (U+0020) followed by pressing the backspace key on iPad. [#1907](https://github.com/stripe/stripe-ios/issues/1907) (Thanks [buhikon](https://github.com/buhikon)!) + +## 21.9.1 2021-12-02 +* Fixes a build warning caused by a duplicate NSURLComponents+Stripe.swift file. + +## 21.9.0 2021-10-18 +### PaymentSheet +This release adds several new features to PaymentSheet, our drop-in UI integration: + +#### More supported payment methods +The list of supported payment methods depends on your integration. +If you’re using a PaymentIntent, we support: +- Card +- SEPA Debit, bancontact, iDEAL, sofort + +If you’re using a PaymentIntent with `setup_future_usage` or a SetupIntent, we support: +- Card +- Apple/GooglePay + +Note: To enable SEPA Debit and sofort, set `PaymentSheet.configuration.allowsDelayedPaymentMethods` to `true` on the client. +These payment methods can't guarantee you will receive funds from your customer at the end of the checkout because they take time to settle. Don't enable these if your business requires immediate payment (e.g., an on-demand service). See https://stripe.com/payments/payment-methods-guide + +#### Pre-fill billing details +PaymentSheet collects billing details like name and email for certain payment methods. Pre-fill these fields to save customers time by setting `PaymentSheet.Configuration.defaultBillingDetails`. + +#### Save payment methods on payment +> This is currently only available for cards + Apple/Google Pay. + +PaymentSheet supports PaymentIntents with `setup_future_usage` set. This property tells us to save the payment method for future use (e.g., taking initial payment of a recurring subscription). +When set, PaymentSheet hides the 'Save this card for future use' checkbox and always saves. + +#### SetupIntent support +> This is currently only available for cards + Apple/Google Pay. + +Initialize PaymentSheet with a SetupIntent to set up cards for future use without charging. + +#### Smart payment method ordering +When a customer is adding a new payment method, PaymentSheet uses information like the customers region to show the most relevant payment methods first. + +#### Other changes +* Postal code collection for cards is now limited to US, CA, UK +* Fixed SwiftUI memory leaks [Issue #1881](https://github.com/stripe/stripe-ios/issues/1881) +* Added "hint" for error messages +* Adds many new localizations. The SDK now localizes in the following languages: bg-BG,ca-ES,cs-CZ,da,de,el-GR,en-GB,es-419,es,et-EE,fi,fil,fr-CA,fr,hr,hu,id,it,ja,ko,lt-LT,lv-LV,ms-MY,mt,nb,nl,nn-NO,pl-PL,pt-BR,pt-PT,ro-RO,ru,sk-SK,sl-SI,sv,tk,tr,vi,zh-Hans,zh-Hant,zh-HK +* `Stripe` and `StripeIdentity` now require `StripeUICore`. See `MIGRATING.md` for more info. + +## 21.8.1 2021-08-10 +* Fixes an issue with image loading when using Swift Package Manager. +* Temporarily disabled WeChat Pay support in PaymentMethods. +* The `Stripe` module now requires `StripeCore`. See `MIGRATING.md` for more info. + +## 21.8.0 2021-08-04 +* Fixes broken card scanning links. (Thanks [ricsantos](https://github.com/ricsantos)) +* Fixes accessibilityLabel for postal code field. (Thanks [romanilchyshyndepop](https://github.com/romanilchyshyndepop)) +* Improves compile time by 30% [#1846](https://github.com/stripe/stripe-ios/pull/1846) (Thanks [JonathanDowning](https://github.com/JonathanDowning)!) +* Releasing `StripeIdentity` iOS SDK for use with [Stripe Identity](https://stripe.com/identity). + +## 21.7.0 2021-07-07 +* Fixes an issue with `additionaDocument` field typo [#1833](https://github.com/stripe/stripe-ios/issues/1833) +* Adds support for WeChat Pay to PaymentMethods +* Weak-links SwiftUI [#1828](https://github.com/stripe/stripe-ios/issues/1828) +* Adds 3DS2 support for Cartes Bancaires +* Fixes an issue with camera rotation during card scanning on iPad +* Fixes an issue where PaymentSheet could cause conflicts when included in an app that also includes PanModal [#1818](https://github.com/stripe/stripe-ios/issues/1818) +* Fixes an issue with building on Xcode 13 [#1822](https://github.com/stripe/stripe-ios/issues/1822) +* Fixes an issue where overriding STPPaymentCardTextField's `brandImage()` func had no effect [#1827](https://github.com/stripe/stripe-ios/issues/1827) +* Fixes documentation typo. (Thanks [iAugux](https://github.com/iAugux)) + +## 21.6.0 2021-05-27 +* Adds `STPCardFormView`, a UI component that collects card details +* Adds 'STPRadarSession'. Note this requires additional Stripe permissions to use. + +## 21.5.1 2021-05-07 +* Fixes the `PaymentSheet` API not being public. +* Fixes an issue with missing headers. (Thanks [jctrouble](https://github.com/jctrouble)!) + +## 21.5.0 2021-05-06 +* Adds the `PaymentSheet`(https://stripe.dev/stripe-ios/docs/Classes/PaymentSheet.html) API, a prebuilt payment UI. +* Fixes Mac Catalyst support in Xcode 12.5 [#1797](https://github.com/stripe/stripe-ios/issues/1797) +* Fixes `STPPaymentCardTextField` not being open [#1768](https://github.com/stripe/stripe-ios/issues/1797) + +## 21.4.0 2021-04-08 +* Fixed warnings in Xcode 12.5. [#1772](https://github.com/stripe/stripe-ios/issues/1772) +* Fixes a layout issue when confirming payments in SwiftUI. [#1761](https://github.com/stripe/stripe-ios/issues/1761) (Thanks [mvarie](https://github.com/mvarie)!) +* Fixes a potential race condition when finalizing 3DS2 confirmations. +* Fixes an issue where a 3DS2 transaction could result in an incorrect error message when the card number is incorrect. [#1778](https://github.com/stripe/stripe-ios/issues/1778) +* Fixes an issue where `STPPaymentHandler.shared().handleNextAction` sometimes didn't return a `handleActionError`. [#1769](https://github.com/stripe/stripe-ios/issues/1769) +* Fixes a layout issue when confirming payments in SwiftUI. [#1761](https://github.com/stripe/stripe-ios/issues/1761) (Thanks [mvarie](https://github.com/mvarie)!) +* Fixes an issue with opening URLs on Mac Catalyst +* Fixes an issue where OXXO next action is mistaken for a cancel in STPPaymentHandler +* SetupIntents for iDEAL, Bancontact, EPS, and Sofort will now send the required mandate information. +* Adds support for BLIK. +* Adds `decline_code` information to STPError. [#1755](https://github.com/stripe/stripe-ios/issues/1755) +* Adds support for SetupIntents to STPApplePayContext +* Allows STPPaymentCardTextField to be subclassed. [#1768](https://github.com/stripe/stripe-ios/issues/1768) + +## 21.3.1 2021-03-25 +* Adds support for Maestro in Apple Pay on iOS 12 or later. + +## 21.3.0 2021-02-18 +* Adds support for SwiftUI in custom integration using the `STPPaymentCardTextField.Representable` View and the `.paymentConfirmationSheet()` ViewModifier. See `IntegrationTester` for usage examples. +* Removes the UIViewController requirement from STPApplePayContext, allowing it to be used in SwiftUI. +* Fixes an issue where `STPPaymentOptionsViewController` could fail to register a card. [#1758](https://github.com/stripe/stripe-ios/issues/1758) +* Fixes an issue where some UnionPay test cards were marked as invalid. [#1759](https://github.com/stripe/stripe-ios/issues/1759) +* Updates tests to run on Carthage 0.37 with .xcframeworks. + + +## 21.2.1 2021-01-29 +* Fixed an issue where a payment card text field could resize incorrectly on smaller devices or with certain languages. [#1600](https://github.com/stripe/stripe-ios/issues/1600) +* Fixed an issue where the SDK could always return English strings in certain situations. [#1677](https://github.com/stripe/stripe-ios/pull/1677) (Thanks [glaures-ioki](https://github.com/glaures-ioki)!) +* Fixed an issue where an STPTheme had no effect on the navigation bar. [#1753](https://github.com/stripe/stripe-ios/pull/1753) (Thanks [@rbenna](https://github.com/rbenna)!) +* Fixed handling of nil region codes. [#1752](https://github.com/stripe/stripe-ios/issues/1752) +* Fixed an issue preventing card scanning from being disabled. [#1751](https://github.com/stripe/stripe-ios/issues/1751) +* Fixed an issue with enabling card scanning in an app with a localized Info.plist.[#1745](https://github.com/stripe/stripe-ios/issues/1745) +* Added a missing additionalDocument parameter to STPConnectAccountIndividualVerification. +* Added support for Afterpay/Clearpay. + +## 21.2.0 2021-01-06 +* Stripe3DS2 is now open source software under the MIT License. +* Fixed various issues with bundling Stripe3DS2 in Cocoapods and Swift Package Manager. All binary dependencies have been removed. +* Fixed an infinite loop during layout on small screen sizes. [#1731](https://github.com/stripe/stripe-ios/issues/1731) +* Fixed issues with missing image assets when using Cocoapods. [#1655](https://github.com/stripe/stripe-ios/issues/1655) [#1722](https://github.com/stripe/stripe-ios/issues/1722) +* Fixed an issue which resulted in unnecessary queries to the BIN information service. +* Adds the ability to `attach` and `detach` PaymentMethod IDs to/from a CustomerContext. [#1729](https://github.com/stripe/stripe-ios/issues/1729) +* Adds support for NetBanking. + +## 21.1.0 2020-12-07 +* Fixes a crash during manual confirmation of a 3DS2 payment. [#1725](https://github.com/stripe/stripe-ios/issues/1725) +* Fixes an issue that could cause some image assets to be missing in certain configurations. [#1722](https://github.com/stripe/stripe-ios/issues/1722) +* Fixes an issue with confirming Alipay transactions. +* Re-exposes `cardNumber` parameter in `STPPaymentCardTextField`. +* Adds support for UPI. + +## 21.0.1 2020-11-19 +* Fixes an issue with some initializers not being exposed publicly following the [conversion to Swift](https://stripe.com/docs/mobile/ios/sdk-21-migration). +* Updates GrabPay integration to support synchronous updates. + +## 21.0.0 2020-11-18 +* The SDK is now written in Swift, and some manual changes are required. Migration instructions are available at [https://stripe.com/docs/mobile/ios/sdk-21-migration](https://stripe.com/docs/mobile/ios/sdk-21-migration). +* Adds full support for Apple silicon. +* Xcode 12.2 is now required. + +## 20.1.1 2020-10-23 +* Fixes an issue when using Cocoapods 1.10 and Xcode 12. [#1683](https://github.com/stripe/stripe-ios/pull/1683) +* Fixes a warning when using Swift Package Manager. [#1675](https://github.com/stripe/stripe-ios/pull/1675) + +## 20.1.0 2020-10-15 +* Adds support for OXXO. [#1592](https://github.com/stripe/stripe-ios/pull/1592) +* Applies a workaround for various bugs in Swift Package Manager. [#1671](https://github.com/stripe/stripe-ios/pull/1671) Please see [#1673](https://github.com/stripe/stripe-ios/issues/1673) for additional notes when using Xcode 12.0. +* Card scanning now works when the device's orientation is unknown. [#1659](https://github.com/stripe/stripe-ios/issues/1659) +* The expiration date field's Simplified Chinese localization has been corrected. (Thanks [cythb](https://github.com/cythb)!) [#1654](https://github.com/stripe/stripe-ios/pull/1654) + +## 20.0.0 2020-09-14 +* [Card scanning](https://github.com/stripe/stripe-ios#card-scanning) is now built into STPAddCardViewController. Card.io support has been removed. [#1629](https://github.com/stripe/stripe-ios/pull/1629) +* Shrunk the SDK from 1.3MB when compressed & thinned to 0.7MB, allowing for easier App Clips integration. [#1643](https://github.com/stripe/stripe-ios/pull/1643) +* Swift Package Manager, Apple Silicon, and Catalyst are now fully supported on Xcode 12. [#1644](https://github.com/stripe/stripe-ios/pull/1644) +* Adds support for 19-digit cards. [#1608](https://github.com/stripe/stripe-ios/pull/1608) +* Adds GrabPay and Sofort as PaymentMethod. [#1627](https://github.com/stripe/stripe-ios/pull/1627) +* Drops support for iOS 10. [#1643](https://github.com/stripe/stripe-ios/pull/1643) + +## 19.4.0 2020-08-13 +* `pkPaymentErrorForStripeError` no longer returns PKPaymentUnknownErrors. Instead, it returns the original NSError back, resulting in dismissal of the Apple Pay sheet. This means ApplePayContext dismisses the Apple Pay sheet for all errors that aren't specifically PKPaymentError types. +* `metadata` fields are no longer populated on retrieved Stripe API objects and must be fetched on your server using your secret key. If this is causing issues with your deployed app versions please reach out to [Stripe Support](https://support.stripe.com/?contact=true). These fields have been marked as deprecated and will be removed in a future SDK version. + +## 19.3.0 2020-05-28 +* Adds giropay PaymentMethod bindings [#1569](https://github.com/stripe/stripe-ios/pull/1569) +* Adds Przelewy24 (P24) PaymentMethod bindings [#1556](https://github.com/stripe/stripe-ios/pull/1556) +* Adds Bancontact PaymentMethod bindings [#1565](https://github.com/stripe/stripe-ios/pull/1565) +* Adds EPS PaymentMethod bindings [#1578](https://github.com/stripe/stripe-ios/pull/1578) +* Replaces es-AR localization with es-419 for full Latin American Spanish support and updates multiple localizations [#1549](https://github.com/stripe/stripe-ios/pull/1549) [#1570](https://github.com/stripe/stripe-ios/pull/1570) +* Fixes missing custom number placeholder in `STPPaymentCardTextField` [#1576](https://github.com/stripe/stripe-ios/pull/1576) +* Adds tabbing on external keyboard support to `STPAUBECSFormView` and correctly types it as a `UIView` instead of `UIControl` [#1580](https://github.com/stripe/stripe-ios/pull/1580) + +## 19.2.0 2020-05-01 +* Adds ability to attach shipping details when confirming PaymentIntents [#1558](https://github.com/stripe/stripe-ios/pull/1558) +* `STPApplePayContext` now provides shipping details in the `applePayContext:didCreatePaymentMethod:paymentInformation:completion:` delegate method and automatically attaches shipping details to PaymentIntents (unless manual confirmation)[#1561](https://github.com/stripe/stripe-ios/pull/1561) +* Adds support for the BECS Direct Debit payment method for Stripe users in Australia [#1547](https://github.com/stripe/stripe-ios/pull/1547) + +## 19.1.1 2020-04-28 +* Add advancedFraudSignalsEnabled property [#1560](https://github.com/stripe/stripe-ios/pull/1560) + +## 19.1.0 2020-04-15 +* Relaxes need for dob for full name connect account (`STPConnectAccountIndividualParams`). [#1539](https://github.com/stripe/stripe-ios/pull/1539) +* Adds Chinese (Traditional) and Chinese (Hong Kong) localizations [#1536](https://github.com/stripe/stripe-ios/pull/1536) +* Adds `STPApplePayContext`, a helper class for Apple Pay. [#1499](https://github.com/stripe/stripe-ios/pull/1499) +* Improves accessibility [#1513](https://github.com/stripe/stripe-ios/pull/1513), [#1504](https://github.com/stripe/stripe-ios/pull/1504) +* Adds support for the Bacs Direct Debit payment method [#1487](https://github.com/stripe/stripe-ios/pull/1487) +* Adds support for 16 digit Diners Club cards [#1498](https://github.com/stripe/stripe-ios/pull/1498) + +## 19.0.1 2020-03-24 +* Fixes an issue building with Xcode 11.4 [#1526](https://github.com/stripe/stripe-ios/pull/1526) + +## 19.0.0 2020-02-12 +* Deprecates the `STPAPIClient` `initWithConfiguration:` method. Set the `configuration` property on the `STPAPIClient` instance instead. [#1474](https://github.com/stripe/stripe-ios/pull/1474) +* Deprecates `publishableKey` and `stripeAccount` properties of `STPPaymentConfiguration`. See [MIGRATING.md](https://github.com/stripe/stripe-ios/blob/master/MIGRATING.md) for more details. [#1474](https://github.com/stripe/stripe-ios/pull/1474) +* Adds explicit STPAPIClient properties on all SDK components that make API requests. These default to `[STPAPIClient sharedClient]`. This is a breaking change for some users of `stripeAccount`. See [MIGRATING.md](https://github.com/stripe/stripe-ios/blob/master/MIGRATING.md) for more details. [#1469](https://github.com/stripe/stripe-ios/pull/1469) +* The user's postal code is now collected by default in countries that support postal codes. We always recommend collecting a postal code to increase card acceptance rates and reduce fraud. See [MIGRATING.md](https://github.com/stripe/stripe-ios/blob/master/MIGRATING.md) for more details. [#1479](https://github.com/stripe/stripe-ios/pull/1479) + +## 18.4.0 2020-01-15 +* Adds support for Klarna Pay on Sources API [#1444](https://github.com/stripe/stripe-ios/pull/1444) +* Compresses images using `pngcrush` to reduce SDK size [#1471](https://github.com/stripe/stripe-ios/pull/1471) +* Adds support for CVC recollection in PaymentIntent confirm [#1473](https://github.com/stripe/stripe-ios/pull/1473) +* Fixes a race condition when setting `defaultPaymentMethod` on `STPPaymentOptionsViewController` [#1476](https://github.com/stripe/stripe-ios/pull/1476) + +## 18.3.0 2019-12-3 +* STPAddCardViewControllerDelegate methods previously removed in v16.0.0 are now marked as deprecated, to help migrating users [#1439](https://github.com/stripe/stripe-ios/pull/1439) +* Fixes an issue where canceling 3DS authentication could leave PaymentIntents in an inaccurate `requires_action` state [#1443](https://github.com/stripe/stripe-ios/pull/1443) +* Fixes text color for large titles [#1446](https://github.com/stripe/stripe-ios/pull/1446) +* Re-adds support for pre-selecting the last selected payment method in STPPaymentContext and STPPaymentOptionsViewController. [#1445](https://github.com/stripe/stripe-ios/pull/1445) +* Fix crash when adding/removing postal code cells [#1450](https://github.com/stripe/stripe-ios/pull/1450) + +## 18.2.0 2019-10-31 +* Adds support for creating tokens with the last 4 digits of an SSN [#1432](https://github.com/stripe/stripe-ios/pull/1432) +* Renames Standard Integration to Basic Integration + +## 18.1.0 2019-10-29 +* Adds localizations for English (Great Britain), Korean, Russian, and Turkish [#1373](https://github.com/stripe/stripe-ios/pull/1373) +* Adds support for SEPA Debit as a PaymentMethod [#1415](https://github.com/stripe/stripe-ios/pull/1415) +* Adds support for custom SEPA Debit Mandate params with PaymentMethod [#1420](https://github.com/stripe/stripe-ios/pull/1420) +* Improves postal code UI for users with mismatched regions [#1302](https://github.com/stripe/stripe-ios/issues/1302) +* Fixes a potential crash when presenting the add card view controller [#1426](https://github.com/stripe/stripe-ios/issues/1426) +* Adds offline status checking to FPX payment flows [#1422](https://github.com/stripe/stripe-ios/pull/1422) +* Adds support for push provisions for Issuing users [#1396](https://github.com/stripe/stripe-ios/pull/1396) + +## 18.0.0 2019-10-04 +* Adds support for building on macOS 10.15 with Catalyst. Use the .xcframework file attached to the release in GitHub. Cocoapods support is coming soon. [#1364](https://github.com/stripe/stripe-ios/issues/1364) +* Errors from the Payment Intents API are now localized by default. See [MIGRATING.md](https://github.com/stripe/stripe-ios/blob/master/MIGRATING.md) for details. +* Adds support for FPX in Standard Integration. [#1390](https://github.com/stripe/stripe-ios/pull/1390) +* Simplified Apple Pay integration when using 3DS2. [#1386](https://github.com/stripe/stripe-ios/pull/1386) +* Improved autocomplete behavior for some STPPaymentHandler blocks. [#1403](https://github.com/stripe/stripe-ios/pull/1403) +* Fixed spurious `keyboardWillAppear` messages triggered by STPPaymentTextCard. [#1393](https://github.com/stripe/stripe-ios/pull/1393) +* Fixed an issue with non-numeric placeholders in STPPaymentTextCard. [#1394](https://github.com/stripe/stripe-ios/pull/1394) +* Dropped support for iOS 9. Please continue to use 17.0.2 if you need to support iOS 9. + +## 17.0.2 2019-09-24 +* Fixes an error that could prevent a 3D Secure 2 challenge dialog from appearing in certain situations. +* Improved VoiceOver support. [#1384](https://github.com/stripe/stripe-ios/pull/1384) +* Updated Apple Pay and Mastercard branding. [#1374](https://github.com/stripe/stripe-ios/pull/1374) +* Updated the Standard Integration example app to use automatic confirmation. [#1363](https://github.com/stripe/stripe-ios/pull/1363) +* Added support for collecting email addresses and phone numbers from Apple Pay. [#1372](https://github.com/stripe/stripe-ios/pull/1372) +* Introduced support for FPX payments. (Invite-only Beta) [#1375](https://github.com/stripe/stripe-ios/pull/1375) + +## 17.0.1 2019-09-09 +* Cancellation during the 3DS2 flow will no longer cause an unexpected error. [#1353](https://github.com/stripe/stripe-ios/pull/1353) +* Large Title UIViewControllers will no longer have a transparent background in iOS 13. [#1362](https://github.com/stripe/stripe-ios/pull/1362) +* Adds an `availableCountries` option to STPPaymentConfiguration, allowing one to limit the list of countries in the address entry view. [#1327](https://github.com/stripe/stripe-ios/pull/1327) +* Fixes a crash when using card.io. [#1357](https://github.com/stripe/stripe-ios/pull/1357) +* Fixes an issue with birthdates when creating a Connect account. [#1361](https://github.com/stripe/stripe-ios/pull/1361) +* Updates example code to Swift 5. [#1354](https://github.com/stripe/stripe-ios/pull/1354) +* The default value of `[STPTheme translucentNavigationBar]` is now `YES`. [#1367](https://github.com/stripe/stripe-ios/pull/1367) + +## 17.0.0 2019-09-04 +* Adds support for iOS 13, including Dark Mode and minor bug fixes. [#1307](https://github.com/stripe/stripe-ios/pull/1307) +* Updates API version from 2015-10-12 to 2019-05-16 [#1254](https://github.com/stripe/stripe-ios/pull/1254) + * Adds `STPSourceRedirectStatusNotRequired` to `STPSourceRedirectStatus`. Previously, optional redirects were marked as `STPSourceRedirectStatusSucceeded`. + * Adds `STPSourceCard3DSecureStatusRecommended` to `STPSourceCard3DSecureStatus`. + * Removes `STPLegalEntityParams`. Initialize an `STPConnectAccountParams` with an `individual` or `company` dictionary instead. See https://stripe.com/docs/api/tokens/create_account#create_account_token-account +* Changes the `STPPaymentContextDelegate paymentContext:didCreatePaymentResult:completion:` completion block type to `STPPaymentStatusBlock`, to let you inform the context that the user canceled. +* Adds initial support for WeChat Pay. [#1326](https://github.com/stripe/stripe-ios/pull/1326) +* The user's billing address will now be included when creating a PaymentIntent from an Apple Pay token. [#1334](https://github.com/stripe/stripe-ios/pull/1334) + + +## 16.0.7 2019-08-23 +* Fixes STPThreeDSUICustomization not initializing defaults correctly. [#1303](https://github.com/stripe/stripe-ios/pull/1303) +* Fixes STPPaymentHandler treating post-authentication errors as authentication errors [#1291](https://github.com/stripe/stripe-ios/pull/1291) +* Removes preferredStatusBarStyle from STPThreeDSUICustomization, see STPThreeDSNavigationBarCustomization.barStyle instead [#1308](https://github.com/stripe/stripe-ios/pull/1308) + +## 16.0.6 2019-08-13 +* Adds a method to STPAuthenticationContext allowing you to configure the SFSafariViewController presented for web-based authentication. +* Adds STPAddress initializer that takes STPPaymentMethodBillingDetails. [#1278](https://github.com/stripe/stripe-ios/pull/1278) +* Adds convenience method to populate STPUserInformation with STPPaymentMethodBillingDetails. [#1278](https://github.com/stripe/stripe-ios/pull/1278) +* STPShippingAddressViewController prefills billing address for PaymentMethods too now, not just Card. [#1278](https://github.com/stripe/stripe-ios/pull/1278) +* Update libStripe3DS2.a to avoid a conflict with Firebase. [#1293](https://github.com/stripe/stripe-ios/issues/1293) + +## 16.0.5 2019-08-09 +* Fixed an compatibility issue when building with certain Cocoapods configurations. [#1288](https://github.com/stripe/stripe-ios/issues/1288) + +## 16.0.4 2019-08-08 +* Improved compatibility with other OpenSSL-using libraries. [#1265](https://github.com/stripe/stripe-ios/issues/1265) +* Fixed compatibility with Xcode 10.1. [#1273](https://github.com/stripe/stripe-ios/issues/1273) +* Fixed an issue where STPPaymentContext could be left in a bad state when cancelled. [#1284](https://github.com/stripe/stripe-ios/pull/1284) + +## 16.0.3 2019-08-01 +* Changes to code obfuscation, resolving an issue with App Store review [#1269](https://github.com/stripe/stripe-ios/pull/1269) +* Adds Apple Pay support to STPPaymentHandler [#1264](https://github.com/stripe/stripe-ios/pull/1264) + +## 16.0.2 2019-07-29 +* Adds API to let users set a default payment option for Standard Integration [#1252](https://github.com/stripe/stripe-ios/pull/1252) +* Removes querying the Advertising Identifier (IDFA). +* Adds customizable UIStatusBarStyle to STDSUICustomization. + +## 16.0.1 2019-07-25 +* Migrates Stripe3DS2.framework to libStripe3DS2.a, resolving an issue with App Store validation. [#1246](https://github.com/stripe/stripe-ios/pull/1246) +* Fixes a crash in STPPaymentHandler. [#1244](https://github.com/stripe/stripe-ios/pull/1244) + +## 16.0.0 2019-07-18 +* Migrates STPPaymentCardTextField.cardParams property type from STPCardParams to STPPaymentMethodCardParams +* STPAddCardViewController: + * Migrates addCardViewController:didCreateSource:completion: and addCardViewController:didCreateToken:completion: to addCardViewController:didCreatePaymentMethod:completion + * Removes managedAccountCurrency property - there’s no equivalent parameter necessary for PaymentMethods. +* STPPaymentOptionViewController now shows, adds, removes PaymentMethods instead of Source/Tokens. +* STPCustomerContext, STPBackendAPIAdapter: + * Removes selectDefaultCustomerSource:completion: - Users must explicitly select their Payment Method of choice. + * Migrates detachSourceFromCustomer:completion:, attachSourceToCustomer:completion to detachPaymentMethodFromCustomer:completion:, attachPaymentMethodToCustomer:completion: + * Adds listPaymentMethodsForCustomerWithCompletion: - the Customer object doesn’t contain attached Payment Methods; you must fetch it from the Payment Methods API. +* STPPaymentContext now uses the new Payment Method APIs listed above instead of Source/Token, and returns the reworked STPPaymentResult containing a PaymentMethod. +* Migrates STPPaymentResult.source to paymentMethod of type STPPaymentMethod +* Deprecates STPPaymentIntentAction* types, replaced by STPIntentAction*. [#1208](https://github.com/stripe/stripe-ios/pull/1208) + * Deprecates `STPPaymentIntentAction`, replaced by `STPIntentAction` + * Deprecates `STPPaymentIntentActionType`, replaced by `STPIntentActionType` + * Deprecates `STPPaymentIntentActionRedirectToURL`, replaced by `STPIntentActionTypeRedirectToURL` +* Adds support for SetupIntents. See https://stripe.com/docs/payments/cards/saving-cards#saving-card-without-payment +* Adds support for 3DS2 authentication. See https://stripe.com/docs/mobile/ios/authentication + +## 15.0.1 2019-04-16 +* Adds configurable support for JCB (Apple Pay). [#1158](https://github.com/stripe/stripe-ios/pull/1158) +* Updates sample apps to use `PaymentIntents` and `PaymentMethods` where available. [#1159](https://github.com/stripe/stripe-ios/pull/1159) +* Changes `STPPaymentMethodCardParams` `expMonth` and `expYear` property types to `NSNumber *` to fix a bug using Apple Pay. [#1161](https://github.com/stripe/stripe-ios/pull/1161) + +## 15.0.0 2019-3-19 +* Renames all former references to 'PaymentMethod' to 'PaymentOption'. See [MIGRATING.md](/MIGRATING.md) for more details. [#1139](https://github.com/stripe/stripe-ios/pull/1139) + * Renames `STPPaymentMethod` to `STPPaymentOption` + * Renames `STPPaymentMethodType` to `STPPaymentOptionType` + * Renames `STPApplePaymentMethod` to `STPApplePayPaymentOption` + * Renames `STPPaymentMethodTuple` to `STPPaymentOptionTuple` + * Renames `STPPaymentMethodsViewController` to `STPPaymentOptionsViewController` + * Renames all properties, methods, comments referencing 'PaymentMethod' to 'PaymentOption' +* Rewrites `STPaymentMethod` and `STPPaymentMethodType` to match the [Stripe API](https://stripe.com/docs/api/payment_methods/object). [#1140](https://github.com/stripe/stripe-ios/pull/1140). +* Adds `[STPAPI createPaymentMethodWithParams:completion:]`, which creates a PaymentMethod. [#1141](https://github.com/stripe/stripe-ios/pull/1141) +* Adds `paymentMethodParams` and `paymentMethodId` to `STPPaymentIntentParams`. You can now confirm a PaymentIntent with a PaymentMethod. [#1142](https://github.com/stripe/stripe-ios/pull/1142) +* Adds `paymentMethodTypes` to `STPPaymentIntent`. +* Deprecates several Source-named properties, based on changes to the [Stripe API](https://stripe.com/docs/upgrades#2019-02-11). [#1146](https://github.com/stripe/stripe-ios/pull/1146) + * Deprecates `STPPaymentIntentParams.saveSourceToCustomer`, replaced by `savePaymentMethod` + * Deprecates `STPPaymentIntentsStatusRequiresSource`, replaced by `STPPaymentIntentsStatusRequiresPaymentMethod` + * Deprecates `STPPaymentIntentsStatusRequiresSourceAction`, replaced by `STPPaymentIntentsStatusRequiresAction` + * Deprecates `STPPaymentIntentSourceAction`, replaced by `STPPaymentIntentAction` + * Deprecates `STPPaymentSourceActionAuthorizeWithURL`, replaced by `STPPaymentActionRedirectToURL` + * Deprecates `STPPaymentIntent.nextSourceAction`, replaced by `nextAction` +* Added new localizations for the following languages [#1050](https://github.com/stripe/stripe-ios/pull/1050) + * Danish + * Spanish (Argentina/Latin America) + * French (Canada) + * Norwegian + * Portuguese (Brazil) + * Portuguese (Portugal) + * Swedish +* Deprecates `STPEphemeralKeyProvider`, replaced by `STPCustomerEphemeralKeyProvider`. We now allow for ephemeral keys that are not customer [#1131](https://github.com/stripe/stripe-ios/pull/1131) +* Adds CVC image for Amex cards [#1046](https://github.com/stripe/stripe-ios/pull/1046) +* Fixed `STPPaymentCardTextField.nextFirstResponderField` to never return nil [#1059](https://github.com/stripe/stripe-ios/pull/1059) +* Improves return key functionality for `STPPaymentCardTextField`, `STPAddCardViewController` [#1059](https://github.com/stripe/stripe-ios/pull/1059) +* Add postal code support for Saudi Arabia [#1127](https://github.com/stripe/stripe-ios/pull/1127) +* CVC field updates validity if card number/brand change [#1128](https://github.com/stripe/stripe-ios/pull/1128) + +## 14.0.0 2018-11-14 +* Changes `STPPaymentCardTextField`, which now copies the `cardParams` property. See [MIGRATING.md](/MIGRATING.md) for more details. [#1031](https://github.com/stripe/stripe-ios/pull/1031) +* Renames `STPPaymentIntentParams.returnUrl` to `STPPaymentIntentParams.returnURL`. [#1037](https://github.com/stripe/stripe-ios/pull/1037) +* Removes `STPPaymentIntent.returnUrl` and adds `STPPaymentIntent.nextSourceAction`, based on changes to the [Stripe API](https://stripe.com/docs/upgrades#2018-11-08). [#1038](https://github.com/stripe/stripe-ios/pull/1038) +* Adds `STPVerificationParams.document_back` property. [#1017](https://github.com/stripe/stripe-ios/pull/1017) +* Fixes bug in `STPPaymentMethodsViewController` where selected payment method changes back if it wasn't dismissed in the `didFinish` delegate method. [#1020](https://github.com/stripe/stripe-ios/pull/1020) + +## 13.2.0 2018-08-14 +* Adds `STPPaymentMethod` protocol implementation for `STPSource`. You can now call `image`/`templatedImage`/`label` on a source. [#976](https://github.com/stripe/stripe-ios/pull/976) +* Fixes crash in `STPAddCardViewController` with some prefilled billing addresses [#1004](https://github.com/stripe/stripe-ios/pull/1004) +* Fixes `STPPaymentCardTextField` layout issues on small screens [#1009](https://github.com/stripe/stripe-ios/pull/1009) +* Fixes hidden text fields in `STPPaymentCardTextField` from being read by VoiceOver [#1012](https://github.com/stripe/stripe-ios/pull/1012) +* Updates example app to add client-side metadata `charge_request_id` to requests to `example-ios-backend` [#1008](https://github.com/stripe/stripe-ios/pull/1008) + +## 13.1.0 2018-07-13 +* Adds `STPPaymentIntent` to support PaymentIntents. [#985](https://github.com/stripe/stripe-ios/pull/985), [#986](https://github.com/stripe/stripe-ios/pull/986), [#987](https://github.com/stripe/stripe-ios/pull/987), [#988](https://github.com/stripe/stripe-ios/pull/988) +* Reduce `NSURLSession` memory footprint. [#969](https://github.com/stripe/stripe-ios/pull/969) +* Fixes invalid JSON error when deleting `Card` from a `Customer`. [#992](https://github.com/stripe/stripe-ios/pull/992) + +## 13.0.3 2018-06-11 +* Fixes payment method label overlapping the checkmark, for Amex on small devices [#952](https://github.com/stripe/stripe-ios/pull/952) +* Adds EPS and Multibanco support to `STPSourceParams` [#961](https://github.com/stripe/stripe-ios/pull/961) +* Adds `STPBillingAddressFieldsName` option to `STPBillingAddressFields` [#964](https://github.com/stripe/stripe-ios/pull/964) +* Fixes crash in `STPColorUtils.perceivedBrightnessForColor` [#954](https://github.com/stripe/stripe-ios/pull/954) +* Applies recommended project changes for Xcode 9.4 [#963](https://github.com/stripe/stripe-ios/pull/963) +* Fixes `[Stripe handleStripeURLCallbackWithURL:url]` incorrectly returning `NO` [#962](https://github.com/stripe/stripe-ios/pull/962) + +## 13.0.2 2018-05-24 +* Makes iDEAL `name` parameter optional, also accepts empty string as `nil` [#940](https://github.com/stripe/stripe-ios/pull/940) +* Adjusts scroll view content offset behavior when focusing on a text field [#943](https://github.com/stripe/stripe-ios/pull/943) + +## 13.0.1 2018-05-17 +* Fixes an issue in `STPRedirectContext` causing some redirecting sources to fail in live mode due to prematurely dismissing the `SFSafariViewController` during the initial redirects. [#937](https://github.com/stripe/stripe-ios/pull/937) + +## 13.0.0 2018-04-26 +* Removes Bitcoin source support. See MIGRATING.md. [#931](https://github.com/stripe/stripe-ios/pull/931) +* Adds Masterpass support to `STPSourceParams` [#928](https://github.com/stripe/stripe-ios/pull/928) +* Adds community submitted Norwegian (nb) translation. Thank @Nailer! +* Fixes example app usage of localization files (they were not able to be tested in Finnish and Norwegian before) +* Silences STPAddress deprecation warnings we ignore to stay compatible with older iOS versions +* Fixes "Card IO" link in full SDK reference [#913](https://github.com/stripe/stripe-ios/pull/913) + +## 12.1.2 2018-03-16 +* Updated the "62..." credit card number BIN range to show a UnionPay icon + +## 12.1.1 2018-02-22 +* Fix issue with apple pay token creation in PaymentContext, introduced by 12.1.0. [#899](https://github.com/stripe/stripe-ios/pull/899) +* Now matches clang static analyzer settings with Cocoapods, so you won't see any more analyzer issues. [#897](https://github.com/stripe/stripe-ios/pull/897) + +## 12.1.0 2018-02-05 +* Adds `createCardSources` to `STPPaymentConfiguration`. If you enable this option, when your user adds a card in the SDK's UI, a card source will be created and attached to their Stripe Customer. If this option is disabled (the default), a card token is created. For more information on card sources, see https://stripe.com/docs/sources/cards + +## 12.0.1 2018-01-31 +* Adding Visa Checkout support to `STPSourceParams` [#889](https://github.com/stripe/stripe-ios/pull/889) + +## 12.0.0 2018-01-16 +* Minimum supported iOS version is now 9.0. + * If you need to support iOS 8, the last supported version is [11.5.0](https://github.com/stripe/stripe-ios/releases/tag/v11.5.0) +* Minimum supported Xcode version is now 9.0 +* `AddressBook` framework support has been removed. +* `STPRedirectContext` will no longer retain itself for the duration of the redirect, you must explicitly maintain a reference to it yourself. [#846](https://github.com/stripe/stripe-ios/pull/846) +* `STPPaymentConfiguration.requiredShippingAddress` now is a set of `STPContactField` objects instead of a `PKAddressField` bitmask. [#848](https://github.com/stripe/stripe-ios/pull/848) +* See MIGRATING.md for more information on any of the previously mentioned breaking API changes. +* Pre-built view controllers now layout properly on iPhone X in landscape orientation, respecting `safeAreaInsets`. [#854](https://github.com/stripe/stripe-ios/pull/854) +* Fixes a bug in `STPAddCardViewController` that prevented users in countries without postal codes from adding a card when `requiredBillingFields = .Zip`. [#853](https://github.com/stripe/stripe-ios/pull/853) +* Fixes a bug in `STPPaymentCardTextField`. When completely filled out, it ignored calls to `becomeFirstResponder`. [#855](https://github.com/stripe/stripe-ios/pull/855) +* `STPPaymentContext` now has a `largeTitleDisplayMode` property, which you can use to control the title display mode in the navigation bar of our pre-built view controllers. [#849](https://github.com/stripe/stripe-ios/pull/849) +* Fixes a bug where `STPPaymentContext`'s `retryLoading` method would not re-retrieve the customer object, even after calling `STPCustomerContext`'s `clearCachedCustomer` method. [#863](https://github.com/stripe/stripe-ios/pull/863) +* `STPPaymentContext`'s `retryLoading` method will now always attempt to retrieve a new customer object, regardless of whether a cached customer object is available. Previously, this method was only intended for recovery from a loading error; if a customer had already been retrieved, `retryLoading` would do nothing. [#863](https://github.com/stripe/stripe-ios/pull/863) +* `STPCustomerContext` has a new property: `includeApplePaySources`. It is turned off by default. [#864](https://github.com/stripe/stripe-ios/pull/864) +* Adds `UITextContentType` support. This turns on QuickType suggestions for the name, email, and address fields; and uses a better keyboard for Payment Card fields. [#870](https://github.com/stripe/stripe-ios/pull/870) +* Fixes a bug that prevented redirects to the 3D Secure authentication flow when it was optional. [#878](https://github.com/stripe/stripe-ios/pull/878) +* `STPPaymentConfiguration` now has a `stripeAccount` property, which can be used to make API requests on behalf of a Connected account. [#875](https://github.com/stripe/stripe-ios/pull/875) +* Adds `- [STPAPIClient createTokenWithConnectAccount:completion:]`, which creates Tokens for Connect Accounts: (optionally) accepting the Terms of Service, and sending information about the legal entity. [#876](https://github.com/stripe/stripe-ios/pull/876) +* Fixes an iOS 11 bug in `STPPaymentCardTextField` that blocked tapping on the number field while editing the expiration or CVC on narrow devices (4" screens). [#883](https://github.com/stripe/stripe-ios/pull/883) + +## 11.5.0 2017-11-09 +* Adds a new helper method to `STPSourceParams` for creating reusable Alipay sources. [#811](https://github.com/stripe/stripe-ios/pull/811) +* Silences spurious availability warnings when using Xcode9 [#823](https://github.com/stripe/stripe-ios/pull/823) +* Auto capitalizes currency code when using `paymentRequestWithMerchantIdentifier ` to improve compatibility with iOS 11 `PKPaymentAuthorizationViewController` [#829](https://github.com/stripe/stripe-ios/pull/829) +* Fixes a bug in `STPRedirectContext` which caused `SFSafariViewController`-based redirects to incorrectly dismiss when switching apps. [#833](https://github.com/stripe/stripe-ios/pull/833) +* Fixes a bug that incorrectly offered users the option to "Use Billing Address" on the shipping address screen when there was no existing billing address to fill in. [#834](https://github.com/stripe/stripe-ios/pull/834) + +## 11.4.0 2017-10-20 +* Restores `[STPCard brandFromString:]` method which was marked as deprecated in a recent version [#801](https://github.com/stripe/stripe-ios/pull/801) +* Adds `[STPBankAccount metadata]` and `[STPCard metadata]` read-only accessors and improves annotation for `[STPSource metadata]` [#808](https://github.com/stripe/stripe-ios/pull/808) +* Un-deprecates `STPBackendAPIAdapter` and all associated methods. [#813](https://github.com/stripe/stripe-ios/pull/813) +* The `STPBackendAPIAdapter` protocol now includes two optional methods, `detachSourceFromCustomer` and `updateCustomerWithShipping`. If you've implemented a class conforming to `STPBackendAPIAdapter`, you may add implementations of these methods to support deleting cards from a customer and saving shipping info to a customer. [#813](https://github.com/stripe/stripe-ios/pull/813) +* Adds the ability to set custom footers on view controllers managed by the SDK. [#792](https://github.com/stripe/stripe-ios/pull/792) +* `STPPaymentMethodsViewController` will now display saved card sources in addition to saved card tokens. [#810](https://github.com/stripe/stripe-ios/pull/810) +* Fixes a bug where certain requests would return a generic failed to parse response error instead of the actual API error. [#809](https://github.com/stripe/stripe-ios/pull/809) + +## 11.3.0 2017-09-13 +* Adds support for creating `STPSourceParams` for P24 source [#779](https://github.com/stripe/stripe-ios/pull/779) +* Adds support for native app-to-app Alipay redirects [#783](https://github.com/stripe/stripe-ios/pull/783) +* Fixes crash when `paymentContext.hostViewController` is set to a `UINavigationController` [#786](https://github.com/stripe/stripe-ios/pull/786) +* Improves support and compatibility with iOS 11 + * Explicitly disable code coverage generation for compatibility with Carthage in Xcode 9 [#795](https://github.com/stripe/stripe-ios/pull/795) + * Restore use of native "Back" buttons [#789](https://github.com/stripe/stripe-ios/pull/789) +* Changes and fixes methods on `STPCard`, `STPCardParams`, `STPBankAccount`, and `STPBankAccountParams` to bring card objects more in line with the rest of the API. See MIGRATING for further details. + * `STPCard` and `STPCardParams` [#760](https://github.com/stripe/stripe-ios/pull/760) + * `STPBankAccount` and `STPBankAccountParams` [#761](https://github.com/stripe/stripe-ios/pull/761) +* Adds nullability annotations to `STPPaymentMethod` protocol [#753](https://github.com/stripe/stripe-ios/pull/753) +* Improves the `[STPAPIResponseDecodable allResponseFields]` by removing all instances of `[NSNull null]` including ones that are nested. See MIGRATING.md. [#747](https://github.com/stripe/stripe-ios/pull/747) + +## 11.2.0 2017-07-27 +* Adds an option to allow users to delete payment methods from the `STPPaymentMethodsViewController`. Enabled by default but can disabled using the `canDeletePaymentMethods` property of `STPPaymentConfiguration`. + * Screenshots: https://user-images.githubusercontent.com/28276156/28131357-7a353474-66ee-11e7-846c-b38277d111fd.png +* Adds a postal code field to `STPPaymentCardTextField`, configurable with `postalCodeEntryEnabled` and `postalCodePlaceholder`. Disabled by default. +* `STPCustomer`'s `shippingAddress` property is now correctly annotated as nullable. +* Removed `STPCheckoutUnknownError`, `STPCheckoutTooManyAttemptsError`, and `STPCustomerContextMissingKeyProviderError`. These errors will no longer occur. + +## 11.1.0 2017-07-12 +* Adds stripeAccount property to `STPAPIClient`, set this to perform API requests on behalf of a connected account +* Fixes the `routingNumber` property of `STPBankAccount` so that it is populated when the information is available +* Adds iOS Objective-C Style Guide + +## 11.0.0 2017-06-27 +* We've greatly simplified the integration for `STPPaymentContext`. See MIGRATING.md. +* As part of this new integration, we've added a new class, `STPCustomerContext`, which will automatically prefetch your customer and cache it for a brief interval. We recommend initializing your `STPCustomerContext` before your user enters your checkout flow so their payment methods are loaded in advance. If in addition to using `STPPaymentContext`, you create a separate `STPPaymentMethodsViewController` to let your customer manage their payment methods outside of your checkout flow, you can use the same instance of `STPCustomerContext` for both. +* We've added a `shippingAddress` property to `STPUserInformation`, which you can use to pre-fill your user's shipping information. +* `STPPaymentContext` will now save your user's shipping information to their Stripe customer object. Shipping information will automatically be pre-filled from the customer object for subsequent checkouts. +* Fixes nullability annotation for `[STPFile stringFromPurpose:]`. See MIGRATING.md. +* Adds description implementations to all public models, for easier logging and debugging. +* The card autofill via SMS feature of `STPPaymentContext` has been removed. See MIGRATING.md. + +## 10.2.0 2017-06-19 +* We've added a `paymentCountry` property to `STPPaymentContext`. This affects the countryCode of Apple Pay payments, and defaults to "US". You should set this to the country your Stripe account is in. +* `paymentRequestWithMerchantIdentifier:` has been deprecated. See MIGRATING.md +* If the card.io framework is present in your app, `STPPaymentContext` and `STPAddCardViewController` will show a "scan card" button. +* `STPAddCardViewController` will now attempt to auto-fill the users city and state from their entered Zip code (United States only) +* Polling for source object updates is deprecated. Check https://stripe.com/docs for the latest best practices on how to integrate with the sources API using webhooks. +* Fixes a crash in `STPCustomerDeserializer` when both data and error are nil. +* `paymentMethodsViewController:didSelectPaymentMethod:` is now optional. +* Updates the example apps to use Alamofire. + +## 10.1.0 2017-05-05 +* Adds STPRedirectContext, a helper class for handling redirect sources. +* STPAPIClient now supports tokenizing a PII number and uploading images. +* Updates STPPaymentCardTextField's icons to match Elements on the web. When the card number is invalid, the field will now display an error icon. +* The alignment of the new brand icons has changed to match the new CVC and error icons. If you use these icons via `STPImageLibrary`, you may need to adjust your layout. +* STPPaymentCardTextField's isValid property is now KVO-observable. +* When creating STPSourceParams for a SEPA debit source, address fields are now optional. +* `STPPaymentMethodsViewControllerDelegate` now has a separate `paymentMethodsViewControllerDidCancel:` callback, differentiating from successful method selections. You should make sure to also dismiss the view controller in that callback +* Because collecting some basic data on tokenization helps us detect fraud, we've removed the ability to disable analytics collection using `[Stripe disableAnalytics]`. + +## 10.0.1 2017-03-16 +* Fixes a bug where card sources didn't include the card owner's name. +* Fixes an issue where STPPaymentMethodsViewController didn't reload after adding a new payment method. + +## 10.0.0 2017-03-06 +* Adds support for creating, retrieving, and polling Sources. You can enable any payment methods available to you in the Dashboard. + * https://stripe.com/docs/mobile/ios/sources + * https://dashboard.stripe.com/account/payments/settings +* Updates the Objective-C example app to include example integrations using several different payment methods. +* Updates `STPCustomer` to include `STPSource` objects in its `sources` array if a customer has attached sources. +* Removes methods deprecated in Version 6.0. +* Fixes property declarations missing strong/nullable identifiers. + +## 9.4.0 2017-02-03 +* Adds button to billing/shipping entry screens to fill address information from the other one. +* Fixes and unifies view controller behavior around theming and nav bars. +* Adds month validity check to `validationStateForExpirationYear` +* Changes some Apple Pay images to better conform to official guidelines. +* Changes STPPaymentCardTextField's card number placeholder to "4242..." +* Updates STPPaymentCardTextField's CVC placeholder so that it changes to "CVV" for Amex cards + +## 9.3.0 2017-01-05 +* Fixes a regression introduced in v9.0.0 in which color in STPTheme is used as the background color for UINavigationBar + * Note: This will cause navigation bar theming to work properly as described in the Stripe iOS docs, but you may need to audit your custom theme settings if you based them on the actual behavior of 9.0-9.2 +* If the navigation bar has a theme different than the view controller's theme, STP view controllers will use the bar's theme to style it's UIBarButtonItems +* Adds a fallback to using main bundle for localized strings lookup if locale is set to a language the SDK doesn't support +* Adds method to get a string of a card brand from `STPCardBrand` +* Updated description of how to run tests in README +* Fixes crash when user cancels payment before STPBackendAPIAdapter methods finish +* Fixes bug where country picker wouldn't update when first selected. + + +## 9.2.0 2016-11-14 +* Moves FBSnapshotTestCase dependency to Cartfile.private. No changes if you are not using Carthage. +* Adds prebuilt UI for collecting shipping information. + +## 9.1.0 2016-11-01 +* Adds localized strings for 7 languages: de, es, fr, it, ja, nl, zh-Hans. +* Slight redesign to card/billing address entry screen. +* Improved internationalization for State/Province/County address field. +* Adds new Mastercard 2-series BIN ranges. +* Fixes an issue where callbacks may be run on the wrong thread. +* Fixes UIAppearance compatibility in STPPaymentCardTextField. +* Fixes a crash when changing application language via an Xcode scheme. + +## 9.0.0 2016-10-04 +* Change minimum requirements to iOS 8 and Xcode 8 +* Adds "app extension API only" support. +* Updates Swift example app to Swift 3 +* Various fixes to ObjC example app + +## 8.0.7 2016-09-15 +* Add ability to set currency for managed accounts when adding card +* Fix broken links for Privacy Policy/Terms of Service for Remember Me feature +* Sort countries in picker alphabetically by name instead of ISO code +* Make "County" field optional on billing address screen. +* PKPayment-related methods are now annotated as available in iOS8+ only +* Optimized speed of input sanitation methods (thanks @kballard!) + +## 8.0.6 2016-09-01 +* Improved internationalization on billing address forms + * Users in countries that don't use postal codes will no longer see that field. + * The country field is now auto filled in with the phone's region + * Changing the selected country will now live update other fields on the form (such as State/County or Zip/Postal Code). +* Fixed an issue where certain Cocoapods configurations could result in Stripe resource files being used in place of other frameworks' or the app's resources. +* Fixed an issue where when using Apple Pay, STPPaymentContext would fire two `didFinishWithStatus` messages. +* Fixed the `deviceSupportsApplePay` method to also check for Discover cards. +* Removed keys from Stripe.bundle's Info.plist that were causing iTunes Connect to sometimes error on app submission. + +## 8.0.5 2016-08-26 +* You can now optionally use an array of PKPaymentSummaryItems to set your payment amount, if you would like more control over how Apple Pay forms are rendered. +* Updated credit card and Apple Pay icons. +* Fixed some images not being included in the resources bundle target. +* Non-US locales now have an alphanumeric keyboard for postal code entry. +* Modals now use UIModalPresentationStyleFormSheet. +* Added more accessibility labels. +* STPPaymentCardTextField now conforms to UIKeyInput (thanks @theill). + +## 8.0.4 2016-08-01 +* Fixed an issue with Apple Pay payments not using the correct currency. +* View controllers now update their status bar and scroll view indicator styles based on their theme. +* SMS code screen now offers to paste copied codes. + +## 8.0.3 2016-07-25 +* Fixed an issue with some Cocoapods installations + +## 8.0.2 2016-07-09 +* Fixed an issue with custom theming of Stripe UI + +## 8.0.1 2016-07-06 +* Fixed error handling in STPAddCardViewController + +## 8.0.0 2016-06-30 +* Added prebuilt UI for collecting and managing card information. + +## 7.0.2 2016-05-24 +* Fixed an issue with validating certain Visa cards. + +## 7.0.1 2016-04-29 +* Added Discover support for Apple Pay +* Add the now-required `accountHolderName` and `accountHolderType` properties to STPBankAccountParams +* We now record performance metrics for the /v1/tokens API - to disable this behavior, call [Stripe disableAnalytics]. +* You can now demo the SDK more easily by running `pod try stripe`. +* This release also removes the deprecated Checkout functionality from the SDK. + +## 6.2.0 2016-02-05 +* Added an `additionalAPIParameters` field to STPCardParams and STPBankAccountParams for sending additional values to the API - useful for beta features. Similarly, added an `allResponseFields` property to STPToken, STPCard, and STPBankAccount for accessing fields in the response that are not yet reflected in those classes' @properties. + +## 6.1.0 2016-01-21 +* Renamed card on STPPaymentCardTextField to cardParams. +* You can now set an STPPaymentCardTextField's contents programmatically by setting cardParams to an STPCardParams object. +* Added delegate methods for responding to didBeginEditing events in STPPaymentCardTextField. +* Added a UIImage category for accessing our card icon images +* Fixed deprecation warnings for deployment targets >= iOS 9.0 + +## 6.0.0 2015-10-19 +* Splits logic in STPCard into 2 classes - STPCard and STPCardParams. STPCardParams is for making requests to the Stripe API, while STPCard represents the response (you'll almost certainly want just to replace any usage of STPCard in your app with STPCardParams). This also applies to STPBankAccount and the newly-created STPBankAccountParams. +* Version 6.0.1 fixes a minor Cocoapods issue. + +## 5.1.0 2015-08-17 +* Adds STPPaymentCardTextField, a new version of github.com/stripe/PaymentKit featuring many bugfixes. It's useful if you need a pre-built credit card entry form. +* Adds the currency param to STPCard for those using managed accounts & debit card payouts. +* Versions 5.1.1 and 5.1.2 fix minor issues with CocoaPods installation +* Version 5.1.3 contains bug fixes for STPPaymentCardTextField. +* Version 5.1.4 improves compatibility with iOS 9. + +## 5.0.0 2015-08-06 +* Fix an issue with Carthage installation +* Fix an issue with CocoaPods frameworks +* Deprecate native Stripe Checkout + +## 4.0.1 2015-05-06 +* Fix a compiler warning +* Versions 4.0.1 and 4.0.2 fix minor issues with CocoaPods and Carthage installation. + +## 4.0.0 2015-05-06 +* Remove STPPaymentPresenter +* Support for latest ApplePayStubs +* Add nullability annotations to improve Swift support (note: this now requires Swift 1.2) +* Bug fixes + +## 3.1.0 2015-01-19 +* Add support for native Stripe Checkout, as well as STPPaymentPresenter for automatically using Checkout as a fallback for Apple Pay +* Add OSX support, including Checkout +* Add framework targets and Carthage support +* It's safe to remove the STRIPE_ENABLE_APPLEPAY compiler flag after this release. + +## 3.0.0 2015-01-05 +* Migrate code into STPAPIClient +* Add 'brand' and 'funding' properties to STPCard + +## 2.2.2 2014-11-17 +* Add bank account tokenization methods + +## 2.2.1 2014-10-27 +* Add billing address fields to our Apple Pay API +* Various bug fixes and code improvements + +## 2.2.0 2014-10-08 +* Move Apple Pay testing functionality into a separate project, ApplePayStubs. For more info, see github.com/stripe/ApplePayStubs. +* Improve the provided example app + +## 2.1.0 2014-10-07 +* Remove token retrieval API method +* Refactor functional tests to use new XCTestCase functionality + +## 2.0.3 2014-09-24 +* Group ApplePay code in a CocoaPods subspec + +## 2.0.2 2014-09-24 +* Move ApplePay code behind a compiler flag to avoid warnings from Apple when accidentally including it + +## 2.0.1 2014-09-18 +* Fix some small bugs related to ApplePay and iOS8 + +## 2.0 2014-09-09 +* Add support for native payments via Pay + +## 1.2 2014-08-21 +* Removed PaymentKit as a dependency. If you'd like to use it, you may still do so by including it separately. +* Removed STPView. PaymentKit provides a near-identical version of this functionality if you need to migrate. +* Improve example project +* Various code fixes + +## 1.1.4 2014-05-22 +* Fixed an issue where tokenization requests would fail under iOS 6 due to SSL certificate verification + +## 1.1.3 2014-05-12 +* Send some basic version and device details with requests for debugging. +* Added -description to STPToken +* Fixed some minor code nits +* Modernized code + +## 1.1.2 2014-04-21 +* Added test suite for SSL certificate expiry/revocation +* You can now set STPView's delegate from Interface Builder + +## 1.1.1 2014-04-14 +* API methods now verify the server's SSL certificate against a preset blacklist. +* Fixed some bugs with SSL verification. +* Note: This version now requires the `Security` framework. You will need to add this to your app if you're not using CocoaPods. + +## 1.0.4 2014-03-24 + +* Upgraded tests from OCUnit to XCTest +* Fixed an issue with the SenTestingKit dependency +* Removed some dead code + +## 1.0.3 2014-03-21 + +* Fixed: Some example files had target memberships set for StripeiOS and iOSTest. +* Fixed: The example publishable key was expired. +* Fixed: Podspec did not pass linting. +* Some fixes for 64-bit. +* Many improvements to the README. +* Fixed example under iOS 7 +* Some source code cleaning and modernization. + +## 1.0.2 2013-09-09 + +* Add exceptions for null successHandler and errorHandler. +* Added the ability to POST the created token to a URL. +* Made STPCard properties nonatomic. +* Moved PaymentKit to be a submodule; added to Podfile as a dependency. +* Fixed some warnings caught by the static analyzer (thanks to jcjimenez!) + +## 1.0.1 2012-11-16 + +* Add CocoaPods support +* Change directory structure of bindings to make it easier to install + +## 1.0.0 2012-11-16 + +* Initial release + +Special thanks to: Todd Heasley, jcjimenez. diff --git a/MIGRATING.md b/MIGRATING.md new file mode 100644 index 00000000..5d9648e6 --- /dev/null +++ b/MIGRATING.md @@ -0,0 +1,308 @@ +## Migration Guides +### Migrating from versions < 23.0.0 +* The `Stripe` module is now split between `StripePaymentSheet`, `StripePayments`, and `StripePaymentsUI`. Some manual changes may be required. Migration instructions are available at [https://stripe.com/docs/mobile/ios/sdk-23-migration](https://stripe.com/docs/mobile/ios/sdk-23-migration). +* [Changed] If you use PaymentSheet, you must now `import StripePaymentSheet`. PaymentSheet users no longer need to import the `Stripe` module. +* [Changed] The minimum iOS version is now 13.0. If you'd like to deploy for iOS 12.0, please use Stripe SDK 22.8.4. +* [Changed] STPPaymentCardTextField's `cardParams` parameter has been deprecated in favor of `paymentMethodParams`, making it easier to include the postal code from the card field. If you need to access the `STPPaymentMethodCardParams`, use `.paymentMethodParams.card`. + * Note that `.paymentMethodParams` returns a copy, so `paymentMethodParams.card` should not be set directly. If you need to set the card information, set `.paymentMethodParams` to a new STPPaymentMethodParams: +``` +cardField.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) +``` + +### Migrating from versions < 22.8.0 +* `PaymentSheet.reset()` has been renamed to `PaymentSheet.resetCustomer()`. If calling the former method, follow the warning in Xcode and apply the suggested fix-it. + +### Migrating from versions < 22.2.0 +* `StripeConnections` SDK has been renamed to `StripeFinancialConnections`. If you included `StripeConnections` to support ACH Direct Debit payments, you will need to rename the dependency to `StripeFinancialConnections`. If you are manually installing `StripeConnections`, you will need to remove the old `StripeConnections.xcframework` and include the new `StripeFinancialConnections.xcframework`, which can be found in the [release assets](https://github.com/stripe/stripe-ios/releases/tag/22.2.0) for version 22.2.0 of the SDK. + +### Migrating from versions < 22.0.0 +* The minimum iOS version is now 12.0. If you'd like to deploy for iOS 11.0, please use Stripe SDK 21.12.0. +* `IdentityVerificationSheet` now has an availability requirement of iOS 14.3 on its initializer instead of the `present` method. If your app supports iOS versions < 14.3, you will need to add an availability check for iOS 14.3 before initializing the sheet. + +### Migrating from versions < 21.12.0 +* `Stripe` now requires `StripeApplePay`. If you are manually installing `Stripe`, you will need to include `StripeApplePay.xcframework`, which can be found in the [release assets](https://github.com/stripe/stripe-ios/releases/tag/21.12.0) for version 21.12.0 of the SDK. If you are using CocoaPods or Swift Package Manager, these dependencies will be imported automatically. + +### Migrating from versions < 21.10.0 +* `StripeIdentity` now requires `StripeCameraCore`. If you are manually installing `StripeIdentity`, you will need to include `StripeCameraCore.xcframework`, which can be found in the [release assets](https://github.com/stripe/stripe-ios/releases/tag/21.10.0) for version 21.10.0 of the SDK. If you are using CocoaPods or Swift Package Manager, these dependencies will be imported automatically. + +### Migrating from versions < 21.9.0 +* `Stripe` and `StripeIdentity` now require `StripeUICore`. If you are manually installing `Stripe` or `StripeIdentity`, you will need to include `StripeUICore.xcframework`, which can be found in the [release assets](https://github.com/stripe/stripe-ios/releases/tag/21.9.0) for version 21.9.0 of the SDK. If you are using CocoaPods or Swift Package Manager, these dependencies will be imported automatically. + +### Migrating from versions < 21.8.1 +* The `Stripe` module now requires `StripeCore`. If you are manually installing the Stripe SDK, you will need to include `StripeCore.xcframework`, which can be found in the [release assets](https://github.com/stripe/stripe-ios/releases/tag/21.8.1) for version 21.8.1 of the SDK. If you are using CocoaPods or Swift Package Manager, these dependencies will be imported automatically. + +### Migrating from versions < 21.4.0 +* STPPaymentHandler now presents its SFSafariViewController using the `.overFullScreen` presentation style by default. To select a different style, implement the `STPAuthenticationContext.configureSafariViewController(_:)` function in your `STPAuthenticationContext`. + +### Migrating from versions < 21.2.0 +* Stripe3DS2 is now a separate component for Carthage users. You must embed both Stripe.xcframework and Stripe3DS2.xcframework in your app. + +### Migrating from versions < 21.0.0 +* The SDK is now written in Swift, and some manual changes are required. Migration instructions are available at [https://stripe.com/docs/mobile/ios/sdk-21-migration](https://stripe.com/docs/mobile/ios/sdk-21-migration). + +### Migrating from versions < 20.1.0 +* Swift Package Manager users may need to remove and re-add Stripe from the `Frameworks, Libraries, and Embedded Content` section of your target's settings after updating. +* Swift Package Manager users with Xcode 12.0 may need to use a [workaround](https://github.com/stripe/stripe-ios/issues/1673) for a code signing issue. This is fixed in Xcode 12.2. + +### Migrating from versions < 20.0.0 +* The minimum iOS version is now 11.0. If you'd like to deploy for iOS 10.0, please use Stripe SDK 19.4.0. +* Card.io is no longer supported. To enable our built-in [card scanning](https://github.com/stripe/stripe-ios#card-scanning) beta, set the `cardScanningEnabled` flag on STPPaymentConfiguration. +* Catalyst support is out of beta, and now requires Swift Package Manager with Xcode 12 or Cocoapods 1.10. + +### Migrating from versions < 19.4.0 +* `metadata` fields are no longer populated on retrieved Stripe API objects and must be fetched on your server using your secret key. If this is causing issues with your deployed app versions please reach out to [Stripe Support](https://support.stripe.com/?contact=true). These fields have been marked as deprecated and will be removed in a future SDK version. + +### Migrating from versions < 19.3.0 +* `STPAUBECSFormView` now inherits from `UIView` instead of `UIControl` + +### Migrating from versions < 19.2.0 +* The `STPApplePayContext` 'applePayContext:didCreatePaymentMethod:completion:` delegate method now includes paymentInformation: 'applePayContext:didCreatePaymentMethod:paymentInformation:completion:`. + + +### Migrating from versions < 19.0.0 +#### `publishableKey` and `stripeAccount` changes +* Deprecates `publishableKey` and `stripeAccount` properties of `STPPaymentConfiguration`. + * If you used `STPPaymentConfiguration.sharedConfiguration` to set `publishableKey` and/or `stripeAccount`, use `STPAPIClient.sharedClient` instead. + * If you passed a STPPaymentConfiguration instance to an SDK component, you should instead create an STPAPIClient, set publishableKey on it, and set the SDK component's APIClient property. +* The SDK now uses `STPAPIClient.sharedClient` to make API requests by default. + +This changes the behavior of the following classes, which previously used API client instances configured from `STPPaymentConfiguration.shared`: `STPCustomerContext`, `STPPaymentOptionsViewController`, `STPAddCardViewController`, `STPPaymentContext`, `STPPinManagementService`, `STPPushProvisioningContext`. + +You are affected by this change if: + +1. You use `stripeAccount` to work with your Connected accounts +2. You use one of the above affected classes +3. You set different `stripeAccount` values on `STPPaymentConfiguration` and `STPAPIClient`, i.e. `STPPaymentConfiguration.shared.stripeAccount != STPAPIClient.shared.stripeAccount` + +If all three of the above conditions are true, you must update your integration! The SDK used to send `STPPaymentConfiguration.shared.stripeAccount`, and will now send `STPAPIClient.shared.stripeAccount`. + +For example, if you are a Connect user who stores Payment Methods on your platform, but clones PaymentMethods to a connected account and creates direct charges on that connected account i.e. if: + +1. You never set `STPPaymentConfiguration.shared.stripeAccount` +2. You set `STPAPIClient.shared.stripeAccount` + +We recommend you do the following: + +``` + // By default, you don't want the SDK to pass stripeAccount + STPAPIClient.shared().publishableKey = "pk_platform" + STPAPIClient.shared().stripeAccount = nil + + // You do want the SDK to pass stripeAccount when it makes payments directly on your connected account, so + // you create a separate APIClient instance... + let connectedAccountAPIClient = STPAPIClient(publishableKey: "pk_platform") + + // ...set stripeAccount on it... + connectedAccountAPIClient.stripeAccount = "your connected account's id" + + // ...and either set the relevant SDK components' apiClient property to your custom APIClient instance: + STPPaymentHandler.shared().apiClient = connectedAccountAPIClient // e.g. if you are using PaymentIntents + + // ...or use it directly to make API requests with `stripeAccount` set: + connectedAccountAPIClient.createToken(withCard:...) // e.g. if you are using Tokens + Charges +``` +#### Postal code changes +* The user's postal code is now collected by default in countries that support postal codes. We always recommend collecting a postal code to increase card acceptance rates and reduce fraud. To disable this behavior: + * For STPPaymentContext and other full-screen UI, set your `STPPaymentConfiguration`'s `.requiredBillingAddressFields` to `STPBillingAddressFieldsNone`. + * For a PKPaymentRequest, set `.requiredBillingContactFields` to an empty set. If your app supports iOS 10, also set `.requiredBillingAddressFields` to `PKAddressFieldNone`. + * For STPPaymentCardView, set `.postalCodeEntryEnabled` to `NO`. +* Users may now enter spaces, dashes, and uppercase letters into the postal code field in situations where the user has not explicitly selected a country. This allows users with non-US addreses to enter their postal code. +* `STPBillingAddressFieldsZip` has been renamed to `STPBillingAddressFieldsPostalCode`. +#### Localization changes +* All [Stripe Error messages](https://stripe.com/docs/api/errors#errors-message) are now localized + based on the device locale. + + For example, when retrieving a SetupIntent with a nonexistent `id` + when the device locale is set to `Locale.JAPAN`, the error message will now be localized. + ``` + // before - English + "No such setupintent: seti_invalid123" + + // after - Japanese + "そのような setupintent はありません : seti_invalid123" + ``` + +### Migrating from versions < 18.0.0 +* Some error messages from the Payment Intents API are now localized to the user's display language. If your application's logic depends on specific `message` strings from the Stripe API, please use the error [`code`](https://stripe.com/docs/error-codes) instead. +* `STPPaymentResult` may contain a `paymentMethodParams` instead of a `paymentMethod` when using single-use payment methods such as FPX. Because of this, `STPPaymentResult.paymentMethod` is now nullable. Instead of setting the `paymentMethodId` manually on your `paymentIntentParams`, you may now call `paymentIntentParams.configure(with result: STPPaymentResult)`: +``` +// 17.0.0 +paymentIntentParams.paymentMethodId = paymentResult.paymentMethod.stripeId + +// 18.0.0 +paymentIntentParams.configure(with: paymentResult) +``` +* `STPPaymentOptionTypeAll` has been renamed to `STPPaymentOptionTypeDefault`. This option will not include FPX or future optional payment methods. +* The minimum iOS version is now 10.0. If you'd like to deploy for iOS 9.0, please use Stripe SDK 17.0.2. + +### Migrating from versions < 17.0.0 +* The API version has been updated from 2015-10-12 to 2019-05-16. CHANGELOG.md has details on the changes made, which includes breaking changes for `STPConnectAccountParams` users. Your backend Stripe API version should be sufficiently decoupled from the SDK's so that keeping their versions in sync is not required, and no further action is required to migrate to this version of the SDK. +* For STPPaymentContext users: the completion block type in `paymentContext:didCreatePaymentResult:completion:` has changed to `STPPaymentStatusBlock`, to let you inform the context that the user has cancelled. + +### Migrating from versions < 16.0.0 +* The following have been migrated from Source/Token to PaymentMethod. If you have integrated with any of these things, you must also migrate to PaymentMethod and the Payment Intent API. See https://stripe.com/docs/payments/payment-intents/migration. See CHANGELOG.md for more details. + * UI components + * STPPaymentCardTextField + * STPAddCardViewController + * STPPaymentOptionsViewController + * PaymentContext + * STPPaymentContext + * STPCustomerContext + * STPBackendAPIAdapter + * STPPaymentResult + * Standard Integration example project +* `STPPaymentIntentAction*` types have been renamed to `STPIntentAction*`. Xcode should offer a deprecation warning & fix-it to help you migrate. +* `STPPaymentHandler` supports 3DS2 authentication, and is recommended instead of `STPRedirectContext`. See https://stripe.com/docs/mobile/ios/authentication + +### Migrating from versions < 15.0.0 +* "PaymentMethod" has a new meaning: https://stripe.com/docs/api/payment_methods/object. All things referring to "PaymentMethod" have been renamed to "PaymentOption" (see CHANGELOG.md for the full list). `STPPaymentMethod` and `STPPaymentMethodType` have been rewritten to match this new API object. +* PaymentMethod succeeds Source as the recommended way to charge customers. In this vein, several 'Source'-named things have been deprecated, and replaced with 'PaymentMethod' equivalents. For example, `STPPaymentIntentsStatusRequiresSource` is replaced by `STPPaymentIntentsStatusRequiresPaymentMethod` (see CHANGELOG.md for the full list). Following the deprecation warnings & fix-its will be enough to migrate your code - they've simply been renamed, and will continue to work for Source-based flows. + +### Migrating from versions < 14.0.0 +* `STPPaymentCardTextField` now copies the `STPCardParams` object when setting/getting the `cardParams` property, instead of sharing the object with the caller. + * Changes to the `STPCardParams` object after setting `cardParams` no longer mutate the object held by the `STPPaymentCardTextField` + * Changes to the object returned by `STPPaymentCardTextField.cardParams` no longer mutate the object held by the `STPPaymentCardTextField` + * This is a breaking change for code like: `paymentCardTextField.cardParams.name = @"Jane Doe";` +* `STPPaymentIntentParams.returnUrl` has been renamed to `STPPaymentIntentParams.returnURL`. Xcode should offer a deprecation warning & fix-it to help you migrate. +* `STPPaymentIntent.returnUrl` has been removed, because it's no longer a property of the PaymentIntent. When the PaymentIntent status is `.requiresSourceAction`, and the `nextSourceAction.type` is `.authorizeWithURL`, you can find the return URL at `nextSourceAction.authorizeWithURL.returnURL`. + +### Migrating from versions < 13.1.0 + * The SDK now supports PaymentIntents with `STPPaymentIntent`, which use `STPRedirectContext` in the same way that `STPSource` does + * `STPRedirectContextCompletionBlock` has been renamed to `STPRedirectContextSourceCompletionBlock`. It has the same signature, and Xcode should offer a deprecation warning & fix-it to help you migrate. + +### Migrating from versions < 13.0.0 +* Remove Bitcoin source support because Stripe no longer processes Bitcoin payments: https://stripe.com/blog/ending-bitcoin-support + * Sources can no longer have a "STPSourceTypeBitcoin" source type. These sources will now be interpreted as "STPSourceTypeUnknown". + * You can no longer `createBitcoinParams`. Please use a different payment method. + +### Migrating from versions < 12.0.0 +* The SDK now requires iOS 9+ and Xcode version 9+. If you need to support iOS 8 or Xcode 8, the last supported version is [11.5.0](https://github.com/stripe/stripe-ios/releases/tag/v11.5.0) +* `STPPaymentConfiguration.requiredShippingAddress` now is a set of `STPContactField` objects instead of a `PKAddressField` bitmask. + * Most of the previous `PKAddressField` constants have matching `STPContactField` constants. To convert your code, switch to passing in a set of the matching constants + * Example: `(PKAddressField)(PKAddressFieldName|PKAddressFieldPostalAddress)` becomes `[NSSet setwithArray:@[STPContactFieldName, STPContactFieldPostalAddress]]`) + * Anywhere you were using `PKAddressFieldNone` you can now simply pass in `nil` + * If you were using `PKAddressFieldAll`, you must switch to manually listing all the fields that you want. + * The new constants also correspond to and work similarly to Apple's new `PKContactField` values. +* `AddressBook` framework support has been removed. If you were using AddressBook related functionality, you must switch over to using the `Contacts` framework. +* `STPRedirectContext` will no longer retain itself for the duration of the redirect. If you were relying on this functionality, you must change your code to explicitly maintain a reference to it. + +### Migrating from versions < 11.4.0 +* The `STPBackendAPIAdapter` protocol and all associated methods are no longer deprecated. We still recommend using `STPCustomerContext` to update a Stripe customer object on your behalf, rather than using your own implementation of `STPBackendAPIAdapter`. + +### Migrating from versions < 11.3.0 +* Changes to `STPCard`, `STPCardParams`, `STPBankAccount`, and `STPBankAccountParams` + * `STPCard` no longer subclasses from `STPCardParams`. You must now specifically create `STPCardParams` objects to create new tokens. + * `STPBankAccount` no longer subclasses from `STPBankAccountParams`. + * You can no longer directly create `STPCard` objects, you should only use ones that have been decoded from Stripe API responses via `STPAPIClient`. + * All `STPCard` and `STPBankAccount` properties have been made readonly. + * Broken out individual address properties on `STPCard` and `STPCardParams` have been deprecated in favor of the grouped `address` property. +* The value of `[STPAPIResponseDecodable allResponseFields]` is now completely (deeply) filtered to not contain any instances of `[NSNull null]`. Previously, only `[NSNull null]` one level deep (shallow) were removed. + +### Migrating from versions < 11.2.0 +* `STPCustomer`'s `shippingAddress` property is now correctly annotated as nullable. Its type is an optional (`STPAddress?`) in Swift. + +### Migrating from versions < 11.0.0 +- We've greatly simplified the integration for `STPPaymentContext`. In order to migrate to the new `STPPaymentContext` integration using ephemeral keys, you'll need to: + 1. On your backend, add a new endpoint that creates an ephemeral key for the Stripe customer associated with your user, and returns its raw JSON. Note that you should _not_ remove the 3 endpoints you added for your initial PaymentContext integration until you're ready to drop support for previous versions of your app. + 2. In your app, make your API client class conform to `STPEphemeralKeyProvider` by adding a method that requests an ephemeral key from the endpoint you added in (1). + 3. In your app, remove any references to `STPBackendAPIAdapter`. Your API client class will no longer need to conform to `STPBackendAPIAdapter`, and you can delete the `retrieveCustomer`, `attachSourceToCustomer`, and `selectDefaultCustomerSource` methods. + 4. Instead of using the initializers for `STPPaymentContext` or `STPPaymentMethodsViewController` that take an `STPBackendAPIAdapter` parameter, you should use the new initializers that take an `STPCustomerContext` parameter. You'll need to set up your instance of `STPCustomerContext` using the key provider you set up in (2). +- For a more detailed overview of the new integration, you can refer to our tutorial at https://stripe.com/docs/mobile/ios/standard +- `[STPFile stringFromPurpose:]` now returns `nil` for `STPFilePurposeUnknown`. Will return a non-nil value for all other `STPFilePurpose`. +- We've removed the `email` and `phone` properties in `STPUserInformation`. You can pre-fill this information in the shipping form using the new `shippingAddress` property. +- The SMS card fill feature has been removed from `STPPaymentContext`, as well as the associated `smsAutofillDisabled` configuration option (ie it will now always behave as if it is disabled). + +### Migrating from versions < 10.2.0 +- `paymentRequestWithMerchantIdentifier:` has been deprecated. You should instead use `paymentRequestWithMerchantIdentifier:country:currency:`. Apple Pay is now available in many countries and currencies, and you should use the appropriate values for your business. +- We've added a `paymentCountry` property to `STPPaymentContext`. This affects the countryCode of Apple Pay payments, and defaults to "US". You should set this to the country your Stripe account is in. +- Polling for source object updates is deprecated. Check https://stripe.com/docs for the latest best practices on how to integrate with the sources API using webhooks. +- `paymentMethodsViewController:didSelectPaymentMethod:` is now optional. If you have an empty implementation of this method, you can remove it. + +### Migrating from versions < 10.1.0 + +- STPPaymentMethodsViewControllerDelegate now has a separate `paymentMethodsViewControllerDidCancel:` callback, differentiating from successful method selections. You should make sure to also dismiss the view controller in that callback. + +### Migrating from versions < 10.0 + +- Methods deprecated in Version 6.0 have now been removed. +- The `STPSource` protocol has been renamed `STPSourceProtocol`. +- `STPSource` is now a model object representing a source from the Stripe API. https://stripe.com/docs/sources +- `STPCustomer` will now include `STPSource` objects in its `sources` array if a customer has attached sources. +- `STPErrorCode` and `STPCardErrorCode` are now first class Swift enums (before, their types were `Int` and `String`, respectively) + +### Migrating from versions < 9.0 + +Version 9.0 drops support for iOS 7.x and Xcode 7.x. If you need to support iOS or Xcode versions below 8.0, the last compatible Stripe SDK release is version 8.0.7. + +### Migrating from versions < 6.0 + +6.0 moves most of the contents of `STPCard` into a new class, `STPCardParams`, which represents a request to the Stripe API. `STPCard` now only refers to responses from the Stripe API. Most apps should be able to simply replace all usage of `STPCard` with `STPCardParams` - you should only use `STPCard` if you're dealing with an API response, e.g. a card attached to an `STPToken`. This renaming has been done in a way that will avoid breaking changes, although using `STPCard`s to make requests to the Stripe API will produce deprecation warnings. + +### Migrating from versions < 5.0 + +5.0 deprecates our native Stripe Checkout adapters. If you were using these, we recommend building your own credit card form instead. If you need help with this, please contact support@stripe.com. + +### Migrating from versions < 3.0 + +Before version 3.0, most token-creation methods were class methods on the `Stripe` class. These are now all instance methods on the `STPAPIClient` class. Where previously you might write +```objective-c +[Stripe createTokenWithCard:card publishableKey:myPublishableKey completion:completion]; +``` +you would now instead write +```objective-c +STPAPIClient *client = [[STPAPIClient alloc] initWithPublishableKey:myPublishableKey]; +[client createTokenWithCard:card completion:completion]; +``` +This version also made several helper classes, including `STPAPIConnection` and `STPUtils`, private. You should remove any references to them from your code (most apps shouldn't have any). + +## Migrating from versions < 1.2 + +Versions of Stripe-iOS prior to 1.2 included a class called `STPView`, which provided a pre-built credit card form. This functionality has been moved from Stripe-iOS to PaymentKit, a separate project. If you were using `STPView` prior to version 1.2, migrating is simple: + +1. Add PaymentKit to your project, as explained on its [project page](https://github.com/stripe/PaymentKit). +2. Replace any references to `STPView` with a `PTKView` instead. Similarly, any classes that implement `STPViewDelegate` should now instead implement the equivalent `PTKViewDelegate` methods. Note that unlike `STPView`, `PTKView` does not take a Stripe API key in its constructor. +3. To submit the credit card details from your `PTKView` instance, where you would previously call `createToken` on your `STPView`, replace that with the following code (assuming `self.paymentView` is your `PTKView` instance): + +```objective-c +if (![self.paymentView isValid]) { + return; +} +STPCard *card = [[STPCard alloc] init]; +card.number = self.paymentView.card.number; +card.expMonth = self.paymentView.card.expMonth; +card.expYear = self.paymentView.card.expYear; +card.cvc = self.paymentView.card.cvc; +STPAPIClient *client = [[STPAPIClient alloc] initWithPublishableKey:publishableKey]; +[client createTokenWithCard:card completion:^(STPToken *token, NSError *error) { + if (error) { + // handle the error as you did previously + } else { + // submit the token to your payment backend as you did previously + } +}]; +``` + +## Misc. notes + +### Handling errors + +See [StripeError.h](https://github.com/stripe/stripe-ios/blob/master/Stripe/PublicHeaders/Stripe/StripeError.h) for a list of error codes that may be returned from the Stripe API. + +### Validating STPCards + +You have a few options for handling validation of credit card data on the client, depending on what your application does. Client-side validation of credit card data is not required since our API will correctly reject invalid card information, but can be useful to validate information as soon as a user enters it, or simply to save a network request. + +The simplest thing you can do is to populate an `STPCard` object and, before sending the request, call `- (BOOL)validateCardReturningError:` on the card. This validates the entire card object, but is not useful for validating card properties one at a time. + +To validate `STPCard` properties individually, you should use the following: + +```objective-c + - (BOOL)validateNumber:error: + - (BOOL)validateCvc:error: + - (BOOL)validateExpMonth:error: + - (BOOL)validateExpYear:error: +``` + +These methods follow the validation method convention used by [key-value validation](http://developer.apple.com/library/mac/#documentation/cocoa/conceptual/KeyValueCoding/Articles/Validation.html). So, you can use these methods by invoking them directly, or by calling `[card validateValue:forKey:error]` for a property on the `STPCard` object. + +When using these validation methods, you will want to set the property on your card object when a property does validate before validating the next property. This allows the methods to use existing properties on the card correctly to validate a new property. For example, validating `5` for the `expMonth` property will return YES if no `expYear` is set. But if `expYear` is set and you try to set `expMonth` to 5 and the combination of `expMonth` and `expYear` is in the past, `5` will not validate. The order in which you call the validate methods does not matter for this though. diff --git a/Package.swift b/Package.swift new file mode 100644 index 00000000..a2d08f9a --- /dev/null +++ b/Package.swift @@ -0,0 +1,156 @@ +// swift-tools-version:5.7 +import PackageDescription + +let package = Package( + name: "Stripe", + defaultLocalization: "en", + platforms: [ + .iOS(.v13) + ], + products: [ + .library( + name: "Stripe", + targets: ["Stripe"] + ), + .library( + name: "StripePayments", + targets: ["StripePayments"] + ), + .library( + name: "StripePaymentsUI", + targets: ["StripePaymentsUI"] + ), + .library( + name: "StripePaymentSheet", + targets: ["StripePaymentSheet"] + ), + .library( + name: "StripeApplePay", + targets: ["StripeApplePay"] + ), + .library( + name: "StripeIdentity", + targets: ["StripeIdentity"] + ), + .library( + name: "StripeCardScan", + targets: ["StripeCardScan"] + ), + .library( + name: "StripeFinancialConnections", + targets: ["StripeFinancialConnections"] + ) + ], + targets: [ + .target( + name: "Stripe", + dependencies: ["Stripe3DS2", "StripeCore", "StripeApplePay", "StripeUICore", "StripePayments", "StripePaymentsUI"], + path: "Stripe/StripeiOS", + exclude: ["Info.plist"], + resources: [ + .process("Resources/StripeiOS.xcassets") + ] + ), + .target( + name: "Stripe3DS2", + path: "Stripe3DS2/Stripe3DS2", + exclude: ["Info.plist", "Resources/CertificateFiles", "include/Stripe3DS2-Prefix.pch"], + resources: [ + .process("Resources") + ], + cSettings: [ + .headerSearchPath(".") + ] + ), + .target( + name: "StripeCameraCore", + dependencies: ["StripeCore"], + path: "StripeCameraCore/StripeCameraCore", + exclude: ["Info.plist"] + ), + .target( + name: "StripeCore", + path: "StripeCore/StripeCore", + exclude: ["Info.plist"] + ), + .target( + name: "StripeApplePay", + dependencies: ["StripeCore"], + path: "StripeApplePay/StripeApplePay", + exclude: ["Info.plist"] + ), + .target( + name: "StripeIdentity", + dependencies: ["StripeCore", "StripeUICore", "StripeCameraCore"], + path: "StripeIdentity/StripeIdentity", + exclude: ["Info.plist"], + resources: [ + .process("Resources/Images") + ] + ), + .target( + name: "StripeCardScan", + dependencies: ["StripeCore"], + path: "StripeCardScan/StripeCardScan", + exclude: ["Info.plist"], + resources: [ + .copy("Resources/CompiledModels/UxModel.mlmodelc"), + .copy("Resources/CompiledModels/SSDOcr.mlmodelc") + ] + ), + .target( + name: "StripeUICore", + dependencies: ["StripeCore"], + path: "StripeUICore/StripeUICore", + exclude: ["Info.plist"], + resources: [ + .process("Resources/StripeUICore.xcassets"), + .process("Resources/JSON") + ] + ), + .target( + name: "StripePayments", + dependencies: ["StripeCore", "Stripe3DS2"], + path: "StripePayments/StripePayments", + exclude: ["Info.plist"], + resources: [ + .process("Resources") + ] + ), + .target( + name: "StripePaymentsUI", + dependencies: ["StripeCore", "Stripe3DS2", "StripePayments", "StripeUICore"], + path: "StripePaymentsUI/StripePaymentsUI", + exclude: ["Info.plist"], + resources: [ + .process("Resources/StripePaymentsUI.xcassets"), + .process("Resources/JSON") + ] + ), + .target( + name: "StripePaymentSheet", + dependencies: ["StripePaymentsUI", "StripeApplePay", "StripePayments", "StripeCore", "StripeUICore"], + path: "StripePaymentSheet/StripePaymentSheet", + exclude: ["Info.plist"], + resources: [ + .process("Resources/StripePaymentSheet.xcassets"), + .process("Resources/JSON") + ] + ), + .target( + name: "StripeFinancialConnections", + dependencies: ["StripeCore", "StripeUICore"], + path: "StripeFinancialConnections/StripeFinancialConnections", + exclude: ["Info.plist"], + resources: [ + .process("Resources/Images"), + ] + ), + .target( + name: "StripeLinkCore", + //dependencies: ["StripeCore"], + path: "StripeLinkCore/StripeLinkCore", + exclude: ["Info.plist"] + ) + ] +) diff --git a/README.md b/README.md index 4fea3738..49e3701a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,166 @@ -# stripe-ios-spm -This is a lightweight Swift Package Manager mirror for the [Stripe iOS SDK](https://github.com/stripe/stripe-ios). It offers [tagged source releases](https://github.com/stripe/stripe-ios-spm/releases) for each version of the SDK. +# Stripe iOS SDK -Please file issues on the [`stripe-ios` issues page](https://github.com/stripe/stripe-ios/issues). +[![CocoaPods](https://img.shields.io/cocoapods/v/Stripe.svg?style=flat)](http://cocoapods.org/?q=author%3Astripe%20name%3Astripe) +[![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) +[![Tuist badge](https://img.shields.io/badge/Powered%20by-Tuist-blue)](https://tuist.io) +[![License](https://img.shields.io/cocoapods/l/Stripe.svg?style=flat)](https://github.com/stripe/stripe-ios/blob/master/LICENSE) +[![Platform](https://img.shields.io/cocoapods/p/Stripe.svg?style=flat)](https://github.com/stripe/stripe-ios#) + +The Stripe iOS SDK makes it quick and easy to build an excellent payment experience in your iOS app. We provide powerful and customizable UI screens and elements that can be used out-of-the-box to collect your users' payment details. We also expose the low-level APIs that power those UIs so that you can build fully custom experiences. + +Get started with our [📚 integration guides](https://stripe.com/docs/payments/accept-a-payment?platform=ios) and [example projects](#examples), or [📘 browse the SDK reference](https://stripe.dev/stripe-ios/docs/index.html). + +Learn about our [Stripe Identity iOS SDK](StripeIdentity/README.md) to verify the identity of your users on iOS. + +> Updating to a newer version of the SDK? See our [migration guide](https://github.com/stripe/stripe-ios/blob/master/MIGRATING.md) and [changelog](https://github.com/stripe/stripe-ios/blob/master/CHANGELOG.md). + +Table of contents +================= + + + * [Features](#features) + * [Releases](#releases) + * [Requirements](#requirements) + * [Getting started](#getting-started) + * [Integration](#integration) + * [Examples](#examples) + * [Building from source](#building-from-source) + * [Card scanning](#card-scanning) + * [Contributing](#contributing) + * [Migrating](#migrating-from-older-versions) + * [Code Stye](#code-style) + * [Licenses](#licenses) + + + +## Features + +**Simplified security**: We make it simple for you to collect sensitive data such as credit card numbers and remain [PCI compliant](https://stripe.com/docs/security#pci-dss-guidelines). This means the sensitive data is sent directly to Stripe instead of passing through your server. For more information, see our [integration security guide](https://stripe.com/docs/security). + +**Apple Pay**: [StripeApplePay](StripeApplePay/README.md) provides a [seamless integration with Apple Pay](https://stripe.com/docs/apple-pay). + +**SCA-ready**: The SDK automatically performs native [3D Secure authentication](https://stripe.com/docs/payments/3d-secure) if needed to comply with [Strong Customer Authentication](https://stripe.com/docs/strong-customer-authentication) regulation in Europe. + +**Native UI**: We provide native screens and elements to collect payment details. For example, [PaymentSheet](https://stripe.com/docs/payments/accept-a-payment?platform=ios) is a prebuilt UI that combines all the steps required to pay - collecting payment details, billing details, and confirming the payment - into a single sheet that displays on top of your app. + +PaymentSheet + +**Stripe API**: [StripePayments](StripePayments/README.md) provides [low-level APIs](https://stripe.dev/stripe-ios/docs/Classes/STPAPIClient.html) that correspond to objects and methods in the Stripe API. You can build your own entirely custom UI on top of this layer, while still taking advantage of utilities like [STPCardValidator](https://stripe.dev/stripe-ios/docs/Classes/STPCardValidator.html) to validate your user’s input. + +**Card scanning**: We support card scanning on iOS 13 and higher. See our [Card scanning](#card-scanning) section. + +**App Clips**: The `StripeApplePay` module provides a [lightweight SDK for offering Apple Pay in an App Clip](https://stripe.com/docs/apple-pay#app-clips). + +**Localized**: We support the following localizations: Bulgarian, Catalan, Chinese (Hong Kong), Chinese (Simplified), Chinese (Traditional), Croatian, Czech, Danish, Dutch, English (US), English (United Kingdom), Estonian, Filipino, Finnish, French, French (Canada), German, Greek, Hungarian, Indonesian, Italian, Japanese, Korean, Latvian, Lithuanian, Malay, Maltese, Norwegian Bokmål, Norwegian Nynorsk (Norway), Polish, Portuguese, Portuguese (Brazil), Romanian, Russian, Slovak, Slovenian, Spanish, Spanish (Latin America), Swedish, Turkish, Thai and Vietnamese. + +#### Recommended usage + +If you're selling digital products or services that will be consumed within your app, (e.g. subscriptions, in-game currencies, game levels, access to premium content, or unlocking a full version), you must use Apple's in-app purchase APIs. See the [App Store review guidelines](https://developer.apple.com/app-store/review/guidelines/#payments) for more information. For all other scenarios you can use this SDK to process payments via Stripe. + +#### Privacy + +The Stripe iOS SDK collects data to help us improve our products and prevent fraud. This data is never used for advertising and is not rented, sold, or given to advertisers. Our full privacy policy is available at [https://stripe.com/privacy](https://stripe.com/privacy). + +For help with Apple's App Privacy Details form in App Store Connect, visit [Stripe iOS SDK Privacy Details](https://support.stripe.com/questions/stripe-ios-sdk-privacy-details). + +## Modules +|Module|Description|Compressed|Uncompressed| +|------|-----------|----------|------------| +|StripePaymentSheet|Stripe's [prebuilt payment UI](https://stripe.com/docs/payments/accept-a-payment?platform=ios&ui=payment-sheet).|2.7MB|6.3MB| +|Stripe|Contains all the below frameworks, plus [Issuing](https://stripe.com/docs/issuing/cards/digital-wallets?platform=iOS) and [Basic Integration](https://stripe.com/docs/mobile/ios/basic).|2.3MB|5.1MB| +|StripeApplePay|[Apple Pay support](/docs/apple-pay), including `STPApplePayContext`.|0.4MB|1.0MB| +|StripePayments|Bindings for the Stripe Payments API.|1.0MB|2.6MB| +|StripePaymentsUI|Bindings for the Stripe Payments API, [STPPaymentCardTextField](https://stripe.com/docs/payments/accept-a-payment?platform=ios&ui=custom), STPCardFormView, and other UI elements.|1.7MB|3.9MB| + +## Releases + +We support Cocoapods, Carthage, and Swift Package Manager. + +If you link the library manually, use a version from our [releases](https://github.com/stripe/stripe-ios/releases) page and make sure to embed all of the required frameworks. + +For the `Stripe` module, link the following frameworks: +- `Stripe.xcframework` +- `Stripe3DS2.xcframework` +- `StripeApplePay.xcframework` +- `StripePayments.xcframework` +- `StripePaymentsUI.xcframework` +- `StripeCore.xcframework` +- `StripeUICore.xcframework` + +For other modules, follow the instructions below: +- [StripePaymentSheet](StripePaymentSheet/README.md#manual-linking) +- [StripePayments](StripePayments/README.md#manual-linking) +- [StripePaymentsUI](StripePaymentsUI/README.md#manual-linking) +- [StripeApplePay](StripeApplePay/README.md#manual-linking) +- [StripeIdentity](StripeIdentity/README.md#manual-linking) + +If you're reading this on GitHub.com, please make sure you are looking at the [tagged version](https://github.com/stripe/stripe-ios/tags) that corresponds to the release you have installed. Otherwise, the instructions and example code may be mismatched with your copy. + +## Requirements + +The Stripe iOS SDK requires Xcode 14.1 or later and is compatible with apps targeting iOS 13 or above. We support Catalyst on macOS 11 or later. + +For iOS 12 support, please use [v22.8.4](https://github.com/stripe/stripe-ios/tree/v22.8.4). For iOS 11 support, please use [v21.13.0](https://github.com/stripe/stripe-ios/tree/v21.13.0). For iOS 10, please use [v19.4.0](https://github.com/stripe/stripe-ios/tree/v19.4.0). If you need to support iOS 9, use [v17.0.2](https://github.com/stripe/stripe-ios/tree/v17.0.2). + +## Getting started + +### Integration + +Get started with our [📚 integration guides](https://stripe.com/docs/payments/accept-a-payment?platform=ios) and [example projects](/Example), or [📘 browse the SDK reference](https://stripe.dev/stripe-ios/docs/index.html) for fine-grained documentation of all the classes and methods in the SDK. + +### Examples + +- [Prebuilt UI](Example/PaymentSheet%20Example) (Recommended) + - This example demonstrates how to build a payment flow using [`PaymentSheet`](https://stripe.com/docs/payments/accept-a-payment?platform=ios), an embeddable native UI component that lets you accept [10+ payment methods](https://stripe.com/docs/payments/payment-methods/integration-options#payment-method-product-support) with a single integration. + +- [Non-Card Payment Examples](Example/Non-Card%20Payment%20Examples) + - This example demonstrates how to manually accept various payment methods using the Stripe API. + +### Building from source + +We use [Tuist](https://tuist.io) to generate Xcode projects, and all Xcode related files have been removed from the master branch of the repository. Note that project files are still available on tagged releases. + +If you want to build from the master branch you need to follow these steps: + +- Clone the repository and `cd` into its directory. +- Install Tuist by following the instructions at [tuist.io](https://docs.tuist.io/tutorial/get-started/). +- Run `tuist generate`, optionally pass the `-n` option if you don't want to open Xcode automatically. + +You can build any of the generated targets as you normally would. + +For more information about Tuist, visit https://tuist.io. + +## Card scanning + +[PaymentSheet](https://stripe.com/docs/payments/accept-a-payment?platform=ios) offers built-in card scanning. To enable card scanning, you'll need to set `NSCameraUsageDescription` in your application's plist, and provide a reason for accessing the camera (e.g. "To scan cards"). Card scanning is supported on devices with iOS 13 or higher. + +You can demo this feature in our [PaymentSheet example app](Example/PaymentSheet%20Example). When you run the example app on a device, you'll see a "Scan Card" button when adding a new card. + +## Contributing + +We welcome contributions of any kind including new features, bug fixes, and documentation improvements. Please first open an issue describing what you want to build if it is a major change so that we can discuss how to move forward. Otherwise, go ahead and open a pull request for minor changes such as typo fixes and one liners. + +### Running tests + +1. Install Carthage 0.37 or later (if you have homebrew installed, `brew install carthage`) +2. From the root of the repo, run `bundle install && bundle exec fastlane stripeios_tests`. This will install the test dependencies and run the tests. +3. Once you have run this once, you can also run the tests in Xcode from the `StripeiOS` target in `Stripe.xcworkspace`. Make sure to use the iPhone 12 mini, iOS 16.1 simulator so the snapshot tests will pass. + +## Migrating from older versions + +See [MIGRATING.md](https://github.com/stripe/stripe-ios/blob/master/MIGRATING.md) + +## Code style +We use [swiftlint](https://github.com/realm/SwiftLint) to enforce code style. + +To install it, run `brew install swiftlint` + +To lint your code before pushing you can run `ci_scripts/lint_modified_files.sh` + +You can also add this script as a pre-push hook by running `ln -s "$(pwd)/ci_scripts/lint_modified_files.sh" .git/hooks/pre-push && chmod +x .git/hooks/pre-push` + +To format modified files automatically, you can use `ci_scripts/format_modified_files.sh` and you can add it as a pre-commit hook using `ln -s "$(pwd)/ci_scripts/format_modified_files.sh" .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit` + +## Licenses + +- [Stripe iOS SDK License](LICENSE) diff --git a/Stripe/BuildConfigurations/Stripe Tests-Debug.xcconfig b/Stripe/BuildConfigurations/Stripe Tests-Debug.xcconfig new file mode 100644 index 00000000..6d153e27 --- /dev/null +++ b/Stripe/BuildConfigurations/Stripe Tests-Debug.xcconfig @@ -0,0 +1,8 @@ +// +// Stripe Tests-Debug.xcconfig +// + +#include "../../BuildConfigurations/StripeiOS Tests-Debug.xcconfig" + +SWIFT_OBJC_BRIDGING_HEADER = StripeiOSTests/StripeiOS Tests-Bridging-Header.h +GCC_PREFIX_HEADER = StripeiOSTests/StripeTests-Prefix.pch diff --git a/Stripe/BuildConfigurations/Stripe Tests-Release.xcconfig b/Stripe/BuildConfigurations/Stripe Tests-Release.xcconfig new file mode 100644 index 00000000..b72a89b2 --- /dev/null +++ b/Stripe/BuildConfigurations/Stripe Tests-Release.xcconfig @@ -0,0 +1,8 @@ +// +// Stripe Tests-Release.xcconfig +// + +#include "../../BuildConfigurations/StripeiOS Tests-Release.xcconfig" + +SWIFT_OBJC_BRIDGING_HEADER = StripeiOSTests/StripeiOS Tests-Bridging-Header.h +GCC_PREFIX_HEADER = StripeiOSTests/StripeTests-Prefix.pch diff --git a/Stripe/BuildConfigurations/Stripe-Debug.xcconfig b/Stripe/BuildConfigurations/Stripe-Debug.xcconfig new file mode 100644 index 00000000..c5831ee6 --- /dev/null +++ b/Stripe/BuildConfigurations/Stripe-Debug.xcconfig @@ -0,0 +1,7 @@ +// +// Stripe-Debug.xcconfig +// + +#include "../../BuildConfigurations/StripeiOS-Debug.xcconfig" + +MODULEMAP_FILE = StripeiOS/Stripe.modulemap diff --git a/Stripe/BuildConfigurations/Stripe-Release.xcconfig b/Stripe/BuildConfigurations/Stripe-Release.xcconfig new file mode 100644 index 00000000..662db163 --- /dev/null +++ b/Stripe/BuildConfigurations/Stripe-Release.xcconfig @@ -0,0 +1,7 @@ +// +// Stripe-Release.xcconfig +// + +#include "../../BuildConfigurations/StripeiOS-Release.xcconfig" + +MODULEMAP_FILE = StripeiOS/Stripe.modulemap diff --git a/Stripe/Project.swift b/Stripe/Project.swift new file mode 100644 index 00000000..b43b8c3a --- /dev/null +++ b/Stripe/Project.swift @@ -0,0 +1,147 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project( + name: "Stripe", + options: .options( + automaticSchemesOptions: .disabled, + disableBundleAccessors: true, + disableSynthesizedResourceAccessors: true + ), + packages: [ + .remote( + url: "https://github.com/uber/ios-snapshot-test-case", + requirement: .upToNextMajor(from: "8.0.0") + ), + .remote( + url: "https://github.com/eurias-stripe/OHHTTPStubs", + requirement: .branch("master") + ), + .remote(url: "https://github.com/erikdoe/ocmock", requirement: .branch("master")), + ], + settings: .settings( + configurations: [ + .debug( + name: "Debug", + xcconfig: "//BuildConfigurations/Project-Debug.xcconfig" + ), + .release( + name: "Release", + xcconfig: "//BuildConfigurations/Project-Release.xcconfig" + ), + ], + defaultSettings: .none + ), + targets: [ + Target( + name: "StripeiOS", + platform: .iOS, + product: .framework, + productName: "Stripe", + bundleId: "com.stripe.stripe-ios", + infoPlist: "StripeiOS/Info.plist", + sources: [ + "StripeiOS/Source/**/*.swift", + "StripeiOS/*.docc", + ], + resources: "StripeiOS/Resources/**", + headers: .headers( + public: "StripeiOS/Stripe-umbrella.h" + ), + dependencies: [ + .project(target: "Stripe3DS2", path: "//Stripe3DS2"), + .project(target: "StripeCore", path: "//StripeCore"), + .project(target: "StripeUICore", path: "//StripeUICore"), + .project(target: "StripeApplePay", path: "//StripeApplePay"), + .project(target: "StripePayments", path: "//StripePayments"), + .project(target: "StripePaymentsUI", path: "//StripePaymentsUI"), + ], + settings: .stripeTargetSettings( + baseXcconfigFilePath: "BuildConfigurations/Stripe" + ) + ), + Target( + name: "StripeiOSTests", + platform: .iOS, + product: .unitTests, + productName: "StripeiOS_Tests", + bundleId: "com.stripe.StripeiOSTests", + infoPlist: "StripeiOSTests/Info.plist", + sources: [ + "StripeiOSTests/**/*.swift", + "StripeiOSTests/**/*.m", + ], + resources: .init(resources: [ + "StripeiOSTests/Resources/*.*", + "StripeiOSTests/Resources/Images.xcassets", + .folderReference(path: "StripeiOSTests/Resources/recorded_network_traffic"), + .folderReference(path: "StripeiOSTests/Resources/MockFiles"), + ]), + headers: .headers( + project: "StripeiOSTests/*.h" + ), + dependencies: [ + .xctest, + .target(name: "StripeiOS"), + .package(product: "OHHTTPStubs"), + .package(product: "OHHTTPStubsSwift"), + .package(product: "OCMock"), + .package(product: "iOSSnapshotTestCase"), + .project(target: "StripeCoreTestUtils", path: "//StripeCore"), + .project(target: "StripePaymentsObjcTestUtils", path: "//StripePayments"), + .project(target: "StripePaymentsTestUtils", path: "//StripePayments"), + .project(target: "StripePayments", path: "//StripePayments"), + .project(target: "StripePaymentsUI", path: "//StripePaymentsUI"), + .project(target: "StripePaymentSheet", path: "//StripePaymentSheet"), + ], + settings: .stripeTargetSettings( + baseXcconfigFilePath: "BuildConfigurations/Stripe Tests" + ), + additionalFiles: [ + "StripeiOSTests.xctestplan" + ] + ), + Target( + name: "StripeiOSTestHostApp", + platform: .iOS, + product: .app, + bundleId: "com.stripe.StripeiOSTestHostApp", + infoPlist: "StripeiOSTestHostApp/Info.plist", + sources: "StripeiOSTestHostApp/*.swift", + resources: "StripeiOSTestHostApp/Resources/**", + settings: .stripeTargetSettings( + baseXcconfigFilePath: "//BuildConfigurations/StripeiOS Tests" + ) + ), + Target( + name: "StripeiOSAppHostedTests", + platform: .iOS, + product: .unitTests, + bundleId: "com.stripe.StripeiOSAppHostedTests", + infoPlist: "StripeiOSAppHostedTests/Info.plist", + sources: "StripeiOSAppHostedTests/*.swift", + dependencies: [ + .xctest, + .target(name: "StripeiOS"), + .target(name: "StripeiOSTestHostApp"), + .project(target: "StripePaymentSheet", path: "//StripePaymentSheet"), + ], + settings: .stripeTargetSettings( + baseXcconfigFilePath: "//BuildConfigurations/StripeiOS Tests" + ) + ), + ], + schemes: [ + Scheme( + name: "StripeiOS", + buildAction: .buildAction(targets: ["StripeiOS"]), + testAction: .testPlans(["StripeiOSTests.xctestplan"]) + ), + Scheme( + name: "StripeiOSTestHostApp", + buildAction: .buildAction(targets: ["StripeiOS"]), + testAction: .targets(["StripeiOSAppHostedTests"]), + runAction: .runAction(executable: "StripeiOSTestHostApp") + ), + ] +) diff --git a/Stripe/Stripe.xcodeproj/project.pbxproj b/Stripe/Stripe.xcodeproj/project.pbxproj new file mode 100644 index 00000000..62195469 --- /dev/null +++ b/Stripe/Stripe.xcodeproj/project.pbxproj @@ -0,0 +1,2261 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + 013F991AB34E38BDBA6E4521 /* STPFormViewSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F44327A2B2C9483F52EE343B /* STPFormViewSnapshotTests.swift */; }; + 0185AC6B123CD73E877D4FCE /* STPPaymentMethodCashAppParamsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ABB2CA7E96BE249CE8C0566 /* STPPaymentMethodCashAppParamsTests.swift */; }; + 0305C1689C25B57C43640173 /* STPPaymentMethodBacsDebitTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF1CF8FB0100664A02468FBC /* STPPaymentMethodBacsDebitTest.swift */; }; + 03E60F9EF24C975AF90E2447 /* StripePaymentsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E136A967522048B313E3C62F /* StripePaymentsUI.framework */; }; + 044B7BECFBDB1F6C8CA08514 /* STPSetupIntentConfirmParamsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CC0B1FC92A573AAEA4F4E94 /* STPSetupIntentConfirmParamsTest.swift */; }; + 0684E2ABDA4566356143CC14 /* STPPaymentMethodSofortParamsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 207677E2A0DBC04C88139372 /* STPPaymentMethodSofortParamsTests.swift */; }; + 07A5CDBFDF2340BAD99D6EB3 /* STPCardFormViewSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D983E089196152DA1C69469 /* STPCardFormViewSnapshotTests.swift */; }; + 07BF3CF1656AF5F5A0678873 /* STPPhoneNumberValidatorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 924E878428D15506711CA628 /* STPPhoneNumberValidatorTest.swift */; }; + 08111F4AD3CA0755420E05F7 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 147D2DC1FFDFC99269039377 /* LaunchScreen.storyboard */; }; + 08ED7A4EB7E64FDAED2C2D39 /* STPShippingAddressViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 789D0B49B0788794739E3DD4 /* STPShippingAddressViewControllerTest.swift */; }; + 093FE3D65978E3DB6B79AE05 /* UIToolbar+Stripe_InputAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2307F2C5E53540D4ACAA1F6 /* UIToolbar+Stripe_InputAccessory.swift */; }; + 0B9C0E9A7A750607413C9E53 /* STPFakeAddPaymentPassViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0669B4CA326CE74D125C789C /* STPFakeAddPaymentPassViewController.swift */; }; + 0C5F4AE769D95AA921F61084 /* STPApplePayPaymentOptionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00985EFC6CB7B912FDBF3813 /* STPApplePayPaymentOptionTest.swift */; }; + 0CBBE909CA773D7D45B9AD4C /* STPAnalyticsClientPaymentSheetTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0241DB84973B21393BEC703E /* STPAnalyticsClientPaymentSheetTest.swift */; }; + 0DFA17378D894C70D72C9F62 /* Error+PaymentSheetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE85C770AEEDBE4AEC93EAA /* Error+PaymentSheetTests.swift */; }; + 0F0F35439565AA0D284A6A70 /* STPElementsSessionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63BDA70DB74745C5F457CF88 /* STPElementsSessionTest.swift */; }; + 0FA3C1494BA57884B5DE3B20 /* Stripe.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4259421D2CD26E37B96F97B2 /* Stripe.framework */; }; + 10342D659764A88A695EF38B /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DFA7A75BA785EBBE4C05DAA3 /* Images.xcassets */; }; + 124D43C1A633922B1DA3E1E7 /* STPShippingAddressViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71711FC8E2FB66E52A5FDD9A /* STPShippingAddressViewController.swift */; }; + 14656D177E67594B8C75A9FE /* STPConnectAccountParamsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DEC752CC82CC4BA1E2351 /* STPConnectAccountParamsTest.swift */; }; + 162C101E57D66F0051164C4A /* Stripe.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 4259421D2CD26E37B96F97B2 /* Stripe.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 172D96526023A80534D54CC0 /* STPBankSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81352A0CBE46A59E6B1A712E /* STPBankSelectionViewController.swift */; }; + 17BD7C0391F3182E32A63D6B /* STPAUBECSDebitFormViewSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBEA9E4823F08C1F5057B5A /* STPAUBECSDebitFormViewSnapshotTests.swift */; }; + 194154708E1A9E013DCE2C72 /* STPPaymentHandlerStubbedMockedFilesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF48EC440E1ED5D6BAA567FF /* STPPaymentHandlerStubbedMockedFilesTests.swift */; }; + 1948544E75A2E16E46CBA00E /* STPRedirectContextTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFCF9EB77A45F3E9E83F5D8B /* STPRedirectContextTest.swift */; }; + 1A058C42C4703458CA1CA522 /* STPCardNumberInputTextFieldValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C4773C2D193BEDF1CBB530 /* STPCardNumberInputTextFieldValidatorTests.swift */; }; + 1BC4044802EE7D3E2643DC84 /* STPPaymentIntentEnumsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C31CAD0FA74B58BA2B8530 /* STPPaymentIntentEnumsTest.swift */; }; + 1CCFC43F7FCD273E2100D321 /* STPPaymentMethodBancontactTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E5416F6AE8BED88980D6F8 /* STPPaymentMethodBancontactTests.swift */; }; + 1CD3AB315580606AF87A7B1F /* STPPaymentCardTextFieldKVOTest.m in Sources */ = {isa = PBXBuildFile; fileRef = BB08D2AC882B21C8ADD76B92 /* STPPaymentCardTextFieldKVOTest.m */; }; + 1E8D8E2494062262A332879C /* STPCardValidatorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1644AA33E81233EF33022BA /* STPCardValidatorTest.swift */; }; + 1F417D0874CC86F4C9AB2790 /* STPIntentWithPreferencesTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8FE751333F580004BD72BA /* STPIntentWithPreferencesTest.swift */; }; + 1F432D0B37949217E4299A20 /* STPPaymentOptionsInternalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C4A4CC7D2E9B5AB3EC3B79 /* STPPaymentOptionsInternalViewController.swift */; }; + 225140E0BD9C0630116DDE4A /* STPPaymentMethodUSBankAccountTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B112FFF3FCA82094281493F /* STPPaymentMethodUSBankAccountTest.swift */; }; + 229E25F8DFCC55CA9EDD15AB /* LinkInMemoryCookieStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB30E3846E24FD57A44764A /* LinkInMemoryCookieStoreTests.swift */; }; + 22BE2ABB29F77362FF16D945 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C2427C1CDFA85BFC6570F1E9 /* Localizable.strings */; }; + 234C71F480318E9062075924 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BBCE3A905041A709E8F279A /* AppDelegate.swift */; }; + 23CF725CFAB2ABED416BF416 /* STPApplePayContextFunctionalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CE268457D21A7209862E004 /* STPApplePayContextFunctionalTest.swift */; }; + 23D1246A5DAB5333650F104F /* STPSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E1FED5CE5974C9C1162E93 /* STPSectionHeaderView.swift */; }; + 240993144289CD0DEC2C73C7 /* STPConfirmPaymentMethodOptionsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05FE1BA89B80336F16924FA2 /* STPConfirmPaymentMethodOptionsTest.swift */; }; + 246920234EE8382FB4E56516 /* STPCardFormViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5E08A1651D9DFE502DA021 /* STPCardFormViewTests.swift */; }; + 279D2BA91198E18730626CE6 /* STPUserInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1217AD643A9E8F88B60F645 /* STPUserInformation.swift */; }; + 27F1783CBFEC06BFD6C114F6 /* STPPaymentMethodKlarnaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD94FF270165D699DA89B24 /* STPPaymentMethodKlarnaTests.swift */; }; + 28538CD5885636DC523E8751 /* STPSourceRedirectTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0436A8574E7D0730641407A /* STPSourceRedirectTest.swift */; }; + 29428CDB658E6F504402D844 /* STPPaymentMethodBillingDetailsTests+Link.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AC0A18C441FCA394BEF6A3D /* STPPaymentMethodBillingDetailsTests+Link.swift */; }; + 2A528B7B2579E5F977797822 /* STPPaymentHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC30E6129279F14506219E98 /* STPPaymentHandlerTests.swift */; }; + 2AC91F23CF3949ADC60D27F7 /* STPThreeDSTextFieldCustomizationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A583966A33DCDCF04322A592 /* STPThreeDSTextFieldCustomizationTest.swift */; }; + 2AE9ABA774B430E174279FEA /* stp_test_upload_image.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = FD3398E2352CEA0264F20AEA /* stp_test_upload_image.jpeg */; }; + 2BD45625F6F665B60C6CAD30 /* STPAddressViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A50AFA4603E488FF3D82D0 /* STPAddressViewModel.swift */; }; + 2C6DC246DD12FE0D87156A4D /* STPPaymentMethodPayPalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A63AC868755CB4745E7458E /* STPPaymentMethodPayPalTests.swift */; }; + 2C7991FDF7B374E0E65E253F /* STPPaymentOptionsViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2922A32A754CFC9AB8B48AE /* STPPaymentOptionsViewControllerTest.swift */; }; + 2C9F69E4A384C5743F4EAF69 /* STPPaymentMethodSwishParamsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9631915F03F157A1CC3FEFFE /* STPPaymentMethodSwishParamsTests.swift */; }; + 2CD7968DA48F7129E16EA0CB /* OHHTTPStubsSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 911CA85A1610303FA0AF0643 /* OHHTTPStubsSwift */; }; + 2E35B0FB60FCBE7608080642 /* STPPushProvisioningContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6887F19BB9804BF45FD703FF /* STPPushProvisioningContext.swift */; }; + 2EB68A59660A4D1E14799DA4 /* OHHTTPStubs in Frameworks */ = {isa = PBXBuildFile; productRef = 62887B4538E4E41E735685E1 /* OHHTTPStubs */; }; + 2F0FC4E67BE577AD66CD1475 /* StripePaymentSheet.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DDC55CC034022DFAC9366E2E /* StripePaymentSheet.framework */; }; + 2F18A1903244E144C7802E09 /* STPCardValidationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A9E7B637A8747431B38FD1D /* STPCardValidationState.swift */; }; + 2F9FA9CBCA3C0CE52FAC9B6B /* ConfirmButtonSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 806124200E77795DCFC8418E /* ConfirmButtonSnapshotTests.swift */; }; + 2FFA7C2D1C7337FDB4C608A5 /* STPCardScannerTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B91C4D5B93FF71C61B140F1 /* STPCardScannerTableViewCell.swift */; }; + 307FD6A103EF7AF3CE451598 /* STPPaymentResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D964D9E01627B419B4BD23C /* STPPaymentResult.swift */; }; + 30D48C62B2FA6B28EC23A5BB /* Stripe-umbrella.h in Headers */ = {isa = PBXBuildFile; fileRef = A8598727045C6268B57A5FC7 /* Stripe-umbrella.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 315713352C770DA3ED9CBDCD /* Enums+CustomStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F7E3CE2105E4A39032CD919 /* Enums+CustomStringConvertible.swift */; }; + 3172C789DF2CE133ECA359D7 /* STPPushProvisioningDetailsFunctionalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A8A6B88797870BC71CCB3AF /* STPPushProvisioningDetailsFunctionalTest.swift */; }; + 319899DEC91B3F88D380DB47 /* STPPaymentMethodGrabPayParamsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8FF64CA314F909D7EC82FE /* STPPaymentMethodGrabPayParamsTest.swift */; }; + 325694E4284BEAE787A5ECB6 /* NSLocale+STPSwizzling.swift in Sources */ = {isa = PBXBuildFile; fileRef = C641A744CCEA67C07E9BFE05 /* NSLocale+STPSwizzling.swift */; }; + 32874C6147344A9CB2EF4DAD /* Stripe3DS2.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 33FDC634FD5D79E824240DDC /* Stripe3DS2.framework */; }; + 331924F0801287BAD413FDCB /* STPMandateCustomerAcceptanceParamsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C713F58BC61A962C720AE0AE /* STPMandateCustomerAcceptanceParamsTest.swift */; }; + 33DF66640B5ABBCB12B46AFE /* STPPaymentMethodCardWalletVisaCheckoutTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82E46B18CDDC191934F3D4BE /* STPPaymentMethodCardWalletVisaCheckoutTest.swift */; }; + 35C1CF73701EECC7DB6AB722 /* FormSpecProviderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C04AFC9CDE50D09D38A3232 /* FormSpecProviderTest.swift */; }; + 35E05040EA813C3B9C8EF054 /* STPViewWithSeparatorSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86983B09A1712944EC012AD4 /* STPViewWithSeparatorSnapshotTests.swift */; }; + 360EEE8B706D2A4A49666F7A /* StripePayments.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 22D1C6EB5826E2D7C80B6CF3 /* StripePayments.framework */; }; + 37E9160706C9EEEFEF133617 /* STPPaymentIntentFunctionalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDDEAB86BE4711841D426F3B /* STPPaymentIntentFunctionalTest.swift */; }; + 37FBCED5F71F03483EA73F27 /* STPPaymentMethodOXXOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80BBCC4D386EE44E809A591C /* STPPaymentMethodOXXOTests.swift */; }; + 385CAC4D2FF119D2E925916B /* STPPostalCodeInputTextFieldFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 917154477796779ECFA1334A /* STPPostalCodeInputTextFieldFormatterTests.swift */; }; + 3930ECBEE003772C1245D25B /* UIViewController+Stripe_ParentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5804C2B9C0704E386B3D25A4 /* UIViewController+Stripe_ParentViewController.swift */; }; + 39B1B88B8506BE4574E6B376 /* STPBankAccountFunctionalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989411FA3CD0CCC38BC227F4 /* STPBankAccountFunctionalTest.swift */; }; + 3AAE488F2461A46143B3A687 /* STPPaymentConfigurationTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 79ABE6A14AF9D14103050876 /* STPPaymentConfigurationTest.m */; }; + 3AD22E0BD44B02D968C6569A /* STPImageLibraryTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F546088BA4F763334CFD3D34 /* STPImageLibraryTest.swift */; }; + 3B237145902E3DB07E747E32 /* TextFieldElement+IBANTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BD2CE41E4F0CF648F44E4A /* TextFieldElement+IBANTest.swift */; }; + 3C1A7B9810B038177FF1CF52 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F29C15B47C7CB0941CD4C9E /* ViewController.swift */; }; + 3C1E9069CD03ED9981D7F3E2 /* ConfirmButtonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFFE40AD9D875709F643D2E5 /* ConfirmButtonTests.swift */; }; + 3CE88568CB9648D6F1503B88 /* STPSource+BasicUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B75875C55D2C2723DC5090 /* STPSource+BasicUI.swift */; }; + 3EA9D509E59DA65EE4EDF98D /* STPAddressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39580123A4F1EA96F91768A /* STPAddressTests.swift */; }; + 3EB3745F556EA12AB27A8545 /* APIRequestTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85AAB72218409F85FE29E69E /* APIRequestTest.swift */; }; + 3FA556CF8B11E2486F505161 /* UIViewController+Stripe_NavigationItemProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F017A08C7E633FB4297D274 /* UIViewController+Stripe_NavigationItemProxy.swift */; }; + 3FD5ABC45AF3A03F4EFE196F /* STPSourceFunctionalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17013F78CE3F9662029FEF5B /* STPSourceFunctionalTest.swift */; }; + 3FECC97E8E7C781B978CA19B /* STPCardBrandChoiceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2177A76093365DC8CD6EA05E /* STPCardBrandChoiceTest.swift */; }; + 4059301B0365BD4220E591FB /* STPPaymentMethodSwishTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DE9F0BAC35EA14579775033 /* STPPaymentMethodSwishTests.swift */; }; + 420F8CAB4FAD6D9AF4AF25C0 /* StripePaymentSheet.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DDC55CC034022DFAC9366E2E /* StripePaymentSheet.framework */; }; + 42395EF962DB8AD6A094630B /* StripePaymentSheet.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DDC55CC034022DFAC9366E2E /* StripePaymentSheet.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 429DBA641E926EBC2D049FE7 /* STPCustomerContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB8FCDBC63A79CD1571A2DFB /* STPCustomerContext.swift */; }; + 42F18560F3DC6980408AF051 /* STPPaymentMethodPayPalParamsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE2A766FB355DD9C461939C1 /* STPPaymentMethodPayPalParamsTests.swift */; }; + 43FFF2881D4EFA7B57A60E09 /* STPPaymentMethodCardTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD02D8298877F10F2EF2A9D /* STPPaymentMethodCardTest.swift */; }; + 44672917D3AC4B83F9EC3BC3 /* UIView+Stripe_SafeAreaBounds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86798C95A778362EF815B4C6 /* UIView+Stripe_SafeAreaBounds.swift */; }; + 446A108C8EB6C338A1D774F8 /* STPPaymentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18FCB69CD3B8C3DAB216A5F0 /* STPPaymentConfiguration.swift */; }; + 447C19BDB2CF5445045F81F7 /* STPPaymentContextAmountModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C876DC7F3E31D7189506A8 /* STPPaymentContextAmountModel.swift */; }; + 450FAE41FB4538462D05F2E4 /* LinkSignupViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C7D85A7FAAFDF4F59BA85E /* LinkSignupViewModelTests.swift */; }; + 45FA9B8CC2D18E29BE81CF8F /* STPIntentActionAlipayHandleRedirectTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD06AED0AF8A9A7FB4A2E66F /* STPIntentActionAlipayHandleRedirectTest.swift */; }; + 460B31EDB22BD6B912567363 /* STPSourceVerificationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E89551702F1F9A3AFF1ED676 /* STPSourceVerificationTest.swift */; }; + 46FF3CC61200F2C27D4F3369 /* STPSourceReceiverTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B7A947152A728EB2CBC4DB2 /* STPSourceReceiverTest.swift */; }; + 492F7C4DABB4CE8EBE34EEF2 /* STPConnectAccountAddressTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967DDC94B687B14E07842CC8 /* STPConnectAccountAddressTest.swift */; }; + 4935C8B3ECFBAD947E694934 /* STPIntentActionTypeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F205F920E971DEA59E3C31 /* STPIntentActionTypeTest.swift */; }; + 4993037E5386D0AF87B24871 /* STPPaymentMethodAffirmParamsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87817A1D3D213AA4ADF6A4C /* STPPaymentMethodAffirmParamsTest.swift */; }; + 4A61DC36F10B9C9C24345613 /* STPRadarSessionFunctionalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D878F923A1F69B58D6B2812 /* STPRadarSessionFunctionalTest.swift */; }; + 4AAA2CD5AEF1F913395B3B95 /* STPPaymentMethodUSBankAccountParamsStubbedTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF13BAEF86594C9CABD4F42A /* STPPaymentMethodUSBankAccountParamsStubbedTest.swift */; }; + 4B0917FC15BF56D0100E0ED1 /* STPGenericInputTextFieldSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A53F005EA8FDDAA66126BA /* STPGenericInputTextFieldSnapshotTests.swift */; }; + 4C3B161481D11385352B06D4 /* STPCustomerContextTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC4B06AB5C02FF54091E5A8 /* STPCustomerContextTest.swift */; }; + 4E09E54E7FEC35C49C59A379 /* STPPushProvisioningDetailsParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B28B8A547CD846277ECD578 /* STPPushProvisioningDetailsParams.swift */; }; + 4E31B1864DA407598FB1BBC6 /* STPPostalCodeInputTextFieldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6618739767139C25C05B3631 /* STPPostalCodeInputTextFieldTests.swift */; }; + 4ED44ACF24949F516867235C /* STPPaymentOptionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6215A9BF343775B1BD0F62AF /* STPPaymentOptionTableViewCell.swift */; }; + 4EFF8B46B12DA4D9AAB22523 /* STPPaymentCardTextFieldCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B42BE6FB5EC1F708875AB8 /* STPPaymentCardTextFieldCell.swift */; }; + 4FB67F10A0B7106A8142B842 /* STPEphemeralKeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588C260880FFC584A00A89F5 /* STPEphemeralKeyManager.swift */; }; + 51044B947A7FDB99451466D8 /* STPGenericInputPickerFieldSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 284C67269D2606DA147AE01D /* STPGenericInputPickerFieldSnapshotTests.swift */; }; + 5170651536332C4842E9D009 /* STPPaymentMethodBoletoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E371E9B3B2E343FE954531C /* STPPaymentMethodBoletoTests.swift */; }; + 51D515315F02D4C03BA12366 /* UserDefaults+StripeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B0E131538728BC4802627B1 /* UserDefaults+StripeTest.swift */; }; + 5212C7875C07F9BF16AFD98D /* STPAPIClient+PushProvisioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = 807FF966F1DE05F3496B817B /* STPAPIClient+PushProvisioning.swift */; }; + 524AE1978E0A4490D1C390C5 /* CustomerAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11C866064B3482878A69892F /* CustomerAdapterTests.swift */; }; + 5302F9246A4A6381CB4FB874 /* StripePaymentsTestUtils.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 77247622AB08FEF48CA0DC26 /* StripePaymentsTestUtils.framework */; }; + 5370700ED1F630E8261507D3 /* STPBankAccountParamsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967C784618A074FF021B3089 /* STPBankAccountParamsTest.swift */; }; + 542610492B38FEB652C6823E /* String+Localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5398E1156E0BFEBBF56FD2F /* String+Localized.swift */; }; + 54331380F5AC68846DBE94D5 /* UITableViewCell+Stripe_Borders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5476BD87E0480A93958F0328 /* UITableViewCell+Stripe_Borders.swift */; }; + 58240AA66FAE55131268E4A0 /* STPAddressFieldTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72903593DC432D01720DC9D9 /* STPAddressFieldTableViewCell.swift */; }; + 583DE9869C885BA02E0A071E /* STPAUBECSFormViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AAE5EE11611F9F7762B64C6 /* STPAUBECSFormViewModelTests.swift */; }; + 58A8B3F57FE98C22D8F90C77 /* STPThreeDSButtonCustomizationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F3B966470A530E0DC53F8C /* STPThreeDSButtonCustomizationTest.swift */; }; + 590DB84AC15709E3C6F1FC3B /* STPThreeDSNavigationBarCustomizationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51408DE266D0345784ADD4FA /* STPThreeDSNavigationBarCustomizationTest.swift */; }; + 5910FCB9822259D5EC7E4051 /* AutoCompleteViewControllerSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFD2F6A5A046A620BAB75B41 /* AutoCompleteViewControllerSnapshotTests.swift */; }; + 5B6F1BF973FC2D8DD6127B7F /* StripePaymentsUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E136A967522048B313E3C62F /* StripePaymentsUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5C51167CC14F653E7117BA61 /* OCMock in Frameworks */ = {isa = PBXBuildFile; productRef = E804AA8C4156CC85FFD9595F /* OCMock */; }; + 5C5E1CE53D89DE8F0B867115 /* STPPaymentMethodSofortTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8063E8073A32E0B081A1DFA /* STPPaymentMethodSofortTests.swift */; }; + 5CA66791F526C266CE72A6A8 /* IntentConfirmParamsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436074876478126262BD05C0 /* IntentConfirmParamsTest.swift */; }; + 5D1A9F97F79DAA3F46C82A28 /* LinkAccountServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC7B7ED388B6C6AE57D3D627 /* LinkAccountServiceTests.swift */; }; + 5D6B52EB4D7258129F134D07 /* STPImageLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93C23F55BEADF9BC74DFBDB /* STPImageLibrary.swift */; }; + 5D7F632025C261B88F0C2016 /* STPFPXBankBrandTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85C29AE44D809CD677B5E52B /* STPFPXBankBrandTest.swift */; }; + 5D9EB3E2725C38D7098B9965 /* STPPaymentMethodParamsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4002981AC12687681616D21E /* STPPaymentMethodParamsTest.swift */; }; + 5E498CDA0115CF9F8463C566 /* STPPaymentMethodAUBECSDebitParamsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FF2B9FF57E100301B5C38DB /* STPPaymentMethodAUBECSDebitParamsTests.swift */; }; + 5E5EE69D140F6FEDA5F0A346 /* STPAPIClientTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8DD70E5ED8E9DE8E9752C9E /* STPAPIClientTest.swift */; }; + 5ECED204FD22CFEA3A806767 /* STPPaymentOptionTuple.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDD30E5DB8DB3AA3567F5C20 /* STPPaymentOptionTuple.swift */; }; + 605EFBDD21426FD30581563F /* STPAnalyticsClient+BasicUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12DBB3F72AEFB52DE27C27ED /* STPAnalyticsClient+BasicUI.swift */; }; + 609C2C8F10AFAA2711639CD0 /* NSArray+StripeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17799DC7FA54E758EED31A6 /* NSArray+StripeTest.swift */; }; + 609E4D384B75F6A111DC0E27 /* STPPaymentActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FFBAA4B44967B157A4F4E91 /* STPPaymentActivityIndicatorView.swift */; }; + 62B91808A088C4F9FDB62C53 /* STPEphemeralKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 890660C21E3666CE7B82695B /* STPEphemeralKey.swift */; }; + 64801CF2D2CAC9008C17D154 /* LinkSecureCookieStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10BAD33BEC6C2894F9266902 /* LinkSecureCookieStoreTests.swift */; }; + 66065B1D65D7D5502D4E2F2B /* STPCard+BasicUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E5FB20B2BEFC00D54FDD87D /* STPCard+BasicUI.swift */; }; + 66B7EF2DC1CBF813707C767C /* STPBSBNumberValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C732C25FD961631BD44FDD /* STPBSBNumberValidatorTests.swift */; }; + 66C38EA9CFB1ED4DF2F974BF /* STPPaymentOptionsViewControllerLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7094644EE056260D6F3B67 /* STPPaymentOptionsViewControllerLocalizationTests.swift */; }; + 68318DB86DFCD19505FC47BA /* NSURLComponents_StripeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CD20E00EAD41091B71ABD5 /* NSURLComponents_StripeTest.swift */; }; + 687517E7FE02FFB96DCE2328 /* STPEphemeralKeyManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C645F78B3EFFAA083B6FD3E9 /* STPEphemeralKeyManagerTest.swift */; }; + 69AC1EDE2A3C03B1D980CA54 /* STPPaymentOptionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A1BB31C7B514984231125B /* STPPaymentOptionsViewController.swift */; }; + 6BF6ECC4A4E61E2FFC3EA20B /* STPAPIClient+BasicUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8AFAE24610EC983727F860 /* STPAPIClient+BasicUI.swift */; }; + 6E7AD3CCC966A7F34922B172 /* NSDictionary+StripeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07275F94914B7E7937D24FE /* NSDictionary+StripeTest.swift */; }; + 6EDFC83541EED9E361B71C02 /* STPCustomerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB817ED5B5DB87AE1290894 /* STPCustomerTest.swift */; }; + 6EF3F611E6EA3CB479D62450 /* AfterpayPriceBreakdownViewSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C742844915B96CFD25BFFF9 /* AfterpayPriceBreakdownViewSnapshotTests.swift */; }; + 6F4FBB4F10B5DB2CF8BB3460 /* STPBinRangeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC0560A312147C37CFE6CF9 /* STPBinRangeTest.swift */; }; + 6F9525063D76A9F86A10CCBF /* STPApplePayContextFunctionalTestExtras.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1272F2E05A0E294DD9ECA26 /* STPApplePayContextFunctionalTestExtras.swift */; }; + 6FCA954C32AB351F902BA876 /* STPPostalCodeValidatorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF7394DDD552EDE996EAD8E /* STPPostalCodeValidatorTest.swift */; }; + 701C464523173C6809544935 /* STPThreeDSUICustomizationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED44491EB0AC72B1B1A773C /* STPThreeDSUICustomizationTest.swift */; }; + 71116C2D5831E271E12DB059 /* ServerErrorMapperTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20879436DCFB1F03BE1608B3 /* ServerErrorMapperTest.swift */; }; + 73AFE2A8839EFAB8330F6CF0 /* STPPaymentIntentTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACE8998EAF997A78759E49B5 /* STPPaymentIntentTest.swift */; }; + 7435E6BB6971012A9B0DB52E /* STPErrorBridgeTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 9EAE9A2AE65771403CE57C11 /* STPErrorBridgeTest.m */; }; + 7589E37795D21AB818B0C333 /* STPAnalyticsClient+Payments.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD89580A3E41D7167C30B287 /* STPAnalyticsClient+Payments.swift */; }; + 7623057AC6AC5369DCD94E84 /* STPPaymentMethodCardWalletTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DC4B62C336EAA05A33FC384 /* STPPaymentMethodCardWalletTest.swift */; }; + 76BC927BC7A591601C1DAB18 /* StripeiOS.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 77E846CD56018D8417A3AB95 /* StripeiOS.xcassets */; }; + 77C0FD1BCDA7BBFB88559B44 /* STPPaymentCardTextFieldTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939360978872BBE4334215B1 /* STPPaymentCardTextFieldTest.swift */; }; + 781EC0163AC001C6A66045B6 /* STPMandateDataParamsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F7AA0B7B86BA5BB2FE92CE /* STPMandateDataParamsTest.swift */; }; + 7844BB705AEB002965EF82B0 /* STPPaymentMethodKlarnaParamsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E12A0CBFA259A032F7AF0C /* STPPaymentMethodKlarnaParamsTests.swift */; }; + 78641CE4011A1C1EE6E35DC5 /* STPIntentActionPromptPayDisplayQrCodeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9466F23BA8712EA2EDA48BBD /* STPIntentActionPromptPayDisplayQrCodeTest.swift */; }; + 786C30837EAD918EDE52284E /* STPCardBrandTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901BE31021AF27DB5D326327 /* STPCardBrandTest.swift */; }; + 78B70C2EE8334F0FA91439CA /* Stripe.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4259421D2CD26E37B96F97B2 /* Stripe.framework */; }; + 795F3783D62AB8E2A00DCD05 /* ConsumerSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6312182B5BCAB940D216650 /* ConsumerSessionTests.swift */; }; + 7A9D7D156B5053638F9B21E1 /* STPPaymentMethodThreeDSecureUsageTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 916DB8789F65D3C1BCB510C0 /* STPPaymentMethodThreeDSecureUsageTest.swift */; }; + 7B9C0D039EA9EF593AEC682D /* STPShippingMethodTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98544B08552407D41D398C68 /* STPShippingMethodTableViewCell.swift */; }; + 7BC98BE168781C5B3EC8A8DB /* STPPaymentMethod+BasicUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C1E9B0EE03825DABF6471A /* STPPaymentMethod+BasicUI.swift */; }; + 7D251ABF1EBF65ACA8A4BDD4 /* STPPaymentMethodUSBankAccountParamsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FDEF9F687C63BADFB96480 /* STPPaymentMethodUSBankAccountParamsTest.swift */; }; + 7D2C0D1BF455625997CBC33B /* STPAPIClientNetworkBridgeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9BA8D8467218C7E691C9FAE /* STPAPIClientNetworkBridgeTest.swift */; }; + 7EAA7334372DBC38DF8FA0AA /* STPPinManagementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EBB07171F6FDCE6E20C454A /* STPPinManagementService.swift */; }; + 7F235CD649F6E97E4E7DD180 /* UIView+Stripe_FirstResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B76DF0FE363F59BF0940A8B /* UIView+Stripe_FirstResponder.swift */; }; + 7F9D08AC5A448C7693162D7D /* STPShippingMethodsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21E4B84223DDA131544DBBA7 /* STPShippingMethodsViewController.swift */; }; + 801F417CE53689B95C4A098B /* STPBankAccountTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D103BC590F1E0EC0C31C7B5F /* STPBankAccountTest.swift */; }; + 812682EA323986B8F698FF3C /* STPPaymentMethodParams+BasicUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53C5AB22D6328E85A6DDF663 /* STPPaymentMethodParams+BasicUI.swift */; }; + 829D43B6705D125FEC9926DA /* STPPaymentContextApplePayTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C995125252BED1EEC018B9D /* STPPaymentContextApplePayTest.swift */; }; + 8378F2A4B0796819BB1C6C54 /* STPPaymentMethodCardParamsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1671EC46C713D51013AD7D8B /* STPPaymentMethodCardParamsTest.swift */; }; + 8520A27C204A068C43592024 /* StripeApplePay.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 52F8AEC50D4623F80F04A533 /* StripeApplePay.framework */; }; + 8532FEBF4F2E0EB282D466CE /* STPGenericInputPickerFieldValidatorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D63B73C5773432CA134D1FC /* STPGenericInputPickerFieldValidatorTest.swift */; }; + 86BAF121184D71F5F4FFAD7B /* STPPaymentCardTextFieldTestsSwift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F16C36797D978E72E612100 /* STPPaymentCardTextFieldTestsSwift.swift */; }; + 87AACDD643A998FFDD505D22 /* KlarnaHelperTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D866DCC403994A0D3CFB1D7 /* KlarnaHelperTest.swift */; }; + 8AA84A3A52A3D79BCA8C8994 /* STPUIVCStripeParentViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88639E3C3AC622B5EF475538 /* STPUIVCStripeParentViewControllerTests.swift */; }; + 8B80FB6FC88D411A90E9D487 /* WalletHeaderViewSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DB03E83746FE78361831546 /* WalletHeaderViewSnapshotTests.swift */; }; + 8C977F8D224A7360AE8E15A7 /* STPPaymentMethodBoletoParamsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C67AC5D415615E9F27D3E3 /* STPPaymentMethodBoletoParamsTests.swift */; }; + 8E423294AB602BF25DB11D8E /* OperationDebouncerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A61763BA2CCA86F9B8FD4F1F /* OperationDebouncerTests.swift */; }; + 8EC1820299152F8565D30A40 /* STPCardCVCInputTextFieldSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1859673CAD068B345F5DD7D /* STPCardCVCInputTextFieldSnapshotTests.swift */; }; + 8F0326E98C74EB62E34B9FEA /* STPIntentActionWeChatPayRedirectToAppTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA5D11C977A95B8E936E907 /* STPIntentActionWeChatPayRedirectToAppTest.swift */; }; + 8F5AF9D3566B8DBCA5AB5188 /* STPPaymentHandlerFunctionalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACF450AD17FF7BCE5916DDF1 /* STPPaymentHandlerFunctionalTest.swift */; }; + 903FFB756C6ED520BE38EF6F /* STPInputTextFieldValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEE395C4DD0E0112AF3720C /* STPInputTextFieldValidatorTests.swift */; }; + 91558F51B87C72E745244958 /* STPPostalCodeInputTextFieldValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9975553E669AF69F3CE437 /* STPPostalCodeInputTextFieldValidatorTests.swift */; }; + 91A839DEDA7D1EAF6FC66BE0 /* STPFormEncoderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30964128998473CAA9F2DD7E /* STPFormEncoderTest.swift */; }; + 922C0DF37F5AAA29375A5454 /* STPSourceOwnerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 095EBF095BA2BC8D299547DB /* STPSourceOwnerTest.swift */; }; + 9291A08CCB34504FCA4B7481 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 005650A59D692F820EF20F5F /* XCTest.framework */; }; + 9363F8F389C04C19B37D0F0A /* StripePaymentsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E136A967522048B313E3C62F /* StripePaymentsUI.framework */; }; + 951344464ACF84F0F6D43D10 /* OneTimeCodeTextFieldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F9CB667BC68767DFB5FACD /* OneTimeCodeTextFieldTests.swift */; }; + 9535CADFFBC9E1FA291E947E /* STPPIIFunctionalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1645D9793463E266501B74FD /* STPPIIFunctionalTest.swift */; }; + 96098727EFA6A72087A35A52 /* STPFormTextFieldTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E1862744F23286D1FB9D4AE /* STPFormTextFieldTest.swift */; }; + 97756805F41DDB51B3ED0326 /* STPPaymentContextSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B86F3355E44DF4A980B82C /* STPPaymentContextSnapshotTests.swift */; }; + 98E2332DE7F54E970BE5EEF7 /* UIBarButtonItem+Stripe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3000668A75E095B514241F /* UIBarButtonItem+Stripe.swift */; }; + 98EE8326C1D133E1C998114F /* STPLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFF957F38AABE5F748C38C0B /* STPLocalizedString.swift */; }; + 9A24970C5FB6D3F7314AE550 /* STPAPIClientStubbedTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4194E605BB5F31E9CBB8F96 /* STPAPIClientStubbedTest.swift */; }; + 9B149DA42FB38C3542E0CB4B /* STPApplePayFunctionalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C77C7BC4BA57EC296CF2F1C /* STPApplePayFunctionalTest.swift */; }; + 9B1AC278FDCDABF26C5E468C /* STPPostalCodeInputTextFieldSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090EF7D598B8DE779C275395 /* STPPostalCodeInputTextFieldSnapshotTests.swift */; }; + 9C13E8A017A4E23BCCDE618B /* UINavigationController+Stripe_Completion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C078573F46762353664AC92 /* UINavigationController+Stripe_Completion.swift */; }; + 9D464A252FBD0D4E2A0A7398 /* STPCountryPickerInputFieldSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D49257A97E71A475A9F6E08 /* STPCountryPickerInputFieldSnapshotTests.swift */; }; + 9D8354BDB04CEC5D1EFCF54F /* STPSwiftFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = E315168EF07F52B733EA77F8 /* STPSwiftFixtures.swift */; }; + 9D9692DFC4F06F8C70145000 /* UINavigationBar+Stripe_Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E369CEC9F5B3758F78E88F /* UINavigationBar+Stripe_Theme.swift */; }; + 9FB20E559379F468070C7B50 /* STPLabeledFormTextFieldViewSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78D1EEABBAE5BD5615486B0F /* STPLabeledFormTextFieldViewSnapshotTests.swift */; }; + 9FD92B3ADEBEC96660B70409 /* STPMocks.m in Sources */ = {isa = PBXBuildFile; fileRef = 88AEABC15CCBB9EA393C175F /* STPMocks.m */; }; + A01BB7F09134F7081679F9C4 /* STPPaymentMethodUPIParamsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E5E1173A37AABB07FB68AB /* STPPaymentMethodUPIParamsTest.swift */; }; + A08C2F0E7F642515B1D263ED /* STPPaymentMethodTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 148C1D7D1BBBC6B74894A869 /* STPPaymentMethodTest.swift */; }; + A0AA0B8AEF5B429858D71F6B /* STPBlocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B42EBAC0DC7ED0D9200DB7 /* STPBlocks.swift */; }; + A12CFA90DAE8BBB39A8C7AA1 /* STPCardFunctionalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D38184A7CD27B978DFA30E69 /* STPCardFunctionalTest.swift */; }; + A22D548084E7DE1FE5ABE8E7 /* STPLabeledMultiFormTextFieldViewSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F68637E75142DCD46710796 /* STPLabeledMultiFormTextFieldViewSnapshotTests.swift */; }; + A66C279957B6AC8F72DE05C7 /* STPCardExpiryInputTextFieldSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C75157665428685C7A4FD20 /* STPCardExpiryInputTextFieldSnapshotTests.swift */; }; + A77C5769B20D7884FC8FC4FB /* STPNumericDigitInputTextFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6223E57D3A198F956A37ED89 /* STPNumericDigitInputTextFormatterTests.swift */; }; + A77EC5CE65161573062E9F98 /* STPShippingAddressViewControllerLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE3D0F21FB3B3E8BCA15FF7C /* STPShippingAddressViewControllerLocalizationTests.swift */; }; + A781FB0F586B26655FAEC3C0 /* STPCertTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D51B04D83D4FEF7F90DF16A /* STPCertTest.swift */; }; + A7A1D3C0D75DCD7217D297FF /* STPShippingMethodsViewControllerLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7F54E1E8FFA1505D24538A6 /* STPShippingMethodsViewControllerLocalizationTests.swift */; }; + A8B0DB753CAA2223C8BED099 /* StripeErrorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3AD586DDED620B9E68F461 /* StripeErrorTest.swift */; }; + AC35943F1EAD50E9D5D509B3 /* STPCardExpiryInputTextFieldFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40BB87E28719FE0C6B946BB5 /* STPCardExpiryInputTextFieldFormatterTests.swift */; }; + AC7C127B11A60222465F4696 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 005650A59D692F820EF20F5F /* XCTest.framework */; }; + ACC1B91FC687AFD0DFD27CD4 /* STPIntentActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33B9D01D037909D1C9C0B617 /* STPIntentActionTest.swift */; }; + ACF6CFE0F8B88FDBBB16968C /* FraudDetectionDataTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FF7A07CFC3B9B7AD6B49EE /* FraudDetectionDataTest.swift */; }; + AD09F2ACD0CDCBD414AC30AD /* STPAPISettingsObjCBridgeTest.m in Sources */ = {isa = PBXBuildFile; fileRef = B669C53A601CD3CB0203A4B9 /* STPAPISettingsObjCBridgeTest.m */; }; + AD9B9F3FF697D4A3892E86F2 /* PaymentAnalyticTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F8FCC84601E4ADC6B7F3CE /* PaymentAnalyticTest.swift */; }; + AE747ADA2841AA06F32558D8 /* STPSourceCardDetailsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63114D0EAAE2606732DF5AA0 /* STPSourceCardDetailsTest.swift */; }; + AF18D569B296BFC1EB5A7338 /* ImageTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAF368BCD5990EE5DC17D299 /* ImageTest.swift */; }; + AF23CB4EF17E87007CFC3E96 /* STPFPXBankStatusResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA08DCDD421CE92ECB61EF5C /* STPFPXBankStatusResponse.swift */; }; + AF44725558E654548FED2A2B /* STPPaymentMethodUPITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940209E5D30E86E856016906 /* STPPaymentMethodUPITests.swift */; }; + B00F7FC372E376C6B2170D37 /* STPPaymentContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C750C2C4AB33BC232D1592BA /* STPPaymentContext.swift */; }; + B1BF689B91D538BDCA4C8578 /* STPCoreViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FC9ED423D40C88D5A24441 /* STPCoreViewController.swift */; }; + B359F6DCB31EAD0814AD9AFD /* STPPaymentMethodSEPADebitTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A41F721AEBB942BB81408A59 /* STPPaymentMethodSEPADebitTest.swift */; }; + B44E4CF6C65522F80C946775 /* STPPaymentMethodFunctionalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8192839B0F1AE9D9F2A94504 /* STPPaymentMethodFunctionalTest.swift */; }; + B4719234E4BBDAD260E31373 /* STPPaymentCardTextFieldViewModelTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6DEE912364C9F4B51B374D0 /* STPPaymentCardTextFieldViewModelTest.swift */; }; + B6656829DEC006DBEED2AA0E /* STPEphemeralKeyTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5BEAA15B53AC5662A33D0E1 /* STPEphemeralKeyTest.swift */; }; + B6784B7F4B9B04617C0EE510 /* PKAddPaymentPassRequest+Stripe_Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 273EE407039913F0B644172B /* PKAddPaymentPassRequest+Stripe_Error.swift */; }; + B6B89E3F7DE0811BD5CB9D31 /* STPAddCardViewControllerLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78BA15D1F0844B1DA71A5348 /* STPAddCardViewControllerLocalizationTests.swift */; }; + B71F04D02538FA1723558C48 /* STPPaymentMethodiDEALTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B83930B631CF8EADFB606D6 /* STPPaymentMethodiDEALTest.swift */; }; + B795A5EB8FDECA1060A9655C /* iOSSnapshotTestCase in Frameworks */ = {isa = PBXBuildFile; productRef = C55551F29B99CF6D6DD9EE2F /* iOSSnapshotTestCase */; }; + B82859A4444B9F735720F232 /* STPMandateOnlineParamsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB58AC5E2E0A68221260FD44 /* STPMandateOnlineParamsTest.swift */; }; + B8385576DC25BDEEB92D812F /* STPEphemeralKeyProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 485E747DA1F72F091986787B /* STPEphemeralKeyProvider.swift */; }; + B86EE8C85E6AB6B0A34C1887 /* STPTokenTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F090B8D315E7FD12A5F9C09 /* STPTokenTest.swift */; }; + B8ED1F697519A6FCD3D79431 /* STPPaymentMethodGiropayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 583DB466066B47C0F716E474 /* STPPaymentMethodGiropayTests.swift */; }; + B917BF282C84507292112B9D /* STPCardBINMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E1C5E08678292561255B1C5 /* STPCardBINMetadataTests.swift */; }; + B98D71ED9ACC2E1B47372F53 /* NSDecimalNumber+StripeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20552E792B8E7BA15821AB5D /* NSDecimalNumber+StripeTest.swift */; }; + BAFD06E994739E1C38DFFBBC /* STPCardScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69BD038947E8E2376A0D240B /* STPCardScanner.swift */; }; + BB46077C256C26418420F240 /* STPAddCardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA793904C7B2D3AA0A4D5EFB /* STPAddCardViewController.swift */; }; + BBB734F006FAD749678B87D1 /* STPPaymentMethodRevolutPayParamsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE7BEADD3824A06C2994854 /* STPPaymentMethodRevolutPayParamsTests.swift */; }; + BC6912C0DE15008C8D8C303C /* STPFloatingPlaceholderTextFieldSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7385193226663A5B79E69ED /* STPFloatingPlaceholderTextFieldSnapshotTests.swift */; }; + BC694A1642DC30D530B60635 /* RotatingCardBrandsViewSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA32D0C9E8A7A69F4899EDC /* RotatingCardBrandsViewSnapshotTests.swift */; }; + BEC0435570B9199B918ED4DA /* STPAPIClient+LinkAccountSessionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E956CFA6317CAA8B41E217CA /* STPAPIClient+LinkAccountSessionTest.swift */; }; + BEC5B2ACC54FB72DEBFB70AB /* STPPaymentMethodBancontactParamsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 655209238C85F466F9F14F14 /* STPPaymentMethodBancontactParamsTests.swift */; }; + BF4A92064926FD7B0E3E92F7 /* StripePayments.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 22D1C6EB5826E2D7C80B6CF3 /* StripePayments.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + BF4ED4828114E2E89A3D4AB7 /* StripeBundleLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D31B0D7BD9F97AF3BB61E6 /* StripeBundleLocator.swift */; }; + C035D82D7096E3005858848C /* StripeCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 43B4E4B85C598D7A9AFCB4D4 /* StripeCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C0688E067AE4FFDFFDDC03BB /* PKPaymentAuthorizationViewController+Stripe_Blocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5D9F97ABC88302478220267 /* PKPaymentAuthorizationViewController+Stripe_Blocks.swift */; }; + C0B59D0A7025A55ECD948D47 /* STPPaymentMethodNetBankingParamsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D41081066DC4465734F7FCD7 /* STPPaymentMethodNetBankingParamsTest.swift */; }; + C1E70FD29BBE36D76A7E6929 /* CardExpiryDateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 153071C69A0BEE033E035DCF /* CardExpiryDateTests.swift */; }; + C314A5C55064C51C2B999E6B /* STPBackendAPIAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B782E1D974A4C131E60E2BD /* STPBackendAPIAdapter.swift */; }; + C32D7ACEBC852CBC295BBEF2 /* STPSetupIntentFunctionalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49AA313E068FB99CEAA5F7D3 /* STPSetupIntentFunctionalTest.swift */; }; + C34D0BBDF6553ACF85204ACD /* STPPaymentMethodAfterpayClearpayTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDD1E823223F450193E8746 /* STPPaymentMethodAfterpayClearpayTest.swift */; }; + C35CF837D67AE8DB7CBDAD98 /* STPThreeDSLabelCustomizationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FDD4956E0A04D33F0856F31 /* STPThreeDSLabelCustomizationTest.swift */; }; + C3AAA4AFEE274B27D3483876 /* STPPaymentMethodFPXTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAEE347A6372AAE2735FAD6F /* STPPaymentMethodFPXTest.swift */; }; + C4C1295E7DA618DFB944A534 /* NSString+StripeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F5F35DB97D8A176FB6ED24 /* NSString+StripeTest.swift */; }; + C4DC3F4FA93A3BAF6EE782A0 /* PKPayment+StripeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D12508C2F1056A7EAFEC86 /* PKPayment+StripeTest.swift */; }; + C57BDA835AED735321906977 /* STPCardExpiryInputTextFieldValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4418164D75002AE6A0273176 /* STPCardExpiryInputTextFieldValidatorTests.swift */; }; + C5D295FE9988CA80ABA57801 /* RotatingCardBrandsViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12632E6710DE8861CAF1BAA4 /* RotatingCardBrandsViewTests.swift */; }; + C7EB8FB325BF491FDE25FE66 /* STPPaymentMethodEPSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C44B4366D6C4FD4B11662C8 /* STPPaymentMethodEPSTests.swift */; }; + C8226E24CA51133091131391 /* STPConfirmCardOptionsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD77D4B1C5B64E45F9DA09B5 /* STPConfirmCardOptionsTest.swift */; }; + C8490E55B1F2EB836144F91C /* STPConnectAccountFunctionalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E97018A201D01A8FA59999C2 /* STPConnectAccountFunctionalTest.swift */; }; + C861BB9EAAD04949E338D7FF /* PaymentTypeCellSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9A4A2B0FB9F8C743BBED48 /* PaymentTypeCellSnapshotTests.swift */; }; + C8A6CA6352B7C8FEE3D91476 /* UIView+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0293B82D7078EE11F9B5639 /* UIView+Helpers.swift */; }; + C9E66A22494C02050AE34A9B /* FBSnapshotTestCase+STPViewControllerLoading.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180CF848E3ABF0236C494D8B /* FBSnapshotTestCase+STPViewControllerLoading.swift */; }; + CA189278AD606BEAC62D545F /* STPPaymentIntentParamsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF902DC49DD90860BD0E5E80 /* STPPaymentIntentParamsTest.swift */; }; + CA4F392070740C56FE2BB461 /* STPStringUtilsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB6AE83989B0596F0C111E13 /* STPStringUtilsTest.swift */; }; + CB5AADE45B7B7A40514C054B /* StripeApplePay.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 52F8AEC50D4623F80F04A533 /* StripeApplePay.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + CBAF9C6F87F746F17495ADC2 /* STPPaymentMethodCashAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DDE50CBC86AD77084C877B6 /* STPPaymentMethodCashAppTests.swift */; }; + CBCA59D39B30D869B4FDC04B /* STPE2ETest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C1548BA518F7AC2A9ECF9D5 /* STPE2ETest.swift */; }; + CC072EBAD035AA54A2AD3ABC /* UIViewController+Stripe_KeyboardAvoiding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F08757CA6F6B2DA65C14E0A /* UIViewController+Stripe_KeyboardAvoiding.swift */; }; + CEE483EB7B06B3C607BC755C /* UINavigationBar+StripeTest.m in Sources */ = {isa = PBXBuildFile; fileRef = E68F6B90F3BC61A49570FAF4 /* UINavigationBar+StripeTest.m */; }; + CEF318C74D2E44C78EF85306 /* STPBankSelectionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 458F8576215E0F8ECE1D74CE /* STPBankSelectionTableViewCell.swift */; }; + CF2E17AC77EB08393B8A3F98 /* STPCardNumberInputTextFieldFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3803D0DED98501AA26B2EAC /* STPCardNumberInputTextFieldFormatterTests.swift */; }; + CFC1F2B8D48FFF7B0F81B5A0 /* STPPaymentMethodAddressTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEB3F9F0228008BE213706DF /* STPPaymentMethodAddressTest.swift */; }; + CFDD431A9A8A82BAA11AE5BF /* Stripe3DS2.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 33FDC634FD5D79E824240DDC /* Stripe3DS2.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D0342D50F9AC319919D93D59 /* STPBECSDebitAccountNumberValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23997D61DF41CA84BFC33080 /* STPBECSDebitAccountNumberValidatorTests.swift */; }; + D0C81317E0AA8EB0370B1BA1 /* LinkLegalTermsViewSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 835CB781FBC19773ACC20676 /* LinkLegalTermsViewSnapshotTests.swift */; }; + D15160C0F0763078DBB434E4 /* STPCardTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D575C31524E596E9C1A8E9B /* STPCardTest.swift */; }; + D151C8724925DCBA4BA4F46A /* StripeCoreTestUtils.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 512A0E7C246D5F044245E069 /* StripeCoreTestUtils.framework */; }; + D2869246B446B8B31F1CD368 /* STPIntentActionLinkAuthenticateAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EB24EC81CE2C8D1C863B044 /* STPIntentActionLinkAuthenticateAccount.swift */; }; + D2C062CE4E54094B1AC33E78 /* STPCardCVCInputTextFieldValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42F83F785EAF24F5DC7ED1A /* STPCardCVCInputTextFieldValidatorTests.swift */; }; + D375ADBD1F4B48380D5347D1 /* CircularButtonSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46AC0B5EC7433E081825D31B /* CircularButtonSnapshotTests.swift */; }; + D3D654D8376AAA634466D31D /* STPSourceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05AA6A1B2A462F1CE2F537C5 /* STPSourceTest.swift */; }; + D4602454AC17D3584BA88217 /* STPPaymentMethodEPSParamsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF93C4B7A5F8FA4E7919794F /* STPPaymentMethodEPSParamsTests.swift */; }; + D53C04A27B6B8EFB70E236A7 /* STPCardParamsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A3F2B714DB3D1DED561A7EF /* STPCardParamsTest.swift */; }; + D54508ED433792AD8AA6610F /* STPPaymentMethodRevolutPayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7963CC618A1A1346EC20C7 /* STPPaymentMethodRevolutPayTests.swift */; }; + D567569568C0D8F2D7B179B3 /* STPPaymentMethodAUBECSDebitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54426CBF6F77ABEFBDFDA8C4 /* STPPaymentMethodAUBECSDebitTests.swift */; }; + D73B7A0C24EDCA415FFBBB18 /* StripeUICore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D794C5E6396B4A19DC4F6921 /* StripeUICore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + D76D24F6A94108853BB08712 /* STPFileTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3E0CA28591EB0748C64D1FA /* STPFileTest.swift */; }; + D776B91F0E8E6CCB6C09AC4F /* STPFileFunctionalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1264C4DCB32B1FA5CE19201 /* STPFileFunctionalTest.swift */; }; + D7956073A8FD3785193E0577 /* STPStackViewWithSeparatorSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E62BB62EA9B782778CA880 /* STPStackViewWithSeparatorSnapshotTests.swift */; }; + D7C555B36C282B99E22B8D45 /* STPInputTextFieldFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A22E5B87755C1F05C3DB438C /* STPInputTextFieldFormatterTests.swift */; }; + D7D24DCC9402153965AF7F1B /* STPPaymentMethodPrzelewy24Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6558C62376C2397030BD4A6 /* STPPaymentMethodPrzelewy24Tests.swift */; }; + D83F76F584BC345CFBA71CF8 /* OneTimeCodeTextFieldSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D85FA7B714BDD8D1FD83B75 /* OneTimeCodeTextFieldSnapshotTests.swift */; }; + D85C432B241FDE23875037F9 /* UserDefaults+Stripe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26302721419496F37DE91DF8 /* UserDefaults+Stripe.swift */; }; + D8BECFB70834CC42BA6706D8 /* STPSourceParamsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6CB4B8FAD14B4D70A63595 /* STPSourceParamsTest.swift */; }; + DC57D2DC40C6BA0C9CF7EC92 /* PayWithLinkButtonSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF342CBC167F9CAB5B49CC32 /* PayWithLinkButtonSnapshotTests.swift */; }; + DCF615643A22D0A7B739547C /* Stripe+Exports.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70DF0B659009041F485EE0F /* Stripe+Exports.swift */; }; + DD16FC7ABCA7817794ECC407 /* STPThreeDSSelectionCustomizationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C23D612FD5AD7772E1B30DCC /* STPThreeDSSelectionCustomizationTest.swift */; }; + DD8E2B99BAE917F83258DC35 /* STPPaymentMethodOptionsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E2638F7AA0906914117C2D5 /* STPPaymentMethodOptionsTest.swift */; }; + DDBF5AAE607C698618DDE865 /* STPCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F8308B7250B642D19827D8 /* STPCameraView.swift */; }; + DE23FEF74E860620A334FDF5 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 884C01B087B4D820395BD374 /* Main.storyboard */; }; + DF73457BF349BC962A6AC502 /* STPCoreScrollViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D1525AF65BDEF691F8BCBE8 /* STPCoreScrollViewController.swift */; }; + DF85F5EC6E16CAD21491891A /* AnalyticsHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61AF6E95FE0DD913204CAB32 /* AnalyticsHelperTests.swift */; }; + E0F011E9C6CA368EF87F8E28 /* STPPaymentMethodBillingDetailsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E24B5689732EB9106DA232 /* STPPaymentMethodBillingDetailsTest.swift */; }; + E2790AB17C8C65CDE1E81532 /* STPPaymentMethodPrzelewy24ParamsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6269E77F81C32A5EC8BE412 /* STPPaymentMethodPrzelewy24ParamsTests.swift */; }; + E3E916EB10E19727D6B33081 /* STPPaymentMethodOXXOParamsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC9D4B0158266B01840AD9A /* STPPaymentMethodOXXOParamsTests.swift */; }; + E3F1BAD22CC6E90B761B0502 /* STPTextFieldDelegateProxyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8A2CD759D465290066EF65 /* STPTextFieldDelegateProxyTests.swift */; }; + E63B5BAF6B5645C979BFBA71 /* STPAddress+BasicUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 498C3FB07CFD532779C755D3 /* STPAddress+BasicUI.swift */; }; + E699508F4DB4D9D4666BAA08 /* STPSetupIntentLastSetupErrorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B057D99A14E5BA6019C349 /* STPSetupIntentLastSetupErrorTest.swift */; }; + E6F428CFAD64979A8874B00B /* STPAnalyticsClientPaymentsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7ACB4FAFAD33296DE34D036 /* STPAnalyticsClientPaymentsTest.swift */; }; + E97168F37D769524B58461B6 /* StripeCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43B4E4B85C598D7A9AFCB4D4 /* StripeCore.framework */; }; + E9A2C6E153CB480891846705 /* STPPaymentMethodGiropayParamsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F372EDF9C2C45E1CA2C76866 /* STPPaymentMethodGiropayParamsTests.swift */; }; + E9C690F3629C0AC3CD0260AF /* StripePaymentsObjcTestUtils.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5D237177A7F99EB0F5F4F5E4 /* StripePaymentsObjcTestUtils.framework */; }; + EA34719659CB9F1A269FECC7 /* StripeUICore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D794C5E6396B4A19DC4F6921 /* StripeUICore.framework */; }; + EA571ECEFDDF10AF87CE2B74 /* STPThreeDSFooterCustomizationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 483B243268646AE65B06E98C /* STPThreeDSFooterCustomizationTest.swift */; }; + EA7FEC518AA07BA59405A5E3 /* STPPaymentIntentParams+BasicUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F01150CD0255164FE2CF3A4 /* STPPaymentIntentParams+BasicUI.swift */; }; + EA80A8DB806DEF4F519059CB /* STPSourceSEPADebitDetailsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21780C410D22264B7C299520 /* STPSourceSEPADebitDetailsTest.swift */; }; + EBD436689635CC28A24DECD4 /* STPPinManagementServiceFunctionalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336CC555B845DED30208D39D /* STPPinManagementServiceFunctionalTest.swift */; }; + EC4DC8E386544959E1AA9355 /* STPSetupIntentTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8B8F540CD05B3DC2C5EEA6 /* STPSetupIntentTest.swift */; }; + EEA502DF8809B8FD0D00785E /* StripePayments.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 22D1C6EB5826E2D7C80B6CF3 /* StripePayments.framework */; }; + EEB5E5E9C4E06B148A91C7BD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6955B3A3353F8442E4FBBBF6 /* Assets.xcassets */; }; + EEBA9A95E8057A06E5E7C103 /* STPCardNumberInputTextFieldSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD289E1EA9F0CE1C848AC0BB /* STPCardNumberInputTextFieldSnapshotTests.swift */; }; + EEFFE199D9769FF449BFD7FF /* STPCoreTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B78C72B0DB434EC7F700FDE0 /* STPCoreTableViewController.swift */; }; + F06EAD0F48302B061ED29E61 /* STPPaymentMethodCardWalletMasterpassTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 533538E3EB92E326CCB95506 /* STPPaymentMethodCardWalletMasterpassTest.swift */; }; + F10FC337254A34ED8F13E341 /* STPPaymentOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B694A39D54886392AA5DE3 /* STPPaymentOption.swift */; }; + F2655328479314A9C8718DE4 /* STPApplePayContextTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F7AB40A5A10C2D267323ABE /* STPApplePayContextTest.swift */; }; + F35E090A607EB5F86FFC3D31 /* STPCardCVCInputTextFieldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A7104C1C470515616E4D2B /* STPCardCVCInputTextFieldTests.swift */; }; + F49D9C4030829D13A6EB45BE /* MockFiles in Resources */ = {isa = PBXBuildFile; fileRef = FE2DED6ABA7407C17C1391B6 /* MockFiles */; }; + F53E04785DB804EA5C2AAC18 /* STPAddressViewModelTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3D2DB08C335695B705F544C /* STPAddressViewModelTest.swift */; }; + F550D4EB3DCFE03D6FC8F023 /* STPIntentActionPayNowDisplayQrCodeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A517686D4D12691351311CA /* STPIntentActionPayNowDisplayQrCodeTest.swift */; }; + F5CC4F320D09A06F0B21ABE6 /* STPPaymentMethodAffirmTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F0DF2ED9232A7CC51F5FCB1 /* STPPaymentMethodAffirmTests.swift */; }; + F729E784CFFC1F79EF5F2ABE /* STPPaymentIntentLastPaymentErrorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7B8DACB0A7294BC235E3BC /* STPPaymentIntentLastPaymentErrorTest.swift */; }; + F835CEC935464FF32726A0A0 /* STPTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E8CA4361964E1BA400EFC89 /* STPTheme.swift */; }; + F86F2DF6E46EFABE23AD5D27 /* STPApplePayContextDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E452877E5D11120B1E28A6E7 /* STPApplePayContextDelegate.swift */; }; + F975CE029DF30419B8DB0D8F /* STPNumericStringValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA36705DED9164663A98B6A /* STPNumericStringValidatorTests.swift */; }; + FB34906C9215D0E03850064B /* STPAddCardViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8704ABFA91A5226847F4A69A /* STPAddCardViewControllerTest.swift */; }; + FBBA3B39598BBECB664C5E7F /* STPApplePayTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFC4BC1AB047ED88C4D13C89 /* STPApplePayTest.swift */; }; + FC166455478EAF51F7C34E68 /* STPApplePayPaymentOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03ACDC7EEC28D1FE50008F65 /* STPApplePayPaymentOption.swift */; }; + FDADE3E36804A8AD82301BF3 /* STPPaymentMethodAfterpayClearpayParamsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBA5B09A3FD875C93218573 /* STPPaymentMethodAfterpayClearpayParamsTest.swift */; }; + FDD1858CAEFCEBB22BEC9BBC /* MKPlacemark+PaymentSheetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DCCA0E8A02B4F4B23837FB4 /* MKPlacemark+PaymentSheetTests.swift */; }; + FE6647242714D9BEA1EBC055 /* STPCardCVCInputTextFieldFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1F6514E7530C2A3478B2F5 /* STPCardCVCInputTextFieldFormatterTests.swift */; }; + FE7C38B95B3B7E028AB21238 /* STPPaymentMethodCardChecksTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A15BBFFA401852A8719E3DDD /* STPPaymentMethodCardChecksTest.swift */; }; + FEE74744B657F86873EA2F3D /* STPPushProvisioningDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E5DA3029F141B5111A5B2C /* STPPushProvisioningDetails.swift */; }; + FEF2E0DAC862FF42B814AFCA /* STPPaymentHandlerFunctionalTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 683F7735569D22CBEC9CA2E6 /* STPPaymentHandlerFunctionalTest.m */; }; + FF0F9BA6FE4B88297A434EA7 /* STPPaymentMethodNetBankingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 739934737B9A09775CD278C9 /* STPPaymentMethodNetBankingTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 32221E5BA07FB5AA4EBFE81C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E63832AA5BB4225708B7C838 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 1628E8B14F1F7C63CF8C9962; + remoteInfo = StripeiOSTestHostApp; + }; + 8F25D3DFD6D65DAE4B581911 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E63832AA5BB4225708B7C838 /* Project object */; + proxyType = 1; + remoteGlobalIDString = ADF894AA8F6022D9BED17346; + remoteInfo = StripeiOS; + }; + D90CE98566BBDA0E7340E1D7 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E63832AA5BB4225708B7C838 /* Project object */; + proxyType = 1; + remoteGlobalIDString = ADF894AA8F6022D9BED17346; + remoteInfo = StripeiOS; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 16FB342935F756BD2EA7CE4C /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + CFDD431A9A8A82BAA11AE5BF /* Stripe3DS2.framework in Embed Frameworks */, + CB5AADE45B7B7A40514C054B /* StripeApplePay.framework in Embed Frameworks */, + C035D82D7096E3005858848C /* StripeCore.framework in Embed Frameworks */, + 42395EF962DB8AD6A094630B /* StripePaymentSheet.framework in Embed Frameworks */, + BF4A92064926FD7B0E3E92F7 /* StripePayments.framework in Embed Frameworks */, + 5B6F1BF973FC2D8DD6127B7F /* StripePaymentsUI.framework in Embed Frameworks */, + D73B7A0C24EDCA415FFBBB18 /* StripeUICore.framework in Embed Frameworks */, + 162C101E57D66F0051164C4A /* Stripe.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + 5789BE2CD297329ED86678C0 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + 9F30C6D40956861F588C2CD0 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + CAAA5D4C8E87896995960E8C /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 002634603200AABECC9686B1 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; + 005650A59D692F820EF20F5F /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; + 00985EFC6CB7B912FDBF3813 /* STPApplePayPaymentOptionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPApplePayPaymentOptionTest.swift; sourceTree = ""; }; + 01B057D99A14E5BA6019C349 /* STPSetupIntentLastSetupErrorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPSetupIntentLastSetupErrorTest.swift; sourceTree = ""; }; + 01B42BE6FB5EC1F708875AB8 /* STPPaymentCardTextFieldCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentCardTextFieldCell.swift; sourceTree = ""; }; + 0241DB84973B21393BEC703E /* STPAnalyticsClientPaymentSheetTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPAnalyticsClientPaymentSheetTest.swift; sourceTree = ""; }; + 02FC9ED423D40C88D5A24441 /* STPCoreViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCoreViewController.swift; sourceTree = ""; }; + 03ACDC7EEC28D1FE50008F65 /* STPApplePayPaymentOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPApplePayPaymentOption.swift; sourceTree = ""; }; + 04838ACE779F5CC949C276CB /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; + 04FE0C74090AE8A871CCE5EC /* mt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mt; path = mt.lproj/Localizable.strings; sourceTree = ""; }; + 05AA6A1B2A462F1CE2F537C5 /* STPSourceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPSourceTest.swift; sourceTree = ""; }; + 05FE1BA89B80336F16924FA2 /* STPConfirmPaymentMethodOptionsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPConfirmPaymentMethodOptionsTest.swift; sourceTree = ""; }; + 064CFCA8FCEA9E4BAB3547D0 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; + 0669B4CA326CE74D125C789C /* STPFakeAddPaymentPassViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPFakeAddPaymentPassViewController.swift; sourceTree = ""; }; + 084B1B9FCCF4AB727B4ECFB2 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; + 090EF7D598B8DE779C275395 /* STPPostalCodeInputTextFieldSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPostalCodeInputTextFieldSnapshotTests.swift; sourceTree = ""; }; + 095EBF095BA2BC8D299547DB /* STPSourceOwnerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPSourceOwnerTest.swift; sourceTree = ""; }; + 0A16326394D71637A2CF68C3 /* fil */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fil; path = fil.lproj/Localizable.strings; sourceTree = ""; }; + 0A8FF64CA314F909D7EC82FE /* STPPaymentMethodGrabPayParamsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodGrabPayParamsTest.swift; sourceTree = ""; }; + 0ABB2CA7E96BE249CE8C0566 /* STPPaymentMethodCashAppParamsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodCashAppParamsTests.swift; sourceTree = ""; }; + 0B91C4D5B93FF71C61B140F1 /* STPCardScannerTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardScannerTableViewCell.swift; sourceTree = ""; }; + 0C44B4366D6C4FD4B11662C8 /* STPPaymentMethodEPSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodEPSTests.swift; sourceTree = ""; }; + 0C75157665428685C7A4FD20 /* STPCardExpiryInputTextFieldSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardExpiryInputTextFieldSnapshotTests.swift; sourceTree = ""; }; + 0C9D6F99E303A17A91101723 /* pl-PL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pl-PL"; path = "pl-PL.lproj/Localizable.strings"; sourceTree = ""; }; + 0DB03E83746FE78361831546 /* WalletHeaderViewSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletHeaderViewSnapshotTests.swift; sourceTree = ""; }; + 0E10C4D64477638398251FFB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 0F735744F27D46F005BB5D67 /* sk-SK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sk-SK"; path = "sk-SK.lproj/Localizable.strings"; sourceTree = ""; }; + 1007571188950D7FBF745A4E /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; + 10BAD33BEC6C2894F9266902 /* LinkSecureCookieStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkSecureCookieStoreTests.swift; sourceTree = ""; }; + 11C866064B3482878A69892F /* CustomerAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerAdapterTests.swift; sourceTree = ""; }; + 121B7EDCCD0957C9A444A8E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + 12632E6710DE8861CAF1BAA4 /* RotatingCardBrandsViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RotatingCardBrandsViewTests.swift; sourceTree = ""; }; + 12D757B36030685C401A6990 /* et-EE */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "et-EE"; path = "et-EE.lproj/Localizable.strings"; sourceTree = ""; }; + 12DBB3F72AEFB52DE27C27ED /* STPAnalyticsClient+BasicUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPAnalyticsClient+BasicUI.swift"; sourceTree = ""; }; + 13DDBEA7D444A8AC14E0F1C8 /* lv-LV */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "lv-LV"; path = "lv-LV.lproj/Localizable.strings"; sourceTree = ""; }; + 148C1D7D1BBBC6B74894A869 /* STPPaymentMethodTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodTest.swift; sourceTree = ""; }; + 153071C69A0BEE033E035DCF /* CardExpiryDateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardExpiryDateTests.swift; sourceTree = ""; }; + 1645D9793463E266501B74FD /* STPPIIFunctionalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPIIFunctionalTest.swift; sourceTree = ""; }; + 1671EC46C713D51013AD7D8B /* STPPaymentMethodCardParamsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodCardParamsTest.swift; sourceTree = ""; }; + 17013F78CE3F9662029FEF5B /* STPSourceFunctionalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPSourceFunctionalTest.swift; sourceTree = ""; }; + 180CF848E3ABF0236C494D8B /* FBSnapshotTestCase+STPViewControllerLoading.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FBSnapshotTestCase+STPViewControllerLoading.swift"; sourceTree = ""; }; + 18FCB69CD3B8C3DAB216A5F0 /* STPPaymentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentConfiguration.swift; sourceTree = ""; }; + 195DEC752CC82CC4BA1E2351 /* STPConnectAccountParamsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPConnectAccountParamsTest.swift; sourceTree = ""; }; + 1A8A6B88797870BC71CCB3AF /* STPPushProvisioningDetailsFunctionalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPushProvisioningDetailsFunctionalTest.swift; sourceTree = ""; }; + 1B76DF0FE363F59BF0940A8B /* UIView+Stripe_FirstResponder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Stripe_FirstResponder.swift"; sourceTree = ""; }; + 1C1548BA518F7AC2A9ECF9D5 /* STPE2ETest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPE2ETest.swift; sourceTree = ""; }; + 1CE268457D21A7209862E004 /* STPApplePayContextFunctionalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPApplePayContextFunctionalTest.swift; sourceTree = ""; }; + 1D23EB567F573612E0794B3A /* Stripe Tests-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Stripe Tests-Debug.xcconfig"; sourceTree = ""; }; + 1D51B04D83D4FEF7F90DF16A /* STPCertTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCertTest.swift; sourceTree = ""; }; + 1D575C31524E596E9C1A8E9B /* STPCardTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardTest.swift; sourceTree = ""; }; + 1D983E089196152DA1C69469 /* STPCardFormViewSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardFormViewSnapshotTests.swift; sourceTree = ""; }; + 1DD6897858F46976A946394E /* StripeiOSAppHostedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StripeiOSAppHostedTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 1E2638F7AA0906914117C2D5 /* STPPaymentMethodOptionsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodOptionsTest.swift; sourceTree = ""; }; + 1E8AFAE24610EC983727F860 /* STPAPIClient+BasicUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPAPIClient+BasicUI.swift"; sourceTree = ""; }; + 1F0DF2ED9232A7CC51F5FCB1 /* STPPaymentMethodAffirmTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodAffirmTests.swift; sourceTree = ""; }; + 1F16C36797D978E72E612100 /* STPPaymentCardTextFieldTestsSwift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentCardTextFieldTestsSwift.swift; sourceTree = ""; }; + 1F29C15B47C7CB0941CD4C9E /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + 1F6CB4B8FAD14B4D70A63595 /* STPSourceParamsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPSourceParamsTest.swift; sourceTree = ""; }; + 1FFBAA4B44967B157A4F4E91 /* STPPaymentActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentActivityIndicatorView.swift; sourceTree = ""; }; + 20552E792B8E7BA15821AB5D /* NSDecimalNumber+StripeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSDecimalNumber+StripeTest.swift"; sourceTree = ""; }; + 207677E2A0DBC04C88139372 /* STPPaymentMethodSofortParamsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodSofortParamsTests.swift; sourceTree = ""; }; + 20879436DCFB1F03BE1608B3 /* ServerErrorMapperTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerErrorMapperTest.swift; sourceTree = ""; }; + 2177A76093365DC8CD6EA05E /* STPCardBrandChoiceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardBrandChoiceTest.swift; sourceTree = ""; }; + 21780C410D22264B7C299520 /* STPSourceSEPADebitDetailsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPSourceSEPADebitDetailsTest.swift; sourceTree = ""; }; + 21E4B84223DDA131544DBBA7 /* STPShippingMethodsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPShippingMethodsViewController.swift; sourceTree = ""; }; + 22D1C6EB5826E2D7C80B6CF3 /* StripePayments.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripePayments.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 23997D61DF41CA84BFC33080 /* STPBECSDebitAccountNumberValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPBECSDebitAccountNumberValidatorTests.swift; sourceTree = ""; }; + 2560B4EEE60D40FB31B8552F /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; + 26302721419496F37DE91DF8 /* UserDefaults+Stripe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+Stripe.swift"; sourceTree = ""; }; + 26E70469F4032553B4BB62DA /* ca-ES */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ca-ES"; path = "ca-ES.lproj/Localizable.strings"; sourceTree = ""; }; + 273EE407039913F0B644172B /* PKAddPaymentPassRequest+Stripe_Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKAddPaymentPassRequest+Stripe_Error.swift"; sourceTree = ""; }; + 27A4D83C0E7D4AE0CCD38B89 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; + 284C67269D2606DA147AE01D /* STPGenericInputPickerFieldSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPGenericInputPickerFieldSnapshotTests.swift; sourceTree = ""; }; + 28A50AFA4603E488FF3D82D0 /* STPAddressViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPAddressViewModel.swift; sourceTree = ""; }; + 294CD46E24BB2743042872D7 /* StripeiOSTestHostApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StripeiOSTestHostApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 2A63AC868755CB4745E7458E /* STPPaymentMethodPayPalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodPayPalTests.swift; sourceTree = ""; }; + 2A8A2CD759D465290066EF65 /* STPTextFieldDelegateProxyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPTextFieldDelegateProxyTests.swift; sourceTree = ""; }; + 2B28B8A547CD846277ECD578 /* STPPushProvisioningDetailsParams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPushProvisioningDetailsParams.swift; sourceTree = ""; }; + 2C078573F46762353664AC92 /* UINavigationController+Stripe_Completion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationController+Stripe_Completion.swift"; sourceTree = ""; }; + 2CC4B06AB5C02FF54091E5A8 /* STPCustomerContextTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCustomerContextTest.swift; sourceTree = ""; }; + 2D1525AF65BDEF691F8BCBE8 /* STPCoreScrollViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCoreScrollViewController.swift; sourceTree = ""; }; + 2D63B73C5773432CA134D1FC /* STPGenericInputPickerFieldValidatorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPGenericInputPickerFieldValidatorTest.swift; sourceTree = ""; }; + 2D878F923A1F69B58D6B2812 /* STPRadarSessionFunctionalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPRadarSessionFunctionalTest.swift; sourceTree = ""; }; + 2DC4B62C336EAA05A33FC384 /* STPPaymentMethodCardWalletTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodCardWalletTest.swift; sourceTree = ""; }; + 2E1862744F23286D1FB9D4AE /* STPFormTextFieldTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPFormTextFieldTest.swift; sourceTree = ""; }; + 2F08757CA6F6B2DA65C14E0A /* UIViewController+Stripe_KeyboardAvoiding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Stripe_KeyboardAvoiding.swift"; sourceTree = ""; }; + 30964128998473CAA9F2DD7E /* STPFormEncoderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPFormEncoderTest.swift; sourceTree = ""; }; + 30B694A39D54886392AA5DE3 /* STPPaymentOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentOption.swift; sourceTree = ""; }; + 313263D9DC0629C5EE279FEB /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; + 336CC555B845DED30208D39D /* STPPinManagementServiceFunctionalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPinManagementServiceFunctionalTest.swift; sourceTree = ""; }; + 33B9D01D037909D1C9C0B617 /* STPIntentActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPIntentActionTest.swift; sourceTree = ""; }; + 33FDC634FD5D79E824240DDC /* Stripe3DS2.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Stripe3DS2.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 35ABE696542469B79A9D52E6 /* tk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tk; path = tk.lproj/Localizable.strings; sourceTree = ""; }; + 35C1E9B0EE03825DABF6471A /* STPPaymentMethod+BasicUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPPaymentMethod+BasicUI.swift"; sourceTree = ""; }; + 3AE7BEADD3824A06C2994854 /* STPPaymentMethodRevolutPayParamsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodRevolutPayParamsTests.swift; sourceTree = ""; }; + 3B0E131538728BC4802627B1 /* UserDefaults+StripeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+StripeTest.swift"; sourceTree = ""; }; + 3B112FFF3FCA82094281493F /* STPPaymentMethodUSBankAccountTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodUSBankAccountTest.swift; sourceTree = ""; }; + 3B3000668A75E095B514241F /* UIBarButtonItem+Stripe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBarButtonItem+Stripe.swift"; sourceTree = ""; }; + 3C742844915B96CFD25BFFF9 /* AfterpayPriceBreakdownViewSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AfterpayPriceBreakdownViewSnapshotTests.swift; sourceTree = ""; }; + 3C77C7BC4BA57EC296CF2F1C /* STPApplePayFunctionalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPApplePayFunctionalTest.swift; sourceTree = ""; }; + 3C995125252BED1EEC018B9D /* STPPaymentContextApplePayTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentContextApplePayTest.swift; sourceTree = ""; }; + 3CDD1E823223F450193E8746 /* STPPaymentMethodAfterpayClearpayTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodAfterpayClearpayTest.swift; sourceTree = ""; }; + 3E1C5E08678292561255B1C5 /* STPCardBINMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardBINMetadataTests.swift; sourceTree = ""; }; + 3E5FB20B2BEFC00D54FDD87D /* STPCard+BasicUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPCard+BasicUI.swift"; sourceTree = ""; }; + 3EBB07171F6FDCE6E20C454A /* STPPinManagementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPinManagementService.swift; sourceTree = ""; }; + 3ED44491EB0AC72B1B1A773C /* STPThreeDSUICustomizationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPThreeDSUICustomizationTest.swift; sourceTree = ""; }; + 3FC0560A312147C37CFE6CF9 /* STPBinRangeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPBinRangeTest.swift; sourceTree = ""; }; + 4002981AC12687681616D21E /* STPPaymentMethodParamsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodParamsTest.swift; sourceTree = ""; }; + 4077600B9B3C1ABFF38383BE /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; + 40BB87E28719FE0C6B946BB5 /* STPCardExpiryInputTextFieldFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardExpiryInputTextFieldFormatterTests.swift; sourceTree = ""; }; + 4259421D2CD26E37B96F97B2 /* Stripe.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Stripe.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 436074876478126262BD05C0 /* IntentConfirmParamsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentConfirmParamsTest.swift; sourceTree = ""; }; + 43ADFC4EF612D7C4A46E81B9 /* lt-LT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "lt-LT"; path = "lt-LT.lproj/Localizable.strings"; sourceTree = ""; }; + 43B4E4B85C598D7A9AFCB4D4 /* StripeCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 4418164D75002AE6A0273176 /* STPCardExpiryInputTextFieldValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardExpiryInputTextFieldValidatorTests.swift; sourceTree = ""; }; + 458F8576215E0F8ECE1D74CE /* STPBankSelectionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPBankSelectionTableViewCell.swift; sourceTree = ""; }; + 45FF7A07CFC3B9B7AD6B49EE /* FraudDetectionDataTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FraudDetectionDataTest.swift; sourceTree = ""; }; + 46AC0B5EC7433E081825D31B /* CircularButtonSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularButtonSnapshotTests.swift; sourceTree = ""; }; + 46C31CAD0FA74B58BA2B8530 /* STPPaymentIntentEnumsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentIntentEnumsTest.swift; sourceTree = ""; }; + 47E12A0CBFA259A032F7AF0C /* STPPaymentMethodKlarnaParamsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodKlarnaParamsTests.swift; sourceTree = ""; }; + 47E5E1173A37AABB07FB68AB /* STPPaymentMethodUPIParamsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodUPIParamsTest.swift; sourceTree = ""; }; + 482693A3D9A527B0E671F757 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; + 483B243268646AE65B06E98C /* STPThreeDSFooterCustomizationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPThreeDSFooterCustomizationTest.swift; sourceTree = ""; }; + 485E747DA1F72F091986787B /* STPEphemeralKeyProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPEphemeralKeyProvider.swift; sourceTree = ""; }; + 498C3FB07CFD532779C755D3 /* STPAddress+BasicUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPAddress+BasicUI.swift"; sourceTree = ""; }; + 49AA313E068FB99CEAA5F7D3 /* STPSetupIntentFunctionalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPSetupIntentFunctionalTest.swift; sourceTree = ""; }; + 4AA36705DED9164663A98B6A /* STPNumericStringValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPNumericStringValidatorTests.swift; sourceTree = ""; }; + 4AAE5EE11611F9F7762B64C6 /* STPAUBECSFormViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPAUBECSFormViewModelTests.swift; sourceTree = ""; }; + 4CA5D11C977A95B8E936E907 /* STPIntentActionWeChatPayRedirectToAppTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPIntentActionWeChatPayRedirectToAppTest.swift; sourceTree = ""; }; + 4DFDFC019C67AFF7C0F7630B /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; + 4E29B46F2C940E0A21734E09 /* StripeiOSTests.xctestplan */ = {isa = PBXFileReference; path = StripeiOSTests.xctestplan; sourceTree = ""; }; + 4E371E9B3B2E343FE954531C /* STPPaymentMethodBoletoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodBoletoTests.swift; sourceTree = ""; }; + 4FD94FF270165D699DA89B24 /* STPPaymentMethodKlarnaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodKlarnaTests.swift; sourceTree = ""; }; + 4FFA8B446217CDE678D7287F /* STPBlocks.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STPBlocks.h; sourceTree = ""; }; + 512A0E7C246D5F044245E069 /* StripeCoreTestUtils.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCoreTestUtils.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 51408DE266D0345784ADD4FA /* STPThreeDSNavigationBarCustomizationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPThreeDSNavigationBarCustomizationTest.swift; sourceTree = ""; }; + 51BD2CE41E4F0CF648F44E4A /* TextFieldElement+IBANTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextFieldElement+IBANTest.swift"; sourceTree = ""; }; + 51E62BB62EA9B782778CA880 /* STPStackViewWithSeparatorSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPStackViewWithSeparatorSnapshotTests.swift; sourceTree = ""; }; + 52F8AEC50D4623F80F04A533 /* StripeApplePay.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeApplePay.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 533538E3EB92E326CCB95506 /* STPPaymentMethodCardWalletMasterpassTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodCardWalletMasterpassTest.swift; sourceTree = ""; }; + 53C5AB22D6328E85A6DDF663 /* STPPaymentMethodParams+BasicUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPPaymentMethodParams+BasicUI.swift"; sourceTree = ""; }; + 54426CBF6F77ABEFBDFDA8C4 /* STPPaymentMethodAUBECSDebitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodAUBECSDebitTests.swift; sourceTree = ""; }; + 5476BD87E0480A93958F0328 /* UITableViewCell+Stripe_Borders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableViewCell+Stripe_Borders.swift"; sourceTree = ""; }; + 5804C2B9C0704E386B3D25A4 /* UIViewController+Stripe_ParentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Stripe_ParentViewController.swift"; sourceTree = ""; }; + 58158E8A117299834246100F /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; + 583DB466066B47C0F716E474 /* STPPaymentMethodGiropayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodGiropayTests.swift; sourceTree = ""; }; + 588C260880FFC584A00A89F5 /* STPEphemeralKeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPEphemeralKeyManager.swift; sourceTree = ""; }; + 58A53F005EA8FDDAA66126BA /* STPGenericInputTextFieldSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPGenericInputTextFieldSnapshotTests.swift; sourceTree = ""; }; + 5D237177A7F99EB0F5F4F5E4 /* StripePaymentsObjcTestUtils.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripePaymentsObjcTestUtils.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5DE9F0BAC35EA14579775033 /* STPPaymentMethodSwishTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodSwishTests.swift; sourceTree = ""; }; + 5E4EA6394497D1BD57ED0032 /* Stripe Tests-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Stripe Tests-Release.xcconfig"; sourceTree = ""; }; + 5F7AB40A5A10C2D267323ABE /* STPApplePayContextTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPApplePayContextTest.swift; sourceTree = ""; }; + 5FDD4956E0A04D33F0856F31 /* STPThreeDSLabelCustomizationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPThreeDSLabelCustomizationTest.swift; sourceTree = ""; }; + 618DE183886175AF23C4E668 /* sl-SI */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sl-SI"; path = "sl-SI.lproj/Localizable.strings"; sourceTree = ""; }; + 61AF6E95FE0DD913204CAB32 /* AnalyticsHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsHelperTests.swift; sourceTree = ""; }; + 61F8308B7250B642D19827D8 /* STPCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCameraView.swift; sourceTree = ""; }; + 6215A9BF343775B1BD0F62AF /* STPPaymentOptionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentOptionTableViewCell.swift; sourceTree = ""; }; + 6223E57D3A198F956A37ED89 /* STPNumericDigitInputTextFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPNumericDigitInputTextFormatterTests.swift; sourceTree = ""; }; + 63114D0EAAE2606732DF5AA0 /* STPSourceCardDetailsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPSourceCardDetailsTest.swift; sourceTree = ""; }; + 63BDA70DB74745C5F457CF88 /* STPElementsSessionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPElementsSessionTest.swift; sourceTree = ""; }; + 63F5F35DB97D8A176FB6ED24 /* NSString+StripeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSString+StripeTest.swift"; sourceTree = ""; }; + 655209238C85F466F9F14F14 /* STPPaymentMethodBancontactParamsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodBancontactParamsTests.swift; sourceTree = ""; }; + 65DCC9BDA647E58D3882C698 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 6618739767139C25C05B3631 /* STPPostalCodeInputTextFieldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPostalCodeInputTextFieldTests.swift; sourceTree = ""; }; + 683F7735569D22CBEC9CA2E6 /* STPPaymentHandlerFunctionalTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPPaymentHandlerFunctionalTest.m; sourceTree = ""; }; + 6887F19BB9804BF45FD703FF /* STPPushProvisioningContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPushProvisioningContext.swift; sourceTree = ""; }; + 6955B3A3353F8442E4FBBBF6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 69BD038947E8E2376A0D240B /* STPCardScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardScanner.swift; sourceTree = ""; }; + 6A9E7B637A8747431B38FD1D /* STPCardValidationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardValidationState.swift; sourceTree = ""; }; + 6B7A947152A728EB2CBC4DB2 /* STPSourceReceiverTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPSourceReceiverTest.swift; sourceTree = ""; }; + 6C7094644EE056260D6F3B67 /* STPPaymentOptionsViewControllerLocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentOptionsViewControllerLocalizationTests.swift; sourceTree = ""; }; + 6C7B8DACB0A7294BC235E3BC /* STPPaymentIntentLastPaymentErrorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentIntentLastPaymentErrorTest.swift; sourceTree = ""; }; + 6C8FE751333F580004BD72BA /* STPIntentWithPreferencesTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPIntentWithPreferencesTest.swift; sourceTree = ""; }; + 6CC0B1FC92A573AAEA4F4E94 /* STPSetupIntentConfirmParamsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPSetupIntentConfirmParamsTest.swift; sourceTree = ""; }; + 6E1F6514E7530C2A3478B2F5 /* STPCardCVCInputTextFieldFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardCVCInputTextFieldFormatterTests.swift; sourceTree = ""; }; + 6F01150CD0255164FE2CF3A4 /* STPPaymentIntentParams+BasicUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPPaymentIntentParams+BasicUI.swift"; sourceTree = ""; }; + 6F017A08C7E633FB4297D274 /* UIViewController+Stripe_NavigationItemProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Stripe_NavigationItemProxy.swift"; sourceTree = ""; }; + 71711FC8E2FB66E52A5FDD9A /* STPShippingAddressViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPShippingAddressViewController.swift; sourceTree = ""; }; + 72903593DC432D01720DC9D9 /* STPAddressFieldTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPAddressFieldTableViewCell.swift; sourceTree = ""; }; + 739934737B9A09775CD278C9 /* STPPaymentMethodNetBankingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodNetBankingTests.swift; sourceTree = ""; }; + 74FDEF9F687C63BADFB96480 /* STPPaymentMethodUSBankAccountParamsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodUSBankAccountParamsTest.swift; sourceTree = ""; }; + 77247622AB08FEF48CA0DC26 /* StripePaymentsTestUtils.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripePaymentsTestUtils.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 77E846CD56018D8417A3AB95 /* StripeiOS.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = StripeiOS.xcassets; sourceTree = ""; }; + 789D0B49B0788794739E3DD4 /* STPShippingAddressViewControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPShippingAddressViewControllerTest.swift; sourceTree = ""; }; + 78BA15D1F0844B1DA71A5348 /* STPAddCardViewControllerLocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPAddCardViewControllerLocalizationTests.swift; sourceTree = ""; }; + 78D1EEABBAE5BD5615486B0F /* STPLabeledFormTextFieldViewSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPLabeledFormTextFieldViewSnapshotTests.swift; sourceTree = ""; }; + 79ABE6A14AF9D14103050876 /* STPPaymentConfigurationTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPPaymentConfigurationTest.m; sourceTree = ""; }; + 7A517686D4D12691351311CA /* STPIntentActionPayNowDisplayQrCodeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPIntentActionPayNowDisplayQrCodeTest.swift; sourceTree = ""; }; + 7A7963CC618A1A1346EC20C7 /* STPPaymentMethodRevolutPayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodRevolutPayTests.swift; sourceTree = ""; }; + 7AC0A18C441FCA394BEF6A3D /* STPPaymentMethodBillingDetailsTests+Link.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPPaymentMethodBillingDetailsTests+Link.swift"; sourceTree = ""; }; + 7B10B1A15063FEDBA4A59953 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 7B8ADF2EA2D5C4C90BCDDDD5 /* bg-BG */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "bg-BG"; path = "bg-BG.lproj/Localizable.strings"; sourceTree = ""; }; + 7B9A4A2B0FB9F8C743BBED48 /* PaymentTypeCellSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentTypeCellSnapshotTests.swift; sourceTree = ""; }; + 7C04AFC9CDE50D09D38A3232 /* FormSpecProviderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormSpecProviderTest.swift; sourceTree = ""; }; + 7CAE7444CEFE1D1EFB888996 /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/Localizable.strings; sourceTree = ""; }; + 7D964D9E01627B419B4BD23C /* STPPaymentResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentResult.swift; sourceTree = ""; }; + 7DDE50CBC86AD77084C877B6 /* STPPaymentMethodCashAppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodCashAppTests.swift; sourceTree = ""; }; + 7F090B8D315E7FD12A5F9C09 /* STPTokenTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPTokenTest.swift; sourceTree = ""; }; + 7F68637E75142DCD46710796 /* STPLabeledMultiFormTextFieldViewSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPLabeledMultiFormTextFieldViewSnapshotTests.swift; sourceTree = ""; }; + 7FF2B9FF57E100301B5C38DB /* STPPaymentMethodAUBECSDebitParamsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodAUBECSDebitParamsTests.swift; sourceTree = ""; }; + 806124200E77795DCFC8418E /* ConfirmButtonSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmButtonSnapshotTests.swift; sourceTree = ""; }; + 807FF966F1DE05F3496B817B /* STPAPIClient+PushProvisioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPAPIClient+PushProvisioning.swift"; sourceTree = ""; }; + 80BBCC4D386EE44E809A591C /* STPPaymentMethodOXXOTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodOXXOTests.swift; sourceTree = ""; }; + 81352A0CBE46A59E6B1A712E /* STPBankSelectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPBankSelectionViewController.swift; sourceTree = ""; }; + 8192839B0F1AE9D9F2A94504 /* STPPaymentMethodFunctionalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodFunctionalTest.swift; sourceTree = ""; }; + 82E46B18CDDC191934F3D4BE /* STPPaymentMethodCardWalletVisaCheckoutTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodCardWalletVisaCheckoutTest.swift; sourceTree = ""; }; + 835CB781FBC19773ACC20676 /* LinkLegalTermsViewSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkLegalTermsViewSnapshotTests.swift; sourceTree = ""; }; + 85AAB72218409F85FE29E69E /* APIRequestTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIRequestTest.swift; sourceTree = ""; }; + 85C29AE44D809CD677B5E52B /* STPFPXBankBrandTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPFPXBankBrandTest.swift; sourceTree = ""; }; + 86798C95A778362EF815B4C6 /* UIView+Stripe_SafeAreaBounds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Stripe_SafeAreaBounds.swift"; sourceTree = ""; }; + 86983B09A1712944EC012AD4 /* STPViewWithSeparatorSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPViewWithSeparatorSnapshotTests.swift; sourceTree = ""; }; + 8704ABFA91A5226847F4A69A /* STPAddCardViewControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPAddCardViewControllerTest.swift; sourceTree = ""; }; + 88639E3C3AC622B5EF475538 /* STPUIVCStripeParentViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPUIVCStripeParentViewControllerTests.swift; sourceTree = ""; }; + 88AEABC15CCBB9EA393C175F /* STPMocks.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPMocks.m; sourceTree = ""; }; + 890660C21E3666CE7B82695B /* STPEphemeralKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPEphemeralKey.swift; sourceTree = ""; }; + 89E5DA3029F141B5111A5B2C /* STPPushProvisioningDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPushProvisioningDetails.swift; sourceTree = ""; }; + 8A3F2B714DB3D1DED561A7EF /* STPCardParamsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardParamsTest.swift; sourceTree = ""; }; + 8BD02D8298877F10F2EF2A9D /* STPPaymentMethodCardTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodCardTest.swift; sourceTree = ""; }; + 8CE85C770AEEDBE4AEC93EAA /* Error+PaymentSheetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Error+PaymentSheetTests.swift"; sourceTree = ""; }; + 8D49257A97E71A475A9F6E08 /* STPCountryPickerInputFieldSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCountryPickerInputFieldSnapshotTests.swift; sourceTree = ""; }; + 8D866DCC403994A0D3CFB1D7 /* KlarnaHelperTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlarnaHelperTest.swift; sourceTree = ""; }; + 8E8CA4361964E1BA400EFC89 /* STPTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPTheme.swift; sourceTree = ""; }; + 8ED737CB1253C3C5704B6C05 /* cs-CZ */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "cs-CZ"; path = "cs-CZ.lproj/Localizable.strings"; sourceTree = ""; }; + 8F7E3CE2105E4A39032CD919 /* Enums+CustomStringConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Enums+CustomStringConvertible.swift"; sourceTree = ""; }; + 901BE31021AF27DB5D326327 /* STPCardBrandTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardBrandTest.swift; sourceTree = ""; }; + 90D0900C5FDBB7952BCF2C3A /* en-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-GB"; path = "en-GB.lproj/Localizable.strings"; sourceTree = ""; }; + 916DB8789F65D3C1BCB510C0 /* STPPaymentMethodThreeDSecureUsageTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodThreeDSecureUsageTest.swift; sourceTree = ""; }; + 917154477796779ECFA1334A /* STPPostalCodeInputTextFieldFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPostalCodeInputTextFieldFormatterTests.swift; sourceTree = ""; }; + 924E878428D15506711CA628 /* STPPhoneNumberValidatorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPhoneNumberValidatorTest.swift; sourceTree = ""; }; + 939360978872BBE4334215B1 /* STPPaymentCardTextFieldTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentCardTextFieldTest.swift; sourceTree = ""; }; + 940209E5D30E86E856016906 /* STPPaymentMethodUPITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodUPITests.swift; sourceTree = ""; }; + 9466F23BA8712EA2EDA48BBD /* STPIntentActionPromptPayDisplayQrCodeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPIntentActionPromptPayDisplayQrCodeTest.swift; sourceTree = ""; }; + 94A7104C1C470515616E4D2B /* STPCardCVCInputTextFieldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardCVCInputTextFieldTests.swift; sourceTree = ""; }; + 9631915F03F157A1CC3FEFFE /* STPPaymentMethodSwishParamsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodSwishParamsTests.swift; sourceTree = ""; }; + 967C784618A074FF021B3089 /* STPBankAccountParamsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPBankAccountParamsTest.swift; sourceTree = ""; }; + 967DDC94B687B14E07842CC8 /* STPConnectAccountAddressTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPConnectAccountAddressTest.swift; sourceTree = ""; }; + 969E196AB597EEF68C38103E /* Project-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Release.xcconfig"; sourceTree = ""; }; + 96E1FED5CE5974C9C1162E93 /* STPSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPSectionHeaderView.swift; sourceTree = ""; }; + 98544B08552407D41D398C68 /* STPShippingMethodTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPShippingMethodTableViewCell.swift; sourceTree = ""; }; + 989411FA3CD0CCC38BC227F4 /* STPBankAccountFunctionalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPBankAccountFunctionalTest.swift; sourceTree = ""; }; + 98F9CB667BC68767DFB5FACD /* OneTimeCodeTextFieldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneTimeCodeTextFieldTests.swift; sourceTree = ""; }; + 9B782E1D974A4C131E60E2BD /* STPBackendAPIAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPBackendAPIAdapter.swift; sourceTree = ""; }; + 9B83930B631CF8EADFB606D6 /* STPPaymentMethodiDEALTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodiDEALTest.swift; sourceTree = ""; }; + 9BBCE3A905041A709E8F279A /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 9D3BBCE8C46A38D0E20DBF4E /* ms-MY */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ms-MY"; path = "ms-MY.lproj/Localizable.strings"; sourceTree = ""; }; + 9D85FA7B714BDD8D1FD83B75 /* OneTimeCodeTextFieldSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneTimeCodeTextFieldSnapshotTests.swift; sourceTree = ""; }; + 9DCCA0E8A02B4F4B23837FB4 /* MKPlacemark+PaymentSheetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MKPlacemark+PaymentSheetTests.swift"; sourceTree = ""; }; + 9EAE9A2AE65771403CE57C11 /* STPErrorBridgeTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPErrorBridgeTest.m; sourceTree = ""; }; + 9EB24EC81CE2C8D1C863B044 /* STPIntentActionLinkAuthenticateAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPIntentActionLinkAuthenticateAccount.swift; sourceTree = ""; }; + 9FEE395C4DD0E0112AF3720C /* STPInputTextFieldValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPInputTextFieldValidatorTests.swift; sourceTree = ""; }; + A0E24B5689732EB9106DA232 /* STPPaymentMethodBillingDetailsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodBillingDetailsTest.swift; sourceTree = ""; }; + A1272F2E05A0E294DD9ECA26 /* STPApplePayContextFunctionalTestExtras.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPApplePayContextFunctionalTestExtras.swift; sourceTree = ""; }; + A15BBFFA401852A8719E3DDD /* STPPaymentMethodCardChecksTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodCardChecksTest.swift; sourceTree = ""; }; + A1C67AC5D415615E9F27D3E3 /* STPPaymentMethodBoletoParamsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodBoletoParamsTests.swift; sourceTree = ""; }; + A1C876DC7F3E31D7189506A8 /* STPPaymentContextAmountModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentContextAmountModel.swift; sourceTree = ""; }; + A22E5B87755C1F05C3DB438C /* STPInputTextFieldFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPInputTextFieldFormatterTests.swift; sourceTree = ""; }; + A2922A32A754CFC9AB8B48AE /* STPPaymentOptionsViewControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentOptionsViewControllerTest.swift; sourceTree = ""; }; + A39580123A4F1EA96F91768A /* STPAddressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPAddressTests.swift; sourceTree = ""; }; + A41F721AEBB942BB81408A59 /* STPPaymentMethodSEPADebitTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodSEPADebitTest.swift; sourceTree = ""; }; + A5398E1156E0BFEBBF56FD2F /* String+Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Localized.swift"; sourceTree = ""; }; + A583966A33DCDCF04322A592 /* STPThreeDSTextFieldCustomizationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPThreeDSTextFieldCustomizationTest.swift; sourceTree = ""; }; + A61763BA2CCA86F9B8FD4F1F /* OperationDebouncerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationDebouncerTests.swift; sourceTree = ""; }; + A6269E77F81C32A5EC8BE412 /* STPPaymentMethodPrzelewy24ParamsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodPrzelewy24ParamsTests.swift; sourceTree = ""; }; + A6F6634AD12771A9BB100DD3 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + A7E369CEC9F5B3758F78E88F /* UINavigationBar+Stripe_Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Stripe_Theme.swift"; sourceTree = ""; }; + A8598727045C6268B57A5FC7 /* Stripe-umbrella.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Stripe-umbrella.h"; sourceTree = ""; }; + A9A1BB31C7B514984231125B /* STPPaymentOptionsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentOptionsViewController.swift; sourceTree = ""; }; + AA3CA5B4B91838CC7ED4D5EB /* ro-RO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ro-RO"; path = "ro-RO.lproj/Localizable.strings"; sourceTree = ""; }; + AAF368BCD5990EE5DC17D299 /* ImageTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageTest.swift; sourceTree = ""; }; + AB1A92C77AC61335184BBDBC /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/Localizable.strings"; sourceTree = ""; }; + ABAABB969BFB7102D5AA5598 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; + ACE8998EAF997A78759E49B5 /* STPPaymentIntentTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentIntentTest.swift; sourceTree = ""; }; + ACF450AD17FF7BCE5916DDF1 /* STPPaymentHandlerFunctionalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentHandlerFunctionalTest.swift; sourceTree = ""; }; + AD06AED0AF8A9A7FB4A2E66F /* STPIntentActionAlipayHandleRedirectTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPIntentActionAlipayHandleRedirectTest.swift; sourceTree = ""; }; + AD8BED2A6066514B51693172 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + AF342CBC167F9CAB5B49CC32 /* PayWithLinkButtonSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayWithLinkButtonSnapshotTests.swift; sourceTree = ""; }; + AFF957F38AABE5F748C38C0B /* STPLocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPLocalizedString.swift; sourceTree = ""; }; + B1217AD643A9E8F88B60F645 /* STPUserInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPUserInformation.swift; sourceTree = ""; }; + B334078D1C3E629BEB498BAB /* nn-NO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "nn-NO"; path = "nn-NO.lproj/Localizable.strings"; sourceTree = ""; }; + B3E0745D13EB19BAA24F3BA3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + B407FE2D39775902A95B1118 /* StripeiOS Tests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "StripeiOS Tests-Bridging-Header.h"; sourceTree = ""; }; + B4D12508C2F1056A7EAFEC86 /* PKPayment+StripeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKPayment+StripeTest.swift"; sourceTree = ""; }; + B4D31B0D7BD9F97AF3BB61E6 /* StripeBundleLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeBundleLocator.swift; sourceTree = ""; }; + B5B86F3355E44DF4A980B82C /* STPPaymentContextSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentContextSnapshotTests.swift; sourceTree = ""; }; + B669C53A601CD3CB0203A4B9 /* STPAPISettingsObjCBridgeTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPAPISettingsObjCBridgeTest.m; sourceTree = ""; }; + B6F3B966470A530E0DC53F8C /* STPThreeDSButtonCustomizationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPThreeDSButtonCustomizationTest.swift; sourceTree = ""; }; + B70DF0B659009041F485EE0F /* Stripe+Exports.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Stripe+Exports.swift"; sourceTree = ""; }; + B78C72B0DB434EC7F700FDE0 /* STPCoreTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCoreTableViewController.swift; sourceTree = ""; }; + B7F7AA0B7B86BA5BB2FE92CE /* STPMandateDataParamsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPMandateDataParamsTest.swift; sourceTree = ""; }; + B8DD70E5ED8E9DE8E9752C9E /* STPAPIClientTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPAPIClientTest.swift; sourceTree = ""; }; + B9BA8D8467218C7E691C9FAE /* STPAPIClientNetworkBridgeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPAPIClientNetworkBridgeTest.swift; sourceTree = ""; }; + BA08DCDD421CE92ECB61EF5C /* STPFPXBankStatusResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPFPXBankStatusResponse.swift; sourceTree = ""; }; + BAFD938F3A149A56CC99FE96 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; + BB08D2AC882B21C8ADD76B92 /* STPPaymentCardTextFieldKVOTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPPaymentCardTextFieldKVOTest.m; sourceTree = ""; }; + BB8FCDBC63A79CD1571A2DFB /* STPCustomerContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCustomerContext.swift; sourceTree = ""; }; + BC7B7ED388B6C6AE57D3D627 /* LinkAccountServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkAccountServiceTests.swift; sourceTree = ""; }; + BCBEA9E4823F08C1F5057B5A /* STPAUBECSDebitFormViewSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPAUBECSDebitFormViewSnapshotTests.swift; sourceTree = ""; }; + BD77D4B1C5B64E45F9DA09B5 /* STPConfirmCardOptionsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPConfirmCardOptionsTest.swift; sourceTree = ""; }; + BD89580A3E41D7167C30B287 /* STPAnalyticsClient+Payments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPAnalyticsClient+Payments.swift"; sourceTree = ""; }; + BEB3F9F0228008BE213706DF /* STPPaymentMethodAddressTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodAddressTest.swift; sourceTree = ""; }; + BF1CF8FB0100664A02468FBC /* STPPaymentMethodBacsDebitTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodBacsDebitTest.swift; sourceTree = ""; }; + BFB4A210A30D1D4F3D3100E5 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; + C0436A8574E7D0730641407A /* STPSourceRedirectTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPSourceRedirectTest.swift; sourceTree = ""; }; + C17799DC7FA54E758EED31A6 /* NSArray+StripeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSArray+StripeTest.swift"; sourceTree = ""; }; + C23D612FD5AD7772E1B30DCC /* STPThreeDSSelectionCustomizationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPThreeDSSelectionCustomizationTest.swift; sourceTree = ""; }; + C3B75875C55D2C2723DC5090 /* STPSource+BasicUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPSource+BasicUI.swift"; sourceTree = ""; }; + C45852D37E323E65C47348B0 /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Localizable.strings"; sourceTree = ""; }; + C5923A4DD3CD39CB64B8A8C9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + C5BEAA15B53AC5662A33D0E1 /* STPEphemeralKeyTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPEphemeralKeyTest.swift; sourceTree = ""; }; + C641A744CCEA67C07E9BFE05 /* NSLocale+STPSwizzling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSLocale+STPSwizzling.swift"; sourceTree = ""; }; + C645F78B3EFFAA083B6FD3E9 /* STPEphemeralKeyManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPEphemeralKeyManagerTest.swift; sourceTree = ""; }; + C713F58BC61A962C720AE0AE /* STPMandateCustomerAcceptanceParamsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPMandateCustomerAcceptanceParamsTest.swift; sourceTree = ""; }; + C750C2C4AB33BC232D1592BA /* STPPaymentContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentContext.swift; sourceTree = ""; }; + C7F54E1E8FFA1505D24538A6 /* STPShippingMethodsViewControllerLocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPShippingMethodsViewControllerLocalizationTests.swift; sourceTree = ""; }; + C8F8FCC84601E4ADC6B7F3CE /* PaymentAnalyticTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentAnalyticTest.swift; sourceTree = ""; }; + C980D24DDC884FECCE39139F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + CA8B8F540CD05B3DC2C5EEA6 /* STPSetupIntentTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPSetupIntentTest.swift; sourceTree = ""; }; + CBC9D4B0158266B01840AD9A /* STPPaymentMethodOXXOParamsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodOXXOParamsTests.swift; sourceTree = ""; }; + CD5AC2BFBC8141F98C00CF9F /* StripeiOS_Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StripeiOS_Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + CDDEAB86BE4711841D426F3B /* STPPaymentIntentFunctionalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentIntentFunctionalTest.swift; sourceTree = ""; }; + CEB30E3846E24FD57A44764A /* LinkInMemoryCookieStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkInMemoryCookieStoreTests.swift; sourceTree = ""; }; + CF13BAEF86594C9CABD4F42A /* STPPaymentMethodUSBankAccountParamsStubbedTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodUSBankAccountParamsStubbedTest.swift; sourceTree = ""; }; + CF902DC49DD90860BD0E5E80 /* STPPaymentIntentParamsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentIntentParamsTest.swift; sourceTree = ""; }; + CFC4BC1AB047ED88C4D13C89 /* STPApplePayTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPApplePayTest.swift; sourceTree = ""; }; + CFFE40AD9D875709F643D2E5 /* ConfirmButtonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmButtonTests.swift; sourceTree = ""; }; + D07275F94914B7E7937D24FE /* NSDictionary+StripeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSDictionary+StripeTest.swift"; sourceTree = ""; }; + D103BC590F1E0EC0C31C7B5F /* STPBankAccountTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPBankAccountTest.swift; sourceTree = ""; }; + D1859673CAD068B345F5DD7D /* STPCardCVCInputTextFieldSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardCVCInputTextFieldSnapshotTests.swift; sourceTree = ""; }; + D2F205F920E971DEA59E3C31 /* STPIntentActionTypeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPIntentActionTypeTest.swift; sourceTree = ""; }; + D3667F97B7665F699D6704BE /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; + D3803D0DED98501AA26B2EAC /* STPCardNumberInputTextFieldFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardNumberInputTextFieldFormatterTests.swift; sourceTree = ""; }; + D38184A7CD27B978DFA30E69 /* STPCardFunctionalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardFunctionalTest.swift; sourceTree = ""; }; + D3D2DB08C335695B705F544C /* STPAddressViewModelTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPAddressViewModelTest.swift; sourceTree = ""; }; + D3E0CA28591EB0748C64D1FA /* STPFileTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPFileTest.swift; sourceTree = ""; }; + D41081066DC4465734F7FCD7 /* STPPaymentMethodNetBankingParamsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodNetBankingParamsTest.swift; sourceTree = ""; }; + D42F83F785EAF24F5DC7ED1A /* STPCardCVCInputTextFieldValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardCVCInputTextFieldValidatorTests.swift; sourceTree = ""; }; + D5C4A4CC7D2E9B5AB3EC3B79 /* STPPaymentOptionsInternalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentOptionsInternalViewController.swift; sourceTree = ""; }; + D609FFD051FC01BF566665A0 /* StripeiOS Tests-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS Tests-Debug.xcconfig"; sourceTree = ""; }; + D794C5E6396B4A19DC4F6921 /* StripeUICore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeUICore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D7C4773C2D193BEDF1CBB530 /* STPCardNumberInputTextFieldValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardNumberInputTextFieldValidatorTests.swift; sourceTree = ""; }; + D87817A1D3D213AA4ADF6A4C /* STPPaymentMethodAffirmParamsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodAffirmParamsTest.swift; sourceTree = ""; }; + D93C23F55BEADF9BC74DFBDB /* STPImageLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPImageLibrary.swift; sourceTree = ""; }; + DA82BB67D434E76B9ABA4CEC /* Stripe-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Stripe-Release.xcconfig"; sourceTree = ""; }; + DAB817ED5B5DB87AE1290894 /* STPCustomerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCustomerTest.swift; sourceTree = ""; }; + DB58AC5E2E0A68221260FD44 /* STPMandateOnlineParamsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPMandateOnlineParamsTest.swift; sourceTree = ""; }; + DC3AD586DDED620B9E68F461 /* StripeErrorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeErrorTest.swift; sourceTree = ""; }; + DDC55CC034022DFAC9366E2E /* StripePaymentSheet.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripePaymentSheet.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + DE24B3BAD7E890661CCA817D /* el-GR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "el-GR"; path = "el-GR.lproj/Localizable.strings"; sourceTree = ""; }; + DE2A766FB355DD9C461939C1 /* STPPaymentMethodPayPalParamsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodPayPalParamsTests.swift; sourceTree = ""; }; + DE3D0F21FB3B3E8BCA15FF7C /* STPShippingAddressViewControllerLocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPShippingAddressViewControllerLocalizationTests.swift; sourceTree = ""; }; + DFA4E34E459EA612E065DE64 /* fr-CA */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "fr-CA"; path = "fr-CA.lproj/Localizable.strings"; sourceTree = ""; }; + DFA7A75BA785EBBE4C05DAA3 /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; + E1264C4DCB32B1FA5CE19201 /* STPFileFunctionalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPFileFunctionalTest.swift; sourceTree = ""; }; + E136A967522048B313E3C62F /* StripePaymentsUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripePaymentsUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + E1644AA33E81233EF33022BA /* STPCardValidatorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardValidatorTest.swift; sourceTree = ""; }; + E1A2173E0891B7138687D544 /* Stripe-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Stripe-Debug.xcconfig"; sourceTree = ""; }; + E1CD20E00EAD41091B71ABD5 /* NSURLComponents_StripeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSURLComponents_StripeTest.swift; sourceTree = ""; }; + E2307F2C5E53540D4ACAA1F6 /* UIToolbar+Stripe_InputAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIToolbar+Stripe_InputAccessory.swift"; sourceTree = ""; }; + E315168EF07F52B733EA77F8 /* STPSwiftFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPSwiftFixtures.swift; sourceTree = ""; }; + E3B42EBAC0DC7ED0D9200DB7 /* STPBlocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPBlocks.swift; sourceTree = ""; }; + E3C8833E7EBA7A1EBF349D19 /* StripeiOS Tests-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS Tests-Release.xcconfig"; sourceTree = ""; }; + E4194E605BB5F31E9CBB8F96 /* STPAPIClientStubbedTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPAPIClientStubbedTest.swift; sourceTree = ""; }; + E452877E5D11120B1E28A6E7 /* STPApplePayContextDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPApplePayContextDelegate.swift; sourceTree = ""; }; + E5D9F97ABC88302478220267 /* PKPaymentAuthorizationViewController+Stripe_Blocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKPaymentAuthorizationViewController+Stripe_Blocks.swift"; sourceTree = ""; }; + E6312182B5BCAB940D216650 /* ConsumerSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsumerSessionTests.swift; sourceTree = ""; }; + E68F6B90F3BC61A49570FAF4 /* UINavigationBar+StripeTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UINavigationBar+StripeTest.m"; sourceTree = ""; }; + E6DEE912364C9F4B51B374D0 /* STPPaymentCardTextFieldViewModelTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentCardTextFieldViewModelTest.swift; sourceTree = ""; }; + E7ACB4FAFAD33296DE34D036 /* STPAnalyticsClientPaymentsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPAnalyticsClientPaymentsTest.swift; sourceTree = ""; }; + E7C7D85A7FAAFDF4F59BA85E /* LinkSignupViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkSignupViewModelTests.swift; sourceTree = ""; }; + E8063E8073A32E0B081A1DFA /* STPPaymentMethodSofortTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodSofortTests.swift; sourceTree = ""; }; + E89551702F1F9A3AFF1ED676 /* STPSourceVerificationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPSourceVerificationTest.swift; sourceTree = ""; }; + E956CFA6317CAA8B41E217CA /* STPAPIClient+LinkAccountSessionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPAPIClient+LinkAccountSessionTest.swift"; sourceTree = ""; }; + E97018A201D01A8FA59999C2 /* STPConnectAccountFunctionalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPConnectAccountFunctionalTest.swift; sourceTree = ""; }; + EA20E0E29EDC1F61ADA226A2 /* zh-HK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-HK"; path = "zh-HK.lproj/Localizable.strings"; sourceTree = ""; }; + EA9975553E669AF69F3CE437 /* STPPostalCodeInputTextFieldValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPostalCodeInputTextFieldValidatorTests.swift; sourceTree = ""; }; + EAEE347A6372AAE2735FAD6F /* STPPaymentMethodFPXTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodFPXTest.swift; sourceTree = ""; }; + EB6AE83989B0596F0C111E13 /* STPStringUtilsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPStringUtilsTest.swift; sourceTree = ""; }; + EB71A4A2762CF864DB198BCF /* Project-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Debug.xcconfig"; sourceTree = ""; }; + ECBA5B09A3FD875C93218573 /* STPPaymentMethodAfterpayClearpayParamsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodAfterpayClearpayParamsTest.swift; sourceTree = ""; }; + EDD30E5DB8DB3AA3567F5C20 /* STPPaymentOptionTuple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentOptionTuple.swift; sourceTree = ""; }; + EF48EC440E1ED5D6BAA567FF /* STPPaymentHandlerStubbedMockedFilesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentHandlerStubbedMockedFilesTests.swift; sourceTree = ""; }; + EFD2F6A5A046A620BAB75B41 /* AutoCompleteViewControllerSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteViewControllerSnapshotTests.swift; sourceTree = ""; }; + F0293B82D7078EE11F9B5639 /* UIView+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Helpers.swift"; sourceTree = ""; }; + F13E3DE09A463B4501733B87 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; + F153420EA142B5CA76E89A04 /* STPMocks.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STPMocks.h; sourceTree = ""; }; + F372EDF9C2C45E1CA2C76866 /* STPPaymentMethodGiropayParamsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodGiropayParamsTests.swift; sourceTree = ""; }; + F3C732C25FD961631BD44FDD /* STPBSBNumberValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPBSBNumberValidatorTests.swift; sourceTree = ""; }; + F44327A2B2C9483F52EE343B /* STPFormViewSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPFormViewSnapshotTests.swift; sourceTree = ""; }; + F4E5416F6AE8BED88980D6F8 /* STPPaymentMethodBancontactTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodBancontactTests.swift; sourceTree = ""; }; + F546088BA4F763334CFD3D34 /* STPImageLibraryTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPImageLibraryTest.swift; sourceTree = ""; }; + F6558C62376C2397030BD4A6 /* STPPaymentMethodPrzelewy24Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodPrzelewy24Tests.swift; sourceTree = ""; }; + F7385193226663A5B79E69ED /* STPFloatingPlaceholderTextFieldSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPFloatingPlaceholderTextFieldSnapshotTests.swift; sourceTree = ""; }; + FA793904C7B2D3AA0A4D5EFB /* STPAddCardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPAddCardViewController.swift; sourceTree = ""; }; + FC30E6129279F14506219E98 /* STPPaymentHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentHandlerTests.swift; sourceTree = ""; }; + FD289E1EA9F0CE1C848AC0BB /* STPCardNumberInputTextFieldSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardNumberInputTextFieldSnapshotTests.swift; sourceTree = ""; }; + FD3398E2352CEA0264F20AEA /* stp_test_upload_image.jpeg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = stp_test_upload_image.jpeg; sourceTree = ""; }; + FDA32D0C9E8A7A69F4899EDC /* RotatingCardBrandsViewSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RotatingCardBrandsViewSnapshotTests.swift; sourceTree = ""; }; + FDF7394DDD552EDE996EAD8E /* STPPostalCodeValidatorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPostalCodeValidatorTest.swift; sourceTree = ""; }; + FE2DED6ABA7407C17C1391B6 /* MockFiles */ = {isa = PBXFileReference; path = MockFiles; sourceTree = ""; }; + FF5E08A1651D9DFE502DA021 /* STPCardFormViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPCardFormViewTests.swift; sourceTree = ""; }; + FF93C4B7A5F8FA4E7919794F /* STPPaymentMethodEPSParamsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodEPSParamsTests.swift; sourceTree = ""; }; + FFCF9EB77A45F3E9E83F5D8B /* STPRedirectContextTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPRedirectContextTest.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 1587AD7E52759FBC80315765 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 32874C6147344A9CB2EF4DAD /* Stripe3DS2.framework in Frameworks */, + 8520A27C204A068C43592024 /* StripeApplePay.framework in Frameworks */, + E97168F37D769524B58461B6 /* StripeCore.framework in Frameworks */, + 360EEE8B706D2A4A49666F7A /* StripePayments.framework in Frameworks */, + 9363F8F389C04C19B37D0F0A /* StripePaymentsUI.framework in Frameworks */, + EA34719659CB9F1A269FECC7 /* StripeUICore.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6ACBDD66E08F7CEE34A8FFFA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AC7C127B11A60222465F4696 /* XCTest.framework in Frameworks */, + D151C8724925DCBA4BA4F46A /* StripeCoreTestUtils.framework in Frameworks */, + 420F8CAB4FAD6D9AF4AF25C0 /* StripePaymentSheet.framework in Frameworks */, + EEA502DF8809B8FD0D00785E /* StripePayments.framework in Frameworks */, + E9C690F3629C0AC3CD0260AF /* StripePaymentsObjcTestUtils.framework in Frameworks */, + 5302F9246A4A6381CB4FB874 /* StripePaymentsTestUtils.framework in Frameworks */, + 03E60F9EF24C975AF90E2447 /* StripePaymentsUI.framework in Frameworks */, + 0FA3C1494BA57884B5DE3B20 /* Stripe.framework in Frameworks */, + 2EB68A59660A4D1E14799DA4 /* OHHTTPStubs in Frameworks */, + 2CD7968DA48F7129E16EA0CB /* OHHTTPStubsSwift in Frameworks */, + 5C51167CC14F653E7117BA61 /* OCMock in Frameworks */, + B795A5EB8FDECA1060A9655C /* iOSSnapshotTestCase in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7129AA5AD64208D3B1A76AAE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9291A08CCB34504FCA4B7481 /* XCTest.framework in Frameworks */, + 2F0FC4E67BE577AD66CD1475 /* StripePaymentSheet.framework in Frameworks */, + 78B70C2EE8334F0FA91439CA /* Stripe.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C04854A8658964DDD0A15E1C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 00AA0CCB7109D2B82EF8EEA0 /* Project */ = { + isa = PBXGroup; + children = ( + D0F67D3A4FA2B67D2AB0A49F /* BuildConfigurations */, + 97BE66955C28DB78DA12F5F6 /* BuildConfigurations */, + B035E857851EAF160C88DC2B /* StripeiOS */, + 9C700DBA02FFEC23B92EF107 /* StripeiOSAppHostedTests */, + CFD249F84390F2310542962A /* StripeiOSTestHostApp */, + EB228B1D404A48918221D9D6 /* StripeiOSTests */, + 4E29B46F2C940E0A21734E09 /* StripeiOSTests.xctestplan */, + ); + name = Project; + sourceTree = ""; + }; + 437EB711F100257495D02BD7 /* Resources */ = { + isa = PBXGroup; + children = ( + 147D2DC1FFDFC99269039377 /* LaunchScreen.storyboard */, + 884C01B087B4D820395BD374 /* Main.storyboard */, + 6955B3A3353F8442E4FBBBF6 /* Assets.xcassets */, + ); + path = Resources; + sourceTree = ""; + }; + 7EA3633D452F7A9C017F4D52 /* Source */ = { + isa = PBXGroup; + children = ( + 8F7E3CE2105E4A39032CD919 /* Enums+CustomStringConvertible.swift */, + 273EE407039913F0B644172B /* PKAddPaymentPassRequest+Stripe_Error.swift */, + E5D9F97ABC88302478220267 /* PKPaymentAuthorizationViewController+Stripe_Blocks.swift */, + FA793904C7B2D3AA0A4D5EFB /* STPAddCardViewController.swift */, + 498C3FB07CFD532779C755D3 /* STPAddress+BasicUI.swift */, + 72903593DC432D01720DC9D9 /* STPAddressFieldTableViewCell.swift */, + 28A50AFA4603E488FF3D82D0 /* STPAddressViewModel.swift */, + 12DBB3F72AEFB52DE27C27ED /* STPAnalyticsClient+BasicUI.swift */, + BD89580A3E41D7167C30B287 /* STPAnalyticsClient+Payments.swift */, + 1E8AFAE24610EC983727F860 /* STPAPIClient+BasicUI.swift */, + 807FF966F1DE05F3496B817B /* STPAPIClient+PushProvisioning.swift */, + E452877E5D11120B1E28A6E7 /* STPApplePayContextDelegate.swift */, + 03ACDC7EEC28D1FE50008F65 /* STPApplePayPaymentOption.swift */, + 9B782E1D974A4C131E60E2BD /* STPBackendAPIAdapter.swift */, + 458F8576215E0F8ECE1D74CE /* STPBankSelectionTableViewCell.swift */, + 81352A0CBE46A59E6B1A712E /* STPBankSelectionViewController.swift */, + E3B42EBAC0DC7ED0D9200DB7 /* STPBlocks.swift */, + 61F8308B7250B642D19827D8 /* STPCameraView.swift */, + 3E5FB20B2BEFC00D54FDD87D /* STPCard+BasicUI.swift */, + 69BD038947E8E2376A0D240B /* STPCardScanner.swift */, + 0B91C4D5B93FF71C61B140F1 /* STPCardScannerTableViewCell.swift */, + 6A9E7B637A8747431B38FD1D /* STPCardValidationState.swift */, + 2D1525AF65BDEF691F8BCBE8 /* STPCoreScrollViewController.swift */, + B78C72B0DB434EC7F700FDE0 /* STPCoreTableViewController.swift */, + 02FC9ED423D40C88D5A24441 /* STPCoreViewController.swift */, + BB8FCDBC63A79CD1571A2DFB /* STPCustomerContext.swift */, + 890660C21E3666CE7B82695B /* STPEphemeralKey.swift */, + 588C260880FFC584A00A89F5 /* STPEphemeralKeyManager.swift */, + 485E747DA1F72F091986787B /* STPEphemeralKeyProvider.swift */, + 0669B4CA326CE74D125C789C /* STPFakeAddPaymentPassViewController.swift */, + BA08DCDD421CE92ECB61EF5C /* STPFPXBankStatusResponse.swift */, + D93C23F55BEADF9BC74DFBDB /* STPImageLibrary.swift */, + 9EB24EC81CE2C8D1C863B044 /* STPIntentActionLinkAuthenticateAccount.swift */, + AFF957F38AABE5F748C38C0B /* STPLocalizedString.swift */, + 1FFBAA4B44967B157A4F4E91 /* STPPaymentActivityIndicatorView.swift */, + 01B42BE6FB5EC1F708875AB8 /* STPPaymentCardTextFieldCell.swift */, + 18FCB69CD3B8C3DAB216A5F0 /* STPPaymentConfiguration.swift */, + C750C2C4AB33BC232D1592BA /* STPPaymentContext.swift */, + A1C876DC7F3E31D7189506A8 /* STPPaymentContextAmountModel.swift */, + 6F01150CD0255164FE2CF3A4 /* STPPaymentIntentParams+BasicUI.swift */, + 35C1E9B0EE03825DABF6471A /* STPPaymentMethod+BasicUI.swift */, + 53C5AB22D6328E85A6DDF663 /* STPPaymentMethodParams+BasicUI.swift */, + 30B694A39D54886392AA5DE3 /* STPPaymentOption.swift */, + D5C4A4CC7D2E9B5AB3EC3B79 /* STPPaymentOptionsInternalViewController.swift */, + A9A1BB31C7B514984231125B /* STPPaymentOptionsViewController.swift */, + 6215A9BF343775B1BD0F62AF /* STPPaymentOptionTableViewCell.swift */, + EDD30E5DB8DB3AA3567F5C20 /* STPPaymentOptionTuple.swift */, + 7D964D9E01627B419B4BD23C /* STPPaymentResult.swift */, + 3EBB07171F6FDCE6E20C454A /* STPPinManagementService.swift */, + 6887F19BB9804BF45FD703FF /* STPPushProvisioningContext.swift */, + 89E5DA3029F141B5111A5B2C /* STPPushProvisioningDetails.swift */, + 2B28B8A547CD846277ECD578 /* STPPushProvisioningDetailsParams.swift */, + 96E1FED5CE5974C9C1162E93 /* STPSectionHeaderView.swift */, + 71711FC8E2FB66E52A5FDD9A /* STPShippingAddressViewController.swift */, + 21E4B84223DDA131544DBBA7 /* STPShippingMethodsViewController.swift */, + 98544B08552407D41D398C68 /* STPShippingMethodTableViewCell.swift */, + C3B75875C55D2C2723DC5090 /* STPSource+BasicUI.swift */, + 8E8CA4361964E1BA400EFC89 /* STPTheme.swift */, + B1217AD643A9E8F88B60F645 /* STPUserInformation.swift */, + A5398E1156E0BFEBBF56FD2F /* String+Localized.swift */, + B70DF0B659009041F485EE0F /* Stripe+Exports.swift */, + B4D31B0D7BD9F97AF3BB61E6 /* StripeBundleLocator.swift */, + 3B3000668A75E095B514241F /* UIBarButtonItem+Stripe.swift */, + A7E369CEC9F5B3758F78E88F /* UINavigationBar+Stripe_Theme.swift */, + 2C078573F46762353664AC92 /* UINavigationController+Stripe_Completion.swift */, + 5476BD87E0480A93958F0328 /* UITableViewCell+Stripe_Borders.swift */, + E2307F2C5E53540D4ACAA1F6 /* UIToolbar+Stripe_InputAccessory.swift */, + F0293B82D7078EE11F9B5639 /* UIView+Helpers.swift */, + 1B76DF0FE363F59BF0940A8B /* UIView+Stripe_FirstResponder.swift */, + 86798C95A778362EF815B4C6 /* UIView+Stripe_SafeAreaBounds.swift */, + 2F08757CA6F6B2DA65C14E0A /* UIViewController+Stripe_KeyboardAvoiding.swift */, + 6F017A08C7E633FB4297D274 /* UIViewController+Stripe_NavigationItemProxy.swift */, + 5804C2B9C0704E386B3D25A4 /* UIViewController+Stripe_ParentViewController.swift */, + 26302721419496F37DE91DF8 /* UserDefaults+Stripe.swift */, + ); + path = Source; + sourceTree = ""; + }; + 85B4F3432AB3A8B94C9D2591 /* Products */ = { + isa = PBXGroup; + children = ( + 4259421D2CD26E37B96F97B2 /* Stripe.framework */, + 33FDC634FD5D79E824240DDC /* Stripe3DS2.framework */, + 52F8AEC50D4623F80F04A533 /* StripeApplePay.framework */, + 43B4E4B85C598D7A9AFCB4D4 /* StripeCore.framework */, + 512A0E7C246D5F044245E069 /* StripeCoreTestUtils.framework */, + CD5AC2BFBC8141F98C00CF9F /* StripeiOS_Tests.xctest */, + 1DD6897858F46976A946394E /* StripeiOSAppHostedTests.xctest */, + 294CD46E24BB2743042872D7 /* StripeiOSTestHostApp.app */, + 22D1C6EB5826E2D7C80B6CF3 /* StripePayments.framework */, + DDC55CC034022DFAC9366E2E /* StripePaymentSheet.framework */, + 5D237177A7F99EB0F5F4F5E4 /* StripePaymentsObjcTestUtils.framework */, + 77247622AB08FEF48CA0DC26 /* StripePaymentsTestUtils.framework */, + E136A967522048B313E3C62F /* StripePaymentsUI.framework */, + D794C5E6396B4A19DC4F6921 /* StripeUICore.framework */, + ); + name = Products; + sourceTree = ""; + }; + 97BE66955C28DB78DA12F5F6 /* BuildConfigurations */ = { + isa = PBXGroup; + children = ( + EB71A4A2762CF864DB198BCF /* Project-Debug.xcconfig */, + 969E196AB597EEF68C38103E /* Project-Release.xcconfig */, + D609FFD051FC01BF566665A0 /* StripeiOS Tests-Debug.xcconfig */, + E3C8833E7EBA7A1EBF349D19 /* StripeiOS Tests-Release.xcconfig */, + ); + name = BuildConfigurations; + path = ../BuildConfigurations; + sourceTree = ""; + }; + 9C700DBA02FFEC23B92EF107 /* StripeiOSAppHostedTests */ = { + isa = PBXGroup; + children = ( + 65DCC9BDA647E58D3882C698 /* Info.plist */, + 10BAD33BEC6C2894F9266902 /* LinkSecureCookieStoreTests.swift */, + ); + path = StripeiOSAppHostedTests; + sourceTree = ""; + }; + AF41AE099441C2A09DECC1AC /* Localizations */ = { + isa = PBXGroup; + children = ( + C2427C1CDFA85BFC6570F1E9 /* Localizable.strings */, + ); + path = Localizations; + sourceTree = ""; + }; + B035E857851EAF160C88DC2B /* StripeiOS */ = { + isa = PBXGroup; + children = ( + FBC7A77342A98B1DE8E416B7 /* Resources */, + 7EA3633D452F7A9C017F4D52 /* Source */, + C5923A4DD3CD39CB64B8A8C9 /* Info.plist */, + A8598727045C6268B57A5FC7 /* Stripe-umbrella.h */, + ); + path = StripeiOS; + sourceTree = ""; + }; + B7A38600F42999DA8F83AD27 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 005650A59D692F820EF20F5F /* XCTest.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + CFD249F84390F2310542962A /* StripeiOSTestHostApp */ = { + isa = PBXGroup; + children = ( + 437EB711F100257495D02BD7 /* Resources */, + 9BBCE3A905041A709E8F279A /* AppDelegate.swift */, + B3E0745D13EB19BAA24F3BA3 /* Info.plist */, + 1F29C15B47C7CB0941CD4C9E /* ViewController.swift */, + ); + path = StripeiOSTestHostApp; + sourceTree = ""; + }; + D0F67D3A4FA2B67D2AB0A49F /* BuildConfigurations */ = { + isa = PBXGroup; + children = ( + 1D23EB567F573612E0794B3A /* Stripe Tests-Debug.xcconfig */, + 5E4EA6394497D1BD57ED0032 /* Stripe Tests-Release.xcconfig */, + E1A2173E0891B7138687D544 /* Stripe-Debug.xcconfig */, + DA82BB67D434E76B9ABA4CEC /* Stripe-Release.xcconfig */, + ); + path = BuildConfigurations; + sourceTree = ""; + }; + E4802964A9471C082CE01BA9 = { + isa = PBXGroup; + children = ( + 00AA0CCB7109D2B82EF8EEA0 /* Project */, + B7A38600F42999DA8F83AD27 /* Frameworks */, + 85B4F3432AB3A8B94C9D2591 /* Products */, + ); + sourceTree = ""; + }; + EB228B1D404A48918221D9D6 /* StripeiOSTests */ = { + isa = PBXGroup; + children = ( + EB90CE5EA5682921B4C247A0 /* Resources */, + 3C742844915B96CFD25BFFF9 /* AfterpayPriceBreakdownViewSnapshotTests.swift */, + 61AF6E95FE0DD913204CAB32 /* AnalyticsHelperTests.swift */, + 85AAB72218409F85FE29E69E /* APIRequestTest.swift */, + EFD2F6A5A046A620BAB75B41 /* AutoCompleteViewControllerSnapshotTests.swift */, + 153071C69A0BEE033E035DCF /* CardExpiryDateTests.swift */, + 46AC0B5EC7433E081825D31B /* CircularButtonSnapshotTests.swift */, + 806124200E77795DCFC8418E /* ConfirmButtonSnapshotTests.swift */, + CFFE40AD9D875709F643D2E5 /* ConfirmButtonTests.swift */, + E6312182B5BCAB940D216650 /* ConsumerSessionTests.swift */, + 11C866064B3482878A69892F /* CustomerAdapterTests.swift */, + 8CE85C770AEEDBE4AEC93EAA /* Error+PaymentSheetTests.swift */, + 180CF848E3ABF0236C494D8B /* FBSnapshotTestCase+STPViewControllerLoading.swift */, + 7C04AFC9CDE50D09D38A3232 /* FormSpecProviderTest.swift */, + 45FF7A07CFC3B9B7AD6B49EE /* FraudDetectionDataTest.swift */, + AAF368BCD5990EE5DC17D299 /* ImageTest.swift */, + 0E10C4D64477638398251FFB /* Info.plist */, + 436074876478126262BD05C0 /* IntentConfirmParamsTest.swift */, + 8D866DCC403994A0D3CFB1D7 /* KlarnaHelperTest.swift */, + BC7B7ED388B6C6AE57D3D627 /* LinkAccountServiceTests.swift */, + CEB30E3846E24FD57A44764A /* LinkInMemoryCookieStoreTests.swift */, + 835CB781FBC19773ACC20676 /* LinkLegalTermsViewSnapshotTests.swift */, + E7C7D85A7FAAFDF4F59BA85E /* LinkSignupViewModelTests.swift */, + 9DCCA0E8A02B4F4B23837FB4 /* MKPlacemark+PaymentSheetTests.swift */, + C17799DC7FA54E758EED31A6 /* NSArray+StripeTest.swift */, + 20552E792B8E7BA15821AB5D /* NSDecimalNumber+StripeTest.swift */, + D07275F94914B7E7937D24FE /* NSDictionary+StripeTest.swift */, + C641A744CCEA67C07E9BFE05 /* NSLocale+STPSwizzling.swift */, + 63F5F35DB97D8A176FB6ED24 /* NSString+StripeTest.swift */, + E1CD20E00EAD41091B71ABD5 /* NSURLComponents_StripeTest.swift */, + 9D85FA7B714BDD8D1FD83B75 /* OneTimeCodeTextFieldSnapshotTests.swift */, + 98F9CB667BC68767DFB5FACD /* OneTimeCodeTextFieldTests.swift */, + A61763BA2CCA86F9B8FD4F1F /* OperationDebouncerTests.swift */, + C8F8FCC84601E4ADC6B7F3CE /* PaymentAnalyticTest.swift */, + 7B9A4A2B0FB9F8C743BBED48 /* PaymentTypeCellSnapshotTests.swift */, + AF342CBC167F9CAB5B49CC32 /* PayWithLinkButtonSnapshotTests.swift */, + B4D12508C2F1056A7EAFEC86 /* PKPayment+StripeTest.swift */, + FDA32D0C9E8A7A69F4899EDC /* RotatingCardBrandsViewSnapshotTests.swift */, + 12632E6710DE8861CAF1BAA4 /* RotatingCardBrandsViewTests.swift */, + 20879436DCFB1F03BE1608B3 /* ServerErrorMapperTest.swift */, + 78BA15D1F0844B1DA71A5348 /* STPAddCardViewControllerLocalizationTests.swift */, + 8704ABFA91A5226847F4A69A /* STPAddCardViewControllerTest.swift */, + A39580123A4F1EA96F91768A /* STPAddressTests.swift */, + D3D2DB08C335695B705F544C /* STPAddressViewModelTest.swift */, + 0241DB84973B21393BEC703E /* STPAnalyticsClientPaymentSheetTest.swift */, + E7ACB4FAFAD33296DE34D036 /* STPAnalyticsClientPaymentsTest.swift */, + E956CFA6317CAA8B41E217CA /* STPAPIClient+LinkAccountSessionTest.swift */, + B9BA8D8467218C7E691C9FAE /* STPAPIClientNetworkBridgeTest.swift */, + E4194E605BB5F31E9CBB8F96 /* STPAPIClientStubbedTest.swift */, + B8DD70E5ED8E9DE8E9752C9E /* STPAPIClientTest.swift */, + B669C53A601CD3CB0203A4B9 /* STPAPISettingsObjCBridgeTest.m */, + 1CE268457D21A7209862E004 /* STPApplePayContextFunctionalTest.swift */, + A1272F2E05A0E294DD9ECA26 /* STPApplePayContextFunctionalTestExtras.swift */, + 5F7AB40A5A10C2D267323ABE /* STPApplePayContextTest.swift */, + 3C77C7BC4BA57EC296CF2F1C /* STPApplePayFunctionalTest.swift */, + 00985EFC6CB7B912FDBF3813 /* STPApplePayPaymentOptionTest.swift */, + CFC4BC1AB047ED88C4D13C89 /* STPApplePayTest.swift */, + BCBEA9E4823F08C1F5057B5A /* STPAUBECSDebitFormViewSnapshotTests.swift */, + 4AAE5EE11611F9F7762B64C6 /* STPAUBECSFormViewModelTests.swift */, + 989411FA3CD0CCC38BC227F4 /* STPBankAccountFunctionalTest.swift */, + 967C784618A074FF021B3089 /* STPBankAccountParamsTest.swift */, + D103BC590F1E0EC0C31C7B5F /* STPBankAccountTest.swift */, + 23997D61DF41CA84BFC33080 /* STPBECSDebitAccountNumberValidatorTests.swift */, + 3FC0560A312147C37CFE6CF9 /* STPBinRangeTest.swift */, + 4FFA8B446217CDE678D7287F /* STPBlocks.h */, + F3C732C25FD961631BD44FDD /* STPBSBNumberValidatorTests.swift */, + 3E1C5E08678292561255B1C5 /* STPCardBINMetadataTests.swift */, + 2177A76093365DC8CD6EA05E /* STPCardBrandChoiceTest.swift */, + 901BE31021AF27DB5D326327 /* STPCardBrandTest.swift */, + 6E1F6514E7530C2A3478B2F5 /* STPCardCVCInputTextFieldFormatterTests.swift */, + D1859673CAD068B345F5DD7D /* STPCardCVCInputTextFieldSnapshotTests.swift */, + 94A7104C1C470515616E4D2B /* STPCardCVCInputTextFieldTests.swift */, + D42F83F785EAF24F5DC7ED1A /* STPCardCVCInputTextFieldValidatorTests.swift */, + 40BB87E28719FE0C6B946BB5 /* STPCardExpiryInputTextFieldFormatterTests.swift */, + 0C75157665428685C7A4FD20 /* STPCardExpiryInputTextFieldSnapshotTests.swift */, + 4418164D75002AE6A0273176 /* STPCardExpiryInputTextFieldValidatorTests.swift */, + 1D983E089196152DA1C69469 /* STPCardFormViewSnapshotTests.swift */, + FF5E08A1651D9DFE502DA021 /* STPCardFormViewTests.swift */, + D38184A7CD27B978DFA30E69 /* STPCardFunctionalTest.swift */, + D3803D0DED98501AA26B2EAC /* STPCardNumberInputTextFieldFormatterTests.swift */, + FD289E1EA9F0CE1C848AC0BB /* STPCardNumberInputTextFieldSnapshotTests.swift */, + D7C4773C2D193BEDF1CBB530 /* STPCardNumberInputTextFieldValidatorTests.swift */, + 8A3F2B714DB3D1DED561A7EF /* STPCardParamsTest.swift */, + 1D575C31524E596E9C1A8E9B /* STPCardTest.swift */, + E1644AA33E81233EF33022BA /* STPCardValidatorTest.swift */, + 1D51B04D83D4FEF7F90DF16A /* STPCertTest.swift */, + BD77D4B1C5B64E45F9DA09B5 /* STPConfirmCardOptionsTest.swift */, + 05FE1BA89B80336F16924FA2 /* STPConfirmPaymentMethodOptionsTest.swift */, + 967DDC94B687B14E07842CC8 /* STPConnectAccountAddressTest.swift */, + E97018A201D01A8FA59999C2 /* STPConnectAccountFunctionalTest.swift */, + 195DEC752CC82CC4BA1E2351 /* STPConnectAccountParamsTest.swift */, + 8D49257A97E71A475A9F6E08 /* STPCountryPickerInputFieldSnapshotTests.swift */, + 2CC4B06AB5C02FF54091E5A8 /* STPCustomerContextTest.swift */, + DAB817ED5B5DB87AE1290894 /* STPCustomerTest.swift */, + 1C1548BA518F7AC2A9ECF9D5 /* STPE2ETest.swift */, + 63BDA70DB74745C5F457CF88 /* STPElementsSessionTest.swift */, + C645F78B3EFFAA083B6FD3E9 /* STPEphemeralKeyManagerTest.swift */, + C5BEAA15B53AC5662A33D0E1 /* STPEphemeralKeyTest.swift */, + 9EAE9A2AE65771403CE57C11 /* STPErrorBridgeTest.m */, + E1264C4DCB32B1FA5CE19201 /* STPFileFunctionalTest.swift */, + D3E0CA28591EB0748C64D1FA /* STPFileTest.swift */, + F7385193226663A5B79E69ED /* STPFloatingPlaceholderTextFieldSnapshotTests.swift */, + 30964128998473CAA9F2DD7E /* STPFormEncoderTest.swift */, + 2E1862744F23286D1FB9D4AE /* STPFormTextFieldTest.swift */, + F44327A2B2C9483F52EE343B /* STPFormViewSnapshotTests.swift */, + 85C29AE44D809CD677B5E52B /* STPFPXBankBrandTest.swift */, + 284C67269D2606DA147AE01D /* STPGenericInputPickerFieldSnapshotTests.swift */, + 2D63B73C5773432CA134D1FC /* STPGenericInputPickerFieldValidatorTest.swift */, + 58A53F005EA8FDDAA66126BA /* STPGenericInputTextFieldSnapshotTests.swift */, + F546088BA4F763334CFD3D34 /* STPImageLibraryTest.swift */, + A22E5B87755C1F05C3DB438C /* STPInputTextFieldFormatterTests.swift */, + 9FEE395C4DD0E0112AF3720C /* STPInputTextFieldValidatorTests.swift */, + AD06AED0AF8A9A7FB4A2E66F /* STPIntentActionAlipayHandleRedirectTest.swift */, + 7A517686D4D12691351311CA /* STPIntentActionPayNowDisplayQrCodeTest.swift */, + 9466F23BA8712EA2EDA48BBD /* STPIntentActionPromptPayDisplayQrCodeTest.swift */, + 33B9D01D037909D1C9C0B617 /* STPIntentActionTest.swift */, + D2F205F920E971DEA59E3C31 /* STPIntentActionTypeTest.swift */, + 4CA5D11C977A95B8E936E907 /* STPIntentActionWeChatPayRedirectToAppTest.swift */, + 6C8FE751333F580004BD72BA /* STPIntentWithPreferencesTest.swift */, + 78D1EEABBAE5BD5615486B0F /* STPLabeledFormTextFieldViewSnapshotTests.swift */, + 7F68637E75142DCD46710796 /* STPLabeledMultiFormTextFieldViewSnapshotTests.swift */, + C713F58BC61A962C720AE0AE /* STPMandateCustomerAcceptanceParamsTest.swift */, + B7F7AA0B7B86BA5BB2FE92CE /* STPMandateDataParamsTest.swift */, + DB58AC5E2E0A68221260FD44 /* STPMandateOnlineParamsTest.swift */, + F153420EA142B5CA76E89A04 /* STPMocks.h */, + 88AEABC15CCBB9EA393C175F /* STPMocks.m */, + 6223E57D3A198F956A37ED89 /* STPNumericDigitInputTextFormatterTests.swift */, + 4AA36705DED9164663A98B6A /* STPNumericStringValidatorTests.swift */, + BB08D2AC882B21C8ADD76B92 /* STPPaymentCardTextFieldKVOTest.m */, + 939360978872BBE4334215B1 /* STPPaymentCardTextFieldTest.swift */, + 1F16C36797D978E72E612100 /* STPPaymentCardTextFieldTestsSwift.swift */, + E6DEE912364C9F4B51B374D0 /* STPPaymentCardTextFieldViewModelTest.swift */, + 79ABE6A14AF9D14103050876 /* STPPaymentConfigurationTest.m */, + 3C995125252BED1EEC018B9D /* STPPaymentContextApplePayTest.swift */, + B5B86F3355E44DF4A980B82C /* STPPaymentContextSnapshotTests.swift */, + 683F7735569D22CBEC9CA2E6 /* STPPaymentHandlerFunctionalTest.m */, + ACF450AD17FF7BCE5916DDF1 /* STPPaymentHandlerFunctionalTest.swift */, + EF48EC440E1ED5D6BAA567FF /* STPPaymentHandlerStubbedMockedFilesTests.swift */, + FC30E6129279F14506219E98 /* STPPaymentHandlerTests.swift */, + 46C31CAD0FA74B58BA2B8530 /* STPPaymentIntentEnumsTest.swift */, + CDDEAB86BE4711841D426F3B /* STPPaymentIntentFunctionalTest.swift */, + 6C7B8DACB0A7294BC235E3BC /* STPPaymentIntentLastPaymentErrorTest.swift */, + CF902DC49DD90860BD0E5E80 /* STPPaymentIntentParamsTest.swift */, + ACE8998EAF997A78759E49B5 /* STPPaymentIntentTest.swift */, + BEB3F9F0228008BE213706DF /* STPPaymentMethodAddressTest.swift */, + D87817A1D3D213AA4ADF6A4C /* STPPaymentMethodAffirmParamsTest.swift */, + 1F0DF2ED9232A7CC51F5FCB1 /* STPPaymentMethodAffirmTests.swift */, + ECBA5B09A3FD875C93218573 /* STPPaymentMethodAfterpayClearpayParamsTest.swift */, + 3CDD1E823223F450193E8746 /* STPPaymentMethodAfterpayClearpayTest.swift */, + 7FF2B9FF57E100301B5C38DB /* STPPaymentMethodAUBECSDebitParamsTests.swift */, + 54426CBF6F77ABEFBDFDA8C4 /* STPPaymentMethodAUBECSDebitTests.swift */, + BF1CF8FB0100664A02468FBC /* STPPaymentMethodBacsDebitTest.swift */, + 655209238C85F466F9F14F14 /* STPPaymentMethodBancontactParamsTests.swift */, + F4E5416F6AE8BED88980D6F8 /* STPPaymentMethodBancontactTests.swift */, + A0E24B5689732EB9106DA232 /* STPPaymentMethodBillingDetailsTest.swift */, + 7AC0A18C441FCA394BEF6A3D /* STPPaymentMethodBillingDetailsTests+Link.swift */, + A1C67AC5D415615E9F27D3E3 /* STPPaymentMethodBoletoParamsTests.swift */, + 4E371E9B3B2E343FE954531C /* STPPaymentMethodBoletoTests.swift */, + A15BBFFA401852A8719E3DDD /* STPPaymentMethodCardChecksTest.swift */, + 1671EC46C713D51013AD7D8B /* STPPaymentMethodCardParamsTest.swift */, + 8BD02D8298877F10F2EF2A9D /* STPPaymentMethodCardTest.swift */, + 533538E3EB92E326CCB95506 /* STPPaymentMethodCardWalletMasterpassTest.swift */, + 2DC4B62C336EAA05A33FC384 /* STPPaymentMethodCardWalletTest.swift */, + 82E46B18CDDC191934F3D4BE /* STPPaymentMethodCardWalletVisaCheckoutTest.swift */, + 0ABB2CA7E96BE249CE8C0566 /* STPPaymentMethodCashAppParamsTests.swift */, + 7DDE50CBC86AD77084C877B6 /* STPPaymentMethodCashAppTests.swift */, + FF93C4B7A5F8FA4E7919794F /* STPPaymentMethodEPSParamsTests.swift */, + 0C44B4366D6C4FD4B11662C8 /* STPPaymentMethodEPSTests.swift */, + EAEE347A6372AAE2735FAD6F /* STPPaymentMethodFPXTest.swift */, + 8192839B0F1AE9D9F2A94504 /* STPPaymentMethodFunctionalTest.swift */, + F372EDF9C2C45E1CA2C76866 /* STPPaymentMethodGiropayParamsTests.swift */, + 583DB466066B47C0F716E474 /* STPPaymentMethodGiropayTests.swift */, + 0A8FF64CA314F909D7EC82FE /* STPPaymentMethodGrabPayParamsTest.swift */, + 9B83930B631CF8EADFB606D6 /* STPPaymentMethodiDEALTest.swift */, + 47E12A0CBFA259A032F7AF0C /* STPPaymentMethodKlarnaParamsTests.swift */, + 4FD94FF270165D699DA89B24 /* STPPaymentMethodKlarnaTests.swift */, + D41081066DC4465734F7FCD7 /* STPPaymentMethodNetBankingParamsTest.swift */, + 739934737B9A09775CD278C9 /* STPPaymentMethodNetBankingTests.swift */, + 1E2638F7AA0906914117C2D5 /* STPPaymentMethodOptionsTest.swift */, + CBC9D4B0158266B01840AD9A /* STPPaymentMethodOXXOParamsTests.swift */, + 80BBCC4D386EE44E809A591C /* STPPaymentMethodOXXOTests.swift */, + 4002981AC12687681616D21E /* STPPaymentMethodParamsTest.swift */, + DE2A766FB355DD9C461939C1 /* STPPaymentMethodPayPalParamsTests.swift */, + 2A63AC868755CB4745E7458E /* STPPaymentMethodPayPalTests.swift */, + A6269E77F81C32A5EC8BE412 /* STPPaymentMethodPrzelewy24ParamsTests.swift */, + F6558C62376C2397030BD4A6 /* STPPaymentMethodPrzelewy24Tests.swift */, + 3AE7BEADD3824A06C2994854 /* STPPaymentMethodRevolutPayParamsTests.swift */, + 7A7963CC618A1A1346EC20C7 /* STPPaymentMethodRevolutPayTests.swift */, + A41F721AEBB942BB81408A59 /* STPPaymentMethodSEPADebitTest.swift */, + 207677E2A0DBC04C88139372 /* STPPaymentMethodSofortParamsTests.swift */, + E8063E8073A32E0B081A1DFA /* STPPaymentMethodSofortTests.swift */, + 9631915F03F157A1CC3FEFFE /* STPPaymentMethodSwishParamsTests.swift */, + 5DE9F0BAC35EA14579775033 /* STPPaymentMethodSwishTests.swift */, + 148C1D7D1BBBC6B74894A869 /* STPPaymentMethodTest.swift */, + 916DB8789F65D3C1BCB510C0 /* STPPaymentMethodThreeDSecureUsageTest.swift */, + 47E5E1173A37AABB07FB68AB /* STPPaymentMethodUPIParamsTest.swift */, + 940209E5D30E86E856016906 /* STPPaymentMethodUPITests.swift */, + CF13BAEF86594C9CABD4F42A /* STPPaymentMethodUSBankAccountParamsStubbedTest.swift */, + 74FDEF9F687C63BADFB96480 /* STPPaymentMethodUSBankAccountParamsTest.swift */, + 3B112FFF3FCA82094281493F /* STPPaymentMethodUSBankAccountTest.swift */, + 6C7094644EE056260D6F3B67 /* STPPaymentOptionsViewControllerLocalizationTests.swift */, + A2922A32A754CFC9AB8B48AE /* STPPaymentOptionsViewControllerTest.swift */, + 924E878428D15506711CA628 /* STPPhoneNumberValidatorTest.swift */, + 1645D9793463E266501B74FD /* STPPIIFunctionalTest.swift */, + 336CC555B845DED30208D39D /* STPPinManagementServiceFunctionalTest.swift */, + 917154477796779ECFA1334A /* STPPostalCodeInputTextFieldFormatterTests.swift */, + 090EF7D598B8DE779C275395 /* STPPostalCodeInputTextFieldSnapshotTests.swift */, + 6618739767139C25C05B3631 /* STPPostalCodeInputTextFieldTests.swift */, + EA9975553E669AF69F3CE437 /* STPPostalCodeInputTextFieldValidatorTests.swift */, + FDF7394DDD552EDE996EAD8E /* STPPostalCodeValidatorTest.swift */, + 1A8A6B88797870BC71CCB3AF /* STPPushProvisioningDetailsFunctionalTest.swift */, + 2D878F923A1F69B58D6B2812 /* STPRadarSessionFunctionalTest.swift */, + FFCF9EB77A45F3E9E83F5D8B /* STPRedirectContextTest.swift */, + 6CC0B1FC92A573AAEA4F4E94 /* STPSetupIntentConfirmParamsTest.swift */, + 49AA313E068FB99CEAA5F7D3 /* STPSetupIntentFunctionalTest.swift */, + 01B057D99A14E5BA6019C349 /* STPSetupIntentLastSetupErrorTest.swift */, + CA8B8F540CD05B3DC2C5EEA6 /* STPSetupIntentTest.swift */, + DE3D0F21FB3B3E8BCA15FF7C /* STPShippingAddressViewControllerLocalizationTests.swift */, + 789D0B49B0788794739E3DD4 /* STPShippingAddressViewControllerTest.swift */, + C7F54E1E8FFA1505D24538A6 /* STPShippingMethodsViewControllerLocalizationTests.swift */, + 63114D0EAAE2606732DF5AA0 /* STPSourceCardDetailsTest.swift */, + 17013F78CE3F9662029FEF5B /* STPSourceFunctionalTest.swift */, + 095EBF095BA2BC8D299547DB /* STPSourceOwnerTest.swift */, + 1F6CB4B8FAD14B4D70A63595 /* STPSourceParamsTest.swift */, + 6B7A947152A728EB2CBC4DB2 /* STPSourceReceiverTest.swift */, + C0436A8574E7D0730641407A /* STPSourceRedirectTest.swift */, + 21780C410D22264B7C299520 /* STPSourceSEPADebitDetailsTest.swift */, + 05AA6A1B2A462F1CE2F537C5 /* STPSourceTest.swift */, + E89551702F1F9A3AFF1ED676 /* STPSourceVerificationTest.swift */, + 51E62BB62EA9B782778CA880 /* STPStackViewWithSeparatorSnapshotTests.swift */, + EB6AE83989B0596F0C111E13 /* STPStringUtilsTest.swift */, + E315168EF07F52B733EA77F8 /* STPSwiftFixtures.swift */, + 2A8A2CD759D465290066EF65 /* STPTextFieldDelegateProxyTests.swift */, + B6F3B966470A530E0DC53F8C /* STPThreeDSButtonCustomizationTest.swift */, + 483B243268646AE65B06E98C /* STPThreeDSFooterCustomizationTest.swift */, + 5FDD4956E0A04D33F0856F31 /* STPThreeDSLabelCustomizationTest.swift */, + 51408DE266D0345784ADD4FA /* STPThreeDSNavigationBarCustomizationTest.swift */, + C23D612FD5AD7772E1B30DCC /* STPThreeDSSelectionCustomizationTest.swift */, + A583966A33DCDCF04322A592 /* STPThreeDSTextFieldCustomizationTest.swift */, + 3ED44491EB0AC72B1B1A773C /* STPThreeDSUICustomizationTest.swift */, + 7F090B8D315E7FD12A5F9C09 /* STPTokenTest.swift */, + 88639E3C3AC622B5EF475538 /* STPUIVCStripeParentViewControllerTests.swift */, + 86983B09A1712944EC012AD4 /* STPViewWithSeparatorSnapshotTests.swift */, + DC3AD586DDED620B9E68F461 /* StripeErrorTest.swift */, + B407FE2D39775902A95B1118 /* StripeiOS Tests-Bridging-Header.h */, + 51BD2CE41E4F0CF648F44E4A /* TextFieldElement+IBANTest.swift */, + E68F6B90F3BC61A49570FAF4 /* UINavigationBar+StripeTest.m */, + 3B0E131538728BC4802627B1 /* UserDefaults+StripeTest.swift */, + 0DB03E83746FE78361831546 /* WalletHeaderViewSnapshotTests.swift */, + ); + path = StripeiOSTests; + sourceTree = ""; + }; + EB90CE5EA5682921B4C247A0 /* Resources */ = { + isa = PBXGroup; + children = ( + DFA7A75BA785EBBE4C05DAA3 /* Images.xcassets */, + FE2DED6ABA7407C17C1391B6 /* MockFiles */, + FD3398E2352CEA0264F20AEA /* stp_test_upload_image.jpeg */, + ); + path = Resources; + sourceTree = ""; + }; + FBC7A77342A98B1DE8E416B7 /* Resources */ = { + isa = PBXGroup; + children = ( + AF41AE099441C2A09DECC1AC /* Localizations */, + 77E846CD56018D8417A3AB95 /* StripeiOS.xcassets */, + ); + path = Resources; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 8D1F9C232F0DF9D129FE872E /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 30D48C62B2FA6B28EC23A5BB /* Stripe-umbrella.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 1628E8B14F1F7C63CF8C9962 /* StripeiOSTestHostApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = B6D8846CE184BBD9139D6D15 /* Build configuration list for PBXNativeTarget "StripeiOSTestHostApp" */; + buildPhases = ( + C4ED1B5A417CECDB38B21698 /* Sources */, + 9BE857B21623552BA4F3FAA1 /* Resources */, + 9F30C6D40956861F588C2CD0 /* Embed Frameworks */, + C04854A8658964DDD0A15E1C /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = StripeiOSTestHostApp; + productName = StripeiOSTestHostApp; + productReference = 294CD46E24BB2743042872D7 /* StripeiOSTestHostApp.app */; + productType = "com.apple.product-type.application"; + }; + 49A14BBD10B1E97F2B43C448 /* StripeiOSAppHostedTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = FCBB942DEBE57423D79373B4 /* Build configuration list for PBXNativeTarget "StripeiOSAppHostedTests" */; + buildPhases = ( + E160E75F2870000E2FA9C5DC /* Sources */, + C501B175E87CBBB0075EB9C5 /* Resources */, + 16FB342935F756BD2EA7CE4C /* Embed Frameworks */, + 7129AA5AD64208D3B1A76AAE /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 4E57D9EF1E91091C57E49111 /* PBXTargetDependency */, + 11BFD72CD1F291E0E86EC784 /* PBXTargetDependency */, + ); + name = StripeiOSAppHostedTests; + productName = StripeiOSAppHostedTests; + productReference = 1DD6897858F46976A946394E /* StripeiOSAppHostedTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 8BE23AD5D9A3D939AF46F31E /* StripeiOSTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 54F4DB1E9C991950FD9EB4B4 /* Build configuration list for PBXNativeTarget "StripeiOSTests" */; + buildPhases = ( + FB03810F22F4E0919BB2EF68 /* Sources */, + BADDFD2C3E190FF24FEB93C7 /* Resources */, + 5789BE2CD297329ED86678C0 /* Embed Frameworks */, + 6ACBDD66E08F7CEE34A8FFFA /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + C4F1FB6EFF8D25D54580374A /* PBXTargetDependency */, + ); + name = StripeiOSTests; + packageProductDependencies = ( + 62887B4538E4E41E735685E1 /* OHHTTPStubs */, + 911CA85A1610303FA0AF0643 /* OHHTTPStubsSwift */, + E804AA8C4156CC85FFD9595F /* OCMock */, + C55551F29B99CF6D6DD9EE2F /* iOSSnapshotTestCase */, + ); + productName = StripeiOS_Tests; + productReference = CD5AC2BFBC8141F98C00CF9F /* StripeiOS_Tests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + ADF894AA8F6022D9BED17346 /* StripeiOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = F0FCB32AE130ABA66178DD9B /* Build configuration list for PBXNativeTarget "StripeiOS" */; + buildPhases = ( + 8D1F9C232F0DF9D129FE872E /* Headers */, + 24CDC502E3D468F309116FE1 /* Sources */, + 0F32E5D5E633BAB349B6EB50 /* Resources */, + CAAA5D4C8E87896995960E8C /* Embed Frameworks */, + 1587AD7E52759FBC80315765 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = StripeiOS; + productName = Stripe; + productReference = 4259421D2CD26E37B96F97B2 /* Stripe.framework */; + productType = "com.apple.product-type.framework"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + E63832AA5BB4225708B7C838 /* Project object */ = { + isa = PBXProject; + attributes = { + TargetAttributes = { + 49A14BBD10B1E97F2B43C448 = { + TestTargetID = 1628E8B14F1F7C63CF8C9962; + }; + }; + }; + buildConfigurationList = 2804CCD7A91C99DF32596147 /* Build configuration list for PBXProject "Stripe" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + "bg-BG", + "ca-ES", + "cs-CZ", + da, + de, + "el-GR", + en, + "en-GB", + es, + "es-419", + "et-EE", + fi, + fil, + fr, + "fr-CA", + hr, + hu, + id, + it, + ja, + ko, + "lt-LT", + "lv-LV", + "ms-MY", + mt, + nb, + nl, + "nn-NO", + "pl-PL", + "pt-BR", + "pt-PT", + "ro-RO", + ru, + "sk-SK", + "sl-SI", + sv, + tk, + tr, + vi, + "zh-HK", + "zh-Hans", + "zh-Hant", + ); + mainGroup = E4802964A9471C082CE01BA9; + packageReferences = ( + D244E8B5402CB2CF89CE7872 /* XCRemoteSwiftPackageReference "ocmock" */, + 44C6210EB47ACF468D255723 /* XCRemoteSwiftPackageReference "OHHTTPStubs" */, + 71086E1440B890A9DC01C9DF /* XCRemoteSwiftPackageReference "ios-snapshot-test-case" */, + ); + productRefGroup = 85B4F3432AB3A8B94C9D2591 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + ADF894AA8F6022D9BED17346 /* StripeiOS */, + 8BE23AD5D9A3D939AF46F31E /* StripeiOSTests */, + 1628E8B14F1F7C63CF8C9962 /* StripeiOSTestHostApp */, + 49A14BBD10B1E97F2B43C448 /* StripeiOSAppHostedTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 0F32E5D5E633BAB349B6EB50 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 22BE2ABB29F77362FF16D945 /* Localizable.strings in Resources */, + 76BC927BC7A591601C1DAB18 /* StripeiOS.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9BE857B21623552BA4F3FAA1 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + EEB5E5E9C4E06B148A91C7BD /* Assets.xcassets in Resources */, + 08111F4AD3CA0755420E05F7 /* LaunchScreen.storyboard in Resources */, + DE23FEF74E860620A334FDF5 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BADDFD2C3E190FF24FEB93C7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 10342D659764A88A695EF38B /* Images.xcassets in Resources */, + F49D9C4030829D13A6EB45BE /* MockFiles in Resources */, + 2AE9ABA774B430E174279FEA /* stp_test_upload_image.jpeg in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C501B175E87CBBB0075EB9C5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 24CDC502E3D468F309116FE1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 315713352C770DA3ED9CBDCD /* Enums+CustomStringConvertible.swift in Sources */, + B6784B7F4B9B04617C0EE510 /* PKAddPaymentPassRequest+Stripe_Error.swift in Sources */, + C0688E067AE4FFDFFDDC03BB /* PKPaymentAuthorizationViewController+Stripe_Blocks.swift in Sources */, + 6BF6ECC4A4E61E2FFC3EA20B /* STPAPIClient+BasicUI.swift in Sources */, + 5212C7875C07F9BF16AFD98D /* STPAPIClient+PushProvisioning.swift in Sources */, + BB46077C256C26418420F240 /* STPAddCardViewController.swift in Sources */, + E63B5BAF6B5645C979BFBA71 /* STPAddress+BasicUI.swift in Sources */, + 58240AA66FAE55131268E4A0 /* STPAddressFieldTableViewCell.swift in Sources */, + 2BD45625F6F665B60C6CAD30 /* STPAddressViewModel.swift in Sources */, + 605EFBDD21426FD30581563F /* STPAnalyticsClient+BasicUI.swift in Sources */, + 7589E37795D21AB818B0C333 /* STPAnalyticsClient+Payments.swift in Sources */, + F86F2DF6E46EFABE23AD5D27 /* STPApplePayContextDelegate.swift in Sources */, + FC166455478EAF51F7C34E68 /* STPApplePayPaymentOption.swift in Sources */, + C314A5C55064C51C2B999E6B /* STPBackendAPIAdapter.swift in Sources */, + CEF318C74D2E44C78EF85306 /* STPBankSelectionTableViewCell.swift in Sources */, + 172D96526023A80534D54CC0 /* STPBankSelectionViewController.swift in Sources */, + A0AA0B8AEF5B429858D71F6B /* STPBlocks.swift in Sources */, + DDBF5AAE607C698618DDE865 /* STPCameraView.swift in Sources */, + 66065B1D65D7D5502D4E2F2B /* STPCard+BasicUI.swift in Sources */, + BAFD06E994739E1C38DFFBBC /* STPCardScanner.swift in Sources */, + 2FFA7C2D1C7337FDB4C608A5 /* STPCardScannerTableViewCell.swift in Sources */, + 2F18A1903244E144C7802E09 /* STPCardValidationState.swift in Sources */, + DF73457BF349BC962A6AC502 /* STPCoreScrollViewController.swift in Sources */, + EEFFE199D9769FF449BFD7FF /* STPCoreTableViewController.swift in Sources */, + B1BF689B91D538BDCA4C8578 /* STPCoreViewController.swift in Sources */, + 429DBA641E926EBC2D049FE7 /* STPCustomerContext.swift in Sources */, + 62B91808A088C4F9FDB62C53 /* STPEphemeralKey.swift in Sources */, + 4FB67F10A0B7106A8142B842 /* STPEphemeralKeyManager.swift in Sources */, + B8385576DC25BDEEB92D812F /* STPEphemeralKeyProvider.swift in Sources */, + AF23CB4EF17E87007CFC3E96 /* STPFPXBankStatusResponse.swift in Sources */, + 0B9C0E9A7A750607413C9E53 /* STPFakeAddPaymentPassViewController.swift in Sources */, + 5D6B52EB4D7258129F134D07 /* STPImageLibrary.swift in Sources */, + D2869246B446B8B31F1CD368 /* STPIntentActionLinkAuthenticateAccount.swift in Sources */, + 98EE8326C1D133E1C998114F /* STPLocalizedString.swift in Sources */, + 609E4D384B75F6A111DC0E27 /* STPPaymentActivityIndicatorView.swift in Sources */, + 4EFF8B46B12DA4D9AAB22523 /* STPPaymentCardTextFieldCell.swift in Sources */, + 446A108C8EB6C338A1D774F8 /* STPPaymentConfiguration.swift in Sources */, + B00F7FC372E376C6B2170D37 /* STPPaymentContext.swift in Sources */, + 447C19BDB2CF5445045F81F7 /* STPPaymentContextAmountModel.swift in Sources */, + EA7FEC518AA07BA59405A5E3 /* STPPaymentIntentParams+BasicUI.swift in Sources */, + 7BC98BE168781C5B3EC8A8DB /* STPPaymentMethod+BasicUI.swift in Sources */, + 812682EA323986B8F698FF3C /* STPPaymentMethodParams+BasicUI.swift in Sources */, + F10FC337254A34ED8F13E341 /* STPPaymentOption.swift in Sources */, + 4ED44ACF24949F516867235C /* STPPaymentOptionTableViewCell.swift in Sources */, + 5ECED204FD22CFEA3A806767 /* STPPaymentOptionTuple.swift in Sources */, + 1F432D0B37949217E4299A20 /* STPPaymentOptionsInternalViewController.swift in Sources */, + 69AC1EDE2A3C03B1D980CA54 /* STPPaymentOptionsViewController.swift in Sources */, + 307FD6A103EF7AF3CE451598 /* STPPaymentResult.swift in Sources */, + 7EAA7334372DBC38DF8FA0AA /* STPPinManagementService.swift in Sources */, + 2E35B0FB60FCBE7608080642 /* STPPushProvisioningContext.swift in Sources */, + FEE74744B657F86873EA2F3D /* STPPushProvisioningDetails.swift in Sources */, + 4E09E54E7FEC35C49C59A379 /* STPPushProvisioningDetailsParams.swift in Sources */, + 23D1246A5DAB5333650F104F /* STPSectionHeaderView.swift in Sources */, + 124D43C1A633922B1DA3E1E7 /* STPShippingAddressViewController.swift in Sources */, + 7B9C0D039EA9EF593AEC682D /* STPShippingMethodTableViewCell.swift in Sources */, + 7F9D08AC5A448C7693162D7D /* STPShippingMethodsViewController.swift in Sources */, + 3CE88568CB9648D6F1503B88 /* STPSource+BasicUI.swift in Sources */, + F835CEC935464FF32726A0A0 /* STPTheme.swift in Sources */, + 279D2BA91198E18730626CE6 /* STPUserInformation.swift in Sources */, + 542610492B38FEB652C6823E /* String+Localized.swift in Sources */, + DCF615643A22D0A7B739547C /* Stripe+Exports.swift in Sources */, + BF4ED4828114E2E89A3D4AB7 /* StripeBundleLocator.swift in Sources */, + 98E2332DE7F54E970BE5EEF7 /* UIBarButtonItem+Stripe.swift in Sources */, + 9D9692DFC4F06F8C70145000 /* UINavigationBar+Stripe_Theme.swift in Sources */, + 9C13E8A017A4E23BCCDE618B /* UINavigationController+Stripe_Completion.swift in Sources */, + 54331380F5AC68846DBE94D5 /* UITableViewCell+Stripe_Borders.swift in Sources */, + 093FE3D65978E3DB6B79AE05 /* UIToolbar+Stripe_InputAccessory.swift in Sources */, + C8A6CA6352B7C8FEE3D91476 /* UIView+Helpers.swift in Sources */, + 7F235CD649F6E97E4E7DD180 /* UIView+Stripe_FirstResponder.swift in Sources */, + 44672917D3AC4B83F9EC3BC3 /* UIView+Stripe_SafeAreaBounds.swift in Sources */, + CC072EBAD035AA54A2AD3ABC /* UIViewController+Stripe_KeyboardAvoiding.swift in Sources */, + 3FA556CF8B11E2486F505161 /* UIViewController+Stripe_NavigationItemProxy.swift in Sources */, + 3930ECBEE003772C1245D25B /* UIViewController+Stripe_ParentViewController.swift in Sources */, + D85C432B241FDE23875037F9 /* UserDefaults+Stripe.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C4ED1B5A417CECDB38B21698 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 234C71F480318E9062075924 /* AppDelegate.swift in Sources */, + 3C1A7B9810B038177FF1CF52 /* ViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E160E75F2870000E2FA9C5DC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 64801CF2D2CAC9008C17D154 /* LinkSecureCookieStoreTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FB03810F22F4E0919BB2EF68 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3EB3745F556EA12AB27A8545 /* APIRequestTest.swift in Sources */, + 6EF3F611E6EA3CB479D62450 /* AfterpayPriceBreakdownViewSnapshotTests.swift in Sources */, + DF85F5EC6E16CAD21491891A /* AnalyticsHelperTests.swift in Sources */, + 5910FCB9822259D5EC7E4051 /* AutoCompleteViewControllerSnapshotTests.swift in Sources */, + C1E70FD29BBE36D76A7E6929 /* CardExpiryDateTests.swift in Sources */, + D375ADBD1F4B48380D5347D1 /* CircularButtonSnapshotTests.swift in Sources */, + 2F9FA9CBCA3C0CE52FAC9B6B /* ConfirmButtonSnapshotTests.swift in Sources */, + 3C1E9069CD03ED9981D7F3E2 /* ConfirmButtonTests.swift in Sources */, + 795F3783D62AB8E2A00DCD05 /* ConsumerSessionTests.swift in Sources */, + 524AE1978E0A4490D1C390C5 /* CustomerAdapterTests.swift in Sources */, + 0DFA17378D894C70D72C9F62 /* Error+PaymentSheetTests.swift in Sources */, + C9E66A22494C02050AE34A9B /* FBSnapshotTestCase+STPViewControllerLoading.swift in Sources */, + 35C1CF73701EECC7DB6AB722 /* FormSpecProviderTest.swift in Sources */, + ACF6CFE0F8B88FDBBB16968C /* FraudDetectionDataTest.swift in Sources */, + AF18D569B296BFC1EB5A7338 /* ImageTest.swift in Sources */, + 5CA66791F526C266CE72A6A8 /* IntentConfirmParamsTest.swift in Sources */, + 87AACDD643A998FFDD505D22 /* KlarnaHelperTest.swift in Sources */, + 5D1A9F97F79DAA3F46C82A28 /* LinkAccountServiceTests.swift in Sources */, + 229E25F8DFCC55CA9EDD15AB /* LinkInMemoryCookieStoreTests.swift in Sources */, + D0C81317E0AA8EB0370B1BA1 /* LinkLegalTermsViewSnapshotTests.swift in Sources */, + 450FAE41FB4538462D05F2E4 /* LinkSignupViewModelTests.swift in Sources */, + FDD1858CAEFCEBB22BEC9BBC /* MKPlacemark+PaymentSheetTests.swift in Sources */, + 609C2C8F10AFAA2711639CD0 /* NSArray+StripeTest.swift in Sources */, + B98D71ED9ACC2E1B47372F53 /* NSDecimalNumber+StripeTest.swift in Sources */, + 6E7AD3CCC966A7F34922B172 /* NSDictionary+StripeTest.swift in Sources */, + 325694E4284BEAE787A5ECB6 /* NSLocale+STPSwizzling.swift in Sources */, + C4C1295E7DA618DFB944A534 /* NSString+StripeTest.swift in Sources */, + 68318DB86DFCD19505FC47BA /* NSURLComponents_StripeTest.swift in Sources */, + D83F76F584BC345CFBA71CF8 /* OneTimeCodeTextFieldSnapshotTests.swift in Sources */, + 951344464ACF84F0F6D43D10 /* OneTimeCodeTextFieldTests.swift in Sources */, + 8E423294AB602BF25DB11D8E /* OperationDebouncerTests.swift in Sources */, + C4DC3F4FA93A3BAF6EE782A0 /* PKPayment+StripeTest.swift in Sources */, + DC57D2DC40C6BA0C9CF7EC92 /* PayWithLinkButtonSnapshotTests.swift in Sources */, + AD9B9F3FF697D4A3892E86F2 /* PaymentAnalyticTest.swift in Sources */, + C861BB9EAAD04949E338D7FF /* PaymentTypeCellSnapshotTests.swift in Sources */, + BC694A1642DC30D530B60635 /* RotatingCardBrandsViewSnapshotTests.swift in Sources */, + C5D295FE9988CA80ABA57801 /* RotatingCardBrandsViewTests.swift in Sources */, + BEC0435570B9199B918ED4DA /* STPAPIClient+LinkAccountSessionTest.swift in Sources */, + 7D2C0D1BF455625997CBC33B /* STPAPIClientNetworkBridgeTest.swift in Sources */, + 9A24970C5FB6D3F7314AE550 /* STPAPIClientStubbedTest.swift in Sources */, + 5E5EE69D140F6FEDA5F0A346 /* STPAPIClientTest.swift in Sources */, + AD09F2ACD0CDCBD414AC30AD /* STPAPISettingsObjCBridgeTest.m in Sources */, + 17BD7C0391F3182E32A63D6B /* STPAUBECSDebitFormViewSnapshotTests.swift in Sources */, + 583DE9869C885BA02E0A071E /* STPAUBECSFormViewModelTests.swift in Sources */, + B6B89E3F7DE0811BD5CB9D31 /* STPAddCardViewControllerLocalizationTests.swift in Sources */, + FB34906C9215D0E03850064B /* STPAddCardViewControllerTest.swift in Sources */, + 3EA9D509E59DA65EE4EDF98D /* STPAddressTests.swift in Sources */, + F53E04785DB804EA5C2AAC18 /* STPAddressViewModelTest.swift in Sources */, + 0CBBE909CA773D7D45B9AD4C /* STPAnalyticsClientPaymentSheetTest.swift in Sources */, + E6F428CFAD64979A8874B00B /* STPAnalyticsClientPaymentsTest.swift in Sources */, + 23CF725CFAB2ABED416BF416 /* STPApplePayContextFunctionalTest.swift in Sources */, + 6F9525063D76A9F86A10CCBF /* STPApplePayContextFunctionalTestExtras.swift in Sources */, + F2655328479314A9C8718DE4 /* STPApplePayContextTest.swift in Sources */, + 9B149DA42FB38C3542E0CB4B /* STPApplePayFunctionalTest.swift in Sources */, + 0C5F4AE769D95AA921F61084 /* STPApplePayPaymentOptionTest.swift in Sources */, + FBBA3B39598BBECB664C5E7F /* STPApplePayTest.swift in Sources */, + D0342D50F9AC319919D93D59 /* STPBECSDebitAccountNumberValidatorTests.swift in Sources */, + 66B7EF2DC1CBF813707C767C /* STPBSBNumberValidatorTests.swift in Sources */, + 39B1B88B8506BE4574E6B376 /* STPBankAccountFunctionalTest.swift in Sources */, + 5370700ED1F630E8261507D3 /* STPBankAccountParamsTest.swift in Sources */, + 801F417CE53689B95C4A098B /* STPBankAccountTest.swift in Sources */, + 6F4FBB4F10B5DB2CF8BB3460 /* STPBinRangeTest.swift in Sources */, + B917BF282C84507292112B9D /* STPCardBINMetadataTests.swift in Sources */, + 3FECC97E8E7C781B978CA19B /* STPCardBrandChoiceTest.swift in Sources */, + 786C30837EAD918EDE52284E /* STPCardBrandTest.swift in Sources */, + FE6647242714D9BEA1EBC055 /* STPCardCVCInputTextFieldFormatterTests.swift in Sources */, + 8EC1820299152F8565D30A40 /* STPCardCVCInputTextFieldSnapshotTests.swift in Sources */, + F35E090A607EB5F86FFC3D31 /* STPCardCVCInputTextFieldTests.swift in Sources */, + D2C062CE4E54094B1AC33E78 /* STPCardCVCInputTextFieldValidatorTests.swift in Sources */, + AC35943F1EAD50E9D5D509B3 /* STPCardExpiryInputTextFieldFormatterTests.swift in Sources */, + A66C279957B6AC8F72DE05C7 /* STPCardExpiryInputTextFieldSnapshotTests.swift in Sources */, + C57BDA835AED735321906977 /* STPCardExpiryInputTextFieldValidatorTests.swift in Sources */, + 07A5CDBFDF2340BAD99D6EB3 /* STPCardFormViewSnapshotTests.swift in Sources */, + 246920234EE8382FB4E56516 /* STPCardFormViewTests.swift in Sources */, + A12CFA90DAE8BBB39A8C7AA1 /* STPCardFunctionalTest.swift in Sources */, + CF2E17AC77EB08393B8A3F98 /* STPCardNumberInputTextFieldFormatterTests.swift in Sources */, + EEBA9A95E8057A06E5E7C103 /* STPCardNumberInputTextFieldSnapshotTests.swift in Sources */, + 1A058C42C4703458CA1CA522 /* STPCardNumberInputTextFieldValidatorTests.swift in Sources */, + D53C04A27B6B8EFB70E236A7 /* STPCardParamsTest.swift in Sources */, + D15160C0F0763078DBB434E4 /* STPCardTest.swift in Sources */, + 1E8D8E2494062262A332879C /* STPCardValidatorTest.swift in Sources */, + A781FB0F586B26655FAEC3C0 /* STPCertTest.swift in Sources */, + C8226E24CA51133091131391 /* STPConfirmCardOptionsTest.swift in Sources */, + 240993144289CD0DEC2C73C7 /* STPConfirmPaymentMethodOptionsTest.swift in Sources */, + 492F7C4DABB4CE8EBE34EEF2 /* STPConnectAccountAddressTest.swift in Sources */, + C8490E55B1F2EB836144F91C /* STPConnectAccountFunctionalTest.swift in Sources */, + 14656D177E67594B8C75A9FE /* STPConnectAccountParamsTest.swift in Sources */, + 9D464A252FBD0D4E2A0A7398 /* STPCountryPickerInputFieldSnapshotTests.swift in Sources */, + 4C3B161481D11385352B06D4 /* STPCustomerContextTest.swift in Sources */, + 6EDFC83541EED9E361B71C02 /* STPCustomerTest.swift in Sources */, + CBCA59D39B30D869B4FDC04B /* STPE2ETest.swift in Sources */, + 0F0F35439565AA0D284A6A70 /* STPElementsSessionTest.swift in Sources */, + 687517E7FE02FFB96DCE2328 /* STPEphemeralKeyManagerTest.swift in Sources */, + B6656829DEC006DBEED2AA0E /* STPEphemeralKeyTest.swift in Sources */, + 7435E6BB6971012A9B0DB52E /* STPErrorBridgeTest.m in Sources */, + 5D7F632025C261B88F0C2016 /* STPFPXBankBrandTest.swift in Sources */, + D776B91F0E8E6CCB6C09AC4F /* STPFileFunctionalTest.swift in Sources */, + D76D24F6A94108853BB08712 /* STPFileTest.swift in Sources */, + BC6912C0DE15008C8D8C303C /* STPFloatingPlaceholderTextFieldSnapshotTests.swift in Sources */, + 91A839DEDA7D1EAF6FC66BE0 /* STPFormEncoderTest.swift in Sources */, + 96098727EFA6A72087A35A52 /* STPFormTextFieldTest.swift in Sources */, + 013F991AB34E38BDBA6E4521 /* STPFormViewSnapshotTests.swift in Sources */, + 51044B947A7FDB99451466D8 /* STPGenericInputPickerFieldSnapshotTests.swift in Sources */, + 8532FEBF4F2E0EB282D466CE /* STPGenericInputPickerFieldValidatorTest.swift in Sources */, + 4B0917FC15BF56D0100E0ED1 /* STPGenericInputTextFieldSnapshotTests.swift in Sources */, + 3AD22E0BD44B02D968C6569A /* STPImageLibraryTest.swift in Sources */, + D7C555B36C282B99E22B8D45 /* STPInputTextFieldFormatterTests.swift in Sources */, + 903FFB756C6ED520BE38EF6F /* STPInputTextFieldValidatorTests.swift in Sources */, + 45FA9B8CC2D18E29BE81CF8F /* STPIntentActionAlipayHandleRedirectTest.swift in Sources */, + F550D4EB3DCFE03D6FC8F023 /* STPIntentActionPayNowDisplayQrCodeTest.swift in Sources */, + 78641CE4011A1C1EE6E35DC5 /* STPIntentActionPromptPayDisplayQrCodeTest.swift in Sources */, + ACC1B91FC687AFD0DFD27CD4 /* STPIntentActionTest.swift in Sources */, + 4935C8B3ECFBAD947E694934 /* STPIntentActionTypeTest.swift in Sources */, + 8F0326E98C74EB62E34B9FEA /* STPIntentActionWeChatPayRedirectToAppTest.swift in Sources */, + 1F417D0874CC86F4C9AB2790 /* STPIntentWithPreferencesTest.swift in Sources */, + 9FB20E559379F468070C7B50 /* STPLabeledFormTextFieldViewSnapshotTests.swift in Sources */, + A22D548084E7DE1FE5ABE8E7 /* STPLabeledMultiFormTextFieldViewSnapshotTests.swift in Sources */, + 331924F0801287BAD413FDCB /* STPMandateCustomerAcceptanceParamsTest.swift in Sources */, + 781EC0163AC001C6A66045B6 /* STPMandateDataParamsTest.swift in Sources */, + B82859A4444B9F735720F232 /* STPMandateOnlineParamsTest.swift in Sources */, + 9FD92B3ADEBEC96660B70409 /* STPMocks.m in Sources */, + A77C5769B20D7884FC8FC4FB /* STPNumericDigitInputTextFormatterTests.swift in Sources */, + F975CE029DF30419B8DB0D8F /* STPNumericStringValidatorTests.swift in Sources */, + 9535CADFFBC9E1FA291E947E /* STPPIIFunctionalTest.swift in Sources */, + 1CD3AB315580606AF87A7B1F /* STPPaymentCardTextFieldKVOTest.m in Sources */, + 77C0FD1BCDA7BBFB88559B44 /* STPPaymentCardTextFieldTest.swift in Sources */, + 86BAF121184D71F5F4FFAD7B /* STPPaymentCardTextFieldTestsSwift.swift in Sources */, + B4719234E4BBDAD260E31373 /* STPPaymentCardTextFieldViewModelTest.swift in Sources */, + 3AAE488F2461A46143B3A687 /* STPPaymentConfigurationTest.m in Sources */, + 829D43B6705D125FEC9926DA /* STPPaymentContextApplePayTest.swift in Sources */, + 97756805F41DDB51B3ED0326 /* STPPaymentContextSnapshotTests.swift in Sources */, + FEF2E0DAC862FF42B814AFCA /* STPPaymentHandlerFunctionalTest.m in Sources */, + 8F5AF9D3566B8DBCA5AB5188 /* STPPaymentHandlerFunctionalTest.swift in Sources */, + 194154708E1A9E013DCE2C72 /* STPPaymentHandlerStubbedMockedFilesTests.swift in Sources */, + 2A528B7B2579E5F977797822 /* STPPaymentHandlerTests.swift in Sources */, + 1BC4044802EE7D3E2643DC84 /* STPPaymentIntentEnumsTest.swift in Sources */, + 37E9160706C9EEEFEF133617 /* STPPaymentIntentFunctionalTest.swift in Sources */, + F729E784CFFC1F79EF5F2ABE /* STPPaymentIntentLastPaymentErrorTest.swift in Sources */, + CA189278AD606BEAC62D545F /* STPPaymentIntentParamsTest.swift in Sources */, + 73AFE2A8839EFAB8330F6CF0 /* STPPaymentIntentTest.swift in Sources */, + 5E498CDA0115CF9F8463C566 /* STPPaymentMethodAUBECSDebitParamsTests.swift in Sources */, + D567569568C0D8F2D7B179B3 /* STPPaymentMethodAUBECSDebitTests.swift in Sources */, + CFC1F2B8D48FFF7B0F81B5A0 /* STPPaymentMethodAddressTest.swift in Sources */, + 4993037E5386D0AF87B24871 /* STPPaymentMethodAffirmParamsTest.swift in Sources */, + F5CC4F320D09A06F0B21ABE6 /* STPPaymentMethodAffirmTests.swift in Sources */, + FDADE3E36804A8AD82301BF3 /* STPPaymentMethodAfterpayClearpayParamsTest.swift in Sources */, + C34D0BBDF6553ACF85204ACD /* STPPaymentMethodAfterpayClearpayTest.swift in Sources */, + 0305C1689C25B57C43640173 /* STPPaymentMethodBacsDebitTest.swift in Sources */, + BEC5B2ACC54FB72DEBFB70AB /* STPPaymentMethodBancontactParamsTests.swift in Sources */, + 1CCFC43F7FCD273E2100D321 /* STPPaymentMethodBancontactTests.swift in Sources */, + E0F011E9C6CA368EF87F8E28 /* STPPaymentMethodBillingDetailsTest.swift in Sources */, + 29428CDB658E6F504402D844 /* STPPaymentMethodBillingDetailsTests+Link.swift in Sources */, + 8C977F8D224A7360AE8E15A7 /* STPPaymentMethodBoletoParamsTests.swift in Sources */, + 5170651536332C4842E9D009 /* STPPaymentMethodBoletoTests.swift in Sources */, + FE7C38B95B3B7E028AB21238 /* STPPaymentMethodCardChecksTest.swift in Sources */, + 8378F2A4B0796819BB1C6C54 /* STPPaymentMethodCardParamsTest.swift in Sources */, + 43FFF2881D4EFA7B57A60E09 /* STPPaymentMethodCardTest.swift in Sources */, + F06EAD0F48302B061ED29E61 /* STPPaymentMethodCardWalletMasterpassTest.swift in Sources */, + 7623057AC6AC5369DCD94E84 /* STPPaymentMethodCardWalletTest.swift in Sources */, + 33DF66640B5ABBCB12B46AFE /* STPPaymentMethodCardWalletVisaCheckoutTest.swift in Sources */, + 0185AC6B123CD73E877D4FCE /* STPPaymentMethodCashAppParamsTests.swift in Sources */, + CBAF9C6F87F746F17495ADC2 /* STPPaymentMethodCashAppTests.swift in Sources */, + D4602454AC17D3584BA88217 /* STPPaymentMethodEPSParamsTests.swift in Sources */, + C7EB8FB325BF491FDE25FE66 /* STPPaymentMethodEPSTests.swift in Sources */, + C3AAA4AFEE274B27D3483876 /* STPPaymentMethodFPXTest.swift in Sources */, + B44E4CF6C65522F80C946775 /* STPPaymentMethodFunctionalTest.swift in Sources */, + E9A2C6E153CB480891846705 /* STPPaymentMethodGiropayParamsTests.swift in Sources */, + B8ED1F697519A6FCD3D79431 /* STPPaymentMethodGiropayTests.swift in Sources */, + 319899DEC91B3F88D380DB47 /* STPPaymentMethodGrabPayParamsTest.swift in Sources */, + 7844BB705AEB002965EF82B0 /* STPPaymentMethodKlarnaParamsTests.swift in Sources */, + 27F1783CBFEC06BFD6C114F6 /* STPPaymentMethodKlarnaTests.swift in Sources */, + C0B59D0A7025A55ECD948D47 /* STPPaymentMethodNetBankingParamsTest.swift in Sources */, + FF0F9BA6FE4B88297A434EA7 /* STPPaymentMethodNetBankingTests.swift in Sources */, + E3E916EB10E19727D6B33081 /* STPPaymentMethodOXXOParamsTests.swift in Sources */, + 37FBCED5F71F03483EA73F27 /* STPPaymentMethodOXXOTests.swift in Sources */, + DD8E2B99BAE917F83258DC35 /* STPPaymentMethodOptionsTest.swift in Sources */, + 5D9EB3E2725C38D7098B9965 /* STPPaymentMethodParamsTest.swift in Sources */, + 42F18560F3DC6980408AF051 /* STPPaymentMethodPayPalParamsTests.swift in Sources */, + 2C6DC246DD12FE0D87156A4D /* STPPaymentMethodPayPalTests.swift in Sources */, + E2790AB17C8C65CDE1E81532 /* STPPaymentMethodPrzelewy24ParamsTests.swift in Sources */, + D7D24DCC9402153965AF7F1B /* STPPaymentMethodPrzelewy24Tests.swift in Sources */, + BBB734F006FAD749678B87D1 /* STPPaymentMethodRevolutPayParamsTests.swift in Sources */, + D54508ED433792AD8AA6610F /* STPPaymentMethodRevolutPayTests.swift in Sources */, + B359F6DCB31EAD0814AD9AFD /* STPPaymentMethodSEPADebitTest.swift in Sources */, + 0684E2ABDA4566356143CC14 /* STPPaymentMethodSofortParamsTests.swift in Sources */, + 5C5E1CE53D89DE8F0B867115 /* STPPaymentMethodSofortTests.swift in Sources */, + 2C9F69E4A384C5743F4EAF69 /* STPPaymentMethodSwishParamsTests.swift in Sources */, + 4059301B0365BD4220E591FB /* STPPaymentMethodSwishTests.swift in Sources */, + A08C2F0E7F642515B1D263ED /* STPPaymentMethodTest.swift in Sources */, + 7A9D7D156B5053638F9B21E1 /* STPPaymentMethodThreeDSecureUsageTest.swift in Sources */, + A01BB7F09134F7081679F9C4 /* STPPaymentMethodUPIParamsTest.swift in Sources */, + AF44725558E654548FED2A2B /* STPPaymentMethodUPITests.swift in Sources */, + 4AAA2CD5AEF1F913395B3B95 /* STPPaymentMethodUSBankAccountParamsStubbedTest.swift in Sources */, + 7D251ABF1EBF65ACA8A4BDD4 /* STPPaymentMethodUSBankAccountParamsTest.swift in Sources */, + 225140E0BD9C0630116DDE4A /* STPPaymentMethodUSBankAccountTest.swift in Sources */, + B71F04D02538FA1723558C48 /* STPPaymentMethodiDEALTest.swift in Sources */, + 66C38EA9CFB1ED4DF2F974BF /* STPPaymentOptionsViewControllerLocalizationTests.swift in Sources */, + 2C7991FDF7B374E0E65E253F /* STPPaymentOptionsViewControllerTest.swift in Sources */, + 07BF3CF1656AF5F5A0678873 /* STPPhoneNumberValidatorTest.swift in Sources */, + EBD436689635CC28A24DECD4 /* STPPinManagementServiceFunctionalTest.swift in Sources */, + 385CAC4D2FF119D2E925916B /* STPPostalCodeInputTextFieldFormatterTests.swift in Sources */, + 9B1AC278FDCDABF26C5E468C /* STPPostalCodeInputTextFieldSnapshotTests.swift in Sources */, + 4E31B1864DA407598FB1BBC6 /* STPPostalCodeInputTextFieldTests.swift in Sources */, + 91558F51B87C72E745244958 /* STPPostalCodeInputTextFieldValidatorTests.swift in Sources */, + 6FCA954C32AB351F902BA876 /* STPPostalCodeValidatorTest.swift in Sources */, + 3172C789DF2CE133ECA359D7 /* STPPushProvisioningDetailsFunctionalTest.swift in Sources */, + 4A61DC36F10B9C9C24345613 /* STPRadarSessionFunctionalTest.swift in Sources */, + 1948544E75A2E16E46CBA00E /* STPRedirectContextTest.swift in Sources */, + 044B7BECFBDB1F6C8CA08514 /* STPSetupIntentConfirmParamsTest.swift in Sources */, + C32D7ACEBC852CBC295BBEF2 /* STPSetupIntentFunctionalTest.swift in Sources */, + E699508F4DB4D9D4666BAA08 /* STPSetupIntentLastSetupErrorTest.swift in Sources */, + EC4DC8E386544959E1AA9355 /* STPSetupIntentTest.swift in Sources */, + A77EC5CE65161573062E9F98 /* STPShippingAddressViewControllerLocalizationTests.swift in Sources */, + 08ED7A4EB7E64FDAED2C2D39 /* STPShippingAddressViewControllerTest.swift in Sources */, + A7A1D3C0D75DCD7217D297FF /* STPShippingMethodsViewControllerLocalizationTests.swift in Sources */, + AE747ADA2841AA06F32558D8 /* STPSourceCardDetailsTest.swift in Sources */, + 3FD5ABC45AF3A03F4EFE196F /* STPSourceFunctionalTest.swift in Sources */, + 922C0DF37F5AAA29375A5454 /* STPSourceOwnerTest.swift in Sources */, + D8BECFB70834CC42BA6706D8 /* STPSourceParamsTest.swift in Sources */, + 46FF3CC61200F2C27D4F3369 /* STPSourceReceiverTest.swift in Sources */, + 28538CD5885636DC523E8751 /* STPSourceRedirectTest.swift in Sources */, + EA80A8DB806DEF4F519059CB /* STPSourceSEPADebitDetailsTest.swift in Sources */, + D3D654D8376AAA634466D31D /* STPSourceTest.swift in Sources */, + 460B31EDB22BD6B912567363 /* STPSourceVerificationTest.swift in Sources */, + D7956073A8FD3785193E0577 /* STPStackViewWithSeparatorSnapshotTests.swift in Sources */, + CA4F392070740C56FE2BB461 /* STPStringUtilsTest.swift in Sources */, + 9D8354BDB04CEC5D1EFCF54F /* STPSwiftFixtures.swift in Sources */, + E3F1BAD22CC6E90B761B0502 /* STPTextFieldDelegateProxyTests.swift in Sources */, + 58A8B3F57FE98C22D8F90C77 /* STPThreeDSButtonCustomizationTest.swift in Sources */, + EA571ECEFDDF10AF87CE2B74 /* STPThreeDSFooterCustomizationTest.swift in Sources */, + C35CF837D67AE8DB7CBDAD98 /* STPThreeDSLabelCustomizationTest.swift in Sources */, + 590DB84AC15709E3C6F1FC3B /* STPThreeDSNavigationBarCustomizationTest.swift in Sources */, + DD16FC7ABCA7817794ECC407 /* STPThreeDSSelectionCustomizationTest.swift in Sources */, + 2AC91F23CF3949ADC60D27F7 /* STPThreeDSTextFieldCustomizationTest.swift in Sources */, + 701C464523173C6809544935 /* STPThreeDSUICustomizationTest.swift in Sources */, + B86EE8C85E6AB6B0A34C1887 /* STPTokenTest.swift in Sources */, + 8AA84A3A52A3D79BCA8C8994 /* STPUIVCStripeParentViewControllerTests.swift in Sources */, + 35E05040EA813C3B9C8EF054 /* STPViewWithSeparatorSnapshotTests.swift in Sources */, + 71116C2D5831E271E12DB059 /* ServerErrorMapperTest.swift in Sources */, + A8B0DB753CAA2223C8BED099 /* StripeErrorTest.swift in Sources */, + 3B237145902E3DB07E747E32 /* TextFieldElement+IBANTest.swift in Sources */, + CEE483EB7B06B3C607BC755C /* UINavigationBar+StripeTest.m in Sources */, + 51D515315F02D4C03BA12366 /* UserDefaults+StripeTest.swift in Sources */, + 8B80FB6FC88D411A90E9D487 /* WalletHeaderViewSnapshotTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 11BFD72CD1F291E0E86EC784 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = StripeiOSTestHostApp; + target = 1628E8B14F1F7C63CF8C9962 /* StripeiOSTestHostApp */; + targetProxy = 32221E5BA07FB5AA4EBFE81C /* PBXContainerItemProxy */; + }; + 4E57D9EF1E91091C57E49111 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = StripeiOS; + target = ADF894AA8F6022D9BED17346 /* StripeiOS */; + targetProxy = D90CE98566BBDA0E7340E1D7 /* PBXContainerItemProxy */; + }; + C4F1FB6EFF8D25D54580374A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = StripeiOS; + target = ADF894AA8F6022D9BED17346 /* StripeiOS */; + targetProxy = 8F25D3DFD6D65DAE4B581911 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 147D2DC1FFDFC99269039377 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + AD8BED2A6066514B51693172 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; + 884C01B087B4D820395BD374 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + C980D24DDC884FECCE39139F /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + C2427C1CDFA85BFC6570F1E9 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 7B8ADF2EA2D5C4C90BCDDDD5 /* bg-BG */, + 26E70469F4032553B4BB62DA /* ca-ES */, + 8ED737CB1253C3C5704B6C05 /* cs-CZ */, + 27A4D83C0E7D4AE0CCD38B89 /* da */, + 2560B4EEE60D40FB31B8552F /* de */, + DE24B3BAD7E890661CCA817D /* el-GR */, + 7B10B1A15063FEDBA4A59953 /* en */, + 90D0900C5FDBB7952BCF2C3A /* en-GB */, + A6F6634AD12771A9BB100DD3 /* es */, + AB1A92C77AC61335184BBDBC /* es-419 */, + 12D757B36030685C401A6990 /* et-EE */, + BAFD938F3A149A56CC99FE96 /* fi */, + 0A16326394D71637A2CF68C3 /* fil */, + 04838ACE779F5CC949C276CB /* fr */, + DFA4E34E459EA612E065DE64 /* fr-CA */, + 7CAE7444CEFE1D1EFB888996 /* hr */, + BFB4A210A30D1D4F3D3100E5 /* hu */, + 084B1B9FCCF4AB727B4ECFB2 /* id */, + ABAABB969BFB7102D5AA5598 /* it */, + 064CFCA8FCEA9E4BAB3547D0 /* ja */, + 1007571188950D7FBF745A4E /* ko */, + 43ADFC4EF612D7C4A46E81B9 /* lt-LT */, + 13DDBEA7D444A8AC14E0F1C8 /* lv-LV */, + 9D3BBCE8C46A38D0E20DBF4E /* ms-MY */, + 04FE0C74090AE8A871CCE5EC /* mt */, + 482693A3D9A527B0E671F757 /* nb */, + D3667F97B7665F699D6704BE /* nl */, + B334078D1C3E629BEB498BAB /* nn-NO */, + 0C9D6F99E303A17A91101723 /* pl-PL */, + 002634603200AABECC9686B1 /* pt-BR */, + C45852D37E323E65C47348B0 /* pt-PT */, + AA3CA5B4B91838CC7ED4D5EB /* ro-RO */, + 121B7EDCCD0957C9A444A8E3 /* ru */, + 0F735744F27D46F005BB5D67 /* sk-SK */, + 618DE183886175AF23C4E668 /* sl-SI */, + 58158E8A117299834246100F /* sv */, + 35ABE696542469B79A9D52E6 /* tk */, + 313263D9DC0629C5EE279FEB /* tr */, + 4077600B9B3C1ABFF38383BE /* vi */, + F13E3DE09A463B4501733B87 /* zh-Hans */, + 4DFDFC019C67AFF7C0F7630B /* zh-Hant */, + EA20E0E29EDC1F61ADA226A2 /* zh-HK */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 006D8C78BFD35DF18042E040 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1D23EB567F573612E0794B3A /* Stripe Tests-Debug.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeiOSTests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeiOSTests; + PRODUCT_NAME = StripeiOS_Tests; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 008139FD6C1D3C3903E1FBC4 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 969E196AB597EEF68C38103E /* Project-Release.xcconfig */; + buildSettings = { + }; + name = Release; + }; + 160D95A170FDDD08C46E4C35 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5E4EA6394497D1BD57ED0032 /* Stripe Tests-Release.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeiOSTests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeiOSTests; + PRODUCT_NAME = StripeiOS_Tests; + SDKROOT = iphoneos; + }; + name = Release; + }; + 2473A3BC44750A2ADE1CE6A4 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E1A2173E0891B7138687D544 /* Stripe-Debug.xcconfig */; + buildSettings = { + INFOPLIST_FILE = StripeiOS/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "com.stripe.stripe-ios"; + PRODUCT_NAME = Stripe; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 4CFD88FE3BC957D5059302C0 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D609FFD051FC01BF566665A0 /* StripeiOS Tests-Debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeiOSAppHostedTests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeiOSAppHostedTests; + PRODUCT_NAME = StripeiOSAppHostedTests; + SDKROOT = iphoneos; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/StripeiOSTestHostApp.app/StripeiOSTestHostApp"; + TEST_TARGET_NAME = StripeiOSTestHostApp; + }; + name = Debug; + }; + 50C10A6D37E11392F6B0E7F2 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E3C8833E7EBA7A1EBF349D19 /* StripeiOS Tests-Release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeiOSAppHostedTests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeiOSAppHostedTests; + PRODUCT_NAME = StripeiOSAppHostedTests; + SDKROOT = iphoneos; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/StripeiOSTestHostApp.app/StripeiOSTestHostApp"; + TEST_TARGET_NAME = StripeiOSTestHostApp; + }; + name = Release; + }; + 610AD56E6CBB9E6781424682 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = EB71A4A2762CF864DB198BCF /* Project-Debug.xcconfig */; + buildSettings = { + }; + name = Debug; + }; + A71DB2DB45060267821F91EF /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E3C8833E7EBA7A1EBF349D19 /* StripeiOS Tests-Release.xcconfig */; + buildSettings = { + INFOPLIST_FILE = StripeiOSTestHostApp/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeiOSTestHostApp; + PRODUCT_NAME = StripeiOSTestHostApp; + SDKROOT = iphoneos; + }; + name = Release; + }; + D3B5E5117D5D1C186CC75505 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D609FFD051FC01BF566665A0 /* StripeiOS Tests-Debug.xcconfig */; + buildSettings = { + INFOPLIST_FILE = StripeiOSTestHostApp/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeiOSTestHostApp; + PRODUCT_NAME = StripeiOSTestHostApp; + SDKROOT = iphoneos; + }; + name = Debug; + }; + D88B8DCAA56931FBE4963518 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = DA82BB67D434E76B9ABA4CEC /* Stripe-Release.xcconfig */; + buildSettings = { + INFOPLIST_FILE = StripeiOS/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "com.stripe.stripe-ios"; + PRODUCT_NAME = Stripe; + SDKROOT = iphoneos; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 2804CCD7A91C99DF32596147 /* Build configuration list for PBXProject "Stripe" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 610AD56E6CBB9E6781424682 /* Debug */, + 008139FD6C1D3C3903E1FBC4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 54F4DB1E9C991950FD9EB4B4 /* Build configuration list for PBXNativeTarget "StripeiOSTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 006D8C78BFD35DF18042E040 /* Debug */, + 160D95A170FDDD08C46E4C35 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B6D8846CE184BBD9139D6D15 /* Build configuration list for PBXNativeTarget "StripeiOSTestHostApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D3B5E5117D5D1C186CC75505 /* Debug */, + A71DB2DB45060267821F91EF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F0FCB32AE130ABA66178DD9B /* Build configuration list for PBXNativeTarget "StripeiOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2473A3BC44750A2ADE1CE6A4 /* Debug */, + D88B8DCAA56931FBE4963518 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + FCBB942DEBE57423D79373B4 /* Build configuration list for PBXNativeTarget "StripeiOSAppHostedTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4CFD88FE3BC957D5059302C0 /* Debug */, + 50C10A6D37E11392F6B0E7F2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 44C6210EB47ACF468D255723 /* XCRemoteSwiftPackageReference "OHHTTPStubs" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/eurias-stripe/OHHTTPStubs"; + requirement = { + branch = master; + kind = branch; + }; + }; + 71086E1440B890A9DC01C9DF /* XCRemoteSwiftPackageReference "ios-snapshot-test-case" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/uber/ios-snapshot-test-case"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.0.0; + }; + }; + D244E8B5402CB2CF89CE7872 /* XCRemoteSwiftPackageReference "ocmock" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/erikdoe/ocmock"; + requirement = { + branch = master; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 62887B4538E4E41E735685E1 /* OHHTTPStubs */ = { + isa = XCSwiftPackageProductDependency; + productName = OHHTTPStubs; + }; + 911CA85A1610303FA0AF0643 /* OHHTTPStubsSwift */ = { + isa = XCSwiftPackageProductDependency; + productName = OHHTTPStubsSwift; + }; + C55551F29B99CF6D6DD9EE2F /* iOSSnapshotTestCase */ = { + isa = XCSwiftPackageProductDependency; + productName = iOSSnapshotTestCase; + }; + E804AA8C4156CC85FFD9595F /* OCMock */ = { + isa = XCSwiftPackageProductDependency; + productName = OCMock; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = E63832AA5BB4225708B7C838 /* Project object */; +} diff --git a/Stripe/Stripe.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Stripe/Stripe.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Stripe/Stripe.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Stripe/Stripe.xcodeproj/xcshareddata/xcschemes/StripeiOS.xcscheme b/Stripe/Stripe.xcodeproj/xcshareddata/xcschemes/StripeiOS.xcscheme new file mode 100644 index 00000000..e730b6df --- /dev/null +++ b/Stripe/Stripe.xcodeproj/xcshareddata/xcschemes/StripeiOS.xcscheme @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Stripe/Stripe.xcodeproj/xcshareddata/xcschemes/StripeiOSTestHostApp.xcscheme b/Stripe/Stripe.xcodeproj/xcshareddata/xcschemes/StripeiOSTestHostApp.xcscheme new file mode 100644 index 00000000..eb6c42eb --- /dev/null +++ b/Stripe/Stripe.xcodeproj/xcshareddata/xcschemes/StripeiOSTestHostApp.xcscheme @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Stripe/StripeiOS/Info.plist b/Stripe/StripeiOS/Info.plist new file mode 100644 index 00000000..abe21473 --- /dev/null +++ b/Stripe/StripeiOS/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + Stripe + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + FMWK + CFBundleShortVersionString + $(CURRENT_PROJECT_VERSION) + CFBundleSignature + ???? + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSPrincipalClass + + + diff --git a/Stripe/StripeiOS/Resources/Localizations/bg-BG.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/bg-BG.lproj/Localizable.strings new file mode 100644 index 00000000..9b4e5b19 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/bg-BG.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - Офлайн"; + +"3D Secure" = "3D Secure удостоверяване"; + +"Add New Card…" = "Добавяне на нова карта..."; + +"Add a Card" = "Добавяне на карта"; + +"Apt." = "Aп."; + +"Contact" = "Контакт"; + +"Delivery" = "Доставка"; + +"Delivery Address" = "Адрес за доставка"; + +"Free" = "Безплатно"; + +"Invalid Shipping Address" = "Невалиден адрес за доставка"; + +"Loading…" = "Зареждане..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Следващо"; + +"Online Banking (FPX)" = "Онлайн банкиране (FPX)"; + +"Payment Method" = "Метод на плащане"; + +"Shipping" = "Доставка"; + +"Shipping Method" = "Метод на доставка"; + +"Use Billing" = "Да се използва адреса за фактуриране"; + +"Use Delivery" = "Да се използва адреса за доставка"; + +"Use Shipping" = "Да се използва адреса за изпращане"; diff --git a/Stripe/StripeiOS/Resources/Localizations/ca-ES.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/ca-ES.lproj/Localizable.strings new file mode 100644 index 00000000..b7979c0a --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/ca-ES.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - Fora de línia"; + +"3D Secure" = "3D Segur"; + +"Add New Card…" = "Afegir nova targeta..."; + +"Add a Card" = "Afegir una targeta"; + +"Apt." = "Apt."; + +"Contact" = "Contacte"; + +"Delivery" = "Lliurament"; + +"Delivery Address" = "Adreça de lliurament"; + +"Free" = "Sense cost"; + +"Invalid Shipping Address" = "Adreça d'enviament invàlida"; + +"Loading…" = "Carregant..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Següent"; + +"Online Banking (FPX)" = "Banca Online (FPX)"; + +"Payment Method" = "Mètode de pagament"; + +"Shipping" = "Enviament"; + +"Shipping Method" = "Mètode d'enviament"; + +"Use Billing" = "Fer servir Facturació"; + +"Use Delivery" = "Fer servir Lliurament"; + +"Use Shipping" = "Fes servir Enviament"; diff --git a/Stripe/StripeiOS/Resources/Localizations/cs-CZ.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/cs-CZ.lproj/Localizable.strings new file mode 100644 index 00000000..a0a89390 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/cs-CZ.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - Offline"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Přidat novou kartu"; + +"Add a Card" = "Přidat kartu"; + +"Apt." = "Byt"; + +"Contact" = "Kontakt"; + +"Delivery" = "Dodávka"; + +"Delivery Address" = "Dodací adresa"; + +"Free" = "Zdarma"; + +"Invalid Shipping Address" = "Neplatná dodací adresa"; + +"Loading…" = "Načítání..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Další"; + +"Online Banking (FPX)" = "Online bankovnictví (FPX)"; + +"Payment Method" = "Způsob platby"; + +"Shipping" = "Doprava"; + +"Shipping Method" = "Způsob dopravy"; + +"Use Billing" = "Použít fakturaci"; + +"Use Delivery" = "Použít dodání"; + +"Use Shipping" = "Použít dopravu"; diff --git a/Stripe/StripeiOS/Resources/Localizations/da.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/da.lproj/Localizable.strings new file mode 100644 index 00000000..8415e65b --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/da.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - Offline"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Tilføj nyt kort …"; + +"Add a Card" = "Tilføj et kort"; + +"Apt." = "Lejlighed"; + +"Contact" = "Kontaktperson"; + +"Delivery" = "Levering"; + +"Delivery Address" = "Leveringsadresse"; + +"Free" = "Gratis"; + +"Invalid Shipping Address" = "Ugyldig forsendelsesadresse"; + +"Loading…" = "Indlæser ..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Næste"; + +"Online Banking (FPX)" = "Onlinebanking (FPX)"; + +"Payment Method" = "Betalingsmetode"; + +"Shipping" = "Forsendelse"; + +"Shipping Method" = "Forsendelsesmetode"; + +"Use Billing" = "Brug faktureringsadresse"; + +"Use Delivery" = "Brug leveringsadresse"; + +"Use Shipping" = "Brug forsendelsesadresse"; diff --git a/Stripe/StripeiOS/Resources/Localizations/de.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/de.lproj/Localizable.strings new file mode 100644 index 00000000..1818eb85 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/de.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ – offline"; + +"3D Secure" = "Secure"; + +"Add New Card…" = "Neue Karte hinzufügen…"; + +"Add a Card" = "Karte hinzufügen"; + +"Apt." = "Adresszeile 2"; + +"Contact" = "Kontakt"; + +"Delivery" = "Lieferinformationen"; + +"Delivery Address" = "Lieferadresse"; + +"Free" = "Kostenlos"; + +"Invalid Shipping Address" = "Ungültige Versandadresse"; + +"Loading…" = "Wird geladen..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Weiter"; + +"Online Banking (FPX)" = "Online-Banking (FPX)"; + +"Payment Method" = "Zahlungsmethode"; + +"Shipping" = "Versand"; + +"Shipping Method" = "Versandmethode"; + +"Use Billing" = "Rechnungsadresse verwenden"; + +"Use Delivery" = "Lieferadresse verwenden"; + +"Use Shipping" = "Versandadresse verwenden"; diff --git a/Stripe/StripeiOS/Resources/Localizations/el-GR.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/el-GR.lproj/Localizable.strings new file mode 100644 index 00000000..a95851a2 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/el-GR.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - Εκτός σύνδεσης"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Προσθήκη νέας κάρτας…"; + +"Add a Card" = "Προσθήκη κάρτας"; + +"Apt." = "Διαμ."; + +"Contact" = "Επικοινωνία"; + +"Delivery" = "Παράδοση"; + +"Delivery Address" = "Διεύθυνση παράδοσης"; + +"Free" = "Δωρεάν"; + +"Invalid Shipping Address" = "Μη έγκυρη διεύθυνση αποστολής"; + +"Loading…" = "Φόρτωση…"; + +"Multibanco" = "Multibanco"; + +"Next" = "Επόμενο"; + +"Online Banking (FPX)" = "Τραπεζικές υπηρεσίες μέσω διαδικτύου (FPX)"; + +"Payment Method" = "Μέθοδος πληρωμής"; + +"Shipping" = "Αποστολή"; + +"Shipping Method" = "Μέθοδος αποστολής"; + +"Use Billing" = "Χρήση χρέωσης"; + +"Use Delivery" = "Χρήση παράδοσης"; + +"Use Shipping" = "Χρήση αποστολής"; diff --git a/Stripe/StripeiOS/Resources/Localizations/en-GB.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/en-GB.lproj/Localizable.strings new file mode 100644 index 00000000..6d515362 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/en-GB.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ — Offline"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Add New Card…"; + +"Add a Card" = "Add a Card"; + +"Apt." = "Apt."; + +"Contact" = "Contact"; + +"Delivery" = "Delivery"; + +"Delivery Address" = "Delivery Address"; + +"Free" = "Free"; + +"Invalid Shipping Address" = "Invalid Shipping Address"; + +"Loading…" = "Loading…"; + +"Multibanco" = "Multibanco"; + +"Next" = "Next"; + +"Online Banking (FPX)" = "Online Banking (FPX)"; + +"Payment Method" = "Payment Method"; + +"Shipping" = "Shipping"; + +"Shipping Method" = "Shipping Method"; + +"Use Billing" = "Use Billing"; + +"Use Delivery" = "Use Delivery"; + +"Use Shipping" = "Use Shipping"; diff --git a/Stripe/StripeiOS/Resources/Localizations/en.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/en.lproj/Localizable.strings new file mode 100644 index 00000000..40b3a294 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/en.lproj/Localizable.strings @@ -0,0 +1,60 @@ +/* Bank name when bank is offline for maintenance. */ +"%@ - Offline" = "%@ - Offline"; + +/* Source type brand name */ +"3D Secure" = "3D Secure"; + +/* Title for Add a Card view */ +"Add a Card" = "Add a Card"; + +/* Button to add a new credit card. */ +"Add New Card…" = "Add New Card…"; + +/* Caption for Apartment/Address line 2 field on address form */ +"Apt." = "Apt."; + +/* Title for contact info form */ +"Contact" = "Contact"; + +/* Title for delivery info form */ +"Delivery" = "Delivery"; + +/* Title for delivery address entry section */ +"Delivery Address" = "Delivery Address"; + +/* Label for free shipping method */ +"Free" = "Free"; + +/* Shipping form error message */ +"Invalid Shipping Address" = "Invalid Shipping Address"; + +/* Title for screen when data is still loading from the network. */ +"Loading…" = "Loading…"; + +/* Source type brand name */ +"Multibanco" = "Multibanco"; + +/* Button to move to the next text entry field */ +"Next" = "Next"; + +/* Button to pay with a Bank Account (using FPX). */ +"Online Banking (FPX)" = "Online Banking (FPX)"; + +/* Title for Payment Method screen */ +"Payment Method" = "Payment Method"; + +/* Title for shipping info form */ +"Shipping" = "Shipping"; + +/* Label for shipping method form */ +"Shipping Method" = "Shipping Method"; + +/* Button to fill shipping address from billing address. */ +"Use Billing" = "Use Billing"; + +/* Button to fill billing address from delivery address. */ +"Use Delivery" = "Use Delivery"; + +/* Button to fill billing address from shipping address. */ +"Use Shipping" = "Use Shipping"; + diff --git a/Stripe/StripeiOS/Resources/Localizations/es-419.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/es-419.lproj/Localizable.strings new file mode 100644 index 00000000..0ff4ff68 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/es-419.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - Fuera de línea"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Agregar nueva tarjeta..."; + +"Add a Card" = "Agrega una tarjeta"; + +"Apt." = "Apto."; + +"Contact" = "Contacto"; + +"Delivery" = "Entrega"; + +"Delivery Address" = "Dirección de entrega"; + +"Free" = "Gratis"; + +"Invalid Shipping Address" = "Dirección de envío inválida"; + +"Loading…" = "Cargando..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Siguiente"; + +"Online Banking (FPX)" = "Banca electrónica (FPX)"; + +"Payment Method" = "Método de pago"; + +"Shipping" = "Envío"; + +"Shipping Method" = "Método de envío"; + +"Use Billing" = "Usar la de facturación"; + +"Use Delivery" = "Usar la de entrega"; + +"Use Shipping" = "Usar la de envío"; diff --git a/Stripe/StripeiOS/Resources/Localizations/es.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/es.lproj/Localizable.strings new file mode 100644 index 00000000..13311015 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/es.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - Fuera de línea"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Añadir tarjeta nueva…"; + +"Add a Card" = "Añade una tarjeta"; + +"Apt." = "Piso"; + +"Contact" = "Contacto"; + +"Delivery" = "Entrega"; + +"Delivery Address" = "Dirección de entrega"; + +"Free" = "Gratuito"; + +"Invalid Shipping Address" = "Dirección de envío no válida"; + +"Loading…" = "Cargando…"; + +"Multibanco" = "Multibanco"; + +"Next" = "Siguiente"; + +"Online Banking (FPX)" = "Banca electrónica (FPX)"; + +"Payment Method" = "Método de pago"; + +"Shipping" = "Envío"; + +"Shipping Method" = "Método de envío"; + +"Use Billing" = "Usar la de facturación"; + +"Use Delivery" = "Usar la de entrega"; + +"Use Shipping" = "Usar la de envío"; diff --git a/Stripe/StripeiOS/Resources/Localizations/et-EE.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/et-EE.lproj/Localizable.strings new file mode 100644 index 00000000..afe2d85c --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/et-EE.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - Võrguühenduseta"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Lisa uus kaart ..."; + +"Add a Card" = "Kaardi lisamine"; + +"Apt." = "Korter"; + +"Contact" = "Kontakt"; + +"Delivery" = "Tarnimine"; + +"Delivery Address" = "Tarneaadress"; + +"Free" = "Tasuta"; + +"Invalid Shipping Address" = "Kehtetu tarneaadress"; + +"Loading…" = "Laadimine ..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Järgmine"; + +"Online Banking (FPX)" = "Internetipank (FPX)"; + +"Payment Method" = "Makseviis"; + +"Shipping" = "Tarnimine"; + +"Shipping Method" = "Tarneviis"; + +"Use Billing" = "Kasuta arveldusaadressi"; + +"Use Delivery" = "Kasuta saaja aadressi"; + +"Use Shipping" = "Kasuta tarneaadressi"; diff --git a/Stripe/StripeiOS/Resources/Localizations/fi.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/fi.lproj/Localizable.strings new file mode 100644 index 00000000..71096979 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/fi.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ Offline-tila"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Lisää uusi kortti..."; + +"Add a Card" = "Lisää kortti"; + +"Apt." = "Asunto"; + +"Contact" = "Yhteystiedot"; + +"Delivery" = "Toimitus"; + +"Delivery Address" = "Toimitusosoite"; + +"Free" = "Maksuton"; + +"Invalid Shipping Address" = "Toimitusosoite ei kelpaa"; + +"Loading…" = "Lataa..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Seuraava"; + +"Online Banking (FPX)" = "Verkkopankki (FPX)"; + +"Payment Method" = "Maksutapa"; + +"Shipping" = "Toimitus"; + +"Shipping Method" = "Toimitustapa"; + +"Use Billing" = "Käytä laskutusosoitetta"; + +"Use Delivery" = "Käytä toimitusosoitetta"; + +"Use Shipping" = "Käytä lähetysosoitetta"; diff --git a/Stripe/StripeiOS/Resources/Localizations/fil.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/fil.lproj/Localizable.strings new file mode 100644 index 00000000..a4252268 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/fil.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - Offline"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Magdagdag ng Bagong Kard..."; + +"Add a Card" = "Magdagdag ng isang Kard"; + +"Apt." = "Apt."; + +"Contact" = "Kontak"; + +"Delivery" = "Pagpapadala"; + +"Delivery Address" = "Adres ng Paghahatarin"; + +"Free" = "Libre"; + +"Invalid Shipping Address" = "Adres ng Nagpadala ay di balido"; + +"Loading…" = "Naglo-load..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Susunod"; + +"Online Banking (FPX)" = "Online Banking (FPX)"; + +"Payment Method" = "Paraan ng Pagbabayad"; + +"Shipping" = "Pagpapadala"; + +"Shipping Method" = "Paraan ng Pagpapadala"; + +"Use Billing" = "Gamitin ang Billing"; + +"Use Delivery" = "Gamitin ang Padadalhan"; + +"Use Shipping" = "Gamitin ang Nagpadala"; diff --git a/Stripe/StripeiOS/Resources/Localizations/fr-CA.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/fr-CA.lproj/Localizable.strings new file mode 100644 index 00000000..8ba185a4 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/fr-CA.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - Hors ligne"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Ajouter une carte…"; + +"Add a Card" = "Ajouter une carte"; + +"Apt." = "Nº d'app."; + +"Contact" = "Contact"; + +"Delivery" = "Livraison"; + +"Delivery Address" = "Adresse de livraison"; + +"Free" = "Gratuit"; + +"Invalid Shipping Address" = "Adresse de livraison non valide"; + +"Loading…" = "Chargement…"; + +"Multibanco" = "Multibanco"; + +"Next" = "Suivant"; + +"Online Banking (FPX)" = "Services bancaires en ligne (FPX)"; + +"Payment Method" = "Moyen de paiement"; + +"Shipping" = "Livraison"; + +"Shipping Method" = "Mode de livraison"; + +"Use Billing" = "Utiliser l'adresse de facturation"; + +"Use Delivery" = "Utiliser l'adresse de livraison"; + +"Use Shipping" = "Utiliser l'adresse de livraison"; diff --git a/Stripe/StripeiOS/Resources/Localizations/fr.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/fr.lproj/Localizable.strings new file mode 100644 index 00000000..191697b3 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/fr.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - Hors ligne"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Ajouter une nouvelle carte..."; + +"Add a Card" = "Ajouter une carte"; + +"Apt." = "Numéro d'appartement"; + +"Contact" = "Contact"; + +"Delivery" = "Livraison"; + +"Delivery Address" = "Adresse de livraison"; + +"Free" = "Gratuit"; + +"Invalid Shipping Address" = "Adresse de livraison incorrecte"; + +"Loading…" = "Chargement en cours..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Suivant"; + +"Online Banking (FPX)" = "Compte bancaire (FPX)"; + +"Payment Method" = "Moyen de paiement"; + +"Shipping" = "Expédition"; + +"Shipping Method" = "Mode de livraison"; + +"Use Billing" = "Utiliser l'adresse de facturation"; + +"Use Delivery" = "Utiliser l'adresse de livraison"; + +"Use Shipping" = "Utiliser l'adresse de livraison"; diff --git a/Stripe/StripeiOS/Resources/Localizations/hr.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/hr.lproj/Localizable.strings new file mode 100644 index 00000000..6f9a9a4b --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/hr.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - Izvan mreže"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Dodaj novu karticu..."; + +"Add a Card" = "Dodaj karticu"; + +"Apt." = "Stan br."; + +"Contact" = "Kontakt"; + +"Delivery" = "Dostava"; + +"Delivery Address" = "Adresa za dostavu"; + +"Free" = "Besplatno"; + +"Invalid Shipping Address" = "Adresa za otpremu nije valjana"; + +"Loading…" = "Učitava se..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Sljedeće"; + +"Online Banking (FPX)" = "Online Banking (FPX)"; + +"Payment Method" = "Način plaćanja"; + +"Shipping" = "Otprema"; + +"Shipping Method" = "Način otpreme"; + +"Use Billing" = "Koristi istu adresu kao i za naplatu"; + +"Use Delivery" = "Koristi istu adresu kao i za dostavu"; + +"Use Shipping" = "Koristi istu adresu kao i za otpremu"; diff --git a/Stripe/StripeiOS/Resources/Localizations/hu.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/hu.lproj/Localizable.strings new file mode 100644 index 00000000..355f47cd --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/hu.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - offline"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Új kártya hozzáadása..."; + +"Add a Card" = "Kártya hozzáadása"; + +"Apt." = "Házszám"; + +"Contact" = "Kapcsolat"; + +"Delivery" = "Szállítás"; + +"Delivery Address" = "Szállítási cím"; + +"Free" = "Ingyenes"; + +"Invalid Shipping Address" = "Érvénytelen szállítási cím"; + +"Loading…" = "Betöltés..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Tovább"; + +"Online Banking (FPX)" = "Online bankolás (FPX)"; + +"Payment Method" = "Fizetési mód"; + +"Shipping" = "Szállítás"; + +"Shipping Method" = "Szállítási mód"; + +"Use Billing" = "Számlázási használata"; + +"Use Delivery" = "Szállítási használata"; + +"Use Shipping" = "Szállítási használata"; diff --git a/Stripe/StripeiOS/Resources/Localizations/id.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/id.lproj/Localizable.strings new file mode 100644 index 00000000..c53fed57 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/id.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - Offline"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Tambahkan Kartu Baru..."; + +"Add a Card" = "Tambahkan sebuah Kartu"; + +"Apt." = "Apt."; + +"Contact" = "Kontak"; + +"Delivery" = "Pengiriman"; + +"Delivery Address" = "Alamat Pengiriman"; + +"Free" = "Gratis"; + +"Invalid Shipping Address" = "Alamat Pengiriman Tidak Valid"; + +"Loading…" = "Memuat…"; + +"Multibanco" = "Multibanco"; + +"Next" = "Berikutnya"; + +"Online Banking (FPX)" = "Perbankan Online (FPX)"; + +"Payment Method" = "Metode Pembayaran"; + +"Shipping" = "Pengiriman"; + +"Shipping Method" = "Metode Pengiriman"; + +"Use Billing" = "Gunakan Tagihan"; + +"Use Delivery" = "Gunakan Pengiriman"; + +"Use Shipping" = "Gunakan Pengiriman"; diff --git a/Stripe/StripeiOS/Resources/Localizations/it.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/it.lproj/Localizable.strings new file mode 100644 index 00000000..e59f1ff5 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/it.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - Offline"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Aggiungi nuova carta…"; + +"Add a Card" = "Aggiungi una carta"; + +"Apt." = "Int."; + +"Contact" = "Contatto"; + +"Delivery" = "Consegna"; + +"Delivery Address" = "Indirizzo di consegna"; + +"Free" = "Gratuita"; + +"Invalid Shipping Address" = "Indirizzo di spedizione non valido"; + +"Loading…" = "Caricamento..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Avanti"; + +"Online Banking (FPX)" = "Online Banking (FPX)"; + +"Payment Method" = "Modalità di pagamento"; + +"Shipping" = "Spedizione"; + +"Shipping Method" = "Modalità di spedizione"; + +"Use Billing" = "Usa indirizzo di fatturazione"; + +"Use Delivery" = "Usa indirizzo di consegna"; + +"Use Shipping" = "Usa indirizzo di spedizione"; diff --git a/Stripe/StripeiOS/Resources/Localizations/ja.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/ja.lproj/Localizable.strings new file mode 100644 index 00000000..4b98d6cc --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/ja.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - オフライン"; + +"3D Secure" = "3D セキュア"; + +"Add New Card…" = "新しいクレジットカードを追加..."; + +"Add a Card" = "カードを追加"; + +"Apt." = "建物名"; + +"Contact" = "連絡先"; + +"Delivery" = "配達先情報"; + +"Delivery Address" = "配達先住所"; + +"Free" = "無料"; + +"Invalid Shipping Address" = "配送先住所が無効です"; + +"Loading…" = "ロード中..."; + +"Multibanco" = "Multibanco"; + +"Next" = "次へ"; + +"Online Banking (FPX)" = "オンラインバンキング (FPX)"; + +"Payment Method" = "お支払い方法"; + +"Shipping" = "配送先情報"; + +"Shipping Method" = "配送方法"; + +"Use Billing" = "請求先を使用"; + +"Use Delivery" = "配達先を使用"; + +"Use Shipping" = "配送先を使用"; diff --git a/Stripe/StripeiOS/Resources/Localizations/ko.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/ko.lproj/Localizable.strings new file mode 100644 index 00000000..2e2e2d79 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/ko.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - 오프라인"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "새 카드 추가..."; + +"Add a Card" = "카드 추가"; + +"Apt." = "아파트"; + +"Contact" = "연락처"; + +"Delivery" = "배달"; + +"Delivery Address" = "배달 주소"; + +"Free" = "무료"; + +"Invalid Shipping Address" = "잘못된 배송 주소"; + +"Loading…" = "로드 중..."; + +"Multibanco" = "Multibanco"; + +"Next" = "다음"; + +"Online Banking (FPX)" = "온라인 뱅킹(FPX)"; + +"Payment Method" = "결제 수단"; + +"Shipping" = "배송"; + +"Shipping Method" = "배송 방법"; + +"Use Billing" = "청구 사용"; + +"Use Delivery" = "배달 사용"; + +"Use Shipping" = "배송 사용"; diff --git a/Stripe/StripeiOS/Resources/Localizations/lt-LT.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/lt-LT.lproj/Localizable.strings new file mode 100644 index 00000000..83a835e9 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/lt-LT.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ – neprisijungęs"; + +"3D Secure" = "3D saugi"; + +"Add New Card…" = "Pridėti naują kortelę..."; + +"Add a Card" = "Pridėti kortelę"; + +"Apt." = "But."; + +"Contact" = "Susisiekti"; + +"Delivery" = "Pristatymas"; + +"Delivery Address" = "Pristatymo adresas"; + +"Free" = "Nemokamai"; + +"Invalid Shipping Address" = "Netinkamas siuntimo adresas"; + +"Loading…" = "Įkeliama..."; + +"Multibanco" = "„Multibanco“"; + +"Next" = "Toliau"; + +"Online Banking (FPX)" = "Interneto banko paslaugos (FPX)"; + +"Payment Method" = "Mokėjimo būdas"; + +"Shipping" = "Siuntimas"; + +"Shipping Method" = "Siuntimo būdas"; + +"Use Billing" = "Naudoti sąskaitų siuntimo adresą"; + +"Use Delivery" = "Naudoti pristatymo adresą"; + +"Use Shipping" = "Naudoti siuntimo adresą"; diff --git a/Stripe/StripeiOS/Resources/Localizations/lv-LV.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/lv-LV.lproj/Localizable.strings new file mode 100644 index 00000000..c06a3481 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/lv-LV.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ — bezsaistē"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Pievienot jaunu karti…"; + +"Add a Card" = "Kartes pievienošana"; + +"Apt." = "Dzīv."; + +"Contact" = "Kontaktinformācija"; + +"Delivery" = "Piegāde"; + +"Delivery Address" = "Piegādes adrese"; + +"Free" = "Bezmaksas"; + +"Invalid Shipping Address" = "Nederīga piegādes adrese"; + +"Loading…" = "Ielādē…"; + +"Multibanco" = "Multibanco"; + +"Next" = "Tālāk"; + +"Online Banking (FPX)" = "Internetbanka (FPX)"; + +"Payment Method" = "Maksājuma metode"; + +"Shipping" = "Piegāde"; + +"Shipping Method" = "Piegādes metode"; + +"Use Billing" = "Izmantot norēķinu adresi"; + +"Use Delivery" = "Izmantot piegādes adresi"; + +"Use Shipping" = "Izmantot piegādes adresi"; diff --git a/Stripe/StripeiOS/Resources/Localizations/ms-MY.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/ms-MY.lproj/Localizable.strings new file mode 100644 index 00000000..a4c59d97 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/ms-MY.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ – Luar Talian"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Tambah Kad Baharu..."; + +"Add a Card" = "Tambah Kad"; + +"Apt." = "Apt."; + +"Contact" = "Maklumat Hubungan"; + +"Delivery" = "Penghantaran"; + +"Delivery Address" = "Alamat Penghantaran"; + +"Free" = "Percuma"; + +"Invalid Shipping Address" = "Alamat Pengiriman Tidak Sah"; + +"Loading…" = "Sedang dimuatkan..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Seterusnya"; + +"Online Banking (FPX)" = "Perbankan Dalam Talian (FPX)"; + +"Payment Method" = "Kaedah Pembayaran"; + +"Shipping" = "Pengiriman"; + +"Shipping Method" = "Kaedah Pengiriman"; + +"Use Billing" = "Gunakan Alamat Pengebilan"; + +"Use Delivery" = "Gunakan Alamat Penghantaran"; + +"Use Shipping" = "Gunakan Alamat Pengiriman"; diff --git a/Stripe/StripeiOS/Resources/Localizations/mt.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/mt.lproj/Localizable.strings new file mode 100644 index 00000000..d7513a3c --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/mt.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@- Offline"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Żid Kard Ġdida…"; + +"Add a Card" = "Żid Kard"; + +"Apt." = "Appartament"; + +"Contact" = "Kuntatt"; + +"Delivery" = "Kunsinna"; + +"Delivery Address" = "Indirizz tal-Kunsinna"; + +"Free" = "Bla ħlas"; + +"Invalid Shipping Address" = "L-Indirizz għat-Trasport tal-Merkanzija Mhux Validu"; + +"Loading…" = "Qed jillowdja…"; + +"Multibanco" = "Multibanco"; + +"Next" = "Li jmiss"; + +"Online Banking (FPX)" = "Bankar online"; + +"Payment Method" = "Metodu tal-Ħlas"; + +"Shipping" = "Trasport tal-Merkanzija"; + +"Shipping Method" = "Il-Mod tat-Trasport tal-Merkanzija"; + +"Use Billing" = "Uża tal-Kont"; + +"Use Delivery" = "Uża tal-Kunsinna"; + +"Use Shipping" = "Uża tat-Trasport tal-Merkanzija"; diff --git a/Stripe/StripeiOS/Resources/Localizations/nb.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/nb.lproj/Localizable.strings new file mode 100644 index 00000000..336da04f --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/nb.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ – frakoblet"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Legg til nytt kort…"; + +"Add a Card" = "Legg til kort"; + +"Apt." = "Leil."; + +"Contact" = "Kontakt"; + +"Delivery" = "Leveranse"; + +"Delivery Address" = "Leveringsadresse"; + +"Free" = "Gratis"; + +"Invalid Shipping Address" = "Ugyldig leveringsadresse"; + +"Loading…" = "Laster ..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Neste"; + +"Online Banking (FPX)" = "Banktjenester på nett (FPX)"; + +"Payment Method" = "Betalingsmåte"; + +"Shipping" = "Frakt"; + +"Shipping Method" = "Forsendelsesmetode"; + +"Use Billing" = "Bruk fakturaadresse"; + +"Use Delivery" = "Bruk leveringsadresse"; + +"Use Shipping" = "Bruk forsendelsesadresse"; diff --git a/Stripe/StripeiOS/Resources/Localizations/nl.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/nl.lproj/Localizable.strings new file mode 100644 index 00000000..d31b0f2f --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/nl.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - offline"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Nieuwe kaart toevoegen…"; + +"Add a Card" = "Een kaart toevoegen"; + +"Apt." = "Adresregel 2"; + +"Contact" = "Contact"; + +"Delivery" = "Bezorging"; + +"Delivery Address" = "Bezorgadres"; + +"Free" = "Gratis"; + +"Invalid Shipping Address" = "Ongeldig verzendadres"; + +"Loading…" = "Laden…"; + +"Multibanco" = "Multibanco"; + +"Next" = "Volgende"; + +"Online Banking (FPX)" = "Online bankieren (FPX)"; + +"Payment Method" = "Betaalmethode"; + +"Shipping" = "Verzending"; + +"Shipping Method" = "Verzendmethode"; + +"Use Billing" = "Factuuradres gebruiken"; + +"Use Delivery" = "Bezorgadres gebruiken"; + +"Use Shipping" = "Verzendadres gebruiken"; diff --git a/Stripe/StripeiOS/Resources/Localizations/nn-NO.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/nn-NO.lproj/Localizable.strings new file mode 100644 index 00000000..559c25dd --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/nn-NO.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - Fråkopla"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Legg til nytt kort …"; + +"Add a Card" = "Legg til kort"; + +"Apt." = "Leilegheit"; + +"Contact" = "Kontakt"; + +"Delivery" = "Leveranse"; + +"Delivery Address" = "Leveringsadresse"; + +"Free" = "Gratis"; + +"Invalid Shipping Address" = "Ugyldig sendingsadresse"; + +"Loading…" = "Lastar …"; + +"Multibanco" = "Multibanco"; + +"Next" = "Neste"; + +"Online Banking (FPX)" = "Nettbank (FPX)"; + +"Payment Method" = "Betalingsmetode"; + +"Shipping" = "Sending"; + +"Shipping Method" = "Sendingsmåte"; + +"Use Billing" = "Bruk fakturering"; + +"Use Delivery" = "Bruk leveringsadressa"; + +"Use Shipping" = "Bruk sendingsadressa"; diff --git a/Stripe/StripeiOS/Resources/Localizations/pl-PL.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/pl-PL.lproj/Localizable.strings new file mode 100644 index 00000000..a5946070 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/pl-PL.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ — offline"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Dodaj nową kartę…"; + +"Add a Card" = "Dodaj kartę"; + +"Apt." = "Nr lokalu"; + +"Contact" = "Kontakt"; + +"Delivery" = "Dostawa"; + +"Delivery Address" = "Adres dostawy"; + +"Free" = "Bezpłatnie"; + +"Invalid Shipping Address" = "Nieprawidłowy adres dostawy"; + +"Loading…" = "Ładowanie…"; + +"Multibanco" = "Multibanco"; + +"Next" = "Dalej"; + +"Online Banking (FPX)" = "Bankowość elektroniczna (FPX)"; + +"Payment Method" = "Metoda płatności"; + +"Shipping" = "Dostawa"; + +"Shipping Method" = "Metoda dostawy"; + +"Use Billing" = "Użyj adresu płatności"; + +"Use Delivery" = "Użyj adresu dostawy"; + +"Use Shipping" = "Użyj adresu dostawy"; diff --git a/Stripe/StripeiOS/Resources/Localizations/pt-BR.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/pt-BR.lproj/Localizable.strings new file mode 100644 index 00000000..7cdcc17e --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/pt-BR.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ – Offline"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Adicionar novo cartão..."; + +"Add a Card" = "Adicionar um cartão"; + +"Apt." = "Apt."; + +"Contact" = "Contato"; + +"Delivery" = "Entrega"; + +"Delivery Address" = "Endereço de entrega"; + +"Free" = "Grátis"; + +"Invalid Shipping Address" = "Endereço de envio inválido"; + +"Loading…" = "Carregando..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Próximo"; + +"Online Banking (FPX)" = "Online Banking (FPX)"; + +"Payment Method" = "Forma de pagamento"; + +"Shipping" = "Dados de entrega"; + +"Shipping Method" = "Método de entrega"; + +"Use Billing" = "Usar cobrança"; + +"Use Delivery" = "Usar entrega"; + +"Use Shipping" = "Usar envio"; diff --git a/Stripe/StripeiOS/Resources/Localizations/pt-PT.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/pt-PT.lproj/Localizable.strings new file mode 100644 index 00000000..0b428ecc --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/pt-PT.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - Offline"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Adicionar Novo Cartão..."; + +"Add a Card" = "Adicionar um cartão"; + +"Apt." = "Apt."; + +"Contact" = "Contacto"; + +"Delivery" = "Entrega"; + +"Delivery Address" = "Endereço de entrega"; + +"Free" = "Gratuito"; + +"Invalid Shipping Address" = "Endereço de envio inválido"; + +"Loading…" = "A carregar..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Seguinte"; + +"Online Banking (FPX)" = "Serviços bancários online (FPX)"; + +"Payment Method" = "Método de Pagamento"; + +"Shipping" = "Envio"; + +"Shipping Method" = "Método de envio"; + +"Use Billing" = "Utilizar endereço de faturação"; + +"Use Delivery" = "Utilizar endereço de entrega"; + +"Use Shipping" = "Utilizar endereço de envio"; diff --git a/Stripe/StripeiOS/Resources/Localizations/ro-RO.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/ro-RO.lproj/Localizable.strings new file mode 100644 index 00000000..666d8c8a --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/ro-RO.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - Offline"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Adăugare card nou..."; + +"Add a Card" = "Adăugare card"; + +"Apt." = "Apartament"; + +"Contact" = "Detalii de contact"; + +"Delivery" = "Informații privind livrarea"; + +"Delivery Address" = "Adresa de livrare"; + +"Free" = "Expediere gratuită"; + +"Invalid Shipping Address" = "Adresa de expediere nu este validă"; + +"Loading…" = "Se încarcă..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Următorul"; + +"Online Banking (FPX)" = "Servicii bancare electronice (FPX)"; + +"Payment Method" = "Metoda de plată"; + +"Shipping" = "Expediere"; + +"Shipping Method" = "Metoda de expediere"; + +"Use Billing" = "Utilizare adresă de facturare"; + +"Use Delivery" = "Utilizare adresă de livrare"; + +"Use Shipping" = "Utilizare adresă de expediere"; diff --git a/Stripe/StripeiOS/Resources/Localizations/ru.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/ru.lproj/Localizable.strings new file mode 100644 index 00000000..9a9d68d5 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/ru.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - не в сети"; + +"3D Secure" = "Метод 3D Secure"; + +"Add New Card…" = "Добавить новую карту..."; + +"Add a Card" = "Добавить карту"; + +"Apt." = "Кв."; + +"Contact" = "Контактные данные"; + +"Delivery" = "Доставка"; + +"Delivery Address" = "Адрес доставки"; + +"Free" = "Бесплатно"; + +"Invalid Shipping Address" = "Ошибочный адрес доставки"; + +"Loading…" = "Идет загрузка..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Далее"; + +"Online Banking (FPX)" = "Онлайн-банкинг (FPX)"; + +"Payment Method" = "Метод оплаты"; + +"Shipping" = "Доставка"; + +"Shipping Method" = "Метод доставки"; + +"Use Billing" = "Использовать адрес выставления счетов"; + +"Use Delivery" = "Использовать адрес доставки"; + +"Use Shipping" = "Использовать адрес доставки"; diff --git a/Stripe/StripeiOS/Resources/Localizations/sk-SK.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/sk-SK.lproj/Localizable.strings new file mode 100644 index 00000000..fb8fa880 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/sk-SK.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ – Offline"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Pridať novú kartu..."; + +"Add a Card" = "Pridať kartu"; + +"Apt." = "Byt"; + +"Contact" = "Kontakt"; + +"Delivery" = "Dodanie"; + +"Delivery Address" = "Dodacia adresa"; + +"Free" = "Zdarma"; + +"Invalid Shipping Address" = "Neplatná dodacia adresa"; + +"Loading…" = "Nahrávanie..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Ďalej"; + +"Online Banking (FPX)" = "Online bankovníctvo (FPX)"; + +"Payment Method" = "Spôsob platby"; + +"Shipping" = "Dodanie"; + +"Shipping Method" = "Spôsob dodania"; + +"Use Billing" = "Použiť fakturačnú adresu"; + +"Use Delivery" = "Použiť adresu doručenia"; + +"Use Shipping" = "Použiť dodaciu adresu"; diff --git a/Stripe/StripeiOS/Resources/Localizations/sl-SI.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/sl-SI.lproj/Localizable.strings new file mode 100644 index 00000000..77eccc7d --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/sl-SI.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ – ni dosegljiva"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Dodaj novo kartico ..."; + +"Add a Card" = "Dodajte kartico"; + +"Apt." = "Stanovanje"; + +"Contact" = "Stik"; + +"Delivery" = "Dostava"; + +"Delivery Address" = "Dostavni naslov"; + +"Free" = "Brezplačno"; + +"Invalid Shipping Address" = "Neveljaven dostavni naslov"; + +"Loading…" = "Nalaganje ..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Naprej"; + +"Online Banking (FPX)" = "Spletno bančništvo (FPX)"; + +"Payment Method" = "Načina plačila"; + +"Shipping" = "Dostava"; + +"Shipping Method" = "Način dostave"; + +"Use Billing" = "Uporabi naslov plačnika računa"; + +"Use Delivery" = "Uporabi dostavo"; + +"Use Shipping" = "Uporabi dostavo"; diff --git a/Stripe/StripeiOS/Resources/Localizations/sv.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/sv.lproj/Localizable.strings new file mode 100644 index 00000000..77af2122 --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/sv.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ – Offline"; + +"3D Secure" = "3D Secure"; + +"Add New Card…" = "Lägg till nytt kort …"; + +"Add a Card" = "Lägg till kort"; + +"Apt." = "Lägenhetsnr."; + +"Contact" = "Kontaktinformation"; + +"Delivery" = "Leverans"; + +"Delivery Address" = "Leveransadress"; + +"Free" = "Gratis"; + +"Invalid Shipping Address" = "Ogiltig leveransadress"; + +"Loading…" = "Laddar ..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Nästa"; + +"Online Banking (FPX)" = "Internetbank (FPX)"; + +"Payment Method" = "Betalningsmetod"; + +"Shipping" = "Frakt"; + +"Shipping Method" = "Leveransmetod"; + +"Use Billing" = "Använd faktureringsadress"; + +"Use Delivery" = "Använd leveransadress"; + +"Use Shipping" = "Använd fraktadress"; diff --git a/Stripe/StripeiOS/Resources/Localizations/tk.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/tk.lproj/Localizable.strings new file mode 100644 index 00000000..e69de29b diff --git a/Stripe/StripeiOS/Resources/Localizations/tr.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/tr.lproj/Localizable.strings new file mode 100644 index 00000000..f660bf8d --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/tr.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - Çevrimdışı"; + +"3D Secure" = "3D Güvenli"; + +"Add New Card…" = "Yeni Kart Ekle..."; + +"Add a Card" = "Kart Ekle"; + +"Apt." = "Apt."; + +"Contact" = "İletişim"; + +"Delivery" = "Teslimat"; + +"Delivery Address" = "Teslimat Adresi"; + +"Free" = "Ücretsiz"; + +"Invalid Shipping Address" = "Geçersiz Gönderi Adresi"; + +"Loading…" = "Yükleniyor..."; + +"Multibanco" = "Multibanco"; + +"Next" = "İleri"; + +"Online Banking (FPX)" = "Çevrimiçi Bankacılık (FPX)"; + +"Payment Method" = "Ödeme Metodu"; + +"Shipping" = "Gönderi"; + +"Shipping Method" = "Gönderi Metodu"; + +"Use Billing" = "Fatura Adresini Kullan"; + +"Use Delivery" = "Teslimat Adresini Kullan"; + +"Use Shipping" = "Gönderi Adresini Kullan"; diff --git a/Stripe/StripeiOS/Resources/Localizations/vi.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/vi.lproj/Localizable.strings new file mode 100644 index 00000000..3234969e --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/vi.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - Ngoại tuyến"; + +"3D Secure" = "Bảo mật 3D"; + +"Add New Card…" = "Thêm thẻ mới..."; + +"Add a Card" = "Thêm thẻ"; + +"Apt." = "Căn hộ"; + +"Contact" = "Liên hệ"; + +"Delivery" = "Giao hàng"; + +"Delivery Address" = "Địa chỉ giao hàng"; + +"Free" = "Miễn phí"; + +"Invalid Shipping Address" = "Địa chỉ vận chuyển không hợp lệ"; + +"Loading…" = "Đang tải..."; + +"Multibanco" = "Multibanco"; + +"Next" = "Tiếp"; + +"Online Banking (FPX)" = "Ngân hàng Trực tuyến (FPX)"; + +"Payment Method" = "Phương thức thanh toán"; + +"Shipping" = "Vận chuyển"; + +"Shipping Method" = "Phương thức vận chuyển"; + +"Use Billing" = "Sử dụng Hóa đơn"; + +"Use Delivery" = "Sử dụng Giao hàng"; + +"Use Shipping" = "Chọn Vận chuyển"; diff --git a/Stripe/StripeiOS/Resources/Localizations/zh-HK.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/zh-HK.lproj/Localizable.strings new file mode 100644 index 00000000..4045335c --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/zh-HK.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - 離線"; + +"3D Secure" = "3DS 驗證"; + +"Add New Card…" = "添加新卡…"; + +"Add a Card" = "添加卡"; + +"Apt." = "公寓"; + +"Contact" = "聯絡資訊"; + +"Delivery" = "收貨"; + +"Delivery Address" = "收貨地址"; + +"Free" = "免費"; + +"Invalid Shipping Address" = "收貨地址無效"; + +"Loading…" = "正在載入..."; + +"Multibanco" = "Multibanco"; + +"Next" = "下一步"; + +"Online Banking (FPX)" = "網上銀行 (FPX)"; + +"Payment Method" = "支付方式"; + +"Shipping" = "配送"; + +"Shipping Method" = "配送方式"; + +"Use Billing" = "使用帳單地址"; + +"Use Delivery" = "使用收貨地址"; + +"Use Shipping" = "使用收貨地址"; diff --git a/Stripe/StripeiOS/Resources/Localizations/zh-Hans.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/zh-Hans.lproj/Localizable.strings new file mode 100644 index 00000000..6b428ddb --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - 离线"; + +"3D Secure" = "3DS 验证"; + +"Add New Card…" = "添加新卡……"; + +"Add a Card" = "添加卡"; + +"Apt." = "公寓"; + +"Contact" = "联系信息"; + +"Delivery" = "收货信息"; + +"Delivery Address" = "收货地址"; + +"Free" = "免费"; + +"Invalid Shipping Address" = "收货地址无效"; + +"Loading…" = "正在加载……"; + +"Multibanco" = "Multibanco"; + +"Next" = "下一步"; + +"Online Banking (FPX)" = "网上银行 (FPX)"; + +"Payment Method" = "支付方式"; + +"Shipping" = "配送"; + +"Shipping Method" = "配送方式"; + +"Use Billing" = "使用账单地址"; + +"Use Delivery" = "使用收货地址"; + +"Use Shipping" = "使用收货地址"; diff --git a/Stripe/StripeiOS/Resources/Localizations/zh-Hant.lproj/Localizable.strings b/Stripe/StripeiOS/Resources/Localizations/zh-Hant.lproj/Localizable.strings new file mode 100644 index 00000000..75b7f79f --- /dev/null +++ b/Stripe/StripeiOS/Resources/Localizations/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,39 @@ +"%@ - Offline" = "%@ - 離線"; + +"3D Secure" = "3DS 驗證"; + +"Add New Card…" = "添加新卡..."; + +"Add a Card" = "新增卡"; + +"Apt." = "公寓"; + +"Contact" = "聯絡資訊"; + +"Delivery" = "寄送"; + +"Delivery Address" = "寄送地址"; + +"Free" = "免費"; + +"Invalid Shipping Address" = "收貨地址無效"; + +"Loading…" = "正在載入..."; + +"Multibanco" = "Multibanco"; + +"Next" = "下一步"; + +"Online Banking (FPX)" = "網上銀行 (FPX)"; + +"Payment Method" = "付款方式"; + +"Shipping" = "送貨"; + +"Shipping Method" = "送貨方式"; + +"Use Billing" = "使用帳單地址"; + +"Use Delivery" = "使用寄送地址"; + +"Use Shipping" = "使用收貨地址"; diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_amex_cvc.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_amex_cvc.imageset/Contents.json new file mode 100644 index 00000000..4a264e40 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_amex_cvc.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_card_form_amex_cvc.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_card_form_amex_cvc@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_card_form_amex_cvc@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_amex_cvc.imageset/stp_card_form_amex_cvc.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_amex_cvc.imageset/stp_card_form_amex_cvc.png new file mode 100644 index 00000000..aabbbb01 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_amex_cvc.imageset/stp_card_form_amex_cvc.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_amex_cvc.imageset/stp_card_form_amex_cvc@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_amex_cvc.imageset/stp_card_form_amex_cvc@2x.png new file mode 100644 index 00000000..599e1fb2 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_amex_cvc.imageset/stp_card_form_amex_cvc@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_amex_cvc.imageset/stp_card_form_amex_cvc@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_amex_cvc.imageset/stp_card_form_amex_cvc@3x.png new file mode 100644 index 00000000..0ef2077b Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_amex_cvc.imageset/stp_card_form_amex_cvc@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_back.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_back.imageset/Contents.json new file mode 100644 index 00000000..63fd9854 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_back.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_card_form_back.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_card_form_back@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_card_form_back@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_back.imageset/stp_card_form_back.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_back.imageset/stp_card_form_back.png new file mode 100644 index 00000000..e307bfc7 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_back.imageset/stp_card_form_back.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_back.imageset/stp_card_form_back@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_back.imageset/stp_card_form_back@2x.png new file mode 100644 index 00000000..7ead641d Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_back.imageset/stp_card_form_back@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_back.imageset/stp_card_form_back@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_back.imageset/stp_card_form_back@3x.png new file mode 100644 index 00000000..00b05459 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_back.imageset/stp_card_form_back@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_front.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_front.imageset/Contents.json new file mode 100644 index 00000000..943a813d --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_front.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_card_form_front.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_card_form_front@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_card_form_front@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_front.imageset/stp_card_form_front.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_front.imageset/stp_card_form_front.png new file mode 100644 index 00000000..08271ba1 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_front.imageset/stp_card_form_front.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_front.imageset/stp_card_form_front@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_front.imageset/stp_card_form_front@2x.png new file mode 100644 index 00000000..e5a90487 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_front.imageset/stp_card_form_front@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_front.imageset/stp_card_form_front@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_front.imageset/stp_card_form_front@3x.png new file mode 100644 index 00000000..6512912e Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Cards/stp_card_form_front.imageset/stp_card_form_front@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_affin_bank.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_affin_bank.imageset/Contents.json new file mode 100644 index 00000000..182babd3 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_affin_bank.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_bank_fpx_affin_bank.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_bank_fpx_affin_bank@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_bank_fpx_affin_bank@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_affin_bank.imageset/stp_bank_fpx_affin_bank.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_affin_bank.imageset/stp_bank_fpx_affin_bank.png new file mode 100644 index 00000000..c55aad7c Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_affin_bank.imageset/stp_bank_fpx_affin_bank.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_affin_bank.imageset/stp_bank_fpx_affin_bank@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_affin_bank.imageset/stp_bank_fpx_affin_bank@2x.png new file mode 100644 index 00000000..ca8834b0 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_affin_bank.imageset/stp_bank_fpx_affin_bank@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_affin_bank.imageset/stp_bank_fpx_affin_bank@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_affin_bank.imageset/stp_bank_fpx_affin_bank@3x.png new file mode 100644 index 00000000..3f14b77a Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_affin_bank.imageset/stp_bank_fpx_affin_bank@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_alliance_bank.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_alliance_bank.imageset/Contents.json new file mode 100644 index 00000000..ef50661e --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_alliance_bank.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_bank_fpx_alliance_bank.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_bank_fpx_alliance_bank@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_bank_fpx_alliance_bank@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_alliance_bank.imageset/stp_bank_fpx_alliance_bank.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_alliance_bank.imageset/stp_bank_fpx_alliance_bank.png new file mode 100644 index 00000000..49a97f33 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_alliance_bank.imageset/stp_bank_fpx_alliance_bank.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_alliance_bank.imageset/stp_bank_fpx_alliance_bank@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_alliance_bank.imageset/stp_bank_fpx_alliance_bank@2x.png new file mode 100644 index 00000000..b3f64207 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_alliance_bank.imageset/stp_bank_fpx_alliance_bank@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_alliance_bank.imageset/stp_bank_fpx_alliance_bank@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_alliance_bank.imageset/stp_bank_fpx_alliance_bank@3x.png new file mode 100644 index 00000000..b1f28c25 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_alliance_bank.imageset/stp_bank_fpx_alliance_bank@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ambank.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ambank.imageset/Contents.json new file mode 100644 index 00000000..5025fc70 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ambank.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_bank_fpx_ambank.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_bank_fpx_ambank@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_bank_fpx_ambank@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ambank.imageset/stp_bank_fpx_ambank.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ambank.imageset/stp_bank_fpx_ambank.png new file mode 100644 index 00000000..2de2c660 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ambank.imageset/stp_bank_fpx_ambank.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ambank.imageset/stp_bank_fpx_ambank@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ambank.imageset/stp_bank_fpx_ambank@2x.png new file mode 100644 index 00000000..6c152301 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ambank.imageset/stp_bank_fpx_ambank@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ambank.imageset/stp_bank_fpx_ambank@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ambank.imageset/stp_bank_fpx_ambank@3x.png new file mode 100644 index 00000000..3bb86b8d Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ambank.imageset/stp_bank_fpx_ambank@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_islam.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_islam.imageset/Contents.json new file mode 100644 index 00000000..4749fb7c --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_islam.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_bank_fpx_bank_islam.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_bank_fpx_bank_islam@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_bank_fpx_bank_islam@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_islam.imageset/stp_bank_fpx_bank_islam.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_islam.imageset/stp_bank_fpx_bank_islam.png new file mode 100644 index 00000000..ccff189d Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_islam.imageset/stp_bank_fpx_bank_islam.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_islam.imageset/stp_bank_fpx_bank_islam@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_islam.imageset/stp_bank_fpx_bank_islam@2x.png new file mode 100644 index 00000000..d17971a2 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_islam.imageset/stp_bank_fpx_bank_islam@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_islam.imageset/stp_bank_fpx_bank_islam@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_islam.imageset/stp_bank_fpx_bank_islam@3x.png new file mode 100644 index 00000000..a2816c5a Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_islam.imageset/stp_bank_fpx_bank_islam@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_muamalat.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_muamalat.imageset/Contents.json new file mode 100644 index 00000000..d1d7e6bb --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_muamalat.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_bank_fpx_bank_muamalat.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_bank_fpx_bank_muamalat@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_bank_fpx_bank_muamalat@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_muamalat.imageset/stp_bank_fpx_bank_muamalat.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_muamalat.imageset/stp_bank_fpx_bank_muamalat.png new file mode 100644 index 00000000..880705c6 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_muamalat.imageset/stp_bank_fpx_bank_muamalat.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_muamalat.imageset/stp_bank_fpx_bank_muamalat@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_muamalat.imageset/stp_bank_fpx_bank_muamalat@2x.png new file mode 100644 index 00000000..1f42b55f Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_muamalat.imageset/stp_bank_fpx_bank_muamalat@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_muamalat.imageset/stp_bank_fpx_bank_muamalat@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_muamalat.imageset/stp_bank_fpx_bank_muamalat@3x.png new file mode 100644 index 00000000..2103335e Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_muamalat.imageset/stp_bank_fpx_bank_muamalat@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_rakyat.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_rakyat.imageset/Contents.json new file mode 100644 index 00000000..4b6ec7f4 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_rakyat.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_bank_fpx_bank_rakyat.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_bank_fpx_bank_rakyat@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_bank_fpx_bank_rakyat@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_rakyat.imageset/stp_bank_fpx_bank_rakyat.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_rakyat.imageset/stp_bank_fpx_bank_rakyat.png new file mode 100644 index 00000000..18f5d398 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_rakyat.imageset/stp_bank_fpx_bank_rakyat.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_rakyat.imageset/stp_bank_fpx_bank_rakyat@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_rakyat.imageset/stp_bank_fpx_bank_rakyat@2x.png new file mode 100644 index 00000000..8fe59be1 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_rakyat.imageset/stp_bank_fpx_bank_rakyat@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_rakyat.imageset/stp_bank_fpx_bank_rakyat@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_rakyat.imageset/stp_bank_fpx_bank_rakyat@3x.png new file mode 100644 index 00000000..00d9c0f4 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bank_rakyat.imageset/stp_bank_fpx_bank_rakyat@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bsn.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bsn.imageset/Contents.json new file mode 100644 index 00000000..9f546f44 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bsn.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_bank_fpx_bsn.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_bank_fpx_bsn@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_bank_fpx_bsn@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bsn.imageset/stp_bank_fpx_bsn.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bsn.imageset/stp_bank_fpx_bsn.png new file mode 100644 index 00000000..686dc52b Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bsn.imageset/stp_bank_fpx_bsn.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bsn.imageset/stp_bank_fpx_bsn@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bsn.imageset/stp_bank_fpx_bsn@2x.png new file mode 100644 index 00000000..aea604cb Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bsn.imageset/stp_bank_fpx_bsn@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bsn.imageset/stp_bank_fpx_bsn@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bsn.imageset/stp_bank_fpx_bsn@3x.png new file mode 100644 index 00000000..e9365fac Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_bsn.imageset/stp_bank_fpx_bsn@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_cimb.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_cimb.imageset/Contents.json new file mode 100644 index 00000000..aff081a9 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_cimb.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_bank_fpx_cimb.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_bank_fpx_cimb@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_bank_fpx_cimb@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_cimb.imageset/stp_bank_fpx_cimb.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_cimb.imageset/stp_bank_fpx_cimb.png new file mode 100644 index 00000000..5c3471ca Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_cimb.imageset/stp_bank_fpx_cimb.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_cimb.imageset/stp_bank_fpx_cimb@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_cimb.imageset/stp_bank_fpx_cimb@2x.png new file mode 100644 index 00000000..85b966c3 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_cimb.imageset/stp_bank_fpx_cimb@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_cimb.imageset/stp_bank_fpx_cimb@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_cimb.imageset/stp_bank_fpx_cimb@3x.png new file mode 100644 index 00000000..79eb5e52 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_cimb.imageset/stp_bank_fpx_cimb@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hong_leong_bank.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hong_leong_bank.imageset/Contents.json new file mode 100644 index 00000000..9b2f9334 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hong_leong_bank.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_bank_fpx_hong_leong_bank.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_bank_fpx_hong_leong_bank@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_bank_fpx_hong_leong_bank@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hong_leong_bank.imageset/stp_bank_fpx_hong_leong_bank.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hong_leong_bank.imageset/stp_bank_fpx_hong_leong_bank.png new file mode 100644 index 00000000..46e2202e Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hong_leong_bank.imageset/stp_bank_fpx_hong_leong_bank.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hong_leong_bank.imageset/stp_bank_fpx_hong_leong_bank@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hong_leong_bank.imageset/stp_bank_fpx_hong_leong_bank@2x.png new file mode 100644 index 00000000..063d38a7 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hong_leong_bank.imageset/stp_bank_fpx_hong_leong_bank@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hong_leong_bank.imageset/stp_bank_fpx_hong_leong_bank@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hong_leong_bank.imageset/stp_bank_fpx_hong_leong_bank@3x.png new file mode 100644 index 00000000..cc6e0a53 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hong_leong_bank.imageset/stp_bank_fpx_hong_leong_bank@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hsbc.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hsbc.imageset/Contents.json new file mode 100644 index 00000000..ba1d3ca0 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hsbc.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_bank_fpx_hsbc.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_bank_fpx_hsbc@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_bank_fpx_hsbc@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hsbc.imageset/stp_bank_fpx_hsbc.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hsbc.imageset/stp_bank_fpx_hsbc.png new file mode 100644 index 00000000..36da803e Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hsbc.imageset/stp_bank_fpx_hsbc.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hsbc.imageset/stp_bank_fpx_hsbc@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hsbc.imageset/stp_bank_fpx_hsbc@2x.png new file mode 100644 index 00000000..79cbdfe3 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hsbc.imageset/stp_bank_fpx_hsbc@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hsbc.imageset/stp_bank_fpx_hsbc@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hsbc.imageset/stp_bank_fpx_hsbc@3x.png new file mode 100644 index 00000000..abae989d Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_hsbc.imageset/stp_bank_fpx_hsbc@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_kfh.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_kfh.imageset/Contents.json new file mode 100644 index 00000000..aa870e59 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_kfh.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_bank_fpx_kfh.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_bank_fpx_kfh@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_bank_fpx_kfh@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_kfh.imageset/stp_bank_fpx_kfh.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_kfh.imageset/stp_bank_fpx_kfh.png new file mode 100644 index 00000000..bebc29a9 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_kfh.imageset/stp_bank_fpx_kfh.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_kfh.imageset/stp_bank_fpx_kfh@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_kfh.imageset/stp_bank_fpx_kfh@2x.png new file mode 100644 index 00000000..db81f9e3 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_kfh.imageset/stp_bank_fpx_kfh@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_kfh.imageset/stp_bank_fpx_kfh@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_kfh.imageset/stp_bank_fpx_kfh@3x.png new file mode 100644 index 00000000..441dd68f Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_kfh.imageset/stp_bank_fpx_kfh@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2e.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2e.imageset/Contents.json new file mode 100644 index 00000000..1c49aa5d --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2e.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_bank_fpx_maybank2e.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_bank_fpx_maybank2e@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_bank_fpx_maybank2e@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2e.imageset/stp_bank_fpx_maybank2e.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2e.imageset/stp_bank_fpx_maybank2e.png new file mode 100644 index 00000000..11be27be Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2e.imageset/stp_bank_fpx_maybank2e.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2e.imageset/stp_bank_fpx_maybank2e@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2e.imageset/stp_bank_fpx_maybank2e@2x.png new file mode 100644 index 00000000..36b1ed23 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2e.imageset/stp_bank_fpx_maybank2e@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2e.imageset/stp_bank_fpx_maybank2e@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2e.imageset/stp_bank_fpx_maybank2e@3x.png new file mode 100644 index 00000000..5edd26bd Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2e.imageset/stp_bank_fpx_maybank2e@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2u.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2u.imageset/Contents.json new file mode 100644 index 00000000..17d19707 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2u.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_bank_fpx_maybank2u.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_bank_fpx_maybank2u@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_bank_fpx_maybank2u@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2u.imageset/stp_bank_fpx_maybank2u.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2u.imageset/stp_bank_fpx_maybank2u.png new file mode 100644 index 00000000..11be27be Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2u.imageset/stp_bank_fpx_maybank2u.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2u.imageset/stp_bank_fpx_maybank2u@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2u.imageset/stp_bank_fpx_maybank2u@2x.png new file mode 100644 index 00000000..ac95c7f0 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2u.imageset/stp_bank_fpx_maybank2u@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2u.imageset/stp_bank_fpx_maybank2u@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2u.imageset/stp_bank_fpx_maybank2u@3x.png new file mode 100644 index 00000000..5edd26bd Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_maybank2u.imageset/stp_bank_fpx_maybank2u@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ocbc.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ocbc.imageset/Contents.json new file mode 100644 index 00000000..46838072 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ocbc.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_bank_fpx_ocbc.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_bank_fpx_ocbc@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_bank_fpx_ocbc@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ocbc.imageset/stp_bank_fpx_ocbc.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ocbc.imageset/stp_bank_fpx_ocbc.png new file mode 100644 index 00000000..39c40013 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ocbc.imageset/stp_bank_fpx_ocbc.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ocbc.imageset/stp_bank_fpx_ocbc@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ocbc.imageset/stp_bank_fpx_ocbc@2x.png new file mode 100644 index 00000000..79e5e44a Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ocbc.imageset/stp_bank_fpx_ocbc@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ocbc.imageset/stp_bank_fpx_ocbc@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ocbc.imageset/stp_bank_fpx_ocbc@3x.png new file mode 100644 index 00000000..9bff169f Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_ocbc.imageset/stp_bank_fpx_ocbc@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_public_bank.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_public_bank.imageset/Contents.json new file mode 100644 index 00000000..4ae1928e --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_public_bank.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_bank_fpx_public_bank.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_bank_fpx_public_bank@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_bank_fpx_public_bank@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_public_bank.imageset/stp_bank_fpx_public_bank.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_public_bank.imageset/stp_bank_fpx_public_bank.png new file mode 100644 index 00000000..ddc7802d Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_public_bank.imageset/stp_bank_fpx_public_bank.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_public_bank.imageset/stp_bank_fpx_public_bank@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_public_bank.imageset/stp_bank_fpx_public_bank@2x.png new file mode 100644 index 00000000..844f65be Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_public_bank.imageset/stp_bank_fpx_public_bank@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_public_bank.imageset/stp_bank_fpx_public_bank@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_public_bank.imageset/stp_bank_fpx_public_bank@3x.png new file mode 100644 index 00000000..7b3d3b30 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_public_bank.imageset/stp_bank_fpx_public_bank@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_rhb.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_rhb.imageset/Contents.json new file mode 100644 index 00000000..1e732798 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_rhb.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_bank_fpx_rhb.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_bank_fpx_rhb@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_bank_fpx_rhb@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_rhb.imageset/stp_bank_fpx_rhb.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_rhb.imageset/stp_bank_fpx_rhb.png new file mode 100644 index 00000000..7a560ebf Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_rhb.imageset/stp_bank_fpx_rhb.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_rhb.imageset/stp_bank_fpx_rhb@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_rhb.imageset/stp_bank_fpx_rhb@2x.png new file mode 100644 index 00000000..69cf3156 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_rhb.imageset/stp_bank_fpx_rhb@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_rhb.imageset/stp_bank_fpx_rhb@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_rhb.imageset/stp_bank_fpx_rhb@3x.png new file mode 100644 index 00000000..98862042 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_rhb.imageset/stp_bank_fpx_rhb@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_standard_chartered.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_standard_chartered.imageset/Contents.json new file mode 100644 index 00000000..4ecaa2a8 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_standard_chartered.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_bank_fpx_standard_chartered.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_bank_fpx_standard_chartered@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_bank_fpx_standard_chartered@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_standard_chartered.imageset/stp_bank_fpx_standard_chartered.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_standard_chartered.imageset/stp_bank_fpx_standard_chartered.png new file mode 100644 index 00000000..db55641e Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_standard_chartered.imageset/stp_bank_fpx_standard_chartered.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_standard_chartered.imageset/stp_bank_fpx_standard_chartered@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_standard_chartered.imageset/stp_bank_fpx_standard_chartered@2x.png new file mode 100644 index 00000000..134a7439 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_standard_chartered.imageset/stp_bank_fpx_standard_chartered@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_standard_chartered.imageset/stp_bank_fpx_standard_chartered@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_standard_chartered.imageset/stp_bank_fpx_standard_chartered@3x.png new file mode 100644 index 00000000..c8d75605 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_standard_chartered.imageset/stp_bank_fpx_standard_chartered@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_uob.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_uob.imageset/Contents.json new file mode 100644 index 00000000..97e8ff91 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_uob.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_bank_fpx_uob.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_bank_fpx_uob@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_bank_fpx_uob@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_uob.imageset/stp_bank_fpx_uob.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_uob.imageset/stp_bank_fpx_uob.png new file mode 100644 index 00000000..26440466 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_uob.imageset/stp_bank_fpx_uob.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_uob.imageset/stp_bank_fpx_uob@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_uob.imageset/stp_bank_fpx_uob@2x.png new file mode 100644 index 00000000..ed32e644 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_uob.imageset/stp_bank_fpx_uob@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_uob.imageset/stp_bank_fpx_uob@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_uob.imageset/stp_bank_fpx_uob@3x.png new file mode 100644 index 00000000..1adae8c7 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_bank_fpx_uob.imageset/stp_bank_fpx_uob@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_big_logo.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_big_logo.imageset/Contents.json new file mode 100644 index 00000000..8d8210fc --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_big_logo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_fpx_big_logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_fpx_big_logo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_fpx_big_logo@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_big_logo.imageset/stp_fpx_big_logo.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_big_logo.imageset/stp_fpx_big_logo.png new file mode 100644 index 00000000..78e28995 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_big_logo.imageset/stp_fpx_big_logo.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_big_logo.imageset/stp_fpx_big_logo@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_big_logo.imageset/stp_fpx_big_logo@2x.png new file mode 100644 index 00000000..397617a0 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_big_logo.imageset/stp_fpx_big_logo@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_big_logo.imageset/stp_fpx_big_logo@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_big_logo.imageset/stp_fpx_big_logo@3x.png new file mode 100644 index 00000000..e157d827 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_big_logo.imageset/stp_fpx_big_logo@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_logo.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_logo.imageset/Contents.json new file mode 100644 index 00000000..da30dcb3 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_logo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_fpx_logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_fpx_logo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_fpx_logo@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_logo.imageset/stp_fpx_logo.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_logo.imageset/stp_fpx_logo.png new file mode 100644 index 00000000..e49c6846 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_logo.imageset/stp_fpx_logo.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_logo.imageset/stp_fpx_logo@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_logo.imageset/stp_fpx_logo@2x.png new file mode 100644 index 00000000..cf33a349 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_logo.imageset/stp_fpx_logo@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_logo.imageset/stp_fpx_logo@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_logo.imageset/stp_fpx_logo@3x.png new file mode 100644 index 00000000..87dc233e Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/FPX/stp_fpx_logo.imageset/stp_fpx_logo@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_add.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_add.imageset/Contents.json new file mode 100644 index 00000000..569c890b --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_add.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_icon_add.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_icon_add@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_icon_add@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_add.imageset/stp_icon_add.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_add.imageset/stp_icon_add.png new file mode 100644 index 00000000..f9595642 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_add.imageset/stp_icon_add.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_add.imageset/stp_icon_add@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_add.imageset/stp_icon_add@2x.png new file mode 100644 index 00000000..2c695a3d Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_add.imageset/stp_icon_add@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_add.imageset/stp_icon_add@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_add.imageset/stp_icon_add@3x.png new file mode 100644 index 00000000..8a108a76 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_add.imageset/stp_icon_add@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_bank.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_bank.imageset/Contents.json new file mode 100644 index 00000000..a368e565 --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_bank.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_icon_bank.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_icon_bank@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_icon_bank@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_bank.imageset/stp_icon_bank.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_bank.imageset/stp_icon_bank.png new file mode 100644 index 00000000..cdb49f12 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_bank.imageset/stp_icon_bank.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_bank.imageset/stp_icon_bank@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_bank.imageset/stp_icon_bank@2x.png new file mode 100644 index 00000000..6a02de3c Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_bank.imageset/stp_icon_bank@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_bank.imageset/stp_icon_bank@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_bank.imageset/stp_icon_bank@3x.png new file mode 100644 index 00000000..f49ebe9a Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_bank.imageset/stp_icon_bank@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_checkmark.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_checkmark.imageset/Contents.json new file mode 100644 index 00000000..79d652fe --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_checkmark.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_icon_checkmark.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_icon_checkmark@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_icon_checkmark@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_checkmark.imageset/stp_icon_checkmark.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_checkmark.imageset/stp_icon_checkmark.png new file mode 100644 index 00000000..253ea054 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_checkmark.imageset/stp_icon_checkmark.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_checkmark.imageset/stp_icon_checkmark@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_checkmark.imageset/stp_icon_checkmark@2x.png new file mode 100644 index 00000000..875e0a1f Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_checkmark.imageset/stp_icon_checkmark@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_checkmark.imageset/stp_icon_checkmark@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_checkmark.imageset/stp_icon_checkmark@3x.png new file mode 100644 index 00000000..25831dc8 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_icon_checkmark.imageset/stp_icon_checkmark@3x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_shipping_form.imageset/Contents.json b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_shipping_form.imageset/Contents.json new file mode 100644 index 00000000..166215bf --- /dev/null +++ b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_shipping_form.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stp_shipping_form.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stp_shipping_form@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stp_shipping_form@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_shipping_form.imageset/stp_shipping_form.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_shipping_form.imageset/stp_shipping_form.png new file mode 100644 index 00000000..33eb8e1d Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_shipping_form.imageset/stp_shipping_form.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_shipping_form.imageset/stp_shipping_form@2x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_shipping_form.imageset/stp_shipping_form@2x.png new file mode 100644 index 00000000..c7dead3f Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_shipping_form.imageset/stp_shipping_form@2x.png differ diff --git a/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_shipping_form.imageset/stp_shipping_form@3x.png b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_shipping_form.imageset/stp_shipping_form@3x.png new file mode 100644 index 00000000..34ede140 Binary files /dev/null and b/Stripe/StripeiOS/Resources/StripeiOS.xcassets/stp_shipping_form.imageset/stp_shipping_form@3x.png differ diff --git a/Stripe/StripeiOS/Source/Enums+CustomStringConvertible.swift b/Stripe/StripeiOS/Source/Enums+CustomStringConvertible.swift new file mode 100644 index 00000000..a7f24c21 --- /dev/null +++ b/Stripe/StripeiOS/Source/Enums+CustomStringConvertible.swift @@ -0,0 +1,63 @@ +// +// Enums+CustomStringConvertible.swift +// Stripe +// +// Autogenerated by generate_objc_enum_string_values.rb +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import SwiftUI + +/// :nodoc: +extension STPBankSelectionMethod: CustomStringConvertible { + public var description: String { + switch self { + case .FPX: + return "FPX" + case .unknown: + return "unknown" + } + } +} + +/// :nodoc: +extension STPBillingAddressFields: CustomStringConvertible { + public var description: String { + switch self { + case .full: + return "full" + case .name: + return "name" + case .none: + return "none" + case .postalCode: + return "postalCode" + case .zip: + return "zip" + } + } +} + +/// :nodoc: +extension STPShippingStatus: CustomStringConvertible { + public var description: String { + switch self { + case .invalid: + return "invalid" + case .valid: + return "valid" + } + } +} + +/// :nodoc: +extension STPShippingType: CustomStringConvertible { + public var description: String { + switch self { + case .delivery: + return "delivery" + case .shipping: + return "shipping" + } + } +} diff --git a/Stripe/StripeiOS/Source/PKAddPaymentPassRequest+Stripe_Error.swift b/Stripe/StripeiOS/Source/PKAddPaymentPassRequest+Stripe_Error.swift new file mode 100644 index 00000000..ab0f3bbe --- /dev/null +++ b/Stripe/StripeiOS/Source/PKAddPaymentPassRequest+Stripe_Error.swift @@ -0,0 +1,30 @@ +// +// PKAddPaymentPassRequest+Stripe_Error.swift +// StripeiOS +// +// Created by Jack Flintermann on 9/29/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +import ObjectiveC +import PassKit + +var stpAddPaymentPassRequest: UInt8 = 0 + +// This is used to store an error on a PKAddPaymentPassRequest +// so that STPFakeAddPaymentPassViewController can inspect it for debugging. +extension PKAddPaymentPassRequest { + @objc var stp_error: NSError? { + get { + return objc_getAssociatedObject(self, &stpAddPaymentPassRequest) as? NSError + } + set(stp_error) { + objc_setAssociatedObject( + self, + &stpAddPaymentPassRequest, + stp_error, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } +} diff --git a/Stripe/StripeiOS/Source/PKPaymentAuthorizationViewController+Stripe_Blocks.swift b/Stripe/StripeiOS/Source/PKPaymentAuthorizationViewController+Stripe_Blocks.swift new file mode 100644 index 00000000..590d3342 --- /dev/null +++ b/Stripe/StripeiOS/Source/PKPaymentAuthorizationViewController+Stripe_Blocks.swift @@ -0,0 +1,187 @@ +// +// PKPaymentAuthorizationViewController+Stripe_Blocks.swift +// StripeiOS +// +// Created by Ben Guo on 4/19/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import ObjectiveC +import PassKit +@_spi(STP) import StripeCore + +typealias STPApplePayPaymentMethodHandlerBlock = (STPPaymentMethod, @escaping STPPaymentStatusBlock) + -> Void +typealias STPPaymentCompletionBlock = (STPPaymentStatus, Error?) -> Void +typealias STPPaymentSummaryItemCompletionBlock = ([PKPaymentSummaryItem]) -> Void +typealias STPShippingMethodSelectionBlock = ( + PKShippingMethod, @escaping STPPaymentSummaryItemCompletionBlock +) -> Void +typealias STPShippingAddressValidationBlock = ( + STPShippingStatus, [PKShippingMethod], [PKPaymentSummaryItem] +) -> Void +typealias STPShippingAddressSelectionBlock = ( + STPAddress, @escaping STPShippingAddressValidationBlock +) -> Void +typealias STPPaymentAuthorizationBlock = (PKPayment) -> Void +extension PKPaymentAuthorizationViewController { + class func stp_controller( + with paymentRequest: PKPaymentRequest, + apiClient: STPAPIClient, + onShippingAddressSelection: @escaping STPShippingAddressSelectionBlock, + onShippingMethodSelection: @escaping STPShippingMethodSelectionBlock, + onPaymentAuthorization: @escaping STPPaymentAuthorizationBlock, + onPaymentMethodCreation: @escaping STPApplePayPaymentMethodHandlerBlock, + onFinish: @escaping STPPaymentCompletionBlock + ) -> Self? { + let delegate = STPBlockBasedApplePayDelegate() + delegate.apiClient = apiClient + delegate.onShippingAddressSelection = onShippingAddressSelection + delegate.onShippingMethodSelection = onShippingMethodSelection + delegate.onPaymentAuthorization = onPaymentAuthorization + delegate.onPaymentMethodCreation = onPaymentMethodCreation + delegate.onFinish = onFinish + let viewController = self.init(paymentRequest: paymentRequest) + viewController?.delegate = delegate + if let viewController = viewController { + objc_setAssociatedObject( + viewController, + UnsafeRawPointer(&kSTPBlockBasedApplePayDelegateAssociatedObjectKey), + delegate, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + return viewController + } +} + +private var kSTPBlockBasedApplePayDelegateAssociatedObjectKey = 0 +typealias STPApplePayShippingMethodCompletionBlock = ( + PKPaymentAuthorizationStatus, [PKPaymentSummaryItem]? +) -> Void +typealias STPApplePayShippingAddressCompletionBlock = ( + PKPaymentAuthorizationStatus, [PKShippingMethod]?, [PKPaymentSummaryItem]? +) -> Void +class STPBlockBasedApplePayDelegate: NSObject, PKPaymentAuthorizationViewControllerDelegate { + var apiClient: STPAPIClient? + var onShippingAddressSelection: STPShippingAddressSelectionBlock? + var onShippingMethodSelection: STPShippingMethodSelectionBlock? + var onPaymentAuthorization: STPPaymentAuthorizationBlock? + var onPaymentMethodCreation: STPApplePayPaymentMethodHandlerBlock? + var onFinish: STPPaymentCompletionBlock? + var lastError: Error? + var didSucceed = false + + // Remove all this once we drop iOS 11 support + func paymentAuthorizationViewController( + _ controller: PKPaymentAuthorizationViewController, + didAuthorizePayment payment: PKPayment, + completion: @escaping (PKPaymentAuthorizationStatus) -> Void + ) { + onPaymentAuthorization?(payment) + + let paymentMethodCreateCompletion: ((STPPaymentMethod?, Error?) -> Void)? = { + result, + paymentMethodCreateError in + if let paymentMethodCreateError = paymentMethodCreateError { + self.lastError = paymentMethodCreateError + completion(.failure) + return + } + guard let result = result else { + self.lastError = NSError.stp_genericFailedToParseResponseError() + completion(.failure) + return + } + self.onPaymentMethodCreation?( + result, + { status, error in + if status != .success || error != nil { + self.lastError = error + completion(.failure) + if controller.presentingViewController == nil { + // If we call completion() after dismissing, didFinishWithStatus is NOT called. + self._finish() + } + return + } + self.didSucceed = true + completion(.success) + if controller.presentingViewController == nil { + // If we call completion() after dismissing, didFinishWithStatus is NOT called. + self._finish() + } + } + ) + } + if let paymentMethodCreateCompletion = paymentMethodCreateCompletion { + apiClient?.createPaymentMethod(with: payment, completion: paymentMethodCreateCompletion) + } + } + + func paymentAuthorizationViewController( + _ controller: PKPaymentAuthorizationViewController, + didSelect shippingMethod: PKShippingMethod, + completion: @escaping (PKPaymentAuthorizationStatus, [PKPaymentSummaryItem]) -> Void + ) { + onShippingMethodSelection?( + shippingMethod, + { summaryItems in + completion(PKPaymentAuthorizationStatus.success, summaryItems) + } + ) + } + + func paymentAuthorizationViewController( + _ controller: PKPaymentAuthorizationViewController, + didSelectShippingContact contact: PKContact, + handler completion: @escaping (PKPaymentRequestShippingContactUpdate) -> Void + ) { + let stpAddress = STPAddress(pkContact: contact) + onShippingAddressSelection?( + stpAddress, + { status, shippingMethods, summaryItems in + if status == .invalid { + let genericShippingError = NSError( + domain: PKPaymentErrorDomain, + code: PKPaymentError.shippingContactInvalidError.rawValue, + userInfo: nil + ) + completion( + PKPaymentRequestShippingContactUpdate( + errors: [genericShippingError], + paymentSummaryItems: summaryItems, + shippingMethods: shippingMethods + ) + ) + } else { + completion( + PKPaymentRequestShippingContactUpdate( + errors: nil, + paymentSummaryItems: summaryItems, + shippingMethods: shippingMethods + ) + ) + } + } + ) + } + + func paymentAuthorizationViewControllerDidFinish( + _ controller: PKPaymentAuthorizationViewController + ) { + _finish() + } + + func _finish() { + if didSucceed { + onFinish?(.success, nil) + } else if let lastError = lastError { + onFinish?(.error, lastError) + } else { + onFinish?(.userCancellation, nil) + } + } +} + +typealias STPPaymentAuthorizationStatusCallback = (PKPaymentAuthorizationStatus) -> Void diff --git a/Stripe/StripeiOS/Source/STPAPIClient+BasicUI.swift b/Stripe/StripeiOS/Source/STPAPIClient+BasicUI.swift new file mode 100644 index 00000000..00e78896 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPAPIClient+BasicUI.swift @@ -0,0 +1,178 @@ +// +// STPAPIClient+BasicUI.swift +// StripeiOS +// +// Created by David Estes on 6/30/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments + +/// A client for making connections to the Stripe API. +extension STPAPIClient { + /// Initializes an API client with the given configuration. + /// - Parameter configuration: The configuration to use. + /// - Returns: An instance of STPAPIClient. + @available( + *, + deprecated, + message: + "This initializer previously configured publishableKey and stripeAccount via the STPPaymentConfiguration instance. This behavior is deprecated; set the STPAPIClient configuration, publishableKey, and stripeAccount properties directly on the STPAPIClient instead." + ) + public convenience init( + configuration: STPPaymentConfiguration + ) { + // For legacy reasons, we'll support this initializer and use the deprecated configuration.{publishableKey, stripeAccount} properties + self.init() + publishableKey = configuration.publishableKey + stripeAccount = configuration.stripeAccount + } +} + +extension STPAPIClient { + /// The client's configuration. + /// Defaults to `STPPaymentConfiguration.shared`. + @objc public var configuration: STPPaymentConfiguration { + get { + if let config = _stored_configuration as? STPPaymentConfiguration { + return config + } else { + return .shared + } + } + set { + _stored_configuration = newValue + } + } + + /// Update a customer with parameters + /// - seealso: https://stripe.com/docs/api#update_customer + func updateCustomer( + withParameters parameters: [String: Any], + using ephemeralKey: STPEphemeralKey, + completion: @escaping STPCustomerCompletionBlock + ) { + let endpoint = "\(APIEndpointCustomers)/\(ephemeralKey.customerID ?? "")" + APIRequest.post( + with: self, + endpoint: endpoint, + additionalHeaders: authorizationHeader(using: ephemeralKey), + parameters: parameters + ) { object, _, error in + completion(object, error) + } + } + + /// Attach a Payment Method to a customer + /// - seealso: https://stripe.com/docs/api/payment_methods/attach + func attachPaymentMethod( + _ paymentMethodID: String, + toCustomerUsing ephemeralKey: STPEphemeralKey, + completion: @escaping STPErrorBlock + ) { + guard let customerID = ephemeralKey.customerID else { + assertionFailure() + completion(nil) + return + } + let endpoint = "\(APIEndpointPaymentMethods)/\(paymentMethodID)/attach" + APIRequest.post( + with: self, + endpoint: endpoint, + additionalHeaders: authorizationHeader(using: ephemeralKey), + parameters: [ + "customer": customerID + ] + ) { _, _, error in + completion(error) + } + } + + /// Detach a Payment Method from a customer + /// - seealso: https://stripe.com/docs/api/payment_methods/detach + func detachPaymentMethod( + _ paymentMethodID: String, + fromCustomerUsing ephemeralKey: STPEphemeralKey, + completion: @escaping STPErrorBlock + ) { + let endpoint = "\(APIEndpointPaymentMethods)/\(paymentMethodID)/detach" + APIRequest.post( + with: self, + endpoint: endpoint, + additionalHeaders: authorizationHeader(using: ephemeralKey), + parameters: [:] + ) { _, _, error in + completion(error) + } + } + + /// Retrieves a list of Payment Methods attached to a customer. + /// @note This only fetches card type Payment Methods + func listPaymentMethodsForCustomer( + using ephemeralKey: STPEphemeralKey, + completion: @escaping STPPaymentMethodsCompletionBlock + ) { + let header = authorizationHeader(using: ephemeralKey) + let params: [String: Any] = [ + "customer": ephemeralKey.customerID ?? "", + "type": "card", + ] + APIRequest.getWith( + self, + endpoint: APIEndpointPaymentMethods, + additionalHeaders: header, + parameters: params as [String: Any] + ) { deserializer, _, error in + if let error = error { + completion(nil, error) + } else if let paymentMethods = deserializer?.paymentMethods { + completion(paymentMethods, nil) + } + } + } + + /// Retrieve a customer + /// - seealso: https://stripe.com/docs/api#retrieve_customer + func retrieveCustomer( + using ephemeralKey: STPEphemeralKey, + completion: @escaping STPCustomerCompletionBlock + ) { + let endpoint = "\(APIEndpointCustomers)/\(ephemeralKey.customerID ?? "")" + APIRequest.getWith( + self, + endpoint: endpoint, + additionalHeaders: authorizationHeader(using: ephemeralKey), + parameters: [:] + ) { object, _, error in + completion(object, error) + } + } + + // MARK: FPX + /// Retrieves the online status of the FPX banks from the Stripe API. + /// - Parameter completion: The callback to run with the returned FPX bank list, or an error. + func retrieveFPXBankStatus( + withCompletion completion: @escaping STPFPXBankStatusCompletionBlock + ) { + APIRequest.getWith( + self, + endpoint: APIEndpointFPXStatus, + parameters: [ + "account_holder_type": "individual" + ] + ) { statusResponse, _, error in + completion(statusResponse, error) + } + } + + // MARK: Helpers + + /// A helper method that returns the Authorization header to use for API requests. If ephemeralKey is nil, uses self.publishableKey instead. + func authorizationHeader(using ephemeralKey: STPEphemeralKey? = nil) -> [String: String] { + return authorizationHeader(using: ephemeralKey?.secret) + } +} + +private let APIEndpointFPXStatus = "fpx/bank_statuses" diff --git a/Stripe/StripeiOS/Source/STPAPIClient+PushProvisioning.swift b/Stripe/StripeiOS/Source/STPAPIClient+PushProvisioning.swift new file mode 100644 index 00000000..1acda148 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPAPIClient+PushProvisioning.swift @@ -0,0 +1,40 @@ +// +// STPAPIClient+PushProvisioning.swift +// StripeiOS +// +// Created by Jack Flintermann on 9/27/18. +// Copyright © 2018 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments +@_spi(STP) import StripePaymentsUI + +typealias STPPushProvisioningDetailsCompletionBlock = (STPPushProvisioningDetails?, Error?) -> Void +extension STPAPIClient { + func retrievePushProvisioningDetails( + with params: STPPushProvisioningDetailsParams, + ephemeralKey: STPEphemeralKey, + completion: @escaping STPPushProvisioningDetailsCompletionBlock + ) { + + let endpoint = "issuing/cards/\(params.cardId)/push_provisioning_details" + let parameters = [ + "ios": [ + "certificates": params.certificatesBase64, + "nonce": params.nonceHex, + "nonce_signature": params.nonceSignatureHex, + ] as [String: Any], + ] + + APIRequest.getWith( + self, + endpoint: endpoint, + additionalHeaders: authorizationHeader(using: ephemeralKey), + parameters: parameters + ) { details, _, error in + completion(details, error) + } + } +} diff --git a/Stripe/StripeiOS/Source/STPAddCardViewController.swift b/Stripe/StripeiOS/Source/STPAddCardViewController.swift new file mode 100644 index 00000000..7037464f --- /dev/null +++ b/Stripe/StripeiOS/Source/STPAddCardViewController.swift @@ -0,0 +1,970 @@ +// +// STPAddCardViewController.swift +// StripeiOS +// +// Created by Jack Flintermann on 3/23/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments +@_spi(STP) import StripePaymentsUI +@_spi(STP) import StripeUICore +import UIKit + +/// This view controller contains a credit card entry form that the user can fill out. On submission, it will use the Stripe API to convert the user's card details to a Stripe token. It renders a right bar button item that submits the form, so it must be shown inside a `UINavigationController`. +public class STPAddCardViewController: STPCoreTableViewController, STPAddressViewModelDelegate, + STPCardScannerDelegate, STPPaymentCardTextFieldDelegate, UITableViewDelegate, + UITableViewDataSource +{ + + /// A convenience initializer; equivalent to calling `init(configuration: STPPaymentConfiguration.shared, theme: STPTheme.defaultTheme)`. + @objc + public convenience init() { + self.init(configuration: STPPaymentConfiguration.shared, theme: STPTheme.defaultTheme) + } + + /// Initializes a new `STPAddCardViewController` with the provided configuration and theme. Don't forget to set the `delegate` property after initialization. + /// - Parameters: + /// - configuration: The configuration to use (this determines the Stripe publishable key to use, the required billing address fields, whether or not to use SMS autofill, etc). - seealso: STPPaymentConfiguration + /// - theme: The theme to use to inform the view controller's visual appearance. - seealso: STPTheme + @objc(initWithConfiguration:theme:) + public init( + configuration: STPPaymentConfiguration, + theme: STPTheme + ) { + addressViewModel = STPAddressViewModel( + requiredBillingFields: configuration.requiredBillingAddressFields, + availableCountries: configuration.availableCountries + ) + super.init(theme: theme) + commonInit(with: configuration) + } + + /// The view controller's delegate. This must be set before showing the view controller in order for it to work properly. - seealso: STPAddCardViewControllerDelegate + @objc public weak var delegate: STPAddCardViewControllerDelegate? + /// You can set this property to pre-fill any information you've already collected from your user. - seealso: STPUserInformation.h + @objc public var prefilledInformation: STPUserInformation? { + didSet { + if let address = prefilledInformation?.billingAddress { + addressViewModel.address = address + } + } + } + + private var _customFooterView: UIView? + /// Provide this view controller with a footer view. + /// When the footer view needs to be resized, it will be sent a + /// `sizeThatFits:` call. The view should respond correctly to this method in order + /// to be sized and positioned properly. + @objc public var customFooterView: UIView? { + get { + _customFooterView + } + set(footerView) { + _customFooterView = footerView + _configureFooterView() + } + } + + func _configureFooterView() { + if isViewLoaded, let footerView = _customFooterView { + let size = footerView.sizeThatFits( + CGSize(width: view.bounds.size.width, height: CGFloat.greatestFiniteMagnitude) + ) + footerView.frame = CGRect(x: 0, y: 0, width: size.width, height: size.height) + + tableView?.tableFooterView = footerView + } + } + + /// The API Client to use to make requests. + /// Defaults to `STPAPIClient.shared` + public var apiClient: STPAPIClient = STPAPIClient.shared + + /// Use init: or initWithConfiguration:theme: + required init( + theme: STPTheme? + ) { + let configuration = STPPaymentConfiguration.shared + addressViewModel = STPAddressViewModel( + requiredBillingFields: configuration.requiredBillingAddressFields, + availableCountries: configuration.availableCountries + ) + super.init(theme: theme) + } + + /// Use init: or initWithConfiguration:theme: + required init( + nibName nibNameOrNil: String?, + bundle nibBundleOrNil: Bundle? + ) { + let configuration = STPPaymentConfiguration.shared + addressViewModel = STPAddressViewModel( + requiredBillingFields: configuration.requiredBillingAddressFields, + availableCountries: configuration.availableCountries + ) + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + } + + /// Use init: or initWithConfiguration:theme: + required init?( + coder aDecoder: NSCoder + ) { + let configuration = STPPaymentConfiguration.shared + addressViewModel = STPAddressViewModel( + requiredBillingFields: configuration.requiredBillingAddressFields, + availableCountries: configuration.availableCountries + ) + super.init(coder: aDecoder) + } + + private var _alwaysEnableDoneButton = false + @objc var alwaysEnableDoneButton: Bool { + get { + _alwaysEnableDoneButton + } + set(alwaysEnableDoneButton) { + if alwaysEnableDoneButton != _alwaysEnableDoneButton { + _alwaysEnableDoneButton = alwaysEnableDoneButton + updateDoneButton() + } + } + } + private var configuration: STPPaymentConfiguration? + @objc var shippingAddress: STPAddress? + private var hasUsedShippingAddress = false + private weak var cardImageView: UIImageView? + private var doneItem: UIBarButtonItem? + private var cardHeaderView: STPSectionHeaderView? + + @available(macCatalyst 14.0, *) + private var cardScanner: STPCardScanner? { + get { + _cardScanner as? STPCardScanner + } + set { + _cardScanner = newValue + } + } + + /// Storage for `cardScanner`. + private var _cardScanner: NSObject? + + @available(macCatalyst 14.0, *) + private var scannerCell: STPCardScannerTableViewCell? { + get { + _scannerCell as? STPCardScannerTableViewCell + } + set { + _scannerCell = newValue + } + } + + /// Storage for `scannerCell`. + private var _scannerCell: NSObject? + + private var _isScanning = false + private var isScanning: Bool { + get { + _isScanning + } + set(isScanning) { + if _isScanning == isScanning { + return + } + _isScanning = isScanning + + cardHeaderView?.button?.isEnabled = !isScanning + let indexPath = IndexPath( + row: 0, + section: STPPaymentCardSection.stpPaymentCardScannerSection.rawValue + ) + tableView?.beginUpdates() + if isScanning { + tableView?.insertRows(at: [indexPath], with: .automatic) + } else { + tableView?.deleteRows(at: [indexPath], with: .automatic) + } + tableView?.endUpdates() + if isScanning { + tableView?.scrollToRow(at: indexPath, at: .middle, animated: true) + } + updateInputAccessoryVisiblity() + } + } + private var addressHeaderView: STPSectionHeaderView? + var paymentCell: STPPaymentCardTextFieldCell? + + private var _loading = false + @objc var loading: Bool { + get { + _loading + } + set(loading) { + if loading == _loading { + return + } + _loading = loading + stp_navigationItemProxy?.setHidesBackButton(loading, animated: true) + stp_navigationItemProxy?.leftBarButtonItem?.isEnabled = !loading + activityIndicator?.animating = loading + if loading { + tableView?.endEditing(true) + var loadingItem: UIBarButtonItem? + if let activityIndicator = activityIndicator { + loadingItem = UIBarButtonItem(customView: activityIndicator) + } + stp_navigationItemProxy?.setRightBarButton(loadingItem, animated: true) + cardHeaderView?.buttonHidden = true + } else { + stp_navigationItemProxy?.setRightBarButton(doneItem, animated: true) + cardHeaderView?.buttonHidden = false + } + var cells = addressViewModel.addressCells as [UITableViewCell] + + if let paymentCell = paymentCell { + cells.append(paymentCell) + } + for cell in cells { + cell.isUserInteractionEnabled = !loading + UIView.animate( + withDuration: 0.1, + animations: { + cell.alpha = loading ? 0.7 : 1.0 + } + ) + } + } + } + private var activityIndicator: STPPaymentActivityIndicatorView? + private weak var lookupActivityIndicator: STPPaymentActivityIndicatorView? + var addressViewModel: STPAddressViewModel + private var inputAccessoryToolbar: UIToolbar? + private var lookupSucceeded = false + private var scannerCompleteAnimationTimer: Timer? + + @objc(commonInitWithConfiguration:) func commonInit(with configuration: STPPaymentConfiguration) + { + STPAnalyticsClient.sharedClient.addClass( + toProductUsageIfNecessary: STPAddCardViewController.self + ) + + self.configuration = configuration + shippingAddress = nil + hasUsedShippingAddress = false + addressViewModel.delegate = self + title = STPLocalizedString("Add a Card", "Title for Add a Card view") + + if #available(macCatalyst 14.0, *) { + cardScanner = STPCardScanner() + } + } + + /// :nodoc: + @objc + public func tableView( + _ tableView: UITableView, + estimatedHeightForRowAt indexPath: IndexPath + ) -> CGFloat { + return 44.0 + } + + @objc override func createAndSetupViews() { + super.createAndSetupViews() + + let doneItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(nextPressed(_:)) + ) + self.doneItem = doneItem + stp_navigationItemProxy?.rightBarButtonItem = doneItem + updateDoneButton() + + stp_navigationItemProxy?.leftBarButtonItem?.accessibilityIdentifier = + "AddCardViewControllerNavBarCancelButtonIdentifier" + stp_navigationItemProxy?.rightBarButtonItem?.accessibilityIdentifier = + "AddCardViewControllerNavBarDoneButtonIdentifier" + + let cardImageView = UIImageView(image: STPLegacyImageLibrary.largeCardFrontImage()) + cardImageView.contentMode = .center + cardImageView.frame = CGRect( + x: 0, + y: 0, + width: view.bounds.size.width, + height: cardImageView.bounds.size.height + (57 * 2) + ) + self.cardImageView = cardImageView + tableView?.tableHeaderView = cardImageView + + let paymentCell = STPPaymentCardTextFieldCell( + style: .default, + reuseIdentifier: "STPAddCardViewControllerPaymentCardTextFieldCell" + ) + paymentCell.paymentField?.delegate = self + if configuration?.requiredBillingAddressFields == .postalCode { + // If postal code collection is enabled, move the postal code field into the card entry field. + // Otherwise, this will be picked up by the billing address fields below. + paymentCell.paymentField?.postalCodeEntryEnabled = true + } + self.paymentCell = paymentCell + + activityIndicator = STPPaymentActivityIndicatorView( + frame: CGRect(x: 0, y: 0, width: 20.0, height: 20.0) + ) + + inputAccessoryToolbar = UIToolbar.stp_inputAccessoryToolbar( + withTarget: self, + action: #selector(paymentFieldNextTapped) + ) + inputAccessoryToolbar?.stp_setEnabled(false) + updateInputAccessoryVisiblity() + tableView?.dataSource = self + tableView?.delegate = self + tableView?.reloadData() + if let address = prefilledInformation?.billingAddress { + addressViewModel.address = address + } + + let addressHeaderView = STPSectionHeaderView() + addressHeaderView.theme = theme + addressHeaderView.title = String.Localized.billing_address + switch configuration?.shippingType { + case .shipping: + addressHeaderView.button?.setTitle( + STPLocalizedString( + "Use Shipping", + "Button to fill billing address from shipping address." + ), + for: .normal + ) + case .delivery: + addressHeaderView.button?.setTitle( + STPLocalizedString( + "Use Delivery", + "Button to fill billing address from delivery address." + ), + for: .normal + ) + default: + break + } + addressHeaderView.button?.addTarget( + self, + action: #selector(useShippingAddress(_:)), + for: .touchUpInside + ) + let requiredFields = configuration?.requiredBillingAddressFields ?? .none + let needsAddress = requiredFields != .none && !addressViewModel.isValid + let buttonVisible = + needsAddress && shippingAddress?.containsContent(for: requiredFields) != nil + && !hasUsedShippingAddress + addressHeaderView.buttonHidden = !buttonVisible + addressHeaderView.setNeedsLayout() + self.addressHeaderView = addressHeaderView + let cardHeaderView = STPSectionHeaderView() + cardHeaderView.theme = theme + cardHeaderView.title = STPPaymentMethodType.card.displayName + cardHeaderView.buttonHidden = true + self.cardHeaderView = cardHeaderView + + // re-set the custom footer view if it was added before we loaded + _configureFooterView() + + view.addGestureRecognizer( + UITapGestureRecognizer(target: self, action: #selector(endEditing)) + ) + + setUpCardScanningIfAvailable() + + STPAnalyticsClient.sharedClient.clearAdditionalInfo() + } + + /// :nodoc: + @objc + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + // Resetting it re-calculates the size based on new view width + // UITableView requires us to call setter again to actually pick up frame + // change on footers + if tableView?.tableFooterView != nil { + customFooterView = tableView?.tableFooterView + } + } + + func setUpCardScanningIfAvailable() { + if #available(macCatalyst 14.0, *) { + if !STPCardScanner.cardScanningAvailable || configuration?.cardScanningEnabled != true { + return + } + let scannerCell = STPCardScannerTableViewCell() + self.scannerCell = scannerCell + + let cardScanner = STPCardScanner(delegate: self) + cardScanner.cameraView = scannerCell.cameraView + self.cardScanner = cardScanner + + cardHeaderView?.buttonHidden = false + cardHeaderView?.button?.setTitle( + String.Localized.scan_card_title_capitalization, + for: .normal + ) + cardHeaderView?.button?.addTarget( + self, + action: #selector(scanCard), + for: .touchUpInside + ) + cardHeaderView?.setNeedsLayout() + } + } + + @available(macCatalyst 14.0, *) + @objc func scanCard() { + view.endEditing(true) + isScanning = true + cardScanner?.start() + } + + @objc func endEditing() { + view.endEditing(false) + } + + /// :nodoc: + @objc + public override func updateAppearance() { + super.updateAppearance() + + view.backgroundColor = theme.primaryBackgroundColor + + let navBarTheme = navigationController?.navigationBar.stp_theme ?? theme + doneItem?.stp_setTheme(navBarTheme) + tableView?.allowsSelection = false + + cardImageView?.tintColor = theme.accentColor + activityIndicator?.tintColor = theme.accentColor + + paymentCell?.theme = theme + cardHeaderView?.theme = theme + addressHeaderView?.theme = theme + for cell in addressViewModel.addressCells { + cell.theme = theme + } + setNeedsStatusBarAppearanceUpdate() + } + + /// :nodoc: + @objc + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + stp_beginObservingKeyboardAndInsettingScrollView( + tableView, + onChange: nil + ) + firstEmptyField()?.becomeFirstResponder() + } + + func firstEmptyField() -> UIResponder? { + + if paymentCell?.isEmpty != nil { + return paymentCell! + } + + for cell in addressViewModel.addressCells { + if cell.contents?.count ?? 0 == 0 { + return cell + } + } + return nil + } + + /// :nodoc: + @objc + public override func handleCancelTapped(_ sender: Any?) { + delegate?.addCardViewControllerDidCancel(self) + } + + @objc func nextPressed(_ sender: Any?) { + loading = true + guard let cardParams = paymentCell?.paymentField?.paymentMethodParams.card else { + return + } + // Create and return a Payment Method + let billingDetails = STPPaymentMethodBillingDetails() + if configuration?.requiredBillingAddressFields == .postalCode { + let address = STPAddress() + address.postalCode = paymentCell?.paymentField?.postalCode + billingDetails.address = STPPaymentMethodAddress(address: address) + } else { + billingDetails.address = STPPaymentMethodAddress(address: addressViewModel.address) + billingDetails.email = addressViewModel.address.email + billingDetails.name = addressViewModel.address.name + billingDetails.phone = addressViewModel.address.phone + } + let paymentMethodParams = STPPaymentMethodParams( + card: cardParams, + billingDetails: billingDetails, + metadata: nil + ) + apiClient.createPaymentMethod(with: paymentMethodParams) { + paymentMethod, + createPaymentMethodError in + if let createPaymentMethodError = createPaymentMethodError { + self.handleError(createPaymentMethodError) + } else { + if let paymentMethod = paymentMethod { + self.delegate?.addCardViewController( + self, + didCreatePaymentMethod: paymentMethod + ) { + attachToCustomerError in + stpDispatchToMainThreadIfNecessary({ + if let attachToCustomerError = attachToCustomerError { + self.handleError(attachToCustomerError) + } else { + self.loading = false + } + }) + } + } + } + } + } + + func handleError(_ error: Error) { + loading = false + firstEmptyField()?.becomeFirstResponder() + + let alertController = UIAlertController( + title: error.localizedDescription, + message: (error as NSError).localizedFailureReason, + preferredStyle: .alert + ) + + alertController.addAction( + UIAlertAction( + title: String.Localized.ok, + style: .cancel, + handler: nil + ) + ) + + present(alertController, animated: true) + } + + func updateDoneButton() { + stp_navigationItemProxy?.rightBarButtonItem?.isEnabled = + (paymentCell?.paymentField?.isValid ?? false && addressViewModel.isValid) + || alwaysEnableDoneButton + } + + func updateInputAccessoryVisiblity() { + // The inputAccessoryToolbar switches from the paymentCell to the first address field. + // It should only be shown when there *is* an address field. This compensates for the lack + // of a 'Return' key on the number pad used for paymentCell entry + let hasAddressCells = (addressViewModel.addressCells.count) > 0 + paymentCell?.inputAccessoryView = hasAddressCells ? inputAccessoryToolbar : nil + } + + // MARK: - STPPaymentCardTextField + @objc + public func paymentCardTextFieldDidChange(_ textField: STPPaymentCardTextField) { + inputAccessoryToolbar?.stp_setEnabled(textField.isValid) + updateDoneButton() + } + + @objc func paymentFieldNextTapped() { + _ = addressViewModel.addressCells.stp_boundSafeObject(at: 0)?.becomeFirstResponder() + } + + @objc + public func paymentCardTextFieldWillEndEditing(forReturn textField: STPPaymentCardTextField) { + paymentFieldNextTapped() + } + + @objc + public func paymentCardTextFieldDidBeginEditingCVC(_ textField: STPPaymentCardTextField) { + let isAmex = STPCardValidator.brand(forNumber: textField.cardNumber ?? "") == .amex + var newImage: UIImage? + var animationTransition: UIView.AnimationOptions + + if isAmex { + newImage = STPLegacyImageLibrary.largeCardAmexCVCImage() + animationTransition = .transitionCrossDissolve + } else { + newImage = STPLegacyImageLibrary.largeCardBackImage() + animationTransition = .transitionFlipFromRight + } + + if let cardImageView = cardImageView { + UIView.transition( + with: cardImageView, + duration: 0.2, + options: animationTransition, + animations: { + self.cardImageView?.image = newImage + } + ) + } + } + + @objc + public func paymentCardTextFieldDidEndEditingCVC(_ textField: STPPaymentCardTextField) { + let isAmex = STPCardValidator.brand(forNumber: textField.cardNumber ?? "") == .amex + let animationTransition: UIView.AnimationOptions = + isAmex ? .transitionCrossDissolve : .transitionFlipFromLeft + + if let cardImageView = cardImageView { + UIView.transition( + with: cardImageView, + duration: 0.2, + options: animationTransition, + animations: { + self.cardImageView?.image = STPLegacyImageLibrary.largeCardFrontImage() + } + ) + } + } + + @objc + public func paymentCardTextFieldDidBeginEditing(_ textField: STPPaymentCardTextField) { + if #available(macCatalyst 14.0, *) { + cardScanner?.stop() + } + } + + // MARK: - STPAddressViewModelDelegate + func addressViewModel(_ addressViewModel: STPAddressViewModel, addedCellAt index: Int) { + let indexPath = IndexPath( + row: index, + section: STPPaymentCardSection.stpPaymentCardBillingAddressSection.rawValue + ) + tableView?.insertRows(at: [indexPath], with: .automatic) + updateInputAccessoryVisiblity() + } + + func addressViewModel(_ addressViewModel: STPAddressViewModel, removedCellAt index: Int) { + let indexPath = IndexPath( + row: Int(index), + section: STPPaymentCardSection.stpPaymentCardBillingAddressSection.rawValue + ) + tableView?.deleteRows(at: [indexPath], with: .automatic) + updateInputAccessoryVisiblity() + } + + func addressViewModelDidChange(_ addressViewModel: STPAddressViewModel) { + updateDoneButton() + } + + func addressViewModelWillUpdate(_ addressViewModel: STPAddressViewModel) { + tableView?.beginUpdates() + } + + func addressViewModelDidUpdate(_ addressViewModel: STPAddressViewModel) { + tableView?.endUpdates() + } + + // MARK: - UITableView + /// :nodoc: + @objc + public func numberOfSections(in tableView: UITableView) -> Int { + return 3 + } + + /// :nodoc: + @objc + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + if section == STPPaymentCardSection.stpPaymentCardNumberSection.rawValue { + return 1 + } else if section == STPPaymentCardSection.stpPaymentCardScannerSection.rawValue { + return isScanning ? 1 : 0 + } else if section == STPPaymentCardSection.stpPaymentCardBillingAddressSection.rawValue { + return addressViewModel.addressCells.count + } + return 0 + } + + /// :nodoc: + @objc + public func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + var cell: UITableViewCell? + switch indexPath.section { + case STPPaymentCardSection.stpPaymentCardNumberSection.rawValue: + cell = paymentCell + case STPPaymentCardSection.stpPaymentCardScannerSection.rawValue: + if #available(macCatalyst 14.0, *) { + cell = scannerCell + } else { + return UITableViewCell() + } + case STPPaymentCardSection.stpPaymentCardBillingAddressSection.rawValue: + cell = addressViewModel.addressCells.stp_boundSafeObject(at: indexPath.row) + default: + return UITableViewCell() // won't be called; exists to make the static analyzer happy + } + cell?.backgroundColor = theme.secondaryBackgroundColor + cell?.contentView.backgroundColor = UIColor.clear + return cell! + } + + /// :nodoc: + @objc + public func tableView( + _ tableView: UITableView, + willDisplay cell: UITableViewCell, + forRowAt indexPath: IndexPath + ) { + let topRow = indexPath.row == 0 + let bottomRow = + self.tableView(tableView, numberOfRowsInSection: indexPath.section) - 1 == indexPath.row + cell.stp_setBorderColor(theme.tertiaryBackgroundColor) + cell.stp_setTopBorderHidden(!topRow) + cell.stp_setBottomBorderHidden(!bottomRow) + cell.stp_setFakeSeparatorColor(theme.quaternaryBackgroundColor) + cell.stp_setFakeSeparatorLeftInset(15.0) + } + + /// :nodoc: + @objc + public func tableView( + _ tableView: UITableView, + heightForFooterInSection section: Int + ) + -> CGFloat + { + if self.tableView(tableView, numberOfRowsInSection: section) == 0 { + return 0.01 + } + return 27.0 + } + + /// :nodoc: + @objc + public override func tableView( + _ tableView: UITableView, + heightForHeaderInSection section: Int + ) -> CGFloat { + let fittingSize = CGSize( + width: view.bounds.size.width, + height: CGFloat.greatestFiniteMagnitude + ) + let numberOfRows = self.tableView(tableView, numberOfRowsInSection: section) + if section == STPPaymentCardSection.stpPaymentCardNumberSection.rawValue { + return cardHeaderView?.sizeThatFits(fittingSize).height ?? 0.0 + } else if section == STPPaymentCardSection.stpPaymentCardBillingAddressSection.rawValue + && numberOfRows != 0 + { + return addressHeaderView?.sizeThatFits(fittingSize).height ?? 0.0 + } else if section == STPPaymentCardSection.stpPaymentCardScannerSection.rawValue { + return 0.01 + } else if numberOfRows != 0 { + return tableView.sectionHeaderHeight + } + return 0.01 + } + + /// :nodoc: + @objc + public func tableView( + _ tableView: UITableView, + viewForHeaderInSection section: Int + ) + -> UIView? + { + if self.tableView(tableView, numberOfRowsInSection: section) == 0 { + return UIView() + } else { + if section == STPPaymentCardSection.stpPaymentCardNumberSection.rawValue { + return cardHeaderView + } else if section == STPPaymentCardSection.stpPaymentCardBillingAddressSection.rawValue + { + return addressHeaderView + } + } + return nil + } + + /// :nodoc: + @objc + public func tableView( + _ tableView: UITableView, + viewForFooterInSection section: Int + ) + -> UIView? + { + return UIView() + } + + @objc func useShippingAddress(_ sender: UIButton) { + tableView?.beginUpdates() + addressViewModel.address = shippingAddress ?? STPAddress() + hasUsedShippingAddress = true + firstEmptyField()?.becomeFirstResponder() + UIView.animate( + withDuration: 0.2, + animations: { + self.addressHeaderView?.buttonHidden = true + } + ) + tableView?.endUpdates() + } + + // MARK: - STPCardScanner + /// :nodoc: + @objc + public override func viewWillTransition( + to size: CGSize, + with coordinator: UIViewControllerTransitionCoordinator + ) { + super.viewWillTransition(to: size, with: coordinator) + let orientation = UIDevice.current.orientation + if orientation.isPortrait || orientation.isLandscape { + if #available(macCatalyst 14.0, *) { + cardScanner?.deviceOrientation = orientation + } + } + if isScanning { + let indexPath = IndexPath( + row: 0, + section: STPPaymentCardSection.stpPaymentCardScannerSection.rawValue + ) + DispatchQueue.main.async(execute: { + self.tableView?.scrollToRow(at: indexPath, at: .middle, animated: true) + }) + } + } + + static let cardScannerKSTPCardScanAnimationTime: TimeInterval = 0.04 + + @available(macCatalyst 14.0, *) + func cardScanner( + _ scanner: STPCardScanner, + didFinishWith cardParams: STPPaymentMethodCardParams?, + error: Error? + ) { + if let error = error { + handleError(error) + } + if let cardParams = cardParams { + view.isUserInteractionEnabled = false + paymentCell?.paymentField?.inputView = UIView() as? UIInputView + var i = 0 + scannerCompleteAnimationTimer = Timer.scheduledTimer( + withTimeInterval: STPAddCardViewController.cardScannerKSTPCardScanAnimationTime, + repeats: true, + block: { timer in + i += 1 + let newParams = STPPaymentMethodCardParams() + guard let number = cardParams.number else { + timer.invalidate() + self.view.isUserInteractionEnabled = false + return + } + if i < number.count { + newParams.number = String( + number[...number.index(number.startIndex, offsetBy: i)] + ) + } else { + newParams.number = number + } + self.paymentCell?.paymentField?.paymentMethodParams = STPPaymentMethodParams( + card: newParams, + billingDetails: nil, + metadata: nil + ) + if i > number.count { + self.paymentCell?.paymentField?.paymentMethodParams = + STPPaymentMethodParams( + card: cardParams, + billingDetails: nil, + metadata: nil + ) + self.isScanning = false + self.paymentCell?.paymentField?.inputView = nil + // Force the inputView to reload by asking the text field to resign/become first responder: + _ = self.paymentCell?.paymentField?.resignFirstResponder() + _ = self.paymentCell?.paymentField?.becomeFirstResponder() + timer.invalidate() + self.view.isUserInteractionEnabled = true + } + } + ) + } else { + isScanning = false + } + } + +} + +/// An `STPAddCardViewControllerDelegate` is notified when an `STPAddCardViewController` +/// successfully creates a card token or is cancelled. It has internal error-handling +/// logic, so there's no error case to deal with. +@objc public protocol STPAddCardViewControllerDelegate: NSObjectProtocol { + /// Called when the user cancels adding a card. You should dismiss (or pop) the + /// view controller at this point. + /// - Parameter addCardViewController: the view controller that has been cancelled + func addCardViewControllerDidCancel(_ addCardViewController: STPAddCardViewController) + + /// This is called when the user successfully adds a card and Stripe returns a + /// Payment Method. + /// You should send the PaymentMethod to your backend to store it on a customer, and then + /// call the provided `completion` block when that call is finished. If an error + /// occurs while talking to your backend, call `completion(error)`, otherwise, + /// dismiss (or pop) the view controller. + /// - Parameters: + /// - addCardViewController: the view controller that successfully created a token + /// - paymentMethod: the Payment Method that was created. - seealso: STPPaymentMethod + /// - completion: call this callback when you're done sending the token to your backend + @objc func addCardViewController( + _ addCardViewController: STPAddCardViewController, + didCreatePaymentMethod paymentMethod: STPPaymentMethod, + completion: @escaping STPErrorBlock + ) + + // MARK: - Deprecated + + /// This method is deprecated as of v16.0.0 (https://github.com/stripe/stripe-ios/blob/master/MIGRATING.md#migrating-from-versions--1600). + /// To use this class, migrate your integration from Charges to PaymentIntents. See https://stripe.com/docs/payments/payment-intents/migration/charges#read + @available( + *, + deprecated, + message: + "Use addCardViewController(_:didCreatePaymentMethod:completion:) instead and migrate your integration to PaymentIntents. See https://stripe.com/docs/payments/payment-intents/migration/charges#read", + renamed: "addCardViewController(_:didCreatePaymentMethod:completion:)" + ) + @objc optional func addCardViewController( + _ addCardViewController: STPAddCardViewController, + didCreateToken token: STPToken, + completion: STPErrorBlock + ) + /// This method is deprecated as of v16.0.0 (https://github.com/stripe/stripe-ios/blob/master/MIGRATING.md#migrating-from-versions--1600). + /// To use this class, migrate your integration from Charges to PaymentIntents. See https://stripe.com/docs/payments/payment-intents/migration/charges#read + @available( + *, + deprecated, + message: + "Use addCardViewController(_:didCreatePaymentMethod:completion:) instead and migrate your integration to PaymentIntents. See https://stripe.com/docs/payments/payment-intents/migration/charges#read", + renamed: "addCardViewController(_:didCreatePaymentMethod:completion:)" + ) + @objc optional func addCardViewController( + _ addCardViewController: STPAddCardViewController, + didCreateSource source: STPSource, + completion: STPErrorBlock + ) +} + +private let STPPaymentCardCellReuseIdentifier = "STPPaymentCardCellReuseIdentifier" +enum STPPaymentCardSection: Int { + case stpPaymentCardNumberSection = 0 + case stpPaymentCardScannerSection = 1 + case stpPaymentCardBillingAddressSection = 2 +} + +/// :nodoc: +@_spi(STP) extension STPAddCardViewController: STPAnalyticsProtocol { + @_spi(STP) public static let stp_analyticsIdentifier = "STPAddCardViewController" +} diff --git a/Stripe/StripeiOS/Source/STPAddress+BasicUI.swift b/Stripe/StripeiOS/Source/STPAddress+BasicUI.swift new file mode 100644 index 00000000..d99df351 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPAddress+BasicUI.swift @@ -0,0 +1,212 @@ +// +// STPAddress+BasicUI.swift +// StripeiOS +// +// Created by David Estes on 6/30/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +import PassKit +@_spi(STP) import StripePayments +@_spi(STP) import StripePaymentsUI +@_spi(STP) import StripeUICore + +/// What set of billing address information you need to collect from your user. +/// +/// @note If the user is from a country that does not use zip/postal codes, +/// the user may not be asked for one regardless of this setting. +@objc +public enum STPBillingAddressFields: UInt { + /// No billing address information + case none + /// Just request the user's billing postal code + case postalCode + /// Request the user's full billing address + case full + /// Just request the user's billing name + case name + /// Just request the user's billing ZIP (synonym for STPBillingAddressFieldsZip) + @available(*, deprecated, message: "Use STPBillingAddressFields.postalCode instead") + case zip +} + +extension STPAddress { + + /// Checks if this STPAddress has the level of valid address information + /// required by the passed in setting. + /// - Parameter requiredFields: The required level of billing address information to + /// check against. + /// - Returns: YES if this address contains at least the necessary information, + /// NO otherwise. + @objc + public func containsRequiredFields(_ requiredFields: STPBillingAddressFields) -> Bool { + switch requiredFields { + case .none: + return true + case .postalCode: + return STPPostalCodeValidator.validationState( + forPostalCode: postalCode, + countryCode: country + ) == .valid + case .full: + return hasValidPostalAddress() + case .name: + return (name?.count ?? 0) > 0 + default: + fatalError() + } + } + + /// Checks if this STPAddress has any content (possibly invalid) in any of the + /// desired billing address fields. + /// Where `containsRequiredFields:` validates that this STPAddress contains valid data in + /// all of the required fields, this method checks for the existence of *any* data. + /// For example, if `desiredFields` is `STPBillingAddressFieldsZip`, this will check + /// if the postalCode is empty. + /// Note: When `desiredFields == STPBillingAddressFieldsNone`, this method always returns + /// NO. + /// @parameter desiredFields The billing address information the caller is interested in. + /// - Returns: YES if there is any data in this STPAddress that's relevant for those fields. + @objc(containsContentForBillingAddressFields:) + public func containsContent(for desiredFields: STPBillingAddressFields) -> Bool { + switch desiredFields { + case .none: + return false + case .postalCode: + return (postalCode?.count ?? 0) > 0 + case .full: + return hasPartialPostalAddress() + case .name: + return (name?.count ?? 0) > 0 + default: + fatalError() + } + } + + /// Checks if this STPAddress has the level of valid address information + /// required by the passed in setting. + /// Note: When `requiredFields == nil`, this method always returns + /// YES. + /// - Parameter requiredFields: The required shipping address information to check against. + /// - Returns: YES if this address contains at least the necessary information, + /// NO otherwise. + @objc + public func containsRequiredShippingAddressFields( + _ requiredFields: Set? + ) + -> Bool + { + guard let requiredFields = requiredFields else { + return true + } + var containsFields = true + + if requiredFields.contains(.name) { + containsFields = containsFields && (name?.count ?? 0) > 0 + } + if requiredFields.contains(.emailAddress) { + containsFields = + containsFields && STPEmailAddressValidator.stringIsValidEmailAddress(email) + } + if requiredFields.contains(.phoneNumber) { + containsFields = + containsFields + && STPPhoneNumberValidator.stringIsValidPhoneNumber( + phone ?? "", + forCountryCode: country + ) + } + if requiredFields.contains(.postalAddress) { + containsFields = containsFields && hasValidPostalAddress() + } + return containsFields + } + + /// Checks if this STPAddress has any content (possibly invalid) in any of the + /// desired shipping address fields. + /// Where `containsRequiredShippingAddressFields:` validates that this STPAddress + /// contains valid data in all of the required fields, this method checks for the + /// existence of *any* data. + /// Note: When `desiredFields == nil`, this method always returns + /// NO. + /// @parameter desiredFields The shipping address information the caller is interested in. + /// - Returns: YES if there is any data in this STPAddress that's relevant for those fields. + @objc + public func containsContent( + forShippingAddressFields desiredFields: Set? + ) + -> Bool + { + guard let desiredFields = desiredFields else { + return false + } + return (desiredFields.contains(.name) && (name?.count ?? 0) > 0) + || (desiredFields.contains(.emailAddress) && (email?.count ?? 0) > 0) + || (desiredFields.contains(.phoneNumber) && (phone?.count ?? 0) > 0) + || (desiredFields.contains(.postalAddress) && hasPartialPostalAddress()) + } + + /// Converts an STPBillingAddressFields enum value into the closest equivalent + /// representation of PKContactField options + /// - Parameter billingAddressFields: Stripe billing address fields enum value to convert. + /// - Returns: The closest representation of the billing address requirement as + /// a PKContactField value. + @objc(applePayContactFieldsFromBillingAddressFields:) + public class func applePayContactFields( + from billingAddressFields: STPBillingAddressFields + ) + -> Set + { + switch billingAddressFields { + case .none: + return Set([]) + case .postalCode, .full: + return Set([.name, .postalAddress]) + case .name: + return Set([.name]) + case .zip: + return Set() + @unknown default: + fatalError() + } + } + + /// Converts a set of STPContactField values into the closest equivalent + /// representation of PKContactField options + /// - Parameter contactFields: Stripe contact fields values to convert. + /// - Returns: The closest representation of the contact fields as + /// a PKContactField value. + @objc + public class func pkContactFields( + fromStripeContactFields contactFields: Set? + ) -> Set? { + guard let contactFields = contactFields else { + return nil + } + + var pkFields: Set = Set() + let stripeToPayKitContactMap: [STPContactField: PKContactField] = [ + STPContactField.postalAddress: PKContactField.postalAddress, + STPContactField.emailAddress: PKContactField.emailAddress, + STPContactField.phoneNumber: PKContactField.phoneNumber, + STPContactField.name: PKContactField.name, + ] + + for contactField in contactFields { + if let convertedField = stripeToPayKitContactMap[contactField] { + pkFields.insert(convertedField) + } + } + return pkFields + } + + private func hasValidPostalAddress() -> Bool { + return (line1?.count ?? 0) > 0 && (city?.count ?? 0) > 0 && (country?.count ?? 0) > 0 + && ((state?.count ?? 0) > 0 || !(country == "US")) + && (STPPostalCodeValidator.validationState( + forPostalCode: postalCode, + countryCode: country + ) == .valid) + } +} diff --git a/Stripe/StripeiOS/Source/STPAddressFieldTableViewCell.swift b/Stripe/StripeiOS/Source/STPAddressFieldTableViewCell.swift new file mode 100644 index 00000000..7ae4b4e7 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPAddressFieldTableViewCell.swift @@ -0,0 +1,509 @@ +// +// STPAddressFieldTableViewCell.swift +// StripeiOS +// +// Created by Ben Guo on 4/13/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripePaymentsUI +@_spi(STP) import StripeUICore +import UIKit + +enum STPAddressFieldType: Int { + case name + case line1 + case line2 + case city + case state + case zip + case country + case email + case phone +} + +protocol STPAddressFieldTableViewCellDelegate: AnyObject { + func addressFieldTableViewCellDidUpdateText(_ cell: STPAddressFieldTableViewCell) + + func addressFieldTableViewCellDidReturn(_ cell: STPAddressFieldTableViewCell) + func addressFieldTableViewCellDidEndEditing(_ cell: STPAddressFieldTableViewCell) + var addressFieldTableViewCountryCode: String? { get set } + var availableCountries: Set? { get set } +} + +class STPAddressFieldTableViewCell: UITableViewCell, UITextFieldDelegate, UIPickerViewDelegate, + UIPickerViewDataSource +{ + + init( + type: STPAddressFieldType, + contents: String?, + lastInList: Bool, + delegate: STPAddressFieldTableViewCellDelegate? + ) { + textField = { + if type == .phone { + // We have very specific US-based phone formatting that's built into STPFormTextField + let formTextField = STPFormTextField() + formTextField.preservesContentsOnPaste = false + formTextField.selectionEnabled = false + return formTextField + } else { + return STPValidatedTextField() + } + }() + + super.init(style: .default, reuseIdentifier: nil) + self.delegate = delegate + theme = STPTheme() + _contents = contents + + textField.delegate = self + textField.addTarget( + self, + action: #selector(STPAddressFieldTableViewCell.textFieldTextDidChange(textField:)), + for: .editingChanged + ) + contentView.addSubview(textField) + + let toolbar = UIToolbar() + let flexibleItem = UIBarButtonItem( + barButtonSystemItem: .flexibleSpace, + target: nil, + action: nil + ) + let nextItem = UIBarButtonItem( + title: STPLocalizedString("Next", nil), + style: .done, + target: self, + action: #selector(nextTapped(sender:)) + ) + toolbar.items = [flexibleItem, nextItem] + inputAccessoryToolbar = toolbar + + var countryCode = NSLocale.autoupdatingCurrent.regionCode + var otherCountryCodes = Array( + self.delegate?.availableCountries ?? Set(NSLocale.isoCountryCodes) + ) + if otherCountryCodes.contains(countryCode ?? "") { + // Remove the current country code to re-add it once we sort the list. + otherCountryCodes.removeAll { $0 == countryCode } + } else { + // If it isn't in the list (if we've been configured to not show that country), don't re-add it. + countryCode = nil + } + let locale = NSLocale.current as NSLocale + otherCountryCodes = + (otherCountryCodes as NSArray).sortedArray(comparator: { code1, code2 in + guard let code1 = code1 as? String, let code2 = code2 as? String else { + return .orderedDescending + } + let localeID1 = NSLocale.localeIdentifier(fromComponents: [ + NSLocale.Key.countryCode.rawValue: code1 + ]) + let localeID2 = NSLocale.localeIdentifier(fromComponents: [ + NSLocale.Key.countryCode.rawValue: code2 + ]) + if let name1 = locale.displayName(forKey: .identifier, value: localeID1), + let name2 = locale.displayName(forKey: .identifier, value: localeID2) + { + return name1.compare(name2) + } else { + return .orderedDescending + } + }) as? [String] ?? [] + if let countryCode = countryCode { + countryCodes = ["", countryCode] + otherCountryCodes + } else { + countryCodes = [""] + otherCountryCodes + } + let pickerView = UIPickerView() + pickerView.dataSource = self + pickerView.delegate = self + countryPickerView = pickerView + + self.lastInList = lastInList + self.type = type + self.textField.text = contents + + var ourCountryCode = self.delegate?.addressFieldTableViewCountryCode + + if ourCountryCode == nil { + ourCountryCode = countryCode + } + delegateCountryCodeDidChange(countryCode: ourCountryCode ?? "") + updateAppearance() + + self.textField.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate( + [ + textField.leadingAnchor.constraint( + equalTo: contentView.safeAreaLayoutGuide.leadingAnchor, + constant: 15 + ), + textField.trailingAnchor.constraint( + equalTo: contentView.safeAreaLayoutGuide.trailingAnchor, + constant: -15 + ), + textField.topAnchor.constraint( + equalTo: contentView.safeAreaLayoutGuide.topAnchor, + constant: 1 + ), + contentView.safeAreaLayoutGuide.bottomAnchor.constraint( + greaterThanOrEqualTo: textField.bottomAnchor + ), + textField.heightAnchor.constraint(greaterThanOrEqualToConstant: 43), + inputAccessoryToolbar?.heightAnchor.constraint(equalToConstant: 44), + ].compactMap { $0 } + ) + } + + var type: STPAddressFieldType = .name + var caption: String? { + get { + self.textField.placeholder + } + set { + self.textField.placeholder = newValue + } + } + + private(set) var textField: STPValidatedTextField + private var _contents: String? + var contents: String? { + get { + // iOS 11 QuickType completions from textContentType have a space at the end. + // This *keeps* that space in the `textField`, but removes leading/trailing spaces from + // the logical contents of this field, so they're ignored for validation and persisting + return _contents?.trimmingCharacters(in: CharacterSet.whitespaces) + } + set { + _contents = newValue + if self.type == .country { + self.updateTextFieldsAndCaptions() + } else { + self.textField.text = contents + } + if self.textField.isFirstResponder { + self.textField.validText = self.potentiallyValidContents + } else { + self.textField.validText = self.validContents + } + self.delegate?.addressFieldTableViewCellDidUpdateText(self) + } + } + + var theme: STPTheme = .defaultTheme { + didSet { + updateAppearance() + } + } + + var lastInList: Bool = false { + didSet { + updateTextFieldsAndCaptions() + } + } + + private var inputAccessoryToolbar: UIToolbar? + private var countryPickerView: UIPickerView? + private var countryCodes: [AnyHashable]? + private weak var delegate: STPAddressFieldTableViewCellDelegate? + private var ourCountryCode: String? + + func updateTextFieldsAndCaptions() { + textField.placeholder = placeholder(for: type) + + if !lastInList { + textField.returnKeyType = .next + } else { + textField.returnKeyType = .default + } + switch type { + case .name: + textField.keyboardType = .default + textField.textContentType = .name + case .line1: + textField.keyboardType = .numbersAndPunctuation + textField.textContentType = .streetAddressLine1 + case .line2: + textField.keyboardType = .numbersAndPunctuation + textField.textContentType = .streetAddressLine2 + case .city: + textField.keyboardType = .default + textField.textContentType = .addressCity + case .state: + textField.keyboardType = .default + textField.textContentType = .addressState + case .zip: + textField.keyboardType = .numbersAndPunctuation + textField.textContentType = .postalCode + case .country: + textField.keyboardType = .default + // Don't set textContentType for Country, because we don't want iOS to skip the UIPickerView for input + textField.inputView = countryPickerView + // If we're being set directly to a country we don't allow, add it to the allowed list + let countryCodes = self.countryCodes ?? [] + if let contents = contents, + !countryCodes.contains(contents) && NSLocale.isoCountryCodes.contains(contents) + { + self.countryCodes = countryCodes + [contents] + } + let index = countryCodes.firstIndex(of: contents ?? "") ?? NSNotFound + if index == NSNotFound { + textField.text = "" + } else { + countryPickerView?.selectRow(index, inComponent: 0, animated: false) + if let countryPickerView = countryPickerView { + textField.text = pickerView( + countryPickerView, + titleForRow: index, + forComponent: 0 + ) + } + } + textField.validText = validContents + case .phone: + self.textField.keyboardType = .numbersAndPunctuation + self.textField.textContentType = .telephoneNumber + let behavior: STPFormTextFieldAutoFormattingBehavior = + (self.countryCodeIsUnitedStates ? .phoneNumbers : .none) + (self.textField as? STPFormTextField)?.autoFormattingBehavior = behavior + case .email: + self.textField.keyboardType = .emailAddress + self.textField.textContentType = .emailAddress + } + + if !self.lastInList { + self.textField.inputAccessoryView = self.inputAccessoryToolbar + } else { + self.textField.inputAccessoryView = nil + } + self.textField.accessibilityLabel = self.textField.placeholder + self.textField.accessibilityIdentifier = self.accessibilityIdentifierForAddressField( + type: self.type + ) + } + + func accessibilityIdentifierForAddressField(type: STPAddressFieldType) -> String { + switch type { + case .name: + return "ShippingAddressFieldTypeNameIdentifier" + case .line1: + return "ShippingAddressFieldTypeLine1Identifier" + case .line2: + return "ShippingAddressFieldTypeLine2Identifier" + case .city: + return "ShippingAddressFieldTypeCityIdentifier" + case .state: + return "ShippingAddressFieldTypeStateIdentifier" + case .zip: + return "ShippingAddressFieldTypeZipIdentifier" + case .country: + return "ShippingAddressFieldTypeCountryIdentifier" + case .email: + return "ShippingAddressFieldTypeEmailIdentifier" + case .phone: + return "ShippingAddressFieldTypePhoneIdentifier" + } + } + + func stateFieldCaption(forCountryCode countryCode: String?) -> String { + return StripeSharedStrings.localizedStateString(for: countryCode) + } + + func placeholder(for addressFieldType: STPAddressFieldType) -> String { + switch addressFieldType { + case .name: + return String.Localized.name + case .line1: + return String.Localized.address + case .line2: + return STPLocalizedString( + "Apt.", + "Caption for Apartment/Address line 2 field on address form" + ) + case .city: + return String.Localized.city + case .state: + return stateFieldCaption(forCountryCode: self.ourCountryCode) + case .zip: + return StripeSharedStrings.localizedPostalCodeString(for: self.ourCountryCode) + case .country: + return String.Localized.country + case .email: + return String.Localized.email + case .phone: + return String.Localized.phone + } + } + + func delegateCountryCodeDidChange(countryCode: String) { + if self.type == .country { + self.contents = countryCode + } + + self.ourCountryCode = countryCode + self.updateTextFieldsAndCaptions() + self.setNeedsLayout() + } + + func updateAppearance() { + self.backgroundColor = self.theme.secondaryBackgroundColor + self.contentView.backgroundColor = .clear + self.textField.placeholderColor = theme.tertiaryForegroundColor + self.textField.defaultColor = theme.primaryForegroundColor + self.textField.errorColor = self.theme.errorColor + self.textField.font = self.theme.font + self.setNeedsLayout() + } + + var countryCodeIsUnitedStates: Bool { + self.ourCountryCode == "US" + } + + public override func becomeFirstResponder() -> Bool { + return self.textField.becomeFirstResponder() + } + + @objc func nextTapped(sender: NSObject) { + delegate?.addressFieldTableViewCellDidReturn(self) + } + + @objc + public func textFieldShouldReturn(_ textField: UITextField) -> Bool { + self.delegate?.addressFieldTableViewCellDidReturn(self) + return false + } + + @objc + public func textFieldDidEndEditing(_ textField: UITextField) { + (textField as? STPFormTextField)?.validText = validContents + self.delegate?.addressFieldTableViewCellDidEndEditing(self) + } + + public override func accessibilityElementCount() -> Int { + return 1 + } + + public override func accessibilityElement(at index: Int) -> Any? { + return textField + } + + public override func index(ofAccessibilityElement element: Any) -> Int { + return 0 + } + + @objc func textFieldTextDidChange(textField: STPValidatedTextField) { + if self.type != .country { + _contents = textField.text + if textField.isFirstResponder { + textField.validText = self.potentiallyValidContents + } else { + textField.validText = self.validContents + } + } + self.delegate?.addressFieldTableViewCellDidUpdateText(self) + } + + // pragma mark - UITextFieldDelegate + + var validContents: Bool { + switch self.type { + case .name, .line1, .city, .state, .country: + return self.contents?.count ?? 0 > 0 + case .line2: + return true + case .zip: + return STPPostalCodeValidator.validationState( + forPostalCode: self.contents, + countryCode: self.ourCountryCode + ) == .valid + case .email: + return STPEmailAddressValidator.stringIsValidEmailAddress(self.contents) + case .phone: + return STPPhoneNumberValidator.stringIsValidPhoneNumber( + self.contents ?? "", + forCountryCode: self.ourCountryCode + ) + } + } + + var potentiallyValidContents: Bool { + switch self.type { + case .name, .line1, .city, .state, .country, .line2, .phone: + return true + case .zip: + let validationState = STPPostalCodeValidator.validationState( + forPostalCode: self.contents, + countryCode: self.ourCountryCode + ) + return validationState == .valid || validationState == .incomplete + case .email: + return STPEmailAddressValidator.stringIsValidPartialEmailAddress(self.contents) + } + } + + public func pickerView( + _ pickerView: UIPickerView, + didSelectRow row: Int, + inComponent component: Int + ) { + guard let countryCode = self.countryCodes?[row] as? String + else { + return + } + self.ourCountryCode = countryCode + self.contents = self.ourCountryCode + textField.text = self.pickerView(pickerView, titleForRow: row, forComponent: component) + // UIControlEvent not fired for programmatic changes + self.textFieldTextDidChange(textField: textField) + self.delegate?.addressFieldTableViewCountryCode = self.ourCountryCode ?? "" + + } + + public func pickerView( + _ pickerView: UIPickerView, + titleForRow row: Int, + forComponent component: Int + ) -> String? { + guard let countryCode = self.countryCodes?[row] as? String else { + return nil + } + let identifier = Locale.identifier(fromComponents: [ + NSLocale.Key.countryCode.rawValue: countryCode + ]) + return (NSLocale.autoupdatingCurrent as NSLocale).displayName( + forKey: NSLocale.Key(rawValue: NSLocale.Key.identifier.rawValue), + value: identifier + ) + } + + public func numberOfComponents(in pickerView: UIPickerView) -> Int { + return 1 + } + + public func pickerView( + _ pickerView: UIPickerView, + numberOfRowsInComponent component: Int + ) + -> Int + { + self.countryCodes?.count ?? 0 + } + + required convenience init?( + coder aDecoder: NSCoder + ) { + assertionFailure("Use initWithType: instead.") + self.init( + type: .name, + contents: nil, + lastInList: false, + delegate: nil + ) + } + +} diff --git a/Stripe/StripeiOS/Source/STPAddressViewModel.swift b/Stripe/StripeiOS/Source/STPAddressViewModel.swift new file mode 100644 index 00000000..aa3fa68b --- /dev/null +++ b/Stripe/StripeiOS/Source/STPAddressViewModel.swift @@ -0,0 +1,434 @@ +// +// STPAddressViewModel.swift +// StripeiOS +// +// Created by Jack Flintermann on 4/21/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Contacts +import CoreLocation +@_spi(STP) import StripeCore +@_spi(STP) import StripePaymentsUI +import UIKit + +protocol STPAddressViewModelDelegate: AnyObject { + func addressViewModelDidChange(_ addressViewModel: STPAddressViewModel) + func addressViewModel(_ addressViewModel: STPAddressViewModel, addedCellAt index: Int) + func addressViewModel(_ addressViewModel: STPAddressViewModel, removedCellAt index: Int) + func addressViewModelWillUpdate(_ addressViewModel: STPAddressViewModel) + func addressViewModelDidUpdate(_ addressViewModel: STPAddressViewModel) +} + +class STPAddressViewModel: STPAddressFieldTableViewCellDelegate { + private(set) var addressCells: [STPAddressFieldTableViewCell] = [] + weak var delegate: STPAddressViewModelDelegate? + + var addressFieldTableViewCountryCode: String? = Locale.autoupdatingCurrent.regionCode { + didSet { + updatePostalCodeCellIfNecessary() + if let addressFieldTableViewCountryCode = addressFieldTableViewCountryCode { + for cell in addressCells { + cell.delegateCountryCodeDidChange(countryCode: addressFieldTableViewCountryCode) + } + } + } + } + + var address: STPAddress { + get { + let address = STPAddress() + for cell in addressCells { + + switch cell.type { + case .name: + address.name = cell.contents + case .line1: + address.line1 = cell.contents + case .line2: + address.line2 = cell.contents + case .city: + address.city = cell.contents + case .state: + address.state = cell.contents + case .zip: + address.postalCode = cell.contents + case .country: + address.country = cell.contents + case .email: + address.email = cell.contents + case .phone: + address.phone = cell.contents + } + } + // Prefer to use the contents of STPAddressFieldTypeCountry, but fallback to + // `addressFieldTableViewCountryCode` if nil (important for STPBillingAddressFieldsPostalCode) + address.country = address.country ?? addressFieldTableViewCountryCode + return address + } + set(address) { + if let country = address.country { + addressFieldTableViewCountryCode = country + } + + for cell in addressCells { + switch cell.type { + case .name: + cell.contents = address.name + case .line1: + cell.contents = address.line1 + case .line2: + cell.contents = address.line2 + case .city: + cell.contents = address.city + case .state: + cell.contents = address.state + case .zip: + cell.contents = address.postalCode + case .country: + cell.contents = address.country + case .email: + cell.contents = address.email + case .phone: + cell.contents = address.phone + } + } + } + } + + // The default value of availableCountries is nil, which will allow all known countries. + var availableCountries: Set? + + var isValid: Bool { + if isBillingAddress { + // The AddressViewModel is only for address fields. + // Determining whether the postal code is present is up to the + // STPCardTextFieldViewModel. + if requiredBillingAddressFields == .postalCode { + return true + } else { + return address.containsRequiredFields(requiredBillingAddressFields) + } + + } else { + if let requiredShippingAddressFields = requiredShippingAddressFields { + return address.containsRequiredShippingAddressFields(requiredShippingAddressFields) + } + return false + } + } + + // The default value of availableCountries is nil, which will allow all known countries. + init( + requiredBillingFields requiredBillingAddressFields: STPBillingAddressFields, + availableCountries: Set? = nil + ) { + isBillingAddress = true + self.availableCountries = availableCountries + self.requiredBillingAddressFields = requiredBillingAddressFields + switch requiredBillingAddressFields { + case .none: + addressCells = [] + case .zip, .postalCode: + addressCells = [] // Postal code cell will be added later if necessary + case .full: + addressCells = [ + STPAddressFieldTableViewCell( + type: .name, + contents: "", + lastInList: false, + delegate: self + ), + STPAddressFieldTableViewCell( + type: .line1, + contents: "", + lastInList: false, + delegate: self + ), + STPAddressFieldTableViewCell( + type: .line2, + contents: "", + lastInList: false, + delegate: self + ), + STPAddressFieldTableViewCell( + type: .country, + contents: addressFieldTableViewCountryCode, + lastInList: false, + delegate: self + ), + // Postal code cell will be added here later if necessary + STPAddressFieldTableViewCell( + type: .city, + contents: "", + lastInList: false, + delegate: self + ), + STPAddressFieldTableViewCell( + type: .state, + contents: "", + lastInList: true, + delegate: self + ), + ] + case .name: + addressCells = [ + STPAddressFieldTableViewCell( + type: .name, + contents: "", + lastInList: true, + delegate: self + ), + ] + default: + fatalError() + } + commonInit() + } + + init( + requiredShippingFields requiredShippingAddressFields: Set, + availableCountries: Set? = nil + ) { + isBillingAddress = false + self.availableCountries = availableCountries + self.requiredShippingAddressFields = requiredShippingAddressFields + var cells: [STPAddressFieldTableViewCell] = [] + + if requiredShippingAddressFields.contains(STPContactField.name) { + cells.append( + STPAddressFieldTableViewCell( + type: .name, + contents: "", + lastInList: false, + delegate: self + ) + ) + } + if requiredShippingAddressFields.contains(.emailAddress) { + cells.append( + STPAddressFieldTableViewCell( + type: .email, + contents: "", + lastInList: false, + delegate: self + ) + ) + } + if requiredShippingAddressFields.contains(STPContactField.postalAddress) { + var postalCells = [ + STPAddressFieldTableViewCell( + type: .name, + contents: "", + lastInList: false, + delegate: self + ), + STPAddressFieldTableViewCell( + type: .line1, + contents: "", + lastInList: false, + delegate: self + ), + STPAddressFieldTableViewCell( + type: .line2, + contents: "", + lastInList: false, + delegate: self + ), + STPAddressFieldTableViewCell( + type: .country, + contents: addressFieldTableViewCountryCode, + lastInList: false, + delegate: self + ), + // Postal code cell will be added here later if necessary + STPAddressFieldTableViewCell( + type: .city, + contents: "", + lastInList: false, + delegate: self + ), + STPAddressFieldTableViewCell( + type: .state, + contents: "", + lastInList: false, + delegate: self + ), + ] + if requiredShippingAddressFields.contains(.name) { + postalCells.remove(at: 0) + } + cells.append(contentsOf: postalCells.compactMap { $0 }) + } + if requiredShippingAddressFields.contains(.phoneNumber) { + cells.append( + STPAddressFieldTableViewCell( + type: .phone, + contents: "", + lastInList: false, + delegate: self + ) + ) + } + if let lastCell = cells.last { + lastCell.lastInList = true + } + addressCells = cells + commonInit() + } + + private func cell(at index: Int) -> STPAddressFieldTableViewCell? { + guard index > 0, + index < addressCells.count + else { + return nil + } + return addressCells[index] + } + + private var isBillingAddress = false + private var requiredBillingAddressFields: STPBillingAddressFields = .none + private var requiredShippingAddressFields: Set? + private var showingPostalCodeCell = false + private var geocodeInProgress = false + + private func commonInit() { + if let countryCode = Locale.autoupdatingCurrent.regionCode { + addressFieldTableViewCountryCode = countryCode + } + updatePostalCodeCellIfNecessary() + } + + private func updatePostalCodeCellIfNecessary() { + delegate?.addressViewModelWillUpdate(self) + let shouldBeShowingPostalCode = STPPostalCodeValidator.postalCodeIsRequired( + forCountryCode: addressFieldTableViewCountryCode + ) + + if shouldBeShowingPostalCode && !showingPostalCodeCell { + if containsStateAndPostalFields() { + // Add before city + let zipFieldIndex = addressCells.firstIndex(where: { $0.type == .city }) ?? 0 + + var mutableAddressCells = addressCells + mutableAddressCells.insert( + STPAddressFieldTableViewCell( + type: .zip, + contents: "", + lastInList: false, + delegate: self + ), + at: zipFieldIndex + ) + addressCells = mutableAddressCells + delegate?.addressViewModel(self, addedCellAt: zipFieldIndex) + delegate?.addressViewModelDidChange(self) + } + } else if !shouldBeShowingPostalCode && showingPostalCodeCell { + if containsStateAndPostalFields() { + if let zipFieldIndex = addressCells.firstIndex(where: { $0.type == .zip }) { + + var mutableAddressCells = addressCells + mutableAddressCells.remove(at: zipFieldIndex) + addressCells = mutableAddressCells + delegate?.addressViewModel(self, removedCellAt: zipFieldIndex) + delegate?.addressViewModelDidChange(self) + } + } + } + showingPostalCodeCell = shouldBeShowingPostalCode + delegate?.addressViewModelDidUpdate(self) + } + + private func containsStateAndPostalFields() -> Bool { + if isBillingAddress { + return requiredBillingAddressFields == .full + } else { + return requiredShippingAddressFields?.contains(.postalAddress) ?? false + } + } + + func updateCityAndState(fromZipCodeCell zipCell: STPAddressFieldTableViewCell?) { + + let zipCode = zipCell?.contents + + if geocodeInProgress || zipCode == nil || !(zipCell?.textField.validText ?? false) + || !(addressFieldTableViewCountryCode == "US") + { + return + } + + var cityCell: STPAddressFieldTableViewCell? + var stateCell: STPAddressFieldTableViewCell? + for cell in addressCells { + if cell.type == .city { + cityCell = cell + } else if cell.type == .state { + stateCell = cell + } + } + + if (cityCell == nil && stateCell == nil) + || ((cityCell?.contents?.count ?? 0) > 0 || (stateCell?.contents?.count ?? 0) > 0) + { + // Don't auto fill if either have text already + // Or if neither are non-nil + return + } else { + geocodeInProgress = true + let geocoder = CLGeocoder() + + let onCompletion: CLGeocodeCompletionHandler = { placemarks, error in + stpDispatchToMainThreadIfNecessary({ + if (placemarks?.count ?? 0) > 0 && error == nil { + let placemark = placemarks?.first + if (cityCell?.contents?.count ?? 0) == 0 + && (stateCell?.contents?.count ?? 0) == 0 + && (zipCell?.contents == zipCode) + { + // Check contents again to make sure they're still empty + // And that zipcode hasn't changed to something else + cityCell?.contents = placemark?.locality + stateCell?.contents = placemark?.administrativeArea + } + } + self.geocodeInProgress = false + }) + } + + let address = CNMutablePostalAddress() + address.postalCode = zipCode ?? "" + address.isoCountryCode = addressFieldTableViewCountryCode ?? "" + + geocoder.geocodePostalAddress( + address, + completionHandler: onCompletion + ) + } + } + + private func cell(after cell: STPAddressFieldTableViewCell?) -> STPAddressFieldTableViewCell? { + guard let cell = cell, + let cellIndex = addressCells.firstIndex(of: cell), + cellIndex + 1 < addressCells.count + else { + return nil + } + return addressCells[cellIndex + 1] + } + + func addressFieldTableViewCellDidUpdateText(_ cell: STPAddressFieldTableViewCell) { + delegate?.addressViewModelDidChange(self) + } + + func addressFieldTableViewCellDidReturn(_ cell: STPAddressFieldTableViewCell) { + _ = self.cell(after: cell)?.becomeFirstResponder() + } + + func addressFieldTableViewCellDidEndEditing(_ cell: STPAddressFieldTableViewCell) { + if cell.type == .zip { + updateCityAndState(fromZipCodeCell: cell) + } + } + +} diff --git a/Stripe/StripeiOS/Source/STPAnalyticsClient+BasicUI.swift b/Stripe/StripeiOS/Source/STPAnalyticsClient+BasicUI.swift new file mode 100644 index 00000000..a0f9aaa1 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPAnalyticsClient+BasicUI.swift @@ -0,0 +1,84 @@ +// +// STPAnalyticsClient+BasicUI.swift +// StripeiOS +// +// Created by David Estes on 6/30/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments + +@objc(STPBasicUIAnalyticsSerializer) +class STPBasicUIAnalyticsSerializer: NSObject, STPAnalyticsSerializer { + static func serializeConfiguration( + _ configuration: NSObject + ) -> [String: + String] + { + var dictionary: [String: String] = [:] + dictionary["publishable_key"] = STPAPIClient.shared.publishableKey ?? "unknown" + + guard let configuration = configuration as? STPPaymentConfiguration else { + return dictionary + } + + if configuration.applePayEnabled && !configuration.fpxEnabled { + dictionary["additional_payment_methods"] = "default" + } else if !configuration.applePayEnabled && !configuration.fpxEnabled { + dictionary["additional_payment_methods"] = "none" + } else if !configuration.applePayEnabled && configuration.fpxEnabled { + dictionary["additional_payment_methods"] = "fpx" + } else if configuration.applePayEnabled && configuration.fpxEnabled { + dictionary["additional_payment_methods"] = "applepay,fpx" + } + + switch configuration.requiredBillingAddressFields { + case .none: + dictionary["required_billing_address_fields"] = "none" + case .postalCode: + dictionary["required_billing_address_fields"] = "zip" + case .full: + dictionary["required_billing_address_fields"] = "full" + case .name: + dictionary["required_billing_address_fields"] = "name" + default: + fatalError() + } + + var shippingFields: [String] = [] + if let shippingAddressFields = configuration.requiredShippingAddressFields { + if shippingAddressFields.contains(.name) { + shippingFields.append("name") + } + if shippingAddressFields.contains(.emailAddress) { + shippingFields.append("email") + } + if shippingAddressFields.contains(.postalAddress) { + shippingFields.append("address") + } + if shippingAddressFields.contains(.phoneNumber) { + shippingFields.append("phone") + } + } + + if shippingFields.isEmpty { + shippingFields.append("none") + } + dictionary["required_shipping_address_fields"] = shippingFields.joined(separator: "_") + + switch configuration.shippingType { + case .shipping: + dictionary["shipping_type"] = "shipping" + case .delivery: + dictionary["shipping_type"] = "delivery" + @unknown default: + break + } + + dictionary["company_name"] = configuration.companyName + dictionary["apple_merchant_identifier"] = configuration.appleMerchantIdentifier ?? "unknown" + return dictionary + } +} diff --git a/Stripe/StripeiOS/Source/STPAnalyticsClient+Payments.swift b/Stripe/StripeiOS/Source/STPAnalyticsClient+Payments.swift new file mode 100644 index 00000000..ce381e66 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPAnalyticsClient+Payments.swift @@ -0,0 +1,28 @@ +// +// STPAnalyticsClient+Payments.swift +// StripeiOS +// +// Created by David Estes on 1/24/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +/// An analytic specific to payments that serializes payment-specific +/// information into its params. +@_spi(STP) public protocol PaymentAnalytic: Analytic { + var productUsage: Set { get } + var additionalParams: [String: Any] { get } +} + +@_spi(STP) extension PaymentAnalytic { + public var params: [String: Any] { + var params = additionalParams + + params["apple_pay_enabled"] = NSNumber(value: StripeAPI.deviceSupportsApplePay()) + params["ocr_type"] = PaymentsSDKVariant.ocrTypeString + params["pay_var"] = PaymentsSDKVariant.variant + return params + } +} diff --git a/Stripe/StripeiOS/Source/STPApplePayContextDelegate.swift b/Stripe/StripeiOS/Source/STPApplePayContextDelegate.swift new file mode 100644 index 00000000..0814a5de --- /dev/null +++ b/Stripe/StripeiOS/Source/STPApplePayContextDelegate.swift @@ -0,0 +1,98 @@ +// +// STPApplePayContextDelegate.swift +// StripeiOS +// +// Created by David Estes on 9/15/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +import PassKit +@_spi(STP) import StripeApplePay +@_spi(STP) import StripeCore + +/// Implement the required methods of this delegate to supply a PaymentIntent to STPApplePayContext and be notified of the completion of the Apple Pay payment. +/// You may also implement the optional delegate methods to handle shipping methods and shipping address changes e.g. to verify you can ship to the address, or update the payment amount. +@objc public protocol STPApplePayContextDelegate: _stpinternal_STPApplePayContextDelegateBase { + /// Called after the customer has authorized Apple Pay. Implement this method to call the completion block with the client secret of a PaymentIntent or SetupIntent. + /// - Parameters: + /// - paymentMethod: The PaymentMethod that represents the customer's Apple Pay payment method. + /// If you create the PaymentIntent with confirmation_method=manual, pass `paymentMethod.stripeId` as the payment_method and confirm=true. Otherwise, you can ignore this parameter. + /// - paymentInformation: The underlying PKPayment created by Apple Pay. + /// If you create the PaymentIntent with confirmation_method=manual, you can collect shipping information using its `shippingContact` and `shippingMethod` properties. + /// - completion: Call this with the PaymentIntent or SetupIntent client secret, or the error that occurred creating the PaymentIntent or SetupIntent. + @objc(applePayContext:didCreatePaymentMethod:paymentInformation:completion:) + func applePayContext( + _ context: STPApplePayContext, + didCreatePaymentMethod paymentMethod: STPPaymentMethod, + paymentInformation: PKPayment, + completion: @escaping STPIntentClientSecretCompletionBlock + ) + + /// Called after the Apple Pay sheet is dismissed with the result of the payment. + /// Your implementation could stop a spinner and display a receipt view or error to the customer, for example. + /// - Parameters: + /// - status: The status of the payment + /// - error: The error that occurred, if any. + @objc(applePayContext:didCompleteWithStatus:error:) + func applePayContext( + _ context: STPApplePayContext, + didCompleteWith status: STPPaymentStatus, + error: Error? + ) +} + +/// A helper class used to bridge StripeApplePay.framework with the legacy Stripe.framework objects. +@objc(STPApplePayContextLegacyHelper) +class STPApplePayContextLegacyHelper: NSObject { + @objc class func performDidCreatePaymentMethod( + _ storage: _stpinternal_ApplePayContextDidCreatePaymentMethodStorage + ) { + let delegate = storage.delegate as! STPApplePayContextDelegate + // Convert the PaymentMethod to an STPPaymentMethod: + guard + let stpPaymentMethod = STPPaymentMethod.decodedObject( + fromAPIResponse: storage.paymentMethod.allResponseFields + ) + else { + assertionFailure("Failed to convert PaymentMethod to STPPaymentMethod") + return + } + delegate.applePayContext( + storage.context, + didCreatePaymentMethod: stpPaymentMethod, + paymentInformation: storage.paymentInformation, + completion: storage.completion + ) + } + + @objc class func performDidComplete(_ storage: _stpinternal_ApplePayContextDidCompleteStorage) { + let delegate = storage.delegate as! STPApplePayContextDelegate + let stpStatus = STPPaymentStatus(applePayStatus: storage.status) + + // If this is a modern API error, convert it down to a legacy STPError. + // This is to avoid changing the API experience for users. + // We can re-evaluate this as we release more of the modern API. + if let modernError = storage.error as? StripeError { + storage.error = NSError.stp_error(from: modernError) + } + + delegate.applePayContext(storage.context, didCompleteWith: stpStatus, error: storage.error) + } + +} + +extension STPPaymentStatus { + init( + applePayStatus: STPApplePayContext.PaymentStatus + ) { + switch applePayStatus { + case .success: + self = .success + case .error: + self = .error + case .userCancellation: + self = .userCancellation + } + } +} diff --git a/Stripe/StripeiOS/Source/STPApplePayPaymentOption.swift b/Stripe/StripeiOS/Source/STPApplePayPaymentOption.swift new file mode 100644 index 00000000..14edba1f --- /dev/null +++ b/Stripe/StripeiOS/Source/STPApplePayPaymentOption.swift @@ -0,0 +1,51 @@ +// +// STPApplePayPaymentOption.swift +// StripeiOS +// +// Created by Ben Guo on 4/19/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripePaymentsUI +import UIKit + +/// An empty class representing that the user wishes to pay via Apple Pay. This can +/// be checked on an `STPPaymentContext`, e.g: +/// ``` +/// if paymentContext.selectedPaymentOption is STPApplePayPaymentOption { +/// // Don't ask the user for their card number; they want to pay with apple pay. +/// } +/// ``` +@objc public class STPApplePayPaymentOption: NSObject, STPPaymentOption { + // MARK: - STPPaymentOption + @objc public var image: UIImage { + return STPImageLibrary.applePayCardImage() + } + + @objc public var templateImage: UIImage { + // No template for Apple Pay + return STPImageLibrary.applePayCardImage() + } + + @objc public var label: String { + return String.Localized.apple_pay + } + + @objc public var isReusable: Bool { + return true + } + + // MARK: - Equality + /// :nodoc: + @objc + public override func isEqual(_ object: Any?) -> Bool { + return object is STPApplePayPaymentOption + } + + /// :nodoc: + @objc public override var hash: Int { + return NSStringFromClass(STPApplePayPaymentOption.self).hash + } +} diff --git a/Stripe/StripeiOS/Source/STPBackendAPIAdapter.swift b/Stripe/StripeiOS/Source/STPBackendAPIAdapter.swift new file mode 100644 index 00000000..2cd6ed58 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPBackendAPIAdapter.swift @@ -0,0 +1,86 @@ +// +// STPBackendAPIAdapter.swift +// StripeiOS +// +// Created by Jack Flintermann on 1/12/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +/// A "bridge" from our pre-built UI (`STPPaymentContext`, `STPPaymentOptionsViewController`) +/// to your backend to fetch Customer-related information needed to power those views. +/// Typically, you will not need to implement this protocol yourself. You +/// should instead use `STPCustomerContext`, which implements +/// and manages retrieving and updating a Stripe customer for you. +/// - seealso: STPCustomerContext.h +/// If you would prefer retrieving and updating your Stripe customer object via +/// your own backend instead of using `STPCustomerContext`, you should make your +/// application's API client conform to this interface. +@objc public protocol STPBackendAPIAdapter: NSObjectProtocol { + /// Retrieve the customer to be displayed inside a payment context. + /// If you are not using STPCustomerContext: + /// On your backend, retrieve the Stripe customer associated with your currently + /// logged-in user ( https://stripe.com/docs/api#retrieve_customer ), and return + /// the raw JSON response from the Stripe API. Back in your iOS app, after you've + /// called this API, deserialize your API response into an `STPCustomer` object + /// (you can use the `STPCustomerDeserializer` class to do this). + /// - seealso: STPCard + /// - Parameter completion: call this callback when you're done fetching and parsing the above information from your backend. For example, `completion(customer, nil)` (if your call succeeds) or `completion(nil, error)` if an error is returned. + func retrieveCustomer(_ completion: STPCustomerCompletionBlock?) + /// Retrieves a list of Payment Methods attached to a customer. + /// If you are implementing your own : + /// Call the list method ( https://stripe.com/docs/api/payment_methods/list ) + /// with the Stripe customer. If this API call succeeds, call `completion(paymentMethods)` + /// with the list of PaymentMethods. Otherwise, call `completion(error)` with the error + /// that occurred. + /// - Parameter completion: Call this callback with the list of Payment Methods attached to the + /// customer. For example, `completion(paymentMethods)` (if your call succeeds) or + /// `completion(error)` if an error is returned. + func listPaymentMethodsForCustomer(completion: STPPaymentMethodsCompletionBlock?) + /// Adds a Payment Method to a customer. + /// If you are implementing your own : + /// On your backend, retrieve the Stripe customer associated with your logged-in user. + /// Then, call the Attach method on the Payment Method with that customer's ID + /// ( https://stripe.com/docs/api/payment_methods/attach ). If this API call succeeds, + /// call `completion(nil)`. Otherwise, call `completion(error)` with the error that + /// occurred. + /// - Parameters: + /// - paymentMethod: A valid Payment Method + /// - completion: Call this callback when you're done adding the payment method + /// to the customer on your backend. For example, `completion(nil)` (if your call succeeds) + /// or `completion(error)` if an error is returned. + func attachPaymentMethod(toCustomer paymentMethod: STPPaymentMethod, completion: STPErrorBlock?) + + /// Deletes the given Payment Method from the customer. + /// If you are implementing your own : + /// Call the Detach method ( https://stripe.com/docs/api/payment_methods/detach ) + /// on the Payment Method. If this API call succeeds, call `completion(nil)`. + /// Otherwise, call `completion(error)` with the error that occurred. + /// - Parameters: + /// - paymentMethod: The Payment Method to delete from the customer + /// - completion: Call this callback when you're done deleting the Payment Method + /// from the customer on your backend. For example, `completion(nil)` (if your call + /// succeeds) or `completion(error)` if an error is returned. + @objc optional func detachPaymentMethod( + fromCustomer paymentMethod: STPPaymentMethod, + completion: STPErrorBlock? + ) + /// Sets the given shipping address on the customer. + /// If you are implementing your own : + /// On your backend, retrieve the Stripe customer associated with your logged-in user. + /// Then, call the Customer Update method ( https://stripe.com/docs/api#update_customer ) + /// specifying shipping to be the given shipping address. If this API call succeeds, + /// call `completion(nil)`. Otherwise, call `completion(error)` with the error that occurred. + /// - Parameters: + /// - shipping: The shipping address to set on the customer + /// - completion: call this callback when you're done updating the customer on + /// your backend. For example, `completion(nil)` (if your call succeeds) or + /// `completion(error)` if an error is returned. + /// - seealso: https://stripe.com/docs/api#update_customer + @objc optional func updateCustomer( + withShippingAddress shipping: STPAddress, + completion: STPErrorBlock? + ) +} diff --git a/Stripe/StripeiOS/Source/STPBankSelectionTableViewCell.swift b/Stripe/StripeiOS/Source/STPBankSelectionTableViewCell.swift new file mode 100644 index 00000000..6d7a730d --- /dev/null +++ b/Stripe/StripeiOS/Source/STPBankSelectionTableViewCell.swift @@ -0,0 +1,124 @@ +// +// STPBankSelectionTableViewCell.swift +// StripeiOS +// +// Created by David Estes on 8/9/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +import UIKit + +class STPBankSelectionTableViewCell: UITableViewCell { + func configure( + withBank bankBrand: STPFPXBankBrand, + theme: STPTheme, + selected: Bool, + offline: Bool, + enabled: Bool + ) { + bank = bankBrand + self.theme = theme + + backgroundColor = theme.secondaryBackgroundColor + + // Left icon + leftIcon?.image = STPLegacyImageLibrary.fpxBrandImage(for: bank) + leftIcon?.tintColor = primaryColorForPaymentOption(withSelected: selected, enabled: enabled) + + // Title label + titleLabel?.font = theme.font + titleLabel?.text = STPFPXBank.stringFrom(bank) + if offline { + let format = STPLocalizedString( + "%@ - Offline", + "Bank name when bank is offline for maintenance." + ) + titleLabel?.text = String(format: format, STPFPXBank.stringFrom(bank) ?? "") + } + titleLabel?.textColor = primaryColorForPaymentOption( + withSelected: isSelected, + enabled: enabled + ) + + // Loading indicator + activityIndicator?.tintColor = theme.accentColor + if selected { + activityIndicator?.startAnimating() + } else { + activityIndicator?.stopAnimating() + } + + setNeedsLayout() + } + + private var bank: STPFPXBankBrand! + private var theme: STPTheme = .defaultTheme + private var leftIcon: UIImageView? + private var titleLabel: UILabel? + private var activityIndicator: UIActivityIndicatorView? + + override init( + style: UITableViewCell.CellStyle, + reuseIdentifier: String? + ) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + // Left icon + let leftIcon = UIImageView() + self.leftIcon = leftIcon + contentView.addSubview(leftIcon) + + // Title label + let titleLabel = UILabel() + self.titleLabel = titleLabel + contentView.addSubview(titleLabel) + + // Loading indicator + let activityIndicator = UIActivityIndicatorView(style: .medium) + self.activityIndicator = activityIndicator + self.activityIndicator?.hidesWhenStopped = true + contentView.addSubview(activityIndicator) + } + + override func layoutSubviews() { + super.layoutSubviews() + + let midY = bounds.midY + let padding: CGFloat = 15.0 + let iconWidth: CGFloat = 26.0 + + // Left icon + leftIcon?.sizeToFit() + leftIcon?.center = CGPoint(x: padding + (iconWidth / 2.0), y: midY) + + // Activity indicator + activityIndicator?.center = CGPoint( + x: bounds.width - padding - (activityIndicator?.bounds.midX ?? 0.0), + y: midY + ) + + // Title label + var labelFrame = bounds + // not every icon is `iconWidth` wide, but give them all the same amount of space: + labelFrame.origin.x = padding + iconWidth + padding + labelFrame.size.width = + (activityIndicator?.frame.minX ?? 0.0) - padding - labelFrame.origin.x + titleLabel?.frame = labelFrame + } + + func primaryColorForPaymentOption(withSelected selected: Bool, enabled: Bool) -> UIColor { + if selected { + return theme.accentColor + } else { + return + (enabled + ? theme.primaryForegroundColor + : theme.primaryForegroundColor.withAlphaComponent(0.6)) + } + } + + required init?( + coder aDecoder: NSCoder + ) { + super.init(coder: aDecoder) + } +} diff --git a/Stripe/StripeiOS/Source/STPBankSelectionViewController.swift b/Stripe/StripeiOS/Source/STPBankSelectionViewController.swift new file mode 100644 index 00000000..b6fb8cdd --- /dev/null +++ b/Stripe/StripeiOS/Source/STPBankSelectionViewController.swift @@ -0,0 +1,300 @@ +// +// STPBankSelectionViewController.swift +// StripeiOS +// +// Created by David Estes on 8/9/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +import PassKit +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments +@_spi(STP) import StripePaymentsUI +import UIKit + +/// The payment methods supported by STPBankSelectionViewController. +@objc public enum STPBankSelectionMethod: Int { + /// FPX (Malaysia) + case FPX + /// An unknown payment method + case unknown +} + +/// This view controller displays a list of banks of the specified type, allowing the user to select one to pay from. +/// Once a bank is selected, it will return a PaymentMethodParams object, which you can use to confirm a PaymentIntent +/// or inspect to obtain details about the selected bank. +public class STPBankSelectionViewController: STPCoreTableViewController, UITableViewDataSource, + UITableViewDelegate +{ + /// A convenience initializer; equivalent to calling `init( bankMethod:bankMethod configuration:STPPaymentConfiguration.shared theme:STPTheme.defaultTheme`. + @objc + public convenience init( + bankMethod: STPBankSelectionMethod + ) { + self.init( + bankMethod: bankMethod, + configuration: STPPaymentConfiguration.shared, + theme: STPTheme.defaultTheme + ) + } + + @objc public convenience required init( + theme: STPTheme? + ) { + self.init( + bankMethod: .FPX, + configuration: STPPaymentConfiguration.shared, + theme: theme ?? .defaultTheme + ) + } + + /// Initializes a new `STPBankSelectionViewController` with the provided configuration and theme. Don't forget to set the `delegate` property after initialization. + /// - Parameters: + /// - bankMethod: The user will be presented with a list of banks for this payment method. STPBankSelectionMethodFPX is currently the only supported payment method. + /// - configuration: The configuration to use. This determines the Stripe publishable key to use when querying metadata about the banks. - seealso: STPPaymentConfiguration + /// - theme: The theme to use to inform the view controller's visual appearance. - seealso: STPTheme + @objc + public init( + bankMethod: STPBankSelectionMethod, + configuration: STPPaymentConfiguration, + theme: STPTheme + ) { + super.init(theme: theme) + STPAnalyticsClient.sharedClient.addClass( + toProductUsageIfNecessary: STPBankSelectionViewController.self + ) + assert(bankMethod == .FPX, "STPBankSelectionViewController currently only supports FPX.") + self.bankMethod = bankMethod + self.configuration = configuration + selectedBank = .unknown + apiClient = STPAPIClient.shared + if bankMethod == .FPX { + _refreshFPXStatus() + NotificationCenter.default.addObserver( + self, + selector: #selector(_refreshFPXStatus), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + } + title = String.Localized.bank_account + } + + /// The view controller's delegate. This must be set before showing the view controller in order for it to work properly. - seealso: STPBankSelectionViewControllerDelegate + @objc public weak var delegate: STPBankSelectionViewControllerDelegate? + + /// The API Client to use to make requests. + /// Defaults to `STPAPIClient.shared` + public var apiClient: STPAPIClient = .shared + + private var bankMethod: STPBankSelectionMethod = .unknown + private var selectedBank: STPFPXBankBrand = .unknown + private var configuration: STPPaymentConfiguration? + private weak var imageView: UIImageView? + private var headerView: STPSectionHeaderView? + private var loading = false + private var bankStatus: STPFPXBankStatusResponse? + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc func _refreshFPXStatus() { + apiClient.retrieveFPXBankStatus(withCompletion: { bankStatusResponse, error in + if error == nil && bankStatusResponse != nil { + if let bankStatusResponse = bankStatusResponse { + self._update(withBankStatus: bankStatusResponse) + } + } + }) + } + + @objc override func createAndSetupViews() { + super.createAndSetupViews() + + tableView?.register( + STPBankSelectionTableViewCell.self, + forCellReuseIdentifier: STPBankSelectionCellReuseIdentifier + ) + + tableView?.dataSource = self + tableView?.delegate = self + tableView?.reloadData() + } + + @objc override func updateAppearance() { + super.updateAppearance() + + tableView?.reloadData() + } + + @objc override func useSystemBackButton() -> Bool { + return true + } + + func _update(withBankStatus bankStatusResponse: STPFPXBankStatusResponse) { + bankStatus = bankStatusResponse + + tableView?.beginUpdates() + if let indexPathsForVisibleRows = tableView?.indexPathsForVisibleRows { + tableView?.reloadRows(at: indexPathsForVisibleRows, with: .none) + } + tableView?.endUpdates() + } + + // MARK: - UITableView + + /// :nodoc: + @objc + public func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + /// :nodoc: + @objc + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return STPFPXBankBrand.unknown.rawValue + } + + /// :nodoc: + @objc + public func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = + tableView.dequeueReusableCell( + withIdentifier: STPBankSelectionCellReuseIdentifier, + for: indexPath + ) + as? STPBankSelectionTableViewCell + let bankBrand = STPFPXBankBrand(rawValue: indexPath.row) + let selected = selectedBank == bankBrand + var offline: Bool? + if let bankBrand = bankBrand { + offline = bankStatus != nil && !(bankStatus?.bankBrandIsOnline(bankBrand) ?? false) + } + if let bankBrand = bankBrand { + cell?.configure( + withBank: bankBrand, + theme: theme, + selected: selected, + offline: offline ?? false, + enabled: !loading + ) + } + return cell! + } + + /// :nodoc: + @objc + public func tableView( + _ tableView: UITableView, + willDisplay cell: UITableViewCell, + forRowAt indexPath: IndexPath + ) { + let topRow = indexPath.row == 0 + let bottomRow = + self.tableView(tableView, numberOfRowsInSection: indexPath.section) - 1 == indexPath.row + cell.stp_setBorderColor(theme.tertiaryBackgroundColor) + cell.stp_setTopBorderHidden(!topRow) + cell.stp_setBottomBorderHidden(!bottomRow) + cell.stp_setFakeSeparatorColor(theme.quaternaryBackgroundColor) + cell.stp_setFakeSeparatorLeftInset(15.0) + } + + /// :nodoc: + @objc + public func tableView( + _ tableView: UITableView, + heightForFooterInSection section: Int + ) + -> CGFloat + { + return 27.0 + } + + /// :nodoc: + @objc + public func tableView( + _ tableView: UITableView, + shouldHighlightRowAt indexPath: IndexPath + ) + -> Bool + { + return !loading + } + + /// :nodoc: + @objc + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if loading { + return // Don't allow user interaction if we're currently setting up a payment method + } + loading = true + tableView.deselectRow(at: indexPath, animated: true) + let bankIndex = indexPath.row + selectedBank = STPFPXBankBrand(rawValue: bankIndex) ?? .unknown + tableView.reloadSections( + NSIndexSet(index: indexPath.section) as IndexSet, + with: .none + ) + + let fpx = STPPaymentMethodFPXParams() + fpx.bank = STPFPXBankBrand(rawValue: bankIndex) ?? .unknown + // Create and return a Payment Method Params object + let paymentMethodParams = STPPaymentMethodParams( + fpx: fpx, + billingDetails: nil, + metadata: nil + ) + if delegate?.responds( + to: #selector( + STPBankSelectionViewControllerDelegate.bankSelectionViewController( + _: + didCreatePaymentMethodParams: + )) + ) ?? false { + delegate?.bankSelectionViewController( + self, + didCreatePaymentMethodParams: paymentMethodParams + ) + } + } + + required init?( + coder aDecoder: NSCoder + ) { + super.init(coder: aDecoder) + } + + required init( + nibName nibNameOrNil: String?, + bundle nibBundleOrNil: Bundle? + ) { + fatalError("init(nibName:bundle:) has not been implemented") + } +} + +/// An `STPBankSelectionViewControllerDelegate` is notified when a user selects a bank. +@objc public protocol STPBankSelectionViewControllerDelegate: NSObjectProtocol { + /// This is called when the user selects a bank. + /// You can use the returned PaymentMethodParams to confirm a PaymentIntent, or inspect + /// it to obtain details about the selected bank. + /// Once you're done, you'll want to dismiss (or pop) the view controller. + /// - Parameters: + /// - bankViewController: the view controller that created the PaymentMethodParams + /// - paymentMethodParams: the PaymentMethodParams that was created. - seealso: STPPaymentMethodParams + @objc(bankSelectionViewController:didCreatePaymentMethodParams:) + func bankSelectionViewController( + _ bankViewController: STPBankSelectionViewController, + didCreatePaymentMethodParams paymentMethodParams: STPPaymentMethodParams + ) +} + +private let STPBankSelectionCellReuseIdentifier = "STPBankSelectionCellReuseIdentifier" + +/// :nodoc: +@_spi(STP) extension STPBankSelectionViewController: STPAnalyticsProtocol { + @_spi(STP) public static var stp_analyticsIdentifier = "STPBankSelectionViewController" +} diff --git a/Stripe/StripeiOS/Source/STPBlocks.swift b/Stripe/StripeiOS/Source/STPBlocks.swift new file mode 100644 index 00000000..e750cc71 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPBlocks.swift @@ -0,0 +1,44 @@ +// +// STPBlocks.swift +// StripeiOS +// +// Created by David Estes on 6/30/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +import PassKit + +/// A callback to be run with a response from the Stripe API containing information about the online status of FPX banks. +/// - Parameters: +/// - bankStatusResponse: The response from Stripe containing the status of the various banks. Will be nil if an error occurs. - seealso: STPFPXBankStatusResponse +/// - error: The error returned from the response, or nil if none occurs. +typealias STPFPXBankStatusCompletionBlock = (STPFPXBankStatusResponse?, Error?) -> Void + +/// These values control the labels used in the shipping info collection form. +@objc public enum STPShippingType: Int { + /// Shipping the purchase to the provided address using a third-party + /// shipping company. + case shipping + /// Delivering the purchase by the seller. + case delivery +} + +/// An enum representing the status of a shipping address validation. +@objc public enum STPShippingStatus: Int { + /// The shipping address is valid. + case valid + /// The shipping address is invalid. + case invalid +} + +/// A callback to be run with a validation result and shipping methods for a +/// shipping address. +/// - Parameters: +/// - status: An enum representing whether the shipping address is valid. +/// - shippingValidationError: If the shipping address is invalid, an error describing the issue with the address. If no error is given and the address is invalid, the default error message will be used. +/// - shippingMethods: The shipping methods available for the address. +/// - selectedShippingMethod: The default selected shipping method for the address. +public typealias STPShippingMethodsCompletionBlock = ( + STPShippingStatus, Error?, [PKShippingMethod]?, PKShippingMethod? +) -> Void diff --git a/Stripe/StripeiOS/Source/STPCameraView.swift b/Stripe/StripeiOS/Source/STPCameraView.swift new file mode 100644 index 00000000..17fa4247 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPCameraView.swift @@ -0,0 +1,77 @@ +// +// STPCameraView.swift +// StripeiOS +// +// Created by David Estes on 8/17/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import AVFoundation +import UIKit + +@available(macCatalyst 14.0, *) +class STPCameraView: UIView { + private var flashLayer: CALayer? + + var captureSession: AVCaptureSession? { + get { + return (videoPreviewLayer.session)! + } + set(captureSession) { + videoPreviewLayer.session = captureSession + } + } + + var videoPreviewLayer: AVCaptureVideoPreviewLayer { + return layer as! AVCaptureVideoPreviewLayer + } + + func playSnapshotAnimation() { + CATransaction.begin() + CATransaction.setValue( + kCFBooleanTrue, + forKey: kCATransactionDisableActions + ) + flashLayer?.frame = CGRect( + x: 0, + y: 0, + width: layer.bounds.size.width, + height: layer.bounds.size.height + ) + flashLayer?.opacity = 1.0 + CATransaction.commit() + DispatchQueue.main.async(execute: { + let fadeAnim = CABasicAnimation(keyPath: "opacity") + fadeAnim.fromValue = NSNumber(value: 1.0) + fadeAnim.toValue = NSNumber(value: 0.0) + fadeAnim.duration = 1.0 + self.flashLayer?.add(fadeAnim, forKey: "opacity") + self.flashLayer?.opacity = 0.0 + }) + } + + override init( + frame: CGRect + ) { + super.init(frame: frame) + flashLayer = CALayer() + if let flashLayer = flashLayer { + layer.addSublayer(flashLayer) + } + flashLayer?.masksToBounds = true + flashLayer?.backgroundColor = UIColor.black.cgColor + flashLayer?.opacity = 0.0 + layer.masksToBounds = true + videoPreviewLayer.videoGravity = .resizeAspectFill + } + + override class var layerClass: AnyClass { + return AVCaptureVideoPreviewLayer.self + } + + required init?( + coder aDecoder: NSCoder + ) { + super.init(coder: aDecoder) + } +} diff --git a/Stripe/StripeiOS/Source/STPCard+BasicUI.swift b/Stripe/StripeiOS/Source/STPCard+BasicUI.swift new file mode 100644 index 00000000..2b90f892 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPCard+BasicUI.swift @@ -0,0 +1,30 @@ +// +// STPCard+BasicUI.swift +// StripeiOS +// +// Created by David Estes on 6/30/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +extension STPCard: STPPaymentOption { + // MARK: - STPPaymentOption + @objc public var image: UIImage { + return STPImageLibrary.cardBrandImage(for: brand) + } + + @objc public var templateImage: UIImage { + return STPImageLibrary.templatedBrandImage(for: brand) + } + + @objc public var label: String { + let brand = STPCard.string(from: self.brand) + return "\(brand) \(last4 )" + } + + @objc public var isReusable: Bool { + return true + } +} diff --git a/Stripe/StripeiOS/Source/STPCardScanner.swift b/Stripe/StripeiOS/Source/STPCardScanner.swift new file mode 100644 index 00000000..74cb2088 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPCardScanner.swift @@ -0,0 +1,488 @@ +// +// STPCardScanner.swift +// StripeiOS +// +// Created by David Estes on 8/17/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import AVFoundation +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments +@_spi(STP) import StripePaymentsUI +import UIKit +import Vision + +enum STPCardScannerError: Int { + /// Camera not available. + case cameraNotAvailable +} + +@available(macCatalyst 14.0, *) +@objc protocol STPCardScannerDelegate: NSObjectProtocol { + @objc(cardScanner:didFinishWithCardParams:error:) func cardScanner( + _ scanner: STPCardScanner, + didFinishWith cardParams: + STPPaymentMethodCardParams?, + error: Error?) +} + +@available(macCatalyst 14.0, *) +@objc(STPCardScanner_legacy) +class STPCardScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, STPCardScanningProtocol { + // iOS will kill the app if it tries to request the camera without an NSCameraUsageDescription + static let cardScanningAvailableCameraHasUsageDescription = { + return + (Bundle.main.infoDictionary?["NSCameraUsageDescription"] != nil + || Bundle.main.localizedInfoDictionary?["NSCameraUsageDescription"] != nil) + }() + + static var cardScanningAvailable: Bool { + // Always allow in tests: + if NSClassFromString("XCTest") != nil { + return true + } + return cardScanningAvailableCameraHasUsageDescription + } + + weak var cameraView: STPCameraView? + + var feedbackGenerator: UINotificationFeedbackGenerator? + + @objc public var deviceOrientation: UIDeviceOrientation { + get { + return stp_deviceOrientation + } + set(newDeviceOrientation) { + stp_deviceOrientation = newDeviceOrientation + + // This is an optimization for portrait mode: The card will be centered in the screen, + // so we can ignore the top and bottom. We'll use the whole frame in landscape. + let kSTPCardScanningScreenCenter = CGRect( + x: 0, y: CGFloat(0.3), width: 1, height: CGFloat(0.4)) + + // iOS camera image data is returned in LandcapeLeft orientation by default. We'll flip it as needed: + switch newDeviceOrientation { + case .portraitUpsideDown: + videoOrientation = .portraitUpsideDown + textOrientation = .left + regionOfInterest = kSTPCardScanningScreenCenter + case .landscapeLeft: + videoOrientation = .landscapeRight + textOrientation = .up + regionOfInterest = CGRect(x: 0, y: 0, width: 1, height: 1) + case .landscapeRight: + videoOrientation = .landscapeLeft + textOrientation = .down + regionOfInterest = CGRect(x: 0, y: 0, width: 1, height: 1) + case .portrait, .faceUp, .faceDown, .unknown: + fallthrough + default: + videoOrientation = .portrait + textOrientation = .right + regionOfInterest = kSTPCardScanningScreenCenter + } + cameraView?.videoPreviewLayer.connection?.videoOrientation = videoOrientation + } + } + + override init() { + } + + init(delegate: STPCardScannerDelegate?) { + super.init() + self.delegate = delegate + captureSessionQueue = DispatchQueue(label: "com.stripe.CardScanning.CaptureSessionQueue") + deviceOrientation = UIDevice.current.orientation + } + + func start() { + if isScanning { + return + } + STPAnalyticsClient.sharedClient.addClass(toProductUsageIfNecessary: STPCardScanner.self) + startTime = Date() + + isScanning = true + timeoutTime = nil + feedbackGenerator = UINotificationFeedbackGenerator() + feedbackGenerator?.prepare() + + captureSessionQueue?.async(execute: { + #if targetEnvironment(simulator) + // Camera not supported on Simulator + self.stopWithError(STPCardScanner.stp_cardScanningError()) + return + #else + self.detectedNumbers = NSCountedSet() + self.detectedExpirations = NSCountedSet() + self.setupCamera() + DispatchQueue.main.async(execute: { + self.cameraView?.captureSession = self.captureSession + self.cameraView?.videoPreviewLayer.connection?.videoOrientation = + self.videoOrientation + }) + #endif + }) + } + + func stop() { + stopWithError(nil) + } + + private weak var delegate: STPCardScannerDelegate? + private var captureDevice: AVCaptureDevice? + private var captureSession: AVCaptureSession? + private var captureSessionQueue: DispatchQueue? + private var videoDataOutput: AVCaptureVideoDataOutput? + private var videoDataOutputQueue: DispatchQueue? + private var textRequest: VNRecognizeTextRequest? + private var isScanning = false + + private var timeoutTime: Date? + private var didTimeout: Bool { + if let timeoutTime = timeoutTime { + return timeoutTime <= Date() + } + return false + } + + private var stp_deviceOrientation: UIDeviceOrientation! + private var videoOrientation: AVCaptureVideoOrientation! + private var textOrientation: CGImagePropertyOrientation! + private var regionOfInterest = CGRect.zero + private var detectedNumbers = NSCountedSet() + private var detectedExpirations = NSCountedSet() + private var startTime: Date? + + // MARK: Public + + class func stp_cardScanningError() -> Error { + let userInfo = [ + NSLocalizedDescriptionKey: String.Localized.allow_camera_access, + STPError.errorMessageKey: "The camera couldn't be used.", + ] + return NSError( + domain: STPCardScannerErrorDomain, + code: STPCardScannerError.cameraNotAvailable.rawValue, + userInfo: userInfo) + } + + deinit { + if isScanning { + captureDevice?.unlockForConfiguration() + captureSession?.stopRunning() + } + } + + func stopWithError(_ error: Error?) { + if isScanning { + finish(with: nil, error: error) + } + } + + // MARK: Setup + func setupCamera() { + weak var weakSelf = self + textRequest = VNRecognizeTextRequest(completionHandler: { request, error in + let strongSelf = weakSelf + if !(strongSelf?.isScanning ?? false) { + return + } + if error != nil { + strongSelf?.stopWithError(STPCardScanner.stp_cardScanningError()) + return + } + strongSelf?.processVNRequest(request) + }) + + let captureDevice = AVCaptureDevice.default( + .builtInWideAngleCamera, for: .video, position: .back) + self.captureDevice = captureDevice + + captureSession = AVCaptureSession() + captureSession?.sessionPreset = .hd1920x1080 + + var deviceInput: AVCaptureDeviceInput? + do { + if let captureDevice = captureDevice { + deviceInput = try AVCaptureDeviceInput(device: captureDevice) + } + } catch { + stopWithError(STPCardScanner.stp_cardScanningError()) + return + } + + if let deviceInput = deviceInput { + if captureSession?.canAddInput(deviceInput) ?? false { + captureSession?.addInput(deviceInput) + } else { + stopWithError(STPCardScanner.stp_cardScanningError()) + return + } + } + + videoDataOutputQueue = DispatchQueue(label: "com.stripe.CardScanning.VideoDataOutputQueue") + videoDataOutput = AVCaptureVideoDataOutput() + videoDataOutput?.alwaysDiscardsLateVideoFrames = true + videoDataOutput?.setSampleBufferDelegate(self, queue: videoDataOutputQueue) + + // This is the recommended pixel buffer format for Vision: + videoDataOutput?.videoSettings = [ + kCVPixelBufferPixelFormatTypeKey as String: + kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, + ] + + if let videoDataOutput = videoDataOutput { + if captureSession?.canAddOutput(videoDataOutput) ?? false { + captureSession?.addOutput(videoDataOutput) + } else { + stopWithError(STPCardScanner.stp_cardScanningError()) + return + } + } + + // This improves recognition quality, but means the VideoDataOutput buffers won't match what we're seeing on screen. + videoDataOutput?.connection(with: .video)?.preferredVideoStabilizationMode = .auto + + captureSession?.startRunning() + + do { + try self.captureDevice?.lockForConfiguration() + self.captureDevice?.autoFocusRangeRestriction = .near + } catch { + } + } + + // MARK: Processing + func captureOutput( + _ output: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + if !isScanning { + return + } + let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) + if pixelBuffer == nil { + return + } + textRequest?.recognitionLevel = .accurate + textRequest?.usesLanguageCorrection = false + textRequest?.regionOfInterest = regionOfInterest + var handler: VNImageRequestHandler? + if let pixelBuffer = pixelBuffer { + handler = VNImageRequestHandler( + cvPixelBuffer: pixelBuffer, orientation: textOrientation, options: [:]) + } + do { + try handler?.perform([textRequest].compactMap { $0 }) + } catch { + } + } + + func processVNRequest(_ request: VNRequest) { + var allNumbers: [String] = [] + for observation in request.results ?? [] { + guard let observation = observation as? VNRecognizedTextObservation else { + continue + } + let candidates = observation.topCandidates(5) + let topCandidate = candidates.first?.string + if STPCardValidator.sanitizedNumericString(for: topCandidate ?? "").count >= 4 { + allNumbers.append(topCandidate ?? "") + } + for recognizedText in candidates { + let possibleNumber = STPCardValidator.sanitizedNumericString( + for: recognizedText.string) + if possibleNumber.count < 4 { + continue // This probably isn't something we're interested in, so don't bother processing it. + } + + // First strategy: We check if Vision sent us a number in a group on its own. If that fails, we'll try + // to catch it later when we iterate over all the numbers. + if STPCardValidator.validationState( + forNumber: possibleNumber, validatingCardBrand: true) + == .valid + { + addDetectedNumber(possibleNumber) + } else if possibleNumber.count >= 4 && possibleNumber.count <= 6 + && STPStringUtils.stringMayContainExpirationDate(recognizedText.string) + { + // Try to parse anything that looks like an expiration date. + let expirationString = STPStringUtils.expirationDateString( + from: recognizedText.string) + let sanitizedExpiration = STPCardValidator.sanitizedNumericString( + for: expirationString ?? "") + let month = (sanitizedExpiration as NSString).substring(to: 2) + let year = (sanitizedExpiration as NSString).substring(from: 2) + + // Ignore expiration dates 10+ years in the future, as they're likely to be incorrect recognitions + let calendar = Calendar(identifier: .gregorian) + let presentYear = calendar.component(.year, from: Date()) + let maxYear = (presentYear % 100) + 10 + + if STPCardValidator.validationState(forExpirationYear: year, inMonth: month) + == .valid + && Int(year) ?? 0 < maxYear + { + addDetectedExpiration(sanitizedExpiration) + } + } + } + } + // Second strategy: We look for consecutive groups of 4/4/4/4 or 4/6/5 + // Vision is sending us groups like ["1234 565", "1234 1"], so we'll normalize these into groups with spaces: + let allGroups = allNumbers.joined(separator: " ").components(separatedBy: " ") + if allGroups.count < 3 { + return + } + for i in 0..<(allGroups.count - 3) { + let string1 = allGroups[i] + let string2 = allGroups[i + 1] + let string3 = allGroups[i + 2] + var string4 = "" + if i + 3 < allGroups.count { + string4 = allGroups[i + 3] + } + // Then we'll go through each group and build a potential match: + let potentialCardString = "\(string1)\(string2)\(string3)\(string4)" + let potentialAmexString = "\(string1)\(string2)\(string3)" + + // Then we'll add valid matches. It's okay if we add a number a second time after doing so above, as the success of that first pass means it's more likely to be a good match. + if STPCardValidator.validationState( + forNumber: potentialCardString, validatingCardBrand: true) + == .valid + { + addDetectedNumber(potentialCardString) + } else if STPCardValidator.validationState( + forNumber: potentialAmexString, validatingCardBrand: true) == .valid + { + addDetectedNumber(potentialAmexString) + } + } + } + + func addDetectedNumber(_ number: String) { + detectedNumbers.add(number) + + // Set a timeout: If we don't get enough scans in the next 0.6 seconds, we'll use the best option we have. + if timeoutTime == nil { + self.timeoutTime = Date().addingTimeInterval(kSTPCardScanningTimeout) + weak var weakSelf = self + DispatchQueue.main.async(execute: { + let strongSelf = weakSelf + strongSelf?.cameraView?.playSnapshotAnimation() + strongSelf?.feedbackGenerator?.notificationOccurred(.success) + }) + // Just in case we don't get any frames, add another call to `finishIfReady` after timeoutTime to check + videoDataOutputQueue?.asyncAfter( + deadline: DispatchTime.now() + kSTPCardScanningTimeout, + execute: { + let strongSelf = weakSelf + if strongSelf?.isScanning ?? false { + strongSelf?.finishIfReady() + } + }) + } + + if (detectedNumbers.count(for: number)) >= kSTPCardScanningMinimumValidScans { + finishIfReady() + } + } + + func addDetectedExpiration(_ expiration: String) { + detectedExpirations.add(expiration) + if (detectedExpirations.count(for: expiration)) >= kSTPCardScanningMinimumValidScans { + finishIfReady() + } + } + + // MARK: Completion + func finishIfReady() { + if !isScanning { + return + } + let detectedNumbers = self.detectedNumbers + let detectedExpirations = self.detectedExpirations + + let topNumber = (detectedNumbers.allObjects as NSArray).sortedArray(comparator: { + obj1, obj2 in + let c1 = detectedNumbers.count(for: obj1) + let c2 = detectedNumbers.count(for: obj2) + if c1 < c2 { + return .orderedAscending + } else if c1 > c2 { + return .orderedDescending + } else { + return .orderedSame + } + }).last + let topExpiration = (detectedExpirations.allObjects as NSArray).sortedArray(comparator: { + obj1, obj2 in + let c1 = detectedExpirations.count(for: obj1) + let c2 = detectedExpirations.count(for: obj2) + if c1 < c2 { + return .orderedAscending + } else if c1 > c2 { + return .orderedDescending + } else { + return .orderedSame + } + }).last + + var didSeeEnoughScans = false + if let topNumber = topNumber, let topExpiration = topExpiration { + didSeeEnoughScans = detectedNumbers.count(for: topNumber) >= kSTPCardScanningMinimumValidScans && + detectedExpirations.count(for: topExpiration) >= kSTPCardScanningMinimumValidScans + } + if didTimeout || didSeeEnoughScans { + let params = STPPaymentMethodCardParams() + params.number = topNumber as? String + if let topExpiration = topExpiration { + params.expMonth = NSNumber( + value: Int((topExpiration as! NSString).substring(to: 2)) ?? 0) + params.expYear = NSNumber( + value: Int((topExpiration as! NSString).substring(from: 2)) ?? 0) + } + finish(with: params, error: nil) + } + } + + func finish(with params: STPPaymentMethodCardParams?, error: Error?) { + var duration: TimeInterval? + if let startTime = startTime { + duration = Date().timeIntervalSince(startTime) + } + isScanning = false + captureDevice?.unlockForConfiguration() + captureSession?.stopRunning() + + DispatchQueue.main.async(execute: { + if params == nil { + STPAnalyticsClient.sharedClient.logCardScanCancelled(withDuration: duration ?? 0.0) + } else { + STPAnalyticsClient.sharedClient.logCardScanSucceeded(withDuration: duration ?? 0.0) + } + self.feedbackGenerator = nil + + self.cameraView?.captureSession = nil + self.delegate?.cardScanner(self, didFinishWith: params, error: error) + }) + } + + // MARK: Orientation +} + +// The number of successful scans required for both card number and expiration date before returning a result. +private let kSTPCardScanningMinimumValidScans = 2 +// Once one successful scan is found, we'll stop scanning after this many seconds. +private let kSTPCardScanningTimeout: TimeInterval = 0.6 +let STPCardScannerErrorDomain = "STPCardScannerErrorDomain" + +/// :nodoc: +@available(macCatalyst 14.0, *) +extension STPCardScanner: STPAnalyticsProtocol { + static var stp_analyticsIdentifier = "STPCardScanner" +} diff --git a/Stripe/StripeiOS/Source/STPCardScannerTableViewCell.swift b/Stripe/StripeiOS/Source/STPCardScannerTableViewCell.swift new file mode 100644 index 00000000..7692e148 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPCardScannerTableViewCell.swift @@ -0,0 +1,67 @@ +// +// STPCardScannerTableViewCell.swift +// StripeiOS +// +// Created by David Estes on 8/17/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import UIKit + +@available(macCatalyst 14.0, *) +class STPCardScannerTableViewCell: UITableViewCell { + private(set) weak var cameraView: STPCameraView? + + private var _theme: STPTheme? + var theme: STPTheme? { + get { + _theme + } + set(theme) { + _theme = theme + updateAppearance() + } + } + + let cardSizeRatio: CGFloat = 2.125 / 3.370 // ID-1 card size (in inches) + + override init( + style: UITableViewCell.CellStyle, + reuseIdentifier: String? + ) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + let cameraView = STPCameraView(frame: bounds) + contentView.addSubview(cameraView) + self.cameraView = cameraView + theme = STPTheme.defaultTheme + self.cameraView?.translatesAutoresizingMaskIntoConstraints = false + contentView.addConstraints( + [ + cameraView.heightAnchor.constraint( + equalTo: cameraView.widthAnchor, + multiplier: cardSizeRatio + ), + cameraView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: 0), + cameraView.leftAnchor.constraint(equalTo: contentView.leftAnchor, constant: 0), + cameraView.rightAnchor.constraint(equalTo: contentView.rightAnchor, constant: 0), + cameraView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 0), + ]) + updateAppearance() + } + + override func layoutSubviews() { + + super.layoutSubviews() + } + + @objc func updateAppearance() { + // The first few frames of the camera view will be black, so our background should be black too. + cameraView?.backgroundColor = UIColor.black + } + + required init?( + coder aDecoder: NSCoder + ) { + super.init(coder: aDecoder) + } +} diff --git a/Stripe/StripeiOS/Source/STPCardValidationState.swift b/Stripe/StripeiOS/Source/STPCardValidationState.swift new file mode 100644 index 00000000..48e1f46b --- /dev/null +++ b/Stripe/StripeiOS/Source/STPCardValidationState.swift @@ -0,0 +1,9 @@ +// +// STPCardValidationState.swift +// StripeiOS +// +// Created by Jack Flintermann on 8/7/15. +// Copyright (c) 2015 Stripe, Inc. All rights reserved. +// + +import Foundation diff --git a/Stripe/StripeiOS/Source/STPCoreScrollViewController.swift b/Stripe/StripeiOS/Source/STPCoreScrollViewController.swift new file mode 100644 index 00000000..de3b7bbf --- /dev/null +++ b/Stripe/StripeiOS/Source/STPCoreScrollViewController.swift @@ -0,0 +1,62 @@ +// +// STPCoreScrollViewController.swift +// StripeiOS +// +// Created by Brian Dorfman on 1/6/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +/// This is the base class for all Stripe scroll view controllers. It is intended +/// for use only by Stripe classes, you should not subclass it yourself in your app. +public class STPCoreScrollViewController: STPCoreViewController { + /// This returns the scroll view being managed by the view controller + @objc public lazy var scrollView: UIScrollView = { + createScrollView() + }() + + /// This method is used by the base implementation to create the object + /// backing the `scrollView` property. Subclasses can override to change the + /// type of the scroll view (eg UITableView or UICollectionView instead of + /// UIScrollView). + + func createScrollView() -> UIScrollView { + return UIScrollView() + } + + override func createAndSetupViews() { + super.createAndSetupViews() + view.addSubview(scrollView) + } + + /// :nodoc: + @objc + public override func viewDidLoad() { + super.viewDidLoad() + + scrollView.contentInsetAdjustmentBehavior = .automatic + } + + /// :nodoc: + @objc + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + scrollView.frame = view.bounds + } + + @objc override func updateAppearance() { + super.updateAppearance() + + scrollView.backgroundColor = theme.primaryBackgroundColor + scrollView.tintColor = theme.accentColor + + if theme.primaryBackgroundColor.isBright { + scrollView.indicatorStyle = .black + } else { + scrollView.indicatorStyle = .white + } + } +} diff --git a/Stripe/StripeiOS/Source/STPCoreTableViewController.swift b/Stripe/StripeiOS/Source/STPCoreTableViewController.swift new file mode 100644 index 00000000..5d7105e3 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPCoreTableViewController.swift @@ -0,0 +1,54 @@ +// +// STPCoreTableViewController.swift +// StripeiOS +// +// Created by Brian Dorfman on 1/6/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +/// This is the base class for all Stripe scroll view controllers. It is intended +/// for use only by Stripe classes, you should not subclass it yourself in your app. +/// It inherits from STPCoreScrollViewController and changes the type of the +/// created scroll view to UITableView, as well as other shared table view logic. +public class STPCoreTableViewController: STPCoreScrollViewController { + /// This points to the same object as `STPCoreScrollViewController`'s `scrollView` + /// property but with the type cast to `UITableView` + + @objc public var tableView: UITableView? { + return (scrollView as? UITableView) + } + + override func createScrollView() -> UIScrollView { + let tableView = UITableView(frame: CGRect.zero, style: .grouped) + tableView.sectionHeaderHeight = 30 + + return tableView + } + + /// :nodoc: + @objc + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + tableView?.reloadData() + } + + @objc override func updateAppearance() { + super.updateAppearance() + tableView?.separatorStyle = .none // handle this with fake separator views for flexibility + } + + /// :nodoc: + @objc + public func tableView( + _ tableView: UITableView, + heightForHeaderInSection section: Int + ) + -> CGFloat + { + return 0.01 + } +} diff --git a/Stripe/StripeiOS/Source/STPCoreViewController.swift b/Stripe/StripeiOS/Source/STPCoreViewController.swift new file mode 100644 index 00000000..105b544d --- /dev/null +++ b/Stripe/StripeiOS/Source/STPCoreViewController.swift @@ -0,0 +1,162 @@ +// +// STPCoreViewController.swift +// StripeiOS +// +// Created by Brian Dorfman on 1/6/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeUICore +import UIKit + +/// This is the base class for all Stripe view controllers. It is intended for use +/// only by Stripe classes, you should not subclass it yourself in your app. +/// It theming, back/cancel button management, and other shared logic for +/// Stripe view controllers. +public class STPCoreViewController: UIViewController { + /// A convenience initializer; equivalent to calling `init(theme: STPTheme.defaultTheme)`. + @objc + public convenience init() { + self.init(theme: STPTheme.defaultTheme) + } + + /// Initializes a new view controller with the specified theme + /// - Parameter theme: The theme to use to inform the view controller's visual appearance. - seealso: STPTheme + @objc public required init( + theme: STPTheme? + ) { + super.init(nibName: nil, bundle: nil) + commonInit(with: theme) + } + + /// Passes through to the default UIViewController behavior for this initializer, + /// and then also sets the default theme as in `init` + @objc public required override init( + nibName nibNameOrNil: String?, + bundle nibBundleOrNil: Bundle? + ) { + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + commonInit(with: STPTheme.defaultTheme) + } + + /// Passes through to the default UIViewController behavior for this initializer, + /// and then also sets the default theme as in `init` + @objc public required init?( + coder aDecoder: NSCoder + ) { + super.init(coder: aDecoder) + commonInit(with: STPTheme.defaultTheme) + } + + private var _theme: STPTheme = STPTheme.defaultTheme + @objc var theme: STPTheme { + get { + _theme + } + set(theme) { + _theme = theme + updateAppearance() + } + } + @objc var cancelItem: UIBarButtonItem? + + /// All designated initializers funnel through this method to do their setup + /// - Parameter theme: Initial theme for this view controller + func commonInit(with theme: STPTheme?) { + if let theme = theme { + _theme = theme + } else { + _theme = .defaultTheme + } + + if !useSystemBackButton() { + cancelItem = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: self, + action: #selector(STPAddCardViewController.handleCancelTapped(_:)) + ) + cancelItem?.accessibilityIdentifier = "CoreViewControllerCancelIdentifier" + + stp_navigationItemProxy?.leftBarButtonItem = cancelItem + } + + NotificationCenter.default.addObserver( + self, + selector: #selector(STPAddCardViewController.updateAppearance), + name: UIContentSizeCategory.didChangeNotification, + object: nil + ) + } + + /// Called in viewDidLoad after doing base implementation, before + /// calling updateAppearance + func createAndSetupViews() { + // do nothing + } + + // These viewDidX() methods have significant code done + // in the base class and super must be called if they are overidden + /// :nodoc: + @objc + public override func viewDidLoad() { + super.viewDidLoad() + + createAndSetupViews() + updateAppearance() + } + /// :nodoc: + @objc + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + updateAppearance() + } + /// :nodoc: + @objc + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + view.endEditing(true) + } + + /// Update views based on current STPTheme + @objc func updateAppearance() { + let navBarTheme = navigationController?.navigationBar.stp_theme ?? theme + navigationItem.leftBarButtonItem?.stp_setTheme(navBarTheme) + navigationItem.rightBarButtonItem?.stp_setTheme(navBarTheme) + cancelItem?.stp_setTheme(navBarTheme) + + view.backgroundColor = theme.primaryBackgroundColor + + setNeedsStatusBarAppearanceUpdate() + } + + /// :nodoc: + @objc public override var preferredStatusBarStyle: UIStatusBarStyle { + let navBarTheme = navigationController?.navigationBar.stp_theme ?? theme + return navBarTheme.secondaryBackgroundColor.isBright + ? .default + : .lightContent + } + + /// Called by the automatically-managed back/cancel button + /// By default pops the top item off the navigation stack, or if we are the + /// root of the navigation controller, dimisses presentation + /// - Parameter sender: Sender of the target action, if applicable. + @objc func handleCancelTapped(_ sender: Any?) { + if stp_isAtRootOfNavigationController() { + // if we're the root of the navigation controller, we've been presented modally. + presentingViewController?.dismiss(animated: true) + } else { + // otherwise, we've been pushed onto the stack. + navigationController?.popViewController(animated: true) + } + } + + /// If you override this and return YES, then your CoreVC implementation will not + /// create and set up a cancel and instead just use the default + /// UIViewController back button behavior. + /// You won't receive calls to `handleCancelTapped` if this is YES. + /// Defaults to NO. + func useSystemBackButton() -> Bool { + return false + } +} diff --git a/Stripe/StripeiOS/Source/STPCustomerContext.swift b/Stripe/StripeiOS/Source/STPCustomerContext.swift new file mode 100644 index 00000000..13820fa2 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPCustomerContext.swift @@ -0,0 +1,419 @@ +// +// STPCustomerContext.swift +// StripeiOS +// +// Created by Ben Guo on 5/2/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripePaymentsUI + +/// An `STPCustomerContext` retrieves and updates a Stripe customer and their attached +/// payment methods using an ephemeral key, a short-lived API key scoped to a specific +/// customer object. If your current user logs out of your app and a new user logs in, +/// be sure to either create a new instance of `STPCustomerContext` or clear the current +/// instance's cache. On your backend, be sure to create and return a +/// new ephemeral key for the Customer object associated with the new user. +open class STPCustomerContext: NSObject, STPBackendAPIAdapter { + /// Initializes a new `STPCustomerContext` with the specified key provider. + /// Upon initialization, a CustomerContext will fetch a new ephemeral key from + /// your backend and use it to prefetch the customer object specified in the key. + /// Subsequent customer and payment method retrievals (e.g. by `STPPaymentContext`) + /// will return the prefetched customer / attached payment methods immediately if + /// its age does not exceed 60 seconds. + /// - Parameter keyProvider: The key provider the customer context will use. + /// - Returns: the newly-instantiated customer context. + @objc(initWithKeyProvider:) + public convenience init( + keyProvider: STPCustomerEphemeralKeyProvider + ) { + self.init(keyProvider: keyProvider, apiClient: STPAPIClient.shared) + } + + /// Initializes a new `STPCustomerContext` with the specified key provider. + /// Upon initialization, a CustomerContext will fetch a new ephemeral key from + /// your backend and use it to prefetch the customer object specified in the key. + /// Subsequent customer and payment method retrievals (e.g. by `STPPaymentContext`) + /// will return the prefetched customer / attached payment methods immediately if + /// its age does not exceed 60 seconds. + /// - Parameters: + /// - keyProvider: The key provider the customer context will use. + /// - apiClient: The API Client to use to make requests. + /// - Returns: the newly-instantiated customer context. + @objc(initWithKeyProvider:apiClient:) + public convenience init( + keyProvider: STPCustomerEphemeralKeyProvider?, + apiClient: STPAPIClient + ) { + let keyManager = STPEphemeralKeyManager( + keyProvider: keyProvider, + apiVersion: STPAPIClient.apiVersion, + performsEagerFetching: true + ) + self.init(keyManager: keyManager, apiClient: apiClient) + } + + /// `STPCustomerContext` will cache its customer object and associated payment methods + /// for up to 60 seconds. If your current user logs out of your app and a new user logs + /// in, be sure to either call this method or create a new instance of `STPCustomerContext`. + /// On your backend, be sure to create and return a new ephemeral key for the + /// customer object associated with the new user. + @objc + public func clearCache() { + clearCachedCustomer() + clearCachedPaymentMethods() + } + + private var _includeApplePayPaymentMethods = false + /// By default, `STPCustomerContext` will filter Apple Pay when it retrieves + /// Payment Methods. Apple Pay payment methods should generally not be re-used and + /// shouldn't be offered to customers as a new payment method (Apple Pay payment + /// methods may only be re-used for subscriptions). + /// If you are using `STPCustomerContext` to back your own UI and would like to + /// disable Apple Pay filtering, set this property to YES. + /// Note: If you are using `STPPaymentContext`, you should not change this property. + @objc public var includeApplePayPaymentMethods: Bool { + get { + _includeApplePayPaymentMethods + } + set(includeApplePayMethods) { + _includeApplePayPaymentMethods = includeApplePayMethods + customer?.updateSources(filteringApplePay: !includeApplePayMethods) + } + } + + private var _customer: STPCustomer? + private var customer: STPCustomer? { + get { + _customer + } + set(customer) { + _customer = customer + customerRetrievedDate = (customer) != nil ? Date() : nil + } + } + @objc internal var customerRetrievedDate: Date? + + private var _paymentMethods: [STPPaymentMethod]? + private var paymentMethods: [STPPaymentMethod]? { + get { + if !includeApplePayPaymentMethods { + var paymentMethodsExcludingApplePay: [STPPaymentMethod]? = [] + for paymentMethod in _paymentMethods ?? [] { + let isApplePay = + paymentMethod.type == .card && paymentMethod.card?.wallet?.type == .applePay + if !isApplePay { + paymentMethodsExcludingApplePay?.append(paymentMethod) + } + } + return paymentMethodsExcludingApplePay ?? [] + } else { + return _paymentMethods ?? [] + } + } + set(paymentMethods) { + _paymentMethods = paymentMethods + paymentMethodsRetrievedDate = paymentMethods != nil ? Date() : nil + } + } + @objc internal var paymentMethodsRetrievedDate: Date? + private var keyManager: STPEphemeralKeyManagerProtocol + private var apiClient: STPAPIClient + + init( + keyManager: STPEphemeralKeyManagerProtocol, + apiClient: STPAPIClient + ) { + STPAnalyticsClient.sharedClient.addClass(toProductUsageIfNecessary: STPCustomerContext.self) + self.keyManager = keyManager + self.apiClient = apiClient + _includeApplePayPaymentMethods = false + super.init() + retrieveCustomer(nil) + listPaymentMethodsForCustomer(completion: nil) + } + + func clearCachedCustomer() { + customer = nil + } + + func clearCachedPaymentMethods() { + paymentMethods = nil + } + + func shouldUseCachedCustomer() -> Bool { + if customer == nil || customerRetrievedDate == nil { + return false + } + let now = Date() + if let customerRetrievedDate = customerRetrievedDate { + return now.timeIntervalSince(customerRetrievedDate) < CachedCustomerMaxAge + } + return false + } + + func shouldUseCachedPaymentMethods() -> Bool { + if paymentMethods == nil || paymentMethodsRetrievedDate == nil { + return false + } + let now = Date() + if let paymentMethodsRetrievedDate = paymentMethodsRetrievedDate { + return now.timeIntervalSince(paymentMethodsRetrievedDate) < CachedCustomerMaxAge + } + return false + } + + // MARK: - STPBackendAPIAdapter + @objc + public func retrieveCustomer(_ completion: STPCustomerCompletionBlock? = nil) { + if shouldUseCachedCustomer() { + if let completion = completion { + stpDispatchToMainThreadIfNecessary({ + completion(self.customer, nil) + }) + } + return + } + keyManager.getOrCreateKey({ ephemeralKey, retrieveKeyError in + guard let ephemeralKey = ephemeralKey, retrieveKeyError == nil else { + if let completion = completion { + stpDispatchToMainThreadIfNecessary({ + completion(nil, retrieveKeyError) + }) + } + return + } + self.apiClient.retrieveCustomer(using: ephemeralKey) { customer, error in + if let customer = customer { + customer.updateSources(filteringApplePay: !self.includeApplePayPaymentMethods) + self.customer = customer + } + if let completion = completion { + stpDispatchToMainThreadIfNecessary({ + completion(self.customer, error) + }) + } + } + }) + } + + @objc + public func updateCustomer( + withShippingAddress shipping: STPAddress, + completion: STPErrorBlock? + ) { + keyManager.getOrCreateKey({ ephemeralKey, retrieveKeyError in + guard let ephemeralKey = ephemeralKey, retrieveKeyError == nil else { + if let completion = completion { + stpDispatchToMainThreadIfNecessary({ + completion(retrieveKeyError) + }) + } + return + } + var params: [String: Any] = [:] + params["shipping"] = STPAddress.shippingInfoForCharge( + with: shipping, + shippingMethod: nil + ) + self.apiClient.updateCustomer( + withParameters: params, + using: ephemeralKey + ) { customer, error in + if let customer = customer { + customer.updateSources(filteringApplePay: !self.includeApplePayPaymentMethods) + self.customer = customer + } + if let completion = completion { + stpDispatchToMainThreadIfNecessary({ + completion(error) + }) + } + } + }) + } + + /// A convenience method for attaching the PaymentMethod to the current Customer + @objc + public func attachPaymentMethodToCustomer( + paymentMethodId: String, + completion: STPErrorBlock? + ) { + keyManager.getOrCreateKey({ ephemeralKey, retrieveKeyError in + guard let ephemeralKey = ephemeralKey, retrieveKeyError == nil else { + if let completion = completion { + stpDispatchToMainThreadIfNecessary({ + completion(retrieveKeyError) + }) + } + return + } + + self.apiClient.attachPaymentMethod( + paymentMethodId, + toCustomerUsing: ephemeralKey + ) { error in + self.clearCachedPaymentMethods() + if let completion = completion { + stpDispatchToMainThreadIfNecessary({ + completion(error) + }) + } + } + }) + } + + @objc + public func attachPaymentMethod( + toCustomer paymentMethod: STPPaymentMethod, + completion: STPErrorBlock? + ) { + attachPaymentMethodToCustomer( + paymentMethodId: paymentMethod.stripeId, + completion: completion + ) + } + + /// A convenience method for detaching the PaymentMethod to the current Customer + @objc + public func detachPaymentMethodFromCustomer( + paymentMethodId: String, + completion: STPErrorBlock? + ) { + keyManager.getOrCreateKey({ ephemeralKey, retrieveKeyError in + guard let ephemeralKey = ephemeralKey, retrieveKeyError == nil else { + if let completion = completion { + stpDispatchToMainThreadIfNecessary({ + completion(retrieveKeyError) + }) + } + return + } + + self.apiClient.detachPaymentMethod( + paymentMethodId, + fromCustomerUsing: ephemeralKey + ) { error in + self.clearCachedPaymentMethods() + if let completion = completion { + stpDispatchToMainThreadIfNecessary({ + completion(error) + }) + } + } + }) + + } + + @objc + public func detachPaymentMethod( + fromCustomer paymentMethod: STPPaymentMethod, + completion: STPErrorBlock? + ) { + detachPaymentMethodFromCustomer( + paymentMethodId: paymentMethod.stripeId, + completion: completion + ) + } + + @objc + public func listPaymentMethodsForCustomer(completion: STPPaymentMethodsCompletionBlock? = nil) { + if shouldUseCachedPaymentMethods() { + if let completion = completion { + stpDispatchToMainThreadIfNecessary({ + completion(self.paymentMethods, nil) + }) + } + return + } + + keyManager.getOrCreateKey({ ephemeralKey, retrieveKeyError in + guard let ephemeralKey = ephemeralKey, retrieveKeyError == nil else { + if let completion = completion { + stpDispatchToMainThreadIfNecessary({ + completion(nil, retrieveKeyError) + }) + } + return + } + + self.apiClient.listPaymentMethodsForCustomer(using: ephemeralKey) { + paymentMethods, + error in + if paymentMethods != nil { + self.paymentMethods = paymentMethods + } + if let completion = completion { + stpDispatchToMainThreadIfNecessary({ + completion(self.paymentMethods, error) + }) + } + } + }) + } + + func saveLastSelectedPaymentMethodID( + forCustomer paymentMethodID: String?, + completion: STPErrorBlock? + ) { + keyManager.getOrCreateKey({ ephemeralKey, retrieveKeyError in + guard let ephemeralKey = ephemeralKey, retrieveKeyError == nil else { + if let completion = completion { + stpDispatchToMainThreadIfNecessary({ + completion(retrieveKeyError) + }) + } + return + } + + var customerToDefaultPaymentMethodID = + (UserDefaults.standard.dictionary(forKey: kLastSelectedPaymentMethodDefaultsKey)) + as? [String: String] ?? [:] + if let customerID = ephemeralKey.customerID { + customerToDefaultPaymentMethodID[customerID] = paymentMethodID + UserDefaults.standard.set( + customerToDefaultPaymentMethodID, + forKey: kLastSelectedPaymentMethodDefaultsKey + ) + } + + if let completion = completion { + stpDispatchToMainThreadIfNecessary({ + completion(nil) + }) + } + }) + } + + func retrieveLastSelectedPaymentMethodIDForCustomer( + completion: @escaping (String?, Error?) -> Void + ) { + keyManager.getOrCreateKey({ ephemeralKey, retrieveKeyError in + guard let ephemeralKey = ephemeralKey, retrieveKeyError == nil else { + stpDispatchToMainThreadIfNecessary({ + completion(nil, retrieveKeyError) + }) + return + } + + let customerToDefaultPaymentMethodID = + (UserDefaults.standard.dictionary(forKey: kLastSelectedPaymentMethodDefaultsKey)) + as? [String: String] ?? [:] + stpDispatchToMainThreadIfNecessary({ + completion(customerToDefaultPaymentMethodID[ephemeralKey.customerID ?? ""], nil) + }) + }) + } +} + +/// Stores the key we use in NSUserDefaults to save a dictionary of Customer id to their last selected payment method ID +private let kLastSelectedPaymentMethodDefaultsKey = + UserDefaults.StripeKeys.customerToLastSelectedPaymentMethod.rawValue +private let CachedCustomerMaxAge: TimeInterval = 60 + +/// :nodoc: +@_spi(STP) extension STPCustomerContext: STPAnalyticsProtocol { + @_spi(STP) public static var stp_analyticsIdentifier = "STPCustomerContext" +} diff --git a/Stripe/StripeiOS/Source/STPEphemeralKey.swift b/Stripe/StripeiOS/Source/STPEphemeralKey.swift new file mode 100644 index 00000000..6ddf10b6 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPEphemeralKey.swift @@ -0,0 +1,104 @@ +// +// STPEphemeralKey.swift +// StripeiOS +// +// Created by Ben Guo on 5/4/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripePayments + +class STPEphemeralKey: NSObject, STPAPIResponseDecodable { + private(set) var stripeID: String + private(set) var created: Date + private(set) var livemode = false + private(set) var secret: String + private(set) var expires: Date + private(set) var customerID: String? + private(set) var issuingCardID: String? + + /// You cannot directly instantiate an `STPEphemeralKey`. You should instead use + /// `decodedObjectFromAPIResponse:` to create a key from a JSON response. + required init( + stripeID: String, + created: Date, + secret: String, + expires: Date + ) { + self.stripeID = stripeID + self.created = created + self.secret = secret + self.expires = expires + super.init() + } + + private(set) var allResponseFields: [AnyHashable: Any] = [:] + + class func decodedObject(fromAPIResponse response: [AnyHashable: Any]?) -> Self? { + guard let response = response else { + return nil + } + let dict = response.stp_dictionaryByRemovingNulls() + + // required fields + guard + let stripeId = dict.stp_string(forKey: "id"), + let created = dict.stp_date(forKey: "created"), + let secret = dict.stp_string(forKey: "secret"), + let expires = dict.stp_date(forKey: "expires"), + let associatedObjects = dict.stp_array(forKey: "associated_objects"), + dict["livemode"] != nil + else { + return nil + } + + var customerID: String? + var issuingCardID: String? + for obj in associatedObjects { + if let obj = obj as? [AnyHashable: Any] { + let type = obj.stp_string(forKey: "type") + if type == "customer" { + customerID = obj.stp_string(forKey: "id") + } + if type == "issuing.card" { + issuingCardID = obj.stp_string(forKey: "id") + } + } + } + if customerID == nil && issuingCardID == nil { + return nil + } + let key = self.init(stripeID: stripeId, created: created, secret: secret, expires: expires) + key.customerID = customerID + key.issuingCardID = issuingCardID + key.stripeID = stripeId + key.livemode = dict.stp_bool(forKey: "livemode", or: true) + key.created = created + key.secret = secret + key.expires = expires + key.allResponseFields = response + return key + } + + override var hash: Int { + return stripeID.hash + } + + override func isEqual(_ object: Any?) -> Bool { + if self === (object as? STPEphemeralKey) { + return true + } + if object == nil || !(object is STPEphemeralKey) { + return false + } + if let object = object as? STPEphemeralKey { + return isEqual(to: object) + } + return false + } + + func isEqual(to other: STPEphemeralKey) -> Bool { + return stripeID == other.stripeID + } +} diff --git a/Stripe/StripeiOS/Source/STPEphemeralKeyManager.swift b/Stripe/StripeiOS/Source/STPEphemeralKeyManager.swift new file mode 100644 index 00000000..b9955c98 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPEphemeralKeyManager.swift @@ -0,0 +1,179 @@ +// +// STPEphemeralKeyManager.swift +// StripeiOS +// +// Created by Ben Guo on 5/9/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripePaymentsUI +import UIKit + +protocol STPEphemeralKeyManagerProtocol { + /// If the retriever's stored ephemeral key has not expired, it will be + /// returned immediately to the given callback. If the stored key is expiring, a + /// new key will be requested from the key provider, and returned to the callback. + /// If the retriever is unable to provide an unexpired key, an error will be returned. + /// - Parameter completion: The callback to be run with the returned key, or an error. + func getOrCreateKey(_ completion: @escaping STPEphemeralKeyCompletionBlock) +} + +typealias STPEphemeralKeyCompletionBlock = (STPEphemeralKey?, Error?) -> Void +class STPEphemeralKeyManager: NSObject, STPEphemeralKeyManagerProtocol { + private var _expirationInterval: TimeInterval = 0.0 + /// If the current ephemeral key expires in less than this time interval, a call + /// to `getOrCreateKey` will request a new key from the manager's key provider. + /// The maximum allowed value is one hour – higher values will be clamped. + var expirationInterval: TimeInterval { + get { + _expirationInterval + } + set(expirationInterval) { + _expirationInterval = TimeInterval(min(expirationInterval, 60 * 60)) + } + } + /// If this value is YES, the manager will eagerly refresh its key on app foregrounding. + private(set) var performsEagerFetching = false + + /// Initializes a new `STPEphemeralKeyManager` with the specified key provider. + /// - Parameters: + /// - keyProvider: The key provider the manager will use. + /// - apiVersion: The Stripe API version the manager will use. + /// - performsEagerFetching: If the manager should eagerly refresh its key on app foregrounding. + /// - Returns: the newly-initiated `STPEphemeralKeyManager`. + @objc init( + keyProvider: Any?, + apiVersion: String, + performsEagerFetching: Bool + ) { + super.init() + assert( + keyProvider is STPCustomerEphemeralKeyProvider + || keyProvider is STPIssuingCardEphemeralKeyProvider, + "Your STPEphemeralKeyProvider must either implement `STPCustomerEphemeralKeyProvider` or `STPIssuingCardEphemeralKeyProvider`." + ) + expirationInterval = DefaultExpirationInterval + self.keyProvider = keyProvider + self.apiVersion = apiVersion + self.performsEagerFetching = performsEagerFetching + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillForegroundNotification), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + @objc dynamic func getOrCreateKey(_ completion: @escaping STPEphemeralKeyCompletionBlock) { + if currentKeyIsUnexpired() { + completion(ephemeralKey, nil) + } else { + if let createKeyPromise = createKeyPromise { + // coalesce repeated calls into one request + createKeyPromise.onSuccess({ key in + completion(key, nil) + }).onFailure({ error in + completion(nil, error) + }) + } else { + createKeyPromise = STPPromise.init().onSuccess({ key in + self.ephemeralKey = key + completion(key, nil) + }).onFailure({ error in + completion(nil, error) + }) + _createKey() + } + } + } + + @objc internal var ephemeralKey: STPEphemeralKey? + private var apiVersion: String? + private var keyProvider: Any? + @objc internal var lastEagerKeyRefresh: Date? + private var createKeyPromise: STPPromise? + + deinit { + NotificationCenter.default.removeObserver( + self, + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + func currentKeyIsUnexpired() -> Bool { + return ephemeralKey != nil + && (ephemeralKey?.expires.timeIntervalSinceNow ?? 0.0) > expirationInterval + } + + func shouldPerformEagerRefresh() -> Bool { + return performsEagerFetching + && (lastEagerKeyRefresh == nil + || (lastEagerKeyRefresh?.timeIntervalSinceNow ?? 0.0) > MinEagerRefreshInterval) + } + + @objc func handleWillForegroundNotification() { + // To make sure we don't end up hitting the ephemeral keys endpoint on every + // foreground (e.g. if there's an issue decoding the ephemeral key), throttle + // eager refreshes to once per hour. + if !currentKeyIsUnexpired() && shouldPerformEagerRefresh() { + lastEagerKeyRefresh = Date() + getOrCreateKey({ _, _ in + // getOrCreateKey sets the self.ephemeralKey. Nothing left to do for us here + }) + } + } + + func _createKey() { + let jsonCompletion = + { (jsonResponse: [AnyHashable: Any]?, error: Error?) in + let key = STPEphemeralKey.decodedObject(fromAPIResponse: jsonResponse) + if let key = key { + self.createKeyPromise?.succeed(key) + } else { + // the API request failed + if let error = error { + self.createKeyPromise?.fail(error) + } else { + // the ephemeral key could not be decoded + self.createKeyPromise?.fail(NSError.stp_ephemeralKeyDecodingError()) + if self.keyProvider is STPCustomerEphemeralKeyProvider { + assert( + false, + "Could not parse the ephemeral key response following protocol STPCustomerEphemeralKeyProvider. Make sure your backend is sending the unmodified JSON of the ephemeral key to your app. For more info, see https://stripe.com/docs/mobile/ios/basic#prepare-your-api" + ) + } else if self.keyProvider is STPIssuingCardEphemeralKeyProvider { + assert( + false, + "Could not parse the ephemeral key response following protocol STPIssuingCardEphemeralKeyProvider. Make sure your backend is sending the unmodified JSON of the ephemeral key to your app. For more info, see https://stripe.com/docs/mobile/ios/basic#prepare-your-api" + ) + } + assert( + false, + "Could not parse the ephemeral key response. Make sure your backend is sending the unmodified JSON of the ephemeral key to your app. For more info, see https://stripe.com/docs/mobile/ios/basic#prepare-your-api" + ) + } + } + self.createKeyPromise = nil + } as STPJSONResponseCompletionBlock + + if keyProvider is STPCustomerEphemeralKeyProvider { + weak var provider = keyProvider as? STPCustomerEphemeralKeyProvider + provider?.createCustomerKey( + withAPIVersion: apiVersion ?? "", + completion: jsonCompletion + ) + } else if keyProvider is STPIssuingCardEphemeralKeyProvider { + weak var provider = keyProvider as? STPIssuingCardEphemeralKeyProvider + provider?.createIssuingCardKey( + withAPIVersion: apiVersion ?? "", + completion: jsonCompletion + ) + } + } +} + +private let DefaultExpirationInterval: TimeInterval = 60 +private let MinEagerRefreshInterval: TimeInterval = 60 * 60 diff --git a/Stripe/StripeiOS/Source/STPEphemeralKeyProvider.swift b/Stripe/StripeiOS/Source/STPEphemeralKeyProvider.swift new file mode 100644 index 00000000..9bac4bb2 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPEphemeralKeyProvider.swift @@ -0,0 +1,74 @@ +// +// STPEphemeralKeyProvider.swift +// StripeiOS +// +// Created by Ben Guo on 5/9/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// You should make your application's API client conform to this interface. +/// It provides a way for Stripe utility classes to request a new ephemeral key from +/// your backend, which it will use to retrieve and update Stripe API objects. +@objc public protocol STPCustomerEphemeralKeyProvider: NSObjectProtocol { + /// Creates a new ephemeral key for retrieving and updating a Stripe customer. + /// On your backend, you should create a new ephemeral key for the Stripe customer + /// associated with your user, and return the raw JSON response from the Stripe API. + /// For an example Ruby implementation of this API, refer to our example backend: + /// https://github.com/stripe/example-mobile-backend/blob/v18.1.0/web.rb + /// Back in your iOS app, once you have a response from this API, call the provided + /// completion block with the JSON response, or an error if one occurred. + /// - Parameters: + /// - apiVersion: The Stripe API version to use when creating a key. + /// You should pass this parameter to your backend, and use it to set the API version + /// in your key creation request. Passing this version parameter ensures that the + /// Stripe SDK can always parse the ephemeral key response from your server. + /// - completion: Call this callback when you're done fetching a new ephemeral + /// key from your backend. For example, `completion(json, nil)` (if your call succeeds) + /// or `completion(nil, error)` if an error is returned. + @objc(createCustomerKeyWithAPIVersion:completion:) func createCustomerKey( + withAPIVersion apiVersion: String, + completion: @escaping STPJSONResponseCompletionBlock + ) +} + +/// You should make your application's API client conform to this interface. +/// It provides a way for Stripe utility classes to request a new ephemeral key from +/// your backend, which it will use to retrieve and update Stripe API objects. +@objc public protocol STPIssuingCardEphemeralKeyProvider: NSObjectProtocol { + /// Creates a new ephemeral key for retrieving and updating a Stripe Issuing Card. + /// On your backend, you should create a new ephemeral key for your logged-in user's + /// primary Issuing Card, and return the raw JSON response from the Stripe API. + /// For an example Ruby implementation of this API, refer to our example backend: + /// https://github.com/stripe/example-mobile-backend/blob/v18.1.0/web.rb + /// Back in your iOS app, once you have a response from this API, call the provided + /// completion block with the JSON response, or an error if one occurred. + /// - Parameters: + /// - apiVersion: The Stripe API version to use when creating a key. + /// You should pass this parameter to your backend, and use it to set the API version + /// in your key creation request. Passing this version parameter ensures that the + /// Stripe SDK can always parse the ephemeral key response from your server. + /// - completion: Call this callback when you're done fetching a new ephemeral + /// key from your backend. For example, `completion(json, nil)` (if your call succeeds) + /// or `completion(nil, error)` if an error is returned. + @objc(createIssuingCardKeyWithAPIVersion:completion:) func createIssuingCardKey( + withAPIVersion apiVersion: String, + completion: @escaping STPJSONResponseCompletionBlock + ) +} + +/// You should make your application's API client conform to this interface. +/// It provides a way for Stripe utility classes to request a new ephemeral key from +/// your backend, which it will use to retrieve and update Stripe API objects. +/// @deprecated use `STPCustomerEphemeralKeyProvider` or `STPIssuingCardEphemeralKeyProvider` +/// depending on the type of key that will@objc be fetched. + +@available( + *, + deprecated, + message: + "use `STPCustomerEphemeralKeyProvider` or `STPIssuingCardEphemeralKeyProvider` depending on the type of key that will be fetched." +) +@objc public protocol STPEphemeralKeyProvider: STPCustomerEphemeralKeyProvider { +} diff --git a/Stripe/StripeiOS/Source/STPFPXBankStatusResponse.swift b/Stripe/StripeiOS/Source/STPFPXBankStatusResponse.swift new file mode 100644 index 00000000..73a2ff66 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPFPXBankStatusResponse.swift @@ -0,0 +1,44 @@ +// +// STPFPXBankStatusResponse.swift +// StripeiOS +// +// Created by David Estes on 10/21/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripePayments + +class STPFPXBankStatusResponse: NSObject, STPAPIResponseDecodable { + func bankBrandIsOnline(_ bankBrand: STPFPXBankBrand) -> Bool { + let bankCode = STPFPXBank.bankCodeFrom(bankBrand, false) + let bankStatus = bankList?[bankCode ?? ""] + if bankCode != nil && bankStatus != nil { + return bankStatus?.boolValue ?? false + } + // This status endpoint isn't reliable. If we don't know this bank's status, default to online. + // The worst that will happen here is that the user ends up at their bank's "Down For Maintenance" page when checking out. + return true + } + + private var bankList: [String: NSNumber]? + private(set) var allResponseFields: [AnyHashable: Any] = [:] + + required internal override init() { + super.init() + } + + class func decodedObject(fromAPIResponse response: [AnyHashable: Any]?) -> Self? { + guard let response = response else { + return nil + } + let dict = response.stp_dictionaryByRemovingNulls() + + let statusResponse = self.init() + statusResponse.bankList = + dict.stp_dictionary(forKey: "parsed_bank_status") as? [String: NSNumber] + statusResponse.allResponseFields = dict + + return statusResponse + } +} diff --git a/Stripe/StripeiOS/Source/STPFakeAddPaymentPassViewController.swift b/Stripe/StripeiOS/Source/STPFakeAddPaymentPassViewController.swift new file mode 100644 index 00000000..34f553ac --- /dev/null +++ b/Stripe/StripeiOS/Source/STPFakeAddPaymentPassViewController.swift @@ -0,0 +1,279 @@ +// +// STPFakeAddPaymentPassViewController.swift +// StripeiOS +// +// Created by Jack Flintermann on 9/28/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +import PassKit +@_spi(STP) import StripeCore +import UIKit + +/// This class is a piece of fake UI that is intended to mimic `PKAddPaymentPassViewController`. That class is restricted to apps with a special entitlement from Apple, and as such can be difficult to build and test against. This class implements the same public API as `PKAddPaymentPassViewController`, and can be used to develop against the Stripe API in *testmode only*. (Obviously it will not actually place cards into the user's Apple Pay wallet either.) When it's time to go to production, you may simply replace all references to `STPFakeAddPaymentPassViewController` in your app with `PKAddPaymentPassViewController` and it will continue to function. For more information on developing against this API, please see https://stripe.com/docs/issuing/cards/digital-wallets . +public class STPFakeAddPaymentPassViewController: UIViewController { + /// @see PKAddPaymentPassViewController + @objc + public class func canAddPaymentPass() -> Bool { + return true + } + + /// @see PKAddPaymentPassViewController + @objc(initWithRequestConfiguration:delegate:) + public required init?( + requestConfiguration configuration: PKAddPaymentPassRequestConfiguration, + delegate: PKAddPaymentPassViewControllerDelegate? + ) { + super.init(nibName: nil, bundle: nil) + assert(delegate != nil, "Invalid parameter not satisfying: delegate != nil") + state = .initial + self.delegate = delegate + self.configuration = configuration + if configuration.primaryAccountSuffix == nil && configuration.cardholderName == nil { + assert( + false, + "Your PKAddPaymentPassRequestConfiguration must provide either a cardholderName or a primaryAccountSuffix." + ) + } + } + + /// @see PKAddPaymentPassViewController + @objc public weak var delegate: PKAddPaymentPassViewControllerDelegate? + private var configuration: PKAddPaymentPassRequestConfiguration? + + private var _state: STPFakeAddPaymentPassViewControllerState = .initial + private var state: STPFakeAddPaymentPassViewControllerState { + get { + _state + } + set(state) { + _state = state + let cancelItem = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: self, + action: #selector(cancel(_:)) + ) + let nextButton = UIButton(type: .system) + nextButton.addTarget(self, action: #selector(next(_:)), for: .touchUpInside) + let indicatorView = UIActivityIndicatorView(style: .medium) + indicatorView.startAnimating() + let loadingItem = UIBarButtonItem(customView: indicatorView) + nextButton.setTitle(STPNonLocalizedString("Next"), for: .normal) + let nextItem = UIBarButtonItem(customView: nextButton) + let doneItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(done(_:)) + ) + + switch state { + case .initial: + contentLabel?.text = STPNonLocalizedString( + "This class simulates the delegate methods that PKAddPaymentPassViewController will call in your app. Press next to continue." + ) + navigationItem.leftBarButtonItem = cancelItem + navigationItem.rightBarButtonItem = nextItem + case .loading: + contentLabel?.text = STPNonLocalizedString("Fetching encrypted card details...") + cancelItem.isEnabled = false + navigationItem.leftBarButtonItem = cancelItem + navigationItem.rightBarButtonItem = loadingItem + case .error: + contentLabel?.text = STPNonLocalizedString("Error: " + (errorText ?? "")) + doneItem.isEnabled = false + navigationItem.leftBarButtonItem = cancelItem + navigationItem.rightBarButtonItem = doneItem + case .success: + contentLabel?.text = STPNonLocalizedString( + "Success! In production, your card would now have been added to your Apple Pay wallet. Your app's success callback will be triggered when the user presses 'Done'." + ) + cancelItem.isEnabled = false + navigationItem.leftBarButtonItem = cancelItem + navigationItem.rightBarButtonItem = doneItem + } + } + } + private var contentLabel: UILabel? + private var errorText: String? + + /// :nodoc: + @objc public convenience override init( + nibName nibNameOrNil: String?, + bundle nibBundleOrNil: Bundle? + ) { + self.init(requestConfiguration: PKAddPaymentPassRequestConfiguration(), delegate: nil)! + } + + /// :nodoc: + @objc public required convenience init?( + coder aDecoder: NSCoder + ) { + self.init(requestConfiguration: PKAddPaymentPassRequestConfiguration(), delegate: nil) + } + + /// :nodoc: + @objc + public override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor.white + + let navBar = UINavigationBar() + view.addSubview(navBar) + navBar.isTranslucent = false + navBar.backgroundColor = UIColor.white + navBar.items = [navigationItem] + navBar.translatesAutoresizingMaskIntoConstraints = false + navBar.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true + navBar.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true + navBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true + + let contentLabel = UILabel(frame: CGRect.zero) + self.contentLabel = contentLabel + contentLabel.textAlignment = .center + contentLabel.textColor = UIColor.black + contentLabel.numberOfLines = 0 + contentLabel.font = UIFont.systemFont(ofSize: 18) + contentLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(contentLabel) + contentLabel.topAnchor.constraint(equalTo: navBar.bottomAnchor).isActive = true + contentLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 10.0).isActive = true + contentLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -10.0).isActive = + true + contentLabel.heightAnchor.constraint(equalToConstant: 150).isActive = true + + var pairs: [AnyHashable] = [] + if configuration?.cardholderName != nil { + pairs.append(["Name", configuration?.cardholderName]) + } + if configuration?.primaryAccountSuffix != nil { + pairs.append([ + "Card Number", + "···· \(configuration?.primaryAccountSuffix ?? "")", + ]) + } + var rows: [AnyHashable] = [] + for pair in pairs { + guard let pair = pair as? [AnyHashable] else { + continue + } + let left = UILabel() + left.text = pair[0] as? String + left.textAlignment = .left + left.font = UIFont.boldSystemFont(ofSize: 16) + let right = UILabel() + right.text = pair[1] as? String + right.textAlignment = .left + right.textColor = UIColor.lightGray + let row = UIStackView(arrangedSubviews: [left, right]) + row.axis = .horizontal + row.distribution = .fillEqually + row.alignment = .fill + row.translatesAutoresizingMaskIntoConstraints = false + rows.append(row) + } + var pairsTable: UIStackView? + if let rows = rows as? [UIView] { + pairsTable = UIStackView(arrangedSubviews: rows) + } + pairsTable?.isLayoutMarginsRelativeArrangement = true + pairsTable?.layoutMargins = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + pairsTable?.axis = .vertical + pairsTable?.translatesAutoresizingMaskIntoConstraints = false + if let pairsTable = pairsTable { + view.addSubview(pairsTable) + } + + pairsTable?.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true + pairsTable?.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true + pairsTable?.topAnchor.constraint(equalTo: contentLabel.bottomAnchor).isActive = true + pairsTable?.heightAnchor.constraint(equalToConstant: CGFloat((rows.count * 50))).isActive = + true + state = .initial + } + + @objc func cancel(_ sender: Any?) { + let castedVC = unsafeBitCast(self, to: PKAddPaymentPassViewController.self) + delegate?.addPaymentPassViewController( + castedVC, + didFinishAdding: nil, + error: NSError( + domain: PKPassKitErrorDomain, + code: PKAddPaymentPassError.userCancelled.rawValue, + userInfo: nil + ) + ) + } + + @objc func next(_ sender: Any?) { + state = .loading + let certificates = [ + "cert1".data(using: .utf8), + "cert2".data(using: .utf8), + ] + let nonce = "nonce".data(using: .utf8) + let nonceSignature = "nonceSignature".data(using: .utf8) + + DispatchQueue.main.asyncAfter( + deadline: DispatchTime.now() + Double(Int64(10 * Double(NSEC_PER_SEC))) + / Double(NSEC_PER_SEC), + execute: { + if self.state == .loading { + self.errorText = + "You exceeded the timeout of 10 seconds to call the request completion handler. Please check your PKAddPaymentPassViewControllerDelegate implementation, and make sure you are calling the `completionHandler` in `addPaymentPassViewController:generateRequestWithCertificateChain:nonce:nonceSignature:completionHandler`." + self.state = .error + } + } + ) + if let nonce = nonce, let nonceSignature = nonceSignature { + let castedVC = unsafeBitCast(self, to: PKAddPaymentPassViewController.self) + delegate?.addPaymentPassViewController( + castedVC, + generateRequestWithCertificateChain: certificates.compactMap { $0 }, + nonce: nonce, + nonceSignature: nonceSignature, + completionHandler: { request in + if self.state == .loading { + var contents: String? + if request.encryptedPassData != nil { + if let encryptedPassData1 = request.encryptedPassData { + contents = String(data: encryptedPassData1, encoding: .utf8) + } + } + if request.stp_error != nil { + var error = + (request.stp_error as NSError?)?.userInfo[STPError.errorMessageKey] + as? String + if error == nil { + error = + (request.stp_error as NSError?)?.userInfo[ + NSLocalizedDescriptionKey + ] as? String + } + self.errorText = error + self.state = .error + } else if contents == "TESTMODE_CONTENTS" { + self.state = .success + } else { + self.errorText = + "Your server response contained the wrong encrypted card details. Please ensure that you are not modifying the response from the Stripe API in any way, and that your request is in testmode." + self.state = .error + } + } + } + ) + } + } + + @objc func done(_ sender: Any?) { + let pass = PKPaymentPass() + let castedVC = unsafeBitCast(self, to: PKAddPaymentPassViewController.self) + delegate?.addPaymentPassViewController(castedVC, didFinishAdding: pass, error: nil) + } +} + +enum STPFakeAddPaymentPassViewControllerState: Int { + case initial + case loading + case error + case success +} diff --git a/Stripe/StripeiOS/Source/STPImageLibrary.swift b/Stripe/StripeiOS/Source/STPImageLibrary.swift new file mode 100644 index 00000000..dac95afb --- /dev/null +++ b/Stripe/StripeiOS/Source/STPImageLibrary.swift @@ -0,0 +1,94 @@ +// +// STPImageLibrary.swift +// StripeiOS +// +// Created by Jack Flintermann on 6/30/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripePaymentsUI +@_spi(STP) import StripeUICore +import UIKit + +/// This class lets you access card icons used by the Stripe SDK. All icons are 32 x 20 points. +@objc class STPLegacyImageLibrary: NSObject { + + /// This returns the appropriate icon for the specified bank brand. + @objc(brandImageForFPXBankBrand:) public class func fpxBrandImage( + for brand: STPFPXBankBrand + ) + -> UIImage + { + let imageName = "stp_bank_fpx_\(STPFPXBank.identifierFrom(brand) ?? "")" + let image = self.safeImageNamed( + imageName, + templateIfAvailable: false + ) + return image + } + + /// An icon representing FPX. + @objc + public class func fpxLogo() -> UIImage { + return self.safeImageNamed("stp_fpx_logo", templateIfAvailable: false) + } + + /// A large branding image for FPX. + @objc + public class func largeFpxLogo() -> UIImage { + return self.safeImageNamed("stp_fpx_big_logo", templateIfAvailable: false) + } + + @objc class func addIcon() -> UIImage { + return self.safeImageNamed("stp_icon_add", templateIfAvailable: true) + } + + @objc class func checkmarkIcon() -> UIImage { + return self.safeImageNamed("stp_icon_checkmark", templateIfAvailable: true) + } + + @objc class func largeCardFrontImage() -> UIImage { + return self.safeImageNamed("stp_card_form_front", templateIfAvailable: true) + } + + @objc class func largeCardBackImage() -> UIImage { + return self.safeImageNamed("stp_card_form_back", templateIfAvailable: true) + } + + @objc class func largeCardAmexCVCImage() -> UIImage { + return self.safeImageNamed("stp_card_form_amex_cvc", templateIfAvailable: true) + } + + @objc class func largeShippingImage() -> UIImage { + return self.safeImageNamed("stp_shipping_form", templateIfAvailable: true) + } + + class func image( + withTintColor color: UIColor, + for image: UIImage + ) -> UIImage { + var newImage: UIImage? + UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale) + color.set() + let templateImage = image.withRenderingMode(.alwaysTemplate) + templateImage.draw( + in: CGRect( + x: 0, + y: 0, + width: templateImage.size.width, + height: templateImage.size.height + ) + ) + newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return newImage ?? image + } +} + +// MARK: - ImageMaker + +// :nodoc: +@_spi(STP) extension STPLegacyImageLibrary: ImageMaker { + @_spi(STP) public typealias BundleLocator = StripeBundleLocator +} diff --git a/Stripe/StripeiOS/Source/STPIntentActionLinkAuthenticateAccount.swift b/Stripe/StripeiOS/Source/STPIntentActionLinkAuthenticateAccount.swift new file mode 100644 index 00000000..be029dbe --- /dev/null +++ b/Stripe/StripeiOS/Source/STPIntentActionLinkAuthenticateAccount.swift @@ -0,0 +1,33 @@ +// +// STPIntentActionLinkAuthenticateAccount.swift +// StripeiOS +// +// Created by Cameron Sabol on 7/6/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +class STPIntentActionLinkAuthenticateAccount: NSObject { + + let allResponseFields: [AnyHashable: Any] + + required init( + _ allResponseFields: [AnyHashable: Any] + ) { + self.allResponseFields = allResponseFields + super.init() + } + +} + +/// :nodoc: +extension STPIntentActionLinkAuthenticateAccount: STPAPIResponseDecodable { + class func decodedObject(fromAPIResponse response: [AnyHashable: Any]?) -> Self? { + guard let response = response else { + return nil + } + + return STPIntentActionLinkAuthenticateAccount(response) as? Self + } +} diff --git a/Stripe/StripeiOS/Source/STPLocalizedString.swift b/Stripe/StripeiOS/Source/STPLocalizedString.swift new file mode 100644 index 00000000..76afd007 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPLocalizedString.swift @@ -0,0 +1,16 @@ +// +// STPLocalizedString.swift +// StripeiOS +// +// Created by David Estes on 10/19/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore + +@inline(__always) func STPLocalizedString(_ key: String, _ comment: String?) -> String { + return STPLocalizationUtils.localizedStripeString( + forKey: key, + bundleLocator: StripeBundleLocator.self + ) +} diff --git a/Stripe/StripeiOS/Source/STPPaymentActivityIndicatorView.swift b/Stripe/StripeiOS/Source/STPPaymentActivityIndicatorView.swift new file mode 100644 index 00000000..68c03b6e --- /dev/null +++ b/Stripe/StripeiOS/Source/STPPaymentActivityIndicatorView.swift @@ -0,0 +1,135 @@ +// +// STPPaymentActivityIndicatorView.swift +// StripeiOS +// +// Created by Jack Flintermann on 5/12/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import UIKit + +/// This class can be used wherever you'd use a `UIActivityIndicatorView` and is intended to have a similar API. It renders as a spinning circle with a gap in it, similar to what you see in the App Store app or in the Apple Pay dialog when making a purchase. To change its color, set the `tintColor` property. +public class STPPaymentActivityIndicatorView: UIView { + /// Tell the view to start or stop spinning. If `hidesWhenStopped` is true, it will fade in/out if animated is true. + @objc + public func setAnimating( + _ animating: Bool, + animated: Bool + ) { + if animating == _animating { + return + } + _animating = animating + if animating { + if hidesWhenStopped { + UIView.animate( + withDuration: animated ? 0.2 : 0, + animations: { + self.alpha = 1.0 + } + ) + } + var currentRotation = Double(0) + if let currentLayer = layer.presentation() { + currentRotation = Double( + truncating: (currentLayer.value(forKeyPath: "transform.rotation.z") as! NSNumber) + ) + } + let animation = CABasicAnimation(keyPath: "transform.rotation.z") + animation.fromValue = NSNumber(value: Float(currentRotation)) + let toValue = NSNumber(value: currentRotation + 2 * Double.pi) + animation.toValue = toValue + animation.duration = 1.0 + animation.repeatCount = Float.infinity + layer.add(animation, forKey: "rotation") + } else { + if hidesWhenStopped { + UIView.animate( + withDuration: animated ? 0.2 : 0, + animations: { + self.alpha = 0.0 + } + ) + } + } + } + + private var _animating = false + /// Whether or not the view is animating. + @objc public var animating: Bool { + get { + _animating + } + set(animating) { + setAnimating(animating, animated: false) + } + } + + private var _hidesWhenStopped = true + /// If true, the view will hide when it is not spinning. Default is true. + @objc public var hidesWhenStopped: Bool { + get { + _hidesWhenStopped + } + set(hidesWhenStopped) { + _hidesWhenStopped = hidesWhenStopped + if !animating && hidesWhenStopped { + alpha = 0 + } else { + alpha = 1 + } + } + } + private weak var indicatorLayer: CAShapeLayer? + + /// :nodoc: + @objc override init( + frame: CGRect + ) { + var initialFrame = frame + if initialFrame.isEmpty { + initialFrame = CGRect(x: frame.origin.x, y: frame.origin.y, width: 40, height: 40) + } + super.init(frame: initialFrame) + backgroundColor = UIColor.clear + let layer = CAShapeLayer() + layer.backgroundColor = UIColor.clear.cgColor + layer.fillColor = UIColor.clear.cgColor + layer.strokeColor = tintColor.cgColor + layer.strokeStart = 0 + layer.lineCap = .round + layer.strokeEnd = 0.75 + layer.lineWidth = 2.0 + indicatorLayer = layer + self.layer.addSublayer(layer) + alpha = 0 + } + + /// :nodoc: + @objc public override var tintColor: UIColor! { + get { + return super.tintColor + } + set(tintColor) { + super.tintColor = tintColor + indicatorLayer?.strokeColor = tintColor.cgColor + } + } + + /// :nodoc: + @objc + public override func layoutSubviews() { + super.layoutSubviews() + var bounds = self.bounds + bounds.size.width = CGFloat(min(bounds.size.width, bounds.size.height)) + bounds.size.height = bounds.size.width + let path = UIBezierPath(ovalIn: bounds) + indicatorLayer?.path = path.cgPath + } + + required init?( + coder aDecoder: NSCoder + ) { + super.init(coder: aDecoder) + } +} diff --git a/Stripe/StripeiOS/Source/STPPaymentCardTextFieldCell.swift b/Stripe/StripeiOS/Source/STPPaymentCardTextFieldCell.swift new file mode 100644 index 00000000..40112d5d --- /dev/null +++ b/Stripe/StripeiOS/Source/STPPaymentCardTextFieldCell.swift @@ -0,0 +1,91 @@ +// +// STPPaymentCardTextFieldCell.swift +// StripeiOS +// +// Created by Jack Flintermann on 6/16/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripePaymentsUI +import UIKit + +class STPPaymentCardTextFieldCell: UITableViewCell { + private(set) weak var paymentField: STPPaymentCardTextField? + + var theme: STPTheme = STPTheme.defaultTheme { + didSet { + updateAppearance() + } + } + + private var _inputAccessoryView: UIView? + override var inputAccessoryView: UIView? { + get { + _inputAccessoryView + } + set(inputAccessoryView) { + _inputAccessoryView = inputAccessoryView + paymentField?.inputAccessoryView = inputAccessoryView + } + } + + func isEmpty() -> Bool { + return (paymentField?.cardNumber?.count ?? 0) == 0 + } + + override init( + style: UITableViewCell.CellStyle, + reuseIdentifier: String? + ) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + let paymentField = STPPaymentCardTextField(frame: bounds) + paymentField.postalCodeEntryEnabled = false + contentView.addSubview(paymentField) + self.paymentField = paymentField + theme = STPTheme.defaultTheme + updateAppearance() + } + + override func layoutSubviews() { + super.layoutSubviews() + paymentField?.frame = contentView.bounds + } + + @objc func updateAppearance() { + paymentField?.backgroundColor = UIColor.clear + paymentField?.placeholderColor = theme.tertiaryForegroundColor + paymentField?.borderColor = UIColor.clear + paymentField?.textColor = theme.primaryForegroundColor + paymentField?.textErrorColor = theme.errorColor + paymentField?.font = theme.font + backgroundColor = theme.secondaryBackgroundColor + } + + @objc override func becomeFirstResponder() -> Bool { + return paymentField?.becomeFirstResponder() ?? false + } + + override func accessibilityElementCount() -> Int { + return paymentField?.allFields.count ?? 0 + } + + override func accessibilityElement(at index: Int) -> Any? { + return paymentField?.allFields[index] + } + + override func index(ofAccessibilityElement element: Any) -> Int { + let fields = paymentField?.allFields + for i in 0..<(fields?.count ?? 0) { + if (element as? AnyHashable) == fields?[i] { + return i + } + } + return 0 + } + + required init?( + coder aDecoder: NSCoder + ) { + super.init(coder: aDecoder) + } +} diff --git a/Stripe/StripeiOS/Source/STPPaymentConfiguration.swift b/Stripe/StripeiOS/Source/STPPaymentConfiguration.swift new file mode 100644 index 00000000..12719a49 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPPaymentConfiguration.swift @@ -0,0 +1,244 @@ +// +// STPPaymentConfiguration.swift +// StripeiOS +// +// Created by Jack Flintermann on 5/18/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +/// An `STPPaymentConfiguration` represents all the options you can set or change +/// around a payment. +/// You provide an `STPPaymentConfiguration` object to your `STPPaymentContext` +/// when making a charge. The configuration generally has settings that +/// will not change from payment to payment and thus is reusable, while the context +/// is specific to a single particular payment instance. +public class STPPaymentConfiguration: NSObject, NSCopying { + /// This is a convenience singleton configuration that uses the default values for + /// every property + @objc(sharedConfiguration) public static var shared = STPPaymentConfiguration() + + private var _applePayEnabled = true + /// The user is allowed to pay with Apple Pay if it's configured and available on their device. + @objc public var applePayEnabled: Bool { + get { + return appleMerchantIdentifier != nil && _applePayEnabled + && StripeAPI.deviceSupportsApplePay() + } + set { + _applePayEnabled = newValue + } + } + + /// The user is allowed to pay with FPX. + @objc public var fpxEnabled = false + + /// The billing address fields the user must fill out when prompted for their + /// payment details. These fields will all be present on the returned PaymentMethod from + /// Stripe. + /// The default value is `STPBillingAddressFieldsPostalCode`. + /// - seealso: https://stripe.com/docs/api/payment_methods/create#create_payment_method-billing_details + @objc public var requiredBillingAddressFields = STPBillingAddressFields.postalCode + /// The shipping address fields the user must fill out when prompted for their + /// shipping info. Set to nil if shipping address is not required. + /// The default value is nil. + @objc public var requiredShippingAddressFields: Set? + /// Whether the user should be prompted to verify prefilled shipping information. + /// The default value is YES. + @objc public var verifyPrefilledShippingAddress = true + /// The type of shipping for this purchase. This property sets the labels displayed + /// when the user is prompted for shipping info, and whether they should also be + /// asked to select a shipping method. + /// The default value is STPShippingTypeShipping. + @objc public var shippingType = STPShippingType.shipping + /// The set of countries supported when entering an address. This property accepts + /// a set of ISO 2-character country codes. + /// The default value is all known countries. Setting this property will limit + /// the available countries to your selected set. + @objc public var availableCountries: Set = Set(NSLocale.isoCountryCodes) + + /// The name of your company, for displaying to the user during payment flows. For + /// example, when using Apple Pay, the payment sheet's final line item will read + /// "PAY {companyName}". + /// The default value is the name of your iOS application which is derived from the + /// `kCFBundleNameKey` of `Bundle.main`. + @objc public var companyName = Bundle.stp_applicationName() ?? "" + /// The Apple Merchant Identifier to use during Apple Pay transactions. To create + /// one of these, see our guide at https://stripe.com/docs/apple-pay . You + /// must set this to a valid identifier in order to automatically enable Apple Pay. + @objc public var appleMerchantIdentifier: String? + /// Determines whether or not the user is able to delete payment options + /// This is only relevant to the `STPPaymentOptionsViewController` which, if + /// enabled, will allow the user to delete payment options by tapping the "Edit" + /// button in the navigation bar or by swiping left on a payment option and tapping + /// "Delete". Currently, the user is not allowed to delete the selected payment + /// option but this may change in the future. + /// Default value is YES but will only work if `STPPaymentOptionsViewController` is + /// initialized with a `STPCustomerContext` either through the `STPPaymentContext` + /// or directly as an init parameter. + @objc public var canDeletePaymentOptions = true + /// Determines whether STPAddCardViewController allows the user to + /// scan cards using the camera on devices running iOS 13 or later. + /// To use this feature, you must also set the `NSCameraUsageDescription` + /// value in your app's Info.plist. + /// @note This feature is currently in beta. Please file bugs at + /// https://github.com/stripe/stripe-ios/issues + /// The default value is currently NO. This will be changed in a future update. + @objc public var cardScanningEnabled = false + // MARK: - Deprecated + + /// An enum value representing which payment options you will accept from your user + /// in addition to credit cards. + @available( + *, + deprecated, + message: + "additionalPaymentOptions has been removed. Set applePayEnabled and fpxEnabled on STPPaymentConfiguration instead." + ) + @objc public var additionalPaymentOptions: Int = 0 + + private var _publishableKey: String? + /// If you used STPPaymentConfiguration.shared.publishableKey, use STPAPIClient.shared.publishableKey instead. The SDK uses STPAPIClient.shared to make API requests by default. + /// Your Stripe publishable key + /// - seealso: https://dashboard.stripe.com/account/apikeys + @available( + *, + deprecated, + message: + "If you used STPPaymentConfiguration.shared.publishableKey, use STPAPIClient.shared.publishableKey instead. If you passed a STPPaymentConfiguration instance to an SDK component, create an STPAPIClient, set publishableKey on it, and set the SDK component's APIClient property." + ) + @objc public var publishableKey: String? { + get { + if self == STPPaymentConfiguration.shared { + return STPAPIClient.shared.publishableKey + } + return _publishableKey ?? "" + } + set(publishableKey) { + if self == STPPaymentConfiguration.shared { + STPAPIClient.shared.publishableKey = publishableKey + } else { + _publishableKey = publishableKey + } + } + } + + private var _stripeAccount: String? + /// If you used STPPaymentConfiguration.shared.stripeAccount, use STPAPIClient.shared.stripeAccount instead. The SDK uses STPAPIClient.shared to make API requests by default. + /// In order to perform API requests on behalf of a connected account, e.g. to + /// create charges for a connected account, set this property to the ID of the + /// account for which this request is being made. + /// - seealso: https://stripe.com/docs/payments#connected-accounts + @available( + *, + deprecated, + message: + "If you used STPPaymentConfiguration.shared.stripeAccount, use STPAPIClient.shared.stripeAccount instead. If you passed a STPPaymentConfiguration instance to an SDK component, create an STPAPIClient, set stripeAccount on it, and set the SDK component's APIClient property." + ) + @objc public var stripeAccount: String? { + get { + if self == STPPaymentConfiguration.shared { + return STPAPIClient.shared.stripeAccount + } + return _stripeAccount ?? "" + } + set(stripeAccount) { + if self == STPPaymentConfiguration.shared { + STPAPIClient.shared.stripeAccount = stripeAccount + } else { + _stripeAccount = stripeAccount + } + } + } + + // MARK: - Description + /// :nodoc: + @objc public override var description: String { + var additionalPaymentOptionsDescription: String? + + var paymentOptions: [String] = [] + + if _applePayEnabled { + paymentOptions.append("STPPaymentOptionTypeApplePay") + } + + if fpxEnabled { + paymentOptions.append("STPPaymentOptionTypeFPX") + } + + additionalPaymentOptionsDescription = paymentOptions.joined(separator: "|") + + var requiredBillingAddressFieldsDescription: String? + + switch requiredBillingAddressFields { + case .none: + requiredBillingAddressFieldsDescription = "STPBillingAddressFieldsNone" + case .postalCode: + requiredBillingAddressFieldsDescription = "STPBillingAddressFieldsPostalCode" + case .full: + requiredBillingAddressFieldsDescription = "STPBillingAddressFieldsFull" + case .name: + requiredBillingAddressFieldsDescription = "STPBillingAddressFieldsName" + default: + break + } + + let requiredShippingAddressFieldsDescription = requiredShippingAddressFields?.map({ + $0.rawValue + }).joined(separator: "|") + + var shippingTypeDescription: String? + + switch shippingType { + case .shipping: + shippingTypeDescription = "STPShippingTypeShipping" + case .delivery: + shippingTypeDescription = "STPShippingTypeDelivery" + default: + break + } + + let props = [ + // Object + String(format: "%@: %p", NSStringFromClass(STPPaymentConfiguration.self), self), + // Basic configuration + "additionalPaymentOptions = \(additionalPaymentOptionsDescription ?? "")", + // Billing and shipping + "requiredBillingAddressFields = \(requiredBillingAddressFieldsDescription ?? "")", + "requiredShippingAddressFields = \(requiredShippingAddressFieldsDescription ?? "")", + "verifyPrefilledShippingAddress = \((verifyPrefilledShippingAddress) ? "YES" : "NO")", + "shippingType = \(shippingTypeDescription ?? "")", + "availableCountries = \(availableCountries )", + // Additional configuration + "companyName = \(companyName )", + "appleMerchantIdentifier = \(appleMerchantIdentifier ?? "")", + "canDeletePaymentOptions = \((canDeletePaymentOptions) ? "YES" : "NO")", + "cardScanningEnabled = \((cardScanningEnabled) ? "YES" : "NO")", + ] + + return "<\(props.joined(separator: "; "))>" + } + + // MARK: - NSCopying + /// :nodoc: + @objc + public func copy(with zone: NSZone? = nil) -> Any { + let copy = STPPaymentConfiguration() + copy.applePayEnabled = _applePayEnabled + copy.fpxEnabled = fpxEnabled + copy.requiredBillingAddressFields = requiredBillingAddressFields + copy.requiredShippingAddressFields = requiredShippingAddressFields + copy.verifyPrefilledShippingAddress = verifyPrefilledShippingAddress + copy.shippingType = shippingType + copy.companyName = companyName + copy.appleMerchantIdentifier = appleMerchantIdentifier + copy.canDeletePaymentOptions = canDeletePaymentOptions + copy.cardScanningEnabled = cardScanningEnabled + copy.availableCountries = availableCountries + copy._publishableKey = _publishableKey + copy._stripeAccount = _stripeAccount + return copy + } +} diff --git a/Stripe/StripeiOS/Source/STPPaymentContext.swift b/Stripe/StripeiOS/Source/STPPaymentContext.swift new file mode 100644 index 00000000..8c75e323 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPPaymentContext.swift @@ -0,0 +1,1253 @@ +// +// STPPaymentContext.swift +// StripeiOS +// +// Created by Jack Flintermann on 4/20/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation +import ObjectiveC +import PassKit +@_spi(STP) import StripeCore +@_spi(STP) import StripePaymentsUI + +/// An `STPPaymentContext` keeps track of all of the state around a payment. It will manage fetching a user's saved payment methods, tracking any information they select, and prompting them for required additional information before completing their purchase. It can be used to power your application's "payment confirmation" page with just a few lines of code. +/// `STPPaymentContext` also provides a unified interface to multiple payment methods - for example, you can write a single integration to accept both credit card payments and Apple Pay. +/// `STPPaymentContext` saves information about a user's payment methods to a Stripe customer object, and requires an `STPCustomerContext` to manage retrieving and modifying the customer. +@objc(STPPaymentContext) +public class STPPaymentContext: NSObject, STPAuthenticationContext, + STPPaymentOptionsViewControllerDelegate, STPShippingAddressViewControllerDelegate +{ + /// This is a convenience initializer; it is equivalent to calling + /// `init(customerContext:customerContext + /// configuration:STPPaymentConfiguration.shared + /// theme:STPTheme.defaultTheme`. + /// - Parameter customerContext: The customer context the payment context will use to fetch + /// and modify its Stripe customer. - seealso: STPCustomerContext.h + /// - Returns: the newly-instantiated payment context + @objc + public convenience init( + customerContext: STPCustomerContext + ) { + self.init(apiAdapter: customerContext) + } + + /// Initializes a new Payment Context with the provided customer context, configuration, + /// and theme. After this class is initialized, you should also make sure to set its + /// `delegate` and `hostViewController` properties. + /// - Parameters: + /// - customerContext: The customer context the payment context will use to fetch + /// and modify its Stripe customer. - seealso: STPCustomerContext.h + /// - configuration: The configuration for the payment context to use. This + /// lets you set your Stripe publishable API key, required billing address fields, etc. + /// - seealso: STPPaymentConfiguration.h + /// - theme: The theme describing the visual appearance of all UI + /// that the payment context automatically creates for you. - seealso: STPTheme.h + /// - Returns: the newly-instantiated payment context + @objc + public convenience init( + customerContext: STPCustomerContext, + configuration: STPPaymentConfiguration, + theme: STPTheme + ) { + self.init( + apiAdapter: customerContext, + configuration: configuration, + theme: theme + ) + } + + /// Note: Instead of providing your own backend API adapter, we recommend using + /// `STPCustomerContext`, which will manage retrieving and updating a + /// Stripe customer for you. - seealso: STPCustomerContext.h + /// This is a convenience initializer; it is equivalent to calling + /// `init(apiAdapter:apiAdapter configuration:STPPaymentConfiguration.shared theme:STPTheme.defaultTheme)`. + @objc + public convenience init( + apiAdapter: STPBackendAPIAdapter + ) { + self.init( + apiAdapter: apiAdapter, + configuration: STPPaymentConfiguration.shared, + theme: STPTheme.defaultTheme + ) + } + + /// Note: Instead of providing your own backend API adapter, we recommend using + /// `STPCustomerContext`, which will manage retrieving and updating a + /// Stripe customer for you. - seealso: STPCustomerContext.h + /// Initializes a new Payment Context with the provided API adapter and configuration. + /// After this class is initialized, you should also make sure to set its `delegate` + /// and `hostViewController` properties. + /// - Parameters: + /// - apiAdapter: The API adapter the payment context will use to fetch and + /// modify its contents. You need to make a class conforming to this protocol that + /// talks to your server. - seealso: STPBackendAPIAdapter.h + /// - configuration: The configuration for the payment context to use. This lets + /// you set your Stripe publishable API key, required billing address fields, etc. + /// - seealso: STPPaymentConfiguration.h + /// - theme: The theme describing the visual appearance of all UI that + /// the payment context automatically creates for you. - seealso: STPTheme.h + /// - Returns: the newly-instantiated payment context + @objc + public init( + apiAdapter: STPBackendAPIAdapter, + configuration: STPPaymentConfiguration, + theme: STPTheme + ) { + STPAnalyticsClient.sharedClient.addClass(toProductUsageIfNecessary: STPPaymentContext.self) + self.configuration = configuration + self.apiAdapter = apiAdapter + self.theme = theme + paymentCurrency = "USD" + paymentCountry = "US" + apiClient = STPAPIClient.shared + modalPresentationStyle = .fullScreen + state = STPPaymentContextState.none + super.init() + retryLoading() + } + + /// Note: Instead of providing your own backend API adapter, we recommend using + /// `STPCustomerContext`, which will manage retrieving and updating a + /// Stripe customer for you. - seealso: STPCustomerContext.h + /// The API adapter the payment context will use to fetch and modify its contents. + /// You need to make a class conforming to this protocol that talks to your server. + /// - seealso: STPBackendAPIAdapter.h + @objc public private(set) var apiAdapter: STPBackendAPIAdapter + /// The configuration for the payment context to use internally. - seealso: STPPaymentConfiguration.h + @objc public private(set) var configuration: STPPaymentConfiguration + /// The visual appearance that will be used by any views that the context generates. - seealso: STPTheme.h + @objc public private(set) var theme: STPTheme + + private var _prefilledInformation: STPUserInformation? + /// If you've already collected some information from your user, you can set it here and it'll be automatically filled out when possible/appropriate in any UI that the payment context creates. + @objc public var prefilledInformation: STPUserInformation? { + get { + _prefilledInformation + } + set(prefilledInformation) { + _prefilledInformation = prefilledInformation + if prefilledInformation?.shippingAddress != nil && shippingAddress == nil { + shippingAddress = prefilledInformation?.shippingAddress + shippingAddressNeedsVerification = true + } + } + } + + private weak var _hostViewController: UIViewController? + /// The view controller that any additional UI will be presented on. If you have a "checkout view controller" in your app, that should be used as the host view controller. + @objc public weak var hostViewController: UIViewController? { + get { + _hostViewController + } + set(hostViewController) { + assert( + _hostViewController == nil, + "You cannot change the hostViewController on an STPPaymentContext after it's already been set." + ) + _hostViewController = hostViewController + if hostViewController is UINavigationController { + originalTopViewController = + (hostViewController as? UINavigationController)?.topViewController + } + if let hostViewController = hostViewController { + artificiallyRetain(hostViewController) + } + } + } + + private weak var _delegate: STPPaymentContextDelegate? + /// This delegate will be notified when the payment context's contents change. - seealso: STPPaymentContextDelegate + @objc public weak var delegate: STPPaymentContextDelegate? { + get { + _delegate + } + set(delegate) { + _delegate = delegate + DispatchQueue.main.async(execute: { + self.delegate?.paymentContextDidChange(self) + }) + } + } + /// Whether or not the payment context is currently loading information from the network. + + @objc public var loading: Bool { + return !(loadingPromise?.completed)! + } + /// @note This is no longer recommended as of v18.3.0 - the SDK automatically saves the Stripe ID of the last selected + /// payment method using NSUserDefaults and displays it as the default pre-selected option. You can override this behavior + /// by setting this property. + /// The Stripe ID of a payment method to display as the default pre-selected option. + /// @note Set this property immediately after initializing STPPaymentContext, or call `retryLoading` afterwards. + @objc public var defaultPaymentMethod: String? + + private var _selectedPaymentOption: STPPaymentOption? + /// The user's currently selected payment option. May be nil. + @objc public private(set) var selectedPaymentOption: STPPaymentOption? { + get { + _selectedPaymentOption + } + set { + if let newValue = newValue, let paymentOptions = self.paymentOptions { + if !paymentOptions.contains(where: { (option) -> Bool in + newValue.isEqual(option) + }) { + if newValue.isReusable { + self.paymentOptions = paymentOptions + [newValue] + } + } + } + if !(_selectedPaymentOption?.isEqual(newValue) ?? false) { + _selectedPaymentOption = newValue + stpDispatchToMainThreadIfNecessary({ + self.delegate?.paymentContextDidChange(self) + }) + } + + } + } + + private var _paymentOptions: [STPPaymentOption]? + /// The available payment options the user can choose between. May be nil. + @objc public private(set) var paymentOptions: [STPPaymentOption]? { + get { + _paymentOptions + } + set { + _paymentOptions = newValue?.sorted(by: { (obj1, obj2) -> Bool in + let applePayKlass = STPApplePayPaymentOption.self + let paymentMethodKlass = STPPaymentMethod.self + if obj1.isKind(of: applePayKlass) { + return true + } else if obj2.isKind(of: applePayKlass) { + return false + } + if obj1.isKind(of: paymentMethodKlass) && obj2.isKind(of: paymentMethodKlass) { + return (obj1.label.compare(obj2.label) == .orderedAscending) + } + return false + }) + } + } + + /// The user's currently selected shipping method. May be nil. + @objc public internal(set) var selectedShippingMethod: PKShippingMethod? + + private var _shippingMethods: [PKShippingMethod]? + /// An array of STPShippingMethod objects that describe the supported shipping methods. May be nil. + @objc public private(set) var shippingMethods: [PKShippingMethod]? { + get { + _shippingMethods + } + set { + _shippingMethods = newValue + if let shippingMethods = newValue, + let selectedShippingMethod = self.selectedShippingMethod + { + if shippingMethods.count == 0 { + self.selectedShippingMethod = nil + } else if shippingMethods.contains(selectedShippingMethod) { + self.selectedShippingMethod = shippingMethods.first + } + } + } + } + + /// The user's shipping address. May be nil. + /// If you've already collected a shipping address from your user, you may + /// prefill it by setting a shippingAddress in PaymentContext's prefilledInformation. + /// When your user enters a new shipping address, PaymentContext will save it to + /// the current customer object. When PaymentContext loads, if you haven't + /// manually set a prefilled value, any shipping information saved on the customer + /// will be used to prefill the shipping address form. Note that because your + /// customer's email may not be the same as the email provided with their shipping + /// info, PaymentContext will not prefill the shipping form's email using your + /// customer's email. + /// You should not rely on the shipping information stored on the Stripe customer + /// for order fulfillment, as your user may change this information if they make + /// multiple purchases. We recommend adding shipping information when you create + /// a charge (which can also help prevent fraud), or saving it to your own + /// database. https://stripe.com/docs/api/payment_intents/create#create_payment_intent-shipping + /// Note: by default, your user will still be prompted to verify a prefilled + /// shipping address. To change this behavior, you can set + /// `verifyPrefilledShippingAddress` to NO in your `STPPaymentConfiguration`. + @objc public private(set) var shippingAddress: STPAddress? + /// The amount of money you're requesting from the user, in the smallest currency + /// unit for the selected currency. For example, to indicate $10 USD, use 1000 + /// (i.e. 1000 cents). For more information, see https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount + /// @note This value must be present and greater than zero in order for Apple Pay + /// to be automatically enabled. + /// @note You should only set either this or `paymentSummaryItems`, not both. + /// The other will be automatically calculated on demand using your `paymentCurrency`. + + @objc public var paymentAmount: Int { + get { + return paymentAmountModel.paymentAmount( + withCurrency: paymentCurrency, + shippingMethod: selectedShippingMethod + ) + } + set(paymentAmount) { + paymentAmountModel = STPPaymentContextAmountModel(amount: paymentAmount) + } + } + /// The three-letter currency code for the currency of the payment (i.e. USD, GBP, + /// JPY, etc). Defaults to "USD". + /// @note Changing this property may change the return value of `paymentAmount` + /// or `paymentSummaryItems` (whichever one you didn't directly set yourself). + @objc public var paymentCurrency: String + /// The two-letter country code for the country where the payment will be processed. + /// You should set this to the country your Stripe account is in. Defaults to "US". + /// @note Changing this property will change the `countryCode` of your Apple Pay + /// payment requests. + /// - seealso: PKPaymentRequest for more information. + @objc public var paymentCountry: String + /// If you support Apple Pay, you can optionally set the PKPaymentSummaryItems + /// you want to display here instead of using `paymentAmount`. Note that the + /// grand total (the amount of the last summary item) must be greater than zero. + /// If not set, a single summary item will be automatically generated using + /// `paymentAmount` and your configuration's `companyName`. + /// - seealso: PKPaymentRequest for more information + /// @note You should only set either this or `paymentAmount`, not both. + /// The other will be automatically calculated on demand using your `paymentCurrency.` + + @objc public var paymentSummaryItems: [PKPaymentSummaryItem] { + get { + return paymentAmountModel.paymentSummaryItems( + withCurrency: paymentCurrency, + companyName: configuration.companyName, + shippingMethod: selectedShippingMethod + ) ?? [] + } + set(paymentSummaryItems) { + paymentAmountModel = STPPaymentContextAmountModel( + paymentSummaryItems: paymentSummaryItems + ) + } + } + + /// A value that indicates whether Apple Pay Later is available for a transaction. + /// Defaults to enabled. + /// - Seealso: This property is mirrors `PKPaymentRequest.applePayLaterAvailability` +#if compiler(>=5.9) + @available(macOS 14.0, iOS 17.0, *) + @objc public var applePayLaterAvailability: PKApplePayLaterAvailability { + // Stored properties cannot be marked potentially unavailable with '@available', so do this workaround instead + get { + return _applePayLaterAvailability as! PKApplePayLaterAvailability + } + set { + _applePayLaterAvailability = newValue + + } + } + private lazy var _applePayLaterAvailability: Any? = { + if #available(macOS 14.0, iOS 17.0, *) { + return PKApplePayLaterAvailability.available + } + return nil + }() +#endif + /// The presentation style used for all view controllers presented modally by the context. + /// Since custom transition styles are not supported, you should set this to either + /// `UIModalPresentationFullScreen`, `UIModalPresentationPageSheet`, or `UIModalPresentationFormSheet`. + /// The default value is `UIModalPresentationFullScreen`. + @objc public var modalPresentationStyle: UIModalPresentationStyle = .fullScreen + /// The mode to use when displaying the title of the navigation bar in all view + /// controllers presented by the context. The default value is `automatic`, + /// which causes the title to use the same styling as the previously displayed + /// navigation item (if the view controller is pushed onto the `hostViewController`). + /// If the `prefersLargeTitles` property of the `hostViewController`'s navigation bar + /// is false, this property has no effect and the navigation item's title is always + /// displayed as a small title. + /// If the view controller is presented modally, `automatic` and + /// `never` always result in a navigation bar with a small title. + @objc public var largeTitleDisplayMode = UINavigationItem.LargeTitleDisplayMode.automatic + /// A view that will be placed as the footer of the payment options selection + /// view controller. + /// When the footer view needs to be resized, it will be sent a + /// `sizeThatFits:` call. The view should respond correctly to this method in order + /// to be sized and positioned properly. + @objc public var paymentOptionsViewControllerFooterView: UIView? + /// A view that will be placed as the footer of the add card view controller. + /// When the footer view needs to be resized, it will be sent a + /// `sizeThatFits:` call. The view should respond correctly to this method in order + /// to be sized and positioned properly. + @objc public var addCardViewControllerFooterView: UIView? + /// The API Client to use to make requests. + /// Defaults to STPAPIClient.shared + public var apiClient: STPAPIClient = .shared + + /// If `paymentContext:didFailToLoadWithError:` is called on your delegate, you + /// can in turn call this method to try loading again (if that hasn't been called, + /// calling this will do nothing). If retrying in turn fails, `paymentContext:didFailToLoadWithError:` + /// will be called again (and you can again call this to keep retrying, etc). + @objc + public func retryLoading() { + // Clear any cached customer object and attached payment methods before refetching + if apiAdapter is STPCustomerContext { + let customerContext = apiAdapter as? STPCustomerContext + customerContext?.clearCache() + } + weak var weakSelf = self + loadingPromise = STPPromise.init().onSuccess({ tuple in + guard let strongSelf = weakSelf else { + return + } + strongSelf.paymentOptions = tuple.paymentOptions + strongSelf.selectedPaymentOption = tuple.selectedPaymentOption + }).onFailure({ error in + guard let strongSelf = weakSelf else { + return + } + if strongSelf.hostViewController != nil { + if strongSelf.paymentOptionsViewController != nil + && strongSelf.paymentOptionsViewController?.viewIfLoaded?.window != nil + { + if let paymentOptionsViewController1 = strongSelf.paymentOptionsViewController { + strongSelf.appropriatelyDismiss(paymentOptionsViewController1) { + strongSelf.delegate?.paymentContext( + strongSelf, + didFailToLoadWithError: error + ) + } + } + } else { + strongSelf.delegate?.paymentContext(strongSelf, didFailToLoadWithError: error) + } + } + }) + apiAdapter.retrieveCustomer({ customer, retrieveCustomerError in + stpDispatchToMainThreadIfNecessary({ + guard let strongSelf = weakSelf else { + return + } + if let retrieveCustomerError = retrieveCustomerError { + strongSelf.loadingPromise?.fail(retrieveCustomerError) + return + } + if strongSelf.shippingAddress == nil && customer?.shippingAddress != nil { + strongSelf.shippingAddress = customer?.shippingAddress + strongSelf.shippingAddressNeedsVerification = true + } + + strongSelf.apiAdapter.listPaymentMethodsForCustomer(completion: { + paymentMethods, + error in + guard let strongSelf2 = weakSelf else { + return + } + stpDispatchToMainThreadIfNecessary({ + if let error = error { + strongSelf2.loadingPromise?.fail(error) + return + } + + if self.defaultPaymentMethod == nil + && (strongSelf2.apiAdapter is STPCustomerContext) + { + // Retrieve the last selected payment method saved by STPCustomerContext + (strongSelf2.apiAdapter as? STPCustomerContext)? + .retrieveLastSelectedPaymentMethodIDForCustomer(completion: { + paymentMethodID, + _ in + guard let strongSelf3 = weakSelf else { + return + } + if let paymentMethods = paymentMethods { + let paymentTuple = STPPaymentOptionTuple( + filteredForUIWith: paymentMethods, + selectedPaymentMethod: paymentMethodID, + configuration: strongSelf3.configuration + ) + strongSelf3.loadingPromise?.succeed(paymentTuple) + } else { + strongSelf3.loadingPromise?.fail( + STPErrorCode.invalidRequestError as! Error + ) + } + }) + } else { + if let paymentMethods = paymentMethods { + let paymentTuple = STPPaymentOptionTuple( + filteredForUIWith: paymentMethods, + selectedPaymentMethod: self.defaultPaymentMethod, + configuration: strongSelf2.configuration + ) + strongSelf2.loadingPromise?.succeed(paymentTuple) + } + } + }) + }) + }) + }) + } + + /// This creates, configures, and appropriately presents an `STPPaymentOptionsViewController` + /// on top of the payment context's `hostViewController`. It'll be dismissed automatically + /// when the user is done selecting their payment method. + /// @note This method will do nothing if it is called while STPPaymentContext is + /// already showing a view controller or in the middle of requesting a payment. + @objc + public func presentPaymentOptionsViewController() { + presentPaymentOptionsViewController(withNewState: .showingRequestedViewController) + } + + /// This creates, configures, and appropriately pushes an `STPPaymentOptionsViewController` + /// onto the navigation stack of the context's `hostViewController`. It'll be popped + /// automatically when the user is done selecting their payment method. + /// @note This method will do nothing if it is called while STPPaymentContext is + /// already showing a view controller or in the middle of requesting a payment. + @objc + public func pushPaymentOptionsViewController() { + assert( + hostViewController != nil && hostViewController?.viewIfLoaded?.window != nil, + "hostViewController must not be nil on STPPaymentContext when calling pushPaymentOptionsViewController on it. Next time, set the hostViewController property first!" + ) + var navigationController: UINavigationController? + if hostViewController is UINavigationController { + navigationController = hostViewController as? UINavigationController + } else { + navigationController = hostViewController?.navigationController + } + assert( + navigationController != nil, + "The payment context's hostViewController is not a navigation controller, or is not contained in one. Either make sure it is inside a navigation controller before calling pushPaymentOptionsViewController, or call presentPaymentOptionsViewController instead." + ) + if state == STPPaymentContextState.none { + state = .showingRequestedViewController + + let paymentOptionsViewController = STPPaymentOptionsViewController(paymentContext: self) + self.paymentOptionsViewController = paymentOptionsViewController + paymentOptionsViewController.prefilledInformation = prefilledInformation + paymentOptionsViewController.defaultPaymentMethod = defaultPaymentMethod + paymentOptionsViewController.paymentOptionsViewControllerFooterView = + paymentOptionsViewControllerFooterView + paymentOptionsViewController.addCardViewControllerFooterView = + addCardViewControllerFooterView + paymentOptionsViewController.navigationItem.largeTitleDisplayMode = + largeTitleDisplayMode + + navigationController?.pushViewController( + paymentOptionsViewController, + animated: transitionAnimationsEnabled() + ) + } + } + + /// This creates, configures, and appropriately presents a view controller for + /// collecting shipping address and shipping method on top of the payment context's + /// `hostViewController`. It'll be dismissed automatically when the user is done + /// entering their shipping info. + /// @note This method will do nothing if it is called while STPPaymentContext is + /// already showing a view controller or in the middle of requesting a payment. + @objc + public func presentShippingViewController() { + presentShippingViewController(withNewState: .showingRequestedViewController) + } + + /// This creates, configures, and appropriately pushes a view controller for + /// collecting shipping address and shipping method onto the navigation stack of + /// the context's `hostViewController`. It'll be popped automatically when the + /// user is done entering their shipping info. + /// @note This method will do nothing if it is called while STPPaymentContext is + /// already showing a view controller, or in the middle of requesting a payment. + @objc + public func pushShippingViewController() { + assert( + hostViewController != nil && hostViewController?.viewIfLoaded?.window != nil, + "hostViewController must not be nil on STPPaymentContext when calling pushShippingViewController on it. Next time, set the hostViewController property first!" + ) + var navigationController: UINavigationController? + if hostViewController is UINavigationController { + navigationController = hostViewController as? UINavigationController + } else { + navigationController = hostViewController?.navigationController + } + assert( + navigationController != nil, + "The payment context's hostViewController is not a navigation controller, or is not contained in one. Either make sure it is inside a navigation controller before calling pushShippingInfoViewController, or call presentShippingInfoViewController instead." + ) + if state == STPPaymentContextState.none { + state = .showingRequestedViewController + + let addressViewController = STPShippingAddressViewController(paymentContext: self) + addressViewController.navigationItem.largeTitleDisplayMode = largeTitleDisplayMode + navigationController?.pushViewController( + addressViewController, + animated: transitionAnimationsEnabled() + ) + } + } + + /// Requests payment from the user. This may need to present some supplemental UI + /// to the user, in which case it will be presented on the payment context's + /// `hostViewController`. For instance, if they've selected Apple Pay as their + /// payment method, calling this method will show the payment sheet. If the user + /// has a card on file, this will use that without presenting any additional UI. + /// After this is called, the `paymentContext:didCreatePaymentResult:completion:` + /// and `paymentContext:didFinishWithStatus:error:` methods will be called on the + /// context's `delegate`. + /// @note This method will do nothing if it is called while STPPaymentContext is + /// already showing a view controller, or in the middle of requesting a payment. + @objc + public func requestPayment() { + weak var weakSelf = self + loadingPromise?.onSuccess({ _ in + guard let strongSelf = weakSelf else { + return + } + + if strongSelf.state != STPPaymentContextState.none { + return + } + + if strongSelf.selectedPaymentOption == nil { + strongSelf.presentPaymentOptionsViewController(withNewState: .requestingPayment) + } else if strongSelf.requestPaymentShouldPresentShippingViewController() { + strongSelf.presentShippingViewController(withNewState: .requestingPayment) + } else if (strongSelf.selectedPaymentOption is STPPaymentMethod) + || (self.selectedPaymentOption is STPPaymentMethodParams) + { + strongSelf.state = .requestingPayment + let result = STPPaymentResult(paymentOption: strongSelf.selectedPaymentOption!) + strongSelf.delegate?.paymentContext(self, didCreatePaymentResult: result) { + status, + error in + stpDispatchToMainThreadIfNecessary({ + strongSelf.didFinish(with: status, error: error) + }) + } + } else if strongSelf.selectedPaymentOption is STPApplePayPaymentOption { + assert( + strongSelf.hostViewController != nil, + "hostViewController must not be nil on STPPaymentContext. Next time, set the hostViewController property first!" + ) + strongSelf.state = .requestingPayment + let paymentRequest = strongSelf.buildPaymentRequest() + let shippingAddressHandler: STPShippingAddressSelectionBlock = { + shippingAddress, + completion in + // Apple Pay always returns a partial address here, so we won't + // update self.shippingAddress or self.shippingMethods + if strongSelf.delegate?.responds( + to: #selector( + STPPaymentContextDelegate.paymentContext( + _: + didUpdateShippingAddress: + completion: + )) + ) + ?? false + { + strongSelf.delegate?.paymentContext?( + strongSelf, + didUpdateShippingAddress: shippingAddress + ) { status, _, shippingMethods, _ in + completion( + status, + shippingMethods ?? [], + strongSelf.paymentSummaryItems + ) + } + } else { + completion( + .valid, + strongSelf.shippingMethods ?? [], + strongSelf.paymentSummaryItems + ) + } + } + let shippingMethodHandler: STPShippingMethodSelectionBlock = { + shippingMethod, + completion in + strongSelf.selectedShippingMethod = shippingMethod + strongSelf.delegate?.paymentContextDidChange(strongSelf) + completion(self.paymentSummaryItems) + } + let paymentHandler: STPPaymentAuthorizationBlock = { payment in + strongSelf.selectedShippingMethod = payment.shippingMethod + if let shippingContact = payment.shippingContact { + strongSelf.shippingAddress = STPAddress(pkContact: shippingContact) + } + strongSelf.shippingAddressNeedsVerification = false + strongSelf.delegate?.paymentContextDidChange(strongSelf) + if strongSelf.apiAdapter is STPCustomerContext { + let customerContext = strongSelf.apiAdapter as? STPCustomerContext + if let shippingAddress1 = strongSelf.shippingAddress { + customerContext?.updateCustomer( + withShippingAddress: shippingAddress1, + completion: nil + ) + } + } + } + let applePayPaymentMethodHandler: STPApplePayPaymentMethodHandlerBlock = { + paymentMethod, + completion in + strongSelf.apiAdapter.attachPaymentMethod(toCustomer: paymentMethod) { + attachPaymentMethodError in + stpDispatchToMainThreadIfNecessary({ + if attachPaymentMethodError != nil { + completion(.error, attachPaymentMethodError) + } else { + let result = STPPaymentResult(paymentOption: paymentMethod) + strongSelf.delegate?.paymentContext( + strongSelf, + didCreatePaymentResult: result + ) { + status, + error in + // for Apple Pay, the didFinishWithStatus callback is fired later when Apple Pay VC finishes + completion(status, error) + } + } + }) + } + } + if let paymentRequest = paymentRequest { + strongSelf.applePayVC = PKPaymentAuthorizationViewController.stp_controller( + with: paymentRequest, + apiClient: strongSelf.apiClient, + onShippingAddressSelection: shippingAddressHandler, + onShippingMethodSelection: shippingMethodHandler, + onPaymentAuthorization: paymentHandler, + onPaymentMethodCreation: applePayPaymentMethodHandler, + onFinish: { status, error in + if strongSelf.applePayVC?.presentingViewController != nil { + strongSelf.hostViewController?.dismiss( + animated: strongSelf.transitionAnimationsEnabled() + ) { + strongSelf.didFinish(with: status, error: error) + } + } else { + strongSelf.didFinish(with: status, error: error) + } + strongSelf.applePayVC = nil + } + ) + } + if let applePayVC1 = strongSelf.applePayVC { + strongSelf.hostViewController?.present( + applePayVC1, + animated: strongSelf.transitionAnimationsEnabled() + ) + } + } + }).onFailure({ error in + guard let strongSelf = weakSelf else { + return + } + strongSelf.didFinish(with: .error, error: error) + }) + } + private var loadingPromise: STPPromise? + private weak var paymentOptionsViewController: STPPaymentOptionsViewController? + private var state: STPPaymentContextState = .none + private var paymentAmountModel = STPPaymentContextAmountModel(amount: 0) + private var shippingAddressNeedsVerification = false + // If hostViewController was set to a nav controller, the original VC on top of the stack + private weak var originalTopViewController: UIViewController? + private var applePayVC: PKPaymentAuthorizationViewController? + + // Disable transition animations in tests + func transitionAnimationsEnabled() -> Bool { + return NSClassFromString("XCTest") == nil + } + + var currentValuePromise: STPPromise { + weak var weakSelf = self + return + (loadingPromise?.map({ _ in + guard let strongSelf = weakSelf, let paymentOptions = strongSelf.paymentOptions + else { + return STPPaymentOptionTuple() + } + return STPPaymentOptionTuple( + paymentOptions: paymentOptions, + selectedPaymentOption: strongSelf.selectedPaymentOption + ) + }))! + } + + func remove(_ paymentOptionToRemove: STPPaymentOption?) { + // Remove payment method from cached representation + var paymentOptions = self.paymentOptions + paymentOptions?.removeAll { $0 as AnyObject === paymentOptionToRemove as AnyObject } + self.paymentOptions = paymentOptions + + // Elect new selected payment method if needed + if let selectedPaymentOption = selectedPaymentOption, + selectedPaymentOption.isEqual(paymentOptionToRemove) + { + self.selectedPaymentOption = self.paymentOptions?.first + } + } + + // MARK: - Payment Methods + + func presentPaymentOptionsViewController(withNewState state: STPPaymentContextState) { + assert( + hostViewController != nil && hostViewController?.viewIfLoaded?.window != nil, + "hostViewController must not be nil on STPPaymentContext when calling pushPaymentOptionsViewController on it. Next time, set the hostViewController property first!" + ) + if self.state == STPPaymentContextState.none { + self.state = state + let paymentOptionsViewController = STPPaymentOptionsViewController(paymentContext: self) + self.paymentOptionsViewController = paymentOptionsViewController + paymentOptionsViewController.prefilledInformation = prefilledInformation + paymentOptionsViewController.defaultPaymentMethod = defaultPaymentMethod + paymentOptionsViewController.paymentOptionsViewControllerFooterView = + paymentOptionsViewControllerFooterView + paymentOptionsViewController.addCardViewControllerFooterView = + addCardViewControllerFooterView + paymentOptionsViewController.navigationItem.largeTitleDisplayMode = + largeTitleDisplayMode + + let navigationController = UINavigationController( + rootViewController: paymentOptionsViewController + ) + navigationController.navigationBar.stp_theme = theme + navigationController.navigationBar.prefersLargeTitles = true + navigationController.modalPresentationStyle = modalPresentationStyle + hostViewController?.present( + navigationController, + animated: transitionAnimationsEnabled() + ) + } + } + + @objc + public func paymentOptionsViewController( + _ paymentOptionsViewController: STPPaymentOptionsViewController, + didSelect paymentOption: STPPaymentOption + ) { + selectedPaymentOption = paymentOption + } + + @objc + public func paymentOptionsViewControllerDidFinish( + _ paymentOptionsViewController: STPPaymentOptionsViewController + ) { + appropriatelyDismiss(paymentOptionsViewController) { + if self.state == .requestingPayment { + self.state = STPPaymentContextState.none + self.requestPayment() + } else { + self.state = STPPaymentContextState.none + } + } + } + + @objc + public func paymentOptionsViewControllerDidCancel( + _ paymentOptionsViewController: STPPaymentOptionsViewController + ) { + appropriatelyDismiss(paymentOptionsViewController) { + if self.state == .requestingPayment { + self.didFinish( + with: .userCancellation, + error: nil + ) + } else { + self.state = STPPaymentContextState.none + } + } + } + + @objc + public func paymentOptionsViewController( + _ paymentOptionsViewController: STPPaymentOptionsViewController, + didFailToLoadWithError error: Error + ) { + // we'll handle this ourselves when the loading promise fails. + } + + @objc(appropriatelyDismissPaymentOptionsViewController:completion:) func appropriatelyDismiss( + _ viewController: STPPaymentOptionsViewController, + completion: @escaping STPVoidBlock + ) { + if viewController.stp_isAtRootOfNavigationController() { + // if we're the root of the navigation controller, we've been presented modally. + viewController.presentingViewController?.dismiss( + animated: transitionAnimationsEnabled() + ) { + self.paymentOptionsViewController = nil + completion() + } + } else { + // otherwise, we've been pushed onto the stack. + var destinationViewController = hostViewController + // If hostViewController is a nav controller, pop to the original VC on top of the stack. + if hostViewController is UINavigationController { + destinationViewController = originalTopViewController + } + viewController.navigationController?.stp_pop( + to: destinationViewController, + animated: transitionAnimationsEnabled() + ) { + self.paymentOptionsViewController = nil + completion() + } + } + } + + // MARK: - Shipping Info + + func presentShippingViewController(withNewState state: STPPaymentContextState) { + assert( + hostViewController != nil && hostViewController?.viewIfLoaded?.window != nil, + "hostViewController must not be nil on STPPaymentContext when calling presentShippingViewController on it. Next time, set the hostViewController property first!" + ) + + if self.state == STPPaymentContextState.none { + self.state = state + + let addressViewController = STPShippingAddressViewController(paymentContext: self) + addressViewController.navigationItem.largeTitleDisplayMode = largeTitleDisplayMode + let navigationController = UINavigationController( + rootViewController: addressViewController + ) + navigationController.navigationBar.stp_theme = theme + navigationController.navigationBar.prefersLargeTitles = true + navigationController.modalPresentationStyle = modalPresentationStyle + hostViewController?.present( + navigationController, + animated: transitionAnimationsEnabled() + ) + } + } + + @objc + public func shippingAddressViewControllerDidCancel( + _ addressViewController: STPShippingAddressViewController + ) { + appropriatelyDismiss(addressViewController) { + if self.state == .requestingPayment { + self.didFinish( + with: .userCancellation, + error: nil + ) + } else { + self.state = STPPaymentContextState.none + } + } + } + + @objc + public func shippingAddressViewController( + _ addressViewController: STPShippingAddressViewController, + didEnter address: STPAddress, + completion: @escaping STPShippingMethodsCompletionBlock + ) { + if delegate?.responds( + to: #selector( + STPPaymentContextDelegate.paymentContext(_:didUpdateShippingAddress:completion:)) + ) + ?? false + { + delegate?.paymentContext?(self, didUpdateShippingAddress: address) { + status, + shippingValidationError, + shippingMethods, + selectedMethod in + self.shippingMethods = shippingMethods + completion(status, shippingValidationError, shippingMethods, selectedMethod) + } + } else { + completion(.valid, nil, nil, nil) + } + } + + @objc + public func shippingAddressViewController( + _ addressViewController: STPShippingAddressViewController, + didFinishWith address: STPAddress, + shippingMethod method: PKShippingMethod? + ) { + shippingAddress = address + shippingAddressNeedsVerification = false + selectedShippingMethod = method + delegate?.paymentContextDidChange(self) + if apiAdapter.responds( + to: #selector(STPCustomerContext.updateCustomer(withShippingAddress:completion:)) + ) { + if let shippingAddress = shippingAddress { + apiAdapter.updateCustomer?(withShippingAddress: shippingAddress, completion: nil) + } + } + appropriatelyDismiss(addressViewController) { + if self.state == .requestingPayment { + self.state = STPPaymentContextState.none + self.requestPayment() + } else { + self.state = STPPaymentContextState.none + } + } + } + + @objc(appropriatelyDismissViewController:completion:) func appropriatelyDismiss( + _ viewController: UIViewController, + completion: @escaping STPVoidBlock + ) { + if viewController.stp_isAtRootOfNavigationController() { + // if we're the root of the navigation controller, we've been presented modally. + viewController.presentingViewController?.dismiss( + animated: transitionAnimationsEnabled() + ) { + completion() + } + } else { + // otherwise, we've been pushed onto the stack. + var destinationViewController = hostViewController + // If hostViewController is a nav controller, pop to the original VC on top of the stack. + if hostViewController is UINavigationController { + destinationViewController = originalTopViewController + } + viewController.navigationController?.stp_pop( + to: destinationViewController, + animated: transitionAnimationsEnabled() + ) { + completion() + } + } + } + + // MARK: - Request Payment + func requestPaymentShouldPresentShippingViewController() -> Bool { + let shippingAddressRequired = (configuration.requiredShippingAddressFields?.count ?? 0) > 0 + var shippingAddressIncomplete: Bool? + if let requiredShippingAddressFields1 = configuration.requiredShippingAddressFields { + shippingAddressIncomplete = + !(shippingAddress?.containsRequiredShippingAddressFields( + requiredShippingAddressFields1 + ) + ?? false) + } + let shippingMethodRequired = + configuration.shippingType == .shipping + && delegate?.responds( + to: #selector( + STPPaymentContextDelegate.paymentContext(_:didUpdateShippingAddress:completion:) + ) + ) + ?? false + && selectedShippingMethod == nil + let verificationRequired = + configuration.verifyPrefilledShippingAddress && shippingAddressNeedsVerification + // true if STPShippingVC should be presented to collect or verify a shipping address + let shouldPresentShippingAddress = + shippingAddressRequired && (shippingAddressIncomplete ?? false || verificationRequired) + // this handles a corner case where STPShippingVC should be presented because: + // - shipping address has been pre-filled + // - no verification is required, but the user still needs to enter a shipping method + let shouldPresentShippingMethods = + shippingAddressRequired && !(shippingAddressIncomplete ?? false) + && !verificationRequired + && shippingMethodRequired + return shouldPresentShippingAddress || shouldPresentShippingMethods + } + + func didFinish( + with status: STPPaymentStatus, + error: Error? + ) { + state = STPPaymentContextState.none + delegate?.paymentContext( + self, + didFinishWith: status, + error: error + ) + } + + func buildPaymentRequest() -> PKPaymentRequest? { + guard let appleMerchantIdentifier = configuration.appleMerchantIdentifier, paymentAmount > 0 + else { + return nil + } + let paymentRequest = StripeAPI.paymentRequest( + withMerchantIdentifier: appleMerchantIdentifier, + country: paymentCountry, + currency: paymentCurrency + ) + +#if compiler(>=5.9) + if #available(macOS 14.0, iOS 17.0, *) { + paymentRequest.applePayLaterAvailability = applePayLaterAvailability._convertedToSwiftValue() + } +#endif + + let summaryItems = paymentSummaryItems + paymentRequest.paymentSummaryItems = summaryItems + + let requiredFields = STPAddress.applePayContactFields( + from: configuration.requiredBillingAddressFields + ) + paymentRequest.requiredBillingContactFields = requiredFields + + var shippingRequiredFields: Set? + if let requiredShippingAddressFields1 = configuration.requiredShippingAddressFields { + shippingRequiredFields = STPAddress.pkContactFields( + fromStripeContactFields: requiredShippingAddressFields1 + ) + } + if let shippingRequiredFields = shippingRequiredFields { + paymentRequest.requiredShippingContactFields = shippingRequiredFields + } + + paymentRequest.currencyCode = paymentCurrency.uppercased() + if let selectedShippingMethod = selectedShippingMethod { + var orderedShippingMethods = shippingMethods + orderedShippingMethods?.removeAll { + $0 as AnyObject === selectedShippingMethod as AnyObject + } + orderedShippingMethods?.insert(selectedShippingMethod, at: 0) + paymentRequest.shippingMethods = orderedShippingMethods + } else { + paymentRequest.shippingMethods = shippingMethods + } + + paymentRequest.shippingType = STPPaymentContext.pkShippingType(configuration.shippingType) + + if let shippingAddress = shippingAddress { + paymentRequest.shippingContact = shippingAddress.pkContactValue() + } + return paymentRequest + } + + class func pkShippingType(_ shippingType: STPShippingType) -> PKShippingType { + switch shippingType { + case .shipping: + return .shipping + case .delivery: + return .delivery + @unknown default: + fatalError() + } + } + + func artificiallyRetain(_ host: NSObject) { + objc_setAssociatedObject( + host, + UnsafeRawPointer(&kSTPPaymentCoordinatorAssociatedObjectKey), + self, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + + // MARK: - STPAuthenticationContext + @objc + public func authenticationPresentingViewController() -> UIViewController { + return hostViewController! + } + + @objc + public func prepare(forPresentation completion: @escaping STPVoidBlock) { + if applePayVC != nil && applePayVC?.presentingViewController != nil { + hostViewController?.dismiss( + animated: transitionAnimationsEnabled() + ) { + completion() + } + } else { + completion() + } + } +} + +/// Implement `STPPaymentContextDelegate` to get notified when a payment context changes, finishes, encounters errors, etc. In practice, if your app has a "checkout screen view controller", that is a good candidate to implement this protocol. +@objc public protocol STPPaymentContextDelegate: NSObjectProtocol { + /// Called when the payment context encounters an error when fetching its initial set of data. A few ways to handle this are: + /// - If you're showing the user a checkout page, dismiss the checkout page when this is called and present the error to the user. + /// - Present the error to the user using a `UIAlertController` with two buttons: Retry and Cancel. If they cancel, dismiss your UI. If they Retry, call `retryLoading` on the payment context. + /// To make it harder to get your UI into a bad state, this won't be called until the context's `hostViewController` has finished appearing. + /// - Parameters: + /// - paymentContext: the payment context that encountered the error + /// - error: the error that was encountered + func paymentContext(_ paymentContext: STPPaymentContext, didFailToLoadWithError error: Error) + /// This is called every time the contents of the payment context change. When this is called, you should update your app's UI to reflect the current state of the payment context. For example, if you have a checkout page with a "selected payment method" row, you should update its payment method with `paymentContext.selectedPaymentOption.label`. If that checkout page has a "buy" button, you should enable/disable it depending on the result of `paymentContext.isReadyForPayment`. + /// - Parameter paymentContext: the payment context that changed + func paymentContextDidChange(_ paymentContext: STPPaymentContext) + /// Inside this method, you should make a call to your backend API to make a PaymentIntent with that Customer + payment method, and invoke the `completion` block when that is done. + /// - Parameters: + /// - paymentContext: The context that succeeded + /// - paymentResult: Information associated with the payment that you can pass to your server. You should go to your backend API with this payment result and use the PaymentIntent API to complete the payment. See https://stripe.com/docs/mobile/ios/basic#submit-payment-intents Once that's done call the `completion` block with any error that occurred (or none, if the payment succeeded). - seealso: STPPaymentResult.h + /// - completion: Call this block when you're done creating a payment intent (or subscription, etc) on your backend. If it succeeded, call `completion(STPPaymentStatusSuccess, nil)`. If it failed with an error, call `completion(STPPaymentStatusError, error)`. If the user canceled, call `completion(STPPaymentStatusUserCancellation, nil)`. + func paymentContext( + _ paymentContext: STPPaymentContext, + didCreatePaymentResult paymentResult: STPPaymentResult, + completion: @escaping STPPaymentStatusBlock + ) + /// This is invoked by an `STPPaymentContext` when it is finished. This will be called after the payment is done and all necessary UI has been dismissed. You should inspect the returned `status` and behave appropriately. For example: if it's `STPPaymentStatusSuccess`, show the user a receipt. If it's `STPPaymentStatusError`, inform the user of the error. If it's `STPPaymentStatusUserCancellation`, do nothing. + /// - Parameters: + /// - paymentContext: The payment context that finished + /// - status: The status of the payment - `STPPaymentStatusSuccess` if it succeeded, `STPPaymentStatusError` if it failed with an error (in which case the `error` parameter will be non-nil), `STPPaymentStatusUserCancellation` if the user canceled the payment. + /// - error: An error that occurred, if any. + func paymentContext( + _ paymentContext: STPPaymentContext, + didFinishWith status: STPPaymentStatus, + error: Error? + ) + + /// Inside this method, you should verify that you can ship to the given address. + /// You should call the completion block with the results of your validation + /// and the available shipping methods for the given address. If you don't implement + /// this method, the user won't be prompted to select a shipping method and all + /// addresses will be valid. If you call the completion block with nil or an + /// empty array of shipping methods, the user won't be prompted to select a + /// shipping method. + /// @note If a user updates their shipping address within the Apple Pay dialog, + /// this address will be anonymized. For example, in the US, it will only include the + /// city, state, and zip code. The payment context will have the user's complete + /// shipping address by the time `paymentContext:didFinishWithStatus:error` is + /// called. + /// - Parameters: + /// - paymentContext: The context that updated its shipping address + /// - address: The current shipping address + /// - completion: Call this block when you're done validating the shipping + /// address and calculating available shipping methods. If you call the completion + /// block with nil or an empty array of shipping methods, the user won't be prompted + /// to select a shipping method. + @objc optional func paymentContext( + _ paymentContext: STPPaymentContext, + didUpdateShippingAddress address: STPAddress, + completion: @escaping STPShippingMethodsCompletionBlock + ) +} + +/// The current state of the payment context +/// - STPPaymentContextStateNone: No view controllers are currently being shown. The payment may or may not have already been completed +/// - STPPaymentContextStateShowingRequestedViewController: The view controller that you requested the context show is being shown (via the push or present payment methods or shipping view controller methods) +/// - STPPaymentContextStateRequestingPayment: The payment context is in the middle of requesting payment. It may be showing some other UI or view controller if more information is necessary to complete the payment. +enum STPPaymentContextState: Int { + case none + case showingRequestedViewController + case requestingPayment +} + +private var kSTPPaymentCoordinatorAssociatedObjectKey = 0 + +/// :nodoc: +@_spi(STP) extension STPPaymentContext: STPAnalyticsProtocol { + @_spi(STP) public static var stp_analyticsIdentifier = "STPPaymentContext" +} + +#if compiler(>=5.9) +@available(macOS 14.0, iOS 17.0, *) +extension PKApplePayLaterAvailability { + func _convertedToSwiftValue() -> PKPaymentRequest.ApplePayLaterAvailability { + switch self { + case .available: + return .available + case .unavailableItemIneligible: + return .unavailable(.itemIneligible) + case .unavailableRecurringTransaction: + return .unavailable(.recurringTransaction) + @unknown default: + fatalError() + } + } +} +#endif diff --git a/Stripe/StripeiOS/Source/STPPaymentContextAmountModel.swift b/Stripe/StripeiOS/Source/STPPaymentContextAmountModel.swift new file mode 100644 index 00000000..c79c13e2 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPPaymentContextAmountModel.swift @@ -0,0 +1,96 @@ +// +// STPPaymentContextAmountModel.swift +// StripeiOS +// +// Created by Brian Dorfman on 8/16/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation +import PassKit +@_spi(STP) import StripePayments + +/// Internal model for STPPaymentContext's `paymentAmount` and +/// `paymentSummaryItems` properties. +class STPPaymentContextAmountModel: NSObject { + private var paymentAmount = 0 + private var paymentSummaryItems: [PKPaymentSummaryItem]? + + init( + amount paymentAmount: Int + ) { + super.init() + self.paymentAmount = paymentAmount + paymentSummaryItems = nil + } + + init( + paymentSummaryItems: [PKPaymentSummaryItem]? + ) { + super.init() + paymentAmount = 0 + self.paymentSummaryItems = paymentSummaryItems + } + + func paymentAmount(withCurrency currency: String?, shippingMethod: PKShippingMethod?) -> Int { + let shippingAmount = + ((shippingMethod != nil) + ? shippingMethod?.amount.stp_amount(withCurrency: currency) : 0) ?? 0 + if paymentSummaryItems == nil { + return paymentAmount + shippingAmount + } else { + let lastItem = paymentSummaryItems?.last + return (lastItem?.amount.stp_amount(withCurrency: currency) ?? 0) + shippingAmount + } + } + + func paymentSummaryItems( + withCurrency currency: String?, + companyName: String?, + shippingMethod: PKShippingMethod? + ) -> [PKPaymentSummaryItem]? { + var shippingItem: PKPaymentSummaryItem? + if let shippingMethod = shippingMethod { + shippingItem = PKPaymentSummaryItem( + label: shippingMethod.label, + amount: shippingMethod.amount + ) + } + if paymentSummaryItems == nil { + let shippingAmount = shippingMethod?.amount.stp_amount(withCurrency: currency) ?? 0 + let total = NSDecimalNumber.stp_decimalNumber( + withAmount: paymentAmount + shippingAmount, + currency: currency + ) + let totalItem = PKPaymentSummaryItem(label: companyName ?? "", amount: total) + var items = [totalItem] + if let shippingItem = shippingItem { + items.insert(shippingItem, at: 0) + } + return items.compactMap { $0 } + } else { + if (paymentSummaryItems?.count ?? 0) > 0 && shippingItem != nil { + var items = paymentSummaryItems + let origTotalItem = items?.last + var newTotal: NSDecimalNumber? + if let amount1 = shippingItem?.amount { + newTotal = origTotalItem?.amount.adding(amount1) + } + var totalItem: PKPaymentSummaryItem? + if let newTotal = newTotal { + totalItem = PKPaymentSummaryItem( + label: origTotalItem?.label ?? "", + amount: newTotal + ) + } + items?.removeLast() + if let items = items { + return items + [shippingItem, totalItem].compactMap { $0 } + } + return nil + } else { + return paymentSummaryItems + } + } + } +} diff --git a/Stripe/StripeiOS/Source/STPPaymentIntentParams+BasicUI.swift b/Stripe/StripeiOS/Source/STPPaymentIntentParams+BasicUI.swift new file mode 100644 index 00000000..007367db --- /dev/null +++ b/Stripe/StripeiOS/Source/STPPaymentIntentParams+BasicUI.swift @@ -0,0 +1,22 @@ +// +// STPPaymentIntentParams+BasicUI.swift +// StripeiOS +// +// Created by David Estes on 6/30/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +extension STPPaymentIntentParams { + /// Provide an STPPaymentResult from STPPaymentContext, and this will populate + /// the proper field (either paymentMethodId or paymentMethodParams) for your PaymentMethod. + @objc + public func configure(with paymentResult: STPPaymentResult) { + if let paymentMethod = paymentResult.paymentMethod { + paymentMethodId = paymentMethod.stripeId + } else if let params = paymentResult.paymentMethodParams { + paymentMethodParams = params + } + } +} diff --git a/Stripe/StripeiOS/Source/STPPaymentMethod+BasicUI.swift b/Stripe/StripeiOS/Source/STPPaymentMethod+BasicUI.swift new file mode 100644 index 00000000..d71801ed --- /dev/null +++ b/Stripe/StripeiOS/Source/STPPaymentMethod+BasicUI.swift @@ -0,0 +1,81 @@ +// +// STPPaymentMethod+BasicUI.swift +// StripeiOS +// +// Created by David Estes on 6/30/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments +@_spi(STP) import StripePaymentsUI +import UIKit + +extension STPPaymentMethod: STPPaymentOption { + // MARK: - STPPaymentOption + @objc public var image: UIImage { + if type == .card, let card = card { + return STPImageLibrary.cardBrandImage(for: card.brand) + } else { + return STPImageLibrary.cardBrandImage(for: .unknown) + } + } + + @objc public var templateImage: UIImage { + if type == .card, let card = card { + return STPImageLibrary.templatedBrandImage(for: card.brand) + } else { + return STPImageLibrary.templatedBrandImage(for: .unknown) + } + } + + @objc public var label: String { + switch type { + case .card: + if let card = card { + let brand = STPCardBrandUtilities.stringFrom(card.brand) + return "\(brand ?? "") \(card.last4 ?? "")" + } else { + return STPCardBrandUtilities.stringFrom(.unknown) ?? "" + } + case .FPX: + if let fpx = fpx { + return STPFPXBank.stringFrom(STPFPXBank.brandFrom(fpx.bankIdentifierCode)) ?? "" + } else { + fallthrough + } + case .USBankAccount: + if let usBankAccount = usBankAccount { + return String( + format: String.Localized.bank_name_account_ending_in_last_4, + usBankAccount.bankName, + usBankAccount.last4 + ) + } else { + fallthrough + } + default: + return type.displayName + } + } + + @objc public var isReusable: Bool { + switch type { + case .card, .link, .USBankAccount: + return true + case .alipay, // Careful! Revisit this if/when we support recurring Alipay + .AUBECSDebit, + .bacsDebit, .SEPADebit, .iDEAL, .FPX, .cardPresent, .giropay, .EPS, .payPal, + .przelewy24, .bancontact, + .OXXO, .sofort, .grabPay, .netBanking, .UPI, .afterpayClearpay, .blik, + .weChatPay, .boleto, .klarna, .linkInstantDebit, .affirm, .cashApp, .paynow, .zip, .revolutPay, .amazonPay, + .alma, .mobilePay, .konbini, .promptPay, .swish, + .unknown: + return false + + @unknown default: + return false + } + } +} diff --git a/Stripe/StripeiOS/Source/STPPaymentMethodParams+BasicUI.swift b/Stripe/StripeiOS/Source/STPPaymentMethodParams+BasicUI.swift new file mode 100644 index 00000000..18ae3622 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPPaymentMethodParams+BasicUI.swift @@ -0,0 +1,49 @@ +// +// STPPaymentMethodParams+BasicUI.swift +// StripeiOS +// +// Created by David Estes on 6/30/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripePaymentsUI +import UIKit + +extension STPPaymentMethodParams: STPPaymentOption { + // MARK: - STPPaymentOption + @objc public var image: UIImage { + if type == .card && card != nil { + let brand = STPCardValidator.brand(forNumber: card?.number ?? "") + return STPImageLibrary.cardBrandImage(for: brand) + } else { + return STPImageLibrary.cardBrandImage(for: .unknown) + } + } + + @objc public var templateImage: UIImage { + if type == .card && card != nil { + let brand = STPCardValidator.brand(forNumber: card?.number ?? "") + return STPImageLibrary.templatedBrandImage(for: brand) + } else if type == .FPX { + return STPImageLibrary.bankIcon() + } else { + return STPImageLibrary.templatedBrandImage(for: .unknown) + } + } + + @objc public var isReusable: Bool { + switch type { + case .card, .link, .USBankAccount: + return true + case .alipay, .AUBECSDebit, .bacsDebit, .SEPADebit, .iDEAL, .FPX, .cardPresent, .giropay, + .grabPay, .EPS, .przelewy24, .bancontact, .netBanking, .OXXO, .payPal, .sofort, .UPI, + .afterpayClearpay, .blik, .weChatPay, .boleto, .klarna, .linkInstantDebit, .affirm, .cashApp, .paynow, + .zip, .revolutPay, .amazonPay, .alma, .mobilePay, .konbini, .promptPay, .swish, + .unknown: + return false + @unknown default: + return false + } + } +} diff --git a/Stripe/StripeiOS/Source/STPPaymentOption.swift b/Stripe/StripeiOS/Source/STPPaymentOption.swift new file mode 100644 index 00000000..278c3d38 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPPaymentOption.swift @@ -0,0 +1,36 @@ +// +// STPPaymentOption.swift +// StripeiOS +// +// Created by Ben Guo on 4/19/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import UIKit + +/// This protocol represents a payment method that a user can select and use to +/// pay. +/// The classes that conform to it and are supported by the UI: +/// - `STPApplePay`, which represents that the user wants to pay with +/// Apple Pay +/// - `STPPaymentMethod`. Only `STPPaymentMethod.type == STPPaymentMethodTypeCard` and +/// `STPPaymentMethod.type == STPPaymentMethodTypeFPX` are supported by `STPPaymentContext` +/// and `STPPaymentOptionsViewController` +/// - `STPPaymentMethodParams`. This should be used with non-reusable payment method, such +/// as FPX and iDEAL. Instead of reaching out to Stripe to create a PaymentMethod, you can +/// pass an STPPaymentMethodParams directly to Stripe when confirming a PaymentIntent. +/// @note card-based Sources, Cards, and FPX support this protocol for use +/// in a custom integration. +@objc public protocol STPPaymentOption: NSObjectProtocol { + /// A small (32 x 20 points) logo image representing the payment method. For + /// example, the Visa logo for a Visa card, or the Apple Pay logo. + var image: UIImage { get } + /// A small (32 x 20 points) logo image representing the payment method that can be + /// used as template for tinted icons. + var templateImage: UIImage { get } + /// A string describing the payment method, such as "Apple Pay" or "Visa 4242". + var label: String { get } + /// Describes whether this payment option may be used multiple times. If it is not reusable, + /// the payment method must be discarded after use. + var isReusable: Bool { get } +} diff --git a/Stripe/StripeiOS/Source/STPPaymentOptionTableViewCell.swift b/Stripe/StripeiOS/Source/STPPaymentOptionTableViewCell.swift new file mode 100644 index 00000000..6fd04a4c --- /dev/null +++ b/Stripe/StripeiOS/Source/STPPaymentOptionTableViewCell.swift @@ -0,0 +1,326 @@ +// +// STPPaymentOptionTableViewCell.swift +// StripeiOS +// +// Created by Ben Guo on 8/30/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripePaymentsUI +import UIKit + +class STPPaymentOptionTableViewCell: UITableViewCell { + @objc(configureForNewCardRowWithTheme:) func configureForNewCardRow(with theme: STPTheme) { + paymentOption = nil + self.theme = theme + + backgroundColor = theme.secondaryBackgroundColor + + // Left icon + leftIcon.image = STPLegacyImageLibrary.addIcon() + leftIcon.tintColor = theme.accentColor + + // Title label + titleLabel.font = theme.font + titleLabel.textColor = theme.accentColor + titleLabel.text = STPLocalizedString("Add New Card…", "Button to add a new credit card.") + + // Checkmark icon + checkmarkIcon.isHidden = true + + setNeedsLayout() + } + + @objc(configureWithPaymentOption:theme:selected:) func configure( + with paymentOption: STPPaymentOption?, + theme: STPTheme, + selected: Bool + ) { + self.paymentOption = paymentOption + self.theme = theme + + backgroundColor = theme.secondaryBackgroundColor + + // Left icon + leftIcon.image = paymentOption?.templateImage + leftIcon.tintColor = primaryColorForPaymentOption(withSelected: selected) + + // Title label + titleLabel.font = theme.font + titleLabel.attributedText = buildAttributedString(with: paymentOption, selected: selected) + + // Checkmark icon + checkmarkIcon.tintColor = theme.accentColor + checkmarkIcon.isHidden = !selected + + // Accessibility + if selected { + accessibilityTraits.insert(.selected) + } else { + accessibilityTraits.remove(.selected) + } + + setNeedsLayout() + } + + @objc(configureForFPXRowWithTheme:) func configureForFPXRow(with theme: STPTheme) { + paymentOption = nil + self.theme = theme + + backgroundColor = theme.secondaryBackgroundColor + + // Left icon + leftIcon.image = STPImageLibrary.bankIcon() + leftIcon.tintColor = primaryColorForPaymentOption(withSelected: false) + + // Title label + titleLabel.font = theme.font + titleLabel.textColor = self.theme.primaryForegroundColor + titleLabel.text = STPLocalizedString( + "Online Banking (FPX)", + "Button to pay with a Bank Account (using FPX)." + ) + + // Checkmark icon + checkmarkIcon.isHidden = true + accessoryType = .disclosureIndicator + setNeedsLayout() + } + + private var paymentOption: STPPaymentOption? + private var theme: STPTheme = .defaultTheme + private var leftIcon = UIImageView() + private var titleLabel = UILabel() + private var checkmarkIcon = UIImageView(image: STPLegacyImageLibrary.checkmarkIcon()) + + override init( + style: UITableViewCell.CellStyle, + reuseIdentifier: String? + ) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + // Left icon + leftIcon.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(leftIcon) + + // Title label + titleLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(titleLabel) + + // Checkmark icon + checkmarkIcon.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(checkmarkIcon) + + NSLayoutConstraint.activate( + [ + self.leftIcon.centerXAnchor.constraint( + equalTo: contentView.leadingAnchor, + constant: kPadding + 0.5 * kDefaultIconWidth + ), + self.leftIcon.centerYAnchor.constraint( + lessThanOrEqualTo: contentView.centerYAnchor + ), + self.checkmarkIcon.widthAnchor.constraint(equalToConstant: kCheckmarkWidth), + self.checkmarkIcon.heightAnchor.constraint( + equalTo: self.checkmarkIcon.widthAnchor, + multiplier: 1.0 + ), + self.checkmarkIcon.centerXAnchor.constraint( + equalTo: contentView.trailingAnchor, + constant: -kPadding + ), + self.checkmarkIcon.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + // Constrain label to leadingAnchor with the default + // icon width so that the text always aligns vertically + // even if the icond widths differ + self.titleLabel.leadingAnchor.constraint( + equalTo: contentView.leadingAnchor, + constant: 2.0 * kPadding + kDefaultIconWidth + ), + self.titleLabel.trailingAnchor.constraint( + equalTo: self.checkmarkIcon.leadingAnchor, + constant: -kPadding + ), + self.titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + ]) + accessibilityTraits.insert(.button) + isAccessibilityElement = true + } + + func primaryColorForPaymentOption(withSelected selected: Bool) -> UIColor { + let fadedColor: UIColor = UIColor(dynamicProvider: { _ in + return self.theme.primaryForegroundColor.withAlphaComponent(0.6) + }) + + return (selected ? theme.accentColor : fadedColor) + } + + func buildAttributedString( + with paymentOption: STPPaymentOption?, + selected: Bool + ) + -> NSAttributedString + { + if let paymentOption = paymentOption as? STPCard { + return buildAttributedString(with: paymentOption, selected: selected) + } else if let source = paymentOption as? STPSource { + if source.type == .card && source.cardDetails != nil { + return buildAttributedString(withCardSource: source, selected: selected) + } + } else if let paymentMethod = paymentOption as? STPPaymentMethod { + if paymentMethod.type == .card && paymentMethod.card != nil { + return buildAttributedString( + withCardPaymentMethod: paymentMethod, + selected: selected + ) + } + if paymentMethod.type == .FPX && paymentMethod.fpx != nil { + return buildAttributedString( + with: STPFPXBank.brandFrom(paymentMethod.fpx?.bankIdentifierCode), + selected: selected + ) + } + } else if paymentOption is STPApplePayPaymentOption { + let label = String.Localized.apple_pay + let primaryColor = primaryColorForPaymentOption(withSelected: selected) + return NSAttributedString( + string: label, + attributes: [ + NSAttributedString.Key.foregroundColor: primaryColor + ] + ) + } else if let paymentMethodParams = paymentOption as? STPPaymentMethodParams { + if paymentMethodParams.type == .card && paymentMethodParams.card != nil { + return buildAttributedString( + withCardPaymentMethodParams: paymentMethodParams, + selected: selected + ) + } + if paymentMethodParams.type == .FPX && paymentMethodParams.fpx != nil { + return buildAttributedString( + with: paymentMethodParams.fpx?.bank ?? STPFPXBankBrand.unknown, + selected: selected + ) + } + } + + // Unrecognized payment method + return NSAttributedString(string: "") + } + + func buildAttributedString(with card: STPCard, selected: Bool) -> NSAttributedString { + return buildAttributedString( + with: card.brand, + last4: card.last4, + selected: selected + ) + } + + func buildAttributedString(withCardSource card: STPSource, selected: Bool) -> NSAttributedString + { + return buildAttributedString( + with: card.cardDetails?.brand ?? .unknown, + last4: card.cardDetails?.last4 ?? "", + selected: selected + ) + } + + func buildAttributedString( + withCardPaymentMethod paymentMethod: STPPaymentMethod, + selected: Bool + ) + -> NSAttributedString + { + return buildAttributedString( + with: paymentMethod.card?.brand ?? .unknown, + last4: paymentMethod.card?.last4 ?? "", + selected: selected + ) + } + + func buildAttributedString( + withCardPaymentMethodParams paymentMethodParams: STPPaymentMethodParams, + selected: Bool + ) -> NSAttributedString { + let brand = STPCardValidator.brand(forNumber: paymentMethodParams.card?.number ?? "") + return buildAttributedString( + with: brand, + last4: paymentMethodParams.card?.last4 ?? "", + selected: selected + ) + } + + func buildAttributedString( + with bankBrand: STPFPXBankBrand, + selected: Bool + ) + -> NSAttributedString + { + let label = (STPFPXBank.stringFrom(bankBrand) ?? "") + " (FPX)" + let primaryColor = primaryColorForPaymentOption(withSelected: selected) + return NSAttributedString( + string: label, + attributes: [ + NSAttributedString.Key.foregroundColor: primaryColor + ] + ) + } + + func buildAttributedString( + with brand: STPCardBrand, + last4: String, + selected: Bool + ) -> NSAttributedString { + let format = String.Localized.card_brand_ending_in_last_4 + let brandString = STPCard.string(from: brand) + let label = String(format: format, brandString, last4) + + let primaryColor = selected ? theme.accentColor : theme.primaryForegroundColor + + let secondaryColor = UIColor(dynamicProvider: { _ in + return primaryColor.withAlphaComponent(0.6) + }) + + let attributes: [NSAttributedString.Key: Any] = [ + NSAttributedString.Key.foregroundColor: secondaryColor, + NSAttributedString.Key.font: self.theme.font, + ] + + let attributedString = NSMutableAttributedString( + string: label, + attributes: attributes as [NSAttributedString.Key: Any] + ) + attributedString.addAttribute( + .foregroundColor, + value: primaryColor, + range: (label as NSString).range(of: brandString) + ) + attributedString.addAttribute( + .foregroundColor, + value: primaryColor, + range: (label as NSString).range(of: last4) + ) + attributedString.addAttribute( + .font, + value: theme.emphasisFont, + range: (label as NSString).range(of: brandString) + ) + attributedString.addAttribute( + .font, + value: theme.emphasisFont, + range: (label as NSString).range(of: last4) + ) + + return attributedString + } + + required init?( + coder aDecoder: NSCoder + ) { + super.init(coder: aDecoder) + } +} + +private let kDefaultIconWidth: CGFloat = 26.0 +private let kPadding: CGFloat = 15.0 +private let kCheckmarkWidth: CGFloat = 14.0 diff --git a/Stripe/StripeiOS/Source/STPPaymentOptionTuple.swift b/Stripe/StripeiOS/Source/STPPaymentOptionTuple.swift new file mode 100644 index 00000000..6084d132 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPPaymentOptionTuple.swift @@ -0,0 +1,88 @@ +// +// STPPaymentOptionTuple.swift +// StripeiOS +// +// Created by Jack Flintermann on 5/17/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation + +class STPPaymentOptionTuple: NSObject { + @objc + public convenience init( + paymentOptions: [STPPaymentOption], + selectedPaymentOption: STPPaymentOption? + ) { + self.init() + self.paymentOptions = paymentOptions + self.selectedPaymentOption = selectedPaymentOption + } + + @objc + public convenience init( + paymentOptions: [STPPaymentOption], + selectedPaymentOption: STPPaymentOption?, + addApplePayOption applePayEnabled: Bool, + addFPXOption fpxEnabled: Bool + ) { + var mutablePaymentOptions = paymentOptions + weak var selected = selectedPaymentOption + + if applePayEnabled { + let applePay = STPApplePayPaymentOption() + mutablePaymentOptions.append(applePay) + + if selected == nil { + selected = applePay + } + } + + if fpxEnabled { + let fpx = STPPaymentMethodFPXParams() + let fpxPaymentOption = STPPaymentMethodParams( + fpx: fpx, + billingDetails: nil, + metadata: nil + ) + mutablePaymentOptions.append(fpxPaymentOption) + } + + self.init( + paymentOptions: mutablePaymentOptions, + selectedPaymentOption: selected + ) + } + + /// Returns a tuple for the given array of STPPaymentMethod, filtered to only include the + /// the types supported by STPPaymentContext/STPPaymentOptionsViewController and adding + /// Apple Pay as a method if appropriate. + /// - Returns: A new tuple ready to be used by the SDK's UI elements + @objc(tupleFilteredForUIWithPaymentMethods:selectedPaymentMethod:configuration:) + public convenience init( + filteredForUIWith paymentMethods: [STPPaymentMethod], + selectedPaymentMethod selectedPaymentMethodID: String?, + configuration: STPPaymentConfiguration + ) { + var paymentOptions: [STPPaymentOption] = [] + var selectedPaymentMethod: STPPaymentMethod? + for paymentMethod in paymentMethods { + if paymentMethod.type == .card { + paymentOptions.append(paymentMethod) + if paymentMethod.stripeId == selectedPaymentMethodID { + selectedPaymentMethod = paymentMethod + } + } + } + + self.init( + paymentOptions: paymentOptions, + selectedPaymentOption: selectedPaymentMethod, + addApplePayOption: configuration.applePayEnabled, + addFPXOption: configuration.fpxEnabled + ) + } + + private(set) weak var selectedPaymentOption: STPPaymentOption? + private(set) var paymentOptions: [STPPaymentOption] = [] +} diff --git a/Stripe/StripeiOS/Source/STPPaymentOptionsInternalViewController.swift b/Stripe/StripeiOS/Source/STPPaymentOptionsInternalViewController.swift new file mode 100644 index 00000000..6a59e1b6 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPPaymentOptionsInternalViewController.swift @@ -0,0 +1,584 @@ +// +// STPPaymentOptionsInternalViewController.swift +// StripeiOS +// +// Created by Jack Flintermann on 6/9/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +import UIKit + +@objc protocol STPPaymentOptionsInternalViewControllerDelegate: AnyObject { + func internalViewControllerDidSelect(_ paymentOption: STPPaymentOption?) + func internalViewControllerDidDelete(_ paymentOption: STPPaymentOption?) + func internalViewControllerDidCreatePaymentOption( + _ paymentOption: STPPaymentOption?, + completion: @escaping STPErrorBlock + ) + func internalViewControllerDidCancel() +} + +class STPPaymentOptionsInternalViewController: STPCoreTableViewController, UITableViewDataSource, + UITableViewDelegate, STPAddCardViewControllerDelegate, STPBankSelectionViewControllerDelegate +{ + init( + configuration: STPPaymentConfiguration, + customerContext: STPCustomerContext?, + apiClient: STPAPIClient, + theme: STPTheme, + prefilledInformation: STPUserInformation?, + shippingAddress: STPAddress?, + paymentOptionTuple tuple: STPPaymentOptionTuple, + delegate: STPPaymentOptionsInternalViewControllerDelegate? + ) { + super.init(theme: theme) + self.configuration = configuration + // This parameter may be a custom API adapter, and not a CustomerContext. + apiAdapter = customerContext + self.apiClient = apiClient + self.prefilledInformation = prefilledInformation + self.shippingAddress = shippingAddress + paymentOptions = tuple.paymentOptions + selectedPaymentOption = tuple.selectedPaymentOption + self.delegate = delegate + + title = STPLocalizedString("Payment Method", "Title for Payment Method screen") + } + + func update(with tuple: STPPaymentOptionTuple) { + if let selectedPaymentOption = selectedPaymentOption, + selectedPaymentOption.isEqual(tuple.selectedPaymentOption) + { + return + } + + paymentOptions = tuple.paymentOptions + selectedPaymentOption = tuple.selectedPaymentOption + + // Reload card list section + let sections = NSMutableIndexSet(index: PaymentOptionSectionCardList) + tableView?.reloadSections(sections as IndexSet, with: .automatic) + } + + private var _customFooterView: UIView? + var customFooterView: UIView? { + get { + _customFooterView + } + set(footerView) { + _customFooterView = footerView + _didSetCustomFooterView() + } + } + func _didSetCustomFooterView() { + if isViewLoaded { + if let size = _customFooterView?.sizeThatFits( + CGSize(width: view.bounds.size.width, height: CGFloat.greatestFiniteMagnitude) + ) { + _customFooterView?.frame = CGRect( + x: 0, + y: 0, + width: size.width, + height: size.height + ) + } + + tableView?.tableFooterView = _customFooterView + } + } + + var addCardViewControllerCustomFooterView: UIView? + var prefilledInformation: STPUserInformation? + private var configuration: STPPaymentConfiguration? + private var apiAdapter: STPBackendAPIAdapter? + private var shippingAddress: STPAddress? + private var paymentOptions: [STPPaymentOption]? + private var apiClient: STPAPIClient = .shared + private var selectedPaymentOption: STPPaymentOption? + private weak var delegate: STPPaymentOptionsInternalViewControllerDelegate? + private var cardImageView: UIImageView? + + override func createAndSetupViews() { + super.createAndSetupViews() + + // Table view + tableView?.register( + STPPaymentOptionTableViewCell.self, + forCellReuseIdentifier: PaymentOptionCellReuseIdentifier + ) + + tableView?.dataSource = self + tableView?.delegate = self + tableView?.reloadData() + + // Table header view + let cardImageView = UIImageView(image: STPLegacyImageLibrary.largeCardFrontImage()) + cardImageView.contentMode = .center + cardImageView.frame = CGRect( + x: 0.0, + y: 0.0, + width: view.bounds.size.width, + height: cardImageView.bounds.size.height + (57.0 * 2.0) + ) + cardImageView.image = STPLegacyImageLibrary.largeCardFrontImage() + cardImageView.tintColor = theme.accentColor + self.cardImageView = cardImageView + + tableView?.tableHeaderView = cardImageView + + // Table view editing state + tableView?.setEditing(false, animated: false) + reloadRightBarButtonItem( + withTableViewIsEditing: tableView?.isEditing ?? false, + animated: false + ) + + stp_navigationItemProxy?.leftBarButtonItem?.accessibilityIdentifier = + "PaymentOptionsViewControllerCancelButtonIdentifier" + // re-set the custom footer view if it was added before we loaded + _didSetCustomFooterView() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + // Resetting it re-calculates the size based on new view width + // UITableView requires us to call setter again to actually pick up frame + // change on footers + if tableView?.tableFooterView != nil { + customFooterView = tableView?.tableFooterView + } + } + + func reloadRightBarButtonItem(withTableViewIsEditing tableViewIsEditing: Bool, animated: Bool) { + var barButtonItem: UIBarButtonItem? + + if !tableViewIsEditing { + if isAnyPaymentOptionDetachable() { + // Show edit button + barButtonItem = UIBarButtonItem( + barButtonSystemItem: .edit, + target: self, + action: #selector(handleEditButtonTapped(_:)) + ) + } else { + // Show no button + barButtonItem = nil + } + } else { + // Show done button + barButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(handleDoneButtonTapped(_:)) + ) + } + + barButtonItem?.stp_setTheme(theme) + + stp_navigationItemProxy?.setRightBarButton(barButtonItem, animated: animated) + } + + func isAnyPaymentOptionDetachable() -> Bool { + for paymentOption in cardPaymentOptions() { + if isPaymentOptionDetachable(paymentOption) { + return true + } + } + + return false + } + + func isPaymentOptionDetachable(_ paymentOption: STPPaymentOption?) -> Bool { + if !(configuration?.canDeletePaymentOptions ?? false) { + // Feature is disabled + return false + } + + if apiAdapter == nil { + // Cannot detach payment methods without customer context + return false + } + + if !(apiAdapter?.responds( + to: #selector(STPCustomerContext.detachPaymentMethod(fromCustomer:completion:)) + ) + ?? false) + { + // Cannot detach payment methods if customerContext is an apiAdapter + // that doesn't implement detachPaymentMethod + return false + } + + if paymentOption == nil { + // Cannot detach non-existent payment method + return false + } + + if !(paymentOption is STPPaymentMethod) { + // Cannot detach non-payment method + return false + } + + // Payment method can be deleted from customer + return true + } + + func cardPaymentOptions() -> [STPPaymentOption] { + guard let paymentOptions = paymentOptions else { + return [] + } + + return paymentOptions.filter({ (o) -> Bool in + if o is STPPaymentMethodParams { + let paymentMethodParams = o as? STPPaymentMethodParams + if paymentMethodParams?.type != .card { + return false + } + } + return true + }) + } + + func apmPaymentOptions() -> [STPPaymentOption] { + guard let paymentOptions = paymentOptions else { + return [] + } + return paymentOptions.filter({ (o) -> Bool in + if (o) is STPPaymentMethodParams { + let paymentMethodParams = o as? STPPaymentMethodParams + if paymentMethodParams?.type == .FPX { + // Add other APMs as we gain support for them in Basic Integration + return true + } + } + return false + }) + } + + // MARK: - Button Handlers + @objc override func handleCancelTapped(_ sender: Any?) { + delegate?.internalViewControllerDidCancel() + } + + @objc func handleEditButtonTapped(_ sender: Any?) { + tableView?.setEditing(true, animated: true) + reloadRightBarButtonItem( + withTableViewIsEditing: tableView?.isEditing ?? false, + animated: true + ) + } + + @objc func handleDoneButtonTapped(_ sender: Any?) { + _endTableViewEditing() + reloadRightBarButtonItem( + withTableViewIsEditing: tableView?.isEditing ?? false, + animated: true + ) + } + + func _endTableViewEditing() { + tableView?.setEditing(false, animated: true) + } + + // MARK: - UITableViewDataSource + func numberOfSections(in tableView: UITableView) -> Int { + if apmPaymentOptions().count > 0 { + return 3 + } else { + return 2 + } + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + if section == PaymentOptionSectionCardList { + return cardPaymentOptions().count + } + + if section == PaymentOptionSectionAddCard { + return 1 + } + + if section == PaymentOptionSectionAPM { + return apmPaymentOptions().count + } + + return 0 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = + tableView.dequeueReusableCell( + withIdentifier: PaymentOptionCellReuseIdentifier, + for: indexPath + ) + as? STPPaymentOptionTableViewCell + + if indexPath.section == PaymentOptionSectionCardList { + weak var paymentOption = + cardPaymentOptions().stp_boundSafeObject(at: indexPath.row) + let selected = paymentOption!.isEqual(selectedPaymentOption) + + cell?.configure(with: paymentOption!, theme: theme, selected: selected) + } else if indexPath.section == PaymentOptionSectionAddCard { + cell?.configureForNewCardRow(with: theme) + cell?.accessibilityIdentifier = "PaymentOptionsTableViewAddNewCardButtonIdentifier" + } else if indexPath.section == PaymentOptionSectionAPM { + weak var paymentOption = + apmPaymentOptions().stp_boundSafeObject(at: indexPath.row) + if paymentOption is STPPaymentMethodParams { + let paymentMethodParams = paymentOption as? STPPaymentMethodParams + if paymentMethodParams?.type == .FPX { + cell?.configureForFPXRow(with: theme) + cell?.accessibilityIdentifier = "PaymentOptionsTableViewFPXButtonIdentifier" + } + } + } + + return cell! + } + + func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + if indexPath.section == PaymentOptionSectionCardList { + weak var paymentOption = + cardPaymentOptions().stp_boundSafeObject(at: indexPath.row) + + if isPaymentOptionDetachable(paymentOption) { + return true + } + } + + return false + } + + func tableView( + _ tableView: UITableView, + commit editingStyle: UITableViewCell.EditingStyle, + forRowAt indexPath: IndexPath + ) { + if indexPath.section == PaymentOptionSectionCardList { + if editingStyle != .delete { + // Showed the user a non-delete option when we shouldn't have + tableView.reloadData() + return + } + + if !(indexPath.row < cardPaymentOptions().count) { + // Data source and table view out of sync for some reason + tableView.reloadData() + return + } + + weak var paymentOptionToDelete = + cardPaymentOptions().stp_boundSafeObject(at: indexPath.row) + + if !isPaymentOptionDetachable(paymentOptionToDelete) { + // Showed the user a delete option for a payment method when we shouldn't have + tableView.reloadData() + return + } + + let paymentMethod = paymentOptionToDelete as? STPPaymentMethod + + // Kickoff request to delete payment method from customer + if let paymentMethod = paymentMethod { + apiAdapter?.detachPaymentMethod?(fromCustomer: paymentMethod, completion: nil) + } + + // Optimistically remove payment method from data source + var paymentOptions = self.paymentOptions + paymentOptions?.removeAll { $0 as AnyObject === paymentOptionToDelete as AnyObject } + self.paymentOptions = paymentOptions + + // Perform deletion animation for single row + tableView.deleteRows(at: [indexPath], with: .automatic) + + var tableViewIsEditing = tableView.isEditing + if !isAnyPaymentOptionDetachable() { + // we deleted the last available payment option, stop editing + // (but delay to next runloop because calling tableView setEditing:animated: + // in this function is not allowed) + DispatchQueue.main.async(execute: { + self._endTableViewEditing() + }) + // manually set the value passed to reloadRightBarButtonItemWithTableViewIsEditing + // below + tableViewIsEditing = false + } + + // Reload right bar button item text + reloadRightBarButtonItem(withTableViewIsEditing: tableViewIsEditing, animated: true) + + // Notify delegate + delegate?.internalViewControllerDidDelete(paymentOptionToDelete) + } + } + + // MARK: - UITableViewDelegate + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if indexPath.section == PaymentOptionSectionCardList { + // Update data source + weak var paymentOption = + cardPaymentOptions().stp_boundSafeObject(at: indexPath.row) + selectedPaymentOption = paymentOption + + // Perform selection animation + tableView.reloadSections( + NSIndexSet(index: PaymentOptionSectionCardList) as IndexSet, + with: .fade + ) + + // Notify delegate + delegate?.internalViewControllerDidSelect(paymentOption) + } else if indexPath.section == PaymentOptionSectionAddCard { + var paymentCardViewController: STPAddCardViewController? + if let configuration = configuration { + paymentCardViewController = STPAddCardViewController( + configuration: configuration, + theme: theme + ) + } + paymentCardViewController?.apiClient = apiClient + paymentCardViewController?.delegate = self + paymentCardViewController?.prefilledInformation = prefilledInformation + paymentCardViewController?.shippingAddress = shippingAddress + paymentCardViewController?.customFooterView = addCardViewControllerCustomFooterView + + if let paymentCardViewController = paymentCardViewController { + navigationController?.pushViewController(paymentCardViewController, animated: true) + } + } else if indexPath.section == PaymentOptionSectionAPM { + weak var paymentOption = + apmPaymentOptions().stp_boundSafeObject(at: indexPath.row) + if paymentOption is STPPaymentMethodParams { + if let paymentMethodParams = paymentOption as? STPPaymentMethodParams, + paymentMethodParams.type == .FPX + { + var bankSelectionViewController: STPBankSelectionViewController? + if let configuration = configuration { + bankSelectionViewController = STPBankSelectionViewController( + bankMethod: .FPX, + configuration: configuration, + theme: theme + ) + } + bankSelectionViewController?.apiClient = apiClient + bankSelectionViewController?.delegate = self + + if let bankSelectionViewController = bankSelectionViewController { + navigationController?.pushViewController( + bankSelectionViewController, + animated: true + ) + } + } + } + } + + tableView.deselectRow(at: indexPath, animated: true) + } + + func tableView( + _ tableView: UITableView, + willDisplay cell: UITableViewCell, + forRowAt indexPath: IndexPath + ) { + let isTopRow = indexPath.row == 0 + let isBottomRow = + self.tableView(tableView, numberOfRowsInSection: indexPath.section) - 1 == indexPath.row + + cell.stp_setBorderColor(theme.tertiaryBackgroundColor) + cell.stp_setTopBorderHidden(!isTopRow) + cell.stp_setBottomBorderHidden(!isBottomRow) + cell.stp_setFakeSeparatorColor(theme.quaternaryBackgroundColor) + cell.stp_setFakeSeparatorLeftInset(15.0) + } + + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + if self.tableView(tableView, numberOfRowsInSection: section) == 0 { + return 0.01 + } + + return 27.0 + } + + override func tableView( + _ tableView: UITableView, + heightForHeaderInSection section: Int + ) + -> CGFloat + { + return 0.01 + } + + func tableView( + _ tableView: UITableView, + editingStyleForRowAt indexPath: IndexPath + ) + -> UITableViewCell.EditingStyle + { + if indexPath.section == PaymentOptionSectionCardList { + return .delete + } + + return .none + } + + func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) { + reloadRightBarButtonItem(withTableViewIsEditing: true, animated: true) + } + + func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) { + reloadRightBarButtonItem(withTableViewIsEditing: tableView.isEditing, animated: true) + } + + // MARK: - STPAddCardViewControllerDelegate + func addCardViewControllerDidCancel(_ addCardViewController: STPAddCardViewController) { + navigationController?.popViewController(animated: true) + } + + @objc func addCardViewController( + _ addCardViewController: STPAddCardViewController, + didCreatePaymentMethod paymentMethod: STPPaymentMethod, + completion: @escaping STPErrorBlock + ) { + delegate?.internalViewControllerDidCreatePaymentOption( + paymentMethod, + completion: completion + ) + } + + @objc func bankSelectionViewController( + _ bankViewController: STPBankSelectionViewController, + didCreatePaymentMethodParams paymentMethodParams: STPPaymentMethodParams + ) { + delegate?.internalViewControllerDidCreatePaymentOption(paymentMethodParams) { _ in + } + } + + required init?( + coder aDecoder: NSCoder + ) { + super.init(coder: aDecoder) + } + + required init( + nibName nibNameOrNil: String?, + bundle nibBundleOrNil: Bundle? + ) { + fatalError("init(nibName:bundle:) has not been implemented") + } + + required init( + theme: STPTheme? + ) { + fatalError("init(theme:) has not been implemented") + } +} + +private let PaymentOptionCellReuseIdentifier = "PaymentOptionCellReuseIdentifier" +private let PaymentOptionSectionCardList = 0 +private let PaymentOptionSectionAddCard = 1 +private let PaymentOptionSectionAPM = 2 diff --git a/Stripe/StripeiOS/Source/STPPaymentOptionsViewController.swift b/Stripe/StripeiOS/Source/STPPaymentOptionsViewController.swift new file mode 100644 index 00000000..9476a3bd --- /dev/null +++ b/Stripe/StripeiOS/Source/STPPaymentOptionsViewController.swift @@ -0,0 +1,655 @@ +// +// STPPaymentOptionsViewController.swift +// StripeiOS +// +// Created by Jack Flintermann on 1/12/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripePaymentsUI +import UIKit + +/// This view controller presents a list of payment method options to the user, +/// which they can select between. They can also add credit cards to the list. +/// It must be displayed inside a `UINavigationController`, so you can either +/// create a `UINavigationController` with an `STPPaymentOptionsViewController` +/// as the `rootViewController` and then present the `UINavigationController`, +/// or push a new `STPPaymentOptionsViewController` onto an existing +/// `UINavigationController`'s stack. You can also have `STPPaymentContext` do this +/// for you automatically, by calling `presentPaymentOptionsViewController` +/// or `pushPaymentOptionsViewController` on it. +public class STPPaymentOptionsViewController: STPCoreViewController, + STPPaymentOptionsInternalViewControllerDelegate, STPAddCardViewControllerDelegate +{ + + /// The delegate for the view controller. + /// The delegate receives callbacks when the user selects a method or cancels, + /// and is responsible for dismissing the payments methods view controller when + /// it is finished. + @objc private(set) weak var delegate: STPPaymentOptionsViewControllerDelegate? + + /// Creates a new payment methods view controller. + /// - Parameter paymentContext: A payment context to power the view controller's view. + /// The payment context will in turn use its backend API adapter to fetch the + /// information it needs from your application. + /// - Returns: an initialized view controller. + @objc(initWithPaymentContext:) + public convenience init( + paymentContext: STPPaymentContext + ) { + self.init( + configuration: paymentContext.configuration, + apiAdapter: paymentContext.apiAdapter, + apiClient: paymentContext.apiClient, + loadingPromise: paymentContext.currentValuePromise, + theme: paymentContext.theme, + shippingAddress: paymentContext.shippingAddress, + delegate: paymentContext + ) + } + + init( + configuration: STPPaymentConfiguration?, + apiAdapter: STPBackendAPIAdapter, + apiClient: STPAPIClient?, + loadingPromise: STPPromise?, + theme: STPTheme?, + shippingAddress: STPAddress?, + delegate: STPPaymentOptionsViewControllerDelegate + ) { + self.apiAdapter = apiAdapter + super.init(theme: theme) + commonInit( + configuration: configuration, + apiAdapter: apiAdapter, + apiClient: apiClient, + loadingPromise: loadingPromise, + shippingAddress: shippingAddress, + delegate: delegate + ) + } + + func commonInit( + configuration: STPPaymentConfiguration?, + apiAdapter: STPBackendAPIAdapter, + apiClient: STPAPIClient?, + loadingPromise: STPPromise?, + shippingAddress: STPAddress?, + delegate: STPPaymentOptionsViewControllerDelegate + ) { + STPAnalyticsClient.sharedClient.addClass( + toProductUsageIfNecessary: STPPaymentOptionsViewController.self + ) + + self.configuration = configuration + self.apiClient = apiClient ?? .shared + self.shippingAddress = shippingAddress + self.apiAdapter = apiAdapter + self.loadingPromise = loadingPromise + self.delegate = delegate + + navigationItem.title = STPLocalizedString( + "Loading…", + "Title for screen when data is still loading from the network." + ) + + weak var weakSelf = self + loadingPromise?.onSuccess({ tuple in + guard let strongSelf = weakSelf else { + return + } + var `internal`: UIViewController? + if (tuple.paymentOptions.count) > 0 { + let customerContext = strongSelf.apiAdapter as? STPCustomerContext + + var payMethodsInternal: STPPaymentOptionsInternalViewController? + if let configuration1 = strongSelf.configuration { + payMethodsInternal = STPPaymentOptionsInternalViewController( + configuration: configuration1, + customerContext: customerContext, + apiClient: strongSelf.apiClient, + theme: strongSelf.theme, + prefilledInformation: strongSelf.prefilledInformation, + shippingAddress: strongSelf.shippingAddress, + paymentOptionTuple: tuple, + delegate: strongSelf + ) + } + if strongSelf.paymentOptionsViewControllerFooterView != nil { + payMethodsInternal?.customFooterView = + strongSelf.paymentOptionsViewControllerFooterView + } + if strongSelf.addCardViewControllerFooterView != nil { + payMethodsInternal?.addCardViewControllerCustomFooterView = + strongSelf.addCardViewControllerFooterView + } + `internal` = payMethodsInternal + } else { + var addCardViewController: STPAddCardViewController? + if let configuration1 = strongSelf.configuration { + addCardViewController = STPAddCardViewController( + configuration: configuration1, + theme: strongSelf.theme + ) + } + addCardViewController?.apiClient = strongSelf.apiClient + addCardViewController?.delegate = strongSelf + addCardViewController?.prefilledInformation = strongSelf.prefilledInformation + addCardViewController?.shippingAddress = strongSelf.shippingAddress + `internal` = addCardViewController + + if strongSelf.addCardViewControllerFooterView != nil { + addCardViewController?.customFooterView = + strongSelf.addCardViewControllerFooterView + } + } + + `internal`?.stp_navigationItemProxy = strongSelf.navigationItem + if let controller = `internal` { + strongSelf.addChild(controller) + } + `internal`?.view.alpha = 0 + if let view = `internal`?.view, let activityIndicator1 = strongSelf.activityIndicator { + strongSelf.view.insertSubview(view, belowSubview: activityIndicator1) + } + if let view = `internal`?.view { + strongSelf.view.addSubview(view) + } + `internal`?.view.frame = strongSelf.view.bounds + `internal`?.didMove(toParent: strongSelf) + UIView.animate( + withDuration: 0.2, + animations: { + strongSelf.activityIndicator?.alpha = 0 + `internal`?.view.alpha = 1 + } + ) { _ in + strongSelf.activityIndicator?.animating = false + } + strongSelf.navigationItem.setRightBarButton( + `internal`?.stp_navigationItemProxy?.rightBarButtonItem, + animated: true + ) + strongSelf.internalViewController = `internal` + }) + } + + /// Initializes a new payment methods view controller without using a + /// payment context. + /// - Parameters: + /// - configuration: The configuration to use to determine what types of + /// payment method to offer your user. - seealso: STPPaymentConfiguration.h + /// - theme: The theme to inform the appearance of the UI. + /// - customerContext: The customer context the view controller will use to + /// fetch and modify its Stripe customer + /// - delegate: A delegate that will be notified when the payment + /// methods view controller's selection changes. + /// - Returns: an initialized view controller. + @objc(initWithConfiguration:theme:customerContext:delegate:) + public convenience init( + configuration: STPPaymentConfiguration, + theme: STPTheme, + customerContext: STPCustomerContext, + delegate: STPPaymentOptionsViewControllerDelegate + ) { + self.init( + configuration: configuration, + theme: theme, + apiAdapter: customerContext, + delegate: delegate + ) + } + + /// Note: Instead of providing your own backend API adapter, we recommend using + /// `STPCustomerContext`, which will manage retrieving and updating a + /// Stripe customer for you. - seealso: STPCustomerContext.h + /// Initializes a new payment methods view controller without using + /// a payment context. + /// - Parameters: + /// - configuration: The configuration to use to determine what types of + /// payment method to offer your user. + /// - theme: The theme to inform the appearance of the UI. + /// - apiAdapter: The API adapter to use to retrieve a customer's stored + /// payment methods and save new ones. + /// - delegate: A delegate that will be notified when the payment methods + /// view controller's selection changes. + @objc(initWithConfiguration:theme:apiAdapter:delegate:) + public init( + configuration: STPPaymentConfiguration, + theme: STPTheme, + apiAdapter: STPBackendAPIAdapter, + delegate: STPPaymentOptionsViewControllerDelegate + ) { + self.apiAdapter = apiAdapter + super.init(theme: theme) + let promise = retrievePaymentMethods(with: configuration, apiAdapter: apiAdapter) + + commonInit( + configuration: configuration, + apiAdapter: apiAdapter, + apiClient: STPAPIClient.shared, + loadingPromise: promise, + shippingAddress: nil, + delegate: delegate + ) + } + + /// If you've already collected some information from your user, you can set it + /// here and it'll be automatically filled out when possible/appropriate in any UI + /// that the payment context creates. + @objc public var prefilledInformation: STPUserInformation? { + didSet { + if let payMethodsInternal = internalViewController as? STPPaymentOptionsInternalViewController { + payMethodsInternal.prefilledInformation = prefilledInformation + } else if let payMethodsInternal = internalViewController as? STPAddCardViewController { + payMethodsInternal.prefilledInformation = prefilledInformation + } + } + } + /// @note This is no longer recommended as of v18.3.0 - the SDK automatically saves the Stripe ID of the last selected + /// payment method using NSUserDefaults and displays it as the default pre-selected option. You can override this behavior + /// by setting this property. + /// The Stripe ID of a payment method to display as the default pre-selected option. + /// @note Setting this after the view controller's view has loaded has no effect. + @objc public var defaultPaymentMethod: String? + /// A view that will be placed as the footer of the view controller when it is + /// showing a list of saved payment methods to select from. + /// When the footer view needs to be resized, it will be sent a + /// `sizeThatFits:` call. The view should respond correctly to this method in order + /// to be sized and positioned properly. + @objc public var paymentOptionsViewControllerFooterView: UIView? { + didSet { + if let payMethodsInternal = internalViewController as? STPPaymentOptionsInternalViewController { + payMethodsInternal.customFooterView = paymentOptionsViewControllerFooterView + } + } + } + + /// A view that will be placed as the footer of the view controller when it is + /// showing the add card view. + /// When the footer view needs to be resized, it will be sent a + /// `sizeThatFits:` call. The view should respond correctly to this method in order + /// to be sized and positioned properly. + @objc public var addCardViewControllerFooterView: UIView? { + didSet { + if let payMethodsInternal = internalViewController as? STPPaymentOptionsInternalViewController { + payMethodsInternal.addCardViewControllerCustomFooterView = addCardViewControllerFooterView + } else if let payMethodsInternal = internalViewController as? STPAddCardViewController { + payMethodsInternal.customFooterView = addCardViewControllerFooterView + } + } + } + + /// The API Client to use to make requests. + /// Defaults to STPAPIClient.shared + public var apiClient: STPAPIClient = .shared + + /// If you're pushing `STPPaymentOptionsViewController` onto an existing + /// `UINavigationController`'s stack, you should use this method to dismiss it, + /// since it may have pushed an additional add card view controller onto the + /// navigation controller's stack. + /// - Parameter completion: The callback to run after the view controller is dismissed. + /// You may specify nil for this parameter. + @objc(dismissWithCompletion:) + public func dismiss(withCompletion completion: STPVoidBlock?) { + if stp_isAtRootOfNavigationController() { + presentingViewController?.dismiss(animated: true, completion: completion) + } else { + var previous = navigationController?.viewControllers.first + for viewController in navigationController?.viewControllers ?? [] { + if viewController == self { + break + } + previous = viewController + } + navigationController?.stp_pop( + to: previous, + animated: true, + completion: completion ?? {} + ) + } + } + + /// Use one of the initializers declared in this interface. + @available( + *, + unavailable, + message: "Use one of the initializers declared in this interface instead." + ) + @objc public required init( + theme: STPTheme? + ) { + fatalError("init(theme:) has not been implemented") + } + + /// Use one of the initializers declared in this interface. + @available( + *, + unavailable, + message: "Use one of the initializers declared in this interface instead." + ) + @objc public required init( + nibName nibNameOrNil: String?, + bundle nibBundleOrNil: Bundle? + ) { + fatalError("init(nibName:bundle:) has not been implemented") + } + + /// Use one of the initializers declared in this interface. + @available( + *, + unavailable, + message: "Use one of the initializers declared in this interface instead." + ) + @objc public required init?( + coder aDecoder: NSCoder + ) { + fatalError("init(coder:) has not been implemented") + } + + private var configuration: STPPaymentConfiguration? + private var shippingAddress: STPAddress? + private var apiAdapter: STPBackendAPIAdapter + var loadingPromise: STPPromise? + private var activityIndicator: STPPaymentActivityIndicatorView? + internal var internalViewController: UIViewController? + + func retrievePaymentMethods( + with configuration: STPPaymentConfiguration, + apiAdapter: STPBackendAPIAdapter? + ) -> STPPromise { + let promise = STPPromise() + apiAdapter?.listPaymentMethodsForCustomer(completion: { paymentMethods, error in + // We don't use stpDispatchToMainThreadIfNecessary here because we want this completion block to always be called asynchronously, so that users can set self.defaultPaymentMethod in time. + DispatchQueue.main.async(execute: { + if let error = error { + promise.fail(error) + } else { + let defaultPaymentMethod = self.defaultPaymentMethod + if defaultPaymentMethod == nil && (apiAdapter is STPCustomerContext) { + // Retrieve the last selected payment method saved by STPCustomerContext + (apiAdapter as? STPCustomerContext)? + .retrieveLastSelectedPaymentMethodIDForCustomer( + completion: { paymentMethodID, _ in + var paymentTuple: STPPaymentOptionTuple? + if let paymentMethods = paymentMethods { + paymentTuple = STPPaymentOptionTuple.init( + filteredForUIWith: paymentMethods, + selectedPaymentMethod: paymentMethodID, + configuration: configuration + ) + } + promise.succeed(paymentTuple!) + }) + } + var paymentTuple: STPPaymentOptionTuple? + if let paymentMethods = paymentMethods { + paymentTuple = STPPaymentOptionTuple.init( + filteredForUIWith: paymentMethods, + selectedPaymentMethod: defaultPaymentMethod, + configuration: configuration + ) + } + promise.succeed(paymentTuple!) + } + }) + }) + return promise + } + + override func createAndSetupViews() { + super.createAndSetupViews() + + let activityIndicator = STPPaymentActivityIndicatorView() + activityIndicator.animating = true + view.addSubview(activityIndicator) + self.activityIndicator = activityIndicator + } + + /// :nodoc: + @objc + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + let centerX = (view.frame.size.width - (activityIndicator?.frame.size.width ?? 0.0)) / 2 + let centerY = (view.frame.size.height - (activityIndicator?.frame.size.height ?? 0.0)) / 2 + activityIndicator?.frame = CGRect( + x: centerX, + y: centerY, + width: activityIndicator?.frame.size.width ?? 0.0, + height: activityIndicator?.frame.size.height ?? 0.0 + ) + internalViewController?.view.frame = view.bounds + } + + /// :nodoc: + @objc + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + weak var weakSelf = self + loadingPromise?.onSuccess({ tuple in + let strongSelf = weakSelf + if strongSelf == nil { + return + } + + if tuple.selectedPaymentOption != nil { + if strongSelf?.delegate?.responds( + to: #selector( + STPPaymentOptionsViewControllerDelegate.paymentOptionsViewController( + _: + didSelect: + )) + ) + ?? false + { + if let strongSelf = strongSelf, + let selectedPaymentOption = tuple.selectedPaymentOption + { + strongSelf.delegate?.paymentOptionsViewController?( + strongSelf, + didSelect: selectedPaymentOption + ) + } + } + } + }).onFailure({ error in + let strongSelf = weakSelf + if strongSelf == nil { + return + } + + if let strongSelf = strongSelf { + strongSelf.delegate?.paymentOptionsViewController( + strongSelf, + didFailToLoadWithError: error + ) + } + }) + } + + @objc override func updateAppearance() { + super.updateAppearance() + + activityIndicator?.tintColor = theme.accentColor + } + + func finish(with paymentOption: STPPaymentOption?) { + let isReusablePaymentMethod = + (paymentOption is STPPaymentMethod) + && (paymentOption as? STPPaymentMethod)?.isReusable ?? false + + if apiAdapter is STPCustomerContext { + if isReusablePaymentMethod { + // Save the payment method + let paymentMethod = paymentOption as? STPPaymentMethod + (apiAdapter as? STPCustomerContext)?.saveLastSelectedPaymentMethodID( + forCustomer: paymentMethod?.stripeId ?? "", + completion: nil + ) + } else { + // The customer selected something else (like Apple Pay) + (apiAdapter as? STPCustomerContext)?.saveLastSelectedPaymentMethodID( + forCustomer: nil, + completion: nil + ) + } + } + + if delegate?.responds( + to: #selector( + STPPaymentOptionsViewControllerDelegate.paymentOptionsViewController(_:didSelect:)) + ) + ?? false + { + if let paymentOption = paymentOption { + delegate?.paymentOptionsViewController?(self, didSelect: paymentOption) + } + } + delegate?.paymentOptionsViewControllerDidFinish(self) + } + + func internalViewControllerDidSelect(_ paymentOption: STPPaymentOption?) { + finish(with: paymentOption) + } + + func internalViewControllerDidDelete(_ paymentOption: STPPaymentOption?) { + if delegate is STPPaymentContext { + // Notify payment context to update its copy of payment methods + if let paymentContext = delegate as? STPPaymentContext, + let paymentOption = paymentOption + { + paymentContext.remove(paymentOption) + } + } + } + + func internalViewControllerDidCreatePaymentOption( + _ paymentOption: STPPaymentOption?, + completion: @escaping STPErrorBlock + ) { + if !(paymentOption?.isReusable ?? false) { + // Don't save a non-reusable payment option + finish(with: paymentOption) + return + } + let paymentMethod = paymentOption as? STPPaymentMethod + if let paymentMethod = paymentMethod { + apiAdapter.attachPaymentMethod(toCustomer: paymentMethod) { error in + stpDispatchToMainThreadIfNecessary({ + completion(error) + if error == nil { + var promise: STPPromise? + if let configuration = self.configuration { + promise = self.retrievePaymentMethods( + with: configuration, + apiAdapter: self.apiAdapter + ) + } + weak var weakSelf = self + promise?.onSuccess({ tuple in + let strongSelf = weakSelf + if strongSelf == nil { + return + } + let paymentTuple = STPPaymentOptionTuple( + paymentOptions: tuple.paymentOptions, + selectedPaymentOption: paymentMethod + ) + if strongSelf?.internalViewController + is STPPaymentOptionsInternalViewController + { + let paymentOptionsVC = + strongSelf?.internalViewController + as? STPPaymentOptionsInternalViewController + paymentOptionsVC?.update(with: paymentTuple) + } + }) + self.finish(with: paymentMethod) + } + }) + } + } + } + + func internalViewControllerDidCancel() { + delegate?.paymentOptionsViewControllerDidCancel(self) + } + + @objc override func handleCancelTapped(_ sender: Any?) { + delegate?.paymentOptionsViewControllerDidCancel(self) + } + + @objc + public func addCardViewControllerDidCancel( + _ addCardViewController: STPAddCardViewController + ) { + // Add card is only our direct delegate if there are no other payment methods possible + // and we skipped directly to this screen. In this case, a cancel from it is the same as a cancel to us. + delegate?.paymentOptionsViewControllerDidCancel(self) + } + + @objc + public func addCardViewController( + _ addCardViewController: STPAddCardViewController, + didCreatePaymentMethod paymentMethod: STPPaymentMethod, + completion: @escaping STPErrorBlock + ) { + internalViewControllerDidCreatePaymentOption(paymentMethod, completion: completion) + } +} + +// MARK: - STPPaymentOptionsViewControllerDelegate + +/// An `STPPaymentOptionsViewControllerDelegate` responds when a user selects a +/// payment option from (or cancels) an `STPPaymentOptionsViewController`. In both +/// of these instances, you should dismiss the view controller (either by popping +/// it off the navigation stack, or dismissing it). +@objc public protocol STPPaymentOptionsViewControllerDelegate: NSObjectProtocol { + /// This is called when the view controller encounters an error fetching the user's + /// payment options from its API adapter. You should dismiss the view controller + /// when this is called. + /// - Parameters: + /// - paymentOptionsViewController: the view controller in question + /// - error: the error that occurred + func paymentOptionsViewController( + _ paymentOptionsViewController: STPPaymentOptionsViewController, + didFailToLoadWithError error: Error + ) + /// This is called when the user selects or adds a payment method, so it will often + /// be called immediately after calling `paymentOptionsViewController:didSelectPaymentOption:`. + /// You should dismiss the view controller when this is called. + /// - Parameter paymentOptionsViewController: the view controller that has finished + func paymentOptionsViewControllerDidFinish( + _ paymentOptionsViewController: STPPaymentOptionsViewController + ) + /// This is called when the user taps "cancel". + /// You should dismiss the view controller when this is called. + /// - Parameter paymentOptionsViewController: the view controller that has finished + func paymentOptionsViewControllerDidCancel( + _ paymentOptionsViewController: STPPaymentOptionsViewController + ) + + /// This is called when the user either makes a selection, or adds a new card. + /// This will be triggered after the view controller loads with the user's current + /// selection (if they have one) and then subsequently when they change their + /// choice. You should use this callback to update any necessary UI in your app + /// that displays the user's currently selected payment method. You should *not* + /// dismiss the view controller at this point, instead do this in + /// `paymentOptionsViewControllerDidFinish:`. `STPPaymentOptionsViewController` + /// will also call the necessary methods on your API adapter, so you don't need to + /// call them directly during this method. + /// - Parameters: + /// - paymentOptionsViewController: the view controller in question + /// - paymentOption: the selected payment method + @objc(paymentOptionsViewController:didSelectPaymentOption:) + optional func paymentOptionsViewController( + _ paymentOptionsViewController: STPPaymentOptionsViewController, + didSelect paymentOption: STPPaymentOption + ) +} + +/// :nodoc: +@_spi(STP) extension STPPaymentOptionsViewController: STPAnalyticsProtocol { + @_spi(STP) public static var stp_analyticsIdentifier = "STPPaymentOptionsViewController" +} diff --git a/Stripe/StripeiOS/Source/STPPaymentResult.swift b/Stripe/StripeiOS/Source/STPPaymentResult.swift new file mode 100644 index 00000000..0a170738 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPPaymentResult.swift @@ -0,0 +1,43 @@ +// +// STPPaymentResult.swift +// StripeiOS +// +// Created by Jack Flintermann on 1/15/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// When you're using `STPPaymentContext` to request your user's payment details, this is the object that will be returned to your application when they've successfully made a payment. +/// See https://stripe.com/docs/mobile/ios/basic#submit-payment-intents +public class STPPaymentResult: NSObject { + /// The payment method that the user has selected. This may come from a variety of different payment methods, such as an Apple Pay payment or a stored credit card. - seealso: STPPaymentMethod.h + /// If paymentMethod is nil, paymentMethodParams will be populated instead. + @objc public private(set) var paymentMethod: STPPaymentMethod? + /// The parameters for a payment method that the user has selected. This is + /// populated for non-reusable payment methods, such as FPX and iDEAL. - seealso: STPPaymentMethodParams.h + /// If paymentMethodParams is nil, paymentMethod will be populated instead. + @objc public private(set) var paymentMethodParams: STPPaymentMethodParams? + /// The STPPaymentOption that was used to initialize this STPPaymentResult, either an STPPaymentMethod or an STPPaymentMethodParams. + + @objc public weak var paymentOption: STPPaymentOption? { + if paymentMethod != nil { + return paymentMethod + } else { + return paymentMethodParams + } + } + + /// Initializes the payment result with a given payment option. This is invoked by `STPPaymentContext` internally; you shouldn't have to call it directly. + @objc + public init( + paymentOption: STPPaymentOption? + ) { + super.init() + if paymentOption is STPPaymentMethod { + paymentMethod = paymentOption as? STPPaymentMethod + } else if paymentOption is STPPaymentMethodParams { + paymentMethodParams = paymentOption as? STPPaymentMethodParams + } + } +} diff --git a/Stripe/StripeiOS/Source/STPPinManagementService.swift b/Stripe/StripeiOS/Source/STPPinManagementService.swift new file mode 100644 index 00000000..bbf471b6 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPPinManagementService.swift @@ -0,0 +1,143 @@ +// +// STPPinManagementService.swift +// StripeiOS +// +// Created by Arnaud Cavailhez on 4/29/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments +@_spi(STP) import StripePaymentsUI +import UIKit + +/// STPAPIClient extensions to manage PIN on Stripe Issuing cards +public class STPPinManagementService: NSObject { + /// The API Client to use to make requests. + /// Defaults to STPAPIClient.shared + public var apiClient: STPAPIClient = STPAPIClient.shared + + /// Create a STPPinManagementService, you must provide an implementation of STPIssuingCardEphemeralKeyProvider + @objc + public init( + keyProvider: STPIssuingCardEphemeralKeyProvider + ) { + super.init() + keyManager = STPEphemeralKeyManager( + keyProvider: keyProvider as Any, + apiVersion: STPAPIClient.apiVersion, + performsEagerFetching: false + ) + } + + /// Retrieves a PIN number for a given card, + /// this call is asynchronous, implement the completion block to receive the updates + @objc + public func retrievePin( + _ cardId: String, + verificationId: String, + oneTimeCode: String, + completion: @escaping STPPinCompletionBlock + ) { + let endpoint = "issuing/cards/\(cardId)/pin" + let parameters = [ + "verification": [ + "id": verificationId, + "one_time_code": oneTimeCode, + ], + ] + keyManager?.getOrCreateKey({ ephemeralKey, keyError in + if ephemeralKey == nil { + completion(nil, .ephemeralKeyError, keyError) + return + } + + if let ephemeralKey = ephemeralKey { + APIRequest.getWith( + self.apiClient, + endpoint: endpoint, + additionalHeaders: self.apiClient.authorizationHeader(using: ephemeralKey), + parameters: parameters + ) { details, _, error in + // Find if there were errors + if details?.error != nil { + let code = details?.error?["code"] as? String + if "api_key_expired" == code { + completion(nil, .ephemeralKeyError, error) + } else if "expired" == code { + completion(nil, .errorVerificationExpired, nil) + } else if "incorrect_code" == code { + completion(nil, .errorVerificationCodeIncorrect, nil) + } else if "too_many_attempts" == code { + completion(nil, .errorVerificationTooManyAttempts, nil) + } else if "already_redeemed" == code { + completion(nil, .errorVerificationAlreadyRedeemed, nil) + } else { + completion(nil, .unknownError, error) + } + return + } + completion(details, .success, nil) + } + } + }) + } + + /// Updates a PIN number for a given card, + /// this call is asynchronous, implement the completion block to receive the updates + @objc + public func updatePin( + _ cardId: String, + newPin: String, + verificationId: String, + oneTimeCode: String, + completion: @escaping STPPinCompletionBlock + ) { + let endpoint = "issuing/cards/\(cardId)/pin" + let parameters = + [ + "verification": [ + "id": verificationId, + "one_time_code": oneTimeCode, + ], + "pin": newPin, + ] as [String: Any] + keyManager?.getOrCreateKey({ ephemeralKey, keyError in + if ephemeralKey == nil { + completion(nil, .ephemeralKeyError, keyError) + return + } + if let ephemeralKey = ephemeralKey { + APIRequest.post( + with: self.apiClient, + endpoint: endpoint, + additionalHeaders: self.apiClient.authorizationHeader(using: ephemeralKey), + parameters: parameters + ) { details, _, error in + // Find if there were errors + if details?.error != nil { + let code = details?.error?["code"] as? String + if "api_key_expired" == code { + completion(nil, .ephemeralKeyError, error) + } else if "expired" == code { + completion(nil, .errorVerificationExpired, nil) + } else if "incorrect_code" == code { + completion(nil, .errorVerificationCodeIncorrect, nil) + } else if "too_many_attempts" == code { + completion(nil, .errorVerificationTooManyAttempts, nil) + } else if "already_redeemed" == code { + completion(nil, .errorVerificationAlreadyRedeemed, nil) + } else { + completion(nil, .unknownError, error) + } + return + } + completion(details, .success, nil) + } + } + }) + } + + private var keyManager: STPEphemeralKeyManagerProtocol? +} diff --git a/Stripe/StripeiOS/Source/STPPushProvisioningContext.swift b/Stripe/StripeiOS/Source/STPPushProvisioningContext.swift new file mode 100644 index 00000000..62973701 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPPushProvisioningContext.swift @@ -0,0 +1,144 @@ +// +// STPPushProvisioningContext.swift +// StripeiOS +// +// Created by Jack Flintermann on 9/27/18. +// Copyright © 2018 Stripe, Inc. All rights reserved. +// + +import Foundation +import PassKit +@_spi(STP) import StripeCore +@_spi(STP) import StripePaymentsUI + +/// This class makes it easier to implement "Push Provisioning", the process by which an end-user can add a card to their Apple Pay wallet without having to type their number. This process is mediated by an Apple class called `PKAddPaymentPassViewController`; this class will help you implement that class' delegate methods. Note that this flow requires a special entitlement from Apple; for more information please see https://stripe.com/docs/issuing/cards/digital-wallets . +public class STPPushProvisioningContext: NSObject { + /// The API Client to use to make requests. + /// Defaults to STPAPIClient.shared + public var apiClient: STPAPIClient = .shared + + /// This is a helper method to generate a PKAddPaymentPassRequestConfiguration that will work with + /// Stripe's Issuing APIs. Pass the returned configuration object to `PKAddPaymentPassViewController`'s `initWithRequestConfiguration:delegate:` initializer. + /// @deprecated Use requestConfiguration(withName:description:last4:brand:primaryAccountIdentifier:) instead. + /// - Parameters: + /// - name: Your cardholder's name. Example: John Appleseed + /// - description: A localized description of your card's name. This will appear in Apple's UI as "{description} will be available in Wallet". Example: Platinum Rewards Card + /// - last4: The last 4 of the card to be added to the user's Apple Pay wallet. Example: 4242 + /// - brand: The brand of the card. Example: `STPCardBrandVisa` + @objc + @available( + *, + deprecated, + message: + "Use `requestConfiguration(withName:description:last4:brand:primaryAccountIdentifier:)` instead.", + renamed: "requestConfiguration(withName:description:last4:brand:primaryAccountIdentifier:)" + ) + public class func requestConfiguration( + withName name: String, + description: String?, + last4: String?, + brand: STPCardBrand + ) -> PKAddPaymentPassRequestConfiguration { + return self.requestConfiguration( + withName: name, + description: description, + last4: last4, + brand: brand, + primaryAccountIdentifier: nil + ) + } + + /// This is a helper method to generate a PKAddPaymentPassRequestConfiguration that will work with + /// Stripe's Issuing APIs. Pass the returned configuration object to `PKAddPaymentPassViewController`'s `initWithRequestConfiguration:delegate:` initializer. + /// - Parameters: + /// - name: Your cardholder's name. Example: John Appleseed + /// - description: A localized description of your card's name. This will appear in Apple's UI as "{description} will be available in Wallet". Example: Platinum Rewards Card + /// - last4: The last 4 of the card to be added to the user's Apple Pay wallet. Example: 4242 + /// - brand: The brand of the card. Example: `STPCardBrandVisa` + /// - primaryAccountIdentifier: The `primary_account_identifier` value from the issued card. + @objc + public class func requestConfiguration( + withName name: String, + description: String?, + last4: String?, + brand: STPCardBrand, + primaryAccountIdentifier: String? + ) -> PKAddPaymentPassRequestConfiguration { + let config = PKAddPaymentPassRequestConfiguration(encryptionScheme: .ECC_V2) + config?.cardholderName = name + config?.primaryAccountSuffix = last4 + config?.localizedDescription = description + config?.style = .payment + if brand == .visa { + config?.paymentNetwork = .visa + } + if brand == .mastercard { + config?.paymentNetwork = .masterCard + } + config?.primaryAccountIdentifier = primaryAccountIdentifier + return config! + } + + /// In order to retreive the encrypted payload that PKAddPaymentPassViewController expects, the Stripe SDK must talk to the Stripe API. As this requires privileged access, you must write a "key provider" that generates an Ephemeral Key on your backend and provides it to the SDK when requested. For more information, see https://stripe.com/docs/mobile/ios/basic#ephemeral-key + @objc + public init( + keyProvider: STPIssuingCardEphemeralKeyProvider + ) { + apiClient = STPAPIClient.shared + keyManager = STPEphemeralKeyManager( + keyProvider: keyProvider, + apiVersion: STPAPIClient.apiVersion, + performsEagerFetching: false + ) + super.init() + } + + /// This method lines up with the method of the same name on `PKAddPaymentPassViewControllerDelegate`. You should implement that protocol in your own app, and when that method is called, call this method on your `STPPushProvisioningContext`. This in turn will first initiate a call to your `keyProvider` (see above) to obtain an Ephemeral Key, then make a call to the Stripe Issuing API to fetch an encrypted payload for the card in question, then return that payload to iOS. + @objc + public func addPaymentPassViewController( + _ controller: PKAddPaymentPassViewController, + generateRequestWithCertificateChain certificates: [Data], + nonce: Data, + nonceSignature: Data, + completionHandler handler: @escaping (PKAddPaymentPassRequest) -> Void + ) { + keyManager.getOrCreateKey({ ephemeralKey, keyError in + if let keyError = keyError { + let request = PKAddPaymentPassRequest() + request.stp_error = keyError as NSError + // handler, bizarrely, cannot take an NSError, but passing an empty PKAddPaymentPassRequest causes roughly equivalent behavior. + handler(request) + return + } + let params = STPPushProvisioningDetailsParams( + cardId: ephemeralKey?.issuingCardID ?? "", + certificates: certificates, + nonce: nonce, + nonceSignature: nonceSignature + ) + if let ephemeralKey = ephemeralKey { + self.apiClient.retrievePushProvisioningDetails( + with: params, + ephemeralKey: ephemeralKey + ) { + details, + error in + if let error = error { + let request = PKAddPaymentPassRequest() + request.stp_error = error as NSError + handler(request) + return + } + let request = PKAddPaymentPassRequest() + request.activationData = details?.activationData + request.encryptedPassData = details?.encryptedPassData + request.ephemeralPublicKey = details?.ephemeralPublicKey + handler(request) + } + } + }) + } + + private var keyManager: STPEphemeralKeyManagerProtocol + private var ephemeralKey: STPEphemeralKey? +} diff --git a/Stripe/StripeiOS/Source/STPPushProvisioningDetails.swift b/Stripe/StripeiOS/Source/STPPushProvisioningDetails.swift new file mode 100644 index 00000000..ae260456 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPPushProvisioningDetails.swift @@ -0,0 +1,97 @@ +// +// STPPushProvisioningDetails.swift +// StripeiOS +// +// Created by Jack Flintermann on 9/26/18. +// Copyright © 2018 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripePayments + +class STPPushProvisioningDetails: NSObject, STPAPIResponseDecodable { + let cardId: String + let livemode: Bool + let encryptedPassData: Data + let activationData: Data + let ephemeralPublicKey: Data + + required init( + cardId: String, + livemode: Bool, + encryptedPass encryptedPassData: Data, + activationData: Data, + ephemeralPublicKey: Data + ) { + self.cardId = cardId + self.livemode = livemode + self.encryptedPassData = encryptedPassData + self.activationData = activationData + self.ephemeralPublicKey = ephemeralPublicKey + super.init() + } + private(set) var allResponseFields: [AnyHashable: Any] = [:] + + // MARK: - STPAPIResponseDecodable + class func decodedObject(fromAPIResponse response: [AnyHashable: Any]?) -> Self? { + guard + let dict = response?.stp_dictionaryByRemovingNulls() + else { + return nil + } + + // required fields + let cardId = dict.stp_string(forKey: "card") + let livemode = dict.stp_bool(forKey: "livemode", or: false) + let encryptedPassString = dict.stp_string(forKey: "contents") + let encryptedPassData = + encryptedPassString != nil + ? Data(base64Encoded: encryptedPassString ?? "", options: []) : nil + + let activationString = dict.stp_string(forKey: "activation_data") + let activationData = + activationString != nil ? Data(base64Encoded: activationString ?? "", options: []) : nil + + let ephemeralPublicKeyString = dict.stp_string(forKey: "ephemeral_public_key") + let ephemeralPublicKeyData = + ephemeralPublicKeyString != nil + ? Data(base64Encoded: ephemeralPublicKeyString ?? "", options: []) : nil + + if cardId == nil || encryptedPassData == nil || activationData == nil + || ephemeralPublicKeyData == nil + { + return nil + } + + if let encryptedPassData = encryptedPassData, let activationData = activationData, + let ephemeralPublicKeyData = ephemeralPublicKeyData + { + let details = self.init( + cardId: cardId ?? "", + livemode: livemode, + encryptedPass: encryptedPassData, + activationData: activationData, + ephemeralPublicKey: ephemeralPublicKeyData + ) + details.allResponseFields = dict + return details + } + return nil + } + + // MARK: - Equality + override func isEqual(_ object: Any?) -> Bool { + if let details = object as? STPPushProvisioningDetails { + return isEqual(to: details) + } + return false + } + + override var hash: Int { + return activationData.hashValue + } + + func isEqual(to details: STPPushProvisioningDetails) -> Bool { + return details.activationData == self.activationData + } +} diff --git a/Stripe/StripeiOS/Source/STPPushProvisioningDetailsParams.swift b/Stripe/StripeiOS/Source/STPPushProvisioningDetailsParams.swift new file mode 100644 index 00000000..c7fbcccb --- /dev/null +++ b/Stripe/StripeiOS/Source/STPPushProvisioningDetailsParams.swift @@ -0,0 +1,73 @@ +// +// STPPushProvisioningDetailsParams.swift +// StripeiOS +// +// Created by Jack Flintermann on 9/26/18. +// Copyright © 2018 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// A helper class for turning the raw certificate array, nonce, and nonce signature emitted by PKAddPaymentPassViewController into a format that is understandable by the Stripe API. +/// If you are using STPPushProvisioningContext to implement your integration, you do not need to use this class. +public class STPPushProvisioningDetailsParams: NSObject { + /// The Stripe ID of the Issuing card object to retrieve details for. + @objc public private(set) var cardId: String + /// An array of certificates that should be used to encrypt the card details. + @objc public private(set) var certificates: [Data] + /// A nonce that should be used during the encryption of the card details. + @objc public private(set) var nonce: Data + /// A nonce signature that should be used during the encryption of the card details. + @objc public private(set) var nonceSignature: Data + /// Implemented for convenience - the Stripe API expects the certificate chain as an array of base64-encoded strings. + + @objc public var certificatesBase64: [String] { + var base64Certificates: [AnyHashable] = [] + for certificate in certificates { + base64Certificates.append(certificate.base64EncodedString(options: [])) + } + return base64Certificates as? [String] ?? [] + } + /// Implemented for convenience - the Stripe API expects the nonce as a hex-encoded string. + + @objc public var nonceHex: String { + STPPushProvisioningDetailsParams.hexadecimalString(for: nonce) + } + /// Implemented for convenience - the Stripe API expects the nonce signature as a hex-encoded string. + + @objc public var nonceSignatureHex: String { + STPPushProvisioningDetailsParams.hexadecimalString(for: nonceSignature) + } + + /// Instantiates a new params object with the provided attributes. + @objc public required init( + cardId: String, + certificates: [Data], + nonce: Data, + nonceSignature: Data + ) { + self.cardId = cardId + self.certificates = certificates + self.nonce = nonce + self.nonceSignature = nonceSignature + } + + @objc(paramsWithCardId:certificates:nonce:nonceSignature:) class func paramsWithCardId( + cardId: String, + certificates: [Data], + nonce: Data, + nonceSignature: Data + ) -> Self { + return self.init( + cardId: cardId, + certificates: certificates, + nonce: nonce, + nonceSignature: nonceSignature + ) + } + + // Adapted from https://stackoverflow.com/questions/39075043/how-to-convert-data-to-hex-string-in-swift + class func hexadecimalString(for data: Data) -> String { + return data.map { String(format: "%02hhx", $0) }.joined() + } +} diff --git a/Stripe/StripeiOS/Source/STPSectionHeaderView.swift b/Stripe/StripeiOS/Source/STPSectionHeaderView.swift new file mode 100644 index 00000000..2b45b2af --- /dev/null +++ b/Stripe/StripeiOS/Source/STPSectionHeaderView.swift @@ -0,0 +1,161 @@ +// +// STPSectionHeaderView.swift +// StripeiOS +// +// Created by Ben Guo on 1/3/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import UIKit + +class STPSectionHeaderView: UIView { + private var _theme: STPTheme = STPTheme.defaultTheme + var theme: STPTheme { + get { + _theme + } + set(theme) { + _theme = theme + updateAppearance() + } + } + + private var _title: String? + var title: String? { + get { + _title + } + set(title) { + _title = title + if let title = title { + let style = NSMutableParagraphStyle() + style.firstLineHeadIndent = 15 + style.headIndent = style.firstLineHeadIndent + let attributes = [ + NSAttributedString.Key.paragraphStyle: style + ] + label?.attributedText = NSAttributedString( + string: title, + attributes: attributes + ) + } else { + label?.attributedText = nil + } + setNeedsLayout() + } + } + weak var button: UIButton? + + private var _buttonHidden = false + var buttonHidden: Bool { + get { + _buttonHidden + } + set(buttonHidden) { + _buttonHidden = buttonHidden + button?.alpha = buttonHidden ? 0 : 1 + } + } + private weak var label: UILabel? + private let buttonInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 15) + + override init( + frame: CGRect + ) { + super.init(frame: frame) + let label = UILabel() + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + label.accessibilityTraits.insert(.header) + addSubview(label) + self.label = label + let button = UIButton(type: .system) + button.contentHorizontalAlignment = .right + button.titleLabel?.numberOfLines = 0 + button.titleLabel?.lineBreakMode = .byWordWrapping + button.titleEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 15) + button.contentEdgeInsets = .zero + addSubview(button) + self.button = button + backgroundColor = UIColor.clear + updateAppearance() + } + + @objc func updateAppearance() { + label?.font = theme.smallFont + label?.textColor = theme.secondaryForegroundColor + button?.titleLabel?.font = theme.smallFont + button?.tintColor = theme.accentColor + } + + override func layoutSubviews() { + super.layoutSubviews() + let bounds = stp_boundsWithHorizontalSafeAreaInsets() + if buttonHidden { + label?.frame = bounds + } else { + let halfWidth = bounds.size.width / 2 + let heightThatFits = self.heightThatFits(bounds.size) + label?.frame = CGRect( + x: bounds.origin.x, + y: bounds.origin.y, + width: halfWidth, + height: heightThatFits + ) + button?.frame = CGRect( + x: bounds.origin.x + halfWidth, + y: bounds.origin.y, + width: halfWidth, + height: heightThatFits + ) + } + } + + func heightThatFits(_ size: CGSize) -> CGFloat { + let labelPadding: CGFloat = 16 + if buttonHidden { + let labelHeight = label?.sizeThatFits(size).height ?? 0.0 + return labelHeight + labelPadding + } else { + let halfSize = CGSize(width: size.width / 2, height: size.height) + let labelHeight = (label?.sizeThatFits(halfSize).height ?? 0.0) + labelPadding + let buttonHeight = height( + forButtonText: button?.titleLabel?.text, + width: halfSize.width + ) + return CGFloat(max(buttonHeight, labelHeight)) + } + } + + private func height(forButtonText text: String?, width: CGFloat) -> CGFloat { + let insets = buttonInsets + let textSize = CGSize( + width: width - insets.left - insets.right, + height: CGFloat.greatestFiniteMagnitude + ) + var attributes: [NSAttributedString.Key: Any]? + if let font1 = button?.titleLabel?.font { + attributes = [ + NSAttributedString.Key.font: font1 + ] + } + let buttonSize = + text?.boundingRect( + with: textSize, + options: .usesLineFragmentOrigin, + attributes: attributes, + context: nil + ).size ?? .zero + return buttonSize.height + insets.top + insets.bottom + } + + override func sizeThatFits(_ size: CGSize) -> CGSize { + return CGSize(width: size.width, height: heightThatFits(size)) + } + + required init?( + coder aDecoder: NSCoder + ) { + super.init(coder: aDecoder) + } +} diff --git a/Stripe/StripeiOS/Source/STPShippingAddressViewController.swift b/Stripe/StripeiOS/Source/STPShippingAddressViewController.swift new file mode 100644 index 00000000..ac63189d --- /dev/null +++ b/Stripe/StripeiOS/Source/STPShippingAddressViewController.swift @@ -0,0 +1,674 @@ +// +// STPShippingAddressViewController.swift +// StripeiOS +// +// Created by Ben Guo on 8/29/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import PassKit +@_spi(STP) import StripeCore +@_spi(STP) import StripePaymentsUI +@_spi(STP) import StripeUICore +import UIKit + +/// This view controller contains a shipping address collection form. It renders a right bar button item that submits the form, so it must be shown inside a `UINavigationController`. Depending on your configuration's shippingType, the view controller may present a shipping method selection form after the user enters an address. +public class STPShippingAddressViewController: STPCoreTableViewController { + + /// A convenience initializer; equivalent to calling `init(configuration: STPPaymentConfiguration.shared theme: STPTheme.defaultTheme currency:"" shippingAddress:nil selectedShippingMethod:nil prefilledInformation:nil)`. + @objc + public convenience init() { + self.init( + configuration: STPPaymentConfiguration.shared, + theme: STPTheme.defaultTheme, + currency: "", + shippingAddress: nil, + selectedShippingMethod: nil, + prefilledInformation: nil + ) + } + + /// Initializes a new `STPShippingAddressViewController` with the given payment context and sets the payment context as its delegate. + /// - Parameter paymentContext: The payment context to use. + @objc(initWithPaymentContext:) + public convenience init( + paymentContext: STPPaymentContext + ) { + STPAnalyticsClient.sharedClient.addClass( + toProductUsageIfNecessary: STPShippingAddressViewController.self + ) + + var billingAddress: STPAddress? + weak var paymentOption = paymentContext.selectedPaymentOption + if paymentOption is STPCard { + let card = paymentOption as? STPCard + billingAddress = card?.address + } else if paymentOption is STPPaymentMethod { + let paymentMethod = paymentOption as? STPPaymentMethod + if let billingDetails1 = paymentMethod?.billingDetails { + billingAddress = STPAddress(paymentMethodBillingDetails: billingDetails1) + } + } + var prefilledInformation: STPUserInformation? + if paymentContext.prefilledInformation != nil { + prefilledInformation = paymentContext.prefilledInformation + } else { + prefilledInformation = STPUserInformation() + } + prefilledInformation?.billingAddress = billingAddress + self.init( + configuration: paymentContext.configuration, + theme: paymentContext.theme, + currency: paymentContext.paymentCurrency, + shippingAddress: paymentContext.shippingAddress, + selectedShippingMethod: paymentContext.selectedShippingMethod, + prefilledInformation: prefilledInformation + ) + + self.delegate = paymentContext + } + + /// Initializes a new `STPShippingAddressCardViewController` with the provided parameters. + /// - Parameters: + /// - configuration: The configuration to use (this determines the required shipping address fields and shipping type). - seealso: STPPaymentConfiguration + /// - theme: The theme to use to inform the view controller's visual appearance. - seealso: STPTheme + /// - currency: The currency to use when displaying amounts for shipping methods. The default is USD. + /// - shippingAddress: If set, the shipping address view controller will be pre-filled with this address. - seealso: STPAddress + /// - selectedShippingMethod: If set, the shipping methods view controller will use this method as the selected shipping method. If `selectedShippingMethod` is nil, the first shipping method in the array of methods returned by your delegate will be selected. + /// - prefilledInformation: If set, the shipping address view controller will be pre-filled with this information. - seealso: STPUserInformation + @objc( + initWithConfiguration: + theme: + currency: + shippingAddress: + selectedShippingMethod: + prefilledInformation: + ) + public init( + configuration: STPPaymentConfiguration, + theme: STPTheme, + currency: String?, + shippingAddress: STPAddress?, + selectedShippingMethod: PKShippingMethod?, + prefilledInformation: STPUserInformation? + ) { + STPAnalyticsClient.sharedClient.addClass( + toProductUsageIfNecessary: STPShippingAddressViewController.self + ) + addressViewModel = STPAddressViewModel( + requiredBillingFields: configuration.requiredBillingAddressFields, + availableCountries: configuration.availableCountries + ) + super.init(theme: theme) + assert( + (configuration.requiredShippingAddressFields?.count ?? 0) > 0, + "`requiredShippingAddressFields` must not be empty when initializing an STPShippingAddressViewController." + ) + self.configuration = configuration + self.currency = currency + self.selectedShippingMethod = selectedShippingMethod + billingAddress = prefilledInformation?.billingAddress + hasUsedBillingAddress = false + addressViewModel = STPAddressViewModel( + requiredShippingFields: configuration.requiredShippingAddressFields ?? [], + availableCountries: configuration.availableCountries + ) + addressViewModel.delegate = self + if let shippingAddress = shippingAddress { + addressViewModel.address = shippingAddress + } else if prefilledInformation?.shippingAddress != nil { + addressViewModel.address = prefilledInformation?.shippingAddress ?? STPAddress() + } + title = title(for: self.configuration?.shippingType ?? .shipping) + } + + /// The view controller's delegate. This must be set before showing the view controller in order for it to work properly. - seealso: STPShippingAddressViewControllerDelegate + @objc public weak var delegate: STPShippingAddressViewControllerDelegate? + + /// If you're pushing `STPShippingAddressViewController` onto an existing `UINavigationController`'s stack, you should use this method to dismiss it, since it may have pushed an additional shipping method view controller onto the navigation controller's stack. + /// - Parameter completion: The callback to run after the view controller is dismissed. You may specify nil for this parameter. + @objc(dismissWithCompletion:) + public func dismiss(withCompletion completion: STPVoidBlock?) { + if stp_isAtRootOfNavigationController() { + presentingViewController?.dismiss(animated: true, completion: completion ?? {}) + } else { + var previous = navigationController?.viewControllers.first + for viewController in navigationController?.viewControllers ?? [] { + if viewController == self { + break + } + previous = viewController + } + navigationController?.stp_pop( + to: previous, + animated: true, + completion: completion ?? {} + ) + } + } + + /// Use one of the initializers declared in this interface. + @available( + *, + unavailable, + message: "Use one of the initializers declared in this interface instead." + ) + @objc public required init( + theme: STPTheme? + ) { + let configuration = STPPaymentConfiguration.shared + addressViewModel = STPAddressViewModel( + requiredBillingFields: configuration.requiredBillingAddressFields, + availableCountries: configuration.availableCountries + ) + + super.init(theme: theme) + } + + /// Use one of the initializers declared in this interface. + @available( + *, + unavailable, + message: "Use one of the initializers declared in this interface instead." + ) + @objc public required init( + nibName nibNameOrNil: String?, + bundle nibBundleOrNil: Bundle? + ) { + let configuration = STPPaymentConfiguration.shared + addressViewModel = STPAddressViewModel( + requiredBillingFields: configuration.requiredBillingAddressFields, + availableCountries: configuration.availableCountries + ) + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + } + + /// Use one of the initializers declared in this interface. + @available( + *, + unavailable, + message: "Use one of the initializers declared in this interface instead." + ) + required init?( + coder aDecoder: NSCoder + ) { + let configuration = STPPaymentConfiguration.shared + addressViewModel = STPAddressViewModel( + requiredBillingFields: configuration.requiredBillingAddressFields, + availableCountries: configuration.availableCountries + ) + super.init(coder: aDecoder) + } + + private var configuration: STPPaymentConfiguration? + private var currency: String? + private var selectedShippingMethod: PKShippingMethod? + private weak var imageView: UIImageView? + private var nextItem: UIBarButtonItem? + + private var _loading = false + private var loading: Bool { + get { + _loading + } + set(loading) { + if loading == _loading { + return + } + _loading = loading + stp_navigationItemProxy?.setHidesBackButton(loading, animated: true) + stp_navigationItemProxy?.leftBarButtonItem?.isEnabled = !loading + activityIndicator?.animating = loading + if loading { + tableView?.endEditing(true) + var loadingItem: UIBarButtonItem? + if let activityIndicator = activityIndicator { + loadingItem = UIBarButtonItem(customView: activityIndicator) + } + stp_navigationItemProxy?.setRightBarButton(loadingItem, animated: true) + } else { + stp_navigationItemProxy?.setRightBarButton(nextItem, animated: true) + } + for cell in addressViewModel.addressCells { + cell.isUserInteractionEnabled = !loading + UIView.animate( + withDuration: 0.1, + animations: { + cell.alpha = loading ? 0.7 : 1.0 + } + ) + } + } + } + private var activityIndicator: STPPaymentActivityIndicatorView? + internal var addressViewModel: STPAddressViewModel + private var billingAddress: STPAddress? + private var hasUsedBillingAddress = false + private var addressHeaderView: STPSectionHeaderView? + + override func createAndSetupViews() { + super.createAndSetupViews() + + var nextItem: UIBarButtonItem? + switch configuration?.shippingType { + case .shipping: + nextItem = UIBarButtonItem( + title: STPLocalizedString("Next", "Button to move to the next text entry field"), + style: .done, + target: self, + action: #selector(next(_:)) + ) + case .delivery, .none, .some: + nextItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(next(_:)) + ) + } + self.nextItem = nextItem + stp_navigationItemProxy?.rightBarButtonItem = nextItem + stp_navigationItemProxy?.rightBarButtonItem?.isEnabled = false + stp_navigationItemProxy?.rightBarButtonItem?.accessibilityIdentifier = + "ShippingViewControllerNextButtonIdentifier" + + let imageView = UIImageView(image: STPLegacyImageLibrary.largeShippingImage()) + imageView.contentMode = .center + imageView.frame = CGRect( + x: 0, + y: 0, + width: view.bounds.size.width, + height: imageView.bounds.size.height + (57 * 2) + ) + self.imageView = imageView + tableView?.tableHeaderView = imageView + + activityIndicator = STPPaymentActivityIndicatorView( + frame: CGRect(x: 0, y: 0, width: 20.0, height: 20.0) + ) + + tableView?.dataSource = self + tableView?.delegate = self + tableView?.reloadData() + view.addGestureRecognizer( + UITapGestureRecognizer( + target: self, + action: #selector(NSMutableAttributedString.endEditing) + ) + ) + + let headerView = STPSectionHeaderView() + headerView.theme = theme + if let shippingType1 = configuration?.shippingType { + headerView.title = headerTitle(for: shippingType1) + } + headerView.button?.setTitle( + STPLocalizedString( + "Use Billing", + "Button to fill shipping address from billing address." + ), + for: .normal + ) + headerView.button?.addTarget( + self, + action: #selector(useBillingAddress(_:)), + for: .touchUpInside + ) + headerView.button?.accessibilityIdentifier = "ShippingAddressViewControllerUseBillingButton" + var buttonVisible = false + if let requiredFields = configuration?.requiredShippingAddressFields { + let needsAddress = + requiredFields.contains(.postalAddress) && !(addressViewModel.isValid) + buttonVisible = + needsAddress + && billingAddress?.containsContent(forShippingAddressFields: requiredFields) + ?? false + && !hasUsedBillingAddress + } + headerView.button?.alpha = buttonVisible ? 1 : 0 + headerView.setNeedsLayout() + addressHeaderView = headerView + + updateDoneButton() + } + + @objc func endEditing() { + view.endEditing(false) + } + + @objc override func updateAppearance() { + super.updateAppearance() + let navBarTheme = navigationController?.navigationBar.stp_theme ?? theme + nextItem?.stp_setTheme(navBarTheme) + + tableView?.allowsSelection = false + + imageView?.tintColor = theme.accentColor + activityIndicator?.tintColor = theme.accentColor + for cell in addressViewModel.addressCells { + cell.theme = theme + } + addressHeaderView?.theme = theme + } + + /// :nodoc: + @objc + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + stp_beginObservingKeyboardAndInsettingScrollView( + tableView, + onChange: nil + ) + firstEmptyField()?.becomeFirstResponder() + } + + func firstEmptyField() -> UIResponder? { + for cell in addressViewModel.addressCells { + if (cell.contents?.count ?? 0) == 0 { + return cell + } + } + return nil + } + + @objc override func handleCancelTapped(_ sender: Any?) { + delegate?.shippingAddressViewControllerDidCancel(self) + } + + @objc func next(_ sender: Any?) { + let address = addressViewModel.address + switch configuration?.shippingType { + case .shipping: + loading = true + delegate?.shippingAddressViewController(self, didEnter: address) { + status, + shippingValidationError, + shippingMethods, + selectedShippingMethod in + self.loading = false + if status == .valid { + if (shippingMethods?.count ?? 0) > 0 { + var nextViewController: STPShippingMethodsViewController? + if let shippingMethods = shippingMethods, + let selectedShippingMethod = selectedShippingMethod + { + nextViewController = STPShippingMethodsViewController( + shippingMethods: shippingMethods, + selectedShippingMethod: selectedShippingMethod, + currency: self.currency ?? "", + theme: self.theme + ) + } + nextViewController?.delegate = self + if let nextViewController = nextViewController { + self.navigationController?.pushViewController( + nextViewController, + animated: true + ) + } + } else { + self.delegate?.shippingAddressViewController( + self, + didFinishWith: address, + shippingMethod: nil + ) + } + } else { + self.handleShippingValidationError(shippingValidationError) + } + } + case .delivery, .none, .some: + delegate?.shippingAddressViewController( + self, + didFinishWith: address, + shippingMethod: nil + ) + } + } + + func updateDoneButton() { + stp_navigationItemProxy?.rightBarButtonItem?.isEnabled = addressViewModel.isValid + } + + func handleShippingValidationError(_ error: Error?) { + firstEmptyField()?.becomeFirstResponder() + var title = STPLocalizedString("Invalid Shipping Address", "Shipping form error message") + var message: String? + if let error = error { + title = error.localizedDescription + message = (error as NSError).localizedFailureReason + } + let alertController = UIAlertController( + title: title, + message: message, + preferredStyle: .alert + ) + alertController.addAction( + UIAlertAction( + title: String.Localized.ok, + style: .cancel, + handler: nil + ) + ) + present(alertController, animated: true) + } + + /// :nodoc: + @objc + public override func tableView( + _ tableView: UITableView, + heightForHeaderInSection section: Int + ) -> CGFloat { + let size = addressHeaderView?.sizeThatFits( + CGSize(width: view.bounds.size.width, height: CGFloat.greatestFiniteMagnitude) + ) + return size?.height ?? 0.0 + } + + @objc func useBillingAddress(_ sender: UIButton) { + guard let billingAddress = billingAddress else { + return + } + tableView?.beginUpdates() + addressViewModel.address = billingAddress + hasUsedBillingAddress = true + firstEmptyField()?.becomeFirstResponder() + UIView.animate( + withDuration: 0.2, + animations: { + self.addressHeaderView?.buttonHidden = true + } + ) + tableView?.endUpdates() + } + + func title(for type: STPShippingType) -> String { + if let shippingAddressFields = configuration?.requiredShippingAddressFields, + shippingAddressFields.contains(.postalAddress) + { + switch type { + case .shipping: + return STPLocalizedString("Shipping", "Title for shipping info form") + case .delivery: + return STPLocalizedString("Delivery", "Title for delivery info form") + } + } else { + return STPLocalizedString("Contact", "Title for contact info form") + } + } + + func headerTitle(for type: STPShippingType) -> String { + if let shippingAddressFields = configuration?.requiredShippingAddressFields, + shippingAddressFields.contains(.postalAddress) + { + switch type { + case .shipping: + return String.Localized.shipping_address + case .delivery: + return STPLocalizedString( + "Delivery Address", + "Title for delivery address entry section" + ) + } + } else { + return STPLocalizedString("Contact", "Title for contact info form") + } + } +} + +/// An `STPShippingAddressViewControllerDelegate` is notified when an `STPShippingAddressViewController` receives an address, completes with an address, or is cancelled. +@objc public protocol STPShippingAddressViewControllerDelegate: NSObjectProtocol { + /// Called when the user cancels entering a shipping address. You should dismiss (or pop) the view controller at this point. + /// - Parameter addressViewController: the view controller that has been cancelled + func shippingAddressViewControllerDidCancel( + _ addressViewController: STPShippingAddressViewController + ) + /// This is called when the user enters a shipping address and taps next. You + /// should validate the address and determine what shipping methods are available, + /// and call the `completion` block when finished. If an error occurrs, call + /// the `completion` block with the error. Otherwise, call the `completion` + /// block with a nil error and an array of available shipping methods. If you don't + /// need to collect a shipping method, you may pass an empty array or nil. + /// - Parameters: + /// - addressViewController: the view controller where the address was entered + /// - address: the address that was entered. - seealso: STPAddress + /// - completion: call this callback when you're done validating the address and determining available shipping methods. + + @objc(shippingAddressViewController:didEnterAddress:completion:) + func shippingAddressViewController( + _ addressViewController: STPShippingAddressViewController, + didEnter address: STPAddress, + completion: @escaping STPShippingMethodsCompletionBlock + ) + /// This is called when the user selects a shipping method. If no shipping methods are given, or if the shipping type doesn't require a shipping method, this will be called after the user has a shipping address and your validation has succeeded. After updating your app with the user's shipping info, you should dismiss (or pop) the view controller. Note that if `shippingMethod` is non-nil, there will be an additional shipping methods view controller on the navigation controller's stack. + /// - Parameters: + /// - addressViewController: the view controller where the address was entered + /// - address: the address that was entered. - seealso: STPAddress + /// - method: the shipping method that was selected. + @objc(shippingAddressViewController:didFinishWithAddress:shippingMethod:) + func shippingAddressViewController( + _ addressViewController: STPShippingAddressViewController, + didFinishWith address: STPAddress, + shippingMethod method: PKShippingMethod? + ) +} + +extension STPShippingAddressViewController: + STPAddressViewModelDelegate, UITableViewDelegate, UITableViewDataSource, + STPShippingMethodsViewControllerDelegate +{ + + // MARK: - UITableView + /// :nodoc: + @objc + public func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + /// :nodoc: + @objc + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return addressViewModel.addressCells.count + } + + /// :nodoc: + @objc + public func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = + addressViewModel.addressCells.stp_boundSafeObject(at: indexPath.row) + cell?.backgroundColor = theme.secondaryBackgroundColor + cell?.contentView.backgroundColor = UIColor.clear + return cell! + } + + /// :nodoc: + @objc + public func tableView( + _ tableView: UITableView, + willDisplay cell: UITableViewCell, + forRowAt indexPath: IndexPath + ) { + let topRow = indexPath.row == 0 + let bottomRow = tableView.numberOfRows(inSection: indexPath.section) - 1 == indexPath.row + cell.stp_setBorderColor(theme.tertiaryBackgroundColor) + cell.stp_setTopBorderHidden(!topRow) + cell.stp_setBottomBorderHidden(!bottomRow) + cell.stp_setFakeSeparatorColor(theme.quaternaryBackgroundColor) + cell.stp_setFakeSeparatorLeftInset(15.0) + } + + /// :nodoc: + @objc + public func tableView( + _ tableView: UITableView, + heightForFooterInSection section: Int + ) + -> CGFloat + { + return 0.01 + } + + /// :nodoc: + @objc + public func tableView( + _ tableView: UITableView, + viewForFooterInSection section: Int + ) + -> UIView? + { + return UIView() + } + + /// :nodoc: + @objc + public func tableView( + _ tableView: UITableView, + viewForHeaderInSection section: Int + ) + -> UIView? + { + return addressHeaderView + } + + // MARK: - STPShippingMethodsViewControllerDelegate + func shippingMethodsViewController( + _ methodsViewController: STPShippingMethodsViewController, + didFinishWith method: PKShippingMethod + ) { + delegate?.shippingAddressViewController( + self, + didFinishWith: addressViewModel.address, + shippingMethod: method + ) + } + + // MARK: - STPAddressViewModelDelegate + func addressViewModel(_ addressViewModel: STPAddressViewModel, addedCellAt index: Int) { + let indexPath = IndexPath(row: index, section: 0) + tableView?.insertRows(at: [indexPath], with: .automatic) + } + + func addressViewModel(_ addressViewModel: STPAddressViewModel, removedCellAt index: Int) { + let indexPath = IndexPath(row: index, section: 0) + tableView?.deleteRows(at: [indexPath], with: .automatic) + } + + func addressViewModelDidChange(_ addressViewModel: STPAddressViewModel) { + updateDoneButton() + } + + func addressViewModelWillUpdate(_ addressViewModel: STPAddressViewModel) { + tableView?.beginUpdates() + } + + func addressViewModelDidUpdate(_ addressViewModel: STPAddressViewModel) { + tableView?.endUpdates() + } +} + +/// :nodoc: +@_spi(STP) extension STPShippingAddressViewController: STPAnalyticsProtocol { + @_spi(STP) public static var stp_analyticsIdentifier = "STPShippingAddressViewController" +} diff --git a/Stripe/StripeiOS/Source/STPShippingMethodTableViewCell.swift b/Stripe/StripeiOS/Source/STPShippingMethodTableViewCell.swift new file mode 100644 index 00000000..f40c493c --- /dev/null +++ b/Stripe/StripeiOS/Source/STPShippingMethodTableViewCell.swift @@ -0,0 +1,147 @@ +// +// STPShippingMethodTableViewCell.swift +// StripeiOS +// +// Created by Ben Guo on 8/30/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import PassKit +@_spi(STP) import StripePayments +import UIKit + +class STPShippingMethodTableViewCell: UITableViewCell { + private var _theme: STPTheme? + var theme: STPTheme? { + get { + _theme + } + set(theme) { + _theme = theme + updateAppearance() + } + } + + func setShippingMethod(_ method: PKShippingMethod, currency: String) { + shippingMethod = method + titleLabel?.text = method.label + subtitleLabel?.text = method.detail + var localeInfo = [ + NSLocale.Key.currencyCode.rawValue: currency + ] + localeInfo[NSLocale.Key.languageCode.rawValue] = NSLocale.preferredLanguages.first ?? "" + let localeID = NSLocale.localeIdentifier(fromComponents: localeInfo) + let locale = NSLocale(localeIdentifier: localeID) + numberFormatter?.locale = locale as Locale + let amount = method.amount.stp_amount(withCurrency: currency) + if amount == 0 { + amountLabel?.text = STPLocalizedString("Free", "Label for free shipping method") + } else { + let number = NSDecimalNumber.stp_decimalNumber( + withAmount: amount, + currency: currency + ) + amountLabel?.text = numberFormatter?.string(from: number) + } + setNeedsLayout() + } + + private weak var titleLabel: UILabel? + private weak var subtitleLabel: UILabel? + private weak var amountLabel: UILabel? + private weak var checkmarkIcon: UIImageView? + private var shippingMethod: PKShippingMethod? + private var numberFormatter: NumberFormatter? + + override init( + style: UITableViewCell.CellStyle, + reuseIdentifier: String? + ) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + theme = STPTheme() + let titleLabel = UILabel() + self.titleLabel = titleLabel + let subtitleLabel = UILabel() + self.subtitleLabel = subtitleLabel + let amountLabel = UILabel() + self.amountLabel = amountLabel + let checkmarkIcon = UIImageView(image: STPLegacyImageLibrary.checkmarkIcon()) + self.checkmarkIcon = checkmarkIcon + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.usesGroupingSeparator = true + numberFormatter = formatter + contentView.addSubview(titleLabel) + contentView.addSubview(subtitleLabel) + contentView.addSubview(amountLabel) + contentView.addSubview(checkmarkIcon) + updateAppearance() + } + + override var isSelected: Bool { + get { + return super.isSelected + } + set(selected) { + super.isSelected = selected + updateAppearance() + } + } + + @objc func updateAppearance() { + contentView.backgroundColor = theme?.secondaryBackgroundColor + backgroundColor = UIColor.clear + titleLabel?.font = theme?.font + subtitleLabel?.font = theme?.smallFont + amountLabel?.font = theme?.font + titleLabel?.textColor = isSelected ? theme?.accentColor : theme?.primaryForegroundColor + amountLabel?.textColor = titleLabel?.textColor + var subduedAccentColor: UIColor? + if #available(iOS 13.0, *) { + subduedAccentColor = UIColor(dynamicProvider: { _ in + return self.theme?.accentColor.withAlphaComponent(0.6) ?? UIColor.clear + }) + } else { + subduedAccentColor = theme?.accentColor.withAlphaComponent(0.6) + } + subtitleLabel?.textColor = isSelected ? subduedAccentColor : theme?.secondaryForegroundColor + checkmarkIcon?.tintColor = theme?.accentColor + checkmarkIcon?.isHidden = !isSelected + } + + override func layoutSubviews() { + super.layoutSubviews() + let midY = bounds.midY + checkmarkIcon?.frame = CGRect(x: 0, y: 0, width: 14, height: 14) + checkmarkIcon?.center = CGPoint( + x: bounds.width - 15 - (checkmarkIcon?.bounds.midX ?? 0.0), + y: midY + ) + amountLabel?.sizeToFit() + amountLabel?.center = CGPoint( + x: (checkmarkIcon?.frame.minX ?? 0.0) - 15 - (amountLabel?.bounds.midX ?? 0.0), + y: midY + ) + let labelWidth = (amountLabel?.frame.minX ?? 0.0) - 30 + titleLabel?.sizeToFit() + titleLabel?.frame = CGRect( + x: 15, + y: 8, + width: labelWidth, + height: titleLabel?.frame.size.height ?? 0.0 + ) + subtitleLabel?.sizeToFit() + subtitleLabel?.frame = CGRect( + x: 15, + y: bounds.size.height - 8 - (subtitleLabel?.frame.size.height ?? 0.0), + width: labelWidth, + height: subtitleLabel?.frame.size.height ?? 0.0 + ) + } + + required init?( + coder aDecoder: NSCoder + ) { + super.init(coder: aDecoder) + } +} diff --git a/Stripe/StripeiOS/Source/STPShippingMethodsViewController.swift b/Stripe/StripeiOS/Source/STPShippingMethodsViewController.swift new file mode 100644 index 00000000..8d915855 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPShippingMethodsViewController.swift @@ -0,0 +1,211 @@ +// +// STPShippingMethodsViewController.swift +// StripeiOS +// +// Created by Ben Guo on 8/29/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import PassKit +@_spi(STP) import StripeCore +import UIKit + +class STPShippingMethodsViewController: STPCoreTableViewController, UITableViewDataSource, + UITableViewDelegate +{ + init( + shippingMethods methods: [PKShippingMethod], + selectedShippingMethod selectedMethod: PKShippingMethod, + currency: String, + theme: STPTheme + ) { + super.init(theme: theme) + shippingMethods = methods + if (methods.firstIndex(of: selectedMethod) ?? NSNotFound) != NSNotFound { + selectedShippingMethod = selectedMethod + } else { + selectedShippingMethod = methods.stp_boundSafeObject(at: 0) + } + + self.currency = currency + title = STPLocalizedString("Shipping", "Title for shipping info form") + } + + weak var delegate: STPShippingMethodsViewControllerDelegate? + private var shippingMethods: [PKShippingMethod]? + private var selectedShippingMethod: PKShippingMethod? + private var currency: String? + private weak var imageView: UIImageView? + private var doneItem: UIBarButtonItem? + private var headerView: STPSectionHeaderView? + + override func createAndSetupViews() { + super.createAndSetupViews() + + tableView?.register( + STPShippingMethodTableViewCell.self, + forCellReuseIdentifier: STPShippingMethodCellReuseIdentifier + ) + + let doneItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(done(_:)) + ) + self.doneItem = doneItem + stp_navigationItemProxy?.rightBarButtonItem = doneItem + stp_navigationItemProxy?.rightBarButtonItem?.accessibilityIdentifier = + "ShippingMethodsViewControllerDoneButtonIdentifier" + + let imageView = UIImageView(image: STPLegacyImageLibrary.largeShippingImage()) + imageView.contentMode = .center + imageView.frame = CGRect( + x: 0, + y: 0, + width: view.bounds.size.width, + height: imageView.bounds.size.height + (57 * 2) + ) + self.imageView = imageView + + tableView?.tableHeaderView = imageView + tableView?.dataSource = self + tableView?.delegate = self + tableView?.reloadData() + + let headerView = STPSectionHeaderView() + headerView.theme = theme + headerView.buttonHidden = true + headerView.title = STPLocalizedString("Shipping Method", "Label for shipping method form") + headerView.setNeedsLayout() + self.headerView = headerView + } + + @objc override func updateAppearance() { + super.updateAppearance() + + let navBarTheme = navigationController?.navigationBar.stp_theme ?? theme + doneItem?.stp_setTheme(navBarTheme) + + imageView?.tintColor = theme.accentColor + for cell in tableView?.visibleCells ?? [] { + let shippingCell = cell as? STPShippingMethodTableViewCell + shippingCell?.theme = theme + } + } + + @objc func done(_ sender: Any?) { + if let selectedShippingMethod = selectedShippingMethod { + delegate?.shippingMethodsViewController(self, didFinishWith: selectedShippingMethod) + } + } + + override func useSystemBackButton() -> Bool { + return true + } + + // MARK: - UITableView + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return shippingMethods?.count ?? 0 + } + + func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = + tableView.dequeueReusableCell( + withIdentifier: STPShippingMethodCellReuseIdentifier, + for: indexPath + ) + as? STPShippingMethodTableViewCell + let method = + shippingMethods?.stp_boundSafeObject(at: indexPath.row) + cell?.theme = theme + if let method = method { + cell?.setShippingMethod(method, currency: currency ?? "") + } + cell?.isSelected = method?.identifier == selectedShippingMethod?.identifier + return cell! + } + + func tableView( + _ tableView: UITableView, + willDisplay cell: UITableViewCell, + forRowAt indexPath: IndexPath + ) { + let topRow = indexPath.row == 0 + let bottomRow = + self.tableView(tableView, numberOfRowsInSection: indexPath.section) - 1 == indexPath.row + cell.stp_setBorderColor(theme.tertiaryBackgroundColor) + cell.stp_setTopBorderHidden(!topRow) + cell.stp_setBottomBorderHidden(!bottomRow) + cell.stp_setFakeSeparatorColor(theme.quaternaryBackgroundColor) + cell.stp_setFakeSeparatorLeftInset(15.0) + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return 57 + } + + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return 27.0 + } + + override func tableView( + _ tableView: UITableView, + heightForHeaderInSection section: Int + ) + -> CGFloat + { + let size = headerView?.sizeThatFits( + CGSize(width: view.bounds.size.width, height: CGFloat.greatestFiniteMagnitude) + ) + return size?.height ?? 0.0 + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + return headerView + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + selectedShippingMethod = + shippingMethods?.stp_boundSafeObject(at: indexPath.row) + tableView.reloadSections( + NSIndexSet(index: indexPath.section) as IndexSet, + with: .fade + ) + } + + required init?( + coder aDecoder: NSCoder + ) { + super.init(coder: aDecoder) + } + + required init( + nibName nibNameOrNil: String?, + bundle nibBundleOrNil: Bundle? + ) { + fatalError("init(nibName:bundle:) has not been implemented") + } + + required init( + theme: STPTheme? + ) { + fatalError("init(theme:) has not been implemented") + } +} + +@objc protocol STPShippingMethodsViewControllerDelegate: NSObjectProtocol { + func shippingMethodsViewController( + _ methodsViewController: STPShippingMethodsViewController, + didFinishWith method: PKShippingMethod + ) +} + +private let STPShippingMethodCellReuseIdentifier = "STPShippingMethodCellReuseIdentifier" diff --git a/Stripe/StripeiOS/Source/STPSource+BasicUI.swift b/Stripe/StripeiOS/Source/STPSource+BasicUI.swift new file mode 100644 index 00000000..67510e70 --- /dev/null +++ b/Stripe/StripeiOS/Source/STPSource+BasicUI.swift @@ -0,0 +1,74 @@ +// +// STPSource+BasicUI.swift +// StripeiOS +// +// Created by David Estes on 6/30/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripePayments +import UIKit + +extension STPSource: STPPaymentOption { + // MARK: - STPPaymentOption + @objc public var image: UIImage { + if type == .card, let cardDetails = cardDetails { + return STPImageLibrary.cardBrandImage(for: cardDetails.brand) + } else { + return STPImageLibrary.cardBrandImage(for: .unknown) + } + } + + @objc public var templateImage: UIImage { + if type == .card, let cardDetails = cardDetails { + return STPImageLibrary.templatedBrandImage(for: cardDetails.brand) + } else { + return STPImageLibrary.templatedBrandImage(for: .unknown) + } + } + + @objc public var label: String { + switch type { + case .bancontact: + return STPPaymentMethodType.bancontact.displayName + case .card: + if let cardDetails = cardDetails { + let brand = STPCard.string(from: cardDetails.brand) + return "\(brand) \(cardDetails.last4 ?? "")" + } else { + return STPCard.string(from: .unknown) + } + case .giropay: + return STPPaymentMethodType.giropay.displayName + case .iDEAL: + return STPPaymentMethodType.iDEAL.displayName + case .SEPADebit: + return STPPaymentMethodType.SEPADebit.displayName + case .sofort: + return STPPaymentMethodType.sofort.displayName + case .threeDSecure: + return STPLocalizedString("3D Secure", "Source type brand name") + case .alipay: + return STPPaymentMethodType.alipay.displayName + case .P24: + return STPPaymentMethodType.przelewy24.displayName + case .EPS: + return STPPaymentMethodType.EPS.displayName + case .multibanco: + return STPLocalizedString("Multibanco", "Source type brand name") + case .weChatPay: + return STPPaymentMethodType.weChatPay.displayName + case .klarna: + return STPPaymentMethodType.klarna.displayName + case .unknown: + return STPPaymentMethodType.unknown.displayName + @unknown default: + return STPPaymentMethodType.unknown.displayName + } + } + + @objc public var isReusable: Bool { + return usage != .singleUse + } +} diff --git a/Stripe/StripeiOS/Source/STPTheme.swift b/Stripe/StripeiOS/Source/STPTheme.swift new file mode 100644 index 00000000..2a36f1ef --- /dev/null +++ b/Stripe/StripeiOS/Source/STPTheme.swift @@ -0,0 +1,212 @@ +// +// STPTheme.swift +// StripeiOS +// +// Created by Jack Flintermann on 5/3/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeUICore +import UIKit + +// swift-format-ignore: DontRepeatTypeInStaticProperties +/// STPTheme objects can be used to visually style Stripe-provided UI. See https://stripe.com/docs/mobile/ios/basic#theming for more information. +final public class STPTheme: NSObject { + + /// The default theme used by all Stripe UI. All themable UI classes, such as `STPAddCardViewController`, have one initializer that takes a `theme` and one that does not. If you use the one that does not, the default theme will be used to customize that view controller's appearance. + @objc public static let defaultTheme = STPTheme() + + /// :nodoc: + @available(*, deprecated, message: "Use defaultTheme instead", renamed: "defaultTheme") + public static func `default`() -> STPTheme { + return STPTheme.defaultTheme + } + + /// The primary background color of the theme. This will be used as the `backgroundColor` for any views with this theme. + @objc public var primaryBackgroundColor: UIColor = STPThemeDefaultPrimaryBackgroundColor + + /// The secondary background color of this theme. This will be used as the `backgroundColor` for any supplemental views inside a view with this theme - for example, a `UITableView` will set it's cells' background color to this value. + @objc public var secondaryBackgroundColor: UIColor = STPThemeDefaultSecondaryBackgroundColor + + /// This color is automatically derived by reducing the alpha of the `primaryBackgroundColor` and is used as a section border color in table view cells. + @objc public var tertiaryBackgroundColor: UIColor { + let colorBlock: STPColorBlock = { + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + self.primaryBackgroundColor.getHue( + &hue, + saturation: &saturation, + brightness: &brightness, + alpha: &alpha + ) + + return UIColor( + hue: hue, + saturation: saturation, + brightness: brightness - 0.09, + alpha: alpha + ) + } + return UIColor(dynamicProvider: { _ in + return colorBlock() + }) + } + + /// This color is automatically derived by reducing the brightness of the `primaryBackgroundColor` and is used as a separator color in table view cells. + @objc public var quaternaryBackgroundColor: UIColor { + let colorBlock: STPColorBlock = { + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + self.primaryBackgroundColor.getHue( + &hue, + saturation: &saturation, + brightness: &brightness, + alpha: &alpha + ) + + return UIColor( + hue: hue, + saturation: saturation, + brightness: brightness - 0.03, + alpha: alpha + ) + } + return UIColor(dynamicProvider: { _ in + return colorBlock() + }) + } + + /// The primary foreground color of this theme. This will be used as the text color for any important labels in a view with this theme (such as the text color for a text field that the user needs to fill out). + @objc public var primaryForegroundColor: UIColor = STPThemeDefaultPrimaryForegroundColor + + /// The secondary foreground color of this theme. This will be used as the text color for any supplementary labels in a view with this theme (such as the placeholder color for a text field that the user needs to fill out). + @objc public var secondaryForegroundColor: UIColor = STPThemeDefaultSecondaryForegroundColor + + /// This color is automatically derived from the `secondaryForegroundColor` with a lower alpha component, used for disabled text. + @objc public var tertiaryForegroundColor: UIColor { + return UIColor(dynamicProvider: { _ in + return self.primaryForegroundColor.withAlphaComponent(0.25) + }) + } + + /// The accent color of this theme - it will be used for any buttons and other elements on a view that are important to highlight. + @objc public var accentColor: UIColor = STPThemeDefaultAccentColor + + /// The error color of this theme - it will be used for rendering any error messages or views. + @objc public var errorColor: UIColor = STPThemeDefaultErrorColor + + /// The font to be used for all views using this theme. Make sure to select an appropriate size. + @objc public var font: UIFont { + get { + if let _font = _font { + return _font + } else { + let fontMetrics = UIFontMetrics(forTextStyle: .body) + return fontMetrics.scaledFont(for: STPThemeDefaultFont) + } + } + set { + _font = newValue + } + } + private var _font: UIFont? + + /// The medium-weight font to be used for all bold text in views using this theme. Make sure to select an appropriate size. + @objc public var emphasisFont: UIFont { + get { + if let _emphasisFont = _emphasisFont { + return _emphasisFont + } else { + let fontMetrics = UIFontMetrics(forTextStyle: .body) + return fontMetrics.scaledFont(for: STPThemeDefaultMediumFont) + } + } + set { + _emphasisFont = newValue + } + } + private var _emphasisFont: UIFont? + + /// The navigation bar style to use for any view controllers presented modally + /// by the SDK. The default value will be determined based on the brightness + /// of the theme's `secondaryBackgroundColor`. + @objc public var barStyle: UIBarStyle { + get { + if let _barStyle = _barStyle { + return _barStyle + } else { + return barStyle(for: secondaryBackgroundColor) + } + } + set { + _barStyle = newValue + } + } + private var _barStyle: UIBarStyle? + + /// A Boolean value indicating whether the navigation bar for any view controllers + /// presented modally by the SDK should be translucent. The default value is YES. + @objc public var translucentNavigationBar = true + + /// This font is automatically derived from the font, with a slightly lower point size, and will be used for supplementary labels. + @objc public var smallFont: UIFont { + return font.withSize(max(font.pointSize - 2, 1)) + } + + /// This font is automatically derived from the font, with a larger point size, and will be used for large labels such as SMS code entry. + @objc public var largeFont: UIFont { + return font.withSize(font.pointSize + 15) + } + + private func barStyle(for color: UIColor) -> UIBarStyle { + if color.isBright { + return .default + } else { + return .black + } + } +} + +extension STPTheme: NSCopying { + /// :nodoc: + @objc + public func copy(with zone: NSZone? = nil) -> Any { + let otherTheme = STPTheme() + otherTheme.primaryBackgroundColor = primaryBackgroundColor + otherTheme.secondaryBackgroundColor = secondaryBackgroundColor + otherTheme.primaryForegroundColor = primaryForegroundColor + otherTheme.secondaryForegroundColor = secondaryForegroundColor + otherTheme.accentColor = accentColor + otherTheme.errorColor = errorColor + otherTheme.translucentNavigationBar = translucentNavigationBar + otherTheme._font = _font + otherTheme._emphasisFont = _emphasisFont + otherTheme._barStyle = _barStyle + + return otherTheme + } +} + +private typealias STPColorBlock = () -> UIColor + +// MARK: Default Colors + +private var STPThemeDefaultPrimaryBackgroundColor: UIColor = .secondarySystemBackground + +private var STPThemeDefaultSecondaryBackgroundColor: UIColor = .systemBackground + +private var STPThemeDefaultPrimaryForegroundColor: UIColor = .label + +private var STPThemeDefaultSecondaryForegroundColor: UIColor = .secondaryLabel + +private var STPThemeDefaultAccentColor: UIColor = .systemBlue + +private var STPThemeDefaultErrorColor: UIColor = .systemRed + +// MARK: Default Fonts +private let STPThemeDefaultFont = UIFont.systemFont(ofSize: 17) +private let STPThemeDefaultMediumFont = UIFont.systemFont(ofSize: 17, weight: .medium) diff --git a/Stripe/StripeiOS/Source/STPUserInformation.swift b/Stripe/StripeiOS/Source/STPUserInformation.swift new file mode 100644 index 00000000..5e6eb2cd --- /dev/null +++ b/Stripe/StripeiOS/Source/STPUserInformation.swift @@ -0,0 +1,42 @@ +// +// STPUserInformation.swift +// StripeiOS +// +// Created by Jack Flintermann on 6/15/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// You can use this class to specify information that you've already collected +/// from your user. You can then set the `prefilledInformation` property on +/// `STPPaymentContext`, `STPAddCardViewController`, etc and it will pre-fill +/// this information whenever possible. +public class STPUserInformation: NSObject, NSCopying { + /// The user's billing address. When set, the add card form will be filled with + /// this address. The user will also have the option to fill their shipping address + /// using this address. + /// @note Set this using `setBillingAddressWithBillingDetails:` to use the billing + /// details from an `STPPaymentMethod` or `STPPaymentMethodParams` instance. + @objc public var billingAddress: STPAddress? + /// The user's shipping address. When set, the shipping address form will be filled + /// with this address. The user will also have the option to fill their billing + /// address using this address. + @objc public var shippingAddress: STPAddress? + + /// A convenience method to populate `billingAddress` with a PaymentMethod's billing details. + /// @note Calling this overwrites the value of `billingAddress`. + @objc(setBillingAddressWithBillingDetails:) + public func setBillingAddress(with billingDetails: STPPaymentMethodBillingDetails) { + billingAddress = STPAddress(paymentMethodBillingDetails: billingDetails) + } + + /// :nodoc: + @objc + public func copy(with zone: NSZone? = nil) -> Any { + let copy = STPUserInformation() + copy.billingAddress = billingAddress + copy.shippingAddress = shippingAddress + return copy + } +} diff --git a/Stripe/StripeiOS/Source/String+Localized.swift b/Stripe/StripeiOS/Source/String+Localized.swift new file mode 100644 index 00000000..55bd59dd --- /dev/null +++ b/Stripe/StripeiOS/Source/String+Localized.swift @@ -0,0 +1,22 @@ +// +// String+Localized.swift +// StripeiOS +// +// Created by Mel Ludowise on 7/6/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripePaymentsUI +@_spi(STP) import StripeUICore + +// MARK: - Legacy strings + +/// Legacy strings +extension StripeSharedStrings { + static func localizedPostalCodeString(for countryCode: String?) -> String { + return countryCode == "US" + ? String.Localized.zip : String.Localized.postal_code + } +} diff --git a/Stripe/StripeiOS/Source/Stripe+Exports.swift b/Stripe/StripeiOS/Source/Stripe+Exports.swift new file mode 100644 index 00000000..16160f1e --- /dev/null +++ b/Stripe/StripeiOS/Source/Stripe+Exports.swift @@ -0,0 +1,13 @@ +// +// Stripe+Exports.swift +// StripeiOS +// +// This file re-exports classes from Stripe's dependent SDKs, enabling users to use classes from all +// Stripe Payments SDKs with one `import Stripe` declaration. +// + +import Foundation +@_exported import StripeApplePay +@_exported import StripeCore +@_exported import StripePayments +@_exported import StripePaymentsUI diff --git a/Stripe/StripeiOS/Source/StripeBundleLocator.swift b/Stripe/StripeiOS/Source/StripeBundleLocator.swift new file mode 100644 index 00000000..2ffd6423 --- /dev/null +++ b/Stripe/StripeiOS/Source/StripeBundleLocator.swift @@ -0,0 +1,20 @@ +// +// StripeBundleLocator.swift +// StripeiOS +// +// Created by Mel Ludowise on 7/6/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +/// :nodoc: +@_spi(STP) public final class StripeBundleLocator: BundleLocatorProtocol { + public static let internalClass: AnyClass = StripeBundleLocator.self + public static let bundleName = "Stripe" + #if SWIFT_PACKAGE + public static let spmResourcesBundle = Bundle.module + #endif + public static let resourcesBundle = StripeBundleLocator.computeResourcesBundle() +} diff --git a/Stripe/StripeiOS/Source/UIBarButtonItem+Stripe.swift b/Stripe/StripeiOS/Source/UIBarButtonItem+Stripe.swift new file mode 100644 index 00000000..2c792c1b --- /dev/null +++ b/Stripe/StripeiOS/Source/UIBarButtonItem+Stripe.swift @@ -0,0 +1,54 @@ +// +// UIBarButtonItem+Stripe.swift +// StripeiOS +// +// Created by Jack Flintermann on 5/18/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeUICore +import UIKit + +extension UIBarButtonItem { + @objc(stp_setTheme:) func stp_setTheme(_ theme: STPTheme) { + let image = backgroundImage(for: .normal, barMetrics: .default) + if let image = image { + let enabledImage: UIImage = STPLegacyImageLibrary.image( + withTintColor: theme.accentColor, + for: image + ) + let disabledImage: UIImage = STPLegacyImageLibrary.image( + withTintColor: theme.secondaryForegroundColor, + for: image + ) + setBackgroundImage(enabledImage, for: .normal, barMetrics: .default) + setBackgroundImage(disabledImage, for: .disabled, barMetrics: .default) + } + + tintColor = isEnabled ? theme.accentColor : theme.secondaryForegroundColor + + setTitleTextAttributes( + [ + NSAttributedString.Key.font: style == .plain ? theme.font : theme.emphasisFont, + NSAttributedString.Key.foregroundColor: theme.accentColor, + ], + for: .normal + ) + + setTitleTextAttributes( + [ + NSAttributedString.Key.font: style == .plain ? theme.font : theme.emphasisFont, + NSAttributedString.Key.foregroundColor: theme.secondaryForegroundColor, + ], + for: .disabled + ) + + setTitleTextAttributes( + [ + NSAttributedString.Key.font: style == .plain ? theme.font : theme.emphasisFont, + NSAttributedString.Key.foregroundColor: theme.accentColor, + ], + for: .highlighted + ) + } +} diff --git a/Stripe/StripeiOS/Source/UINavigationBar+Stripe_Theme.swift b/Stripe/StripeiOS/Source/UINavigationBar+Stripe_Theme.swift new file mode 100644 index 00000000..d46ee524 --- /dev/null +++ b/Stripe/StripeiOS/Source/UINavigationBar+Stripe_Theme.swift @@ -0,0 +1,142 @@ +// +// UINavigationBar+Stripe_Theme.swift +// StripeiOS +// +// Created by Jack Flintermann on 5/17/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import ObjectiveC +import UIKit + +/// This allows quickly setting the appearance of a `UINavigationBar` to match your +/// application. This is useful if you're presenting an `STPAddCardViewController` +/// or `STPPaymentOptionsViewController` inside a `UINavigationController`. +extension UINavigationBar { + /// Sets the navigation bar's appearance to the desired theme. This will affect the + /// bar's `tintColor` and `barTintColor` properties, as well as the color of the + /// single-pixel line at the bottom of the navbar. + /// - Parameter theme: the theme to use to style the navigation bar. - seealso: STPTheme.h + /// @deprecated Use the `stp_theme` property instead + @available(*, deprecated, message: "Use the `stp_theme` property instead") + @objc + public func stp_setTheme(_ theme: STPTheme) { + stp_theme = theme + } + + /// Sets the navigation bar's appearance to the desired theme. This will affect the bar's `tintColor` and `barTintColor` properties, as well as the color of the single-pixel line at the bottom of the navbar. + /// Stripe view controllers will use their navigation bar's theme for their UIBarButtonItems instead of their own theme if it is not nil. + /// - seealso: STPTheme.h + + @objc public var stp_theme: STPTheme? { + get { + return objc_getAssociatedObject( + self, + UnsafeRawPointer(&kUINavigationBarSTPThemeObjectKey) + ) + as? STPTheme + } + set(theme) { + objc_setAssociatedObject( + self, + UnsafeRawPointer(&kUINavigationBarSTPThemeObjectKey), + theme, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + + if let hairlineImageView = stp_hairlineImageView() { + hairlineImageView.isHidden = theme != nil + } + + guard let theme = theme else { + return + } + + stp_artificialHairlineView().backgroundColor = theme.tertiaryBackgroundColor + barTintColor = theme.secondaryBackgroundColor + tintColor = theme.accentColor + barStyle = theme.barStyle + isTranslucent = theme.translucentNavigationBar + + titleTextAttributes = [ + NSAttributedString.Key.font: theme.emphasisFont, + NSAttributedString.Key.foregroundColor: theme.primaryForegroundColor, + ] + + largeTitleTextAttributes = [ + NSAttributedString.Key.foregroundColor: theme.primaryForegroundColor + ] + + standardAppearance.backgroundColor = theme.secondaryBackgroundColor + if let titleTextAttributes = titleTextAttributes { + standardAppearance.titleTextAttributes = titleTextAttributes + } + if let largeTitleTextAttributes = largeTitleTextAttributes { + standardAppearance.largeTitleTextAttributes = largeTitleTextAttributes + } + standardAppearance.buttonAppearance.normal.titleTextAttributes = [ + NSAttributedString.Key.font: theme.font, + NSAttributedString.Key.foregroundColor: theme.accentColor, + ] + + standardAppearance.buttonAppearance.highlighted.titleTextAttributes = [ + NSAttributedString.Key.font: theme.font, + NSAttributedString.Key.foregroundColor: theme.accentColor, + ] + + standardAppearance.buttonAppearance.disabled.titleTextAttributes = [ + NSAttributedString.Key.font: theme.font, + NSAttributedString.Key.foregroundColor: theme.secondaryForegroundColor, + ] + + standardAppearance.doneButtonAppearance.normal.titleTextAttributes = [ + NSAttributedString.Key.font: theme.emphasisFont, + NSAttributedString.Key.foregroundColor: theme.accentColor, + ] + + standardAppearance.doneButtonAppearance.highlighted.titleTextAttributes = [ + NSAttributedString.Key.font: theme.emphasisFont, + NSAttributedString.Key.foregroundColor: theme.accentColor, + ] + + standardAppearance.doneButtonAppearance.disabled.titleTextAttributes = [ + NSAttributedString.Key.font: theme.emphasisFont, + NSAttributedString.Key.foregroundColor: theme.secondaryForegroundColor, + ] + scrollEdgeAppearance = standardAppearance + compactAppearance = standardAppearance + } + } + + func stp_artificialHairlineView() -> UIView { + var view = viewWithTag(STPNavigationBarHairlineViewTag) + if view == nil { + view = UIView(frame: CGRect(x: 0, y: bounds.maxY, width: bounds.width, height: 0.5)) + view?.autoresizingMask = [.flexibleWidth, .flexibleTopMargin] + view?.tag = STPNavigationBarHairlineViewTag + if let view = view { + addSubview(view) + } + } + return view! + } + + func stp_hairlineImageView() -> UIImageView? { + return stp_hairlineImageView(self) + } + + func stp_hairlineImageView(_ view: UIView) -> UIImageView? { + if (view is UIImageView) && view.bounds.size.height <= 1.0 { + return (view as? UIImageView)! + } + for subview in view.subviews { + if let imageView = stp_hairlineImageView(subview) { + return imageView + } + } + return nil + } +} + +private let STPNavigationBarHairlineViewTag = 787473 +private var kUINavigationBarSTPThemeObjectKey = 0 diff --git a/Stripe/StripeiOS/Source/UINavigationController+Stripe_Completion.swift b/Stripe/StripeiOS/Source/UINavigationController+Stripe_Completion.swift new file mode 100644 index 00000000..f0ed8296 --- /dev/null +++ b/Stripe/StripeiOS/Source/UINavigationController+Stripe_Completion.swift @@ -0,0 +1,61 @@ +// +// UINavigationController+Stripe_Completion.swift +// StripeiOS +// +// Created by Jack Flintermann on 3/23/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import UIKit + +// See http://stackoverflow.com/questions/9906966/completion-handler-for-uinavigationcontroller-pushviewcontrolleranimated/33767837#33767837 for some discussion around why using CATransaction is unreliable here. + +extension UINavigationController { + func stp_push( + _ viewController: UIViewController?, + animated: Bool, + completion: @escaping STPVoidBlock + ) { + if let viewController = viewController { + pushViewController(viewController, animated: animated) + } + if transitionCoordinator != nil && animated { + transitionCoordinator?.animate(alongsideTransition: nil) { _ in + completion() + } + } else { + completion() + } + } + + func stp_popViewController( + animated: Bool, + completion: @escaping STPVoidBlock + ) { + popViewController(animated: animated) + if transitionCoordinator != nil && animated { + transitionCoordinator?.animate(alongsideTransition: nil) { _ in + completion() + } + } else { + completion() + } + } + + @objc(stp_popToViewController:animated:completion:) func stp_pop( + to viewController: UIViewController?, + animated: Bool, + completion: @escaping STPVoidBlock + ) { + if let viewController = viewController { + popToViewController(viewController, animated: animated) + } + if transitionCoordinator != nil && animated { + transitionCoordinator?.animate(alongsideTransition: nil) { _ in + completion() + } + } else { + completion() + } + } +} diff --git a/Stripe/StripeiOS/Source/UITableViewCell+Stripe_Borders.swift b/Stripe/StripeiOS/Source/UITableViewCell+Stripe_Borders.swift new file mode 100644 index 00000000..c70200a0 --- /dev/null +++ b/Stripe/StripeiOS/Source/UITableViewCell+Stripe_Borders.swift @@ -0,0 +1,103 @@ +// +// UITableViewCell+Stripe_Borders.swift +// StripeiOS +// +// Created by Jack Flintermann on 5/16/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import UIKit + +private let STPTableViewCellTopBorderTag = 787473 +private let STPTableViewCellBottomBorderTag = 787474 +private let STPTableViewCellFakeSeparatorTag = 787475 + +extension UITableViewCell { + @objc(stp_setBorderColor:) func stp_setBorderColor(_ color: UIColor?) { + stp_topBorderView()?.backgroundColor = color + stp_bottomBorderView()?.backgroundColor = color + } + + @objc(stp_setTopBorderHidden:) func stp_setTopBorderHidden(_ hidden: Bool) { + stp_topBorderView()?.isHidden = hidden + } + + @objc(stp_setBottomBorderHidden:) func stp_setBottomBorderHidden(_ hidden: Bool) { + stp_bottomBorderView()?.isHidden = hidden + stp_fakeSeparatorView()?.isHidden = !hidden + } + + @objc(stp_setFakeSeparatorLeftInset:) func stp_setFakeSeparatorLeftInset(_ leftInset: CGFloat) { + stp_fakeSeparatorView()?.frame = CGRect( + x: leftInset, + y: bounds.size.height - 0.5, + width: bounds.size.width - leftInset, + height: 0.5 + ) + } + + @objc(stp_setFakeSeparatorColor:) func stp_setFakeSeparatorColor(_ color: UIColor?) { + stp_fakeSeparatorView()?.backgroundColor = color + } + + func stp_topBorderView() -> UIView? { + var view = viewWithTag(STPTableViewCellTopBorderTag) + if view == nil { + view = UIView(frame: CGRect(x: 0, y: 0, width: bounds.size.width, height: 0.5)) + view?.autoresizingMask = [.flexibleWidth, .flexibleBottomMargin] + view?.tag = STPTableViewCellTopBorderTag + view?.backgroundColor = backgroundColor + view?.isHidden = true + view?.accessibilityIdentifier = "stp_topBorderView" + if let view = view { + addSubview(view) + } + } + return view + } + + func stp_bottomBorderView() -> UIView? { + var view = viewWithTag(STPTableViewCellBottomBorderTag) + if view == nil { + view = UIView( + frame: CGRect( + x: 0, + y: bounds.size.height - 0.5, + width: bounds.size.width, + height: 0.5 + ) + ) + view?.autoresizingMask = [.flexibleWidth, .flexibleTopMargin] + view?.tag = STPTableViewCellBottomBorderTag + view?.backgroundColor = backgroundColor + view?.isHidden = true + view?.accessibilityIdentifier = "stp_bottomBorderView" + if let view = view { + addSubview(view) + } + } + return view + } + + func stp_fakeSeparatorView() -> UIView? { + var view = viewWithTag(STPTableViewCellFakeSeparatorTag) + if view == nil { + view = UIView( + frame: CGRect( + x: 0, + y: bounds.size.height - 0.5, + width: bounds.size.width, + height: 0.5 + ) + ) + view?.autoresizingMask = [.flexibleWidth, .flexibleTopMargin] + view?.tag = STPTableViewCellFakeSeparatorTag + view?.backgroundColor = backgroundColor + view?.accessibilityIdentifier = "stp_fakeSeparatorView" + if let view = view { + addSubview(view) + } + } + return view + } +} diff --git a/Stripe/StripeiOS/Source/UIToolbar+Stripe_InputAccessory.swift b/Stripe/StripeiOS/Source/UIToolbar+Stripe_InputAccessory.swift new file mode 100644 index 00000000..4194764e --- /dev/null +++ b/Stripe/StripeiOS/Source/UIToolbar+Stripe_InputAccessory.swift @@ -0,0 +1,38 @@ +// +// UIToolbar+Stripe_InputAccessory.swift +// StripeiOS +// +// Created by Jack Flintermann on 4/22/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import UIKit + +extension UIToolbar { + @objc(stp_inputAccessoryToolbarWithTarget:action:) class func stp_inputAccessoryToolbar( + withTarget target: Any?, + action: Selector + ) -> Self { + let toolbar = self.init() + let flexibleItem = UIBarButtonItem( + barButtonSystemItem: .flexibleSpace, + target: nil, + action: nil + ) + let nextItem = UIBarButtonItem( + title: STPLocalizedString("Next", "Button to move to the next text entry field"), + style: .done, + target: target, + action: action + ) + toolbar.items = [flexibleItem, nextItem] + toolbar.autoresizingMask = .flexibleHeight + return toolbar + } + + @objc(stp_setEnabled:) func stp_setEnabled(_ enabled: Bool) { + for barButtonItem in items ?? [] { + barButtonItem.isEnabled = enabled + } + } +} diff --git a/Stripe/StripeiOS/Source/UIView+Helpers.swift b/Stripe/StripeiOS/Source/UIView+Helpers.swift new file mode 100644 index 00000000..11f2db09 --- /dev/null +++ b/Stripe/StripeiOS/Source/UIView+Helpers.swift @@ -0,0 +1,25 @@ +// +// UIView+Helpers.swift +// StripeiOS +// +// Created by Yuki Tokuhiro on 11/4/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import UIKit + +protocol SafeAreaLayoutGuide { + var leadingAnchor: NSLayoutXAxisAnchor { get } + var trailingAnchor: NSLayoutXAxisAnchor { get } + var leftAnchor: NSLayoutXAxisAnchor { get } + var rightAnchor: NSLayoutXAxisAnchor { get } + var topAnchor: NSLayoutYAxisAnchor { get } + var bottomAnchor: NSLayoutYAxisAnchor { get } + var widthAnchor: NSLayoutDimension { get } + var heightAnchor: NSLayoutDimension { get } + var centerXAnchor: NSLayoutXAxisAnchor { get } + var centerYAnchor: NSLayoutYAxisAnchor { get } +} + +extension UIView: SafeAreaLayoutGuide {} +extension UILayoutGuide: SafeAreaLayoutGuide {} diff --git a/Stripe/StripeiOS/Source/UIView+Stripe_FirstResponder.swift b/Stripe/StripeiOS/Source/UIView+Stripe_FirstResponder.swift new file mode 100644 index 00000000..40267b0a --- /dev/null +++ b/Stripe/StripeiOS/Source/UIView+Stripe_FirstResponder.swift @@ -0,0 +1,24 @@ +// +// UIView+Stripe_FirstResponder.swift +// StripeiOS +// +// Created by Jack Flintermann on 4/15/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import UIKit + +extension UIView { + @objc func stp_findFirstResponder() -> UIView? { + if isFirstResponder { + return self + } + for subView in subviews { + let responder = subView.stp_findFirstResponder() + if let responder = responder { + return responder + } + } + return nil + } +} diff --git a/Stripe/StripeiOS/Source/UIView+Stripe_SafeAreaBounds.swift b/Stripe/StripeiOS/Source/UIView+Stripe_SafeAreaBounds.swift new file mode 100644 index 00000000..d29b49b8 --- /dev/null +++ b/Stripe/StripeiOS/Source/UIView+Stripe_SafeAreaBounds.swift @@ -0,0 +1,24 @@ +// +// UIView+Stripe_SafeAreaBounds.swift +// StripeiOS +// +// Created by Ben Guo on 12/12/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import UIKit + +extension UIView { + /// Returns this view's bounds inset to `safeAreaInsets.left` and `safeAreaInsets.right`. + /// Top and bottom safe area insets are ignored. On iOS <11, this returns self.bounds. + @objc func stp_boundsWithHorizontalSafeAreaInsets() -> CGRect { + let insets = safeAreaInsets + let safeBounds = CGRect( + x: bounds.origin.x + insets.left, + y: bounds.origin.y, + width: bounds.size.width - (insets.left + insets.right), + height: bounds.size.height + ) + return safeBounds + } +} diff --git a/Stripe/StripeiOS/Source/UIViewController+Stripe_KeyboardAvoiding.swift b/Stripe/StripeiOS/Source/UIViewController+Stripe_KeyboardAvoiding.swift new file mode 100644 index 00000000..ce6408dc --- /dev/null +++ b/Stripe/StripeiOS/Source/UIViewController+Stripe_KeyboardAvoiding.swift @@ -0,0 +1,166 @@ +// +// UIViewController+Stripe_KeyboardAvoiding.swift +// StripeiOS +// +// Created by Jack Flintermann on 4/15/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import UIKit + +typealias STPKeyboardFrameBlock = (CGRect, UIView?) -> Void +extension UIViewController { + @objc(stp_beginObservingKeyboardAndInsettingScrollView:onChangeBlock:) + func stp_beginObservingKeyboardAndInsettingScrollView( + _ scrollView: UIScrollView?, + onChange block: STPKeyboardFrameBlock? + ) { + if let existing = stp_keyboardDetectingViewController() { + existing.removeFromParent() + existing.view.removeFromSuperview() + existing.didMove(toParent: nil) + } + let keyboardAvoiding = STPKeyboardDetectingViewController( + keyboardFrameBlock: block, + scrollView: scrollView + ) + addChild(keyboardAvoiding) + view.addSubview(keyboardAvoiding.view) + keyboardAvoiding.didMove(toParent: self) + } + + @objc func stp_keyboardDetectingViewController() -> STPKeyboardDetectingViewController? { + return + (children as NSArray).filtered( + using: NSPredicate(block: { viewController, _ in + return viewController is STPKeyboardDetectingViewController + }) + ).first as? STPKeyboardDetectingViewController + } +} + +// This is a private class that is only a UIViewController subclass by virtue of the fact +// that that makes it easier to attach to another UIViewController as a child. +class STPKeyboardDetectingViewController: UIViewController { + var lastKeyboardFrame = CGRect.zero + weak var lastResponder: UIView? + var keyboardFrameBlock: STPKeyboardFrameBlock? + weak var managedScrollView: UIScrollView? + var currentBottomInsetChange: CGFloat = 0.0 + + init( + keyboardFrameBlock block: STPKeyboardFrameBlock?, + scrollView: UIScrollView? + ) { + keyboardFrameBlock = block + super.init(nibName: nil, bundle: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillChangeFrame(_:)), + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(textFieldWillBeginEditing(_:)), + name: UITextField.textDidBeginEditingNotification, + object: nil + ) + managedScrollView = scrollView + currentBottomInsetChange = 0 + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override func loadView() { + let view = UIView() + view.backgroundColor = UIColor.clear + view.autoresizingMask = [] + self.view = view + } + + @objc func textFieldWillBeginEditing(_ notification: Notification) { + guard let textField = notification.object as? UITextField, let parentView = parent?.view, + textField.isDescendant(of: parentView) + else { + return + } + if let keyboardFrameBlock = keyboardFrameBlock, + textField != lastResponder && !lastKeyboardFrame.isEmpty + { + UIView.animate( + withDuration: 0.3, + delay: 0, + options: .curveEaseOut, + animations: { + keyboardFrameBlock(self.lastKeyboardFrame, textField) + } + ) + } + } + + @objc func keyboardWillChangeFrame(_ notification: Notification) { + // As of iOS 8, this all takes place inside the necessary animation block + // https://twitter.com/SmileyKeith/status/684100833823174656 + guard + var keyboardFrame = + (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)? + .cgRectValue, + let window = view.window + else { + return + } + keyboardFrame = window.convert(keyboardFrame, from: nil) + + if managedScrollView != nil { + if !lastKeyboardFrame.equalTo(keyboardFrame) { + let responder = parent?.view.stp_findFirstResponder() + lastResponder = responder + doKeyboardChangeAnimation(withNewFrame: keyboardFrame) + } + } + } + + func doKeyboardChangeAnimation(withNewFrame keyboardFrame: CGRect) { + lastKeyboardFrame = keyboardFrame + + if let managedScrollView = managedScrollView { + let scrollView = managedScrollView + let scrollViewSuperView = managedScrollView.superview + + var contentInsets = scrollView.contentInset + var scrollIndicatorInsets: UIEdgeInsets = .zero + #if !TARGET_OS_MACCATALYST + scrollIndicatorInsets = scrollView.verticalScrollIndicatorInsets + #else + scrollIndicatorInsets = scrollView.scrollIndicatorInsets + #endif + + let windowFrame = scrollViewSuperView?.convert( + scrollViewSuperView?.frame ?? CGRect.zero, + to: nil + ) + + let bottomIntersection = windowFrame?.intersection(keyboardFrame) + let bottomInsetDelta = + (bottomIntersection?.size.height ?? 0.0) - currentBottomInsetChange + contentInsets.bottom += bottomInsetDelta + scrollIndicatorInsets.bottom += bottomInsetDelta + currentBottomInsetChange += bottomInsetDelta + scrollView.contentInset = contentInsets + scrollView.scrollIndicatorInsets = scrollIndicatorInsets + } + + if let keyboardFrameBlock = keyboardFrameBlock { + keyboardFrameBlock(keyboardFrame, lastResponder) + } + } + + required init?( + coder aDecoder: NSCoder + ) { + super.init(coder: aDecoder) + } +} diff --git a/Stripe/StripeiOS/Source/UIViewController+Stripe_NavigationItemProxy.swift b/Stripe/StripeiOS/Source/UIViewController+Stripe_NavigationItemProxy.swift new file mode 100644 index 00000000..0b5fd9ee --- /dev/null +++ b/Stripe/StripeiOS/Source/UIViewController+Stripe_NavigationItemProxy.swift @@ -0,0 +1,38 @@ +// +// UIViewController+Stripe_NavigationItemProxy.swift +// StripeiOS +// +// Created by Jack Flintermann on 6/9/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import ObjectiveC +import UIKit + +extension UIViewController { + @objc var stp_navigationItemProxy: UINavigationItem? { + get { + return objc_getAssociatedObject(self, UnsafeRawPointer(&kSTPNavigationItemProxyKey)) + as? UINavigationItem ?? self.navigationItem + } + set(stp_navigationItemProxy) { + objc_setAssociatedObject( + self, + UnsafeRawPointer(&kSTPNavigationItemProxyKey), + stp_navigationItemProxy, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + if navigationItem.leftBarButtonItem != nil { + stp_navigationItemProxy?.leftBarButtonItem = navigationItem.leftBarButtonItem + } + if navigationItem.rightBarButtonItem != nil { + stp_navigationItemProxy?.rightBarButtonItem = navigationItem.rightBarButtonItem + } + if navigationItem.title != nil { + stp_navigationItemProxy?.title = navigationItem.title + } + } + } +} + +private var kSTPNavigationItemProxyKey = 0 diff --git a/Stripe/StripeiOS/Source/UIViewController+Stripe_ParentViewController.swift b/Stripe/StripeiOS/Source/UIViewController+Stripe_ParentViewController.swift new file mode 100644 index 00000000..534172cb --- /dev/null +++ b/Stripe/StripeiOS/Source/UIViewController+Stripe_ParentViewController.swift @@ -0,0 +1,50 @@ +// +// UIViewController+Stripe_ParentViewController.swift +// StripeiOS +// +// Created by Jack Flintermann on 1/12/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import UIKit + +extension UIViewController { + @objc(stp_parentViewControllerOfClass:) func stp_parentViewControllerOf( + _ klass: AnyClass + ) + -> UIViewController? + { + if let parent = parent, parent.isKind(of: klass) { + return parent + } + return parent?.stp_parentViewControllerOf(klass) + } + + @objc func stp_isTopNavigationController() -> Bool { + return navigationController?.topViewController == self + } + + @objc func stp_isAtRootOfNavigationController() -> Bool { + let viewController = navigationController?.viewControllers.first + var tested: UIViewController? = self + while tested != nil { + if tested == viewController { + return true + } + if let parent = tested?.parent { + tested = parent + } else { + return false + } + } + return false + } + + @objc func stp_previousViewControllerInNavigation() -> UIViewController? { + let index = navigationController?.viewControllers.firstIndex(of: self) ?? NSNotFound + if index == NSNotFound || index <= 0 { + return nil + } + return navigationController?.viewControllers[index - 1] + } +} diff --git a/Stripe/StripeiOS/Source/UserDefaults+Stripe.swift b/Stripe/StripeiOS/Source/UserDefaults+Stripe.swift new file mode 100644 index 00000000..72ee855f --- /dev/null +++ b/Stripe/StripeiOS/Source/UserDefaults+Stripe.swift @@ -0,0 +1,29 @@ +// +// UserDefaults+Stripe.swift +// StripeiOS +// +// Created by Yuki Tokuhiro on 5/21/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +extension UserDefaults { + /// Canonical list of all UserDefaults keys the SDK uses + @_spi(STP) public enum StripeKeys: String { + /// The key for a dictionary of Customer id to their last selected payment method ID + case customerToLastSelectedPaymentMethod = "com.stripe.lib:STPStripeCustomerToLastSelectedPaymentMethodKey" + } + + @_spi(STP) public var customerToLastSelectedPaymentMethod: [String: String]? { + get { + let key = StripeKeys.customerToLastSelectedPaymentMethod.rawValue + return dictionary(forKey: key) as? [String: String] + } + set { + let key = StripeKeys.customerToLastSelectedPaymentMethod.rawValue + setValue(newValue, forKey: key) + } + } + +} diff --git a/Stripe/StripeiOS/Stripe-umbrella.h b/Stripe/StripeiOS/Stripe-umbrella.h new file mode 100644 index 00000000..b062ed0c --- /dev/null +++ b/Stripe/StripeiOS/Stripe-umbrella.h @@ -0,0 +1,11 @@ +// +// Stripe-umbrella.h +// StripeiOS +// +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +#ifndef Stripe_umbrella_h +#define Stripe_umbrella_h + +#endif /* Stripe_umbrella_h */ diff --git a/Stripe/StripeiOS/Stripe.modulemap b/Stripe/StripeiOS/Stripe.modulemap new file mode 100644 index 00000000..35dad19e --- /dev/null +++ b/Stripe/StripeiOS/Stripe.modulemap @@ -0,0 +1,6 @@ +framework module Stripe { + umbrella header "Stripe-umbrella.h" + + export * + module * { export * } +} diff --git a/Stripe/StripeiOSAppHostedTests/Info.plist b/Stripe/StripeiOSAppHostedTests/Info.plist new file mode 100644 index 00000000..64d65ca4 --- /dev/null +++ b/Stripe/StripeiOSAppHostedTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Stripe/StripeiOSAppHostedTests/LinkSecureCookieStoreTests.swift b/Stripe/StripeiOSAppHostedTests/LinkSecureCookieStoreTests.swift new file mode 100644 index 00000000..225d3b7f --- /dev/null +++ b/Stripe/StripeiOSAppHostedTests/LinkSecureCookieStoreTests.swift @@ -0,0 +1,76 @@ +// +// LinkSecureCookieStoreTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 12/22/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@testable import Stripe +@testable import StripePaymentSheet +import XCTest + +class LinkSecureCookieStoreTests: XCTestCase { + + static let testKey: LinkCookieKey = .session + + let cookieStore: LinkSecureCookieStore = .shared + + func testWrite() { + cookieStore.write(key: Self.testKey, value: "cookie_value") + + XCTAssertEqual(cookieStore.read(key: Self.testKey), "cookie_value") + } + + func testWrite_allowSyncTrue() { + cookieStore.write(key: Self.testKey, value: "cookie_value", allowSync: true) + + XCTAssertEqual(cookieStore.read(key: Self.testKey), "cookie_value") + } + + func testWrite_overwriting() { + cookieStore.write(key: Self.testKey, value: "cookie_value") + cookieStore.write(key: Self.testKey, value: "new_cookie_value") + + XCTAssertEqual(cookieStore.read(key: Self.testKey), "new_cookie_value") + } + + func testDelete() { + cookieStore.write(key: Self.testKey, value: "cookie_value") + cookieStore.delete(key: Self.testKey) + + XCTAssertNil(cookieStore.read(key: Self.testKey)) + } + + func testDelete_allowSyncTrue() { + cookieStore.write(key: Self.testKey, value: "cookie_value", allowSync: true) + cookieStore.delete(key: Self.testKey) + + XCTAssertNil(cookieStore.read(key: Self.testKey)) + } + + // MARK: Session cookies + + func testFormattedSessionCookies() { + cookieStore.write(key: .session, value: "cookie_value") + XCTAssertEqual(cookieStore.formattedSessionCookies(), [ + "verification_session_client_secrets": ["cookie_value"] + ]) + + cookieStore.delete(key: .session) + XCTAssertNil(cookieStore.formattedSessionCookies()) + } + + func testUpdateSessionCookie() { + cookieStore.updateSessionCookie(with: "top_secret") + XCTAssertEqual(cookieStore.read(key: .session), "top_secret") + + // Updating with a `nil` client secret should be a no-op. + cookieStore.updateSessionCookie(with: nil) + XCTAssertEqual(cookieStore.read(key: .session), "top_secret") + + cookieStore.updateSessionCookie(with: "") + XCTAssertNil(cookieStore.read(key: .session)) + } + +} diff --git a/Stripe/StripeiOSTestHostApp/AppDelegate.swift b/Stripe/StripeiOSTestHostApp/AppDelegate.swift new file mode 100644 index 00000000..729e9852 --- /dev/null +++ b/Stripe/StripeiOSTestHostApp/AppDelegate.swift @@ -0,0 +1,18 @@ +// +// AppDelegate.swift +// StripeiOSTestHostApp +// +// Created by Cameron Sabol on 11/4/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } +} diff --git a/Stripe/StripeiOSTestHostApp/Info.plist b/Stripe/StripeiOSTestHostApp/Info.plist new file mode 100644 index 00000000..5b531f7b --- /dev/null +++ b/Stripe/StripeiOSTestHostApp/Info.plist @@ -0,0 +1,66 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Stripe/StripeiOSTestHostApp/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Stripe/StripeiOSTestHostApp/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/Stripe/StripeiOSTestHostApp/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOSTestHostApp/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Stripe/StripeiOSTestHostApp/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..9221b9bb --- /dev/null +++ b/Stripe/StripeiOSTestHostApp/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOSTestHostApp/Resources/Assets.xcassets/Contents.json b/Stripe/StripeiOSTestHostApp/Resources/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Stripe/StripeiOSTestHostApp/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe/StripeiOSTestHostApp/Resources/Base.lproj/LaunchScreen.storyboard b/Stripe/StripeiOSTestHostApp/Resources/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..865e9329 --- /dev/null +++ b/Stripe/StripeiOSTestHostApp/Resources/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Stripe/StripeiOSTestHostApp/Resources/Base.lproj/Main.storyboard b/Stripe/StripeiOSTestHostApp/Resources/Base.lproj/Main.storyboard new file mode 100644 index 00000000..25a76385 --- /dev/null +++ b/Stripe/StripeiOSTestHostApp/Resources/Base.lproj/Main.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Stripe/StripeiOSTestHostApp/ViewController.swift b/Stripe/StripeiOSTestHostApp/ViewController.swift new file mode 100644 index 00000000..a8daad08 --- /dev/null +++ b/Stripe/StripeiOSTestHostApp/ViewController.swift @@ -0,0 +1,18 @@ +// +// ViewController.swift +// StripeiOSTestHostApp +// +// Created by Cameron Sabol on 11/4/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +class ViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + // Do any additional setup after loading the view. + } + +} diff --git a/Stripe/StripeiOSTests.xctestplan b/Stripe/StripeiOSTests.xctestplan new file mode 100644 index 00000000..2f5591e1 --- /dev/null +++ b/Stripe/StripeiOSTests.xctestplan @@ -0,0 +1,40 @@ +{ + "configurations" : [ + { + "id" : "E4E61B3B-0FEC-4C2A-BE82-E8558946FFBC", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : false, + "environmentVariableEntries" : [ + { + "key" : "FB_REFERENCE_IMAGE_DIR", + "value" : "$(SOURCE_ROOT)\/..\/Tests\/ReferenceImages" + } + ], + "targetForVariableExpansion" : { + "containerPath" : "container:Stripe.xcodeproj", + "identifier" : "ADF894AA8F6022D9BED17346", + "name" : "StripeiOS" + } + }, + "testTargets" : [ + { + "skippedTests" : [ + "PaymentMethodMessagingViewSnapshotTests", + "STPCardBINMetadataTests", + "TextFieldElementCardTest\/testBINRangeThatRequiresNetworkCallToValidate()" + ], + "target" : { + "containerPath" : "container:Stripe.xcodeproj", + "identifier" : "8BE23AD5D9A3D939AF46F31E", + "name" : "StripeiOSTests" + } + } + ], + "version" : 1 +} diff --git a/Stripe/StripeiOSTests/APIRequestTest.swift b/Stripe/StripeiOSTests/APIRequestTest.swift new file mode 100644 index 00000000..d4ff8553 --- /dev/null +++ b/Stripe/StripeiOSTests/APIRequestTest.swift @@ -0,0 +1,288 @@ +// +// APIRequestTest.swift +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 9/23/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class AnyAPIResponse: NSObject, STPAPIResponseDecodable { + override required init() { + super.init() + } + + static func decodedObject(fromAPIResponse response: [AnyHashable: Any]?) -> Self? { + guard let response = response else { + return nil + } + let a = Self() + a.allResponseFields = response + return a + } + + var allResponseFields: [AnyHashable: Any] = [:] + +} + +class APIRequestTest: XCTestCase { + let apiClient = STPAPIClient() + override func setUp() { + // HTTPBin clone + apiClient.apiURL = URL(string: "https://luxurious-alpine-devourer.glitch.me") + } + + func testPublishableKeyAuthorization() { + let e = expectation(description: "Request completed") + apiClient.publishableKey = "pk_foo" + APIRequest.getWith( + apiClient, + endpoint: "bearer", + parameters: ["foo": "bar"] + ) { + (obj, _, error) in + guard let obj = obj, + let token = obj.allResponseFields["token"] as? String + else { + XCTFail() + XCTAssertNil(error) + return + } + XCTAssertEqual(token, self.apiClient.publishableKey) + e.fulfill() + } + waitForExpectations(timeout: 2) + } + + func testStripeAccountAuthorization() { + + } + + func testGet() { + let e = expectation(description: "Request completed") + APIRequest.getWith(apiClient, endpoint: "get", parameters: [:]) { + (obj, response, error) in + XCTAssertNil(error) + XCTAssertEqual(response?.statusCode, 200) + XCTAssertNotNil(obj) + e.fulfill() + } + waitForExpectations(timeout: 2) + } + + func testPost() { + let parameters = ["foo": "bar"] + let e = expectation(description: "Request completed") + APIRequest.post( + with: apiClient, + endpoint: "post", + parameters: ["foo": "bar"] + ) { + (obj, response, error) in + XCTAssertNil(error) + XCTAssertEqual(response?.statusCode, 200) + guard let obj = obj, + let form = obj.allResponseFields["form"] as? [String: String], + let headers = obj.allResponseFields["headers"] as? [String: String] + else { + XCTFail() + return + } + XCTAssertNotNil(headers["X-Stripe-User-Agent"]) + XCTAssertEqual(headers["Stripe-Version"], STPAPIClient.apiVersion) + XCTAssertEqual(form, parameters) + e.fulfill() + } + waitForExpectations(timeout: 2) + } + + func testDelete() { + let e = expectation(description: "Request completed") + APIRequest.delete(with: apiClient, endpoint: "delete", parameters: [:]) { + (obj, response, error) in + XCTAssertNil(error) + XCTAssertEqual(response?.statusCode, 200) + guard let obj = obj, + let headers = obj.allResponseFields["headers"] as? [String: String] + else { + XCTFail() + return + } + XCTAssertNotNil(headers["X-Stripe-User-Agent"]) + XCTAssertEqual(headers["Stripe-Version"], STPAPIClient.apiVersion) + + e.fulfill() + } + waitForExpectations(timeout: 2) + } + + func testParseResponseWithConnectionError() { + let expectation = self.expectation(description: "parseResponse") + + let httpURLResponse = HTTPURLResponse() + let json: [AnyHashable: Any] = [:] + let body = try? JSONSerialization.data(withJSONObject: json, options: []) + let errorParameter = NSError( + domain: NSURLErrorDomain, + code: NSURLErrorNotConnectedToInternet, + userInfo: nil + ) + + APIRequest.parseResponse( + httpURLResponse, + body: body, + error: errorParameter + ) { (object: STPCard?, response, error) in + guard let error = error else { + XCTFail() + return + } + XCTAssertNil(object) + XCTAssertEqual(response, httpURLResponse) + XCTAssertEqual(error as NSError, errorParameter) + expectation.fulfill() + } + + waitForExpectations(timeout: 2.0, handler: nil) + } + + func testParseResponseWithReturnedError() { + let expectation = self.expectation(description: "parseResponse") + + let httpURLResponse = HTTPURLResponse() + let json = [ + "error": [ + "type": "invalid_request_error", + "message": "Your card number is incorrect.", + "code": "incorrect_number", + ], + ] + let body = try? JSONSerialization.data(withJSONObject: json, options: []) + let errorParameter: NSError? = nil + let expectedError = NSError.stp_error(fromStripeResponse: json) + + APIRequest.parseResponse( + httpURLResponse, + body: body, + error: errorParameter + ) { (object: STPCard?, response, error) in + guard let error = error, let expectedError = expectedError else { + XCTFail() + return + } + XCTAssertNil(object) + XCTAssertEqual(response, httpURLResponse) + XCTAssertEqual(error as NSError, expectedError as NSError) + expectation.fulfill() + } + + waitForExpectations(timeout: 2.0, handler: nil) + } + + func testParseResponseWithMissingError() { + let expectation = self.expectation(description: "parseResponse") + + let httpURLResponse = HTTPURLResponse() + let json: [AnyHashable: Any] = [:] + let body = try? JSONSerialization.data(withJSONObject: json, options: []) + let errorParameter: NSError? = nil + let expectedError = NSError.stp_genericFailedToParseResponseError() + + APIRequest.parseResponse( + httpURLResponse, + body: body, + error: errorParameter + ) { (object: STPCard?, response, error) in + guard let error = error else { + XCTFail() + return + } + XCTAssertNil(object) + XCTAssertEqual(response, httpURLResponse) + XCTAssertEqual(error as NSError, expectedError as NSError) + expectation.fulfill() + } + + waitForExpectations(timeout: 2.0, handler: nil) + } + + func testParseResponseWithResponseObjectAndReturnedError() { + let expectation = self.expectation(description: "parseResponse") + + let httpURLResponse = HTTPURLResponse() + let json: [AnyHashable: Any] = STPTestUtils.jsonNamed("CardSource")! + let body = try? JSONSerialization.data(withJSONObject: json, options: []) + let errorParameter: NSError? = NSError( + domain: NSURLErrorDomain, + code: NSURLErrorUnknown, + userInfo: nil + ) + + APIRequest.parseResponse( + httpURLResponse, + body: body, + error: errorParameter + ) { (object: STPCard?, response, error) in + guard let error = error else { + XCTFail() + return + } + XCTAssertNil(object) + XCTAssertEqual(response, httpURLResponse) + XCTAssertEqual(error as NSError, errorParameter) + expectation.fulfill() + } + + waitForExpectations(timeout: 2.0, handler: nil) + } + + func test429Backoff() { + var inProgress = true + + let e = expectation(description: "Request completed") + // We expect this request to retry a few times with exponential backoff before calling the completion handler. + APIRequest.getWith(apiClient, endpoint: "status/429", parameters: [:]) { + (_, response, _) in + XCTAssertEqual(response?.statusCode, 429) + inProgress = false + e.fulfill() + } + + let checkedStillInProgress = expectation( + description: "Checked that we're still in progress after 2s" + ) + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2.0) { + // Make sure we're still in progress after 2 seconds + // This shows that we're retrying the request a few times + // while applying an appropriate amount of backoff. + XCTAssertEqual(inProgress, true) + checkedStillInProgress.fulfill() + } + + wait(for: [e, checkedStillInProgress], timeout: 30) + } + + func test429NoBackoff() { + let oldMaxRetries = StripeAPI.maxRetries + StripeAPI.maxRetries = 0 + + let e = expectation(description: "Request completed") + APIRequest.getWith(apiClient, endpoint: "status/429", parameters: [:]) { + (_, response, _) in + XCTAssertEqual(response?.statusCode, 429) + e.fulfill() + } + + // We expect this request to return ~immediately, so we set a timeout lower than the highest + // amount of backoff. + wait(for: [e], timeout: 5.0) + StripeAPI.maxRetries = oldMaxRetries + } +} diff --git a/Stripe/StripeiOSTests/AfterpayPriceBreakdownViewSnapshotTests.swift b/Stripe/StripeiOSTests/AfterpayPriceBreakdownViewSnapshotTests.swift new file mode 100644 index 00000000..d08dd10f --- /dev/null +++ b/Stripe/StripeiOSTests/AfterpayPriceBreakdownViewSnapshotTests.swift @@ -0,0 +1,56 @@ +// +// AfterpayPriceBreakdownViewSnapshotTests.swift +// StripeiOS Tests +// +// Created by Jaime Park on 6/15/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class AfterpayPriceBreakdownViewSnapshotTests: FBSnapshotTestCase { + override func setUp() { + super.setUp() +// recordMode = true + } + + func embedInRenderableView( + _ priceBreakdownView: AfterpayPriceBreakdownView, + width: Int, + height: Int + ) -> UIView { + let containingView = UIView(frame: CGRect(x: 0, y: 0, width: width, height: height)) + containingView.addSubview(priceBreakdownView) + priceBreakdownView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + priceBreakdownView.leadingAnchor.constraint(equalTo: containingView.leadingAnchor), + containingView.trailingAnchor.constraint(equalTo: priceBreakdownView.trailingAnchor), + priceBreakdownView.topAnchor.constraint(equalTo: containingView.topAnchor), + containingView.bottomAnchor.constraint(equalTo: priceBreakdownView.bottomAnchor), + ]) + + return containingView + } + + func testClearpayInMultiRow() { + NSLocale.stp_withLocale(as: NSLocale(localeIdentifier: "en_GB")) { [self] in + let priceBreakdownView = AfterpayPriceBreakdownView(amount: 1000, currency: "GBP") + let containingView = embedInRenderableView(priceBreakdownView, width: 320, height: 50) + + STPSnapshotVerifyView(containingView) + } + } + + func testAfterpayInSingleRow() { + let priceBreakdownView = AfterpayPriceBreakdownView(amount: 1000, currency: "USD") + let containingView = embedInRenderableView(priceBreakdownView, width: 500, height: 30) + + STPSnapshotVerifyView(containingView) + } +} diff --git a/Stripe/StripeiOSTests/AnalyticsHelperTests.swift b/Stripe/StripeiOSTests/AnalyticsHelperTests.swift new file mode 100644 index 00000000..2c011fcf --- /dev/null +++ b/Stripe/StripeiOSTests/AnalyticsHelperTests.swift @@ -0,0 +1,60 @@ +// +// AnalyticsHelperTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 2/22/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class AnalyticsHelperTests: XCTestCase { + + func test_getDuration() { + let (sut, timeReference) = makeSUT() + + sut.startTimeMeasurement(.checkout) + + // Advance the clock by 10 seconds. + timeReference.advanceBy(10) + XCTAssertEqual(sut.getDuration(for: .checkout), 10) + + // Advance the clock by 5 seconds. + timeReference.advanceBy(5) + XCTAssertEqual(sut.getDuration(for: .checkout), 15) + } + + func test_getDuration_returnsNilWhenNotStarted() { + let (sut, _) = makeSUT() + XCTAssertNil(sut.getDuration(for: .checkout)) + } + +} + +extension AnalyticsHelperTests { + + class MockTimeReference { + var date = Date() + + func advanceBy(_ timeInterval: TimeInterval) { + date = date.addingTimeInterval(timeInterval) + } + + func now() -> Date { + return date + } + } + + func makeSUT() -> (AnalyticsHelper, MockTimeReference) { + let timeReference = MockTimeReference() + let helper = AnalyticsHelper(timeProvider: timeReference.now) + return (helper, timeReference) + } + +} diff --git a/Stripe/StripeiOSTests/AutoCompleteViewControllerSnapshotTests.swift b/Stripe/StripeiOSTests/AutoCompleteViewControllerSnapshotTests.swift new file mode 100644 index 00000000..c5f49d77 --- /dev/null +++ b/Stripe/StripeiOSTests/AutoCompleteViewControllerSnapshotTests.swift @@ -0,0 +1,149 @@ +// +// AutoCompleteViewControllerSnapshotTests.swift +// StripeiOS Tests +// +// Created by Nick Porter on 6/7/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +import iOSSnapshotTestCase + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI +@testable@_spi(STP) import StripeUICore + +class AutoCompleteViewControllerSnapshotTests: FBSnapshotTestCase { + + private var configuration: AddressViewController.Configuration { + return AddressViewController.Configuration() + } + + private let addressSpecProvider: AddressSpecProvider = { + let specProvider = AddressSpecProvider() + specProvider.addressSpecs = [ + "US": AddressSpec( + format: "ACSZP", + require: "AZ", + cityNameType: .post_town, + stateNameType: .state, + zip: "", + zipNameType: .pin + ), + ] + return specProvider + }() + + private let mockSearchResults: [AddressSearchResult] = [ + MockAddressSearchResult( + title: "199 Water Street", + subtitle: "New York, NY 10038 United States", + titleHighlightRanges: [NSValue(range: NSRange(location: 0, length: 6))], + subtitleHighlightRanges: [NSValue(range: NSRange(location: 2, length: 4))] + ), + MockAddressSearchResult( + title: "354 Oyster Point Blvd", + subtitle: "San Francisco, CA 94080 United States", + titleHighlightRanges: [NSValue(range: NSRange(location: 2, length: 4))], + subtitleHighlightRanges: [NSValue(range: NSRange(location: 4, length: 2))] + ), + MockAddressSearchResult( + title: "10 Boulevard", + subtitle: "Haussmann Paris 75009 France", + titleHighlightRanges: [NSValue(range: NSRange(location: 4, length: 2))], + subtitleHighlightRanges: [NSValue(range: NSRange(location: 0, length: 4))] + ), + ] + + override func setUp() { + super.setUp() + + // self.recordMode = true + } + + func testAutoCompleteViewController() { + let testWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 428, height: 500)) + testWindow.isHidden = false + let vc = AutoCompleteViewController( + configuration: configuration, + initialLine1Text: nil, + addressSpecProvider: addressSpecProvider + ) + vc.results = mockSearchResults + testWindow.rootViewController = vc + + verify(vc.view) + } + + func testAutoCompleteViewController_darkMode() { + let testWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 428, height: 500)) + testWindow.isHidden = false + testWindow.overrideUserInterfaceStyle = .dark + let vc = AutoCompleteViewController( + configuration: configuration, + initialLine1Text: nil, + addressSpecProvider: addressSpecProvider + ) + + vc.results = mockSearchResults + testWindow.rootViewController = vc + + verify(vc.view) + } + + func testAutoCompleteViewController_appearance() { + let testWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 428, height: 500)) + testWindow.isHidden = false + + var config = configuration + config.appearance.colors.background = .blue + config.appearance.colors.text = .yellow + config.appearance.colors.textSecondary = .red + config.appearance.colors.componentPlaceholderText = .cyan + config.appearance.colors.componentBackground = .red + config.appearance.colors.componentDivider = .green + config.appearance.cornerRadius = 0.0 + config.appearance.borderWidth = 2.0 + config.appearance.font.base = UIFont(name: "AmericanTypeWriter", size: 12)! + config.appearance.colors.primary = .red + + let vc = AutoCompleteViewController( + configuration: config, + initialLine1Text: nil, + addressSpecProvider: addressSpecProvider + ) + vc.results = mockSearchResults + testWindow.rootViewController = vc + + verify(vc.view) + } + + func verify( + _ view: UIView, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + STPSnapshotVerifyView( + view, + identifier: identifier, + suffixes: FBSnapshotTestCaseDefaultSuffixes(), + file: file, + line: line + ) + } +} + +private struct MockAddressSearchResult: AddressSearchResult { + let title: String + let subtitle: String + let titleHighlightRanges: [NSValue] + let subtitleHighlightRanges: [NSValue] + + func asAddress(completion: @escaping (PaymentSheet.Address?) -> Void) { + completion(nil) + } +} diff --git a/Stripe/StripeiOSTests/CardExpiryDateTests.swift b/Stripe/StripeiOSTests/CardExpiryDateTests.swift new file mode 100644 index 00000000..c9fa5255 --- /dev/null +++ b/Stripe/StripeiOSTests/CardExpiryDateTests.swift @@ -0,0 +1,73 @@ +// +// CardExpiryDateTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 4/15/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class CardExpiryDateTests: XCTestCase { + + func test_init() { + let sut = CardExpiryDate(month: 2, year: 2026) + XCTAssertEqual(sut.month, 2) + XCTAssertEqual(sut.year, 2026) + } + + func test_init_shouldNormalizeTheYear() { + let sut = CardExpiryDate(month: 2, year: 50) + XCTAssertEqual(sut.year, 2050) + } + + func test_initFromString() { + let sut = CardExpiryDate("0226") + XCTAssertEqual(sut?.month, 2) + XCTAssertEqual(sut?.year, 2026) + } + + func test_initFromString_withInvalidString() { + XCTAssertNil(CardExpiryDate("")) // empty + XCTAssertNil(CardExpiryDate("0")) // missing 3 digits + XCTAssertNil(CardExpiryDate("023")) // missing a digit + XCTAssertNil(CardExpiryDate("1234567890")) // too many digits + XCTAssertNil(CardExpiryDate("abcd")) // alpha + + // month out of range + XCTAssertNil(CardExpiryDate("1326")) + XCTAssertNil(CardExpiryDate("0026")) + XCTAssertNil(CardExpiryDate("-126")) + + // year out of range + XCTAssertNil(CardExpiryDate("02-1")) + } + + func test_displayString() { + let sut = CardExpiryDate(month: 2, year: 2026) + XCTAssertEqual(sut.displayString, "0226") + } + + func test_expired() throws { + let calendar = Calendar(identifier: .gregorian) + + let sut = CardExpiryDate(month: 2, year: 2026) + + let aDayBefore = try XCTUnwrap(calendar.date(from: .init(year: 2026, month: 2, day: 28))) + let aMonthBefore = try XCTUnwrap(calendar.date(from: .init(year: 2026, month: 1, day: 31))) + let aDayAfter = try XCTUnwrap(calendar.date(from: .init(year: 2026, month: 3, day: 1))) + let aMonthAfter = try XCTUnwrap(calendar.date(from: .init(year: 2026, month: 3, day: 30))) + + XCTAssertFalse(sut.expired(now: aDayBefore)) + XCTAssertFalse(sut.expired(now: aMonthBefore)) + XCTAssertTrue(sut.expired(now: aDayAfter)) + XCTAssertTrue(sut.expired(now: aMonthAfter)) + } + +} diff --git a/Stripe/StripeiOSTests/CircularButtonSnapshotTests.swift b/Stripe/StripeiOSTests/CircularButtonSnapshotTests.swift new file mode 100644 index 00000000..7c336ccb --- /dev/null +++ b/Stripe/StripeiOSTests/CircularButtonSnapshotTests.swift @@ -0,0 +1,66 @@ +// +// CircularButtonSnapshotTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 1/7/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase +@_spi(STP) import StripeUICore + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class CircularButtonSnapshotTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() + // recordMode = true + } + + func testNormal() { + let button = CircularButton(style: .close) + verify(button) + } + + func testDisabled() { + let button = CircularButton(style: .close) + button.isEnabled = false + verify(button) + } + + func verify( + _ button: CircularButton, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + // Ensures that the button shadow gets captured + let wrapper = UIView() + wrapper.addAndPinSubview( + button, + insets: .insets(top: 10, leading: 10, bottom: 10, trailing: 10) + ) + + // Adding the view to a window updates the traits + let window = UIWindow() + window.addSubview(wrapper) + + let size = wrapper.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + wrapper.bounds = CGRect(origin: .zero, size: size) + + // Test light mode + wrapper.overrideUserInterfaceStyle = .light + STPSnapshotVerifyView(wrapper, identifier: identifier, file: file, line: line) + + // Test dark mode + wrapper.overrideUserInterfaceStyle = .dark + let updatedIdentifier = (identifier ?? "").appending("darkMode") + STPSnapshotVerifyView(wrapper, identifier: updatedIdentifier, file: file, line: line) + } + +} diff --git a/Stripe/StripeiOSTests/ConfirmButtonSnapshotTests.swift b/Stripe/StripeiOSTests/ConfirmButtonSnapshotTests.swift new file mode 100644 index 00000000..ab87d3a0 --- /dev/null +++ b/Stripe/StripeiOSTests/ConfirmButtonSnapshotTests.swift @@ -0,0 +1,86 @@ +// +// ConfirmButtonSnapshotTests.swift +// StripeiOS Tests +// +// Created by Nick Porter on 3/11/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +import iOSSnapshotTestCase +import StripeCoreTestUtils +import UIKit + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePaymentSheet + +class ConfirmButtonSnapshotTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() + // self.recordMode = true + } + + func testConfirmButton() { + let confirmButton = ConfirmButton(style: .stripe, callToAction: .setup, didTap: {}) + + verify(confirmButton) + } + + // Tests that `primaryButton` appearance is used over standard variables + func testConfirmButtonBackgroundColor() { + var appearance = PaymentSheet.Appearance.default + var button = PaymentSheet.Appearance.PrimaryButton() + button.backgroundColor = .red + appearance.primaryButton = button + + let confirmButton = ConfirmButton( + style: .stripe, + callToAction: .setup, + appearance: appearance, + didTap: {} + ) + + verify(confirmButton) + } + + func testConfirmButtonCustomFont() throws { + var appearance = PaymentSheet.Appearance.default + appearance.font.base = try XCTUnwrap(UIFont(name: "AmericanTypewriter", size: 12.0)) + + let confirmButton = ConfirmButton( + style: .stripe, + callToAction: .custom(title: "Custom Title"), + appearance: appearance, + didTap: {} + ) + + verify(confirmButton) + } + + func testConfirmButtonCustomFontScales() throws { + var appearance = PaymentSheet.Appearance.default + appearance.font.base = try XCTUnwrap(UIFont(name: "AmericanTypewriter", size: 12.0)) + appearance.font.sizeScaleFactor = 0.85 + + let confirmButton = ConfirmButton( + style: .stripe, + callToAction: .custom(title: "Custom Title"), + appearance: appearance, + didTap: {} + ) + + verify(confirmButton) + } + + func verify( + _ view: UIView, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + view.autosizeHeight(width: 300) + STPSnapshotVerifyView(view, identifier: identifier, file: file, line: line) + } +} diff --git a/Stripe/StripeiOSTests/ConfirmButtonTests.swift b/Stripe/StripeiOSTests/ConfirmButtonTests.swift new file mode 100644 index 00000000..57b559a9 --- /dev/null +++ b/Stripe/StripeiOSTests/ConfirmButtonTests.swift @@ -0,0 +1,91 @@ +// +// ConfirmButtonTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 10/6/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class ConfirmButtonTests: XCTestCase { + + func testBuyButtonShouldAutomaticallyAdjustItsForegroundColor() { + let testCases: [(background: UIColor, foreground: UIColor)] = [ + // Dark backgrounds + (background: .systemBlue, foreground: .white), + (background: .black, foreground: .white), + // Light backgrounds + ( + background: UIColor(red: 1.0, green: 0.87, blue: 0.98, alpha: 1.0), + foreground: .black + ), + ( + background: UIColor(red: 1.0, green: 0.89, blue: 0.35, alpha: 1.0), + foreground: .black + ), + ] + + for (backgroundColor, expectedForeground) in testCases { + let button = ConfirmButton.BuyButton() + button.tintColor = backgroundColor + button.update( + status: .enabled, + callToAction: .pay(amount: 900, currency: "usd"), + animated: false + ) + + XCTAssertEqual( + // Test against `.cgColor` because any color set as `.backgroundColor` + // will be automatically wrapped in `UIDynamicModifiedColor` (private subclass) by iOS. + button.backgroundColor?.cgColor, + backgroundColor.cgColor + ) + + XCTAssertEqual( + button.foregroundColor, + expectedForeground, + "The foreground color should contrast with the background color" + ) + } + } + + func testUpdateShouldCallTheCompletionBlock() { + let sut = ConfirmButton( + style: .stripe, + callToAction: .pay(amount: 1000, currency: "usd"), + didTap: {} + ) + + let expectation = XCTestExpectation(description: "Should call the completion block") + + sut.update(state: .disabled, animated: false) { + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } + + func testUpdateShouldCallTheCompletionBlockWhenAnimated() { + let sut = ConfirmButton( + style: .stripe, + callToAction: .pay(amount: 1000, currency: "usd"), + didTap: {} + ) + + let expectation = XCTestExpectation(description: "Should call the completion block") + + sut.update(state: .disabled, animated: true) { + expectation.fulfill() + } + + wait(for: [expectation], timeout: 3) + } + +} diff --git a/Stripe/StripeiOSTests/ConsumerSessionTests.swift b/Stripe/StripeiOSTests/ConsumerSessionTests.swift new file mode 100644 index 00000000..dc8ddf6a --- /dev/null +++ b/Stripe/StripeiOSTests/ConsumerSessionTests.swift @@ -0,0 +1,490 @@ +// +// ConsumerSessionTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 4/21/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class ConsumerSessionTests: XCTestCase { + +// Disable Consumer Session integration tests +/* + let apiClient: STPAPIClient = { + let apiClient = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + return apiClient + }() + + let cookieStore = LinkInMemoryCookieStore() + + func testLookupSession_noParams() { + let expectation = self.expectation(description: "Lookup ConsumerSession") + + ConsumerSession.lookupSession(for: nil, with: apiClient, cookieStore: cookieStore) { + result in + switch result { + case .success(let lookupResponse): + switch lookupResponse.responseType { + case .found: + XCTFail("Got a response without any params") + + case .notFound(let errorMessage): + XCTFail("Got not found response with \(errorMessage)") + + case .noAvailableLookupParams: + break // Pass + } + case .failure(let error): + XCTFail("Received error: \(error.nonGenericDescription)") + } + + expectation.fulfill() + } + wait(for: [expectation], timeout: STPTestingNetworkRequestTimeout) + } + + func testLookupSession_shouldDeleteInvalidSessionCookies() { + let expectation = self.expectation(description: "Lookup ConsumerSession") + + cookieStore.write(key: .session, value: "bad_session_cookie", allowSync: false) + + ConsumerSession.lookupSession(for: nil, with: apiClient, cookieStore: cookieStore) { + result in + switch result { + case .success(let lookupResponse): + switch lookupResponse.responseType { + case .notFound: + // Expected response type. + break + + case .noAvailableLookupParams, .found: + XCTFail("Unexpected response type: \(lookupResponse.responseType)") + } + case .failure(let error): + XCTFail("Received error: \(error.nonGenericDescription)") + } + + expectation.fulfill() + } + + wait(for: [expectation], timeout: STPTestingNetworkRequestTimeout) + XCTAssertNil(cookieStore.read(key: .session), "Invalid cookie not deleted") + } + + func testLookupSession_cookieOnly() { + _ = createVerifiedConsumerSession() + let expectation = self.expectation(description: "Lookup ConsumerSession") + ConsumerSession.lookupSession(for: nil, with: apiClient, cookieStore: cookieStore) { + result in + switch result { + case .success(let lookupResponse): + switch lookupResponse.responseType { + case .found: + break // Pass + + case .notFound(let errorMessage): + XCTFail("Got not found response with \(errorMessage)") + + case .noAvailableLookupParams: + XCTFail("Got no avilable lookup params") + } + case .failure(let error): + XCTFail("Received error: \(error.nonGenericDescription)") + } + + expectation.fulfill() + } + wait(for: [expectation], timeout: STPTestingNetworkRequestTimeout) + } + + func testLookupSession_existingConsumer() { + let expectation = self.expectation(description: "Lookup ConsumerSession") + + ConsumerSession.lookupSession( + for: "mobile-payments-sdk-ci+a-consumer@stripe.com", + with: apiClient, + cookieStore: cookieStore + ) { result in + switch result { + case .success(let lookupResponse): + switch lookupResponse.responseType { + case .found: + break // Pass + + case .notFound(let errorMessage): + XCTFail("Got not found response with \(errorMessage)") + + case .noAvailableLookupParams: + XCTFail("Got no available lookup params") + } + case .failure(let error): + XCTFail("Received error: \(error.nonGenericDescription)") + } + + expectation.fulfill() + } + wait(for: [expectation], timeout: STPTestingNetworkRequestTimeout) + } + + func testLookupSession_newConsumer() { + let expectation = self.expectation(description: "Lookup ConsumerSession") + + ConsumerSession.lookupSession( + for: "mobile-payments-sdk-ci+not-a-consumer@stripe.com", + with: apiClient, + cookieStore: cookieStore + ) { result in + switch result { + case .success(let lookupResponse): + switch lookupResponse.responseType { + case .found(let consumerSession): + XCTFail("Got unexpected found response with \(consumerSession)") + + case .notFound: + break // Pass + + case .noAvailableLookupParams: + XCTFail("Got no available lookup params") + } + case .failure(let error): + XCTFail("Received error: \(error.nonGenericDescription)") + } + + expectation.fulfill() + } + wait(for: [expectation], timeout: STPTestingNetworkRequestTimeout) + } + + // tests signup, createPaymentDetails + func testSignUpAndCreateDetails() { + let expectation = self.expectation(description: "consumer sign up") + let newAccountEmail = "mobile-payments-sdk-ci+\(UUID())@stripe.com" + + var sessionWithKey: ConsumerSession.SessionWithPublishableKey? + + ConsumerSession.signUp( + email: newAccountEmail, + phoneNumber: "+13105551234", + legalName: nil, + countryCode: "US", + consentAction: nil, + with: apiClient, + cookieStore: cookieStore + ) { result in + switch result { + case .success(let signupResponse): + XCTAssertTrue(signupResponse.consumerSession.isVerifiedForSignup) + XCTAssertTrue( + signupResponse.consumerSession.verificationSessions.isVerifiedForSignup + ) + XCTAssertTrue( + signupResponse.consumerSession.verificationSessions.contains(where: { + $0.type == .signup + }) + ) + + sessionWithKey = signupResponse + case .failure(let error): + XCTFail("Received error: \(error.nonGenericDescription)") + } + + expectation.fulfill() + } + + wait(for: [expectation], timeout: STPTestingNetworkRequestTimeout) + + if let consumerSession = sessionWithKey?.consumerSession { + let cardParams = STPPaymentMethodCardParams() + cardParams.number = "4242424242424242" + cardParams.expMonth = 12 + cardParams.expYear = NSNumber( + value: Calendar.autoupdatingCurrent.component(.year, from: Date()) + 1 + ) + cardParams.cvc = "123" + + let billingParams = STPPaymentMethodBillingDetails() + billingParams.name = "Payments SDK CI" + let address = STPPaymentMethodAddress() + address.postalCode = "55555" + billingParams.address = address + + let paymentMethodParams = STPPaymentMethodParams.paramsWith( + card: cardParams, + billingDetails: billingParams, + metadata: nil + ) + + let createExpectation = self.expectation(description: "create payment details") + consumerSession.createPaymentDetails( + paymentMethodParams: paymentMethodParams, + with: apiClient, + consumerAccountPublishableKey: sessionWithKey?.publishableKey + ) { result in + switch result { + case .success(let createdPaymentDetails): + if case .card(let cardDetails) = createdPaymentDetails.details { + XCTAssertEqual(cardDetails.expiryMonth, cardParams.expMonth?.intValue) + XCTAssertEqual(cardDetails.expiryYear, cardParams.expYear?.intValue) + } else { + XCTAssert(false) + } + case .failure(let error): + XCTFail("Received error: \(error.nonGenericDescription)") + } + + createExpectation.fulfill() + } + + wait(for: [createExpectation], timeout: STPTestingNetworkRequestTimeout) + } + } + + func testListPaymentDetails() { + let (consumerSession, publishableKey) = createVerifiedConsumerSession() + + let listExpectation = self.expectation(description: "list payment details") + + consumerSession.listPaymentDetails( + with: apiClient, + consumerAccountPublishableKey: publishableKey + ) { result in + switch result { + case .success(let paymentDetails): + XCTAssertFalse(paymentDetails.isEmpty) + case .failure(let error): + XCTFail("Received error: \(error.nonGenericDescription)") + } + + listExpectation.fulfill() + } + + wait(for: [listExpectation], timeout: STPTestingNetworkRequestTimeout) + } + + func testCreateLinkAccountSession() { + let createLinkAccountSessionExpectation = self.expectation( + description: "Create LinkAccountSession" + ) + + let (consumerSession, publishableKey) = createVerifiedConsumerSession() + consumerSession.createLinkAccountSession( + with: apiClient, + consumerAccountPublishableKey: publishableKey + ) { result in + switch result { + case .success: + // Pass + break + case .failure(let error): + XCTFail("Received error: \(error.nonGenericDescription)") + } + + createLinkAccountSessionExpectation.fulfill() + } + + wait(for: [createLinkAccountSessionExpectation], timeout: STPTestingNetworkRequestTimeout) + } + + func testUpdatePaymentDetails() { + let (consumerSession, publishableKey) = createVerifiedConsumerSession() + + let listExpectation = self.expectation(description: "list payment details") + var storedPaymentDetails = [ConsumerPaymentDetails]() + + consumerSession.listPaymentDetails( + with: apiClient, + consumerAccountPublishableKey: publishableKey + ) { result in + switch result { + case .success(let paymentDetails): + storedPaymentDetails = paymentDetails + case .failure(let error): + XCTFail("Received error: \(error.nonGenericDescription)") + } + + listExpectation.fulfill() + } + + wait(for: [listExpectation], timeout: STPTestingNetworkRequestTimeout) + + let billingParams = STPPaymentMethodBillingDetails() + billingParams.name = "Payments SDK CI" + let address = STPPaymentMethodAddress() + address.postalCode = "55555" + billingParams.address = address + + let updateExpectation = self.expectation(description: "update payment details") + let paymentMethodToUpdate = try! XCTUnwrap(storedPaymentDetails.first) + + guard case .card(let card) = paymentMethodToUpdate.details else { + XCTFail("Payment method must be `card` type") + return + } + + let calendar = Calendar(identifier: .gregorian) + let yearOne = calendar.component(.year, from: Date()) + 1 + let yearTwo = calendar.component(.year, from: Date()) + 2 + + // toggle between expiry years/months + let newExpiryDate = CardExpiryDate( + month: card.expiryDate.month == 1 ? 2 : 1, + year: card.expiryDate.year == yearOne ? yearTwo : yearOne + ) + + let updateParams = UpdatePaymentDetailsParams( + isDefault: !paymentMethodToUpdate.isDefault, + details: .card( + expiryDate: newExpiryDate, + billingDetails: billingParams + ) + ) + + consumerSession.updatePaymentDetails( + with: apiClient, + id: paymentMethodToUpdate.stripeID, + updateParams: updateParams, + consumerAccountPublishableKey: publishableKey + ) { result in + switch result { + case .success(let paymentDetails): + XCTAssertNotEqual(paymentDetails.isDefault, paymentMethodToUpdate.isDefault) + switch paymentDetails.details { + case .card(let card): + XCTAssertEqual(newExpiryDate, card.expiryDate) + case .bankAccount, .unparsable: + XCTFail("Unexpected payment details type") + } + case .failure(let error): + XCTFail("Received error: \(error.nonGenericDescription)") + } + + updateExpectation.fulfill() + } + + wait(for: [updateExpectation], timeout: STPTestingNetworkRequestTimeout) + } + + func testLogout() { + let (consumerSession, publishableKey) = createVerifiedConsumerSession() + + XCTAssertNotNil(cookieStore.formattedSessionCookies()) + + let logoutExpectation = self.expectation(description: "Logout") + + consumerSession.logout( + with: apiClient, + cookieStore: cookieStore, + consumerAccountPublishableKey: publishableKey + ) { result in + switch result { + case .success: + XCTAssertNil(self.cookieStore.formattedSessionCookies()) + case .failure(let error): + XCTFail("Received error: \(error.nonGenericDescription)") + } + + logoutExpectation.fulfill() + } + + wait(for: [logoutExpectation], timeout: STPTestingNetworkRequestTimeout) + } +*/ +} + +extension ConsumerSessionTests { +/* + fileprivate func lookupExistingConsumer() -> ConsumerSession.SessionWithPublishableKey { + var sessionWithKey: ConsumerSession.SessionWithPublishableKey! + + let lookupExpectation = self.expectation(description: "Lookup ConsumerSession") + + let email = "mobile-payments-sdk-ci+a-consumer@stripe.com" + + ConsumerSession.lookupSession( + for: email, + with: apiClient, + cookieStore: cookieStore + ) { result in + switch result { + case .success(let lookupResponse): + switch lookupResponse.responseType { + case .found(let session): + sessionWithKey = session + case .notFound(let errorMessage): + XCTFail("Got not found response with \(errorMessage)") + case .noAvailableLookupParams: + XCTFail("Got no avilable lookup params") + } + case .failure(let error): + XCTFail("Received error: \(error.nonGenericDescription)") + } + + lookupExpectation.fulfill() + } + + wait(for: [lookupExpectation], timeout: STPTestingNetworkRequestTimeout) + + return sessionWithKey + } + + fileprivate func createVerifiedConsumerSession() -> (ConsumerSession, String) { + let sessionWithKey = lookupExistingConsumer() + var consumerSession = sessionWithKey.consumerSession + let publishableKey = sessionWithKey.publishableKey + + // Start verification + + let startVerificationExpectation = self.expectation(description: "Start verification") + + consumerSession.startVerification( + with: apiClient, + cookieStore: cookieStore, + consumerAccountPublishableKey: publishableKey + ) { result in + switch result { + case .success: + // Pass + break + case .failure(let error): + XCTFail("Received error: \(error.nonGenericDescription)") + } + + startVerificationExpectation.fulfill() + } + + wait(for: [startVerificationExpectation], timeout: STPTestingNetworkRequestTimeout) + + // Verify via SMS + + let confirmVerificationExpectation = self.expectation(description: "Confirm verification") + + consumerSession.confirmSMSVerification( + with: "000000", + with: apiClient, + cookieStore: cookieStore, + consumerAccountPublishableKey: publishableKey + ) { result in + switch result { + case .success(let verifiedSession): + consumerSession = verifiedSession + case .failure(let error): + XCTFail("Received error: \(error.nonGenericDescription)") + } + + confirmVerificationExpectation.fulfill() + } + + wait(for: [confirmVerificationExpectation], timeout: STPTestingNetworkRequestTimeout) + + return (consumerSession, publishableKey) + } +*/ +} diff --git a/Stripe/StripeiOSTests/CustomerAdapterTests.swift b/Stripe/StripeiOSTests/CustomerAdapterTests.swift new file mode 100644 index 00000000..9d472436 --- /dev/null +++ b/Stripe/StripeiOSTests/CustomerAdapterTests.swift @@ -0,0 +1,234 @@ +// +// CustomerAdapterTests.swift +// StripePaymentSheetTests +// + +import Foundation +import OHHTTPStubs +import OHHTTPStubsSwift +@_spi(STP) @testable import StripeCore +import StripeCoreTestUtils +@_spi(STP) @testable import StripePayments +@_spi(STP) @testable import StripePaymentSheet +import XCTest + +enum MockEphemeralKeyEndpoint { + case customerEphemeralKey(CustomerEphemeralKey) + case error(Error) + + init(_ error: Error) { + self = .error(error) + } + + init(_ customerEphemeralKey: CustomerEphemeralKey) { + self = .customerEphemeralKey(customerEphemeralKey) + } + + func getEphemeralKey() async throws -> CustomerEphemeralKey { + switch self { + case .customerEphemeralKey(let key): + return key + case .error(let error): + throw error + } + } +} + +class CustomerAdapterTests: APIStubbedTestCase { + + func stubListPaymentMethods( + key: CustomerEphemeralKey, + paymentMethodType: String, + paymentMethodJSONs: [[AnyHashable: Any]], + expectedCount: Int, + apiClient: STPAPIClient + ) { + let exp = expectation(description: "listPaymentMethod") + exp.expectedFulfillmentCount = expectedCount + stub { urlRequest in + if urlRequest.url?.absoluteString.contains("/payment_methods") ?? false + && urlRequest.url?.absoluteString.contains("type=\(paymentMethodType)") ?? false + && urlRequest.httpMethod == "GET" + { + // Check to make sure we pass the ephemeral key correctly + let keyFromHeader = urlRequest.allHTTPHeaderFields!["Authorization"]? + .replacingOccurrences(of: "Bearer ", with: "") + XCTAssertEqual(keyFromHeader, key.ephemeralKeySecret) + return true + } + return false + } response: { urlRequest in + let paymentMethodsJSON = """ + { + "object": "list", + "url": "/v1/payment_methods", + "has_more": false, + "data": [ + ] + } + """ + var pmList = + try! JSONSerialization.jsonObject( + with: paymentMethodsJSON.data(using: .utf8)!, + options: [] + ) as! [AnyHashable: Any] + // Only send the example cards for a card request + if urlRequest.url?.absoluteString.contains("card") ?? false { + pmList["data"] = paymentMethodJSONs + } else if urlRequest.url?.absoluteString.contains("us_bank_account") ?? false { + pmList["data"] = paymentMethodJSONs + } + DispatchQueue.main.async { + // Fulfill after response is sent + exp.fulfill() + } + return HTTPStubsResponse(jsonObject: pmList, statusCode: 200, headers: nil) + } + } + + func testGetOrCreateKeyErrorForwardedToFetchPMs() async throws { + let exp = expectation(description: "fetchPMs") + let expectedError = NSError(domain: "test", code: 123, userInfo: nil) + let apiClient = stubbedAPIClient() + stub { urlRequest in + return urlRequest.url?.absoluteString.contains("/payment_methods") ?? false + } response: { _ in + XCTFail("Retrieve PMs should not be called") + return HTTPStubsResponse(error: NSError(domain: "test", code: 100, userInfo: nil)) + } + let ekm = MockEphemeralKeyEndpoint(expectedError) + let sut = StripeCustomerAdapter(customerEphemeralKeyProvider: ekm.getEphemeralKey, apiClient: apiClient) + do { + _ = try await sut.fetchPaymentMethods() + } catch { + XCTAssertEqual((error as NSError?)?.domain, expectedError.domain) + exp.fulfill() + } + await waitForExpectations(timeout: 2) + } + + let exampleKey = CustomerEphemeralKey(customerId: "abc123", ephemeralKeySecret: "ek_123") + + func testFetchPMs() async throws { + let expectedPaymentMethods = [STPFixtures.paymentMethod()] + let expectedPaymentMethodsJSON = [STPFixtures.paymentMethodJSON()] + let apiClient = stubbedAPIClient() + // Expect 1 call per PM: cards + stubListPaymentMethods(key: exampleKey, paymentMethodType: "card", paymentMethodJSONs: expectedPaymentMethodsJSON, expectedCount: 1, apiClient: apiClient) + stubListPaymentMethods(key: exampleKey, paymentMethodType: "us_bank_account", paymentMethodJSONs: [], expectedCount: 1, apiClient: apiClient) + + let ekm = MockEphemeralKeyEndpoint(exampleKey) + let sut = StripeCustomerAdapter(customerEphemeralKeyProvider: ekm.getEphemeralKey, apiClient: apiClient) + let pms = try await sut.fetchPaymentMethods() + + XCTAssertEqual(pms.count, 1) + XCTAssertEqual(pms[0].stripeId, expectedPaymentMethods[0].stripeId) + await waitForExpectations(timeout: 2) + } + + func testFetchPM_CardAndUSBankAccount() async throws { + let expectedPaymentMethods_card = [STPFixtures.paymentMethod()] + let expectedPaymentMethods_cardJSON = [STPFixtures.paymentMethodJSON()] + + let expectedPaymentMethods_usbank = [STPFixtures.bankAccountPaymentMethod()] + let expectedPaymentMethods_usbankJSON = [STPFixtures.bankAccountPaymentMethodJSON()] + + let apiClient = stubbedAPIClient() + stubListPaymentMethods(key: exampleKey, paymentMethodType: "card", paymentMethodJSONs: expectedPaymentMethods_cardJSON, expectedCount: 1, apiClient: apiClient) + stubListPaymentMethods(key: exampleKey, paymentMethodType: "us_bank_account", paymentMethodJSONs: expectedPaymentMethods_usbankJSON, expectedCount: 1, apiClient: apiClient) + + let ekm = MockEphemeralKeyEndpoint(exampleKey) + let sut = StripeCustomerAdapter(customerEphemeralKeyProvider: ekm.getEphemeralKey, + setupIntentClientSecretProvider: { return "si_" }, + apiClient: apiClient) + let pms = try await sut.fetchPaymentMethods() + XCTAssertEqual(pms.count, 2) + XCTAssertEqual(pms[0].stripeId, expectedPaymentMethods_card[0].stripeId) + XCTAssertEqual(pms[1].stripeId, expectedPaymentMethods_usbank[0].stripeId) + await waitForExpectations(timeout: 2) + } + + func testAttachPM() async throws { + let expectedPaymentMethods = [STPFixtures.paymentMethod()] + let expectedPaymentMethodsJSON = [STPFixtures.paymentMethodJSON()] + let apiClient = stubbedAPIClient() + // Expect 1 call per PM: cards + stubListPaymentMethods(key: exampleKey, paymentMethodType: "card", paymentMethodJSONs: expectedPaymentMethodsJSON, expectedCount: 1, apiClient: apiClient) + stubListPaymentMethods(key: exampleKey, paymentMethodType: "us_bank_account", paymentMethodJSONs: [], expectedCount: 1, apiClient: apiClient) + + let ekm = MockEphemeralKeyEndpoint(exampleKey) + let sut = StripeCustomerAdapter(customerEphemeralKeyProvider: ekm.getEphemeralKey, apiClient: apiClient) + let pms = try await sut.fetchPaymentMethods() + + XCTAssertEqual(pms.count, 1) + XCTAssertEqual(pms[0].stripeId, expectedPaymentMethods[0].stripeId) + + await waitForExpectations(timeout: 2) + } + + func testAttachPaymentMethodCallsAPIClientCorrectly() async { + let apiClient = stubbedAPIClient() + let expectedPaymentMethodJSON = STPFixtures.paymentMethodJSON() + let expectedPaymentMethods = [STPFixtures.paymentMethod()] + + let exp = expectation(description: "payment method attach") + // We're attaching 1 payment method: + exp.expectedFulfillmentCount = 1 + stub { urlRequest in + if urlRequest.url?.absoluteString.contains("/payment_method") ?? false + && urlRequest.httpMethod == "POST" + { + return true + } + return false + } response: { _ in + exp.fulfill() + return HTTPStubsResponse( + jsonObject: expectedPaymentMethodJSON, + statusCode: 200, + headers: nil + ) + } + + let ekm = MockEphemeralKeyEndpoint(exampleKey) + let sut = StripeCustomerAdapter(customerEphemeralKeyProvider: ekm.getEphemeralKey, apiClient: apiClient) + try! await sut.attachPaymentMethod(expectedPaymentMethods.first!.stripeId) + + await waitForExpectations(timeout: 2, handler: nil) + } + + func testDetachPaymentMethodCallsAPIClientCorrectly() async { + let apiClient = stubbedAPIClient() + let expectedPaymentMethodJSON = STPFixtures.paymentMethodJSON() + let expectedPaymentMethods = [STPFixtures.paymentMethod()] + + let exp = expectation(description: "payment method detach") + // We're detaching 1 payment method: + exp.expectedFulfillmentCount = 1 + stub { urlRequest in + if urlRequest.url?.absoluteString.contains("/payment_method") ?? false + && urlRequest.httpMethod == "POST" + { + return true + } + return false + } response: { _ in + exp.fulfill() + return HTTPStubsResponse( + jsonObject: expectedPaymentMethodJSON, + statusCode: 200, + headers: nil + ) + } + + let ekm = MockEphemeralKeyEndpoint(exampleKey) + let sut = StripeCustomerAdapter(customerEphemeralKeyProvider: ekm.getEphemeralKey, apiClient: apiClient) + try! await sut.detachPaymentMethod(paymentMethodId: expectedPaymentMethods.first!.stripeId) + + await waitForExpectations(timeout: 2, handler: nil) + } + + func configuration() -> CustomerSheet.Configuration { + return CustomerSheet.Configuration() + } +} diff --git a/Stripe/StripeiOSTests/Error+PaymentSheetTests.swift b/Stripe/StripeiOSTests/Error+PaymentSheetTests.swift new file mode 100644 index 00000000..eb2980b2 --- /dev/null +++ b/Stripe/StripeiOSTests/Error+PaymentSheetTests.swift @@ -0,0 +1,54 @@ +// +// Error+PaymentSheetTests.swift +// StripeiOSTests +// +// Created by Nick Porter on 3/21/23. +// + +import Foundation +import XCTest + +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePaymentSheet + +class Error_PaymentSheetTests: XCTestCase { + + private enum TestableError: LocalizedError { + case generic + case custom(String) + + var errorDescription: String? { + switch self { + case .generic: + return NSError.stp_unexpectedErrorMessage() + case .custom(let errorMessage): + return errorMessage + } + } + } + + func testPaymentSheetError_UsesDebugDescription() { + let error = PaymentSheetError.unknown(debugDescription: "Test debugDescription") + + XCTAssertEqual("An unknown error occurred in PaymentSheet. Test debugDescription", error.nonGenericDescription) + } + + func testError_HasGenericLocalizedDescription_NoSeverError() { + XCTAssertEqual(NSError.stp_unexpectedErrorMessage(), TestableError.generic.nonGenericDescription) + } + + func testError_HasGenericLocalizedDescription_WithServerError() { + let serverErrorMessage = "Test failed server response error messasge" + let info = [NSLocalizedDescriptionKey: NSError.stp_unexpectedErrorMessage(), STPError.errorMessageKey: serverErrorMessage] + let error = NSError(domain: "Test error domain", code: 123, userInfo: info) + + XCTAssertEqual(serverErrorMessage, error.nonGenericDescription) + } + + func testError_HasLocalizedDescription() { + let errorMessage = "Test errorMessage" + let error = TestableError.custom(errorMessage) + + XCTAssertEqual(errorMessage, error.nonGenericDescription) + } +} diff --git a/Stripe/StripeiOSTests/FBSnapshotTestCase+STPViewControllerLoading.swift b/Stripe/StripeiOSTests/FBSnapshotTestCase+STPViewControllerLoading.swift new file mode 100644 index 00000000..3b75e218 --- /dev/null +++ b/Stripe/StripeiOSTests/FBSnapshotTestCase+STPViewControllerLoading.swift @@ -0,0 +1,79 @@ +// +// FBSnapshotTestCase+STPViewControllerLoading.swift +// StripeiOS Tests +// +// Created by Brian Dorfman on 12/11/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase + +extension FBSnapshotTestCase { + /// Embeds the given controller in a navigation controller, prepares it for + /// snapshot testing and returns the view controller's view. + @objc(stp_preparedAndSizedViewForSnapshotTestFromViewController:) + func stp_preparedAndSizedViewForSnapshotTest(from viewController: UIViewController?) -> UIView? + { + let navController = stp_navigationControllerForSnapshotTest(withRootVC: viewController) + return stp_preparedAndSizedViewForSnapshotTest(from: navController) + } + + /// Returns a navigation controller initialized with the given root view controller + /// and prepares it for snapshot testing (adding it to a UIWindow and loading views) + @objc func stp_navigationControllerForSnapshotTest( + withRootVC viewController: UIViewController? + ) + -> UINavigationController? + { + var navController: UINavigationController? + if let viewController = viewController { + navController = UINavigationController(rootViewController: viewController) + } + let testWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + testWindow.rootViewController = navController + testWindow.isHidden = false + + // Test that views loaded properly + loads them on first call + XCTAssertNotNil(navController?.view) + XCTAssertNotNil(viewController?.view) + + return navController + } + + /// Returns a view for snapshot testing from the topViewController of the given + /// navigation controller, making necessary layout adjustments for + /// `STPCoreScrollViewController`. + @objc(stp_preparedAndSizedViewForSnapshotTestFromNavigationController:) + func stp_preparedAndSizedViewForSnapshotTest( + from navController: UINavigationController? + ) + -> UIView? + { + let viewController = navController?.topViewController + + // Test that views loaded properly + loads them on first call + XCTAssertNotNil(navController?.view) + XCTAssertNotNil(viewController?.view) + + if viewController is STPCoreScrollViewController { + guard let scrollView = (viewController as? STPCoreScrollViewController)?.scrollView, + let navController = navController + else { + return nil + } + navController.view.layoutIfNeeded() + + let topOffset = scrollView.convert(scrollView.frame.origin, to: navController.view).y + navController.view.frame = CGRect( + x: 0, + y: 0, + width: 320, + height: (topOffset) + (scrollView.contentSize.height) + + (scrollView.contentInset.top) + + (scrollView.contentInset.bottom) + ) + } + + return navController?.view + } +} diff --git a/Stripe/StripeiOSTests/FormSpecProviderTest.swift b/Stripe/StripeiOSTests/FormSpecProviderTest.swift new file mode 100644 index 00000000..56fc602d --- /dev/null +++ b/Stripe/StripeiOSTests/FormSpecProviderTest.swift @@ -0,0 +1,665 @@ +// +// FormSpecProviderTest.swift +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 3/7/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class FormSpecProviderTest: XCTestCase { + func testLoadsJSON() throws { + let e = expectation(description: "Loads form specs file") + let sut = FormSpecProvider() + sut.load { loaded in + XCTAssertTrue(loaded) + e.fulfill() + } + waitForExpectations(timeout: 2, handler: nil) + + guard let eps = sut.formSpec(for: "eps") else { + XCTFail() + return + } + XCTAssertEqual(eps.fields.count, 5) + XCTAssertEqual( + eps.fields.first, + .name(FormSpec.NameFieldSpec(apiPath: ["v1": "billing_details[name]"], translationId: nil)) + ) + + // ...and iDEAL has the correct dropdown spec + guard let ideal = sut.formSpec(for: "ideal"), + case .name = ideal.fields[0], + case .selector(let selector) = ideal.fields[3] + else { + XCTFail() + return + } + XCTAssertEqual(selector.apiPath?["v1"], "ideal[bank]") + XCTAssertEqual(selector.items.count, 11) + } + + func testLoadJsonCanOverwriteLoadedSpecs() throws { + let e = expectation(description: "Loads form specs file") + let sut = FormSpecProvider() + let paymentMethodType = "eps" + sut.load { loaded in + XCTAssertTrue(loaded) + e.fulfill() + } + waitForExpectations(timeout: 2, handler: nil) + let eps = try XCTUnwrap(sut.formSpec(for: paymentMethodType)) + XCTAssertEqual(eps.fields.count, 5) + XCTAssertEqual( + eps.fields.first, + .name(FormSpec.NameFieldSpec(apiPath: ["v1": "billing_details[name]"], translationId: nil)) + ) + let updatedSpecJson = + """ + [{ + "type": "eps", + "async": false, + "fields": [ + { + "type": "name", + "api_path": { + "v1": "billing_details[someOtherValue]" + } + } + ] + }] + """.data(using: .utf8)! + let formSpec = try! JSONSerialization.jsonObject(with: updatedSpecJson) + + let result = sut.loadFrom(formSpec) + XCTAssert(result) + + let epsUpdated = try XCTUnwrap(sut.formSpec(for: paymentMethodType)) + XCTAssertEqual(epsUpdated.fields.count, 1) + XCTAssertEqual( + epsUpdated.fields.first, + .name( + FormSpec.NameFieldSpec( + apiPath: ["v1": "billing_details[someOtherValue]"], + translationId: nil + ) + ) + ) + } + + func testLoadJsonFailsGracefully() throws { + let e = expectation(description: "Loads form specs file") + let sut = FormSpecProvider() + let paymentMethodType = "eps" + sut.load { loaded in + XCTAssertTrue(loaded) + e.fulfill() + } + waitForExpectations(timeout: 20, handler: nil) + let eps = try XCTUnwrap(sut.formSpec(for: paymentMethodType)) + XCTAssertEqual(eps.fields.count, 5) + XCTAssertEqual( + eps.fields.first, + .name(FormSpec.NameFieldSpec(apiPath: ["v1": "billing_details[name]"], translationId: nil)) + ) + let updatedSpecJson = + """ + [{ + "INVALID_type": "eps", + "async": false, + "fields": [ + { + "type": "name", + "api_path": { + "v1": "billing_details[someOtherValue]" + } + } + ] + }] + """.data(using: .utf8)! + let formSpec = try! JSONSerialization.jsonObject(with: updatedSpecJson) + + let result = sut.loadFrom(formSpec) + XCTAssertFalse(result) + let epsUpdated = try XCTUnwrap(sut.formSpec(for: paymentMethodType)) + XCTAssertEqual(epsUpdated.fields.count, 5) + XCTAssertEqual( + epsUpdated.fields.first, + .name(FormSpec.NameFieldSpec(apiPath: ["v1": "billing_details[name]"], translationId: nil)) + ) + } + + func testLoadNotValidJsonFailsGracefully() throws { + let e = expectation(description: "Loads form specs file") + let sut = FormSpecProvider() + let paymentMethodType = "eps" + sut.load { loaded in + XCTAssertTrue(loaded) + e.fulfill() + } + waitForExpectations(timeout: 2, handler: nil) + + let eps = try XCTUnwrap(sut.formSpec(for: paymentMethodType)) + XCTAssertEqual(eps.fields.count, 5) + XCTAssertEqual( + eps.fields.first, + .name(FormSpec.NameFieldSpec(apiPath: ["v1": "billing_details[name]"], translationId: nil)) + ) + + let updatedSpecJson = + """ + NOT VALID JSON + """.data(using: .utf8)! + + let result = sut.loadFrom(updatedSpecJson) + XCTAssertFalse(result) + let epsUpdated = try XCTUnwrap(sut.formSpec(for: paymentMethodType)) + XCTAssertEqual(epsUpdated.fields.count, 5) + XCTAssertEqual( + epsUpdated.fields.first, + .name(FormSpec.NameFieldSpec(apiPath: ["v1": "billing_details[name]"], translationId: nil)) + ) + } + + func testLoadJsonDoesOverwrites() throws { + let e = expectation(description: "Loads form specs file") + let sut = FormSpecProvider() + let paymentMethodType = "eps" + sut.load { loaded in + XCTAssertTrue(loaded) + e.fulfill() + } + waitForExpectations(timeout: 2, handler: nil) + let eps = try XCTUnwrap(sut.formSpec(for: paymentMethodType)) + XCTAssertEqual(eps.fields.count, 5) + XCTAssertEqual( + eps.fields.first, + .name(FormSpec.NameFieldSpec(apiPath: ["v1": "billing_details[name]"], translationId: nil)) + ) + guard + case .redirect_to_url = eps.nextActionSpec?.confirmResponseStatusSpecs[ + "requires_action" + ]?.type, + case .finished = eps.nextActionSpec?.postConfirmHandlingPiStatusSpecs?["succeeded"]? + .type, + case .canceled = eps.nextActionSpec?.postConfirmHandlingPiStatusSpecs?[ + "requires_action" + ]?.type + else { + XCTFail() + return + } + + let updatedSpecJson = + """ + [{ + "type": "eps", + "async": false, + "fields": [ + { + "type": "name", + "api_path": { + "v1": "billing_details[someOtherValue]" + } + } + ], + "next_action_spec": { + "confirm_response_status_specs": { + "requires_action": { + "type": "redirect_to_url" + } + }, + "post_confirm_handling_pi_status_specs": { + "succeeded": { + "type": "finished" + }, + "requires_action": { + "type": "finished" + } + } + } + }] + """.data(using: .utf8)! + let formSpec = try! JSONSerialization.jsonObject(with: updatedSpecJson) + + let result = sut.loadFrom(formSpec) + XCTAssert(result) + + // Validate ability to override LPM behavior of next actions + let epsUpdated = try XCTUnwrap(sut.formSpec(for: paymentMethodType)) + XCTAssertEqual(epsUpdated.fields.count, 1) + XCTAssertEqual( + epsUpdated.fields.first, + .name( + FormSpec.NameFieldSpec( + apiPath: ["v1": "billing_details[someOtherValue]"], + translationId: nil + ) + ) + ) + guard + case .redirect_to_url = epsUpdated.nextActionSpec?.confirmResponseStatusSpecs[ + "requires_action" + ]?.type, + case .finished = epsUpdated.nextActionSpec?.postConfirmHandlingPiStatusSpecs?[ + "succeeded" + ]?.type, + case .finished = epsUpdated.nextActionSpec?.postConfirmHandlingPiStatusSpecs?[ + "requires_action" + ]?.type + else { + XCTFail() + return + } + } + + func testLoadJsonDoesOverwritesWithoutNextActionSpec() throws { + let e = expectation(description: "Loads form specs file") + let sut = FormSpecProvider() + let paymentMethodType = "affirm" + sut.load { loaded in + XCTAssertTrue(loaded) + e.fulfill() + } + waitForExpectations(timeout: 2, handler: nil) + let affirm = try XCTUnwrap(sut.formSpec(for: paymentMethodType)) + XCTAssertEqual(affirm.fields.count, 1) + XCTAssertEqual(affirm.fields.first, .affirm_header) + guard + case .redirect_to_url = affirm.nextActionSpec?.confirmResponseStatusSpecs[ + "requires_action" + ]?.type, + case .finished = affirm.nextActionSpec?.postConfirmHandlingPiStatusSpecs?["succeeded"]? + .type, + case .canceled = affirm.nextActionSpec?.postConfirmHandlingPiStatusSpecs?[ + "requires_action" + ]?.type + else { + XCTFail() + return + } + + let updatedSpecJson = + """ + [{ + "type": "affirm", + "async": false, + "fields": [ + { + "type": "name" + } + ] + }] + """.data(using: .utf8)! + let formSpec = try! JSONSerialization.jsonObject(with: updatedSpecJson) as! [NSDictionary] + + let result = sut.loadFrom(formSpec) + XCTAssert(result) + + guard let affirmUpdated = sut.formSpec(for: paymentMethodType), + affirmUpdated.fields.count == 1, + affirmUpdated.fields.first + == .name(FormSpec.NameFieldSpec(apiPath: nil, translationId: nil)), + affirmUpdated.nextActionSpec == nil + else { + XCTFail() + return + } + } + + func testLoadJsonDoesNotOverwriteWhenWithUnsupportedNextAction() throws { + let e = expectation(description: "Loads form specs file") + let sut = FormSpecProvider() + let paymentMethodType = "eps" + sut.load { loaded in + XCTAssertTrue(loaded) + e.fulfill() + } + waitForExpectations(timeout: 2, handler: nil) + let eps = try XCTUnwrap(sut.formSpec(for: paymentMethodType)) + XCTAssertEqual(eps.fields.count, 5) + XCTAssertEqual( + eps.fields.first, + .name(FormSpec.NameFieldSpec(apiPath: ["v1": "billing_details[name]"], translationId: nil)) + ) + guard + case .redirect_to_url(let redirectToURLDetails) = eps.nextActionSpec?.confirmResponseStatusSpecs[ + "requires_action" + ]?.type, + case .finished = eps.nextActionSpec?.postConfirmHandlingPiStatusSpecs?["succeeded"]? + .type, + case .canceled = eps.nextActionSpec?.postConfirmHandlingPiStatusSpecs?[ + "requires_action" + ]?.type + else { + XCTFail() + return + } + XCTAssertEqual(redirectToURLDetails.redirectStrategy, .none) + XCTAssertEqual(redirectToURLDetails.urlPath, "next_action[redirect_to_url][url]") + XCTAssertEqual(redirectToURLDetails.returnUrlPath, "next_action[redirect_to_url][return_url]") + + let updatedSpecJsonWithUnsupportedNextAction = + """ + [{ + "type": "eps", + "async": false, + "fields": [ + { + "type": "name", + "api_path": { + "v1": "billing_details[someOtherValue]" + } + } + ], + "next_action_spec": { + "confirm_response_status_specs": { + "requires_action": { + "type": "redirect_to_url_v2_NotSupported" + } + }, + "post_confirm_handling_pi_status_specs": { + "succeeded": { + "type": "finished" + }, + "requires_action": { + "type": "canceled" + } + } + } + }] + """.data(using: .utf8)! + let formSpec = + try! JSONSerialization.jsonObject(with: updatedSpecJsonWithUnsupportedNextAction) + as! [NSDictionary] + let result = sut.loadFrom(formSpec) + XCTAssertFalse(result) + + // Validate that we were not able to override the spec read in from disk + let epsUpdated = try XCTUnwrap(sut.formSpec(for: paymentMethodType)) + XCTAssertEqual(epsUpdated.fields.count, 5) + XCTAssertEqual( + epsUpdated.fields.first, + .name(FormSpec.NameFieldSpec(apiPath: ["v1": "billing_details[name]"], translationId: nil)) + ) + guard + case .redirect_to_url = epsUpdated.nextActionSpec?.confirmResponseStatusSpecs[ + "requires_action" + ]?.type, + case .finished = epsUpdated.nextActionSpec?.postConfirmHandlingPiStatusSpecs?[ + "succeeded" + ]?.type, + case .canceled = epsUpdated.nextActionSpec?.postConfirmHandlingPiStatusSpecs?[ + "requires_action" + ]?.type + else { + XCTFail() + return + } + } + func testContainsKnownNextAction() throws { + let formSpec = + """ + [{ + "type": "eps", + "async": false, + "fields": [ + ], + "next_action_spec": { + "confirm_response_status_specs": { + "requires_action": { + "type": "redirect_to_url" + } + }, + "post_confirm_handling_pi_status_specs": { + "succeeded": { + "type": "finished" + }, + "requires_action": { + "type": "canceled" + } + } + } + }] + """.data(using: .utf8)! + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let decodedFormSpecs = try decoder.decode([FormSpec].self, from: formSpec) + + let sut = FormSpecProvider() + XCTAssertFalse(sut.containsUnknownNextActions(formSpec: decodedFormSpecs[0])) + } + + func testContainsUnknownNextAction_confirm() throws { + let formSpec = + """ + [{ + "type": "eps", + "async": false, + "fields": [ + ], + "next_action_spec": { + "confirm_response_status_specs": { + "requires_action": { + "type": "redirect_to_url_v2_NotSupported" + } + }, + "post_confirm_handling_pi_status_specs": { + "succeeded": { + "type": "finished" + }, + "requires_action": { + "type": "canceled" + } + } + } + }] + """.data(using: .utf8)! + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let decodedFormSpecs = try decoder.decode([FormSpec].self, from: formSpec) + + let sut = FormSpecProvider() + XCTAssert(sut.containsUnknownNextActions(formSpec: decodedFormSpecs[0])) + } + + func testContainsUnknownNextAction_PostConfirm() throws { + let formSpec = + """ + [{ + "type": "eps", + "async": false, + "fields": [ + ], + "next_action_spec": { + "confirm_response_status_specs": { + "requires_action": { + "type": "redirect_to_url" + } + }, + "post_confirm_handling_pi_status_specs": { + "succeeded": { + "type": "finished_NotSupportedType" + }, + "requires_action": { + "type": "canceled" + } + } + } + }] + """.data(using: .utf8)! + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let decodedFormSpecs = try decoder.decode([FormSpec].self, from: formSpec) + + let sut = FormSpecProvider() + XCTAssert(sut.containsUnknownNextActions(formSpec: decodedFormSpecs[0])) + } + func testRedirectToURLWithExternalBrowserStrategy() throws { + let formSpec = + """ + [{ + "type": "eps", + "async": false, + "fields": [ + ], + "next_action_spec": { + "confirm_response_status_specs": { + "requires_action": { + "type": "redirect_to_url", + "native_mobile_redirect_strategy": "external_browser" + } + }, + "post_confirm_handling_pi_status_specs": { + "succeeded": { + "type": "finished" + } + } + } + }] + """.data(using: .utf8)! + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let eps = try decoder.decode([FormSpec].self, from: formSpec).first + + guard case let .redirect_to_url(redirectToURL) = eps?.nextActionSpec?.confirmResponseStatusSpecs["requires_action"]?.type else { + XCTFail("Unable to parse requires_action") + return + } + XCTAssertEqual(redirectToURL.redirectStrategy, .external_browser) + XCTAssertEqual(redirectToURL.urlPath, "next_action[redirect_to_url][url]") + XCTAssertEqual(redirectToURL.returnUrlPath, "next_action[redirect_to_url][return_url]") + } + func testRedirectToURLWithFollowRedirectsStrategy() throws { + let formSpec = + """ + [{ + "type": "eps", + "async": false, + "fields": [ + ], + "next_action_spec": { + "confirm_response_status_specs": { + "requires_action": { + "type": "redirect_to_url", + "native_mobile_redirect_strategy": "follow_redirects" + } + }, + "post_confirm_handling_pi_status_specs": { + "succeeded": { + "type": "finished" + } + } + } + }] + """.data(using: .utf8)! + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let eps = try decoder.decode([FormSpec].self, from: formSpec).first + + guard case let .redirect_to_url(redirectToURL) = eps?.nextActionSpec?.confirmResponseStatusSpecs["requires_action"]?.type else { + XCTFail("Unable to parse requires_action") + return + } + XCTAssertEqual(redirectToURL.redirectStrategy, .follow_redirects) + XCTAssertEqual(redirectToURL.urlPath, "next_action[redirect_to_url][url]") + XCTAssertEqual(redirectToURL.returnUrlPath, "next_action[redirect_to_url][return_url]") + } + + func testUnparsableSpecIsSkipped() throws { + let e = expectation(description: "Loads form specs file") + let sut = FormSpecProvider() + sut.load { loaded in + XCTAssertTrue(loaded) + e.fulfill() + } + waitForExpectations(timeout: 2, handler: nil) + + let json = + """ + [ + { + "type": "eps", + "async": false, + "fields": [ + ], + "next_action_spec": { + "confirm_response_status_specs": { + "requires_action": { + "type": "redirect_to_url" + } + }, + "post_confirm_handling_pi_status_specs": { + "succeeded": { + "type": "finished_NotSupportedType" + }, + "requires_action": { + "type": "canceled" + } + } + } + }, + { + "type": "affirm", + "async": false, + "fields": [ + { + "type": "name", + "translationId": "invalid.translation.id" + } + ] + }, + { + "type": "klarna", + "async": false, + "fields": [ + { + "type": "klarna_header", + } + ] + } + ] + """.data(using: .utf8)! + + let formSpec = try! JSONSerialization.jsonObject(with: json) + + // loadFrom should return false because not all specs were parsed successfully. + XCTAssertFalse(sut.loadFrom(formSpec)) + + // Verify that eps spec was not overridden. + let epsSpec = sut.formSpec(for: "eps") + XCTAssertEqual(epsSpec?.fields.count, 5) + guard + case .name = epsSpec?.fields[0], + case .placeholder(let emailPlaceholder) = epsSpec?.fields[1], + emailPlaceholder.field == .email, + case .placeholder(let phonePlaceholder) = epsSpec?.fields[2], + phonePlaceholder.field == .phone, + case .selector = epsSpec?.fields[3], + case .placeholder(let addressPlaceholder) = epsSpec?.fields[4], + addressPlaceholder.field == .billingAddress + else { + XCTFail("Incorrect eps spec: \(epsSpec!)") + return + } + + // Verify that the affirm spec was not overridden. + let affirmSpec = sut.formSpec(for: "affirm") + XCTAssertEqual(affirmSpec?.fields.count, 1) + XCTAssertEqual(affirmSpec?.fields[0], .affirm_header) + + // Verify that the klarna spec is successfully replaced. + let klarnaSpec = sut.formSpec(for: "klarna") + XCTAssertEqual(klarnaSpec?.fields.count, 1) + XCTAssertEqual(klarnaSpec?.fields[0], .klarna_header) + } +} diff --git a/Stripe/StripeiOSTests/FraudDetectionDataTest.swift b/Stripe/StripeiOSTests/FraudDetectionDataTest.swift new file mode 100644 index 00000000..3b4ba93d --- /dev/null +++ b/Stripe/StripeiOSTests/FraudDetectionDataTest.swift @@ -0,0 +1,31 @@ +// +// FraudDetectionDataTest.swift +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 5/21/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class FraudDetectionDataTest: XCTestCase { + func testResetsSIDIfExpired() { + FraudDetectionData.shared.sidCreationDate = Date(timeInterval: -30 * 60 - 1, since: Date()) + FraudDetectionData.shared.resetSIDIfExpired() + XCTAssertNil(FraudDetectionData.shared.sid) + } + + func testSIDNotExpired() { + // Test resets sid if expired + FraudDetectionData.shared.sid = "123" + FraudDetectionData.shared.sidCreationDate = Date() + FraudDetectionData.shared.resetSIDIfExpired() + XCTAssertNotNil(FraudDetectionData.shared.sid) + } +} diff --git a/Stripe/StripeiOSTests/ImageTest.swift b/Stripe/StripeiOSTests/ImageTest.swift new file mode 100644 index 00000000..3c31b62e --- /dev/null +++ b/Stripe/StripeiOSTests/ImageTest.swift @@ -0,0 +1,27 @@ +// +// ImageTest.swift +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 5/19/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class ImageTest: XCTestCase { + func testAllImagesExist() throws { + for image in Image.allCases { + let image = UIImage( + named: image.rawValue, + in: StripePaymentSheetBundleLocator.resourcesBundle, + compatibleWith: nil + ) + XCTAssertNotNil(image) + } + } +} diff --git a/Stripe/StripeiOSTests/Info.plist b/Stripe/StripeiOSTests/Info.plist new file mode 100644 index 00000000..ba72822e --- /dev/null +++ b/Stripe/StripeiOSTests/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1 + + diff --git a/Stripe/StripeiOSTests/IntentConfirmParamsTest.swift b/Stripe/StripeiOSTests/IntentConfirmParamsTest.swift new file mode 100644 index 00000000..54b2ce62 --- /dev/null +++ b/Stripe/StripeiOSTests/IntentConfirmParamsTest.swift @@ -0,0 +1,32 @@ +// +// IntentConfirmParamsTest.swift +// StripeiOSTests +// +// Created by Nick Porter on 10/11/23. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +final class IntentConfirmParamsTest: XCTestCase { + + func testMakeDashboardParams() { + let params = IntentConfirmParams.makeDashboardParams(paymentIntentClientSecret: "test_client_secret", + paymentMethodID: "test_payment_method_id", + shouldSave: true, + paymentMethodType: .card, + customer: .init(id: "test_id", + ephemeralKeySecret: "test_key")) + + XCTAssertEqual(params.clientSecret, "test_client_secret") + XCTAssertEqual(params.paymentMethodId, "test_payment_method_id") + XCTAssertEqual(params.paymentMethodOptions?.cardOptions?.additionalAPIParameters["setup_future_usage"] as? String, "off_session") + XCTAssertTrue(params.paymentMethodOptions?.cardOptions?.additionalAPIParameters["moto"] as? Bool ?? false) + } + +} diff --git a/Stripe/StripeiOSTests/KlarnaHelperTest.swift b/Stripe/StripeiOSTests/KlarnaHelperTest.swift new file mode 100644 index 00000000..052399f7 --- /dev/null +++ b/Stripe/StripeiOSTests/KlarnaHelperTest.swift @@ -0,0 +1,97 @@ +// +// KlarnaHelperTest.swift +// StripeiOS Tests +// +// Created by Nick Porter on 11/1/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class KlarnaHelperTest: XCTestCase { + + func testAvailableCountries_eur() { + let expected = ["AT", "FI", "DE", "NL", "BE", "ES", "IT", "FR", "GR", "IE", "PT"] + XCTAssertEqual(expected, KlarnaHelper.availableCountries(currency: "eur")) + } + + func testAvailableCountries_dkk() { + let expected = ["DK"] + XCTAssertEqual(expected, KlarnaHelper.availableCountries(currency: "dkk")) + } + + func testAvailableCountries_nok() { + let expected = ["NO"] + XCTAssertEqual(expected, KlarnaHelper.availableCountries(currency: "nok")) + } + + func testAvailableCountries_sek() { + let expected = ["SE"] + XCTAssertEqual(expected, KlarnaHelper.availableCountries(currency: "sek")) + } + + func testAvailableCountries_gbp() { + let expected = ["GB"] + XCTAssertEqual(expected, KlarnaHelper.availableCountries(currency: "gbp")) + } + + func testAvailableCountries_usd() { + let expected = ["US"] + XCTAssertEqual(expected, KlarnaHelper.availableCountries(currency: "usd")) + } + + func testAvailableCountries_aud() { + let expected = ["AU"] + XCTAssertEqual(expected, KlarnaHelper.availableCountries(currency: "aud")) + } + + func testAvailableCountries_cad() { + let expected = ["CA"] + XCTAssertEqual(expected, KlarnaHelper.availableCountries(currency: "cad")) + } + + func testAvailableCountries_czk() { + let expected = ["CZ"] + XCTAssertEqual(expected, KlarnaHelper.availableCountries(currency: "czk")) + } + + func testAvailableCountries_nzd() { + let expected = ["NZ"] + XCTAssertEqual(expected, KlarnaHelper.availableCountries(currency: "nzd")) + } + + func testAvailableCountries_pln() { + let expected = ["PL"] + XCTAssertEqual(expected, KlarnaHelper.availableCountries(currency: "pln")) + } + + func testAvailableCountries_chf() { + let expected = ["CH"] + XCTAssertEqual(expected, KlarnaHelper.availableCountries(currency: "chf")) + } + + func testCanBuyNow_shouldReturnTrue() { + // https://site-admin.stripe.com/docs/payments/klarna#payment-options + let canBuyNow = ["de_AT", "nl_BE", "de_DE", "it_IT", "nl_NL", "es_ES", "sv_SE", "en_CA", "en_AU", "pl_PL", "es_PT", "de_CH", "fr_CA"] + + for country in canBuyNow { + XCTAssertTrue(KlarnaHelper.canBuyNow(locale: Locale(identifier: country))) + } + } + + func testCanBuyNow_shouldReturnFalse() { + // https://site-admin.stripe.com/docs/payments/klarna#payment-options + let canNotBuyNow = ["da_DK", "fi_FI", "fr_FR", "no_NO", "en_GB", "en_US"] + + for country in canNotBuyNow { + XCTAssertFalse(KlarnaHelper.canBuyNow(locale: Locale(identifier: country))) + } + } + +} diff --git a/Stripe/StripeiOSTests/LinkAccountServiceTests.swift b/Stripe/StripeiOSTests/LinkAccountServiceTests.swift new file mode 100644 index 00000000..5fe54322 --- /dev/null +++ b/Stripe/StripeiOSTests/LinkAccountServiceTests.swift @@ -0,0 +1,42 @@ +// +// LinkAccountServiceTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 2/22/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class LinkAccountServiceTests: XCTestCase { + + func testWrite() { + let sut = makeSUT() + XCTAssertTrue(sut.hasEmailLoggedOut(email: "user@example.com")) + XCTAssertTrue(sut.hasEmailLoggedOut(email: "USER@EXAMPLE.COM")) + XCTAssertFalse(sut.hasEmailLoggedOut(email: "user@example.net")) + } + +} + +extension LinkAccountServiceTests { + + func makeSUT() -> LinkAccountService { + let cookieStore = LinkInMemoryCookieStore() + + cookieStore.write( + key: .lastLogoutEmail, + // SHA-256 hash for `user@example.com` + value: "tMmiiTI7IaAcPpQPFQ65uMVCWH8av9jw4cwf/F5HVRQ=" + ) + + return LinkAccountService(cookieStore: cookieStore) + } + +} diff --git a/Stripe/StripeiOSTests/LinkInMemoryCookieStoreTests.swift b/Stripe/StripeiOSTests/LinkInMemoryCookieStoreTests.swift new file mode 100644 index 00000000..1eb64c5d --- /dev/null +++ b/Stripe/StripeiOSTests/LinkInMemoryCookieStoreTests.swift @@ -0,0 +1,81 @@ +// +// LinkInMemoryCookieStoreTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 12/21/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class LinkInMemoryCookieStoreTests: XCTestCase { + + static let testKey: LinkCookieKey = .session + + func testWrite() { + let sut = makeSUT() + sut.write(key: Self.testKey, value: "cookie_value") + + XCTAssertEqual(sut.read(key: Self.testKey), "cookie_value") + } + + func testWrite_overwriting() { + let sut = makeSUT() + sut.write(key: Self.testKey, value: "cookie_value") + sut.write(key: Self.testKey, value: "new_cookie_value") + + XCTAssertEqual(sut.read(key: Self.testKey), "new_cookie_value") + } + + func testDelete() { + let sut = makeSUT() + sut.write(key: Self.testKey, value: "cookie_value") + sut.delete(key: Self.testKey) + + XCTAssertNil(sut.read(key: Self.testKey)) + } + + // MARK: Session cookies + + func testFormattedSessionCookies() { + let sut = makeSUT() + + sut.write(key: .session, value: "cookie_value") + XCTAssertEqual( + sut.formattedSessionCookies(), + [ + "verification_session_client_secrets": ["cookie_value"] + ] + ) + + sut.delete(key: .session) + XCTAssertNil(sut.formattedSessionCookies()) + } + + func testUpdateSessionCookie() { + let sut = makeSUT() + sut.updateSessionCookie(with: "top_secret") + XCTAssertEqual(sut.read(key: .session), "top_secret") + + sut.updateSessionCookie(with: nil) + XCTAssertEqual(sut.read(key: .session), "top_secret") + + sut.updateSessionCookie(with: "") + XCTAssertNil(sut.read(key: .session)) + } + +} + +extension LinkInMemoryCookieStoreTests { + + func makeSUT() -> LinkInMemoryCookieStore { + return LinkInMemoryCookieStore() + } + +} diff --git a/Stripe/StripeiOSTests/LinkLegalTermsViewSnapshotTests.swift b/Stripe/StripeiOSTests/LinkLegalTermsViewSnapshotTests.swift new file mode 100644 index 00000000..82a55ac6 --- /dev/null +++ b/Stripe/StripeiOSTests/LinkLegalTermsViewSnapshotTests.swift @@ -0,0 +1,94 @@ +// +// LinkLegalTermsViewSnapshotTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 1/26/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase +import UIKit + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI +@testable@_spi(STP) import StripeUICore + +class LinkLegalTermsViewSnapshotTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() + // recordMode = true + } + + func testDefault() { + let sut = makeSUT() + verify(sut) + } + + func testCentered() { + let sut = makeSUT(textAlignment: .center) + verify(sut) + } + + func testColorCustomization() { + let sut = makeSUT() + sut.textColor = .orange + sut.tintColor = .purple + verify(sut) + } + + func testLocalization() { + performLocalizedSnapshotTest(forLanguage: "de") + performLocalizedSnapshotTest(forLanguage: "es") + performLocalizedSnapshotTest(forLanguage: "el-GR") + performLocalizedSnapshotTest(forLanguage: "it") + performLocalizedSnapshotTest(forLanguage: "ja") + performLocalizedSnapshotTest(forLanguage: "ko") + performLocalizedSnapshotTest(forLanguage: "zh-Hans") + } + +} + +// MARK: - Helpers + +extension LinkLegalTermsViewSnapshotTests { + + func verify( + _ view: UIView, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + view.autosizeHeight(width: 250) + STPSnapshotVerifyView(view, identifier: identifier, file: file, line: line) + } + + func performLocalizedSnapshotTest( + forLanguage language: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + STPLocalizationUtils.overrideLanguage(to: language) + let sut = makeSUT() + STPLocalizationUtils.overrideLanguage(to: nil) + verify(sut, identifier: language, file: file, line: line) + } + +} + +// MARK: - Factory + +extension LinkLegalTermsViewSnapshotTests { + + func makeSUT() -> LinkLegalTermsView { + return LinkLegalTermsView() + } + + func makeSUT(textAlignment: NSTextAlignment) -> LinkLegalTermsView { + return LinkLegalTermsView(textAlignment: textAlignment) + } + +} diff --git a/Stripe/StripeiOSTests/LinkSignupViewModelTests.swift b/Stripe/StripeiOSTests/LinkSignupViewModelTests.swift new file mode 100644 index 00000000..d11f6a34 --- /dev/null +++ b/Stripe/StripeiOSTests/LinkSignupViewModelTests.swift @@ -0,0 +1,179 @@ +// +// LinkSignupViewModelTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 1/21/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +import StripeCoreTestUtils +@_spi(STP) import StripeUICore +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class LinkInlineSignupViewModelTests: XCTestCase { + + // Should be ~4x the debounce time for best results. + let accountLookupTimeout: TimeInterval = 4 + + func test_defaults() { + let sut = makeSUT(country: "US") + + XCTAssertFalse(sut.shouldShowEmailField) + XCTAssertFalse(sut.shouldShowPhoneField) + XCTAssertFalse(sut.shouldShowNameField) + XCTAssertFalse(sut.shouldShowLegalTerms) + } + + func test_shouldShowEmailFieldWhenCheckboxIsChecked() { + let sut = makeSUT(country: "US") + + sut.saveCheckboxChecked = true + XCTAssertTrue(sut.shouldShowEmailField) + + sut.saveCheckboxChecked = false + XCTAssertFalse(sut.shouldShowEmailField) + } + + func test_shouldShowRegistrationFieldsWhenEmailIsProvided() { + let sut = makeSUT(country: "US") + + sut.saveCheckboxChecked = true + sut.emailAddress = "user@example.com" + + // Wait for async change on `shouldShowPhoneField`. + let showPhoneFieldExpectation = expectation( + for: sut, + keyPath: \.shouldShowPhoneField, + equalsToValue: true + ) + wait(for: [showPhoneFieldExpectation], timeout: accountLookupTimeout) + + XCTAssertFalse(sut.shouldShowNameField, "Should not show name field for US customers") + XCTAssertTrue( + sut.shouldShowLegalTerms, + "Should show legal terms when creating a new account" + ) + + sut.emailAddress = nil + + // Wait for async change on `shouldShowPhoneField`. + let hidePhoneFieldExpectation = expectation( + for: sut, + keyPath: \.shouldShowPhoneField, + equalsToValue: false + ) + wait(for: [hidePhoneFieldExpectation], timeout: accountLookupTimeout) + XCTAssertFalse(sut.shouldShowNameField) + XCTAssertFalse(sut.shouldShowLegalTerms) + } + + func test_shouldShowNameField_nonUSCustomers() { + let sut = makeSUT(country: "CA", hasAccount: true) + sut.saveCheckboxChecked = true + XCTAssertTrue(sut.shouldShowNameField, "Should show name field for non-US customers") + } + + func test_action_returnsNilUnlessPhoneRequirementIsFulfilled() { + let sut = makeSUT(country: "US", hasAccount: true) + + sut.saveCheckboxChecked = true + XCTAssertNil(sut.action) + + sut.phoneNumber = PhoneNumber(number: "5555555555", countryCode: "US") + XCTAssertNotNil(sut.action) + } + + func test_action_returnsNilUnlessNameRequirementIsFulfilled() { + // Non-US customers require providing a name + let sut = makeSUT(country: "CA", hasAccount: true) + + sut.saveCheckboxChecked = true + sut.phoneNumber = PhoneNumber(number: "5555555555", countryCode: "CA") + XCTAssertNil(sut.action, "`action` must be nil unless a name is provided") + + sut.legalName = "Jane Doe" + XCTAssertNotNil(sut.action) + } + + func test_action_returnsContinueWithoutLinkIfCheckboxIsNotChecked() { + let sut = makeSUT(country: "US") + + sut.saveCheckboxChecked = false + XCTAssertEqual(sut.action, .continueWithoutLink) + } + + func test_action_returnsContinueWithoutLinkIfLookupFails() { + let sut = makeSUT(country: "US", shouldFailLookup: true) + + sut.saveCheckboxChecked = true + sut.emailAddress = "user@example.com" + + // Wait for lookup to fail + let lookupFailedExpectation = expectation( + for: sut, + keyPath: \.lookupFailed, + equalsToValue: true + ) + wait(for: [lookupFailedExpectation], timeout: accountLookupTimeout) + + XCTAssertEqual(sut.action, .continueWithoutLink) + } + +} + +extension LinkInlineSignupViewModelTests { + + struct MockAccountService: LinkAccountServiceProtocol { + let shouldFailLookup: Bool + + func lookupAccount( + withEmail email: String?, + completion: @escaping (Result) -> Void + ) { + if shouldFailLookup { + completion(.failure(NSError.stp_genericConnectionError())) + } else { + completion( + .success( + PaymentSheetLinkAccount( + email: "user@example.com", + session: nil, + publishableKey: nil + ) + ) + ) + } + } + + func hasEmailLoggedOut(email: String) -> Bool { + // TODO(porter): Determine if we want to implement this in tests + return false + } + } + + func makeSUT( + country: String, + hasAccount: Bool = false, + shouldFailLookup: Bool = false + ) -> LinkInlineSignupViewModel { + let linkAccount: PaymentSheetLinkAccount? = + hasAccount + ? PaymentSheetLinkAccount(email: "user@example.com", session: nil, publishableKey: nil) + : nil + + return LinkInlineSignupViewModel( + configuration: .init(), + accountService: MockAccountService(shouldFailLookup: shouldFailLookup), + linkAccount: linkAccount, + country: country + ) + } + +} diff --git a/Stripe/StripeiOSTests/MKPlacemark+PaymentSheetTests.swift b/Stripe/StripeiOSTests/MKPlacemark+PaymentSheetTests.swift new file mode 100644 index 00000000..711e3863 --- /dev/null +++ b/Stripe/StripeiOSTests/MKPlacemark+PaymentSheetTests.swift @@ -0,0 +1,209 @@ +// +// MKPlacemark+PaymentSheetTests.swift +// StripeiOS Tests +// +// Created by Nick Porter on 6/13/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Contacts +import MapKit +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class MKPlacemark_PaymentSheetTests: XCTestCase { + + // All address dictionaries are based on an actual placemark of an `MKLocalSearchCompletion` + + func testAsAddress_UnitedStates() { + // Search string used to generate address dictionary: "4 Pennsylvania Pl" + let addressDictionary = + [ + CNPostalAddressStreetKey: "4 Pennsylvania Plaza", + CNPostalAddressStateKey: "NY", + CNPostalAddressCountryKey: "United States", + CNPostalAddressISOCountryCodeKey: "US", + CNPostalAddressCityKey: "New York", + "SubThoroughfare": "4", + CNPostalAddressPostalCodeKey: "10001", + "Thoroughfare": "Pennsylvania Plaza", + CNPostalAddressSubLocalityKey: "Manhattan", + CNPostalAddressSubAdministrativeAreaKey: "New York County", + "Name": "4 Pennsylvania Plaza", + ] as [String: Any] + let placemark = MKPlacemark( + coordinate: CLLocationCoordinate2D(), + addressDictionary: addressDictionary + ) + let expectedAddress = PaymentSheet.Address( + city: "New York", + country: "US", + line1: "4 Pennsylvania Plaza", + line2: nil, + postalCode: "10001", + state: "NY" + ) + + XCTAssertEqual(placemark.asAddress, expectedAddress) + } + + func testAsAddress_Canada() { + // Search string used to generate address dictionary: "40 Bay St To" + let addressDictionary = + [ + CNPostalAddressStreetKey: "40 Bay St", + CNPostalAddressStateKey: "ON", + CNPostalAddressISOCountryCodeKey: "CA", + CNPostalAddressCountryKey: "Canada", + CNPostalAddressCityKey: "Toronto", + "SubThoroughfare": "40", + CNPostalAddressPostalCodeKey: "M5J 2X2", + "Thoroughfare": "Bay St", + CNPostalAddressSubLocalityKey: "Downtown Toronto", + CNPostalAddressSubAdministrativeAreaKey: "SubAdministrativeArea", + "Name": "40 Bay St", + ] as [String: Any] + let placemark = MKPlacemark( + coordinate: CLLocationCoordinate2D(), + addressDictionary: addressDictionary + ) + let expectedAddress = PaymentSheet.Address( + city: "Toronto", + country: "CA", + line1: "40 Bay St", + line2: nil, + postalCode: "M5J 2X2", + state: "ON" + ) + + XCTAssertEqual(placemark.asAddress, expectedAddress) + } + + func testAsAddress_Germany() { + // Search string used to generate address dictionary: "Rüsternallee 14" + let addressDictionary = + [ + CNPostalAddressStreetKey: "Rüsternallee 14", + CNPostalAddressISOCountryCodeKey: "DE", + CNPostalAddressCountryKey: "Germany", + CNPostalAddressCityKey: "Berlin", + CNPostalAddressPostalCodeKey: "14050", + "SubThoroughfare": "14", + "Thoroughfare": "Rüsternallee", + CNPostalAddressSubLocalityKey: "Charlottenburg-Wilmersdorf", + CNPostalAddressSubAdministrativeAreaKey: "Berlin", + "Name": "Rüsternallee 14", + ] as [String: Any] + let placemark = MKPlacemark( + coordinate: CLLocationCoordinate2D(), + addressDictionary: addressDictionary + ) + let expectedAddress = PaymentSheet.Address( + city: "Berlin", + country: "DE", + line1: "Rüsternallee 14", + line2: nil, + postalCode: "14050", + state: nil + ) + + XCTAssertEqual(placemark.asAddress, expectedAddress) + } + + func testAsAddress_Brazil() { + // Search string used to generate address dictionary: "Avenida Paulista 500" + let addressDictionary = + [ + CNPostalAddressStreetKey: "Avenida Paulista, 500", + CNPostalAddressStateKey: "SP", + CNPostalAddressISOCountryCodeKey: "BR", + CNPostalAddressCountryKey: "Brazil", + CNPostalAddressCityKey: "Paulínia", + "SubThoroughfare": "500", + CNPostalAddressPostalCodeKey: "13145-089", + "Thoroughfare": "Avenida Paulista", + CNPostalAddressSubLocalityKey: "Jardim Planalto", + "Name": "Avenida Paulista, 500", + ] as [String: Any] + let placemark = MKPlacemark( + coordinate: CLLocationCoordinate2D(), + addressDictionary: addressDictionary + ) + let expectedAddress = PaymentSheet.Address( + city: "Paulínia", + country: "BR", + line1: "Avenida Paulista, 500", + line2: nil, + postalCode: "13145-089", + state: "SP" + ) + + XCTAssertEqual(placemark.asAddress, expectedAddress) + } + + func testAsAddress_Japan() { + // Search string used to generate address dictionary: "Nagatacho 2" + let addressDictionary = + [ + CNPostalAddressStreetKey: "Nagatacho 2-Chōme", + CNPostalAddressStateKey: "Tokyo", + CNPostalAddressISOCountryCodeKey: "JP", + CNPostalAddressCountryKey: "Japan", + CNPostalAddressCityKey: "Chiyoda", + "Thoroughfare": "Nagatacho 2-Chōme", + CNPostalAddressSubLocalityKey: "Nagatacho", + "Name": "Nagatacho 2-Chōme", + ] as [String: Any] + let placemark = MKPlacemark( + coordinate: CLLocationCoordinate2D(), + addressDictionary: addressDictionary + ) + let expectedAddress = PaymentSheet.Address( + city: "Chiyoda", + country: "JP", + line1: "Nagatacho 2-Chōme", + line2: nil, + postalCode: nil, + state: "Tokyo" + ) + + XCTAssertEqual(placemark.asAddress, expectedAddress) + } + + func testAsAddress_Australia() { + // Search string used to generate address dictionary: "488 George St Syd" + let addressDictionary = + [ + CNPostalAddressStreetKey: "488 George St", + CNPostalAddressStateKey: "NSW", + CNPostalAddressISOCountryCodeKey: "AU", + CNPostalAddressCountryKey: "Australia", + CNPostalAddressCityKey: "Sydney", + CNPostalAddressPostalCodeKey: "2000", + "SubThoroughfare": "488", + "Thoroughfare": "George St", + CNPostalAddressSubAdministrativeAreaKey: "Sydney", + "Name": "488 George St", + ] as [String: Any] + let placemark = MKPlacemark( + coordinate: CLLocationCoordinate2D(), + addressDictionary: addressDictionary + ) + let expectedAddress = PaymentSheet.Address( + city: "Sydney", + country: "AU", + line1: "488 George St", + line2: nil, + postalCode: "2000", + state: "NSW" + ) + + XCTAssertEqual(placemark.asAddress, expectedAddress) + } + +} diff --git a/Stripe/StripeiOSTests/NSArray+StripeTest.swift b/Stripe/StripeiOSTests/NSArray+StripeTest.swift new file mode 100644 index 00000000..8e12190e --- /dev/null +++ b/Stripe/StripeiOSTests/NSArray+StripeTest.swift @@ -0,0 +1,67 @@ +// +// NSArray+StripeTest.swift +// StripeiOS Tests +// +// Created by Jack Flintermann on 1/19/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI +import XCTest + +class Array_StripeTest: XCTestCase { + func test_arrayByRemovingNulls_removesNullsDeeply() { + let array: [Any] = [ + "id", + NSNull(), // null in root + [ + "user": "user_123", + "country": NSNull(), // null in dictionary + "nicknames": ["john", "johnny", NSNull()], + "profiles": [ + "facebook": "fb_123", + "twitter": NSNull(), + ], + ], + [ + NSNull(), // null in array + [ + "id": "fee_123", + "frequency": NSNull(), + ], + ["payment", NSNull()], + ], + ] + + let expected: [Any] = [ + "id", + [ + "user": "user_123", + "nicknames": ["john", "johnny"], + "profiles": [ + "facebook": "fb_123" + ], + ], + [ + [ + "id": "fee_123" + ], ["payment"], + ], + ] + + let result = array.stp_arrayByRemovingNulls() + + XCTAssertEqual(result as NSArray, expected as NSArray) + } + + func test_arrayByRemovingNulls_keepsEmptyLeaves() { + let array = [NSNull()] + let result = array.stp_arrayByRemovingNulls() + + XCTAssertEqual(result as NSArray, [] as NSArray) + } +} diff --git a/Stripe/StripeiOSTests/NSDecimalNumber+StripeTest.swift b/Stripe/StripeiOSTests/NSDecimalNumber+StripeTest.swift new file mode 100644 index 00000000..33ae8d48 --- /dev/null +++ b/Stripe/StripeiOSTests/NSDecimalNumber+StripeTest.swift @@ -0,0 +1,103 @@ +// +// NSDecimalNumber+StripeTest.swift +// StripeiOS Tests +// +// Created by Ben Guo on 4/19/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI +import XCTest + +class NSDecimalNumberStripeTest: XCTestCase { + // an incomplete list of 2 decimal point currencies + private let twoDecimalPointCurrencies = [ + "usd", + "dkk", + "eur", + "aud", + "sek", + "sgd", + + // Special cases: + "cop", + "pkr", + "lak", + "rsd", + ] + + private let noDecimalPointCurrencies = [ + "bif", + "clp", + "djf", + "gnf", + "jpy", + "kmf", + "krw", + "mga", + "pyg", + "rwf", + "vnd", + "vuv", + "xaf", + "xof", + "xpf", + ] + + private let threeDecimalCurrencies = [ + "bhd", + "jod", + "kwd", + "omr", + "tnd", + ] + + func testDecimalAmount_twoDecimal() { + for twoDecimalPointCurrency in twoDecimalPointCurrencies { + let decimalNumber = NSDecimalNumber.stp_decimalNumber(withAmount: 92123, currency: twoDecimalPointCurrency) + XCTAssertEqual(decimalNumber, NSDecimalNumber(string: "921.23")) + } + } + + func testDecimalAmount_noDecimal() { + for currency in noDecimalPointCurrencies { + let decimalNumber = NSDecimalNumber.stp_decimalNumber(withAmount: 92123, currency: currency) + XCTAssertEqual(decimalNumber, NSDecimalNumber(string: "92123")) + } + } + + func testDecimalAmount_threeDecimal() { + for currency in threeDecimalCurrencies { + let decimalNumber = NSDecimalNumber.stp_decimalNumber(withAmount: 92123, currency: currency) + XCTAssertEqual(decimalNumber, NSDecimalNumber(string: "92.123")) + } + } + + func testAmount_twoDecimal() { + for twoDecimalPointCurrency in twoDecimalPointCurrencies { + let amount = NSDecimalNumber(value: 1000.12) + let decimalNumber = amount.stp_amount(withCurrency: twoDecimalPointCurrency) + XCTAssertEqual(decimalNumber, 100012) + } + } + + func testAmount_noDecimal() { + for noDecimalPointCurrency in noDecimalPointCurrencies { + let amount = NSDecimalNumber(value: 1000.12) + let decimalNumber = amount.stp_amount(withCurrency: noDecimalPointCurrency) + XCTAssertEqual(decimalNumber, 1000) + } + } + + func testAmount_threeDecimal() { + for threeDecimalPointCurrency in threeDecimalCurrencies { + let amount = NSDecimalNumber(value: 1000.12) + let decimalNumber = amount.stp_amount(withCurrency: threeDecimalPointCurrency) + XCTAssertEqual(decimalNumber, 1000120) + } + } +} diff --git a/Stripe/StripeiOSTests/NSDictionary+StripeTest.swift b/Stripe/StripeiOSTests/NSDictionary+StripeTest.swift new file mode 100644 index 00000000..84287033 --- /dev/null +++ b/Stripe/StripeiOSTests/NSDictionary+StripeTest.swift @@ -0,0 +1,254 @@ +// +// Dictionary+StripeTest.swift +// StripeiOS Tests +// +// Created by Joey Dong on 7/24/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI +import XCTest + +class Dictionary_StripeTest: XCTestCase { + // MARK: - dictionaryByRemovingNullsValidatingRequiredFields + func test_dictionaryByRemovingNulls_removesNullsDeeply() { + let dictionary = + [ + "id": "card_123", + "tokenization_method": NSNull(), // null in root + "metadata": [ + "user": "user_123", + "country": NSNull(), // null in dictionary + "nicknames": ["john", "johnny", NSNull()], + "profiles": [ + "facebook": "fb_123", + "twitter": NSNull(), + ], + ], + "fees": [ + NSNull(), // null in array + [ + "id": "fee_123", + "frequency": NSNull(), + ], + ["payment", NSNull()], + ], + ] as [AnyHashable: Any] + + let expected = + [ + "id": "card_123", + "metadata": [ + "user": "user_123", + "nicknames": ["john", "johnny"], + "profiles": [ + "facebook": "fb_123" + ], + ], + "fees": [ + [ + "id": "fee_123" + ], ["payment"], + ], + ] as [AnyHashable: Any] + + let result = dictionary.stp_dictionaryByRemovingNulls() + XCTAssertEqual(result as NSDictionary, expected as NSDictionary) + } + + func test_dictionaryByRemovingNullsValidatingRequiredFields_keepsEmptyLeaves() { + let dictionary = + [ + "id": NSNull() + ] as [AnyHashable: Any] + let result = dictionary.stp_dictionaryByRemovingNulls() + + XCTAssertEqual(result as NSDictionary, [:] as NSDictionary) + } + + // MARK: - dictionaryByRemovingNonStrings + func test_dictionaryByRemovingNonStrings_basicCases() { + // Empty dictionary + var dictionary = [:] as [AnyHashable: Any] + var expected = [:] as [AnyHashable: Any] + var result = dictionary.stp_dictionaryByRemovingNonStrings() + XCTAssertEqual(result as NSDictionary, expected as NSDictionary) + + // Regular case + dictionary = + [ + "user": "user_123", + "nicknames": "John, Johnny", + ] + expected = + [ + "user": "user_123", + "nicknames": "John, Johnny", + ] + result = dictionary.stp_dictionaryByRemovingNonStrings() + XCTAssertEqual(result as NSDictionary, expected as NSDictionary) + + // Strips non-NSString keys and values + dictionary = + [ + "user": "user_123", + "nicknames": "John, Johnny", + "profiles": NSNull(), + NSNull(): "San Francisco, CA", + "age": NSNumber(value: 21), + NSNumber(value: 21): "age", + "fees": [ + "plan": "monthly" + ], + "visits": ["january", "february"], + ] + expected = + [ + "user": "user_123", + "nicknames": "John, Johnny", + ] + result = dictionary.stp_dictionaryByRemovingNonStrings() + XCTAssertEqual(result as NSDictionary, expected as NSDictionary) + + // Strips non-NSString keys and values + dictionary = + [ + "user": "user_123", + "nicknames": "John, Johnny", + "profiles": NSNull(), + NSNull(): NSNull(), + "age": NSNumber(value: 21), + NSNumber(value: 21): NSNumber(value: 21), + "fees": [ + "plan": "monthly" + ], + "visits": ["january", "february"], + ] + expected = + [ + "user": "user_123", + "nicknames": "John, Johnny", + ] + result = dictionary.stp_dictionaryByRemovingNonStrings() + XCTAssertEqual(result as NSDictionary, expected as NSDictionary) + } + + // MARK: - Getters + func testArrayForKey() { + let dict = + [ + "a": ["foo"] + ] as [AnyHashable: Any] + + XCTAssertEqual(dict.stp_array(forKey: "a") as! [String], ["foo"]) + XCTAssertNil(dict.stp_array(forKey: "b")) + } + + func testBoolForKey() { + let dict = + [ + "a": NSNumber(value: 1), + "b": NSNumber(value: 0), + "c": "true", + "d": "false", + "e": "1", + "f": "foo", + ] as [AnyHashable: Any] + + XCTAssertTrue(dict.stp_bool(forKey: "a", or: false)) + XCTAssertFalse(dict.stp_bool(forKey: "b", or: true)) + XCTAssertTrue(dict.stp_bool(forKey: "c", or: false)) + XCTAssertFalse(dict.stp_bool(forKey: "d", or: true)) + XCTAssertTrue(dict.stp_bool(forKey: "e", or: false)) + XCTAssertFalse(dict.stp_bool(forKey: "f", or: false)) + } + + func testIntForKey() { + let dict = + [ + "a": NSNumber(value: 1), + "b": NSNumber(value: -1), + "c": "1", + "d": "-1", + "e": "10.0", + "f": "10.5", + "g": NSNumber(value: 10.0), + "h": NSNumber(value: 10.5), + "i": "foo", + ] as [AnyHashable: Any] + + XCTAssertEqual(dict.stp_int(forKey: "a", or: 0), 1) + XCTAssertEqual(dict.stp_int(forKey: "b", or: 0), -1) + XCTAssertEqual(dict.stp_int(forKey: "c", or: 0), 1) + XCTAssertEqual(dict.stp_int(forKey: "d", or: 0), -1) + XCTAssertEqual(dict.stp_int(forKey: "e", or: 0), 10) + XCTAssertEqual(dict.stp_int(forKey: "f", or: 0), 10) + XCTAssertEqual(dict.stp_int(forKey: "g", or: 0), 10) + XCTAssertEqual(dict.stp_int(forKey: "h", or: 0), 10) + XCTAssertEqual(dict.stp_int(forKey: "i", or: 0), 0) + } + + func testDateForKey() { + let dict = + [ + "a": NSNumber(value: 0), + "b": "0", + ] as [AnyHashable: Any] + let expectedDate = Date(timeIntervalSince1970: 0) + + XCTAssertEqual(dict.stp_date(forKey: "a"), expectedDate) + XCTAssertEqual(dict.stp_date(forKey: "b"), expectedDate) + XCTAssertNil(dict.stp_date(forKey: "c")) + } + + func testDictionaryForKey() { + let dict = + [ + "a": [ + "foo": "bar" + ], + ] as [AnyHashable: Any] + + XCTAssertEqual( + dict.stp_dictionary(forKey: "a")! as NSDictionary, + [ + "foo": "bar" + ] as NSDictionary + ) + XCTAssertNil(dict.stp_dictionary(forKey: "b")) + } + + func testNumberForKey() { + let dict = + [ + "a": NSNumber(value: 1) + ] as [AnyHashable: Any] + + XCTAssertEqual(dict.stp_number(forKey: "a"), NSNumber(value: 1)) + XCTAssertNil(dict.stp_number(forKey: "b")) + } + + func testStringForKey() { + let dict = + [ + "a": "foo" + ] as [AnyHashable: Any] + XCTAssertEqual(dict.stp_string(forKey: "a"), "foo") + XCTAssertNil(dict.stp_string(forKey: "b")) + } + + func testURLForKey() { + let dict = + [ + "a": "https://example.com", + "b": "not a url", + ] as [AnyHashable: Any] + XCTAssertEqual(dict.stp_url(forKey: "a"), URL(string: "https://example.com")) + XCTAssertNil(dict.stp_url(forKey: "b")) + XCTAssertNil(dict.stp_url(forKey: "c")) + } +} diff --git a/Stripe/StripeiOSTests/NSLocale+STPSwizzling.swift b/Stripe/StripeiOSTests/NSLocale+STPSwizzling.swift new file mode 100644 index 00000000..bff5b323 --- /dev/null +++ b/Stripe/StripeiOSTests/NSLocale+STPSwizzling.swift @@ -0,0 +1,96 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +import Foundation +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI +// +// NSLocale+STPSwizzling.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 7/17/18. +// Copyright © 2018 Stripe, Inc. All rights reserved. +// + +import ObjectiveC + +class STPLocaleSwizzling { + static var stpLocaleOverride: NSLocale? + static var hasSwizzled: Bool = false + +} + +extension NSLocale { + class func swizzleIfNeeded() { + if !STPLocaleSwizzling.hasSwizzled { + self.stp_swizzleClassMethod(#selector(getter: current), withReplacement: #selector(stp_current)) + self.stp_swizzleClassMethod(#selector(getter: autoupdatingCurrent), withReplacement: #selector(stp_autoUpdatingCurrent)) + self.stp_swizzleClassMethod(#selector(getter: system), withReplacement: #selector(stp_system)) + STPLocaleSwizzling.hasSwizzled = true + } + } + + class func stp_withLocale(as locale: NSLocale?, perform block: @escaping () -> Void) { + swizzleIfNeeded() + let currentLocale = NSLocale.current as NSLocale + self.stp_setCurrentLocale(locale) + block() + self.stp_resetCurrentLocale() + assert((currentLocale as Locale == NSLocale.current), "Failed to reset locale.") + } + + class func stp_setCurrentLocale(_ locale: NSLocale?) { + swizzleIfNeeded() + STPLocaleSwizzling.stpLocaleOverride = locale + } + + class func stp_resetCurrentLocale() { + swizzleIfNeeded() + self.stp_setCurrentLocale(nil) + } + + @objc class func stp_current() -> NSLocale { + return STPLocaleSwizzling.stpLocaleOverride ?? self.stp_current() + } + + @objc class func stp_autoUpdatingCurrent() -> NSLocale { + return STPLocaleSwizzling.stpLocaleOverride ?? self.stp_autoUpdatingCurrent() + } + + @objc class func stp_system() -> NSLocale { + return STPLocaleSwizzling.stpLocaleOverride ?? self.stp_system() + } +} + +extension NSObject { + class func stp_swizzleClassMethod(_ original: Selector, withReplacement replacement: Selector) { + let `class`: AnyClass? = object_getClass(self) + let originalMethod = class_getClassMethod(self, original) + let replacementMethod = class_getClassMethod(self, replacement) + + var addedMethod = false + if let replacementMethod { + addedMethod = class_addMethod( + `class`, + original, + method_getImplementation(replacementMethod), + method_getTypeEncoding(replacementMethod)) + } + if addedMethod { + if let originalMethod { + class_replaceMethod( + `class`, + replacement, + method_getImplementation(originalMethod), + method_getTypeEncoding(originalMethod)) + } + } else { + if let originalMethod, let replacementMethod { + method_exchangeImplementations(originalMethod, replacementMethod) + } + } + } +} diff --git a/Stripe/StripeiOSTests/NSString+StripeTest.swift b/Stripe/StripeiOSTests/NSString+StripeTest.swift new file mode 100644 index 00000000..f0d1421d --- /dev/null +++ b/Stripe/StripeiOSTests/NSString+StripeTest.swift @@ -0,0 +1,95 @@ +// +// NSString+StripeTest.swift +// StripeiOS Tests +// +// Created by Ben Guo on 3/22/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI +import XCTest + +class NSString_StripeTest: XCTestCase { + + func testIsBlank() { + XCTAssertTrue("".isBlank) + XCTAssertTrue(" ".isBlank) + XCTAssertTrue("\t\t\t".isBlank) + XCTAssertFalse("a".isBlank) + XCTAssertFalse(" a ".isBlank) + } + + func testSafeSubstringToIndex() { + XCTAssertEqual("foo".stp_safeSubstring(to: 0), "") + XCTAssertEqual("foo".stp_safeSubstring(to: 500), "foo") + XCTAssertEqual("foo".stp_safeSubstring(to: 1), "f") + XCTAssertEqual("foo".stp_safeSubstring(to: -1), "") + XCTAssertEqual("foo".stp_safeSubstring(to: -100), "") + XCTAssertEqual("".stp_safeSubstring(to: 0), "") + XCTAssertEqual("".stp_safeSubstring(to: 1), "") + } + + func testSafeSubstringFromIndex() { + XCTAssertEqual("foo".stp_safeSubstring(from: 0), "foo") + XCTAssertEqual("foo".stp_safeSubstring(from: 1), "oo") + XCTAssertEqual("foo".stp_safeSubstring(from: 3), "") + XCTAssertEqual("foo".stp_safeSubstring(from: -1), "foo") + XCTAssertEqual("foo".stp_safeSubstring(from: -100), "foo") + XCTAssertEqual("".stp_safeSubstring(from: 0), "") + XCTAssertEqual("".stp_safeSubstring(from: 1), "") + } + + func testStringByRemovingSuffix() { + XCTAssertEqual("foobar".stp_string(byRemovingSuffix: "bar"), "foo") + XCTAssertEqual("foobar".stp_string(byRemovingSuffix: "baz"), "foobar") + XCTAssertEqual("foobar".stp_string(byRemovingSuffix: nil), "foobar") + XCTAssertEqual("foobar".stp_string(byRemovingSuffix: "foobar"), "") + XCTAssertEqual("foobar".stp_string(byRemovingSuffix: ""), "foobar") + XCTAssertEqual("foobar".stp_string(byRemovingSuffix: "oba"), "foobar") + + XCTAssertEqual("foobar☺¿".stp_string(byRemovingSuffix: "bar☺¿"), "foo") + XCTAssertEqual("foobar☺¿".stp_string(byRemovingSuffix: "bar¿"), "foobar☺¿") + + XCTAssertEqual("foobar\u{202C}".stp_string(byRemovingSuffix: "bar"), "foobar\u{202C}") + XCTAssertEqual("foobar\u{202C}".stp_string(byRemovingSuffix: "bar\u{202C}"), "foo") + + // e + \u0041 => é + XCTAssertEqual("foobare\u{0301}".stp_string(byRemovingSuffix: "bare"), "foobare\u{0301}") + XCTAssertEqual("foobare\u{0301}".stp_string(byRemovingSuffix: "bare\u{0301}"), "foo") + XCTAssertEqual("foobare".stp_string(byRemovingSuffix: "bare\u{0301}"), "foobare") + + } + + func testLocalizedAmountDisplayString() { + XCTAssertEqual(String.localizedAmountDisplayString(for: 1099, currency: "USD"), "$10.99") + XCTAssertEqual( + String.localizedAmountDisplayString( + for: 1099, + currency: "USD", + locale: Locale(identifier: "fr_FR") + ), + "10,99 $US" + ) + XCTAssertEqual( + String.localizedAmountDisplayString( + for: 1099, + currency: "USD", + locale: Locale(identifier: "zh_HANT") + ), + "US$10.99" + ) + + XCTAssertEqual( + String.localizedAmountDisplayString( + for: 1099, + currency: "ZZZ", + locale: Locale(identifier: "z") + ), + "ZZZ 10.99" + ) + } +} diff --git a/Stripe/StripeiOSTests/NSURLComponents_StripeTest.swift b/Stripe/StripeiOSTests/NSURLComponents_StripeTest.swift new file mode 100644 index 00000000..86da11a4 --- /dev/null +++ b/Stripe/StripeiOSTests/NSURLComponents_StripeTest.swift @@ -0,0 +1,40 @@ +// +// NSURLComponents_StripeTest.swift +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 5/24/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI +import XCTest + +class NSURLComponents_StripeTest: XCTestCase { + func testCaseInsensitiveSchemeComparison() { + let lhs = NSURLComponents(string: "com.bar.foo://host")! + let rhs = NSURLComponents(string: "COM.BAR.FOO://HOST")! + XCTAssert(lhs.stp_matchesURLComponents(lhs)) // sanity + XCTAssert(lhs.stp_matchesURLComponents(rhs)) + XCTAssert(rhs.stp_matchesURLComponents(lhs)) + } + + func testMatchesURLsWithQueryString() { + // e.g. STPSourceFunctionalTest passes "https://shop.example.com/crtABC" for the return_url, + // but the Source object returned by the API comes has "https://shop.example.com/crtABC?redirect_merchant_name=xctest" + let expectedComponents = NSURLComponents( + string: "https://shop.example.com/crtABC?redirect_merchant_name=xctest" + )! + let components = NSURLComponents(string: "https://shop.example.com/crtABC")! + XCTAssertTrue(components.stp_matchesURLComponents(expectedComponents)) + } + + func testMatchesURLWithNilParameters() { + let nil1 = NSURLComponents(string: "")! + let nil2 = NSURLComponents(string: "")! + XCTAssert(nil1.stp_matchesURLComponents(nil2)) + } +} diff --git a/Stripe/StripeiOSTests/OneTimeCodeTextFieldSnapshotTests.swift b/Stripe/StripeiOSTests/OneTimeCodeTextFieldSnapshotTests.swift new file mode 100644 index 00000000..b6c903d5 --- /dev/null +++ b/Stripe/StripeiOSTests/OneTimeCodeTextFieldSnapshotTests.swift @@ -0,0 +1,53 @@ +// +// OneTimeCodeTextFieldSnapshotTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 11/10/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase +import StripeCoreTestUtils +import UIKit + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI +@testable@_spi(STP) import StripeUICore + +class OneTimeCodeTextFieldSnapshotTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() + // self.recordMode = true + } + + func testEmpty() { + let field = OneTimeCodeTextField(numberOfDigits: 6, theme: LinkUI.appearance.asElementsTheme) + verify(field) + } + + func testFilled() { + let field = OneTimeCodeTextField(numberOfDigits: 6, theme: LinkUI.appearance.asElementsTheme) + field.value = "123456" + verify(field) + } + + func testDisabled() { + let field = OneTimeCodeTextField(numberOfDigits: 6, theme: LinkUI.appearance.asElementsTheme) + field.value = "123456" + field.isEnabled = false + verify(field) + } + + func verify( + _ view: UIView, + file: StaticString = #filePath, + line: UInt = #line + ) { + view.autosizeHeight(width: 300) + STPSnapshotVerifyView(view, file: file, line: line) + } +} diff --git a/Stripe/StripeiOSTests/OneTimeCodeTextFieldTests.swift b/Stripe/StripeiOSTests/OneTimeCodeTextFieldTests.swift new file mode 100644 index 00000000..17dd57dc --- /dev/null +++ b/Stripe/StripeiOSTests/OneTimeCodeTextFieldTests.swift @@ -0,0 +1,322 @@ +// +// OneTimeCodeTextFieldTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 11/5/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI +@testable@_spi(STP) import StripeUICore + +class OneTimeCodeTextFieldTests: XCTestCase { + + func test_isComplete() { + let field = makeSUT() + + field.value = "12345" + XCTAssertFalse(field.isComplete) + + field.value = "123456" + XCTAssertTrue(field.isComplete) + } + + func test_insertText() { + let field = makeSUT() + + field.insertText("1") + XCTAssertEqual(field.value, "1") + + field.insertText("2") + XCTAssertEqual(field.value, "12") + XCTAssertEqual(field.selectedTextRange?.start, field.endOfDocument) + XCTAssertEqual(field.selectedTextRange?.end, field.endOfDocument) + } + + func test_insertText_shouldNotInsertBeyondNumberOfDigits() { + let field = makeSUT(numberOfDigits: 4) + field.value = "123" + field.selectedTextRange = field.textRange( + from: field.endOfDocument, + to: field.endOfDocument + ) + field.insertText("45") + XCTAssertEqual(field.value, "1234") + } + + func test_insertText_shouldIgnoreInvalidCharacters() { + let field = makeSUT() + field.insertText("123-456") + XCTAssertEqual(field.value, "123456") + } + + func test_deleteBackward() throws { + let field = makeSUT(value: "12") + + field.selectedTextRange = field.textRange( + from: try XCTUnwrap(field.position(from: field.endOfDocument, in: .left, offset: 1)), + to: field.endOfDocument + ) + field.deleteBackward() + XCTAssertEqual(field.value, "1") + + field.selectedTextRange = field.textRange( + from: try XCTUnwrap(field.position(from: field.endOfDocument, in: .left, offset: 1)), + to: field.endOfDocument + ) + field.deleteBackward() + XCTAssertEqual(field.value, "") + + // Delete while empty + field.selectedTextRange = field.textRange( + from: field.beginningOfDocument, + to: field.endOfDocument + ) + field.deleteBackward() + XCTAssertEqual(field.value, "") + } + +// TODO(RUN_MOBILESDK-1848): This test is broken on iOS 16, as it invokes the pasteboard permission dialog +// func test_paste() { +// UIPasteboard.general.string = "123-456" +// +// let field = makeSUT() +// field.paste(nil) +// XCTAssertEqual(field.value, "123456") +// } + + // MARK: - UITextInput conformance + + func test_beginningOfDocument() throws { + let field = makeSUT(value: "123456") + + let position = try XCTUnwrap( + field.beginningOfDocument as? OneTimeCodeTextField.TextPosition + ) + XCTAssertEqual(position.index, 0) + } + + func test_endOfDocument() throws { + let field = makeSUT(value: "123456") + + let position = try XCTUnwrap(field.endOfDocument as? OneTimeCodeTextField.TextPosition) + XCTAssertEqual(position.index, 6) + } + + func test_textInRange() { + let field = makeSUT(value: "123456") + + let result = field.text( + in: OneTimeCodeTextField.TextRange( + start: OneTimeCodeTextField.TextPosition(0), + end: OneTimeCodeTextField.TextPosition(3) + ) + ) + + XCTAssertEqual(result, "123") + } + + func test_textInRange_emptyRange() { + let field = makeSUT(value: "123456") + + let result = field.text( + in: OneTimeCodeTextField.TextRange( + start: OneTimeCodeTextField.TextPosition(0), + end: OneTimeCodeTextField.TextPosition(0) + ) + ) + + XCTAssertNil(result) + } + + func test_positionFromOffset() { + let field = makeSUT(value: "123456") + + XCTAssertEqual( + field.position(from: field.beginningOfDocument, offset: 3), + OneTimeCodeTextField.TextPosition(3) + ) + + XCTAssertNil( + field.position(from: field.beginningOfDocument, offset: 10), + "Should return nil when offsetting to an out of bounds position" + ) + + XCTAssertNil( + field.position(from: field.beginningOfDocument, offset: -1), + "Should return nil when offsetting to an out of bounds position" + ) + } + + func test_positionInDirection() { + let field = makeSUT(value: "123456") + + XCTAssertEqual( + field.position(from: field.beginningOfDocument, in: .right, offset: 1), + OneTimeCodeTextField.TextPosition(1) + ) + + XCTAssertEqual( + field.position(from: field.endOfDocument, in: .left, offset: 1), + OneTimeCodeTextField.TextPosition(5) + ) + + // Y axis + XCTAssertEqual( + field.position(from: field.beginningOfDocument, in: .up, offset: 1), + field.beginningOfDocument + ) + + XCTAssertEqual( + field.position(from: field.beginningOfDocument, in: .down, offset: 1), + field.endOfDocument + ) + } + + func test_compare() { + let field = makeSUT(value: "123456") + + XCTAssertEqual( + field.compare(field.beginningOfDocument, to: field.beginningOfDocument), + .orderedSame + ) + + XCTAssertEqual( + field.compare(field.beginningOfDocument, to: field.endOfDocument), + .orderedAscending + ) + + XCTAssertEqual( + field.compare(field.endOfDocument, to: field.beginningOfDocument), + .orderedDescending + ) + } + + func test_offsetToPosition() { + let field = makeSUT(value: "123456") + + XCTAssertEqual(field.offset(from: field.beginningOfDocument, to: field.endOfDocument), 6) + XCTAssertEqual(field.offset(from: field.endOfDocument, to: field.beginningOfDocument), -6) + XCTAssertEqual( + field.offset(from: field.beginningOfDocument, to: OneTimeCodeTextField.TextPosition(3)), + 3 + ) + } + + func test_positionFarthestInDirection() throws { + let field = makeSUT(value: "123456") + + let position = try XCTUnwrap( + OneTimeCodeTextField.TextRange( + start: field.beginningOfDocument, + end: field.endOfDocument + ) + ) + + XCTAssertEqual( + field.position(within: position, farthestIn: .left), + field.beginningOfDocument + ) + + XCTAssertEqual( + field.position(within: position, farthestIn: .right), + field.endOfDocument + ) + + // Y axis + XCTAssertEqual( + field.position(within: position, farthestIn: .up), + field.beginningOfDocument + ) + + XCTAssertEqual( + field.position(within: position, farthestIn: .down), + field.endOfDocument + ) + } + + func test_characterRangeByExtendingInDirection() throws { + let field = makeSUT(value: "123456") + + let position = OneTimeCodeTextField.TextPosition(3) + + XCTAssertEqual( + field.characterRange(byExtending: position, in: .left), + OneTimeCodeTextField.TextRange(start: field.beginningOfDocument, end: position) + ) + + XCTAssertEqual( + field.characterRange(byExtending: position, in: .right), + OneTimeCodeTextField.TextRange(start: position, end: field.endOfDocument) + ) + + // Y axis + XCTAssertNil(field.characterRange(byExtending: position, in: .up)) + XCTAssertNil(field.characterRange(byExtending: position, in: .down)) + } + + func test_firstRectForRange_singleDigit() { + let sut = makeSUT(value: "123456") + + // A [0,1] text range + let range = OneTimeCodeTextField.TextRange( + start: OneTimeCodeTextField.TextPosition(0), + end: OneTimeCodeTextField.TextPosition(1) + ) + let rect = sut.firstRect(for: range) + XCTAssertEqual(rect.minX, 0, accuracy: 0.2) + XCTAssertEqual(rect.minY, 0, accuracy: 0.2) + XCTAssertEqual(rect.width, 46.0, accuracy: 0.2) + XCTAssertEqual(rect.height, 60, accuracy: 0.2) + } + + func test_firstRectForRange_multipleDigits() { + let sut = makeSUT(value: "123456") + + // A [0,3] Text range + let range = OneTimeCodeTextField.TextRange( + start: OneTimeCodeTextField.TextPosition(0), + end: OneTimeCodeTextField.TextPosition(3) + ) + let rect = sut.firstRect(for: range) + XCTAssertEqual(rect.minX, 0, accuracy: 0.2) + XCTAssertEqual(rect.minY, 0, accuracy: 0.2) + XCTAssertEqual(rect.width, 150, accuracy: 0.2) + XCTAssertEqual(rect.height, 60, accuracy: 0.2) + } + + func test_caretRectForPosition() { + let sut = makeSUT() + let frame = sut.caretRect(for: OneTimeCodeTextField.TextPosition(1)) + XCTAssertEqual(frame.minX, 74, accuracy: 0.2) + XCTAssertEqual(frame.minY, 18, accuracy: 0.2) + XCTAssertEqual(frame.width, 2, accuracy: 0.2) + XCTAssertEqual(frame.height, 24, accuracy: 0.2) + } + +} + +// MARK: - Factory methods + +extension OneTimeCodeTextFieldTests { + + fileprivate func makeSUT(numberOfDigits: Int = 6) -> OneTimeCodeTextField { + let sut = OneTimeCodeTextField(numberOfDigits: numberOfDigits, theme: LinkUI.appearance.asElementsTheme) + sut.frame = CGRect(x: 0, y: 0, width: 320, height: 60) + sut.layoutIfNeeded() + return sut + } + + fileprivate func makeSUT(value: String) -> OneTimeCodeTextField { + let sut = makeSUT() + sut.value = value + return sut + } + +} diff --git a/Stripe/StripeiOSTests/OperationDebouncerTests.swift b/Stripe/StripeiOSTests/OperationDebouncerTests.swift new file mode 100644 index 00000000..7a6f3ae0 --- /dev/null +++ b/Stripe/StripeiOSTests/OperationDebouncerTests.swift @@ -0,0 +1,45 @@ +// +// OperationDebouncerTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 1/23/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class OperationDebouncerTests: XCTestCase { + + func testEnqueueShouldDebounce() { + let sut = makeSUT() + + let expectation = self.expectation(description: "Should execute the block just once") + expectation.assertForOverFulfill = true + + // Call `enqueue(block:)` 3 times + for _ in 0..<3 { + sut.enqueue { + expectation.fulfill() + } + } + + Thread.sleep(forTimeInterval: 1) + + wait(for: [expectation], timeout: 1) + } + +} + +extension OperationDebouncerTests { + + func makeSUT() -> OperationDebouncer { + return OperationDebouncer(debounceTime: .milliseconds(500)) + } + +} diff --git a/Stripe/StripeiOSTests/PKPayment+StripeTest.swift b/Stripe/StripeiOSTests/PKPayment+StripeTest.swift new file mode 100644 index 00000000..c71eee52 --- /dev/null +++ b/Stripe/StripeiOSTests/PKPayment+StripeTest.swift @@ -0,0 +1,36 @@ +// +// PKPayment+StripeTest.swift +// StripeiOS Tests +// +// Created by Ben Guo on 7/6/15. +// Copyright © 2015 Stripe, Inc. All rights reserved. +// + +import PassKit +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class PKPayment_StripeTest: XCTestCase { + func testIsSimulated() { + let payment = PKPayment() + let paymentToken = PKPaymentToken() + + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wundeclared-selector" + paymentToken.perform(Selector(("setTransactionIdentifier:")), with: "Simulated Identifier") + payment.perform(#selector(setter: STPPaymentMethodCardParams.token), with: paymentToken) + // #pragma clang diagnostic pop + + XCTAssertTrue(payment.stp_isSimulated()) + } + + func testTransactionIdentifier() { + let identifier = PKPayment.stp_testTransactionIdentifier() + XCTAssertTrue(identifier.contains("ApplePayStubs~4242424242424242~0~USD~")) + } +} diff --git a/Stripe/StripeiOSTests/PayWithLinkButtonSnapshotTests.swift b/Stripe/StripeiOSTests/PayWithLinkButtonSnapshotTests.swift new file mode 100644 index 00000000..d2021487 --- /dev/null +++ b/Stripe/StripeiOSTests/PayWithLinkButtonSnapshotTests.swift @@ -0,0 +1,119 @@ +// +// PayWithLinkButtonSnapshotTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 11/17/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase +import StripeCoreTestUtils +import UIKit + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class PayWithLinkButtonSnapshotTests: FBSnapshotTestCase { + + private let emailAddress = "customer@example.com" + private let longEmailAddress = "long.customer.name@example.com" + + override func setUp() { + super.setUp() + // recordMode = true + } + + func testDefault() { + let sut = makeSUT() + sut.linkAccount = makeAccountStub(email: emailAddress, isRegistered: false) + verify(sut) + + sut.isHighlighted = true + verify(sut, identifier: "Highlighted") + } + + func testDefault_rounded() { + let sut = makeSUT() + sut.cornerRadius = 16 + sut.linkAccount = makeAccountStub(email: emailAddress, isRegistered: false) + verify(sut) + } + + func testDisabled() { + let sut = makeSUT() + sut.isEnabled = false + verify(sut) + } + + func testRegistered() { + let sut = makeSUT() + sut.linkAccount = makeAccountStub(email: emailAddress, isRegistered: true) + verify(sut) + } + + func testRegistered_rounded() { + let sut = makeSUT() + sut.cornerRadius = 16 + sut.linkAccount = makeAccountStub(email: emailAddress, isRegistered: true) + verify(sut) + } + + func testRegistered_square() { + let sut = makeSUT() + sut.cornerRadius = 0 + sut.linkAccount = makeAccountStub(email: emailAddress, isRegistered: true) + verify(sut) + } + + func testRegistered_withLongEmailAddress() { + let sut = PayWithLinkButton() + sut.linkAccount = makeAccountStub(email: longEmailAddress, isRegistered: true) + verify(sut) + } + + func testRegistered_withCardInfo() { + let sut = PayWithLinkButton() + sut.linkAccount = makeAccountStub(email: emailAddress, isRegistered: true, lastPM: .init(last4: "3155", brand: .visa)) + verify(sut) + } + + func verify( + _ sut: UIView, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + sut.autosizeHeight(width: 300) + STPSnapshotVerifyView(sut, identifier: identifier, file: file, line: line) + } + +} + +extension PayWithLinkButtonSnapshotTests { + + fileprivate struct LinkAccountStub: PaymentSheetLinkAccountInfoProtocol { + let email: String + let redactedPhoneNumber: String? + let lastPM: LinkPMDisplayDetails? + let isRegistered: Bool + let isLoggedIn: Bool + } + + fileprivate func makeAccountStub(email: String, isRegistered: Bool, lastPM: LinkPMDisplayDetails? = nil) -> LinkAccountStub { + return LinkAccountStub( + email: email, + redactedPhoneNumber: "+1********55", + lastPM: lastPM, + isRegistered: isRegistered, + isLoggedIn: false + ) + } + + fileprivate func makeSUT() -> PayWithLinkButton { + return PayWithLinkButton() + } + +} diff --git a/Stripe/StripeiOSTests/PaymentAnalyticTest.swift b/Stripe/StripeiOSTests/PaymentAnalyticTest.swift new file mode 100644 index 00000000..3b25a0ce --- /dev/null +++ b/Stripe/StripeiOSTests/PaymentAnalyticTest.swift @@ -0,0 +1,33 @@ +// +// PaymentAnalyticTest.swift +// StripeiOS Tests +// +// Created by Mel Ludowise on 5/26/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +final class PaymentAnalyticTest: XCTestCase { + + func testParams() { + let analytic = GenericPaymentAnalytic( + event: .cardScanCancelled, + paymentConfiguration: STPPaymentConfiguration(), + productUsage: [ + STPPaymentContext.stp_analyticsIdentifier + ], + additionalParams: [:] + ) + + XCTAssertNotNil(analytic.params["apple_pay_enabled"] as? NSNumber) + XCTAssertNotNil(analytic.params["ocr_type"] as? String) + } +} diff --git a/Stripe/StripeiOSTests/PaymentTypeCellSnapshotTests.swift b/Stripe/StripeiOSTests/PaymentTypeCellSnapshotTests.swift new file mode 100644 index 00000000..a83ab881 --- /dev/null +++ b/Stripe/StripeiOSTests/PaymentTypeCellSnapshotTests.swift @@ -0,0 +1,79 @@ +// +// PaymentTypeCellSnapshotTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 12/17/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class PaymentTypeCellSnapshotTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() + // recordMode = true + } + + func testCardUnselected() { + let cell = PaymentMethodTypeCollectionView.PaymentTypeCell() + cell.paymentMethodType = .card + cell.frame = CGRect( + origin: .zero, + size: CGSize( + width: PaymentMethodTypeCollectionView.cellHeight, + height: PaymentMethodTypeCollectionView.cellHeight + ) + ) + STPSnapshotVerifyView(cell) + } + + func testCardSelected() { + let cell = PaymentMethodTypeCollectionView.PaymentTypeCell() + cell.paymentMethodType = .card + cell.frame = CGRect( + origin: .zero, + size: CGSize( + width: PaymentMethodTypeCollectionView.cellHeight, + height: PaymentMethodTypeCollectionView.cellHeight + ) + ) + cell.isSelected = true + STPSnapshotVerifyView(cell) + } + + func testCardUnselected_forceDarkMode() { + let cell = PaymentMethodTypeCollectionView.PaymentTypeCell() + cell.overrideUserInterfaceStyle = .dark + cell.paymentMethodType = .card + cell.frame = CGRect( + origin: .zero, + size: CGSize( + width: PaymentMethodTypeCollectionView.cellHeight, + height: PaymentMethodTypeCollectionView.cellHeight + ) + ) + STPSnapshotVerifyView(cell) + } + + func testCardSelected_forceDarkMode() { + let cell = PaymentMethodTypeCollectionView.PaymentTypeCell() + cell.overrideUserInterfaceStyle = .dark + cell.paymentMethodType = .card + cell.frame = CGRect( + origin: .zero, + size: CGSize( + width: PaymentMethodTypeCollectionView.cellHeight, + height: PaymentMethodTypeCollectionView.cellHeight + ) + ) + cell.isSelected = true + STPSnapshotVerifyView(cell) + } +} diff --git a/Stripe/StripeiOSTests/Resources/Images.xcassets/Contents.json b/Stripe/StripeiOSTests/Resources/Images.xcassets/Contents.json new file mode 100644 index 00000000..da4a164c --- /dev/null +++ b/Stripe/StripeiOSTests/Resources/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Stripe/StripeiOSTests/Resources/MockFiles/paymentIntentResponse.json b/Stripe/StripeiOSTests/Resources/MockFiles/paymentIntentResponse.json new file mode 100644 index 00000000..53200cdf --- /dev/null +++ b/Stripe/StripeiOSTests/Resources/MockFiles/paymentIntentResponse.json @@ -0,0 +1,53 @@ +{ + "id": "pi_3LN3c2L123456789", + "object": "payment_intent", + "amount": 5099, + "amount_details": { + "tip": {} + }, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_123456_secret_654321", + "confirmation_method": "automatic", + "created": 1658187870, + "currency": "usd", + "description": null, + "last_payment_error": null, + "livemode": false, + "next_action": , + "payment_method": , + "payment_method_options": { + "us_bank_account": { + "verification_method": "automatic" + } + }, + "payment_method_types": [ + "card", + "afterpay_clearpay", + "klarna", + "us_bank_account", + "affirm", + "blik" + ], + "processing": null, + "receipt_email": null, + "setup_future_usage": null, + "shipping": { + "address": { + "city": "San Francisco", + "country": "US", + "line1": "510 Townsend St", + "line2": null, + "postal_code": "94102", + "state": "California" + }, + "carrier": null, + "name": "John Doe", + "phone": null, + "tracking_number": null + }, + "source": null, + "status": +} diff --git a/Stripe/StripeiOSTests/Resources/stp_test_upload_image.jpeg b/Stripe/StripeiOSTests/Resources/stp_test_upload_image.jpeg new file mode 100644 index 00000000..bb7e0e73 Binary files /dev/null and b/Stripe/StripeiOSTests/Resources/stp_test_upload_image.jpeg differ diff --git a/Stripe/StripeiOSTests/RotatingCardBrandsViewSnapshotTests.swift b/Stripe/StripeiOSTests/RotatingCardBrandsViewSnapshotTests.swift new file mode 100644 index 00000000..62033d27 --- /dev/null +++ b/Stripe/StripeiOSTests/RotatingCardBrandsViewSnapshotTests.swift @@ -0,0 +1,38 @@ +// +// RotatingCardBrandsViewSnapshotTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 6/27/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase +import StripeCoreTestUtils +@_spi(STP) import StripePayments +import XCTest + +@testable import Stripe +@testable @_spi(STP) import StripePaymentSheet + +class RotatingCardBrandsViewSnapshotTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() +// recordMode = true + } + + func testAllCardBrands() { + let rotatingCardBrandsView = RotatingCardBrandsView() + rotatingCardBrandsView.cardBrands = RotatingCardBrandsView.orderedCardBrands(from: STPCardBrand.allCases) + rotatingCardBrandsView.autosizeHeight(width: 140) + STPSnapshotVerifyView(rotatingCardBrandsView) + } + + func testSingleCardBrand() { + let rotatingCardBrandsView = RotatingCardBrandsView() + rotatingCardBrandsView.cardBrands = [.visa] + rotatingCardBrandsView.autosizeHeight(width: 140) + STPSnapshotVerifyView(rotatingCardBrandsView) + } + +} diff --git a/Stripe/StripeiOSTests/RotatingCardBrandsViewTests.swift b/Stripe/StripeiOSTests/RotatingCardBrandsViewTests.swift new file mode 100644 index 00000000..b52ff221 --- /dev/null +++ b/Stripe/StripeiOSTests/RotatingCardBrandsViewTests.swift @@ -0,0 +1,42 @@ +// +// RotatingCardBrandsViewTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 6/27/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripePayments +import XCTest + +@testable import Stripe +@testable @_spi(STP) import StripePaymentSheet + +class RotatingCardBrandsViewTests: XCTestCase { + + func testOrdering() { + XCTAssertEqual([.visa, + .mastercard, + .amex, + .discover, + .dinersClub, + .JCB, + .unionPay, + ], RotatingCardBrandsView.orderedCardBrands(from: STPCardBrand.allCases)) + } + + func testRotatesOnMoreThreeOrMoreBrands() { + let rotatingCardBrandsView = RotatingCardBrandsView() + rotatingCardBrandsView.cardBrands = [.visa] + XCTAssertTrue(rotatingCardBrandsView.rotatingCardBrandView.isHidden) + rotatingCardBrandsView.cardBrands = [.visa, .mastercard] + XCTAssertTrue(rotatingCardBrandsView.rotatingCardBrandView.isHidden) + rotatingCardBrandsView.cardBrands = [.visa, .mastercard, .amex] + XCTAssertTrue(rotatingCardBrandsView.rotatingCardBrandView.isHidden) + rotatingCardBrandsView.cardBrands = [.visa, .mastercard, .amex, .dinersClub] + XCTAssertFalse(rotatingCardBrandsView.rotatingCardBrandView.isHidden) + rotatingCardBrandsView.cardBrands = [.visa, .mastercard, .amex, .dinersClub, .JCB] + XCTAssertFalse(rotatingCardBrandsView.rotatingCardBrandView.isHidden) + } + +} diff --git a/Stripe/StripeiOSTests/STPAPIClient+LinkAccountSessionTest.swift b/Stripe/StripeiOSTests/STPAPIClient+LinkAccountSessionTest.swift new file mode 100644 index 00000000..42232763 --- /dev/null +++ b/Stripe/StripeiOSTests/STPAPIClient+LinkAccountSessionTest.swift @@ -0,0 +1,23 @@ +// +// STPAPIClient+LinkAccountSessionTest.swift +// StripeiOSTests +// +// Created by Yuki Tokuhiro on 4/26/23. +// + +@testable @_spi(STP) import StripePayments +import XCTest + +final class STPAPIClient_LinkAccountSessionTest: XCTestCase { + + func testCreateLinkAccountSessionForDeferredIntent() { + let e = expectation(description: "create link account session") + let apiClient = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + apiClient.createLinkAccountSessionForDeferredIntent(sessionId: "mobile_test_\(UUID().uuidString)", amount: nil, currency: nil, onBehalfOf: nil) { linkAccountSession, error in + XCTAssertNil(error) + XCTAssertNotNil(linkAccountSession) + e.fulfill() + } + waitForExpectations(timeout: 10) + } +} diff --git a/Stripe/StripeiOSTests/STPAPIClientNetworkBridgeTest.swift b/Stripe/StripeiOSTests/STPAPIClientNetworkBridgeTest.swift new file mode 100644 index 00000000..42b7389b --- /dev/null +++ b/Stripe/StripeiOSTests/STPAPIClientNetworkBridgeTest.swift @@ -0,0 +1,370 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPAPIClientNetworkBridgeTest.m +// StripeiOS +// +// Created by David Estes on 9/23/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import PassKit +import Stripe +import StripeCoreTestUtils +import XCTest + +class StripeAPIBridgeNetworkTest: XCTestCase { + var client: STPAPIClient! + + override func setUp() { + client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + super.setUp() + } + + // MARK: Bank Account + func testCreateTokenWithBankAccount() { + let exp = expectation(description: "Request complete") + let params = STPBankAccountParams() + params.accountNumber = "000123456789" + params.routingNumber = "110000000" + params.country = "US" + + client?.createToken(withBankAccount: params) { token, error in + XCTAssertNotNil(token) + XCTAssertNil(error) + exp.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: PII + + func testCreateTokenWithPII() { + let exp = expectation(description: "Create token") + + client?.createToken(withPersonalIDNumber: "123456789") { token, error in + XCTAssertNotNil(token) + XCTAssertNil(error) + exp.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateTokenWithSSNLast4() { + let exp = expectation(description: "Create SSN") + + client?.createToken(withSSNLast4: "1234") { token, error in + XCTAssertNotNil(token) + XCTAssertNil(error) + exp.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: Connect Accounts + + func testCreateConnectAccount() { + let exp = expectation(description: "Create connect account") + let companyParams = STPConnectAccountCompanyParams() + companyParams.name = "Company" + let params = STPConnectAccountParams(company: companyParams) + client?.createToken(withConnectAccount: params) { token, error in + XCTAssertNotNil(token) + XCTAssertNil(error) + exp.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: Upload + + func testUploadFile() { + let exp = expectation(description: "Upload file") + let image = UIImage( + named: "stp_test_upload_image.jpeg", + in: Bundle(for: StripeAPIBridgeNetworkTest.self), + compatibleWith: nil)! + + client?.uploadImage(image, purpose: .disputeEvidence) { file, error in + XCTAssertNotNil(file) + XCTAssertNil(error) + exp.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: Credit Cards + + func testCardToken() { + let exp = expectation(description: "Create card token") + let params = STPCardParams() + params.number = "4242424242424242" + params.expYear = 42 + params.expMonth = 12 + params.cvc = "123" + + client?.createToken(withCard: params) { token, error in + XCTAssertNotNil(token) + XCTAssertNil(error) + exp.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCVCUpdate() { + let exp = expectation(description: "CVC Update") + + client?.createToken(forCVCUpdate: "123") { token, error in + XCTAssertNotNil(token) + XCTAssertNil(error) + exp.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: Sources + + func testCreateRetrieveAndPollSource() { + let exp = expectation(description: "Upload file") + let expR = expectation(description: "Retrieve source") + let expP = expectation(description: "Poll source") + + let card = STPCardParams() + card.number = "4242424242424242" + card.expYear = 42 + card.expMonth = 12 + card.cvc = "123" + + let params = STPSourceParams.cardParams(withCard: card) + + client.createSource(with: params) { [self] source, error in + guard let source = source else { + XCTFail() + return + } + XCTAssertNil(error) + exp.fulfill() + + client?.retrieveSource(withId: source.stripeID, clientSecret: source.clientSecret!) { source2, error2 in + XCTAssertNotNil(source2) + XCTAssertNil(error2) + expR.fulfill() + } + + client?.startPollingSource(withId: source.stripeID, clientSecret: source.clientSecret!, timeout: 10) { [self] source2, error2 in + XCTAssertNotNil(source2) + XCTAssertNil(error2) + client?.stopPollingSource(withId: source.stripeID) + expP.fulfill() + } + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: Payment Intents + + func testRetrievePaymentIntent() { + let exp = expectation(description: "Fetch") + let exp2 = expectation(description: "Fetch with expansion") + + let testClient = STPTestingAPIClient() + testClient.createPaymentIntent(withParams: nil) { [self] clientSecret, error in + XCTAssertNil(error) + + client?.retrievePaymentIntent(withClientSecret: clientSecret!) { pi, error2 in + XCTAssertNotNil(pi) + XCTAssertNil(error2) + exp.fulfill() + } + + client?.retrievePaymentIntent(withClientSecret: clientSecret!, expand: ["metadata"]) { pi, error2 in + XCTAssertNotNil(pi) + XCTAssertNil(error2) + exp2.fulfill() + } + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testConfirmPaymentIntent() { + let exp = expectation(description: "Confirm") + let exp2 = expectation(description: "Confirm with expansion") + let testClient = STPTestingAPIClient() + + let card = STPPaymentMethodCardParams() + card.number = "4242424242424242" + card.expYear = NSNumber(value: 42) + card.expMonth = NSNumber(value: 12) + card.cvc = "123" + + testClient.createPaymentIntent(withParams: nil) { [self] clientSecret, error in + XCTAssertNil(error) + + let params = STPPaymentIntentParams(clientSecret: clientSecret!) + params.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + + client?.confirmPaymentIntent(with: params) { pi, error2 in + XCTAssertNotNil(pi) + XCTAssertNil(error2) + exp.fulfill() + } + } + + testClient.createPaymentIntent(withParams: nil) { [self] clientSecret, error in + XCTAssertNil(error) + + let params = STPPaymentIntentParams(clientSecret: clientSecret!) + params.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + + client?.confirmPaymentIntent(with: params) { pi, error2 in + XCTAssertNotNil(pi) + XCTAssertNil(error2) + exp2.fulfill() + } + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: Setup Intents + + func testRetrieveSetupIntent() { + let exp = expectation(description: "Fetch") + + let testClient = STPTestingAPIClient() + testClient.createSetupIntent(withParams: nil) { [self] clientSecret, error in + XCTAssertNil(error) + + client?.retrieveSetupIntent(withClientSecret: clientSecret!) { si, error2 in + XCTAssertNotNil(si) + XCTAssertNil(error2) + exp.fulfill() + } + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testConfirmSetupIntent() { + let exp = expectation(description: "Confirm") + let testClient = STPTestingAPIClient() + + let card = STPPaymentMethodCardParams() + card.number = "4242424242424242" + card.expYear = NSNumber(value: 42) + card.expMonth = NSNumber(value: 12) + card.cvc = "123" + + testClient.createSetupIntent(withParams: nil) { [self] clientSecret, error in + XCTAssertNil(error) + + let params = STPSetupIntentConfirmParams(clientSecret: clientSecret!) + params.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + + client?.confirmSetupIntent(with: params) { si, error2 in + XCTAssertNotNil(si) + XCTAssertNil(error2) + exp.fulfill() + } + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: Payment Methods + + func testCreatePaymentMethod() { + let exp = expectation(description: "Create PaymentMethod") + + let card = STPPaymentMethodCardParams() + card.number = "4242424242424242" + card.expYear = NSNumber(value: 42) + card.expMonth = NSNumber(value: 12) + card.cvc = "123" + + let params = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + + client?.createPaymentMethod(with: params) { pm, error in + XCTAssertNotNil(pm) + XCTAssertNil(error) + exp.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: Radar + + func testCreateRadarSession() { + let exp = expectation(description: "Create session") + + client?.createRadarSession { session, error in + XCTAssertNotNil(session) + XCTAssertNil(error) + exp.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: ApplePay + + func testCreateApplePayToken() { + let exp = expectation(description: "CreateToken") + let exp2 = expectation(description: "CreateSource") + let exp3 = expectation(description: "CreatePM") + let payment = STPFixtures.applePayPayment() + client?.createToken(with: payment) { token, error in + // The certificate used to sign our fake Apple Pay test payment is invalid, which makes sense. + // Expect an error. + XCTAssertNil(token) + XCTAssertNotNil(error) + exp.fulfill() + } + + client?.createSource(with: payment) { source, error in + XCTAssertNil(source) + XCTAssertNotNil(error) + exp2.fulfill() + } + + client?.createPaymentMethod(with: payment) { pm, error in + XCTAssertNil(pm) + XCTAssertNotNil(error) + exp3.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testPKPaymentError() { + let exp = expectation(description: "Upload file") + let params = STPCardParams() + params.number = "4242424242424242" + params.expYear = 20 + params.expMonth = 12 + params.cvc = "123" + + client?.createToken(withCard: params) { token, error in + XCTAssertNil(token) + XCTAssertNotNil(error) + XCTAssertNotNil(STPAPIClient.pkPaymentError(forStripeError: error)) + + exp.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} + +// These are a little redundant with the existing +// API tests, but it's a good way to test that the +// bridge works correctly. diff --git a/Stripe/StripeiOSTests/STPAPIClientStubbedTest.swift b/Stripe/StripeiOSTests/STPAPIClientStubbedTest.swift new file mode 100644 index 00000000..73546eb1 --- /dev/null +++ b/Stripe/StripeiOSTests/STPAPIClientStubbedTest.swift @@ -0,0 +1,311 @@ +// +// STPAPIClientStubbedTest.swift +// StripeiOS Tests +// +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import OHHTTPStubs +import OHHTTPStubsSwift +import StripeCoreTestUtils +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeApplePay +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet + +class STPAPIClientStubbedTest: APIStubbedTestCase { + func testCreatePaymentMethodWithAdditionalPaymentUserAgentValues() { + let sut = stubbedAPIClient() + stub { urlRequest in + guard let queryItems = urlRequest.queryItems else { + return false + } + XCTAssertTrue(queryItems.contains(where: { item in + // The additional payment user agent values "foo" and "bar" should be in the payment_user_agent field + item.name == "payment_user_agent" && item.value!.hasSuffix("%3B%20foo%3B%20bar") + })) + return true + } response: { _ in + return .init() + } + let e = expectation(description: "") + sut.createPaymentMethod(with: ._testValidCardValue(), additionalPaymentUserAgentValues: ["foo", "bar"]) { _, _ in + e.fulfill() + } + waitForExpectations(timeout: 10) + } + + func testSetupIntent_LinkAccountSessionForUSBankAccount() { + let sut = stubbedAPIClient() + stub { urlRequest in + return urlRequest.url?.absoluteString.contains( + "/setup_intents/seti_12345/link_account_sessions" + ) ?? false + } response: { urlRequest in + guard let data = urlRequest.httpBodyOrBodyStream, + let body = String(data: data, encoding: .utf8) + else { + return HTTPStubsResponse( + data: "".data(using: .utf8)!, + statusCode: 400, + headers: nil + ) + } + XCTAssert(body.contains("client_secret=si_client_secret_123")) + XCTAssert( + body.contains( + "payment_method_data%5Bbilling_details%5D%5Bemail%5D=test%40example.com" + ) + ) + XCTAssert( + body.contains("payment_method_data%5Bbilling_details%5D%5Bname%5D=Test%20Tester") + ) + XCTAssert(body.contains("payment_method_data%5Btype%5D=us_bank_account")) + + let jsonText = """ + { + "id": "xxxxx", + "object": "link_account_session", + "client_secret": "las_client_secret_123456", + "linked_accounts": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/linked_accounts" + }, + "livemode": false + } + """ + return HTTPStubsResponse( + data: jsonText.data(using: .utf8)!, + statusCode: 200, + headers: nil + ) + } + + let expectCallback = expectation(description: "bindings serialize/deserialize") + sut.createLinkAccountSession( + setupIntentID: "seti_12345", + clientSecret: "si_client_secret_123", + paymentMethodType: .USBankAccount, + customerName: "Test Tester", + customerEmailAddress: "test@example.com" + ) { intent, _ in + guard let intent = intent else { + XCTFail("Intent was null") + return + } + XCTAssertEqual(intent.clientSecret, "las_client_secret_123456") + expectCallback.fulfill() + } + + wait(for: [expectCallback], timeout: 2.0) + } + + func testPaymentIntent_LinkAccountSessionForUSBankAccount() { + let sut = stubbedAPIClient() + stub { urlRequest in + return urlRequest.url?.absoluteString.contains( + "/payment_intents/pi_12345/link_account_sessions" + ) ?? false + } response: { urlRequest in + guard let data = urlRequest.httpBodyOrBodyStream, + let body = String(data: data, encoding: .utf8) + else { + return HTTPStubsResponse( + data: "".data(using: .utf8)!, + statusCode: 400, + headers: nil + ) + } + XCTAssert(body.contains("client_secret=si_client_secret_123")) + XCTAssert( + body.contains( + "payment_method_data%5Bbilling_details%5D%5Bemail%5D=test%40example.com" + ) + ) + XCTAssert( + body.contains("payment_method_data%5Bbilling_details%5D%5Bname%5D=Test%20Tester") + ) + XCTAssert(body.contains("payment_method_data%5Btype%5D=us_bank_account")) + + let jsonText = """ + { + "id": "las_12345", + "object": "link_account_session", + "client_secret": "las_client_secret_654321", + "linked_accounts": { + "object": "list", + "data": [ + + ], + "has_more": false, + "total_count": 0, + "url": "/v1/linked_accounts" + }, + "livemode": false + } + """ + return HTTPStubsResponse( + data: jsonText.data(using: .utf8)!, + statusCode: 200, + headers: nil + ) + } + + let expectCallback = expectation(description: "bindings serialize/deserialize") + sut.createLinkAccountSession( + paymentIntentID: "pi_12345", + clientSecret: "si_client_secret_123", + paymentMethodType: .USBankAccount, + customerName: "Test Tester", + customerEmailAddress: "test@example.com" + ) { intent, _ in + guard let intent = intent else { + XCTFail("Intent was null") + return + } + XCTAssertEqual(intent.clientSecret, "las_client_secret_654321") + expectCallback.fulfill() + } + + wait(for: [expectCallback], timeout: 2.0) + } + + func testSetupIntent_LinkAccountSessionAttach() { + let sut = stubbedAPIClient() + stub { urlRequest in + return urlRequest.url?.absoluteString.contains( + "/setup_intents/seti_12345/link_account_sessions/las_123456/attach" + ) ?? false + } response: { urlRequest in + guard let data = urlRequest.httpBodyOrBodyStream, + let body = String(data: data, encoding: .utf8) + else { + return HTTPStubsResponse( + data: "".data(using: .utf8)!, + statusCode: 400, + headers: nil + ) + } + XCTAssert(body.contains("client_secret=si_client_secret_123")) + + let jsonText = """ + { + "id": "seti_12345", + "object": "setup_intent", + "cancellation_reason": null, + "client_secret": "seti_abc_secret_def", + "created": 1647000000, + "description": null, + "last_setup_error": null, + "livemode": false, + "next_action": null, + "payment_method": "pm_abcdefg", + "payment_method_options": { + "us_bank_account": { + "verification_method": "instant" + } + }, + "payment_method_types": [ + "us_bank_account" + ], + "status": "requires_confirmation", + "usage": "off_session" + } + """ + return HTTPStubsResponse( + data: jsonText.data(using: .utf8)!, + statusCode: 200, + headers: nil + ) + } + + let expectCallback = expectation(description: "bindings serialize/deserialize") + sut.attachLinkAccountSession( + setupIntentID: "seti_12345", + linkAccountSessionID: "las_123456", + clientSecret: "si_client_secret_123" + ) { intent, _ in + guard let intent = intent else { + XCTFail("Intent was null") + return + } + XCTAssertEqual(intent.paymentMethodID, "pm_abcdefg") + expectCallback.fulfill() + } + + wait(for: [expectCallback], timeout: 2.0) + } + + func testPaymentIntent_LinkAccountSessionAttach() { + let sut = stubbedAPIClient() + stub { urlRequest in + return urlRequest.url?.absoluteString.contains( + "/payment_intents/pi_12345/link_account_sessions/las_123456/attach" + ) ?? false + } response: { urlRequest in + guard let data = urlRequest.httpBodyOrBodyStream, + let body = String(data: data, encoding: .utf8) + else { + return HTTPStubsResponse( + data: "".data(using: .utf8)!, + statusCode: 400, + headers: nil + ) + } + XCTAssert(body.contains("client_secret=pi_client_secret_123")) + + let jsonText = """ + { + "id": "pi_12345", + "object": "payment_intent", + "amount": 100, + "currency": "usd", + "cancellation_reason": null, + "client_secret": "seti_abc_secret_def", + "created": 1647000000, + "description": null, + "last_setup_error": null, + "livemode": false, + "next_action": null, + "payment_method": "pm_abcdefg", + "payment_method_options": { + "us_bank_account": { + "verification_method": "instant" + } + }, + "payment_method_types": [ + "us_bank_account" + ], + "status": "requires_payment_method" + } + """ + return HTTPStubsResponse( + data: jsonText.data(using: .utf8)!, + statusCode: 200, + headers: nil + ) + } + + let expectCallback = expectation(description: "bindings serialize/deserialize") + sut.attachLinkAccountSession( + paymentIntentID: "pi_12345", + linkAccountSessionID: "las_123456", + clientSecret: "pi_client_secret_123" + ) { intent, _ in + guard let intent = intent else { + XCTFail("Intent was null") + return + } + XCTAssertEqual(intent.paymentMethodId, "pm_abcdefg") + expectCallback.fulfill() + } + + wait(for: [expectCallback], timeout: 2.0) + } +} diff --git a/Stripe/StripeiOSTests/STPAPIClientTest.swift b/Stripe/StripeiOSTests/STPAPIClientTest.swift new file mode 100644 index 00000000..0fc6d6d2 --- /dev/null +++ b/Stripe/StripeiOSTests/STPAPIClientTest.swift @@ -0,0 +1,152 @@ +// +// STPAPIClientTest.swift +// StripeiOS Tests +// +// Created by Jack Flintermann on 12/19/14. +// Copyright (c) 2014 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeApplePay +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPAPIClientTest: XCTestCase { + func testSharedClient() { + XCTAssert(STPAPIClient.shared === STPAPIClient.shared) + } + + func testSetDefaultPublishableKey() { + let clientInitializedBefore = STPAPIClient() + StripeAPI.defaultPublishableKey = "test" + let clientInitializedAfter = STPAPIClient() + let sharedClient = STPAPIClient.shared + XCTAssertEqual(clientInitializedBefore.publishableKey, "test") + XCTAssertEqual(clientInitializedAfter.publishableKey, "test") + + // Setting the STPAPIClient instance overrides Stripe.defaultPublishableKey... + sharedClient.publishableKey = "test2" + XCTAssertEqual(sharedClient.publishableKey, "test2") + + // ...while Stripe.defaultPublishableKey remains the same + XCTAssertEqual(StripeAPI.defaultPublishableKey, "test") + } + + func testInitWithPublishableKey() { + let sut = STPAPIClient(publishableKey: "pk_foo") + let authHeader = sut.configuredRequest( + for: URL(string: "https://www.stripe.com")!, + additionalHeaders: [:] + ).allHTTPHeaderFields?["Authorization"] + XCTAssertEqual(authHeader, "Bearer pk_foo") + } + + func testSetPublishableKey() { + let sut = STPAPIClient(publishableKey: "pk_foo") + var authHeader = sut.configuredRequest( + for: URL(string: "https://www.stripe.com")!, + additionalHeaders: [:] + ).allHTTPHeaderFields?["Authorization"] + XCTAssertEqual(authHeader, "Bearer pk_foo") + sut.publishableKey = "pk_bar" + authHeader = + sut.configuredRequest( + for: URL(string: "https://www.stripe.com")!, + additionalHeaders: [:] + ) + .allHTTPHeaderFields?["Authorization"] + XCTAssertEqual(authHeader, "Bearer pk_bar") + } + + func testEphemeralKeyOverwritesHeader() { + let sut = STPAPIClient(publishableKey: "pk_foo") + let ephemeralKey = STPFixtures.ephemeralKey() + let additionalHeaders = sut.authorizationHeader(using: ephemeralKey) + let authHeader = sut.configuredRequest( + for: URL(string: "https://www.stripe.com")!, + additionalHeaders: additionalHeaders + ).allHTTPHeaderFields?["Authorization"] + XCTAssertEqual(authHeader, "Bearer " + (ephemeralKey.secret)) + } + + func testSetStripeAccount() { + let sut = STPAPIClient(publishableKey: "pk_foo") + var accountHeader = sut.configuredRequest( + for: URL(string: "https://www.stripe.com")!, + additionalHeaders: [:] + ).allHTTPHeaderFields?["Stripe-Account"] + XCTAssertNil(accountHeader) + sut.stripeAccount = "acct_123" + accountHeader = + sut.configuredRequest( + for: URL(string: "https://www.stripe.com")!, + additionalHeaders: [:] + ) + .allHTTPHeaderFields?["Stripe-Account"] + XCTAssertEqual(accountHeader, "acct_123") + } + + func testInitWithConfiguration() { + let config = STPPaymentConfiguration() + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + config.publishableKey = "pk_123" + config.stripeAccount = "acct_123" + + let sut = STPAPIClient(configuration: config) + XCTAssertEqual(sut.publishableKey, config.publishableKey) + XCTAssertEqual(sut.stripeAccount, config.stripeAccount) + // #pragma clang diagnostic pop + + let accountHeader = sut.configuredRequest( + for: URL(string: "https://www.stripe.com")!, + additionalHeaders: [:] + ).allHTTPHeaderFields?["Stripe-Account"] + XCTAssertEqual(accountHeader, "acct_123") + } + + private struct MockUAUsageClass: STPAnalyticsProtocol { + static let stp_analyticsIdentifier = "MockUAUsageClass" + } + + func testPaymentUserAgent() { + STPAnalyticsClient.sharedClient.productUsage = .init() + STPAnalyticsClient.sharedClient.addClass(toProductUsageIfNecessary: MockUAUsageClass.self) + var params: [String: Any] = [:] + params = STPAPIClient.paramsAddingPaymentUserAgent(params) + XCTAssertEqual(params["payment_user_agent"] as! String, "stripe-ios/\(StripeAPIConfiguration.STPSDKVersion); variant.legacy; MockUAUsageClass") + + params = STPAPIClient.paramsAddingPaymentUserAgent(params, additionalValues: ["foo"]) + XCTAssertEqual(params["payment_user_agent"] as! String, "stripe-ios/\(StripeAPIConfiguration.STPSDKVersion); variant.legacy; MockUAUsageClass; foo") + } + + func testSetAppInfo() { + let sut = STPAPIClient(publishableKey: "pk_foo") + sut.appInfo = STPAppInfo( + name: "MyAwesomeLibrary", + partnerId: "pp_partner_1234", + version: "1.2.34", + url: "https://myawesomelibrary.info" + ) + let userAgentHeader = sut.configuredRequest( + for: URL(string: "https://www.stripe.com")!, + additionalHeaders: [:] + ).allHTTPHeaderFields?["X-Stripe-User-Agent"] + var userAgentHeaderDict: [AnyHashable: Any]? + do { + if let data = userAgentHeader?.data(using: .utf8) { + userAgentHeaderDict = + try JSONSerialization.jsonObject(with: data, options: []) as? [AnyHashable: Any] + } + } catch { + } + XCTAssertEqual(userAgentHeaderDict?["name"] as! String, "MyAwesomeLibrary") + XCTAssertEqual(userAgentHeaderDict?["partner_id"] as! String, "pp_partner_1234") + XCTAssertEqual(userAgentHeaderDict?["version"] as! String, "1.2.34") + XCTAssertEqual(userAgentHeaderDict?["url"] as! String, "https://myawesomelibrary.info") + } +} diff --git a/Stripe/StripeiOSTests/STPAPISettingsObjCBridgeTest.m b/Stripe/StripeiOSTests/STPAPISettingsObjCBridgeTest.m new file mode 100644 index 00000000..046c294a --- /dev/null +++ b/Stripe/StripeiOSTests/STPAPISettingsObjCBridgeTest.m @@ -0,0 +1,84 @@ +// +// STPObjcBridgeTest.m +// StripeiOS Tests +// +// Created by David Estes on 9/21/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@import Stripe; +@import XCTest; +@import PassKit; +@import StripePaymentsObjcTestUtils; + +@interface StripeAPIBridgeTest : XCTestCase + +@end + +@implementation StripeAPIBridgeTest + +- (void)testStripeAPIBridge { + NSString *testKey = @"pk_test_123"; + StripeAPI.defaultPublishableKey = testKey; + XCTAssertEqualObjects(StripeAPI.defaultPublishableKey, testKey); + StripeAPI.defaultPublishableKey = nil; + + StripeAPI.advancedFraudSignalsEnabled = NO; + XCTAssertFalse(StripeAPI.advancedFraudSignalsEnabled); + StripeAPI.advancedFraudSignalsEnabled = YES; + + + StripeAPI.maxRetries = 2; + XCTAssertEqual(StripeAPI.maxRetries, 2); + StripeAPI.maxRetries = 3; + + // Check that this at least doesn't crash + [StripeAPI handleStripeURLCallbackWithURL:[NSURL URLWithString:@"https://example.com"]]; + + StripeAPI.jcbPaymentNetworkSupported = YES; + XCTAssertTrue(StripeAPI.jcbPaymentNetworkSupported); + StripeAPI.jcbPaymentNetworkSupported = NO; + + StripeAPI.additionalEnabledApplePayNetworks = @[PKPaymentNetworkJCB]; + XCTAssertTrue([StripeAPI.additionalEnabledApplePayNetworks containsObject:PKPaymentNetworkJCB]); + StripeAPI.additionalEnabledApplePayNetworks = @[]; + + PKPaymentRequest *request = [StripeAPI paymentRequestWithMerchantIdentifier:@"test" country:@"US" currency:@"USD"]; + request.paymentSummaryItems = @[[PKPaymentSummaryItem summaryItemWithLabel:@"bar" amount:[NSDecimalNumber decimalNumberWithString:@"1.00"]]]; + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wdeprecated" + PKPaymentRequest *request2 = [StripeAPI paymentRequestWithMerchantIdentifier:@"test"]; + request2.paymentSummaryItems = @[[PKPaymentSummaryItem summaryItemWithLabel:@"bar" amount:[NSDecimalNumber decimalNumberWithString:@"1.00"]]]; + #pragma clang diagnostic pop + + XCTAssertTrue([StripeAPI canSubmitPaymentRequest:request]); + XCTAssertTrue([StripeAPI canSubmitPaymentRequest:request2]); + + XCTAssertTrue([StripeAPI deviceSupportsApplePay]); +} + +- (void)testSTPAPIClientBridgeKeys { + NSString *testKey = @"pk_test_123"; + StripeAPI.defaultPublishableKey = testKey; + XCTAssertEqualObjects(testKey, StripeAPI.defaultPublishableKey); + StripeAPI.defaultPublishableKey = nil; +} + +- (void)testSTPAPIClientBridgeSettings { + STPAPIClient *client = [[STPAPIClient alloc] initWithPublishableKey:@"pk_test_123"]; + STPPaymentConfiguration *config = [[STPPaymentConfiguration alloc] init]; + client.configuration = config; + XCTAssertEqual(config, client.configuration); + + NSString *stripeAccount = @"acct_123"; + client.stripeAccount = stripeAccount; + XCTAssertEqualObjects(stripeAccount, client.stripeAccount); + + STPAppInfo *appInfo = [[STPAppInfo alloc] initWithName:@"test" partnerId:@"abc123" version:@"1.0" url:@"https://example.com"]; + client.appInfo = appInfo; + XCTAssertEqualObjects(appInfo.name, client.appInfo.name); + + XCTAssertNotNil(STPAPIClient.apiVersion); +} + +@end diff --git a/Stripe/StripeiOSTests/STPAUBECSDebitFormViewSnapshotTests.swift b/Stripe/StripeiOSTests/STPAUBECSDebitFormViewSnapshotTests.swift new file mode 100644 index 00000000..7a0d0a79 --- /dev/null +++ b/Stripe/StripeiOSTests/STPAUBECSDebitFormViewSnapshotTests.swift @@ -0,0 +1,118 @@ +// +// STPAUBECSDebitFormViewSnapshotTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 3/13/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPAUBECSDebitFormViewSnapshotTests: FBSnapshotTestCase { + override func setUp() { + super.setUp() +// self.recordMode = true + } + + func testDefaultAppearance() { + let view = _newFormView() + _size(toFit: view) + STPSnapshotVerifyView(view, identifier: "STPAUBECSDebitFormView.defaultAppearance") + } + + func testNoDataCustomization() { + let view = _newFormView() + + _applyCustomization(view) + + _size(toFit: view) + + STPSnapshotVerifyView(view, identifier: "STPAUBECSDebitFormView.noDataCustomization") + } + + func testWithDataAppearance() { + let view = _newFormView() + view.nameTextField().text = "Jenny Rosen" + view.emailTextField().text = "jrosen@example.com" + view.bsbNumberTextField().text = "111111" + view.accountNumberTextField().text = "123456" + _size(toFit: view) + + STPSnapshotVerifyView(view, identifier: "STPAUBECSDebitFormView.withDataAppearance") + } + + func testWithDataCustomization() { + let view = _newFormView() + view.nameTextField().text = "Jenny Rosen" + view.emailTextField().text = "jrosen@example.com" + view.bsbNumberTextField().text = "111111" + view.accountNumberTextField().text = "123456" + _applyCustomization(view) + _size(toFit: view) + + STPSnapshotVerifyView(view, identifier: "STPAUBECSDebitFormView.withDataAppearance") + } + + func testInvalidBSBAndEmailAppearance() { + let view = _newFormView() + view.nameTextField().text = "Jenny Rosen" + view.emailTextField().text = "jrosen" + view.bsbNumberTextField().text = "666666" + view.accountNumberTextField().text = "123456" + _size(toFit: view) + + STPSnapshotVerifyView( + view, + identifier: "STPAUBECSDebitFormView.invalidBSBAndEmailAppearance" + ) + } + + func testInvalidBSBAndEmailCustomization() { + let view = _newFormView() + view.nameTextField().text = "Jenny Rosen" + view.emailTextField().text = "jrosen" + view.bsbNumberTextField().text = "666666" + view.accountNumberTextField().text = "123456" + _applyCustomization(view) + _size(toFit: view) + + STPSnapshotVerifyView( + view, + identifier: "STPAUBECSDebitFormView.invalidBSBAndEmailCustomization" + ) + } + + // MARK: - Helpers + func _newFormView() -> STPAUBECSDebitFormView { + let formView = STPAUBECSDebitFormView(companyName: "Snapshotter") + formView.frame = CGRect(x: 0.0, y: 0.0, width: 320.0, height: 600.0) + return formView + } + + func _applyCustomization(_ view: STPAUBECSDebitFormView?) { + view?.formFont = UIFont.boldSystemFont(ofSize: 12.0) + view?.formTextColor = UIColor.blue + view?.formTextErrorColor = UIColor.orange + view?.formPlaceholderColor = UIColor.black + view?.formCursorColor = UIColor.red + view?.formBackgroundColor = UIColor( + red: 255.0 / 255.0, + green: 45.0 / 255.0, + blue: 85.0 / 255.0, + alpha: 1.0 + ) + } + + func _size(toFit view: STPAUBECSDebitFormView?) { + var adjustedFrame = view?.frame + adjustedFrame?.size.height = + view?.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height ?? 0.0 + view?.frame = adjustedFrame! + } +} diff --git a/Stripe/StripeiOSTests/STPAUBECSFormViewModelTests.swift b/Stripe/StripeiOSTests/STPAUBECSFormViewModelTests.swift new file mode 100644 index 00000000..829f50c6 --- /dev/null +++ b/Stripe/StripeiOSTests/STPAUBECSFormViewModelTests.swift @@ -0,0 +1,584 @@ +// +// STPAUBECSFormViewModelTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 3/13/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPAUBECSFormViewModelTests: XCTestCase { + func testBECSDebitParams() { + do { + // Test empty data + let model = STPAUBECSFormViewModel() + XCTAssertNil(model.becsDebitParams, "params with no data should be nil") + } + + do { + // Test complete/valid data + let model = STPAUBECSFormViewModel() + model.accountNumber = "123456" + model.bsbNumber = "111-111" + + let params = model.becsDebitParams + XCTAssertNotNil(params, "Failed to create BECS Debit params") + XCTAssertEqual(params?.accountNumber, "123456") + XCTAssertEqual(params?.bsbNumber, "111111") + } + + do { + // Test complete/valid data w/o formatting + let model = STPAUBECSFormViewModel() + model.accountNumber = "123456" + model.bsbNumber = "111111" + + let params = model.becsDebitParams + XCTAssertNotNil(params, "Failed to create BECS Debit params") + XCTAssertEqual(params?.accountNumber, "123456") + XCTAssertEqual(params?.bsbNumber, "111111") + } + + do { + // Test complete/valid accountNumber, incomplete bsb number + let model = STPAUBECSFormViewModel() + model.accountNumber = "123456" + model.bsbNumber = "111-" + + let params = model.becsDebitParams + XCTAssertNil(params, "Should not create params with incomplete bsb number") + } + + do { + // Test incomplete accountNumber, complete/valid bsb number + let model = STPAUBECSFormViewModel() + model.accountNumber = "1234" + model.bsbNumber = "111-111" + + let params = model.becsDebitParams + XCTAssertNil(params, "Should not create params with incomplete account number") + } + + do { + // Test invalid accountNumber, complete/valid bsb number + let model = STPAUBECSFormViewModel() + model.accountNumber = "12345678910" + model.bsbNumber = "111-111" + + let params = model.becsDebitParams + XCTAssertNil(params, "Should not create params with invalid account number") + } + + do { + // Test complete/valid accountNumber, invalid bsb number + let model = STPAUBECSFormViewModel() + model.accountNumber = "123456" + model.bsbNumber = "666-666" + + let params = model.becsDebitParams + XCTAssertNil(params, "Should not create params with incomplete bsb number") + } + } + + func testPaymentMethodParams() { + do { + /// Test empty + let model = STPAUBECSFormViewModel() + XCTAssertNil(model.paymentMethodParams, "params with no data should be nil") + } + + do { + /// name: + + /// email: + + /// bsb: + (formatting) + /// account: + + let model = STPAUBECSFormViewModel() + model.name = "Jenny Rosen" + model.email = "jrosen@example.com" + model.accountNumber = "123456" + model.bsbNumber = "111-111" + + let params = model.paymentMethodParams + XCTAssertNotNil(params, "Failed to create BECS Debit params") + XCTAssertEqual(params?.billingDetails?.name, "Jenny Rosen") + XCTAssertEqual(params?.billingDetails?.email, "jrosen@example.com") + XCTAssertEqual(params?.auBECSDebit?.accountNumber, "123456") + XCTAssertEqual(params?.auBECSDebit?.bsbNumber, "111111") + } + + do { + /// name: + + /// email: + + /// bsb: + + /// account: + + let model = STPAUBECSFormViewModel() + model.name = "Jenny Rosen" + model.email = "jrosen@example.com" + model.accountNumber = "123456" + model.bsbNumber = "111111" + + let params = model.paymentMethodParams + XCTAssertNotNil(params, "Failed to create BECS Debit params") + XCTAssertEqual(params?.billingDetails?.name, "Jenny Rosen") + XCTAssertEqual(params?.billingDetails?.email, "jrosen@example.com") + XCTAssertEqual(params?.auBECSDebit?.accountNumber, "123456") + XCTAssertEqual(params?.auBECSDebit?.bsbNumber, "111111") + } + + do { + /// name: + + /// email: + + /// bsb: x (incomplete) + /// account: + + let model = STPAUBECSFormViewModel() + model.name = "Jenny Rosen" + model.email = "jrosen@example.com" + model.accountNumber = "123456" + model.bsbNumber = "111-" + + let params = model.paymentMethodParams + XCTAssertNil(params, "Should not create params with incomplete bsb number") + } + + do { + /// name: + + /// email: + + /// bsb: + + /// account: x (incomplete) + let model = STPAUBECSFormViewModel() + model.name = "Jenny Rosen" + model.email = "jrosen@example.com" + model.accountNumber = "1234" + model.bsbNumber = "111-111" + + let params = model.paymentMethodParams + XCTAssertNil(params, "Should not create params with incomplete account number") + } + + do { + /// name: + + /// email: + + /// bsb: + + /// account: x + let model = STPAUBECSFormViewModel() + model.name = "Jenny Rosen" + model.email = "jrosen@example.com" + model.accountNumber = "12345678910" + model.bsbNumber = "111-111" + + let params = model.paymentMethodParams + XCTAssertNil(params, "Should not create params with invalid account number") + } + + do { + /// name: + + /// email: + + /// bsb: x + /// account: + + let model = STPAUBECSFormViewModel() + model.name = "Jenny Rosen" + model.email = "jrosen@example.com" + model.accountNumber = "123456" + model.bsbNumber = "666-666" + + let params = model.paymentMethodParams + XCTAssertNil(params, "Should not create params with incomplete bsb number") + } + + do { + /// name: x + /// email: + + /// bsb: + (formatting) + /// account: + + let model = STPAUBECSFormViewModel() + model.name = "" + model.email = "jrosen@example.com" + model.accountNumber = "123456" + model.bsbNumber = "111-111" + + let params = model.paymentMethodParams + XCTAssertNil(params, "Should not create payment method params without name.") + } + + do { + /// name: + + /// email: x + /// bsb: + (formatting) + /// account: + + let model = STPAUBECSFormViewModel() + model.name = "Jenny Rosen" + model.email = "jrose" + model.accountNumber = "123456" + model.bsbNumber = "111-111" + + let params = model.paymentMethodParams + XCTAssertNil(params, "Should not create payment method params with invalid email.") + } + } + + func testBSBLabelForInput() { + do { + // empty test + let model = STPAUBECSFormViewModel() + var isErrorString = true + var bsbLabel = model.bsbLabel( + forInput: "", + editing: false, + isErrorString: &isErrorString + ) + XCTAssertFalse(isErrorString, "Empty input shouldn't be an error.") + XCTAssertNil(bsbLabel, "No bsb label for empty input.") + + isErrorString = true + bsbLabel = model.bsbLabel(forInput: nil, editing: true, isErrorString: &isErrorString) + XCTAssertFalse(isErrorString, "nil input shouldn't be an error.") + XCTAssertNil(bsbLabel, "No bsb label for nil input.") + } + + do { + // invalid test + let model = STPAUBECSFormViewModel() + var isErrorString = false + var bsbLabel = model.bsbLabel( + forInput: "666-666", + editing: false, + isErrorString: &isErrorString + ) + XCTAssertTrue(isErrorString, "Invalid input should be an error.") + XCTAssertEqual(bsbLabel, "The BSB you entered is invalid.") + + isErrorString = false + bsbLabel = model.bsbLabel( + forInput: "666-666", + editing: true, + isErrorString: &isErrorString + ) + XCTAssertTrue(isErrorString, "Invalid input should be an error (editing).") + XCTAssertEqual(bsbLabel, "The BSB you entered is invalid.") + } + + do { + // incomplete test + let model = STPAUBECSFormViewModel() + var isErrorString = false + var bsbLabel = model.bsbLabel( + forInput: "111-11", + editing: false, + isErrorString: &isErrorString + ) + XCTAssertTrue(isErrorString, "Incomplete input should be an error when not editing.") + XCTAssertEqual(bsbLabel, "The BSB you entered is incomplete.") + + isErrorString = true + bsbLabel = model.bsbLabel( + forInput: "111-11", + editing: true, + isErrorString: &isErrorString + ) + XCTAssertFalse(isErrorString, "Incomplete input should not be an error when editing.") + XCTAssertEqual(bsbLabel, "St George Bank (division of Westpac Bank)") + } + + do { + // valid test + let model = STPAUBECSFormViewModel() + var isErrorString = true + var bsbLabel = model.bsbLabel( + forInput: "111-111", + editing: false, + isErrorString: &isErrorString + ) + XCTAssertFalse(isErrorString, "Complete input should be not an error when not editing.") + XCTAssertEqual(bsbLabel, "St George Bank (division of Westpac Bank)") + + isErrorString = true + bsbLabel = model.bsbLabel( + forInput: "111-111", + editing: true, + isErrorString: &isErrorString + ) + XCTAssertFalse(isErrorString, "Complete input should not be an error when editing.") + XCTAssertEqual(bsbLabel, "St George Bank (division of Westpac Bank)") + } + } + + func testIsInputValid() { + do { + // name + let model = STPAUBECSFormViewModel() + XCTAssertTrue( + model.isInputValid("", for: .name, editing: false), + "Name should always be valid." + ) + XCTAssertTrue( + model.isInputValid("Jen", for: .name, editing: true), + "Name should always be valid." + ) + } + + do { + // email + let model = STPAUBECSFormViewModel() + XCTAssertFalse( + model.isInputValid("jrosen", for: .email, editing: false), + "Partial email is invalid when not editing." + ) + XCTAssertTrue( + model.isInputValid("jrosen", for: .email, editing: true), + "Partial email is valid when editing." + ) + + XCTAssertTrue( + model.isInputValid("", for: .email, editing: false), + "Empty email is always valid." + ) + XCTAssertTrue( + model.isInputValid("", for: .email, editing: true), + "Empty email is always valid." + ) + + XCTAssertTrue( + model.isInputValid("jrosen@example.com", for: .email, editing: false), + "Valid email." + ) + XCTAssertTrue( + model.isInputValid("jrosen@example.com", for: .email, editing: true), + "Valid email." + ) + } + + do { + // bsb + let model = STPAUBECSFormViewModel() + XCTAssertFalse( + model.isInputValid("111-1", for: .BSBNumber, editing: false), + "Partial bsb is invalid when not editing." + ) + XCTAssertTrue( + model.isInputValid("111-1", for: .BSBNumber, editing: true), + "Partial bsb is valid when editing." + ) + + XCTAssertTrue( + model.isInputValid("", for: .BSBNumber, editing: false), + "Empty bsb is always valid." + ) + XCTAssertTrue( + model.isInputValid("", for: .BSBNumber, editing: true), + "Empty bsb is always valid." + ) + + XCTAssertTrue( + model.isInputValid("111-111", for: .BSBNumber, editing: false), + "Valid bsb." + ) + XCTAssertTrue( + model.isInputValid("111-111", for: .BSBNumber, editing: true), + "Valid bsb." + ) + + XCTAssertFalse( + model.isInputValid("666-6", for: .BSBNumber, editing: false), + "Invalid partial bsb is always invalid." + ) + XCTAssertFalse( + model.isInputValid("666-6", for: .BSBNumber, editing: true), + "Invalid partial bsb is always invalid." + ) + + XCTAssertFalse( + model.isInputValid("666-666", for: .BSBNumber, editing: false), + "Invalid full bsb is always invalid." + ) + XCTAssertFalse( + model.isInputValid("666-666", for: .BSBNumber, editing: true), + "Invalid full bsb is always invalid." + ) + } + + do { + // account + let model = STPAUBECSFormViewModel() + XCTAssertFalse( + model.isInputValid("1234", for: .accountNumber, editing: false), + "Partial account number is invalid when not editing." + ) + XCTAssertTrue( + model.isInputValid("1234", for: .accountNumber, editing: true), + "Partial account number is valid when editing." + ) + + XCTAssertTrue( + model.isInputValid("", for: .accountNumber, editing: false), + "Empty account number is always valid." + ) + XCTAssertTrue( + model.isInputValid("", for: .accountNumber, editing: true), + "Empty account number is always valid." + ) + + XCTAssertTrue( + model.isInputValid("12345", for: .accountNumber, editing: false), + "Valid account number." + ) + XCTAssertTrue( + model.isInputValid("12345", for: .accountNumber, editing: true), + "Valid account number." + ) + + XCTAssertFalse( + model.isInputValid("12345678910", for: .accountNumber, editing: false), + "Invalid account number is always invalid." + ) + XCTAssertFalse( + model.isInputValid("12345678910", for: .accountNumber, editing: true), + "Invalid account number is always invalid." + ) + } + } + + func testIsFieldComplete() { + do { + // name + let model = STPAUBECSFormViewModel() + XCTAssertFalse( + model.isFieldComplete(withInput: "", in: .name, editing: false), + "Empty name is not complete." + ) + XCTAssertFalse( + model.isFieldComplete(withInput: "", in: .name, editing: true), + "Empty name is not complete." + ) + + XCTAssertTrue( + model.isFieldComplete(withInput: "Jen", in: .name, editing: false), + "Non-empty name is complete." + ) + XCTAssertTrue( + model.isFieldComplete(withInput: "Jenny Rosen", in: .name, editing: true), + "Non-empty name is complete." + ) + } + + do { + // email + let model = STPAUBECSFormViewModel() + XCTAssertFalse( + model.isFieldComplete(withInput: "jrosen", in: .email, editing: false), + "Partial email is not complete." + ) + XCTAssertFalse( + model.isFieldComplete(withInput: "jrosen", in: .email, editing: true), + "Partial email is not complete." + ) + + XCTAssertTrue( + model.isFieldComplete(withInput: "jrosen@example.com", in: .email, editing: false), + "Full email is complete." + ) + XCTAssertTrue( + model.isFieldComplete(withInput: "jrosen@example.com", in: .email, editing: true), + "Full email is complete." + ) + } + + do { + // bsb + let model = STPAUBECSFormViewModel() + XCTAssertFalse( + model.isFieldComplete(withInput: "111-1", in: .BSBNumber, editing: false), + "Partial bsb is not complete." + ) + XCTAssertFalse( + model.isFieldComplete(withInput: "111-1", in: .BSBNumber, editing: true), + "Partial bsb is not complete." + ) + + XCTAssertFalse( + model.isFieldComplete(withInput: "", in: .BSBNumber, editing: false), + "Empty bsb is not complete." + ) + XCTAssertFalse( + model.isFieldComplete(withInput: "", in: .BSBNumber, editing: true), + "Empty bsb is not complete." + ) + + XCTAssertTrue( + model.isFieldComplete(withInput: "111-111", in: .BSBNumber, editing: false), + "Full bsb is complete." + ) + XCTAssertTrue( + model.isFieldComplete(withInput: "111-111", in: .BSBNumber, editing: true), + "Full bsb is complete." + ) + + XCTAssertFalse( + model.isFieldComplete(withInput: "666-6", in: .BSBNumber, editing: false), + "Invalid partial bsb is not complete." + ) + XCTAssertFalse( + model.isFieldComplete(withInput: "666-6", in: .BSBNumber, editing: true), + "Invalid partial bsb is not complete." + ) + + XCTAssertFalse( + model.isFieldComplete(withInput: "666-666", in: .BSBNumber, editing: false), + "Invalid full bsb is not complete." + ) + XCTAssertFalse( + model.isFieldComplete(withInput: "666-666", in: .BSBNumber, editing: true), + "Invalid full bsb is not complete." + ) + } + + do { + // account + let model = STPAUBECSFormViewModel() + XCTAssertFalse( + model.isFieldComplete(withInput: "1234", in: .accountNumber, editing: false), + "Partial account number is not complete." + ) + XCTAssertFalse( + model.isFieldComplete(withInput: "1234", in: .accountNumber, editing: true), + "Partial account number is not complete." + ) + + XCTAssertFalse( + model.isFieldComplete(withInput: "", in: .accountNumber, editing: false), + "Empty account number is not complete." + ) + XCTAssertFalse( + model.isFieldComplete(withInput: "", in: .accountNumber, editing: true), + "Empty account number is not complete." + ) + + XCTAssertTrue( + model.isFieldComplete(withInput: "12345", in: .accountNumber, editing: false), + "Min length account number is complete when not editing." + ) + XCTAssertFalse( + model.isFieldComplete(withInput: "12345", in: .accountNumber, editing: true), + "Min length account number is not complete when editing." + ) + + XCTAssertTrue( + model.isFieldComplete(withInput: "123456789", in: .accountNumber, editing: true), + "Max length account number is complete when editing." + ) + + XCTAssertFalse( + model.isFieldComplete(withInput: "12345678910", in: .accountNumber, editing: false), + "Invalid account number is not complete." + ) + XCTAssertFalse( + model.isFieldComplete(withInput: "12345678910", in: .accountNumber, editing: true), + "Invalid account number is not complete." + ) + } + } +} diff --git a/Stripe/StripeiOSTests/STPAddCardViewControllerLocalizationTests.swift b/Stripe/StripeiOSTests/STPAddCardViewControllerLocalizationTests.swift new file mode 100644 index 00000000..acd40973 --- /dev/null +++ b/Stripe/StripeiOSTests/STPAddCardViewControllerLocalizationTests.swift @@ -0,0 +1,106 @@ +// +// STPAddCardViewControllerLocalizationTests.swift +// StripeiOS Tests +// +// Created by Brian Dorfman on 10/17/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPAddCardViewControllerLocalizationTests: FBSnapshotTestCase { + override func setUp() { + super.setUp() + +// self.recordMode = true + } + + func performSnapshotTest(forLanguage language: String?, delivery: Bool) { + let config = STPPaymentConfiguration() + config.companyName = "Test Company" + config.requiredBillingAddressFields = .full + config.shippingType = delivery ? .delivery : .shipping + config.cardScanningEnabled = true + STPLocalizationUtils.overrideLanguage(to: language) + + let addCardVC = STPAddCardViewController( + configuration: config, + theme: STPTheme.defaultTheme + ) + addCardVC.shippingAddress = STPAddress() + addCardVC.shippingAddress?.line1 = "1" // trigger "use shipping address" button + + let viewToTest = stp_preparedAndSizedViewForSnapshotTest(from: addCardVC)! + + if delivery { + addCardVC.addressViewModel.addressFieldTableViewCountryCode = "INVALID" + STPSnapshotVerifyView(viewToTest, identifier: "delivery") + } else { + // This method rejects nil or empty country codes to stop strange looking behavior + // when scrolling to the top "unset" position in the picker, so put in + // an invalid country code instead to test seeing the "Country" placeholder + addCardVC.addressViewModel.addressFieldTableViewCountryCode = "INVALID" + STPSnapshotVerifyView(viewToTest, identifier: "no_country") + + addCardVC.addressViewModel.addressFieldTableViewCountryCode = "US" + STPSnapshotVerifyView(viewToTest, identifier: "US") + + addCardVC.addressViewModel.addressFieldTableViewCountryCode = "GB" + STPSnapshotVerifyView(viewToTest, identifier: "GB") + + addCardVC.addressViewModel.addressFieldTableViewCountryCode = "CA" + STPSnapshotVerifyView(viewToTest, identifier: "CA") + + addCardVC.addressViewModel.addressFieldTableViewCountryCode = "MX" + STPSnapshotVerifyView(viewToTest, identifier: "MX") + } + + STPLocalizationUtils.overrideLanguage(to: nil) + } + + func testGerman() { + performSnapshotTest(forLanguage: "de", delivery: false) + performSnapshotTest(forLanguage: "de", delivery: true) + } + + func testEnglish() { + performSnapshotTest(forLanguage: "en", delivery: false) + performSnapshotTest(forLanguage: "en", delivery: true) + } + + func testSpanish() { + performSnapshotTest(forLanguage: "es", delivery: false) + performSnapshotTest(forLanguage: "es", delivery: true) + } + + func testFrench() { + performSnapshotTest(forLanguage: "fr", delivery: false) + performSnapshotTest(forLanguage: "fr", delivery: true) + } + + func testItalian() { + performSnapshotTest(forLanguage: "it", delivery: false) + performSnapshotTest(forLanguage: "it", delivery: true) + } + + func testJapanese() { + performSnapshotTest(forLanguage: "ja", delivery: false) + performSnapshotTest(forLanguage: "ja", delivery: true) + } + + func testDutch() { + performSnapshotTest(forLanguage: "nl", delivery: false) + performSnapshotTest(forLanguage: "nl", delivery: true) + } + + func testChinese() { + performSnapshotTest(forLanguage: "zh-Hans", delivery: false) + performSnapshotTest(forLanguage: "zh-Hans", delivery: true) + } +} diff --git a/Stripe/StripeiOSTests/STPAddCardViewControllerTest.swift b/Stripe/StripeiOSTests/STPAddCardViewControllerTest.swift new file mode 100644 index 00000000..b2db1513 --- /dev/null +++ b/Stripe/StripeiOSTests/STPAddCardViewControllerTest.swift @@ -0,0 +1,292 @@ +// +// STPAddCardViewControllerTest.swift +// StripeiOS Tests +// +// Created by Ben Guo on 7/5/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import OHHTTPStubs +import OHHTTPStubsSwift +import StripeCoreTestUtils + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class MockDelegate: NSObject, STPAddCardViewControllerDelegate { + func addCardViewControllerDidCancel(_ addCardViewController: STPAddCardViewController) { + + } + + var addCardViewControllerDidCreatePaymentMethodBlock: + (STPAddCardViewController, STPPaymentMethod, STPErrorBlock) -> Void = { _, _, _ in } + func addCardViewController( + _ addCardViewController: STPAddCardViewController, + didCreatePaymentMethod paymentMethod: STPPaymentMethod, + completion: @escaping STPErrorBlock + ) { + addCardViewControllerDidCreatePaymentMethodBlock( + addCardViewController, + paymentMethod, + completion + ) + } +} + +class STPAddCardViewControllerTest: APIStubbedTestCase { + + func paymentMethodAPIFilter( + expectedCardParams: STPPaymentMethodCardParams, + urlRequest: URLRequest + ) -> Bool { + if urlRequest.url?.absoluteString.contains("payment_methods") ?? false { + let cardNumber = urlRequest.queryItems?.first(where: { item in + item.name == "card[number]" + }) + XCTAssertEqual(cardNumber!.value, expectedCardParams.number) + return true + } + return false + } + + func buildAddCardViewController() -> STPAddCardViewController? { + let config = STPPaymentConfiguration() + let theme = STPTheme.defaultTheme + let vc = STPAddCardViewController( + configuration: config, + theme: theme + ) + XCTAssertNotNil(vc.view) + return vc + } + + func testPrefilledBillingAddress_removeAddress() { + let config = STPPaymentConfiguration() + config.requiredBillingAddressFields = .postalCode + let sut = STPAddCardViewController( + configuration: config, + theme: STPTheme.defaultTheme + ) + let address = STPAddress() + address.name = "John Smith Doe" + address.phone = "8885551212" + address.email = "foo@example.com" + address.line1 = "55 John St" + address.city = "Harare" + address.postalCode = "10002" + // Zimbabwe does not require zip codes, while the default locale for tests (US) does + address.country = "ZW" + // Sanity checks + XCTAssertFalse(STPPostalCodeValidator.postalCodeIsRequired(forCountryCode: "ZW")) + XCTAssertTrue(STPPostalCodeValidator.postalCodeIsRequired(forCountryCode: "US")) + + let prefilledInfo = STPUserInformation() + prefilledInfo.billingAddress = address + sut.prefilledInformation = prefilledInfo + + XCTAssertNoThrow(sut.loadView()) + XCTAssertNoThrow(sut.viewDidLoad()) + } + + func testPrefilledBillingAddress_viewDidLoadHappensBeforeSettingAddress() { + let config = STPPaymentConfiguration() + config.requiredBillingAddressFields = .full + let sut = STPAddCardViewController( + configuration: config, + theme: STPTheme.defaultTheme + ) + XCTAssertNoThrow(sut.loadView()) + XCTAssertNoThrow(sut.viewDidLoad()) + + let address = STPAddress() + address.name = "John Smith Doe" + address.line1 = "55 John St" + address.city = "Harare" + address.postalCode = "10002" + + let prefilledInfo = STPUserInformation() + prefilledInfo.billingAddress = address + sut.prefilledInformation = prefilledInfo + + let nameCell = sut.addressViewModel.addressCells.first { $0.type == .name }! + XCTAssertEqual( nameCell.contents, "John Smith Doe") + + let line1Cell = sut.addressViewModel.addressCells.first { $0.type == .line1 }! + XCTAssertEqual( line1Cell.contents, "55 John St") + + let cityCell = sut.addressViewModel.addressCells.first { $0.type == .city }! + XCTAssertEqual( cityCell.contents, "Harare") + + let zipCell = sut.addressViewModel.addressCells.first { $0.type == .zip }! + XCTAssertEqual( zipCell.contents, "10002") + } + + func testPrefilledBillingAddress_addAddress() { + // Zimbabwe does not require zip codes, while the default locale for tests (US) does + NSLocale.stp_withLocale(as: NSLocale(localeIdentifier: "en_ZW")) { + // Sanity checks + XCTAssertFalse(STPPostalCodeValidator.postalCodeIsRequired(forCountryCode: "ZW")) + XCTAssertTrue(STPPostalCodeValidator.postalCodeIsRequired(forCountryCode: "US")) + let config = STPPaymentConfiguration() + config.requiredBillingAddressFields = .postalCode + let sut = STPAddCardViewController( + configuration: config, + theme: STPTheme.defaultTheme + ) + let address = STPAddress() + address.name = "John Smith Doe" + address.phone = "8885551212" + address.email = "foo@example.com" + address.line1 = "55 John St" + address.city = "New York" + address.state = "NY" + address.postalCode = "10002" + address.country = "US" + + let prefilledInfo = STPUserInformation() + prefilledInfo.billingAddress = address + sut.prefilledInformation = prefilledInfo + + XCTAssertNoThrow(sut.loadView()) + XCTAssertNoThrow(sut.viewDidLoad()) + } + } + + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Warc-performSelector-leaks" + func testNextWithCreatePaymentMethodError() { + let sut = buildAddCardViewController()! + let expectedCardParams = STPFixtures.paymentMethodCardParams() + sut.paymentCell?.paymentField!.cardParams = expectedCardParams + + let exp = expectation(description: "createPaymentMethodWithCard network request") + stub { urlRequest in + return self.paymentMethodAPIFilter( + expectedCardParams: expectedCardParams, + urlRequest: urlRequest + ) + } response: { _ in + XCTAssertTrue(sut.loading) + let paymentMethod = ["error": "intentionally_invalid"] + defer { + exp.fulfill() + } + return HTTPStubsResponse(jsonObject: paymentMethod, statusCode: 200, headers: nil) + } + sut.apiClient = stubbedAPIClient() + // tap next button + let nextButton = sut.navigationItem.rightBarButtonItem + _ = nextButton?.target?.perform(nextButton?.action, with: nextButton) + + waitForExpectations(timeout: 2, handler: nil) + + // It takes a few more spins on the runloop before we get a response from + // the HTTP stubs, so we'll wait 0.5 seconds before checking the loading indicator. + let loadExp = expectation(description: "loading has stopped") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + XCTAssertFalse(sut.loading) + loadExp.fulfill() + } + waitForExpectations(timeout: 1, handler: nil) + } + + func testNextWithCreatePaymentMethodSuccessAndDidCreatePaymentMethodError() { + let sut = buildAddCardViewController()! + let createPaymentMethodExp = expectation(description: "createPaymentMethodWithCard") + + let expectedCardParams = STPFixtures.paymentMethodCardParams() + let expectedPaymentMethod = STPFixtures.paymentMethod() + let expectedPaymentMethodData = STPFixtures.paymentMethodJSON() + + stub { urlRequest in + return self.paymentMethodAPIFilter( + expectedCardParams: expectedCardParams, + urlRequest: urlRequest + ) + } response: { _ in + XCTAssertTrue(sut.loading) + defer { + createPaymentMethodExp.fulfill() + } + return HTTPStubsResponse( + jsonObject: expectedPaymentMethodData, + statusCode: 200, + headers: nil + ) + } + + let mockDelegate = MockDelegate() + sut.apiClient = stubbedAPIClient() + sut.delegate = mockDelegate + sut.paymentCell?.paymentField!.cardParams = expectedCardParams + + let didCreatePaymentMethodExp = expectation(description: "didCreatePaymentMethod") + + mockDelegate.addCardViewControllerDidCreatePaymentMethodBlock = { + (_, paymentMethod, completion) in + XCTAssertTrue(sut.loading) + let error = NSError.stp_genericFailedToParseResponseError() + XCTAssertEqual(paymentMethod.stripeId, expectedPaymentMethod.stripeId) + completion(error) + XCTAssertFalse(sut.loading) + didCreatePaymentMethodExp.fulfill() + } + + // tap next button + let nextButton = sut.navigationItem.rightBarButtonItem + _ = nextButton?.target?.perform(nextButton?.action, with: nextButton) + + waitForExpectations(timeout: 2, handler: nil) + } + + func testNextWithCreateTokenSuccessAndDidCreateTokenSuccess() { + let sut = buildAddCardViewController()! + + let createPaymentMethodExp = expectation(description: "createPaymentMethodWithCard") + + let expectedCardParams = STPFixtures.paymentMethodCardParams() + let expectedPaymentMethod = STPFixtures.paymentMethod() + let expectedPaymentMethodData = STPFixtures.paymentMethodJSON() + + stub { urlRequest in + return self.paymentMethodAPIFilter( + expectedCardParams: expectedCardParams, + urlRequest: urlRequest + ) + } response: { _ in + XCTAssertTrue(sut.loading) + defer { + createPaymentMethodExp.fulfill() + } + return HTTPStubsResponse( + jsonObject: expectedPaymentMethodData, + statusCode: 200, + headers: nil + ) + } + + let mockDelegate = MockDelegate() + sut.apiClient = stubbedAPIClient() + sut.delegate = mockDelegate + sut.paymentCell?.paymentField!.cardParams = expectedCardParams + + let didCreatePaymentMethodExp = expectation(description: "didCreatePaymentMethod") + mockDelegate.addCardViewControllerDidCreatePaymentMethodBlock = { + (_, paymentMethod, completion) in + XCTAssertTrue(sut.loading) + XCTAssertEqual(paymentMethod.stripeId, expectedPaymentMethod.stripeId) + completion(nil) + XCTAssertFalse(sut.loading) + didCreatePaymentMethodExp.fulfill() + } + + // tap next button + let nextButton = sut.navigationItem.rightBarButtonItem + _ = nextButton?.target?.perform(nextButton?.action, with: nextButton) + + waitForExpectations(timeout: 2, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPAddressTests.swift b/Stripe/StripeiOSTests/STPAddressTests.swift new file mode 100644 index 00000000..3d7eb6d6 --- /dev/null +++ b/Stripe/StripeiOSTests/STPAddressTests.swift @@ -0,0 +1,531 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPAddressTests.m +// Stripe +// +// Created by Ben Guo on 4/13/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Contacts +import PassKit + +class STPAddressTests: XCTestCase { + func testInitWithPKContact_complete() { + let contact = PKContact() + do { + var name = PersonNameComponents() + name.givenName = "John" + name.familyName = "Doe" + contact.name = name + + contact.emailAddress = "foo@example.com" + contact.phoneNumber = CNPhoneNumber(stringValue: "888-555-1212") + + let address = CNMutablePostalAddress() + address.street = "55 John St" + address.city = "New York" + address.state = "NY" + address.postalCode = "10002" + address.isoCountryCode = "US" + address.country = "United States" + contact.postalAddress = address + } + + let address = STPAddress(pkContact: contact) + XCTAssertEqual("John Doe", address.name) + XCTAssertEqual("8885551212", address.phone) + XCTAssertEqual("foo@example.com", address.email) + XCTAssertEqual("55 John St", address.line1) + XCTAssertEqual("New York", address.city) + XCTAssertEqual("NY", address.state) + XCTAssertEqual("10002", address.postalCode) + XCTAssertEqual("US", address.country) + } + + func testInitWithPKContact_partial() { + let contact = PKContact() + do { + var name = PersonNameComponents() + name.givenName = "John" + contact.name = name + + let address = CNMutablePostalAddress() + address.state = "VA" + contact.postalAddress = address + } + + let address = STPAddress(pkContact: contact) + XCTAssertEqual("John", address.name) + XCTAssertNil(address.phone) + XCTAssertNil(address.email) + XCTAssertNil(address.line1) + XCTAssertNil(address.city) + XCTAssertEqual("VA", address.state) + XCTAssertNil(address.postalCode) + XCTAssertNil(address.country) + } + + func testInitWithCNContact_complete() { + if CNContact.self == nil { + // Method not supported by iOS version + return + } + + let contact = CNMutableContact() + do { + contact.givenName = "John" + contact.familyName = "Doe" + + contact.emailAddresses = [ + CNLabeledValue( + label: CNLabelHome, + value: "foo@example.com"), + CNLabeledValue( + label: CNLabelWork, + value: "bar@example.com"), + ] + + contact.phoneNumbers = [ + CNLabeledValue( + label: CNLabelHome, + value: CNPhoneNumber(stringValue: "888-555-1212")), + CNLabeledValue( + label: CNLabelWork, + value: CNPhoneNumber(stringValue: "555-555-5555")), + ] + + let address = CNMutablePostalAddress() + address.street = "55 John St" + address.city = "New York" + address.state = "NY" + address.postalCode = "10002" + address.isoCountryCode = "US" + address.country = "United States" + contact.postalAddresses = [ + CNLabeledValue( + label: CNLabelHome, + value: address), + ] + } + + let address = STPAddress(cnContact: contact) + XCTAssertEqual("John Doe", address.name) + XCTAssertEqual("8885551212", address.phone) + XCTAssertEqual("foo@example.com", address.email) + XCTAssertEqual("55 John St", address.line1) + XCTAssertEqual("New York", address.city) + XCTAssertEqual("NY", address.state) + XCTAssertEqual("10002", address.postalCode) + XCTAssertEqual("US", address.country) + } + + func testInitWithCNContact_partial() { + let contact = CNMutableContact() + do { + contact.givenName = "John" + + let address = CNMutablePostalAddress() + address.state = "VA" + contact.postalAddresses = [ + CNLabeledValue( + label: CNLabelHome, + value: address), + ] + } + + let address = STPAddress(cnContact: contact) + XCTAssertEqual("John", address.name) + XCTAssertNil(address.phone) + XCTAssertNil(address.email) + XCTAssertNil(address.line1) + XCTAssertNil(address.city) + XCTAssertEqual("VA", address.state) + XCTAssertNil(address.postalCode) + XCTAssertNil(address.country) + } + + func testPKContactValue() { + let address = STPAddress() + address.name = "John Smith Doe" + address.phone = "8885551212" + address.email = "foo@example.com" + address.line1 = "55 John St" + address.city = "New York" + address.state = "NY" + address.postalCode = "10002" + address.country = "US" + + let contact = address.pkContactValue() + XCTAssertEqual(contact.name?.givenName, "John") + XCTAssertEqual(contact.name?.familyName, "Smith Doe") + XCTAssertEqual(contact.phoneNumber?.stringValue, "8885551212") + XCTAssertEqual(contact.emailAddress, "foo@example.com") + let postalAddress = contact.postalAddress + XCTAssertEqual(postalAddress?.street, "55 John St") + XCTAssertEqual(postalAddress?.city, "New York") + XCTAssertEqual(postalAddress?.state, "NY") + XCTAssertEqual(postalAddress?.postalCode, "10002") + XCTAssertEqual(postalAddress?.country, "US") + } + + func testContainsRequiredFieldsNone() { + let address = STPAddress() + XCTAssertTrue(address.containsRequiredFields(.none)) + address.line1 = "55 John St" + address.city = "New York" + address.state = "NY" + address.postalCode = "10002" + address.country = "US" + address.phone = "8885551212" + address.email = "foo@example.com" + address.name = "John Doe" + XCTAssertTrue(address.containsRequiredFields(.none)) + address.country = "UK" + XCTAssertTrue(address.containsRequiredFields(.none)) + } + + func testContainsRequiredFieldsZip() { + let address = STPAddress() + + // nil country is treated as generic postal requirement + XCTAssertFalse(address.containsRequiredFields(.postalCode)) + address.country = "IE" // should pass for country which doesn't require zip/postal + XCTAssertTrue(address.containsRequiredFields(.postalCode)) + address.country = "US" + XCTAssertFalse(address.containsRequiredFields(.postalCode)) + address.postalCode = "10002" + XCTAssertTrue(address.containsRequiredFields(.postalCode)) + address.postalCode = "ABCDE" + XCTAssertFalse(address.containsRequiredFields(.postalCode)) + address.country = "UK" // should pass for alphanumeric countries + XCTAssertTrue(address.containsRequiredFields(.postalCode)) + address.country = nil // nil treated as alphanumeric + XCTAssertTrue(address.containsRequiredFields(.postalCode)) + } + + func testContainsRequiredFieldsFull() { + let address = STPAddress() + + /// Required fields for full are: + /// line1, city, country, state (US only) and a valid postal code (based on country) + + XCTAssertFalse(address.containsRequiredFields(.full)) + address.country = "US" + address.line1 = "55 John St" + + // Fail on partial + XCTAssertFalse(address.containsRequiredFields(.full)) + + address.city = "New York" + + // For US fail if missing state or zip + XCTAssertFalse(address.containsRequiredFields(.full)) + address.state = "NY" + XCTAssertFalse(address.containsRequiredFields(.full)) + address.postalCode = "ABCDE" + XCTAssertFalse(address.containsRequiredFields(.full)) + // postal must be numeric for US + address.postalCode = "10002" + XCTAssertTrue(address.containsRequiredFields(.full)) + address.phone = "8885551212" + address.email = "foo@example.com" + address.name = "John Doe" + // Name/phone/email should have no effect + XCTAssertTrue(address.containsRequiredFields(.full)) + + // Non US countries don't require state + address.country = "UK" + XCTAssertTrue(address.containsRequiredFields(.full)) + address.state = nil + XCTAssertTrue(address.containsRequiredFields(.full)) + // alphanumeric postal ok in some countries + address.postalCode = "ABCDE" + XCTAssertTrue(address.containsRequiredFields(.full)) + // UK requires ZIP + address.postalCode = nil + XCTAssertFalse(address.containsRequiredFields(.full)) + + address.country = "IE" // Doesn't require postal or state, but allows them + XCTAssertTrue(address.containsRequiredFields(.full)) + address.postalCode = "ABCDE" + XCTAssertTrue(address.containsRequiredFields(.full)) + address.state = "Test" + XCTAssertTrue(address.containsRequiredFields(.full)) + } + + func testContainsRequiredFieldsName() { + let address = STPAddress() + + XCTAssertFalse(address.containsRequiredFields(.name)) + address.name = "Jane Doe" + XCTAssertTrue(address.containsRequiredFields(.name)) + } + + func testContainsContentForBillingAddressFields() { + var address = STPAddress() + + // Empty address should return false for everything + XCTAssertFalse(address.containsContent(for: .none)) + XCTAssertFalse(address.containsContent(for: .postalCode)) + XCTAssertFalse(address.containsContent(for: .full)) + XCTAssertFalse(address.containsContent(for: .name)) + + // 1+ characters in postalCode will return true for .PostalCode && .Full + address.postalCode = "0" + XCTAssertFalse(address.containsContent(for: .none)) + XCTAssertTrue(address.containsContent(for: .postalCode)) + XCTAssertTrue(address.containsContent(for: .full)) + // empty string returns false + address.postalCode = "" + XCTAssertFalse(address.containsContent(for: .none)) + XCTAssertFalse(address.containsContent(for: .postalCode)) + XCTAssertFalse(address.containsContent(for: .full)) + address.postalCode = nil + + // 1+ characters in name will return true for .Name + address.name = "Jane Doe" + XCTAssertTrue(address.containsContent(for: .name)) + // empty string returns false + address.name = "" + XCTAssertFalse(address.containsContent(for: .name)) + address.name = nil + + // Test every other property that contributes to the full address, ensuring it returns True for .Full only + // This is *not* refactoring-safe, but I think it's better than a bunch of duplicated code + for propertyName in ["line1", "line2", "city", "state", "country"] { + for testValue in ["a", "0", "Foo Bar"] { + address.setValue(testValue, forKey: propertyName) + XCTAssertFalse(address.containsContent(for: .none)) + XCTAssertFalse(address.containsContent(for: .postalCode)) + XCTAssertTrue(address.containsContent(for: .full)) + XCTAssertFalse(address.containsContent(for: .name)) + address.setValue(nil, forKey: propertyName) + } + + // Make sure that empty string is treated like nil, and returns false for these properties + address.setValue("", forKey: propertyName) + XCTAssertFalse(address.containsContent(for: .none)) + XCTAssertFalse(address.containsContent(for: .postalCode)) + XCTAssertFalse(address.containsContent(for: .full)) + XCTAssertFalse(address.containsContent(for: .name)) + address.setValue(nil, forKey: propertyName) + } + + // ensure it still returns false for everything since it has been cleared + XCTAssertFalse(address.containsContent(for: .none)) + XCTAssertFalse(address.containsContent(for: .postalCode)) + XCTAssertFalse(address.containsContent(for: .full)) + XCTAssertFalse(address.containsContent(for: .name)) + } + + func testContainsRequiredShippingAddressFields() { + let address = STPAddress() + XCTAssertTrue(address.containsRequiredShippingAddressFields(nil)) + let allFields = Set([ + STPContactField.postalAddress, + STPContactField.emailAddress, + STPContactField.phoneNumber, + STPContactField.name, + ]) as? Set + XCTAssertFalse(address.containsRequiredShippingAddressFields(allFields)) + + address.name = "John Smith" + XCTAssertTrue((address.containsRequiredShippingAddressFields(Set([STPContactField.name])))) + XCTAssertFalse((address.containsRequiredShippingAddressFields(Set([STPContactField.emailAddress])))) + + address.email = "john@example.com" + XCTAssertTrue((address.containsRequiredShippingAddressFields(Set([STPContactField.name, STPContactField.emailAddress])))) + XCTAssertFalse((address.containsRequiredShippingAddressFields(allFields))) + + address.phone = "5555555555" + XCTAssertTrue((address.containsRequiredShippingAddressFields(Set([ + STPContactField.name, + STPContactField.emailAddress, + STPContactField.phoneNumber, + ])))) + address.phone = "555" + XCTAssertFalse((address.containsRequiredShippingAddressFields(Set([ + STPContactField.name, + STPContactField.emailAddress, + STPContactField.phoneNumber, + ])))) + XCTAssertFalse((address.containsRequiredShippingAddressFields(allFields))) + address.country = "GB" + XCTAssertTrue((address.containsRequiredShippingAddressFields(Set([STPContactField.name, STPContactField.emailAddress])))) + address.phone = "5555555555" + XCTAssertTrue((address.containsRequiredShippingAddressFields(Set([ + STPContactField.name, + STPContactField.emailAddress, + STPContactField.phoneNumber, + ])))) + + address.country = "US" + address.phone = "5555555555" + address.line1 = "55 John St" + address.city = "New York" + address.state = "NY" + address.postalCode = "12345" + XCTAssertTrue(address.containsRequiredShippingAddressFields(allFields)) + } + + func testContainsContentForShippingAddressFields() { + var address = STPAddress() + + // Empty address should return false for everything + XCTAssertFalse((address.containsContent(forShippingAddressFields: nil))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.name])))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.phoneNumber])))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.emailAddress])))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.postalAddress])))) + + // Name + address.name = "Smith" + XCTAssertFalse((address.containsContent(forShippingAddressFields: nil))) + XCTAssertTrue((address.containsContent(forShippingAddressFields: Set([STPContactField.name])))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.phoneNumber])))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.emailAddress])))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.postalAddress])))) + address.name = "" + + // Phone + address.phone = "1" + XCTAssertFalse((address.containsContent(forShippingAddressFields: nil))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.name])))) + XCTAssertTrue((address.containsContent(forShippingAddressFields: Set([STPContactField.phoneNumber])))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.emailAddress])))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.postalAddress])))) + address.phone = "" + + // Email + address.email = "f" + XCTAssertFalse((address.containsContent(forShippingAddressFields: nil))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.name])))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.phoneNumber])))) + XCTAssertTrue((address.containsContent(forShippingAddressFields: Set([STPContactField.emailAddress])))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.postalAddress])))) + address.email = "" + + // Test every property that contributes to the full address + // This is *not* refactoring-safe, but I think it's better than a bunch more duplicated code + for propertyName in ["line1", "line2", "city", "state", "postalCode", "country"] { + for testValue in ["a", "0", "Foo Bar"] { + address.setValue(testValue, forKey: propertyName) + XCTAssertFalse((address.containsContent(forShippingAddressFields: nil))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.name])))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.phoneNumber])))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.emailAddress])))) + XCTAssertTrue((address.containsContent(forShippingAddressFields: Set([STPContactField.postalAddress])))) + address.setValue("", forKey: propertyName) + } + } + + // ensure it still returns false for everything with empty strings + XCTAssertFalse((address.containsContent(forShippingAddressFields: nil))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.name])))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.phoneNumber])))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.emailAddress])))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.postalAddress])))) + + // Try a hybrid address, and make sure some bitwise combinations work + address.name = "a" + address.phone = "1" + address.line1 = "_" + XCTAssertFalse((address.containsContent(forShippingAddressFields: nil))) + XCTAssertTrue((address.containsContent(forShippingAddressFields: Set([STPContactField.name])))) + XCTAssertTrue((address.containsContent(forShippingAddressFields: Set([STPContactField.phoneNumber])))) + XCTAssertFalse((address.containsContent(forShippingAddressFields: Set([STPContactField.emailAddress])))) + XCTAssertTrue((address.containsContent(forShippingAddressFields: Set([STPContactField.postalAddress])))) + + XCTAssertTrue((address.containsContent(forShippingAddressFields: Set([STPContactField.name, STPContactField.emailAddress])))) + XCTAssertTrue((address.containsContent(forShippingAddressFields: Set([STPContactField.phoneNumber, STPContactField.emailAddress])))) + XCTAssertTrue( + (address.containsContent( + forShippingAddressFields: Set([ + STPContactField.postalAddress, + STPContactField.emailAddress, + STPContactField.phoneNumber, + STPContactField.name, + ])))) + + } + + func testShippingInfoForCharge() { + let address = STPFixtures.address() + let method = PKShippingMethod() + method.label = "UPS Ground" + let info = STPAddress.shippingInfoForCharge( + with: address, + shippingMethod: method) as NSDictionary? + let expected: NSDictionary = [ + "address": [ + "city": address.city, + "country": address.country, + "line1": address.line1, + "line2": address.line2, + "postal_code": address.postalCode, + "state": address.state, + ], + "name": address.name, + "phone": address.phone, + "carrier": method.label, + ] + XCTAssertEqual(expected, info) + } + + // MARK: STPFormEncodable Tests + + func testRootObjectName() { + XCTAssertNil(STPAddress.rootObjectName()) + } + + func testPropertyNamesToFormFieldNamesMapping() { + let address = STPAddress() + + let mapping = STPAddress.propertyNamesToFormFieldNamesMapping() + + for propertyName in mapping.keys { + guard let propertyName = propertyName as? String else { + continue + } + XCTAssertFalse(propertyName.contains(":")) + XCTAssert(address.responds(to: NSSelectorFromString(propertyName))) + } + + for formFieldName in mapping.values { + XCTAssert(formFieldName.count > 0) + } + + XCTAssertEqual(mapping.values.count, mapping.values.count) + } + + // MARK: NSCopying Tests + + func testCopyWithZone() { + let address = STPFixtures.address() + let copiedAddress = address.copy() as! STPAddress + + XCTAssertNotEqual(address, copiedAddress, "should be different objects") + + // The property names we expect to *not* be equal objects + let notEqualProperties = [ + // these include the object's address, so they won't be the same across copies + "debugDescription", + "description", + "hash", + ] + // use runtime inspection to find the list of properties. If a new property is + // added to the fixture, but not the `copyWithZone:` implementation, this should catch it + for property in STPTestUtils.propertyNames(of: address) { + if notEqualProperties.contains(property) { + XCTAssertNotEqual( + address.value(forKey: property) as! NSObject, + copiedAddress.value(forKey: property) as! NSObject) + } else { + XCTAssertEqual( + address.value(forKey: property) as! NSObject, + copiedAddress.value(forKey: property) as! NSObject) + } + } + } +} diff --git a/Stripe/StripeiOSTests/STPAddressViewModelTest.swift b/Stripe/StripeiOSTests/STPAddressViewModelTest.swift new file mode 100644 index 00000000..429368e2 --- /dev/null +++ b/Stripe/StripeiOSTests/STPAddressViewModelTest.swift @@ -0,0 +1,205 @@ +// +// STPAddressViewModelTest.swift +// StripeiOS Tests +// +// Created by Ben Guo on 10/21/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPAddressViewModelTest: XCTestCase { + func testInitWithRequiredBillingFields() { + var sut = STPAddressViewModel(requiredBillingFields: .none, availableCountries: nil) + XCTAssertTrue(sut.addressCells.count == 0) + XCTAssertTrue(sut.isValid) + + sut = STPAddressViewModel(requiredBillingFields: .postalCode, availableCountries: nil) + XCTAssertTrue(sut.addressCells.count == 0) + + sut = STPAddressViewModel(requiredBillingFields: .full, availableCountries: nil) + XCTAssertTrue(sut.addressCells.count == 7) + let types: [STPAddressFieldType] = [ + .name, + .line1, + .line2, + .country, + .zip, + .city, + .state, + ] + for i in 0..(), + availableCountries: nil + ) + XCTAssertTrue(sut.addressCells.count == 0) + + sut = STPAddressViewModel( + requiredShippingFields: Set([.name]), + availableCountries: nil + ) + XCTAssertTrue(sut.addressCells.count == 1) + let cell1 = sut.addressCells[0] + XCTAssertEqual(cell1.type, .name) + + sut = STPAddressViewModel( + requiredShippingFields: Set([.name, .emailAddress]), + availableCountries: nil + ) + XCTAssertTrue(sut.addressCells.count == 2) + var types: [STPAddressFieldType] = [.name, .email] + for i in 0..([ + .postalAddress, .emailAddress, .phoneNumber, + ]), + availableCountries: nil + ) + XCTAssertTrue(sut.addressCells.count == 9) + types = [ + .email, + .name, + .line1, + .line2, + .country, + .zip, + .city, + .state, + .phone, + ] + for i in 0..([ + .postalAddress, + .emailAddress, + .phoneNumber, + ]), + availableCountries: nil + ) + sut.addressCells[0].contents = "foo@example.com" + sut.addressCells[1].contents = "John Smith" + sut.addressCells[2].contents = "55 John St" + sut.addressCells[3].contents = "#3B" + sut.addressCells[4].contents = "US" + sut.addressCells[5].contents = "10002" + sut.addressCells[6].contents = "New York" + sut.addressCells[7].contents = "NY" + sut.addressCells[8].contents = "555-555-5555" + + XCTAssertEqual(sut.address.email, "foo@example.com") + XCTAssertEqual(sut.address.name, "John Smith") + XCTAssertEqual(sut.address.line1, "55 John St") + XCTAssertEqual(sut.address.line2, "#3B") + XCTAssertEqual(sut.address.city, "New York") + XCTAssertEqual(sut.address.state, "NY") + XCTAssertEqual(sut.address.postalCode, "10002") + XCTAssertEqual(sut.address.country, "US") + XCTAssertEqual(sut.address.phone, "555-555-5555") + } + + func testSetAddress() { + let address = STPAddress() + address.email = "foo@example.com" + address.name = "John Smith" + address.line1 = "55 John St" + address.line2 = "#3B" + address.city = "New York" + address.state = "NY" + address.postalCode = "10002" + address.country = "US" + address.phone = "555-555-5555" + + let sut = STPAddressViewModel( + requiredShippingFields: Set([ + .postalAddress, + .emailAddress, + .phoneNumber, + ]), + availableCountries: nil + ) + sut.address = address + XCTAssertEqual(sut.addressCells[0].contents, "foo@example.com") + XCTAssertEqual(sut.addressCells[1].contents, "John Smith") + XCTAssertEqual(sut.addressCells[2].contents, "55 John St") + XCTAssertEqual(sut.addressCells[3].contents, "#3B") + XCTAssertEqual(sut.addressCells[4].contents, "US") + XCTAssertEqual(sut.addressCells[4].textField.text, "United States") + XCTAssertEqual(sut.addressCells[5].contents, "10002") + XCTAssertEqual(sut.addressCells[6].contents, "New York") + XCTAssertEqual(sut.addressCells[7].contents, "NY") + XCTAssertEqual(sut.addressCells[8].contents, "555-555-5555") + } + + func testIsValid_Zip() { + let sut = STPAddressViewModel(requiredBillingFields: .postalCode, availableCountries: nil) + + let address = STPAddress() + + address.country = "US" + sut.address = address + // The AddressViewModel shouldn't request any information when requesting ZIPs. + XCTAssertEqual(sut.addressCells.count, 0) + + address.postalCode = "94016" + sut.address = address + XCTAssertTrue(sut.isValid) + + address.country = "MO" // in Macao, postalCode is optional + address.postalCode = nil + sut.address = address + XCTAssertEqual(sut.addressCells.count, 0) + XCTAssertTrue(sut.isValid, "in Macao, postalCode is optional, valid without one") + } + + func testIsValid_Full() { + let sut = STPAddressViewModel(requiredBillingFields: .full, availableCountries: nil) + XCTAssertFalse(sut.isValid) + sut.addressCells[0].contents = "John Smith" + sut.addressCells[1].contents = "55 John St" + sut.addressCells[2].contents = "#3B" + XCTAssertFalse(sut.isValid) + sut.addressCells[3].contents = "10002" + sut.addressCells[4].contents = "New York" + sut.addressCells[5].contents = "NY" + sut.addressCells[6].contents = "US" + XCTAssertTrue(sut.isValid) + } + + func testIsValid_Name() { + let sut = STPAddressViewModel(requiredBillingFields: .name, availableCountries: nil) + + let address = STPAddress() + + address.name = "" + sut.address = address + XCTAssertEqual(sut.addressCells.count, 1) + XCTAssertFalse(sut.isValid) + + address.name = "Jane Doe" + sut.address = address + XCTAssertEqual(sut.addressCells.count, 1) + XCTAssertTrue(sut.isValid) + } +} diff --git a/Stripe/StripeiOSTests/STPAnalyticsClientPaymentSheetTest.swift b/Stripe/StripeiOSTests/STPAnalyticsClientPaymentSheetTest.swift new file mode 100644 index 00000000..0faa94d1 --- /dev/null +++ b/Stripe/StripeiOSTests/STPAnalyticsClientPaymentSheetTest.swift @@ -0,0 +1,277 @@ +// +// STPAnalyticsClientPaymentSheetTest.swift +// StripeiOS Tests +// +// Created by Mel Ludowise on 5/26/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet + +class STPAnalyticsClientPaymentSheetTest: XCTestCase { + private var client: STPAnalyticsClient! + + override func setUp() { + super.setUp() + client = STPAnalyticsClient() + } + + func testPaymentSheetInit() { + let customerConfig = PaymentSheet.CustomerConfiguration(id: "", ephemeralKeySecret: "") + let applePayConfig = PaymentSheet.ApplePayConfiguration( + merchantId: "", + merchantCountryCode: "" + ) + XCTAssertEqual( + client.paymentSheetInitEventValue( + isCustom: false, + configuration: makeConfig(applePay: nil, customer: nil) + ).rawValue, + "mc_complete_init_default" + ) + XCTAssertEqual( + client.paymentSheetInitEventValue( + isCustom: true, + configuration: makeConfig(applePay: nil, customer: nil) + ).rawValue, + "mc_custom_init_default" + ) + XCTAssertEqual( + client.paymentSheetInitEventValue( + isCustom: false, + configuration: makeConfig(applePay: applePayConfig, customer: nil) + ).rawValue, + "mc_complete_init_applepay" + ) + XCTAssertEqual( + client.paymentSheetInitEventValue( + isCustom: true, + configuration: makeConfig(applePay: applePayConfig, customer: nil) + ).rawValue, + "mc_custom_init_applepay" + ) + XCTAssertEqual( + client.paymentSheetInitEventValue( + isCustom: false, + configuration: makeConfig(applePay: nil, customer: customerConfig) + ).rawValue, + "mc_complete_init_customer" + ) + XCTAssertEqual( + client.paymentSheetInitEventValue( + isCustom: true, + configuration: makeConfig(applePay: nil, customer: customerConfig) + ).rawValue, + "mc_custom_init_customer" + ) + XCTAssertEqual( + client.paymentSheetInitEventValue( + isCustom: false, + configuration: makeConfig(applePay: applePayConfig, customer: customerConfig) + ).rawValue, + "mc_complete_init_customer_applepay" + ) + XCTAssertEqual( + client.paymentSheetInitEventValue( + isCustom: true, + configuration: makeConfig(applePay: applePayConfig, customer: customerConfig) + ).rawValue, + "mc_custom_init_customer_applepay" + ) + } + + func testPaymentSheetAddsUsage() { + let client = STPAnalyticsClient.sharedClient + _ = PaymentSheet( + paymentIntentClientSecret: "", + configuration: PaymentSheet.Configuration() + ) + XCTAssertTrue(client.productUsage.contains("PaymentSheet")) + + _ = PaymentSheet.FlowController( + intent: .paymentIntent(STPFixtures.paymentIntent()), + savedPaymentMethods: [], + isLinkEnabled: false, + configuration: PaymentSheet.Configuration() + ) + XCTAssertTrue(client.productUsage.contains("PaymentSheet.FlowController")) + } + + func testVariousPaymentSheetEvents() { + let client = STPTestingAnalyticsClient() + let event1 = XCTestExpectation(description: "mc_custom_sheet_newpm_show") + client.registerExpectation(event1) + client.logPaymentSheetShow( + isCustom: true, + paymentMethod: .newPM, + linkEnabled: false, + activeLinkSession: false, + currency: "USD" + ) + + let event2 = XCTestExpectation(description: "mc_complete_sheet_savedpm_show") + client.registerExpectation(event2) + client.logPaymentSheetShow( + isCustom: false, + paymentMethod: .savedPM, + linkEnabled: false, + activeLinkSession: false, + currency: "USD" + ) + + let event3 = XCTestExpectation(description: "mc_complete_payment_savedpm_success") + client.registerExpectation(event3) + client.logPaymentSheetPayment( + isCustom: false, + paymentMethod: .savedPM, + result: .completed, + linkEnabled: false, + activeLinkSession: false, + linkSessionType: .ephemeral, + currency: "USD", + deferredIntentConfirmationType: nil + ) + + let event4 = XCTestExpectation(description: "mc_custom_payment_applepay_failure") + client.registerExpectation(event4) + client.logPaymentSheetPayment( + isCustom: true, + paymentMethod: .applePay, + result: .failed(error: PaymentSheetError.unknown(debugDescription: "Error")), + linkEnabled: false, + activeLinkSession: false, + linkSessionType: .ephemeral, + currency: "USD", + deferredIntentConfirmationType: nil + ) + + let event5 = XCTestExpectation(description: "mc_custom_paymentoption_applepay_select") + client.registerExpectation(event5) + client.logPaymentSheetPaymentOptionSelect(isCustom: true, paymentMethod: .applePay) + + let event6 = XCTestExpectation(description: "mc_complete_paymentoption_newpm_select") + client.registerExpectation(event6) + client.logPaymentSheetPaymentOptionSelect(isCustom: false, paymentMethod: .newPM) + + wait( + for: [event1, event2, event3, event4, event5, event6], + timeout: STPTestingNetworkRequestTimeout + ) + } + + func testPaymentSheetAnalyticPayload() throws { + // setup + let analytic = PaymentSheetAnalytic( + event: STPAnalyticEvent.mcInitCompleteApplePay, + productUsage: Set([STPPaymentContext.stp_analyticsIdentifier]), + additionalParams: ["testKey": "testVal"] + ) + + let client = STPAnalyticsClient() + client.addAdditionalInfo("test-additional-info") + client.addClass(toProductUsageIfNecessary: STPPaymentContext.self) + + // test + let apiClient = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let payload = client.payload(from: analytic, apiClient: apiClient) + + // verify + XCTAssertEqual(16, payload.count) + XCTAssertNotNil(payload["device_type"] as? String) + XCTAssertEqual("Wi-Fi", payload["network_type"] as? String) + // In xctest, this is the version of Xcode + XCTAssertNotNil(payload["app_version"] as? String) + XCTAssertEqual("none", payload["ocr_type"] as? String) + XCTAssertEqual( + STPAnalyticEvent.mcInitCompleteApplePay.rawValue, + payload["event"] as? String + ) + XCTAssertEqual(STPTestingDefaultPublishableKey, payload["publishable_key"] as? String) + XCTAssertEqual("analytics.stripeios-1.0", payload["analytics_ua"] as? String) + XCTAssertEqual("xctest", payload["app_name"] as? String) + XCTAssertNotNil(payload["os_version"] as? String) + XCTAssertNil(payload["ui_usage_level"]) + XCTAssertTrue(payload["apple_pay_enabled"] as? Bool ?? false) + XCTAssertEqual("legacy", payload["pay_var"] as? String) + XCTAssertNil(payload["link_session_type"] as? String) + XCTAssertEqual(STPAPIClient.STPSDKVersion, payload["bindings_version"] as? String) + XCTAssertEqual("testVal", payload["testKey"] as? String) + XCTAssertEqual("X", payload["install"] as? String) + + let additionalInfo = try XCTUnwrap(payload["additional_info"] as? [String]) + XCTAssertEqual(1, additionalInfo.count) + XCTAssertEqual("test-additional-info", additionalInfo[0]) + + let productUsage = try XCTUnwrap(payload["product_usage"] as? [String]) + XCTAssertEqual(1, productUsage.count) + XCTAssertEqual(STPPaymentContext.stp_analyticsIdentifier, productUsage[0]) + } + + func testLogPaymentSheetPayment_shouldIncludeDuration() throws { + let client = STPTestingAnalyticsClient() + + client.logPaymentSheetShow( + isCustom: false, + paymentMethod: .newPM, + linkEnabled: false, + activeLinkSession: false, + currency: "USD" + ) + + client.logPaymentSheetPayment( + isCustom: false, + paymentMethod: .savedPM, + result: .completed, + linkEnabled: false, + activeLinkSession: false, + linkSessionType: .ephemeral, + currency: "USD", + deferredIntentConfirmationType: nil + ) + + let duration = client.lastPayload?["duration"] as? TimeInterval + XCTAssertNotNil(duration) + } +} + +// MARK: - Helpers + +extension STPAnalyticsClientPaymentSheetTest { + fileprivate func makeConfig( + applePay: PaymentSheet.ApplePayConfiguration?, + customer: PaymentSheet.CustomerConfiguration? + ) -> PaymentSheet.Configuration { + var config = PaymentSheet.Configuration() + config.applePay = applePay + config.customer = customer + return config + } +} + +// MARK: - Mock types + +private class STPTestingAnalyticsClient: STPAnalyticsClient { + var expectedEvents: [String: XCTestExpectation] = [:] + + var lastPayload: [String: Any]? + + func registerExpectation(_ expectation: XCTestExpectation) { + expectedEvents[expectation.description] = expectation + } + + override func logPayload(_ payload: [String: Any]) { + if let event = payload["event"] as? String, + let expectedEvent = expectedEvents[event] + { + expectedEvent.fulfill() + } + + lastPayload = payload + } +} diff --git a/Stripe/StripeiOSTests/STPAnalyticsClientPaymentsTest.swift b/Stripe/StripeiOSTests/STPAnalyticsClientPaymentsTest.swift new file mode 100644 index 00000000..3c1468ed --- /dev/null +++ b/Stripe/StripeiOSTests/STPAnalyticsClientPaymentsTest.swift @@ -0,0 +1,236 @@ +// +// STPAnalyticsClientPaymentsTest.swift +// StripeiOS Tests +// +// Created by Mel Ludowise on 5/26/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import StripeApplePay +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPAnalyticsClientPaymentsTest: XCTestCase { + private var client: STPAnalyticsClient! + + override func setUp() { + super.setUp() + client = STPAnalyticsClient() + } + + func testAdditionalInfo() { + XCTAssertEqual(client.additionalInfo(), []) + + // Add some info + client.addAdditionalInfo("hello") + client.addAdditionalInfo("i'm additional info") + client.addAdditionalInfo("how are you?") + + XCTAssertEqual(client.additionalInfo(), ["hello", "how are you?", "i'm additional info"]) + + // Clear it + client.clearAdditionalInfo() + XCTAssertEqual(client.additionalInfo(), []) + } + + func testPayloadFromAnalytic() throws { + client.addAdditionalInfo("test_additional_info") + + let mockAnalytic = MockAnalytic() + let payload = client.payload(from: mockAnalytic) + + XCTAssertEqual(payload.count, 14) + + // Verify event name is included + XCTAssertEqual(payload["event"] as? String, mockAnalytic.event.rawValue) + + // Verify additionalInfo is included + XCTAssertEqual(payload["additional_info"] as? [String], ["test_additional_info"]) + + // Verify all the analytic params are in the payload + XCTAssertEqual(payload["test_param1"] as? Int, 1) + XCTAssertEqual(payload["test_param2"] as? String, "two") + + // Verify productUsage is included + XCTAssertNotNil(payload["product_usage"]) + + // Verify install method is Xcode + XCTAssertEqual(payload["install"] as? String, "X") + } + + func testPayloadFromErrorAnalytic() throws { + client.addAdditionalInfo("test_additional_info") + + let mockAnalytic = MockErrorAnalytic() + let payload = client.payload(from: mockAnalytic) + + // Verify event name is included + XCTAssertEqual(payload["event"] as? String, mockAnalytic.event.rawValue) + + // Verify additionalInfo is included + XCTAssertEqual(payload["additional_info"] as? [String], ["test_additional_info"]) + + // Verify all the analytic params are in the payload + XCTAssertEqual(payload["test_param1"] as? Int, 1) + XCTAssertEqual(payload["test_param2"] as? String, "two") + + // Verify productUsage is included + XCTAssertNotNil(payload["product_usage"]) + + // Verify error_dictionary is included + let errorDict = try XCTUnwrap(payload["error_dictionary"] as? [String: Any]) + XCTAssertTrue( + NSDictionary(dictionary: errorDict).isEqual( + to: mockAnalytic.error.serializeForLogging() + ) + ) + } + + func testTokenTypeFromParameters() { + let card = STPFixtures.cardParams() + let cardDict = buildTokenParams(card) + XCTAssertEqual(STPAnalyticsClient.tokenType(fromParameters: cardDict), "card") + + let account = STPFixtures.accountParams() + let accountDict = buildTokenParams(account) + XCTAssertEqual(STPAnalyticsClient.tokenType(fromParameters: accountDict), "account") + + let bank = STPFixtures.bankAccountParams() + let bankDict = buildTokenParams(bank) + XCTAssertEqual(STPAnalyticsClient.tokenType(fromParameters: bankDict), "bank_account") + + let applePay = STPFixtures.applePayPayment() + let applePayDict = addTelemetry(applePay.stp_tokenParameters(apiClient: .shared)) + XCTAssertEqual(STPAnalyticsClient.tokenType(fromParameters: applePayDict), "apple_pay") + } + + // MARK: - Tests various classes report usage + + func testCardTextFieldAddsUsage() { + _ = STPPaymentCardTextField() + XCTAssertTrue( + STPAnalyticsClient.sharedClient.productUsage.contains("STPPaymentCardTextField") + ) + } + + func testPaymentContextAddsUsage() { + let keyManager = STPEphemeralKeyManager( + keyProvider: MockKeyProvider(), + apiVersion: "1", + performsEagerFetching: false + ) + let apiClient = STPAPIClient() + let customerContext = STPCustomerContext.init(keyManager: keyManager, apiClient: apiClient) + _ = STPPaymentContext(customerContext: customerContext) + XCTAssertTrue(STPAnalyticsClient.sharedClient.productUsage.contains("STPCustomerContext")) + } + + func testApplePayContextAddsUsage() { + _ = STPApplePayContext(paymentRequest: STPFixtures.applePayRequest(), delegate: nil) + XCTAssertTrue(STPAnalyticsClient.sharedClient.productUsage.contains("STPApplePayContext")) + } + + func testCustomerContextAddsUsage() { + let keyManager = STPEphemeralKeyManager( + keyProvider: MockKeyProvider(), + apiVersion: "1", + performsEagerFetching: false + ) + let apiClient = STPAPIClient() + _ = STPCustomerContext(keyManager: keyManager, apiClient: apiClient) + XCTAssertTrue(STPAnalyticsClient.sharedClient.productUsage.contains("STPCustomerContext")) + } + + func testAddCardVCAddsUsage() { + _ = STPAddCardViewController() + XCTAssertTrue( + STPAnalyticsClient.sharedClient.productUsage.contains("STPAddCardViewController") + ) + } + + func testBankSelectionVCAddsUsage() { + _ = STPBankSelectionViewController() + XCTAssertTrue( + STPAnalyticsClient.sharedClient.productUsage.contains("STPBankSelectionViewController") + ) + } + + func testShippingVCAddsUsage() { + let config = STPPaymentConfiguration() + config.requiredShippingAddressFields = [STPContactField.postalAddress] + _ = STPShippingAddressViewController( + configuration: config, + theme: .defaultTheme, + currency: nil, + shippingAddress: nil, + selectedShippingMethod: nil, + prefilledInformation: nil + ) + XCTAssertTrue( + STPAnalyticsClient.sharedClient.productUsage.contains( + "STPShippingAddressViewController" + ) + ) + } +} + +// MARK: - Helpers + +extension STPAnalyticsClientPaymentsTest { + fileprivate func buildTokenParams(_ object: T) -> [String: Any] + { + return addTelemetry(STPFormEncoder.dictionary(forObject: object)) + } + + fileprivate func addTelemetry(_ params: [String: Any]) -> [String: Any] { + // STPAPIClient adds these before determining the token type, + // so do the same in the test + return STPTelemetryClient.shared.paramsByAddingTelemetryFields(toParams: params) + } +} + +// MARK: - Mock types + +private struct MockAnalytic: Analytic { + let event = STPAnalyticEvent.sourceCreation + + let params: [String: Any] = [ + "test_param1": 1, + "test_param2": "two", + ] +} + +private struct MockErrorAnalytic: ErrorAnalytic { + let event = STPAnalyticEvent.sourceCreation + + let params: [String: Any] = [ + "test_param1": 1, + "test_param2": "two", + ] + + let error: Error = NSError(domain: "domain", code: 100, userInfo: nil) +} + +private struct MockAnalyticsClass1: STPAnalyticsProtocol { + static let stp_analyticsIdentifier = "MockAnalyticsClass1" +} + +private struct MockAnalyticsClass2: STPAnalyticsProtocol { + static let stp_analyticsIdentifier = "MockAnalyticsClass2" +} + +private class MockKeyProvider: NSObject, STPCustomerEphemeralKeyProvider { + func createCustomerKey( + withAPIVersion apiVersion: String, + completion: @escaping STPJSONResponseCompletionBlock + ) { + guard apiVersion == "1" else { return } + + completion(nil, NSError.stp_genericConnectionError()) + } +} diff --git a/Stripe/StripeiOSTests/STPApplePayContextFunctionalTest.swift b/Stripe/StripeiOSTests/STPApplePayContextFunctionalTest.swift new file mode 100644 index 00000000..050c6c0a --- /dev/null +++ b/Stripe/StripeiOSTests/STPApplePayContextFunctionalTest.swift @@ -0,0 +1,359 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPApplePayContextFunctionalTest.m +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 3/5/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import OHHTTPStubs +@testable import Stripe +@testable import StripeApplePay +@testable import StripeCoreTestUtils +@testable import StripePayments +@testable import StripePaymentsObjcTestUtils + +class STPTestApplePayContextDelegate: NSObject, STPApplePayContextDelegate { + func applePayContext(_ context: StripeApplePay.STPApplePayContext, didCreatePaymentMethod paymentMethod: StripePayments.STPPaymentMethod, paymentInformation: PKPayment, completion: @escaping StripeApplePay.STPIntentClientSecretCompletionBlock) { + didCreatePaymentMethodDelegateMethod!(paymentMethod, paymentInformation, completion) + } + + func applePayContext(_ context: StripeApplePay.STPApplePayContext, didCompleteWith status: StripePayments.STPPaymentStatus, error: Error?) { + didCompleteDelegateMethod!(status, error) + } + + var didCompleteDelegateMethod: ((_ status: STPPaymentStatus, _ error: Error?) -> Void)? + var didCreatePaymentMethodDelegateMethod: ((_ paymentMethod: STPPaymentMethod?, _ paymentInformation: PKPayment?, _ completion: @escaping STPIntentClientSecretCompletionBlock) -> Void)? +} + +class STPApplePayContextFunctionalTest: XCTestCase { + var apiClient: STPApplePayContextFunctionalTestAPIClient! + var delegate: STPTestApplePayContextDelegate! + var context: STPApplePayContext! + + override func setUp() { + delegate = STPTestApplePayContextDelegate() + let apiClient = STPApplePayContextFunctionalTestAPIClient(publishableKey: STPTestingDefaultPublishableKey) + apiClient.setupStubs() + apiClient.applePayContext = context + self.apiClient = apiClient + + context = STPApplePayContext(paymentRequest: STPFixtures.applePayRequest(), delegate: delegate) + self.apiClient.applePayContext = context + context?.apiClient = self.apiClient + context?.authorizationController = STPTestPKPaymentAuthorizationController() + } + + override func tearDown() { + HTTPStubs.removeAllStubs() + } + + func testCompletesManualConfirmationPaymentIntent() { + var clientSecret: String? + // A manual confirmation PI confirmed server-side... + let delegate = self.delegate + delegate?.didCreatePaymentMethodDelegateMethod = { paymentMethod, paymentInformation, completion in + XCTAssertNotNil(paymentInformation) + if let stripeId = paymentMethod?.stripeId { + STPTestingAPIClient.shared.createPaymentIntent(withParams: [ + "confirmation_method": "manual", + "payment_method": stripeId, + "confirm": NSNumber(value: true), + ]) { _clientSecret, _ in + XCTAssertNotNil(_clientSecret) + clientSecret = _clientSecret + completion(clientSecret, nil) + } + } + } + + // ...used with ApplePayContext + let context = STPApplePayContext(paymentRequest: STPFixtures.applePayRequest(), delegate: self.delegate)! + context.apiClient = apiClient + _startApplePayForContext(withExpectedStatus: .success) + + // ...calls applePayContext:didCompleteWithStatus:error: + let didCallCompletion = expectation(description: "applePayContext:didCompleteWithStatus: called") + delegate?.didCompleteDelegateMethod = { [self] status, error in + XCTAssertEqual(status, .success) + XCTAssertNil(error) + + // ...and results in a successful PI + apiClient?.retrievePaymentIntent(withClientSecret: clientSecret!) { paymentIntent, paymentIntentRetrieveError in + XCTAssertNil(paymentIntentRetrieveError) + XCTAssert(paymentIntent?.status == .succeeded) + didCallCompletion.fulfill() + } + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCompletesAutomaticConfirmationPaymentIntent() { + var clientSecret: String? + // An automatic confirmation PI with the PaymentMethod attached... + let delegate = self.delegate + delegate?.didCreatePaymentMethodDelegateMethod = { _, _, completion in + STPTestingAPIClient.shared.createPaymentIntent(withParams: nil) { newClientSecret, _ in + clientSecret = newClientSecret + completion(newClientSecret, nil) + } + } + + // ...used with ApplePayContext + _startApplePayForContext(withExpectedStatus: .success) + + // ...calls applePayContext:didCompleteWithStatus:error: + let didCallCompletion = expectation(description: "applePayContext:didCompleteWithStatus: called") + delegate?.didCompleteDelegateMethod = { [self] status, error in + XCTAssertEqual(status, .success) + XCTAssertNil(error) + + // ...and results in a successful PI + apiClient?.retrievePaymentIntent(withClientSecret: clientSecret!) { paymentIntent, paymentIntentRetrieveError in + XCTAssertNil(paymentIntentRetrieveError) + XCTAssert(paymentIntent?.status == .succeeded) + XCTAssertEqual(paymentIntent?.shipping?.name, "Jane Doe") + XCTAssertEqual(paymentIntent?.shipping?.address?.line1, "510 Townsend St") + didCallCompletion.fulfill() + } + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCompletesAutomaticConfirmationPaymentIntentManualCapture() { + var clientSecret: String? + // An automatic confirmation PI with the PaymentMethod attached... + let delegate = self.delegate + delegate?.didCreatePaymentMethodDelegateMethod = { _, _, completion in + STPTestingAPIClient.shared.createPaymentIntent(withParams: ["capture_method": "manual"]) { newClientSecret, _ in + clientSecret = newClientSecret + completion(newClientSecret, nil) + } + } + + // ...used with ApplePayContext + _startApplePayForContext(withExpectedStatus: .success) + + // ...calls applePayContext:didCompleteWithStatus:error: + let didCallCompletion = expectation(description: "applePayContext:didCompleteWithStatus: called") + delegate?.didCompleteDelegateMethod = { [self] status, error in + XCTAssertEqual(status, .success) + XCTAssertNil(error) + + // ...and results in a successful PI + apiClient?.retrievePaymentIntent(withClientSecret: clientSecret!) { paymentIntent, paymentIntentRetrieveError in + XCTAssertNil(paymentIntentRetrieveError) + XCTAssert(paymentIntent?.status == .requiresCapture) + didCallCompletion.fulfill() + } + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCompletesSetupIntent() { + var clientSecret: String? + // An automatic confirmation SI... + let delegate = self.delegate + delegate?.didCreatePaymentMethodDelegateMethod = { _, _, completion in + STPTestingAPIClient.shared.createSetupIntent(withParams: nil) { newClientSecret, _ in + clientSecret = newClientSecret + completion(newClientSecret, nil) + } + } + + // ...used with ApplePayContext + _startApplePayForContext(withExpectedStatus: .success) + + // ...calls applePayContext:didCompleteWithStatus:error: + let didCallCompletion = expectation(description: "applePayContext:didCompleteWithStatus: called") + delegate?.didCompleteDelegateMethod = { [self] status, error in + XCTAssertEqual(status, .success) + XCTAssertNil(error) + + // ...and results in a successful PI + apiClient?.retrieveSetupIntent(withClientSecret: clientSecret!) { setupIntent, setupIntentRetrieveError in + XCTAssertNil(setupIntentRetrieveError) + XCTAssert(setupIntent?.status == .succeeded) + didCallCompletion.fulfill() + } + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: - Error tests + + func testBadPaymentIntentClientSecretErrors() { + var clientSecret: String? + // An invalid PaymentIntent client secret... + let delegate = self.delegate + delegate?.didCreatePaymentMethodDelegateMethod = { _, _, completion in + DispatchQueue.main.async { + clientSecret = "pi_bad_secret_1234" + completion(clientSecret, nil) + } + } + + // ...used with ApplePayContext + _startApplePayForContext(withExpectedStatus: .failure) + + // ...calls applePayContext:didCompleteWithStatus:error: + let didCallCompletion = expectation(description: "applePayContext:didCompleteWithStatus: called") + delegate?.didCompleteDelegateMethod = { status, error in + // ...and results in an error + XCTAssertEqual(status, .error) + XCTAssertNotNil(error) + XCTAssertEqual((error as NSError?)?.domain, STPError.stripeDomain) + didCallCompletion.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testBadSetupIntentClientSecretErrors() { + var clientSecret: String? + // An invalid SetupIntent client secret... + let delegate = self.delegate + delegate?.didCreatePaymentMethodDelegateMethod = { _, _, completion in + DispatchQueue.main.async { + clientSecret = "seti_bad_secret_1234" + completion(clientSecret, nil) + } + } + + // ...used with ApplePayContext + _startApplePayForContext(withExpectedStatus: .failure) + + // ...calls applePayContext:didCompleteWithStatus:error: + let didCallCompletion = expectation(description: "applePayContext:didCompleteWithStatus: called") + delegate?.didCompleteDelegateMethod = { status, error in + // ...and results in an error + XCTAssertEqual(status, .error) + XCTAssertNotNil(error) + XCTAssertEqual((error as NSError?)?.domain, STPError.stripeDomain) + didCallCompletion.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: - Cancel tests + + func testCancelBeforeIntentConfirmsCancels() { + // Cancelling Apple Pay *before* the context attempts to confirms the PI/SI... + let delegate = self.delegate + delegate?.didCreatePaymentMethodDelegateMethod = { _, _, completion in + self.context.paymentAuthorizationControllerDidFinish(self.context.authorizationController!) // Simulate cancel before passing PI to the context + // ...should never retrieve the PI (b/c it is cancelled before) + completion("A 'client secret' that triggers an exception if fetched", nil) + } + // Simulate user tapping 'Pay' button in Apple Pay + self.context.paymentAuthorizationController(self.context.authorizationController!, didAuthorizePayment: STPFixtures.simulatorApplePayPayment()) { _ in } + + // ...calls applePayContext:didCompleteWithStatus:error: + let didCallCompletion = expectation(description: "applePayContext:didCompleteWithStatus: called") + delegate?.didCompleteDelegateMethod = { status, error in + // ...and results in a 'user cancel' status + XCTAssertEqual(status, .userCancellation) + XCTAssertNil(error) + didCallCompletion.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCancelAfterPaymentIntentConfirmsStillSucceeds() { + // Cancelling Apple Pay *after* the context attempts to confirm the PI... + apiClient?.shouldSimulateCancelAfterConfirmBegins = true + + var clientSecret: String? + let delegate = self.delegate + delegate?.didCreatePaymentMethodDelegateMethod = { _, _, completion in + STPTestingAPIClient.shared.createPaymentIntent(withParams: nil) { newClientSecret, _ in + clientSecret = newClientSecret + completion(newClientSecret, nil) + } + } + // Simulate user tapping 'Pay' button in Apple Pay + self.context.paymentAuthorizationController(self.context.authorizationController!, didAuthorizePayment: STPFixtures.simulatorApplePayPayment()) { _ in } + + // ...calls applePayContext:didCompleteWithStatus:error: + let didCallCompletion = expectation(description: "applePayContext:didCompleteWithStatus: called") + delegate?.didCompleteDelegateMethod = { [self] status, error in + XCTAssertEqual(status, .success) + XCTAssertNil(error) + + // ...and results in a successful PI + apiClient?.retrievePaymentIntent(withClientSecret: clientSecret!) { paymentIntent, paymentIntentRetrieveError in + XCTAssertNil(paymentIntentRetrieveError) + XCTAssert(paymentIntent?.status == .succeeded) + didCallCompletion.fulfill() + } + } + + waitForExpectations(timeout: 20.0, handler: nil) // give this a longer timeout, it tends to take a while + } + + func testCancelAfterSetupIntentConfirmsStillSucceeds() { + // Cancelling Apple Pay *after* the context attempts to confirm the SI... + apiClient?.shouldSimulateCancelAfterConfirmBegins = true + + var clientSecret: String? + let delegate = self.delegate + delegate?.didCreatePaymentMethodDelegateMethod = { _, _, completion in + STPTestingAPIClient.shared.createSetupIntent(withParams: nil) { newClientSecret, _ in + clientSecret = newClientSecret + completion(newClientSecret, nil) + } + } + // Simulate user tapping 'Pay' button in Apple Pay + self.context.paymentAuthorizationController(self.context.authorizationController!, didAuthorizePayment: STPFixtures.simulatorApplePayPayment()) { _ in } + + // ...calls applePayContext:didCompleteWithStatus:error: + let didCallCompletion = expectation(description: "applePayContext:didCompleteWithStatus: called") + delegate?.didCompleteDelegateMethod = { [self] status, error in + XCTAssertEqual(status, .success) + XCTAssertNil(error) + + // ...and results in a successful SI + apiClient?.retrieveSetupIntent(withClientSecret: clientSecret!) { setupIntent, setupIntentRetrieveError in + XCTAssertNil(setupIntentRetrieveError) + XCTAssert(setupIntent?.status == .succeeded) + didCallCompletion.fulfill() + } + } + + waitForExpectations(timeout: 20.0, handler: nil) // give this a longer timeout, it tends to take a while + } + + // MARK: - Helper + + /// Simulates user tapping 'Pay' button in Apple Pay sheet + func _startApplePayForContext(withExpectedStatus expectedStatus: PKPaymentAuthorizationStatus) { + // When the user taps 'Pay', PKPaymentAuthorizationController calls `didAuthorizePayment:completion:` + // After you call its completion block, it calls `paymentAuthorizationControllerDidFinish:` + let didCallAuthorizePaymentCompletion = expectation(description: "ApplePayContext called completion block of paymentAuthorizationController:didAuthorizePayment:completion:") + if let authorizationController = context?.authorizationController { + context?.paymentAuthorizationController(authorizationController, didAuthorizePayment: STPFixtures.simulatorApplePayPayment(), handler: { [self] result in + XCTAssertEqual(expectedStatus, result.status) + DispatchQueue.main.async(execute: { [self] in + if let authorizationController = context?.authorizationController { + context?.paymentAuthorizationControllerDidFinish(authorizationController) + } + didCallAuthorizePaymentCompletion.fulfill() + }) + }) + } + } +} + +class STPTestPKPaymentAuthorizationController: PKPaymentAuthorizationController { + // Stub dismissViewControllerAnimated: to just call its completion block + override func dismiss(completion: (() -> Void)? = nil) { + completion?() + } +} diff --git a/Stripe/StripeiOSTests/STPApplePayContextFunctionalTestExtras.swift b/Stripe/StripeiOSTests/STPApplePayContextFunctionalTestExtras.swift new file mode 100644 index 00000000..15a7394b --- /dev/null +++ b/Stripe/StripeiOSTests/STPApplePayContextFunctionalTestExtras.swift @@ -0,0 +1,44 @@ +// +// STPApplePayContextFunctionalTestExtras.swift +// StripeiOS Tests +// +// Created by David Estes on 3/23/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import OHHTTPStubs +import OHHTTPStubsSwift + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeApplePay +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPApplePayContextFunctionalTestAPIClient: STPAPIClient { + @objc var applePayContext: STPApplePayContext? + @objc var shouldSimulateCancelAfterConfirmBegins: Bool = false + + @objc func setupStubs() { + stub { urlRequest in + // Hook SetupIntent or PaymentIntent confirmation + if let urlString = urlRequest.url?.absoluteString, + urlString.contains("_intents/"), + urlString.hasSuffix("/confirm") + { + if self.shouldSimulateCancelAfterConfirmBegins { + self.applePayContext!.paymentAuthorizationControllerDidFinish( + self.applePayContext!.authorizationController! + ) + } + } + // Let everything pass through to the underlying API + return false + } response: { _ in + // This doesn't matter, we're not sending responses for anything. + return HTTPStubsResponse() + } + } +} diff --git a/Stripe/StripeiOSTests/STPApplePayContextTest.swift b/Stripe/StripeiOSTests/STPApplePayContextTest.swift new file mode 100644 index 00000000..21bcbc49 --- /dev/null +++ b/Stripe/StripeiOSTests/STPApplePayContextTest.swift @@ -0,0 +1,163 @@ +// +// STPApplePayContextTest.swift +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 2/20/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripePayments + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeApplePay +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPApplePayTestDelegateiOS11: NSObject, STPApplePayContextDelegate { + func applePayContext( + _ context: STPApplePayContext, + didSelectShippingContact contact: PKContact, + handler completion: @escaping (PKPaymentRequestShippingContactUpdate) -> Void + ) { + completion(PKPaymentRequestShippingContactUpdate()) + } + + func applePayContext( + _ context: STPApplePayContext, + didSelect shippingMethod: PKShippingMethod, + handler completion: @escaping (PKPaymentRequestShippingMethodUpdate) -> Void + ) { + completion(PKPaymentRequestShippingMethodUpdate()) + } + + func applePayContext( + _ context: STPApplePayContext, + didCompleteWith status: STPPaymentStatus, + error: Error? + ) { + } + + func applePayContext( + _ context: STPApplePayContext, + didCreatePaymentMethod paymentMethod: STPPaymentMethod, + paymentInformation: PKPayment, + completion: STPIntentClientSecretCompletionBlock + ) { + } +} + +// MARK: - STPApplePayTestDelegateiOS11 +class STPApplePayContextTest: XCTestCase { + func testiOS11ApplePayDelegateMethodsForwarded() { + // With a user that only implements iOS 11 delegate methods... + let delegate = STPApplePayTestDelegateiOS11() + let request = StripeAPI.paymentRequest( + withMerchantIdentifier: "foo", + country: "US", + currency: "USD" + ) + request.paymentSummaryItems = [ + PKPaymentSummaryItem(label: "bar", amount: NSDecimalNumber(string: "1.00")) + ] + let context = STPApplePayContext(paymentRequest: request, delegate: delegate)! + + // ...the context should respondToSelector appropriately... + XCTAssertTrue( + context.responds( + to: #selector( + PKPaymentAuthorizationControllerDelegate.paymentAuthorizationController( + _: + didSelectShippingContact: + handler: + )) + ) + ) + XCTAssertFalse( + context.responds( + to: #selector( + PKPaymentAuthorizationControllerDelegate.paymentAuthorizationController( + _: + didSelectShippingContact: + completion: + )) + ) + ) + + // ...and forward the PassKit delegate method to its delegate + let vc: PKPaymentAuthorizationController = PKPaymentAuthorizationController() + let contact = PKContact() + let shippingContactExpectation = expectation( + description: "didSelectShippingContact forwarded" + ) + context.paymentAuthorizationController( + vc, + didSelectShippingContact: contact, + handler: { _ in + shippingContactExpectation.fulfill() + } + ) + + let method = PKShippingMethod() + let shippingMethodExpectation = expectation( + description: "didSelectShippingMethod forwarded" + ) + context.paymentAuthorizationController( + vc, + didSelectShippingMethod: method, + handler: { _ in + shippingMethodExpectation.fulfill() + } + ) + waitForExpectations(timeout: 2, handler: nil) + } + + func testConvertsShippingDetails() { + let delegate = STPApplePayTestDelegateiOS11() + let request = StripeAPI.paymentRequest( + withMerchantIdentifier: "foo", + country: "US", + currency: "USD" + ) + request.paymentSummaryItems = [ + PKPaymentSummaryItem(label: "bar", amount: NSDecimalNumber(string: "1.00")) + ] + let context = STPApplePayContext(paymentRequest: request, delegate: delegate) + + let payment = STPFixtures.simulatorApplePayPayment() + let shipping = PKContact() + shipping.name = PersonNameComponentsFormatter().personNameComponents(from: "Jane Doe") + shipping.phoneNumber = CNPhoneNumber(stringValue: "555-555-5555") + let address = CNMutablePostalAddress() + address.street = "510 Townsend St" + address.city = "San Francisco" + address.state = "CA" + address.isoCountryCode = "US" + address.postalCode = "94105" + shipping.postalAddress = address + payment.perform(#selector(setter: PKPaymentRequest.shippingContact), with: shipping) + + let shippingParams = context!._shippingDetails(from: payment) + XCTAssertNotNil(shippingParams) + XCTAssertEqual(shippingParams?.name, "Jane Doe") + XCTAssertNil(shippingParams?.carrier) + XCTAssertEqual(shippingParams?.phone, "555-555-5555") + XCTAssertNil(shippingParams?.trackingNumber) + + XCTAssertEqual(shippingParams?.address.line1, "510 Townsend St") + XCTAssertNil(shippingParams?.address.line2) + XCTAssertEqual(shippingParams?.address.city, "San Francisco") + XCTAssertEqual(shippingParams?.address.state, "CA") + XCTAssertEqual(shippingParams?.address.country, "US") + XCTAssertEqual(shippingParams?.address.postalCode, "94105") + } + + // Tests stp_tokenParameters in StripeApplePay, not StripePayments + func testStpTokenParameters() { + let applePay = STPFixtures.applePayPayment() + let applePayDict = applePay.stp_tokenParameters(apiClient: .shared) + XCTAssertNotNil(applePayDict["pk_token"]) + XCTAssertEqual((applePayDict["card"] as! NSDictionary)["name"] as! String, "Test Testerson") + XCTAssertEqual(applePayDict["pk_token_instrument_name"] as! String, "Master Charge") + } +} diff --git a/Stripe/StripeiOSTests/STPApplePayFunctionalTest.swift b/Stripe/StripeiOSTests/STPApplePayFunctionalTest.swift new file mode 100644 index 00000000..3a65a0eb --- /dev/null +++ b/Stripe/StripeiOSTests/STPApplePayFunctionalTest.swift @@ -0,0 +1,111 @@ +// +// STPApplePayFunctionalTest.swift +// StripeiOS Tests +// +// Created by Jack Flintermann on 12/21/14. +// Copyright (c) 2014 Stripe, Inc. All rights reserved. +// + +import PassKit +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP)@_spi(StripeApplePayTokenization) import StripeApplePay +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable import StripePaymentsTestUtils +@testable@_spi(STP) import StripePaymentsUI + +class STPApplePayFunctionalTest: STPNetworkStubbingTestCase { + override func setUp() { + // self.recordingMode = YES; + super.setUp() + } + + // TODO: regenerate these fixtures with a fresh/real PKPayment + func testCreateTokenWithPaymentClassic() { + let payment = STPFixtures.applePayPayment() + let client = STPAPIClient(publishableKey: "pk_test_vOo1umqsYxSrP5UXfOeL3ecm") + + let expectation = self.expectation(description: "Apple pay token creation") + client.createToken( + with: payment + ) { token, error in + expectation.fulfill() + XCTAssertNil(token, "token should be nil") + guard let error = error else { + XCTFail("error should not be nil") + return + } + + // Since we can't actually generate a new cryptogram in a CI environment, we should just post a blob of expired token data and + // make sure we get the "too long since tokenization" error. This at least asserts that our blob has been correctly formatted and + // can be decrypted by the backend. + XCTAssert( + ((error as NSError).userInfo[STPError.errorMessageKey] as? NSString)?.range( + of: "too long" + ).location != NSNotFound, + "Error is unrelated to 24-hour expiry: \(error)" + ) + } + waitForExpectations(timeout: 5.0, handler: nil) + } + + func testCreateTokenWithPayment() { + let payment = STPFixtures.applePayPayment() + let client = STPAPIClient(publishableKey: "pk_test_vOo1umqsYxSrP5UXfOeL3ecm") + + let expectation = self.expectation(description: "Apple pay token creation") + StripeAPI.Token.create( + apiClient: client, + payment: payment + ) { result in + expectation.fulfill() + XCTAssertNil(try? result.get(), "token should be nil") + guard case .failure(let error) = result else { + XCTFail("error should not be nil") + return + } + + // Since we can't actually generate a new cryptogram in a CI environment, we should just post a blob of expired token data and + // make sure we get the "too long since tokenization" error. This at least asserts that our blob has been correctly formatted and + // can be decrypted by the backend. + XCTAssert( + ((error as NSError).userInfo[STPError.errorMessageKey] as? NSString)?.range( + of: "too long" + ).location != NSNotFound, + "Error is unrelated to 24-hour expiry: \(error)" + ) + } + waitForExpectations(timeout: 5.0, handler: nil) + } + + func testCreateSourceWithPayment() { + let payment = STPFixtures.applePayPayment() + let client = STPAPIClient(publishableKey: "pk_test_vOo1umqsYxSrP5UXfOeL3ecm") + + let expectation = self.expectation(description: "Apple pay source creation") + client.createSource( + with: payment + ) { source, error in + expectation.fulfill() + XCTAssertNil(source, "token should be nil") + guard let error = error else { + XCTFail("error should not be nil") + return + } + + // Since we can't actually generate a new cryptogram in a CI environment, we should just post a blob of expired token data and + // make sure we get the "too long since tokenization" error. This at least asserts that our blob has been correctly formatted and + // can be decrypted by the backend. + XCTAssert( + ((error as NSError).userInfo[STPError.errorMessageKey] as? NSString)?.range( + of: "too long" + ).location != NSNotFound, + "Error is unrelated to 24-hour expiry: \(error)" + ) + } + waitForExpectations(timeout: 5.0, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPApplePayPaymentOptionTest.swift b/Stripe/StripeiOSTests/STPApplePayPaymentOptionTest.swift new file mode 100644 index 00000000..b0893b29 --- /dev/null +++ b/Stripe/StripeiOSTests/STPApplePayPaymentOptionTest.swift @@ -0,0 +1,40 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPApplePayPaymentOptionTest.m +// Stripe +// +// Created by Joey Dong on 7/28/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +class STPApplePayPaymentOptionTest: XCTestCase { + // MARK: - STPPaymentOption Tests + + func testImage() { + let applePay = STPApplePayPaymentOption() + XCTAssertNotNil(applePay.image) + } + + func testTemplateImage() { + let applePay = STPApplePayPaymentOption() + XCTAssertNotNil(applePay.templateImage) + } + + func testLabel() { + let applePay = STPApplePayPaymentOption() + XCTAssertEqual(applePay.label, "Apple Pay") + } + + // MARK: - Equality Tests + + func testApplePayEquals() { + let applePay1 = STPApplePayPaymentOption() + let applePay2 = STPApplePayPaymentOption() + + XCTAssertEqual(applePay1, applePay1) + XCTAssertEqual(applePay1, applePay2) + + XCTAssertEqual(applePay1.hash, applePay1.hash) + XCTAssertEqual(applePay1.hash, applePay2.hash) + } +} diff --git a/Stripe/StripeiOSTests/STPApplePayTest.swift b/Stripe/StripeiOSTests/STPApplePayTest.swift new file mode 100644 index 00000000..312ae125 --- /dev/null +++ b/Stripe/StripeiOSTests/STPApplePayTest.swift @@ -0,0 +1,80 @@ +// +// STPApplePayTest.swift +// StripeiOS Tests +// +// Created by David Estes on 9/21/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPApplePaySwiftTest: XCTestCase { + func testAdditionalPaymentNetwork() { + XCTAssertFalse(StripeAPI.supportedPKPaymentNetworks().contains(.JCB)) + StripeAPI.additionalEnabledApplePayNetworks = [.JCB] + XCTAssertTrue(StripeAPI.supportedPKPaymentNetworks().contains(.JCB)) + StripeAPI.additionalEnabledApplePayNetworks = [] + } + + // Tests stp_tokenParameters in StripePayments, not StripeApplePay + func testStpTokenParameters() { + let applePay = STPFixtures.applePayPayment() + let applePayDict = applePay.stp_tokenParameters(apiClient: .shared) + XCTAssertNotNil(applePayDict["pk_token"]) + XCTAssertEqual((applePayDict["card"] as! NSDictionary)["name"] as! String, "Test Testerson") + XCTAssertEqual(applePayDict["pk_token_instrument_name"] as! String, "Master Charge") + } + + func testPaymentRequestWithMerchantIdentifierCountryCurrency() { + let paymentRequest = StripeAPI.paymentRequest(withMerchantIdentifier: "foo", country: "GB", currency: "GBP") + XCTAssertEqual(paymentRequest.merchantIdentifier, "foo") + let expectedNetworks = Set([ + .amex, + .masterCard, + .visa, + .discover, + .maestro, + ]) + XCTAssertEqual(Set(paymentRequest.supportedNetworks), expectedNetworks) + XCTAssertEqual(paymentRequest.merchantCapabilities, PKMerchantCapability.capability3DS) + XCTAssertEqual(paymentRequest.countryCode, "GB") + XCTAssertEqual(paymentRequest.currencyCode, "GBP") + XCTAssertEqual(paymentRequest.requiredBillingContactFields, Set([.postalAddress])) + } + + func testCanSubmitPaymentRequestReturnsYES() { + let request = PKPaymentRequest() + request.merchantIdentifier = "foo" + request.paymentSummaryItems = [ + PKPaymentSummaryItem(label: "bar", amount: NSDecimalNumber(string: "1.00")) + ] + + XCTAssertTrue(StripeAPI.canSubmitPaymentRequest(request)) + } + + func testCanSubmitPaymentRequestIfTotalIsZero() { + let request = PKPaymentRequest() + request.merchantIdentifier = "foo" + request.paymentSummaryItems = [ + PKPaymentSummaryItem(label: "bar", amount: NSDecimalNumber(string: "0.00")) + ] + + XCTAssertTrue(StripeAPI.canSubmitPaymentRequest(request)) + } + + func testCanSubmitPaymentRequestReturnsNOIfMerchantIdentifierIsNil() { + let request = PKPaymentRequest() + request.paymentSummaryItems = [ + PKPaymentSummaryItem(label: "bar", amount: NSDecimalNumber(string: "1.00")) + ] + + XCTAssertFalse(StripeAPI.canSubmitPaymentRequest(request)) + } +} diff --git a/Stripe/StripeiOSTests/STPBECSDebitAccountNumberValidatorTests.swift b/Stripe/StripeiOSTests/STPBECSDebitAccountNumberValidatorTests.swift new file mode 100644 index 00000000..4e06739c --- /dev/null +++ b/Stripe/StripeiOSTests/STPBECSDebitAccountNumberValidatorTests.swift @@ -0,0 +1,240 @@ +// +// STPBECSDebitAccountNumberValidatorTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 3/13/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPBECSDebitAccountNumberValidatorTests: XCTestCase { + func testValidationStateForText() { + let tests = [ + // empty input + [ + "input": "", + "bsb": "", + "editing": NSNumber(value: false), + "expected": NSNumber(value: STPTextValidationState.empty.rawValue), + ], + [ + "input": "", + "bsb": "0", + "editing": NSNumber(value: false), + "expected": NSNumber(value: STPTextValidationState.empty.rawValue), + ], + [ + "input": "", + "editing": NSNumber(value: false), + "expected": NSNumber(value: STPTextValidationState.empty.rawValue), + ], + [ + "input": "", + "bsb": "00", + "editing": NSNumber(value: false), + "expected": NSNumber(value: STPTextValidationState.empty.rawValue), + ], + // incomplete input + [ + "input": "1", + "bsb": "", + "editing": NSNumber(value: false), + "expected": NSNumber(value: STPTextValidationState.incomplete.rawValue), + ], + [ + "input": "1", + "bsb": "0", + "editing": NSNumber(value: false), + "expected": NSNumber(value: STPTextValidationState.incomplete.rawValue), + ], + [ + "input": "1", + "bsb": "00", + "editing": NSNumber(value: false), + "expected": NSNumber(value: STPTextValidationState.incomplete.rawValue), + ], + [ + "input": "1", + "editing": NSNumber(value: false), + "expected": NSNumber(value: STPTextValidationState.incomplete.rawValue), + ], + [ + "input": "12345", + "bsb": "06", + "editing": NSNumber(value: false), + "expected": NSNumber(value: STPTextValidationState.incomplete.rawValue), + ], + // incomplete input (editing) + [ + "input": "1", + "bsb": "", + "editing": NSNumber(value: true), + "expected": NSNumber(value: STPTextValidationState.incomplete.rawValue), + ], + [ + "input": "1", + "bsb": "0", + "editing": NSNumber(value: true), + "expected": NSNumber(value: STPTextValidationState.incomplete.rawValue), + ], + [ + "input": "1", + "bsb": "00", + "editing": NSNumber(value: true), + "expected": NSNumber(value: STPTextValidationState.incomplete.rawValue), + ], + [ + "input": "1", + "editing": NSNumber(value: true), + "expected": NSNumber(value: STPTextValidationState.incomplete.rawValue), + ], + [ + "input": "12345", + "bsb": "06", + "editing": NSNumber(value: true), + "expected": NSNumber(value: STPTextValidationState.incomplete.rawValue), + ], + [ + "input": "12345678", + "bsb": "", + "editing": NSNumber(value: true), + "expected": NSNumber(value: STPTextValidationState.incomplete.rawValue), + ], + // complete + [ + "input": "12345", + "bsb": "", + "editing": NSNumber(value: false), + "expected": NSNumber(value: STPTextValidationState.complete.rawValue), + ], + [ + "input": "123456", + "bsb": "", + "editing": NSNumber(value: false), + "expected": NSNumber(value: STPTextValidationState.complete.rawValue), + ], + [ + "input": "1234567", + "bsb": "", + "editing": NSNumber(value: false), + "expected": NSNumber(value: STPTextValidationState.complete.rawValue), + ], + [ + "input": "12345678", + "bsb": "", + "editing": NSNumber(value: false), + "expected": NSNumber(value: STPTextValidationState.complete.rawValue), + ], + [ + "input": "123456789", + "bsb": "", + "editing": NSNumber(value: false), + "expected": NSNumber(value: STPTextValidationState.complete.rawValue), + ], + // complete (editing) + [ + "input": "123456789", + "bsb": "", + "editing": NSNumber(value: true), + "expected": NSNumber(value: STPTextValidationState.complete.rawValue), + ], + // invalid + [ + "input": "12345678910", + "bsb": "", + "editing": NSNumber(value: false), + "expected": NSNumber(value: STPTextValidationState.invalid.rawValue), + ], + // invalid (editing) + [ + "input": "12345678910", + "bsb": "", + "editing": NSNumber(value: true), + "expected": NSNumber(value: STPTextValidationState.invalid.rawValue), + ], + ] + + for test in tests { + let input = (test["input"] as? String)! + let bsb = test["bsb"] as? String + let editing = (test["editing"] as? NSNumber)!.boolValue + let expected = STPTextValidationState( + rawValue: (test["expected"] as! NSNumber).intValue + )! + + XCTAssertEqual( + STPBECSDebitAccountNumberValidator.validationState( + forText: input, + withBSBNumber: bsb, + completeOnMaxLengthOnly: editing + ), + expected + ) + } + } + + func testformattedSanitizedTextFromString() { + let tests = [ + [ + "input": "", + "bsb": "00", + "expected": "", + ], + [ + "input": "1", + "bsb": "00", + "expected": "1", + ], + [ + "input": "--111111--", + "bsb": "00", + "expected": "111111", + ], + [ + "input": "12345678910", + "bsb": "00", + "expected": "123456789", + ], + [ + "input": "", + "bsb": "06", + "expected": "", + ], + [ + "input": "1", + "bsb": "06", + "expected": "1", + ], + [ + "input": "--111111--", + "bsb": "06", + "expected": "111111", + ], + [ + "input": "12345678910", + "bsb": "06", + "expected": "123456789", + ], + ] + + for test in tests { + let input = (test["input"])! + let bsb = test["bsb"] + let expected = test["expected"] + XCTAssertEqual( + STPBECSDebitAccountNumberValidator.formattedSanitizedText( + from: input, + withBSBNumber: bsb + ), + expected + ) + } + } +} diff --git a/Stripe/StripeiOSTests/STPBSBNumberValidatorTests.swift b/Stripe/StripeiOSTests/STPBSBNumberValidatorTests.swift new file mode 100644 index 00000000..efe7b37a --- /dev/null +++ b/Stripe/StripeiOSTests/STPBSBNumberValidatorTests.swift @@ -0,0 +1,92 @@ +// +// STPBSBNumberValidatorTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 3/13/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPBSBNumberValidatorTests: XCTestCase { + func testValidationStateForText() { + // Don't use the special test key behavior of treating 00 as a valid BSB. + STPAPIClient.shared.publishableKey = "pk_live_not_a_real_key" + let tests: [(String, STPTextValidationState)] = [ + ("", .empty), + ("1", .incomplete), + ("11", .incomplete), + ("00", .invalid), + ("111111", .complete), + ("111-111", .complete), + ("--111-111--", .complete), + ("1234567", .invalid), + ] + + for test in tests { + XCTAssertEqual(STPBSBNumberValidator.validationState(forText: test.0), test.1) + } + } + + func testformattedSanitizedTextFromString() { + let tests = [ + ["", ""], + ["1", "1"], + ["11", "11"], + ["111", "111-"], + ["111111", "111-111"], + ["--111111--", "111-111"], + ["1234567", "123-456"], + ] + + for test in tests { + XCTAssertEqual(STPBSBNumberValidator.formattedSanitizedText(from: test[0]), test[1]) + } + } + + func testIdentityForText() { + let tests = [ + ["", NSNull()], + ["9", NSNull()], + ["94", NSNull()], + ["941", "Delphi Bank (division of Bendigo and Adelaide Bank)"], + ["942", "Bank of Sydney"], + ["942942", "Bank of Sydney"], + ["40", "Commonwealth Bank of Australia"], + ["942-942", "Bank of Sydney"], + ["942942111", "Bank of Sydney"], + ] + + for test in tests { + if test[1] as! NSObject == NSNull() { + XCTAssertNil(STPBSBNumberValidator.identity(forText: test[0] as! String)) + } else { + XCTAssertEqual( + STPBSBNumberValidator.identity(forText: test[0] as! String), + test[1] as? String + ) + } + } + } + + func testIconForText() { + // Don't use the special test key behavior of treating 00 as a valid BSB. + STPAPIClient.shared.publishableKey = "pk_live_not_a_real_key" + let defaultIcon = STPBSBNumberValidator.icon(forText: nil) + XCTAssertNotNil(defaultIcon, "Nil default icon") + + XCTAssertEqual(defaultIcon, STPBSBNumberValidator.icon(forText: "00")) + + let bankIcon = STPBSBNumberValidator.icon(forText: "11") + XCTAssertNotNil(bankIcon, "Nil icon for bank `11`") + XCTAssertFalse((defaultIcon == bankIcon), "Icon for `11` is same as default") + + XCTAssertEqual(bankIcon, STPBSBNumberValidator.icon(forText: "111-111")) + } +} diff --git a/Stripe/StripeiOSTests/STPBankAccountFunctionalTest.swift b/Stripe/StripeiOSTests/STPBankAccountFunctionalTest.swift new file mode 100644 index 00000000..a7fac99b --- /dev/null +++ b/Stripe/StripeiOSTests/STPBankAccountFunctionalTest.swift @@ -0,0 +1,61 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPBankAccountFunctionalTest.m +// Stripe +// +// Created by Charles Scalesse on 10/2/14. +// +// + +import StripeCoreTestUtils +import XCTest + +class STPBankAccountFunctionalTest: XCTestCase { + func testCreateAndRetreiveBankAccountToken() { + let bankAccount = STPBankAccountParams() + bankAccount.accountNumber = "000123456789" + bankAccount.routingNumber = "110000000" + bankAccount.country = "US" + bankAccount.accountHolderName = "Jimmy bob" + bankAccount.accountHolderType = STPBankAccountHolderType.company + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let expectation = self.expectation(description: "Bank account creation") + client.createToken( + withBankAccount: bankAccount) { token, error in + expectation.fulfill() + XCTAssertNil(error, "error should be nil") + XCTAssertNotNil(token, "token should not be nil") + + XCTAssertNotNil(token?.tokenId) + XCTAssertEqual(token?.type, .bankAccount) + XCTAssertNotNil(token?.bankAccount?.stripeID) + XCTAssertEqual("STRIPE TEST BANK", token?.bankAccount?.bankName) + XCTAssertEqual("6789", token?.bankAccount?.last4) + XCTAssertEqual("Jimmy bob", token?.bankAccount?.accountHolderName) + XCTAssertEqual(token?.bankAccount?.accountHolderType, STPBankAccountHolderType.company) + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testInvalidKey() { + let bankAccount = STPBankAccountParams() + bankAccount.accountNumber = "000123456789" + bankAccount.routingNumber = "110000000" + bankAccount.country = "US" + + let client = STPAPIClient(publishableKey: "not_a_valid_key_asdf") + + let expectation = self.expectation(description: "Bad bank account creation") + + client.createToken( + withBankAccount: bankAccount) { token, error in + expectation.fulfill() + XCTAssertNil(token, "token should be nil") + XCTAssertNotNil(error, "error should not be nil") + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPBankAccountParamsTest.swift b/Stripe/StripeiOSTests/STPBankAccountParamsTest.swift new file mode 100644 index 00000000..989bb8ef --- /dev/null +++ b/Stripe/StripeiOSTests/STPBankAccountParamsTest.swift @@ -0,0 +1,113 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPBankAccountParamsTest.m +// Stripe +// +// Created by Joey Dong on 6/19/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +@testable import Stripe +@testable import StripePayments +import XCTest + +class STPBankAccountParamsTest: XCTestCase { + // MARK: - + + func testLast4ReturnsAccountNumberLast4() { + let bankAccountParams = STPBankAccountParams() + bankAccountParams.accountNumber = "000123456789" + XCTAssertEqual(bankAccountParams.last4, "6789") + } + + func testLast4ReturnsNilWhenNoAccountNumberSet() { + let bankAccountParams = STPBankAccountParams() + XCTAssertNil(bankAccountParams.last4) + } + + func testLast4ReturnsNilWhenAccountNumberIsLessThanLength4() { + let bankAccountParams = STPBankAccountParams() + bankAccountParams.accountNumber = "123" + XCTAssertNil(bankAccountParams.last4) + } + + // MARK: - STPBankAccountHolderType Tests + + func testAccountHolderTypeFromString() { + XCTAssertEqual(STPBankAccountParams.accountHolderType(from: "individual"), STPBankAccountHolderType.individual) + XCTAssertEqual(STPBankAccountParams.accountHolderType(from: "INDIVIDUAL"), STPBankAccountHolderType.individual) + + XCTAssertEqual(STPBankAccountParams.accountHolderType(from: "company"), STPBankAccountHolderType.company) + XCTAssertEqual(STPBankAccountParams.accountHolderType(from: "COMPANY"), STPBankAccountHolderType.company) + + XCTAssertEqual(STPBankAccountParams.accountHolderType(from: "garbage"), STPBankAccountHolderType.individual) + XCTAssertEqual(STPBankAccountParams.accountHolderType(from: "GARBAGE"), STPBankAccountHolderType.individual) + } + + func testStringFromAccountHolderType() { + let values = [ + STPBankAccountHolderType.individual, + STPBankAccountHolderType.company, + ] + + for accountHolderType in values { + let string = STPBankAccountParams.string(from: accountHolderType) + + switch accountHolderType { + case STPBankAccountHolderType.individual: + XCTAssertEqual(string, "individual") + case STPBankAccountHolderType.company: + XCTAssertEqual(string, "company") + default: + break + } + } + } + + // MARK: - Description Tests + + func testDescription() { + let bankAccountParams = STPBankAccountParams() + XCTAssertNotNil(bankAccountParams.description) + } + + // MARK: - STPFormEncodable Tests + + func testRootObjectName() { + XCTAssertEqual(STPBankAccountParams.rootObjectName(), "bank_account") + } + + func testPropertyNamesToFormFieldNamesMapping() { + let bankAccountParams = STPBankAccountParams() + + let mapping = STPBankAccountParams.propertyNamesToFormFieldNamesMapping() + + for propertyName in mapping.keys { + guard let propertyName = propertyName as? String else { + continue + } + XCTAssertFalse(propertyName.contains(":")) + XCTAssert(bankAccountParams.responds(to: NSSelectorFromString(propertyName))) + } + + for formFieldName in mapping.values { + guard let formFieldName = formFieldName as? String else { + continue + } + XCTAssert((formFieldName is NSString)) + XCTAssert(formFieldName.count > 0) + } + + XCTAssertEqual(mapping.values.count, Set(mapping.values).count) + } + + func testAccountHolderTypeString() { + let bankAccountParams = STPBankAccountParams() + + bankAccountParams.accountHolderType = STPBankAccountHolderType.individual + XCTAssertEqual(bankAccountParams.accountHolderTypeString(), "individual") + + bankAccountParams.accountHolderType = .company + XCTAssertEqual(bankAccountParams.accountHolderTypeString(), "company") + } +} diff --git a/Stripe/StripeiOSTests/STPBankAccountTest.swift b/Stripe/StripeiOSTests/STPBankAccountTest.swift new file mode 100644 index 00000000..8ddb033b --- /dev/null +++ b/Stripe/StripeiOSTests/STPBankAccountTest.swift @@ -0,0 +1,124 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPBankAccountTest.m +// Stripe +// +// Created by Charles Scalesse on 10/2/14. +// +// + +@testable import StripePayments +import XCTest + +class STPBankAccountTest: XCTestCase { + // MARK: - STPBankAccountStatus Tests + + func testStatusFromString() { + XCTAssertEqual(STPBankAccount.status(from: "new"), STPBankAccountStatus.new) + XCTAssertEqual(STPBankAccount.status(from: "NEW"), STPBankAccountStatus.new) + + XCTAssertEqual(STPBankAccount.status(from: "validated"), STPBankAccountStatus.validated) + XCTAssertEqual(STPBankAccount.status(from: "VALIDATED"), STPBankAccountStatus.validated) + + XCTAssertEqual(STPBankAccount.status(from: "verified"), STPBankAccountStatus.verified) + XCTAssertEqual(STPBankAccount.status(from: "VERIFIED"), STPBankAccountStatus.verified) + + XCTAssertEqual(STPBankAccount.status(from: "verification_failed"), STPBankAccountStatus.verificationFailed) + XCTAssertEqual(STPBankAccount.status(from: "VERIFICATION_FAILED"), STPBankAccountStatus.verificationFailed) + + XCTAssertEqual(STPBankAccount.status(from: "errored"), STPBankAccountStatus.errored) + XCTAssertEqual(STPBankAccount.status(from: "ERRORED"), STPBankAccountStatus.errored) + + XCTAssertEqual(STPBankAccount.status(from: "garbage"), STPBankAccountStatus.new) + XCTAssertEqual(STPBankAccount.status(from: "GARBAGE"), STPBankAccountStatus.new) + } + + func testStringFromStatus() { + let values = [ + STPBankAccountStatus.new, + STPBankAccountStatus.validated, + STPBankAccountStatus.verified, + STPBankAccountStatus.verificationFailed, + STPBankAccountStatus.errored, + ] + + for status in values { + let string = STPBankAccount.string(from: status) + + switch status { + case STPBankAccountStatus.new: + XCTAssertEqual(string, "new") + case STPBankAccountStatus.validated: + XCTAssertEqual(string, "validated") + case STPBankAccountStatus.verified: + XCTAssertEqual(string, "verified") + case STPBankAccountStatus.verificationFailed: + XCTAssertEqual(string, "verification_failed") + case STPBankAccountStatus.errored: + XCTAssertEqual(string, "errored") + default: + break + } + } + } + + // MARK: - Equality Tests + + func testBankAccountEquals() { + let bankAccount1 = STPBankAccount.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("BankAccount")) + let bankAccount2 = STPBankAccount.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("BankAccount")) + + XCTAssertEqual(bankAccount1, bankAccount1) + XCTAssertEqual(bankAccount1, bankAccount2) + + XCTAssertEqual(bankAccount1?.hash, bankAccount1?.hash) + XCTAssertEqual(bankAccount1?.hash, bankAccount2?.hash) + } + + // MARK: - Description Tests + + func testDescription() { + let bankAccount = STPBankAccount.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("BankAccount")) + XCTAssertNotNil(bankAccount?.description) + } + + // MARK: - STPAPIResponseDecodable Tests + + func testDecodedObjectFromAPIResponseRequiredFields() { + let requiredFields = [ + "id", + "last4", + "bank_name", + "country", + "currency", + "status", + ] + + for field in requiredFields { + var response = STPTestUtils.jsonNamed("BankAccount") + response!.removeValue(forKey: field) + + XCTAssertNil(STPBankAccount.decodedObject(fromAPIResponse: response)) + } + + XCTAssertNotNil(STPBankAccount.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("BankAccount"))) + } + + func testDecodedObjectFromAPIResponseMapping() { + let response = STPTestUtils.jsonNamed("BankAccount") + let bankAccount = STPBankAccount.decodedObject(fromAPIResponse: response) + + XCTAssertEqual(bankAccount?.stripeID, "ba_1AZmya2eZvKYlo2CQzt7Fwnz") + XCTAssertEqual(bankAccount?.accountHolderName, "Jane Austen") + XCTAssertEqual(bankAccount?.accountHolderType, .individual) + XCTAssertEqual(bankAccount?.bankName, "STRIPE TEST BANK") + XCTAssertEqual(bankAccount?.country, "US") + XCTAssertEqual(bankAccount?.currency, "usd") + XCTAssertEqual(bankAccount?.fingerprint, "1JWtPxqbdX5Gamtc") + XCTAssertEqual(bankAccount?.last4, "6789") + XCTAssertEqual(bankAccount?.routingNumber, "110000000") + XCTAssertEqual(bankAccount?.status, STPBankAccountStatus.new) + + XCTAssertEqual(bankAccount!.allResponseFields as NSDictionary, response! as NSDictionary) + } +} diff --git a/Stripe/StripeiOSTests/STPBinRangeTest.swift b/Stripe/StripeiOSTests/STPBinRangeTest.swift new file mode 100644 index 00000000..8bd42f92 --- /dev/null +++ b/Stripe/StripeiOSTests/STPBinRangeTest.swift @@ -0,0 +1,190 @@ +// +// STPBinRangeTest.swift +// StripeiOS Tests +// +// Created by Jack Flintermann on 5/24/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPBinRangeTest: XCTestCase { + func testAllRanges() { + for binRange in STPBINController.shared.allRanges() { + XCTAssertEqual(binRange.accountRangeLow.count, binRange.accountRangeHigh.count) + } + } + + func testMatchesNumber() { + var binRange = STPBINRange( + panLength: 0, + brand: .unknown, + accountRangeLow: "134", + accountRangeHigh: "167", + country: nil + ) + + XCTAssertFalse(binRange.matchesNumber("0")) + XCTAssertTrue(binRange.matchesNumber("1")) + XCTAssertFalse(binRange.matchesNumber("2")) + + XCTAssertFalse(binRange.matchesNumber("00")) + XCTAssertTrue(binRange.matchesNumber("13")) + XCTAssertTrue(binRange.matchesNumber("14")) + XCTAssertTrue(binRange.matchesNumber("16")) + XCTAssertFalse(binRange.matchesNumber("20")) + + XCTAssertFalse(binRange.matchesNumber("133")) + XCTAssertTrue(binRange.matchesNumber("134")) + XCTAssertTrue(binRange.matchesNumber("135")) + XCTAssertTrue(binRange.matchesNumber("167")) + XCTAssertFalse(binRange.matchesNumber("168")) + + XCTAssertFalse(binRange.matchesNumber("1244")) + XCTAssertTrue(binRange.matchesNumber("1340")) + XCTAssertTrue(binRange.matchesNumber("1344")) + XCTAssertTrue(binRange.matchesNumber("1444")) + XCTAssertTrue(binRange.matchesNumber("1670")) + XCTAssertTrue(binRange.matchesNumber("1679")) + XCTAssertFalse(binRange.matchesNumber("1680")) + + binRange = STPBINRange( + panLength: 0, + brand: .unknown, + accountRangeLow: "004", + accountRangeHigh: "017", + country: nil + ) + + XCTAssertTrue(binRange.matchesNumber("0")) + XCTAssertFalse(binRange.matchesNumber("1")) + + XCTAssertTrue(binRange.matchesNumber("00")) + XCTAssertTrue(binRange.matchesNumber("01")) + XCTAssertFalse(binRange.matchesNumber("10")) + XCTAssertFalse(binRange.matchesNumber("20")) + + XCTAssertFalse(binRange.matchesNumber("000")) + XCTAssertFalse(binRange.matchesNumber("002")) + XCTAssertTrue(binRange.matchesNumber("004")) + XCTAssertTrue(binRange.matchesNumber("009")) + XCTAssertTrue(binRange.matchesNumber("014")) + XCTAssertTrue(binRange.matchesNumber("017")) + XCTAssertFalse(binRange.matchesNumber("019")) + XCTAssertFalse(binRange.matchesNumber("020")) + XCTAssertFalse(binRange.matchesNumber("100")) + + XCTAssertFalse(binRange.matchesNumber("0000")) + XCTAssertFalse(binRange.matchesNumber("0021")) + XCTAssertTrue(binRange.matchesNumber("0044")) + XCTAssertTrue(binRange.matchesNumber("0098")) + XCTAssertTrue(binRange.matchesNumber("0143")) + XCTAssertTrue(binRange.matchesNumber("0173")) + XCTAssertFalse(binRange.matchesNumber("0195")) + XCTAssertFalse(binRange.matchesNumber("0202")) + XCTAssertFalse(binRange.matchesNumber("1004")) + + binRange = STPBINRange( + panLength: 0, + brand: .unknown, + accountRangeLow: "", + accountRangeHigh: "", + country: nil + ) + XCTAssertTrue(binRange.matchesNumber("")) + XCTAssertTrue(binRange.matchesNumber("1")) + } + + func testBinRangesForNumber() { + var binRanges: [STPBINRange]? + + binRanges = STPBINController.shared.binRanges(forNumber: "4136000000008") + XCTAssertEqual(binRanges?.count, 3) + + binRanges = STPBINController.shared.binRanges(forNumber: "4242424242424242") + XCTAssertEqual(binRanges?.count, 2) + + binRanges = STPBINController.shared.binRanges(forNumber: "5555555555554444") + XCTAssertEqual(binRanges?.count, 2) + + binRanges = STPBINController.shared.binRanges(forNumber: "") + XCTAssertEqual(binRanges?.count, STPBINController.shared.allRanges().count) + + binRanges = STPBINController.shared.binRanges(forNumber: "123") + XCTAssertEqual(binRanges?.count, 1) + } + + func testBinRangesForBrand() { + let allBrands: [STPCardBrand] = [ + .visa, + .amex, + .mastercard, + .discover, + .JCB, + .dinersClub, + .unionPay, + .unknown, + ] + for brand in allBrands { + let binRanges = STPBINController.shared.binRanges(for: brand) + for binRange in binRanges { + XCTAssertEqual(binRange.brand, brand) + } + } + } + + func testMostSpecificBinRangeForNumber() { + var binRange: STPBINRange? + + binRange = STPBINController.shared.mostSpecificBINRange(forNumber: "") + XCTAssertNotEqual(binRange?.brand, .unknown) + + binRange = STPBINController.shared.mostSpecificBINRange(forNumber: "4242424242422") + XCTAssertEqual(binRange?.brand, .visa) + XCTAssertEqual(binRange?.panLength, 16) + + binRange = STPBINController.shared.mostSpecificBINRange(forNumber: "4136000000008") + XCTAssertEqual(binRange?.brand, .visa) + XCTAssertEqual(binRange?.panLength, 13) + + binRange = STPBINController.shared.mostSpecificBINRange(forNumber: "4242424242424242") + XCTAssertEqual(binRange?.brand, .visa) + XCTAssertEqual(binRange?.panLength, 16) + } + + func testMostSpecificBinRangePrefersKnownBrand() { + // 624478 is a real world case that returns ranges for UnionPay and NYCE, the latter being handled as unknown. + let mockedRanges = [ + STPBINRange( + panLength: 16, + brand: .unionPay, + accountRangeLow: "6244780000000000", + accountRangeHigh: "6244789999999999", + country: "HK" + ), + STPBINRange( + panLength: 16, + brand: .unknown, + accountRangeLow: "6244780000000000", + accountRangeHigh: "6244789999999999", + country: "CN" + ), + ] + + STPBINController.shared.sRetrievedRanges["624478"] = mockedRanges + STPBINController.shared.sAllRanges += mockedRanges + + let binRange = STPBINController.shared.mostSpecificBINRange(forNumber: "624478") + XCTAssertEqual(binRange.accountRangeLow, "6244780000000000") + XCTAssertEqual(binRange.accountRangeHigh, "6244789999999999") + XCTAssertEqual(binRange.brand, .unionPay) + + // Cleanup added values to avoid issues caused by singleton state. + STPBINController.shared.sRetrievedRanges["624478"] = nil + STPBINController.shared.sAllRanges = STPBINController.STPBINRangeInitialRanges + } +} diff --git a/Stripe/StripeiOSTests/STPBlocks.h b/Stripe/StripeiOSTests/STPBlocks.h new file mode 100644 index 00000000..d9be6f72 --- /dev/null +++ b/Stripe/StripeiOSTests/STPBlocks.h @@ -0,0 +1,255 @@ +// +// STPBlocks.h +// Stripe +// +// Created by Jack Flintermann on 3/23/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +#import +#import + +@class STP3DS2AuthenticateResponse; +@class STPToken; +@class STPFile; +@class STPSource; +@class STPCustomer; +@protocol STPSourceProtocol; +@class STPPaymentIntent; +@class STPSetupIntent; +@class STPPaymentMethod; +@class STPIssuingCardPin; +@class STPFPXBankStatusResponse; + +/** + These values control the labels used in the shipping info collection form. + */ +typedef NS_ENUM(NSUInteger, STPShippingType) { + /** + Shipping the purchase to the provided address using a third-party + shipping company. + */ + STPShippingTypeShipping, + /** + Delivering the purchase by the seller. + */ + STPShippingTypeDelivery, +}; + +/** + An enum representing the status of a shipping address validation. + */ +typedef NS_ENUM(NSUInteger, STPShippingStatus) { + /** + The shipping address is valid. + */ + STPShippingStatusValid, + /** + The shipping address is invalid. + */ + STPShippingStatusInvalid, +}; + +/** + An enum representing the status of a payment requested from the user. + */ +typedef NS_ENUM(NSUInteger, STPPaymentStatus) { + /** + The payment succeeded. + */ + STPPaymentStatusSuccess, + /** + The payment failed due to an unforeseen error, such as the user's Internet connection being offline. + */ + STPPaymentStatusError, + /** + The user cancelled the payment (for example, by hitting "cancel" in the Apple Pay dialog). + */ + STPPaymentStatusUserCancellation, +}; + +/** + An empty block, called with no arguments, returning nothing. + */ +typedef void (^STPVoidBlock)(void); + +/** + A block that may optionally be called with an error. + + @param error The error that occurred, if any. + */ +typedef void (^STPErrorBlock)(NSError * __nullable error); + +/** + A block that contains a boolean success param and may optionally be called with an error. + + @param success Whether the task succeeded. + @param error The error that occurred, if any. + */ +typedef void (^STPBooleanSuccessBlock)(BOOL success, NSError * __nullable error); + +/** + A callback to be run with a JSON response. + + @param jsonResponse The JSON response, or nil if an error occured. + @param error The error that occurred, if any. + */ +typedef void (^STPJSONResponseCompletionBlock)(NSDictionary * __nullable jsonResponse, NSError * __nullable error); + +/** + A callback to be run with a token response from the Stripe API. + + @param token The Stripe token from the response. Will be nil if an error occurs. @see STPToken + @param error The error returned from the response, or nil if none occurs. @see StripeError.h for possible values. + */ +typedef void (^STPTokenCompletionBlock)(STPToken * __nullable token, NSError * __nullable error); + +/** + A callback to be run with a source response from the Stripe API. + + @param source The Stripe source from the response. Will be nil if an error occurs. @see STPSource + @param error The error returned from the response, or nil if none occurs. @see StripeError.h for possible values. + */ +typedef void (^STPSourceCompletionBlock)(STPSource * __nullable source, NSError * __nullable error); + +/** + A callback to be run with a source or card response from the Stripe API. + + @param source The Stripe source from the response. Will be nil if an error occurs. @see STPSourceProtocol + @param error The error returned from the response, or nil if none occurs. @see StripeError.h for possible values. + */ +typedef void (^STPSourceProtocolCompletionBlock)(id __nullable source, NSError * __nullable error); + +/** + A callback to be run with a PaymentIntent response from the Stripe API. + + @param paymentIntent The Stripe PaymentIntent from the response. Will be nil if an error occurs. @see STPPaymentIntent + @param error The error returned from the response, or nil if none occurs. @see StripeError.h for possible values. + */ +typedef void (^STPPaymentIntentCompletionBlock)(STPPaymentIntent * __nullable paymentIntent, NSError * __nullable error); + +/** + A callback to be run with a PaymentIntent response from the Stripe API. + + @param setupIntent The Stripe SetupIntent from the response. Will be nil if an error occurs. @see STPSetupIntent + @param error The error returned from the response, or nil if none occurs. @see StripeError.h for possible values. + */ +typedef void (^STPSetupIntentCompletionBlock)(STPSetupIntent * __nullable setupIntent, NSError * __nullable error); + +/** + A callback to be run with a PaymentMethod response from the Stripe API. + + @param paymentMethod The Stripe PaymentMethod from the response. Will be nil if an error occurs. @see STPPaymentMethod + @param error The error returned from the response, or nil if none occurs. @see StripeError.h for possible values. + */ +typedef void (^STPPaymentMethodCompletionBlock)(STPPaymentMethod * __nullable paymentMethod, NSError * __nullable error); + +/** + A callback to be run with an array of PaymentMethods response from the Stripe API. + + @param paymentMethods An array of PaymentMethod from the response. Will be nil if an error occurs. @see STPPaymentMethod + @param error The error returned from the response, or nil if none occurs. @see StripeError.h for possible values. + */ +typedef void (^STPPaymentMethodsCompletionBlock)(NSArray *__nullable paymentMethods, NSError * __nullable error); + +/** + A callback to be run with a validation result and shipping methods for a + shipping address. + + @param status An enum representing whether the shipping address is valid. + @param shippingValidationError If the shipping address is invalid, an error describing the issue with the address. If no error is given and the address is invalid, the default error message will be used. + @param shippingMethods The shipping methods available for the address. + @param selectedShippingMethod The default selected shipping method for the address. + */ +typedef void (^STPShippingMethodsCompletionBlock)(STPShippingStatus status, NSError * __nullable shippingValidationError, NSArray* __nullable shippingMethods, PKShippingMethod * __nullable selectedShippingMethod); + +/** + A callback to be run with a file response from the Stripe API. + + @param file The Stripe file from the response. Will be nil if an error occurs. @see STPFile + @param error The error returned from the response, or nil if none occurs. @see StripeError.h for possible values. + */ +typedef void (^STPFileCompletionBlock)(STPFile * __nullable file, NSError * __nullable error); + +/** + A callback to be run with a customer response from the Stripe API. + + @param customer The Stripe customer from the response, or nil if an error occurred. @see STPCustomer + @param error The error returned from the response, or nil if none occurs. + */ +typedef void (^STPCustomerCompletionBlock)(STPCustomer * __nullable customer, NSError * __nullable error); + +/** + An enum representing the success and error states of PIN management + */ +typedef NS_ENUM(NSUInteger, STPPinStatus) { + /** + The verification object was already redeemed + */ + STPPinSuccess, + /** + The verification object was already redeemed + */ + STPPinErrorVerificationAlreadyRedeemed, + /** + The one-time code was incorrect + */ + STPPinErrorVerificationCodeIncorrect, + /** + The verification object was expired + */ + STPPinErrorVerificationExpired, + /** + The verification object has been attempted too many times + */ + STPPinErrorVerificationTooManyAttempts, + /** + An error occured while retrieving the ephemeral key + */ + STPPinEphemeralKeyError, + /** + An unknown error occured + */ + STPPinUnknownError, +}; + +/** + A callback to be run with a card PIN response from the Stripe API. + + @param cardPin The Stripe card PIN from the response. Will be nil if an error occurs. @see STPIssuingCardPin + @param status The status to help you sort between different error state, or STPPinSuccess when succesful. @see STPPinStatus for possible values. + @param error The error returned from the response, or nil if none occurs. @see StripeError.h for possible values. + */ +typedef void (^STPPinCompletionBlock)(STPIssuingCardPin * __nullable cardPin, STPPinStatus status, NSError * __nullable error); + +/** + A callback to be run with a 3DS2 authenticate response from the Stripe API. + + @param authenticateResponse The Stripe AuthenticateResponse. Will be nil if an error occurs. @see STP3DS2AuthenticateResponse + @param error The error returned from the response, or nil if none occurs. + */ +typedef void (^STP3DS2AuthenticateCompletionBlock)(STP3DS2AuthenticateResponse * _Nullable authenticateResponse, NSError * _Nullable error); + +/** + A callback to be run with a response from the Stripe API containing information about the online status of FPX banks. + + @param bankStatusResponse The response from Stripe containing the status of the various banks. Will be nil if an error occurs. @see STPFPXBankStatusResponse + @param error The error returned from the response, or nil if none occurs. + */ +typedef void (^STPFPXBankStatusCompletionBlock)(STPFPXBankStatusResponse * _Nullable bankStatusResponse, NSError * _Nullable error); + +/** + A block called with a payment status and an optional error. + + @param error The error that occurred, if any. + */ +typedef void (^STPPaymentStatusBlock)(STPPaymentStatus status, NSError * __nullable error); + +/** + A block to be run with the client secret of a PaymentIntent or SetupIntent. + + @param clientSecret The client secret of the PaymentIntent or SetupIntent. See https://stripe.com/docs/api/payment_intents/object#payment_intent_object-client_secret + @param error The error that occurred when creating the Intent, or nil if none occurred. + */ +typedef void (^STPIntentClientSecretCompletionBlock)(NSString * __nullable clientSecret, NSError * __nullable error); + diff --git a/Stripe/StripeiOSTests/STPCardBINMetadataTests.swift b/Stripe/StripeiOSTests/STPCardBINMetadataTests.swift new file mode 100644 index 00000000..bb98e92f --- /dev/null +++ b/Stripe/StripeiOSTests/STPCardBINMetadataTests.swift @@ -0,0 +1,55 @@ +// +// STPCardBINMetadataTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 7/20/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPCardBINMetadataTests: XCTestCase { + func testAPICall() { + STPAPIClient.shared.publishableKey = STPTestingDefaultPublishableKey + + let expectation = self.expectation(description: "Retrieve card metadata") + + // 625035 is a randomly selected UnionPay BIN + STPBINRange.retrieve( + forPrefix: "625035", + completion: { result in + let cardMetadata = try! result.get() + XCTAssertTrue(cardMetadata.data.count > 0) + XCTAssertEqual(cardMetadata.data.first!.brand, .unionPay) + expectation.fulfill() + } + ) + wait(for: [expectation], timeout: STPTestingNetworkRequestTimeout) + } + + func testLoadingInBINRange() { + STPAPIClient.shared.publishableKey = STPTestingDefaultPublishableKey + + let expectation = self.expectation(description: "Retrieve card metadata") + let hardCodedBinRanges = STPBINController.shared.allRanges() + STPBINController.shared.retrieveBINRanges(forPrefix: "625035") { result in + let ranges = try! result.get() + XCTAssertTrue(ranges.count > 0) + XCTAssertTrue( + STPBINController.shared.allRanges().count == hardCodedBinRanges.count + ranges.count + ) + for range in ranges { + XCTAssertTrue(STPBINController.shared.allRanges().contains(range)) + } + expectation.fulfill() + } + wait(for: [expectation], timeout: STPTestingNetworkRequestTimeout) + + } +} diff --git a/Stripe/StripeiOSTests/STPCardBrandChoiceTest.swift b/Stripe/StripeiOSTests/STPCardBrandChoiceTest.swift new file mode 100644 index 00000000..65c76c65 --- /dev/null +++ b/Stripe/StripeiOSTests/STPCardBrandChoiceTest.swift @@ -0,0 +1,29 @@ +// +// STPCardBrandChoiceTest.swift +// StripeiOSTests +// +// Created by Nick Porter on 8/29/23. +// + +import Foundation +@_spi(STP) import StripePayments +import XCTest + +class STPCardBrandChoiceTest: XCTestCase { + + func testDecodingHappy() throws { + let responseDict = ["eligible": true] + + let cardBranceChoice = try XCTUnwrap(STPCardBrandChoice.decodedObject(fromAPIResponse: responseDict)) + XCTAssertEqual(true, cardBranceChoice.eligible) + } + + func testDecodingNil() throws { + XCTAssertNil(STPCardBrandChoice.decodedObject(fromAPIResponse: nil)) + } + + func testDecodingEmpty() throws { + let cardBranceChoice = try XCTUnwrap(STPCardBrandChoice.decodedObject(fromAPIResponse: [:])) + XCTAssertEqual(false, cardBranceChoice.eligible) + } +} diff --git a/Stripe/StripeiOSTests/STPCardBrandTest.swift b/Stripe/StripeiOSTests/STPCardBrandTest.swift new file mode 100644 index 00000000..4584e9ee --- /dev/null +++ b/Stripe/StripeiOSTests/STPCardBrandTest.swift @@ -0,0 +1,93 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPCardBrandTest.m +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 6/3/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +class STPCardBrandTest: XCTestCase { + func testStringFromBrand() { + let brands = [ + NSNumber(value: STPCardBrand.amex.rawValue), + NSNumber(value: STPCardBrand.dinersClub.rawValue), + NSNumber(value: STPCardBrand.discover.rawValue), + NSNumber(value: STPCardBrand.JCB.rawValue), + NSNumber(value: STPCardBrand.mastercard.rawValue), + NSNumber(value: STPCardBrand.unionPay.rawValue), + NSNumber(value: STPCardBrand.visa.rawValue), + NSNumber(value: STPCardBrand.cartesBancaires.rawValue), + NSNumber(value: STPCardBrand.unknown.rawValue), + ] + + for brandNumber in brands { + let brand = STPCardBrand(rawValue: brandNumber.intValue) + let string = STPCardBrandUtilities.stringFrom(brand!) + + switch brand { + case .amex: + XCTAssertEqual(string, "American Express") + case .dinersClub: + XCTAssertEqual(string, "Diners Club") + case .discover: + XCTAssertEqual(string, "Discover") + case .JCB: + XCTAssertEqual(string, "JCB") + case .mastercard: + XCTAssertEqual(string, "Mastercard") + case .unionPay: + XCTAssertEqual(string, "UnionPay") + case .visa: + XCTAssertEqual(string, "Visa") + case .cartesBancaires: + XCTAssertEqual(string, "Cartes Bancaires") + case .unknown: + XCTAssertEqual(string, "Unknown") + @unknown default: + break + } + } + } + + func testApiValueFromBrand() { + let brands = [ + STPCardBrand.visa, + STPCardBrand.amex, + STPCardBrand.mastercard, + STPCardBrand.discover, + STPCardBrand.JCB, + STPCardBrand.dinersClub, + STPCardBrand.unionPay, + STPCardBrand.cartesBancaires, + STPCardBrand.unknown, + ] + + for brand in brands { + let string = STPCardBrandUtilities.apiValue(from: brand) + + switch brand { + case .amex: + XCTAssertEqual(string, "american_express") + case .dinersClub: + XCTAssertEqual(string, "diners_club") + case .discover: + XCTAssertEqual(string, "discover") + case .JCB: + XCTAssertEqual(string, "jcb") + case .mastercard: + XCTAssertEqual(string, "mastercard") + case .unionPay: + XCTAssertEqual(string, "unionpay") + case .visa: + XCTAssertEqual(string, "visa") + case .cartesBancaires: + XCTAssertEqual(string, "cartes_bancaires") + case .unknown: + XCTAssertEqual(string, "unknown") + @unknown default: + break + } + } + } +} diff --git a/Stripe/StripeiOSTests/STPCardCVCInputTextFieldFormatterTests.swift b/Stripe/StripeiOSTests/STPCardCVCInputTextFieldFormatterTests.swift new file mode 100644 index 00000000..1288fcea --- /dev/null +++ b/Stripe/StripeiOSTests/STPCardCVCInputTextFieldFormatterTests.swift @@ -0,0 +1,52 @@ +// +// STPCardCVCInputTextFieldFormatterTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/28/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPCardCVCInputTextFieldFormatterTests: XCTestCase { + + func testAllowedInput() { + let formatter = STPCardCVCInputTextFieldFormatter() + + formatter.cardBrand = .unknown + XCTAssertTrue(formatter.isAllowedInput("1", to: "", at: NSRange(location: 0, length: 1))) + XCTAssertTrue(formatter.isAllowedInput("12", to: "", at: NSRange(location: 0, length: 2))) + XCTAssertTrue(formatter.isAllowedInput("2", to: "1", at: NSRange(location: 1, length: 1))) + XCTAssertTrue(formatter.isAllowedInput("3", to: "12", at: NSRange(location: 2, length: 1))) + XCTAssertTrue(formatter.isAllowedInput("123", to: "", at: NSRange(location: 0, length: 1))) + XCTAssertTrue(formatter.isAllowedInput("4", to: "123", at: NSRange(location: 3, length: 1))) + XCTAssertFalse(formatter.isAllowedInput("5", to: "1234", at: NSRange(location: 4, length: 1))) + + formatter.cardBrand = .amex + XCTAssertTrue(formatter.isAllowedInput("1", to: "", at: NSRange(location: 0, length: 1))) + XCTAssertTrue(formatter.isAllowedInput("12", to: "", at: NSRange(location: 0, length: 2))) + XCTAssertTrue(formatter.isAllowedInput("2", to: "1", at: NSRange(location: 1, length: 1))) + XCTAssertTrue(formatter.isAllowedInput("3", to: "12", at: NSRange(location: 2, length: 1))) + XCTAssertTrue(formatter.isAllowedInput("123", to: "", at: NSRange(location: 0, length: 1))) + XCTAssertTrue(formatter.isAllowedInput("4", to: "123", at: NSRange(location: 3, length: 1))) + XCTAssertFalse(formatter.isAllowedInput("5", to: "1234", at: NSRange(location: 4, length: 1))) + + formatter.cardBrand = .visa + XCTAssertTrue(formatter.isAllowedInput("1", to: "", at: NSRange(location: 0, length: 1))) + XCTAssertTrue(formatter.isAllowedInput("12", to: "", at: NSRange(location: 0, length: 2))) + XCTAssertTrue(formatter.isAllowedInput("2", to: "1", at: NSRange(location: 1, length: 1))) + XCTAssertTrue(formatter.isAllowedInput("3", to: "12", at: NSRange(location: 2, length: 1))) + XCTAssertTrue(formatter.isAllowedInput("123", to: "", at: NSRange(location: 0, length: 1))) + XCTAssertFalse(formatter.isAllowedInput("4", to: "123", at: NSRange(location: 3, length: 1))) + XCTAssertFalse(formatter.isAllowedInput("5", to: "1234", at: NSRange(location: 4, length: 1))) + + XCTAssertFalse(formatter.isAllowedInput("a", to: "123", at: NSRange(location: 0, length: 1))) + } + +} diff --git a/Stripe/StripeiOSTests/STPCardCVCInputTextFieldSnapshotTests.swift b/Stripe/StripeiOSTests/STPCardCVCInputTextFieldSnapshotTests.swift new file mode 100644 index 00000000..f9f80057 --- /dev/null +++ b/Stripe/StripeiOSTests/STPCardCVCInputTextFieldSnapshotTests.swift @@ -0,0 +1,61 @@ +// +// STPCardCVCInputTextFieldSnapshotTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/29/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPCardCVCInputTextFieldSnapshotTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() + // recordMode = true + } + + func testEmpty() { + let field = STPCardCVCInputTextField() + field.sizeToFit() + field.frame.size.width = 200 + + STPSnapshotVerifyView(field) + } + + func testIncomplete() { + let field = STPCardCVCInputTextField() + field.sizeToFit() + field.frame.size.width = 200 + field.text = "1" + field.textDidChange() + + STPSnapshotVerifyView(field) + } + + func testValid() { + let field = STPCardCVCInputTextField() + field.sizeToFit() + field.frame.size.width = 200 + field.text = "123" + field.textDidChange() + + STPSnapshotVerifyView(field) + } + + func testInvalid() { + let field = STPCardCVCInputTextField() + field.sizeToFit() + field.frame.size.width = 200 + field.text = "12345" + field.textDidChange() + + STPSnapshotVerifyView(field) + } +} diff --git a/Stripe/StripeiOSTests/STPCardCVCInputTextFieldTests.swift b/Stripe/StripeiOSTests/STPCardCVCInputTextFieldTests.swift new file mode 100644 index 00000000..dcd15604 --- /dev/null +++ b/Stripe/StripeiOSTests/STPCardCVCInputTextFieldTests.swift @@ -0,0 +1,34 @@ +// +// STPCardCVCInputTextFieldTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 8/31/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPCardCVCInputTextFieldTests: XCTestCase { + + func testTruncatingCVCWhenTooLong() { + let cvcField = STPCardCVCInputTextField() + cvcField.cardBrand = .amex + cvcField.text = String( + repeating: "1", + count: Int(STPCardValidator.maxCVCLength(for: .amex)) + ) + XCTAssertEqual(cvcField.text?.count, Int(STPCardValidator.maxCVCLength(for: .amex))) + + // Switching the card brand to `visa` should truncate the field text to + // the max length allowed for the brand + cvcField.cardBrand = .visa + XCTAssertEqual(cvcField.text?.count, Int(STPCardValidator.maxCVCLength(for: .visa))) + } + +} diff --git a/Stripe/StripeiOSTests/STPCardCVCInputTextFieldValidatorTests.swift b/Stripe/StripeiOSTests/STPCardCVCInputTextFieldValidatorTests.swift new file mode 100644 index 00000000..deb018dc --- /dev/null +++ b/Stripe/StripeiOSTests/STPCardCVCInputTextFieldValidatorTests.swift @@ -0,0 +1,54 @@ +// +// STPCardCVCInputTextFieldValidatorTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/28/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPCardCVCInputTextFieldValidatorTests: XCTestCase { + + func testValidation() { + let validator = STPCardCVCInputTextFieldValidator() + validator.cardBrand = .visa + + validator.inputValue = "123" + if case .valid = validator.validationState { + XCTAssertTrue(true) + } else { + XCTAssertTrue(false, "123 should be valid for Visa") + } + + validator.inputValue = "1" + if case .incomplete(let description) = validator.validationState { + XCTAssertTrue(true) + XCTAssertEqual(description, "Your card's security code is incomplete.") + } else { + XCTAssertTrue(false, "1 should be incomplete for Visa") + } + + validator.inputValue = "1234" + if case .invalid(let errorMessage) = validator.validationState { + XCTAssertEqual(errorMessage, "Your card's security code is invalid.") + } else { + XCTAssertTrue(false, "1234 should be invalid for Visa") + } + + validator.cardBrand = .amex + // don't update inputValue so we know validationState is recalculated on cardBrand change + if case .valid = validator.validationState { + XCTAssertTrue(true) + } else { + XCTAssertTrue(false, "1234 should be valid for Amex") + } + } + +} diff --git a/Stripe/StripeiOSTests/STPCardExpiryInputTextFieldFormatterTests.swift b/Stripe/StripeiOSTests/STPCardExpiryInputTextFieldFormatterTests.swift new file mode 100644 index 00000000..ee89f7f3 --- /dev/null +++ b/Stripe/StripeiOSTests/STPCardExpiryInputTextFieldFormatterTests.swift @@ -0,0 +1,64 @@ +// +// STPCardExpiryInputTextFieldFormatterTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/28/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPCardExpiryInputTextFieldFormatterTests: XCTestCase { + + func testAllowedInput() { + let formatter = STPCardExpiryInputTextFieldFormatter() + XCTAssertTrue(formatter.isAllowedInput("1226", to: "", at: NSRange(location: 0, length: 0))) + XCTAssertTrue(formatter.isAllowedInput("12/26", to: "", at: NSRange(location: 0, length: 0))) + XCTAssertTrue(formatter.isAllowedInput("12 / 26", to: "", at: NSRange(location: 0, length: 0))) + XCTAssertTrue(formatter.isAllowedInput("122026", to: "", at: NSRange(location: 0, length: 0))) + XCTAssertTrue(formatter.isAllowedInput("12/2026", to: "", at: NSRange(location: 0, length: 0))) + + XCTAssertTrue(formatter.isAllowedInput("1", to: "", at: NSRange(location: 0, length: 0))) + XCTAssertTrue(formatter.isAllowedInput("2", to: "1", at: NSRange(location: 1, length: 0))) + XCTAssertTrue(formatter.isAllowedInput("2", to: "12", at: NSRange(location: 2, length: 0))) + XCTAssertTrue(formatter.isAllowedInput("2", to: "12/", at: NSRange(location: 2, length: 0))) + + // the formatter does NOT verify that these are sensical dates (that is delegated to the validator) + XCTAssertTrue(formatter.isAllowedInput("16/1901", to: "", at: NSRange(location: 0, length: 0))) + + XCTAssertFalse(formatter.isAllowedInput("12 / 25 / 26", to: "", at: NSRange(location: 0, length: 0))) + XCTAssertFalse(formatter.isAllowedInput("12 / 25 / 26", to: "", at: NSRange(location: 0, length: 0))) + XCTAssertFalse(formatter.isAllowedInput("12.26", to: "", at: NSRange(location: 0, length: 0))) + XCTAssertFalse(formatter.isAllowedInput("2026/12", to: "", at: NSRange(location: 0, length: 0))) + } + + func testFormattedText() { + let formatter = STPCardExpiryInputTextFieldFormatter() + XCTAssertEqual( + formatter.formattedText(from: "1226", with: [:]), + NSAttributedString(string: "12/26") + ) + XCTAssertEqual( + formatter.formattedText(from: "12/26", with: [:]), + NSAttributedString(string: "12/26") + ) + XCTAssertEqual( + formatter.formattedText(from: "12 / 26", with: [:]), + NSAttributedString(string: "12/26") + ) + XCTAssertEqual( + formatter.formattedText(from: "122026", with: [:]), + NSAttributedString(string: "12/26") + ) + XCTAssertEqual( + formatter.formattedText(from: "12 / 2026", with: [:]), + NSAttributedString(string: "12/26") + ) + } +} diff --git a/Stripe/StripeiOSTests/STPCardExpiryInputTextFieldSnapshotTests.swift b/Stripe/StripeiOSTests/STPCardExpiryInputTextFieldSnapshotTests.swift new file mode 100644 index 00000000..868afbd7 --- /dev/null +++ b/Stripe/StripeiOSTests/STPCardExpiryInputTextFieldSnapshotTests.swift @@ -0,0 +1,56 @@ +// +// STPCardExpiryInputTextFieldSnapshotTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/29/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPCardExpiryInputTextFieldSnapshotTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() + // recordMode = true + } + + func testEmpty() { + let field = STPCardExpiryInputTextField() + field.sizeToFit() + field.frame.size.width = 200 + + STPSnapshotVerifyView(field) + } + + func testIncomplete() { + let field = STPCardExpiryInputTextField() + field.sizeToFit() + field.frame.size.width = 200 + field.text = "1" + field.textDidChange() + + STPSnapshotVerifyView(field) + } + + // We can't have a valid test here because the date would have to change as time marches on + // func testValid() { + // } + + func testInvalid() { + let field = STPCardExpiryInputTextField() + field.sizeToFit() + field.frame.size.width = 200 + field.text = "16/22" + field.textDidChange() + + STPSnapshotVerifyView(field) + } + +} diff --git a/Stripe/StripeiOSTests/STPCardExpiryInputTextFieldValidatorTests.swift b/Stripe/StripeiOSTests/STPCardExpiryInputTextFieldValidatorTests.swift new file mode 100644 index 00000000..9b235605 --- /dev/null +++ b/Stripe/StripeiOSTests/STPCardExpiryInputTextFieldValidatorTests.swift @@ -0,0 +1,131 @@ +// +// STPCardExpiryInputTextFieldValidatorTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/28/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPCardExpiryInputTextFieldValidatorTests: XCTestCase { + + func testValidation() { + let now = Date() + guard + let nowMonth = Calendar(identifier: .gregorian).dateComponents( + Set([Calendar.Component.month]), + from: now + ).month, + let fullYear = Calendar(identifier: .gregorian).dateComponents( + Set([Calendar.Component.year]), + from: now + ).year + else { + XCTFail("Chaos reigns") + return + } + let nowYear = fullYear % 100 + + let validator = STPCardExpiryInputTextFieldValidator() + validator.inputValue = String(format: "01/%2d", (nowYear + 1) % 100) + if case .valid = validator.validationState { + XCTAssertTrue(true) + } else { + XCTFail("January of next year should be valid") + } + + let oneMonthAhead: String = { + if nowMonth == 12 { + return String(format: "01/%2d", (nowYear + 1) % 100) + } else { + return String(format: "%02d/%2d", nowMonth + 1, nowYear) + } + }() + validator.inputValue = oneMonthAhead + if case .valid = validator.validationState { + XCTAssertTrue(true) + } else { + XCTFail("One month ahead should be valid") + } + + let oneMonthAgo: String = { + if nowMonth == 1 { + return String(format: "01/%2d", max(0, nowYear - 1)) + } else { + return String(format: "%02d/%2d", nowMonth - 1, nowYear) + } + }() + validator.inputValue = oneMonthAgo + if case .invalid(let errorMessage) = validator.validationState { + XCTAssertEqual(errorMessage, "Your card's expiration year is invalid.") + } else { + XCTFail("One month ago should be invalid") + } + + let nonsensical = "16/55" + validator.inputValue = nonsensical + if case .invalid(let errorMessage) = validator.validationState { + XCTAssertEqual(errorMessage, "Your card's expiration date is invalid.") + } else { + XCTFail("Invalid month+year should be invalid") + } + + validator.inputValue = "2" + if case .incomplete(let description) = validator.validationState { + XCTAssertEqual(description, "Your card's expiration date is incomplete.") + } else { + XCTFail("One digit should be incomplete") + } + + validator.inputValue = "2/" + if case .incomplete(let description) = validator.validationState { + XCTAssertEqual(description, "Your card's expiration date is incomplete.") + } else { + XCTFail("One digit with separator should be incomplete") + } + + validator.inputValue = String(format: "1/%2d", (nowYear + 1) % 100) + if case .incomplete(let description) = validator.validationState { + XCTAssertEqual(description, "Your card's expiration date is incomplete.") + } else { + XCTFail("Single digit month should be incomplete") + } + + validator.inputValue = "13/" + if case .invalid(let description) = validator.validationState { + XCTAssertEqual(description, "Your card's expiration month is invalid.") + } else { + XCTFail("Invalid month should be invalid") + } + } + + func testExpiryStringFormatsYear() throws { + let validator = STPCardExpiryInputTextFieldValidator() + + validator.inputValue = "02/24" + + let expiryStrings = try XCTUnwrap(validator.expiryStrings) + + XCTAssertEqual(expiryStrings.month, "02") + XCTAssertEqual(expiryStrings.year, "2024") + } + + func testExpiryStringDoesNotFormatYear() throws { + let validator = STPCardExpiryInputTextFieldValidator() + + validator.inputValue = "02/2024" + + let expiryStrings = try XCTUnwrap(validator.expiryStrings) + + XCTAssertEqual(expiryStrings.month, "02") + XCTAssertEqual(expiryStrings.year, "2024") + } + +} diff --git a/Stripe/StripeiOSTests/STPCardFormViewSnapshotTests.swift b/Stripe/StripeiOSTests/STPCardFormViewSnapshotTests.swift new file mode 100644 index 00000000..6c1bab7e --- /dev/null +++ b/Stripe/StripeiOSTests/STPCardFormViewSnapshotTests.swift @@ -0,0 +1,126 @@ +// +// STPCardFormViewSnapshotTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/29/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPCardFormViewSnapshotTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() + // recordMode = true + } + + func testEmpty() { + let formView = STPCardFormView(billingAddressCollection: .automatic) + formView.countryCode = "US" + formView.frame = CGRect(origin: .zero, size: CGSize(width: 300, height: 225)) + + STPSnapshotVerifyView(formView) + } + + func testIncomplete() { + let formView = STPCardFormView(billingAddressCollection: .automatic) + formView.countryCode = "US" + formView.frame = CGRect(origin: .zero, size: CGSize(width: 300, height: 265)) + + formView.numberField.text = "4242" + formView.numberField.textDidChange() + formView.cvcField.text = "123" + formView.cvcField.textDidChange() + + STPSnapshotVerifyView(formView) + } + + // valid expiration date will change over time so we just test without it + func testCompleteWithoutExpiry() { + let formView = STPCardFormView(billingAddressCollection: .automatic) + formView.countryCode = "US" + formView.frame = CGRect(origin: .zero, size: CGSize(width: 300, height: 225)) + + formView.numberField.text = "4242424242424242" + formView.numberField.textDidChange() + formView.cvcField.text = "123" + formView.cvcField.textDidChange() + formView.postalCodeField.text = "12345" + + STPSnapshotVerifyView(formView) + } + + func testEmptyHiddenPostalCode() { + let formView = STPCardFormView(billingAddressCollection: .automatic) + formView.countryCode = "AE" + formView.frame = CGRect(origin: .zero, size: CGSize(width: 300, height: 225)) + + STPSnapshotVerifyView(formView) + } + + func testWithFullBillingDetails() { + let formView = STPCardFormView(billingAddressCollection: .required) + formView.countryCode = "US" + formView.frame = CGRect(origin: .zero, size: CGSize(width: 300, height: 400)) + + STPSnapshotVerifyView(formView) + } + + // MARK: - Standalone + + func testDefaultStandalone() { + let formView = STPCardFormView() + formView.countryCode = "US" + formView.frame = CGRect(origin: .zero, size: CGSize(width: 300, height: 225)) + + STPSnapshotVerifyView(formView) + } + + func testBorderlessStandalone() { + let formView = STPCardFormView(style: .borderless) + formView.countryCode = "US" + formView.frame = CGRect(origin: .zero, size: CGSize(width: 300, height: 225)) + + STPSnapshotVerifyView(formView) + } + + func testCustomBackgroundStandalone() { + let formView = STPCardFormView() + formView.countryCode = "US" + formView.backgroundColor = .green + formView.frame = CGRect(origin: .zero, size: CGSize(width: 300, height: 225)) + + STPSnapshotVerifyView(formView) + } + + func testCustomBackgroundDisabledColorStandalone() { + let formView = STPCardFormView() + formView.countryCode = "US" + formView.disabledBackgroundColor = .green + formView.isUserInteractionEnabled = false + formView.frame = CGRect(origin: .zero, size: CGSize(width: 300, height: 225)) + + STPSnapshotVerifyView(formView) + } + + func testBorderlessStandaloneIncomplete() { + let formView = STPCardFormView(style: .borderless) + formView.countryCode = "US" + formView.frame = CGRect(origin: .zero, size: CGSize(width: 300, height: 225)) + + formView.numberField.text = "4242" + formView.numberField.textDidChange() + formView.cvcField.text = "123" + formView.cvcField.textDidChange() + + STPSnapshotVerifyView(formView) + } + +} diff --git a/Stripe/StripeiOSTests/STPCardFormViewTests.swift b/Stripe/StripeiOSTests/STPCardFormViewTests.swift new file mode 100644 index 00000000..86c0ef55 --- /dev/null +++ b/Stripe/StripeiOSTests/STPCardFormViewTests.swift @@ -0,0 +1,243 @@ +// +// STPCardFormViewTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 1/19/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPCardFormViewTests: XCTestCase { + + func testMarkFormErrorsLogic() { + let cardForm = STPCardFormView() + + let handledErrorsTypes = [ + "incorrect_number", + "invalid_number", + "invalid_expiry_month", + "invalid_expiry_year", + "expired_card", + "invalid_cvc", + "incorrect_cvc", + "incorrect_zip", + ] + + let unhandledErrorTypes = [ + "card_declined", + "processing_error", + "imaginary_error", + "", + nil, + ] + + for shouldHandle in handledErrorsTypes { + let error = NSError( + domain: STPError.stripeDomain, + code: STPErrorCode.apiError.rawValue, + userInfo: [STPError.stripeErrorCodeKey: shouldHandle] + ) + XCTAssertTrue( + cardForm.markFormErrors(for: error), + "Failed to handle error for \(shouldHandle)" + ) + } + + for shouldNotHandle in unhandledErrorTypes { + let error: NSError + if let shouldNotHandle = shouldNotHandle { + error = NSError( + domain: STPError.stripeDomain, + code: STPErrorCode.apiError.rawValue, + userInfo: [STPError.stripeErrorCodeKey: shouldNotHandle] + ) + } else { + error = NSError( + domain: STPError.stripeDomain, + code: STPErrorCode.apiError.rawValue, + userInfo: nil + ) + } + XCTAssertFalse( + cardForm.markFormErrors(for: error), + "Incorrectly handled \(shouldNotHandle ?? "nil")" + ) + } + } + + func testHidingPostalCodeOnInit() { + NSLocale.stp_withLocale(as: NSLocale(localeIdentifier: "zh_Hans_HK")) { + let cardForm = STPCardFormView() + XCTAssertTrue(cardForm.postalCodeField.isHidden) + } + } + + func testHidingPostalUPECodeOnInit() { + NSLocale.stp_withLocale(as: NSLocale(localeIdentifier: "zh_Hans_HK")) { + let cardForm = STPCardFormView( + billingAddressCollection: .automatic, + includeCardScanning: false, + mergeBillingFields: false, + style: .standard, + postalCodeRequirement: .upe, + prefillDetails: nil + ) + XCTAssertTrue(cardForm.postalCodeField.isHidden) + } + } + + func testNotHidingPostalUPECodeOnInit() { + NSLocale.stp_withLocale(as: NSLocale(localeIdentifier: "en_US")) { + let cardForm = STPCardFormView( + billingAddressCollection: .automatic, + includeCardScanning: false, + mergeBillingFields: false, + style: .standard, + postalCodeRequirement: .upe, + prefillDetails: nil + ) + XCTAssertFalse(cardForm.postalCodeField.isHidden) + } + } + + func testPanLockedOnInit() { + NSLocale.stp_withLocale(as: NSLocale(localeIdentifier: "en_US")) { + let cardForm = STPCardFormView( + billingAddressCollection: .automatic, + includeCardScanning: false, + mergeBillingFields: false, + style: .standard, + postalCodeRequirement: .upe, + prefillDetails: nil, + inputMode: .panLocked + ) + XCTAssertFalse(cardForm.numberField.isUserInteractionEnabled) + } + } + + func testPrefilledOnInit() { + let prefillDeatils = STPCardFormView.PrefillDetails( + last4: "4242", + expiryMonth: 12, + expiryYear: 25, + cardBrand: .amex + ) + NSLocale.stp_withLocale(as: NSLocale(localeIdentifier: "en_US")) { + let cardForm = STPCardFormView( + billingAddressCollection: .automatic, + includeCardScanning: false, + mergeBillingFields: false, + style: .standard, + postalCodeRequirement: .upe, + prefillDetails: prefillDeatils, + inputMode: .panLocked + ) + + XCTAssertEqual(cardForm.numberField.text, prefillDeatils.formattedLast4) + XCTAssertEqual(cardForm.numberField.cardBrand, prefillDeatils.cardBrand) + XCTAssertEqual(cardForm.expiryField.text, prefillDeatils.formattedExpiry) + XCTAssertEqual(cardForm.cvcField.cardBrand, prefillDeatils.cardBrand) + } + } + + // MARK: Functional Tests + // If these fail it's _possibly_ because the returned error formats have changed + + func helperFunctionalTestNumber(_ cardNumber: String, shouldHandle: Bool) { + let createPaymentIntentExpectation = self.expectation( + description: "createPaymentIntentExpectation" + ) + var retrievedClientSecret: String? + STPTestingAPIClient.shared.createPaymentIntent(withParams: nil) { + (createdPIClientSecret, _) in + if let createdPIClientSecret = createdPIClientSecret { + retrievedClientSecret = createdPIClientSecret + createPaymentIntentExpectation.fulfill() + } else { + XCTFail() + } + } + wait(for: [createPaymentIntentExpectation], timeout: 8) // STPTestingNetworkRequestTimeout + guard let clientSecret = retrievedClientSecret, + let currentYear = Calendar.current.dateComponents([.year], from: Date()).year + else { + XCTFail() + return + } + + // STPTestingDefaultPublishableKey + let client = STPAPIClient(publishableKey: "pk_test_ErsyMEOTudSjQR8hh0VrQr5X008sBXGOu6") + + let expiryYear = NSNumber(value: currentYear + 2) + let expiryMonth = NSNumber(1) + + let cardParams = STPPaymentMethodCardParams() + cardParams.number = cardNumber + cardParams.expYear = expiryYear + cardParams.expMonth = expiryMonth + cardParams.cvc = "123" + + let address = STPPaymentMethodAddress() + address.postalCode = "12345" + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.address = address + + let paymentMethodParams = STPPaymentMethodParams.paramsWith( + card: cardParams, + billingDetails: billingDetails, + metadata: nil + ) + + let paymentIntentParams = STPPaymentIntentParams(clientSecret: clientSecret) + paymentIntentParams.paymentMethodParams = paymentMethodParams + + let confirmExpectation = expectation(description: "confirmExpectation") + client.confirmPaymentIntent(with: paymentIntentParams) { (_, error) in + if let error = error { + let cardForm = STPCardFormView() + if shouldHandle { + XCTAssertTrue( + cardForm.markFormErrors(for: error), + "Failed to handle \(error) for \(cardNumber)" + ) + } else { + XCTAssertFalse( + cardForm.markFormErrors(for: error), + "Incorrectly handled \(error) for \(cardNumber)" + ) + } + confirmExpectation.fulfill() + } else { + XCTFail() + } + } + wait(for: [confirmExpectation], timeout: 8) // STPTestingNetworkRequestTimeout + } + + func testExpiredCard() { + helperFunctionalTestNumber("4000000000000069", shouldHandle: true) + } + + func testIncorrectCVC() { + helperFunctionalTestNumber("4000000000000127", shouldHandle: true) + } + + func testIncorrectCardNumber() { + helperFunctionalTestNumber("4242424242424241", shouldHandle: true) + } + + func testCardDeclined() { + helperFunctionalTestNumber("4000000000000002", shouldHandle: false) + } + + func testProcessingError() { + helperFunctionalTestNumber("4000000000000119", shouldHandle: false) + } +} diff --git a/Stripe/StripeiOSTests/STPCardFunctionalTest.swift b/Stripe/StripeiOSTests/STPCardFunctionalTest.swift new file mode 100644 index 00000000..2b762cf4 --- /dev/null +++ b/Stripe/StripeiOSTests/STPCardFunctionalTest.swift @@ -0,0 +1,145 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPCardFunctionalTest.m +// Stripe +// +// Created by Ray Morgan on 7/11/14. +// +// + +import StripeCoreTestUtils +import XCTest + +class STPCardFunctionalTest: XCTestCase { + func testCreateCardToken() { + let card = STPCardParams() + + card.number = "4242 4242 4242 4242" + card.expMonth = 6 + card.expYear = 2024 + card.currency = "usd" + card.address.line1 = "123 Fake Street" + card.address.line2 = "Apartment 4" + card.address.city = "New York" + card.address.state = "NY" + card.address.country = "USA" + card.address.postalCode = "10002" + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let expectation = self.expectation(description: "Card creation") + + client.createToken( + withCard: card) { token, error in + XCTAssertNil(error, "error should be nil") + XCTAssertNotNil(token, "token should not be nil") + + XCTAssertNotNil(token?.tokenId) + XCTAssertEqual(token?.type, .card) + XCTAssertEqual(6, token?.card?.expMonth) + XCTAssertEqual(2024, token?.card?.expYear) + XCTAssertEqual("4242", token?.card?.last4) + XCTAssertEqual("usd", token?.card?.currency) + XCTAssertEqual("10002", token?.card?.address?.postalCode) + expectation.fulfill() + + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCardTokenCreationWithInvalidParams() { + let card = STPCardParams() + + card.number = "4242 4242 4242 4241" + card.expMonth = 6 + card.expYear = 2024 + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let expectation = self.expectation(description: "Card creation") + + client.createToken( + withCard: card) { token, error in + XCTAssertNotNil(error, "error should not be nil") + XCTAssertEqual((error as NSError?)?.code, 70) + XCTAssertEqual((error as NSError?)?.domain, STPError.stripeDomain) + XCTAssertEqual((error as NSError?)?.userInfo[STPError.errorParameterKey] as! String, "number") + XCTAssertNil(token, "token should be nil") + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCardTokenCreationWithExpiredCard() { + let card = STPCardParams() + + card.number = "4242 4242 4242 4242" + card.expMonth = 6 + card.expYear = 2013 + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let expectation = self.expectation(description: "Card creation") + + client.createToken( + withCard: card) { token, error in + XCTAssertNotNil(error, "error should not be nil") + XCTAssertEqual((error as NSError?)?.code, 70) + XCTAssertEqual((error as NSError?)?.domain, STPError.stripeDomain ) + XCTAssertEqual((error as NSError?)?.userInfo[STPError.cardErrorCodeKey] as! String, STPError.invalidExpYear) + XCTAssertEqual((error as NSError?)?.userInfo[STPError.errorParameterKey] as! String, "expYear") + XCTAssertNil(token, "token should be nil") + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testInvalidKey() { + let card = STPCardParams() + + card.number = "4242 4242 4242 4242" + card.expMonth = 6 + card.expYear = 2024 + + let client = STPAPIClient(publishableKey: "not_a_valid_key_asdf") + + let expectation = self.expectation(description: "Card failure") + client.createToken( + withCard: card) { token, error in + XCTAssertNil(token, "token should be nil") + XCTAssertNotNil(error, "error should not be nil") + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateCVCUpdateToken() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let expectation = self.expectation(description: "CVC Update Token Creation") + + client.createToken(forCVCUpdate: "1234") { token, error in + XCTAssertNil(error, "error should be nil") + XCTAssertNotNil(token, "token should not be nil") + + XCTAssertNotNil(token?.tokenId) + XCTAssertEqual(token?.type, .cvcUpdate, "token should be type CVC Update") + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testInvalidCVC() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let expectation = self.expectation(description: "Invalid CVC") + + client.createToken( + forCVCUpdate: "1") { token, error in + XCTAssertNil(token, "token should be nil") + XCTAssertNotNil(error, "error should not be nil") + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPCardNumberInputTextFieldFormatterTests.swift b/Stripe/StripeiOSTests/STPCardNumberInputTextFieldFormatterTests.swift new file mode 100644 index 00000000..2c6bc060 --- /dev/null +++ b/Stripe/StripeiOSTests/STPCardNumberInputTextFieldFormatterTests.swift @@ -0,0 +1,73 @@ +// +// STPCardNumberInputTextFieldFormatterTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/28/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPCardNumberInputTextFieldFormatterTests: XCTestCase { + + func testAllowedInput() { + let formatter = STPCardNumberInputTextFieldFormatter() + XCTAssertTrue(formatter.isAllowedInput("4242424242424242", to: "", at: NSRange(location: 0, length: 0))) + XCTAssertTrue(formatter.isAllowedInput("424242424242424", to: "", at: NSRange(location: 0, length: 0))) + XCTAssertTrue( + formatter.isAllowedInput("4242 4242 4242 4242", to: "", at: NSRange(location: 0, length: 0)) + ) + XCTAssertTrue(formatter.isAllowedInput("42424242 42424242", to: "", at: NSRange(location: 0, length: 0))) + XCTAssertTrue(formatter.isAllowedInput("4242 ", to: "", at: NSRange(location: 0, length: 0))) + XCTAssertTrue(formatter.isAllowedInput("3566002020360505", to: "", at: NSRange(location: 0, length: 0))) + XCTAssertTrue(formatter.isAllowedInput(" ", to: "4242", at: NSRange(location: 4, length: 0))) + + XCTAssertFalse( + formatter.isAllowedInput("4242.4242.4242.4242", to: "", at: NSRange(location: 0, length: 0)) + ) + XCTAssertFalse(formatter.isAllowedInput("4", to: "4242424242424242", at: NSRange(location: 0, length: 0))) + } + + func testFormatting() { + let formatter = STPCardNumberInputTextFieldFormatter() + var expected: NSMutableAttributedString = NSMutableAttributedString() + + expected = NSMutableAttributedString(string: "4242424242424242") + expected.addAttribute(.kern, value: NSNumber(0), range: NSRange(location: 0, length: 3)) + expected.addAttribute(.kern, value: NSNumber(5), range: NSRange(location: 3, length: 1)) + expected.addAttribute(.kern, value: NSNumber(0), range: NSRange(location: 4, length: 3)) + expected.addAttribute(.kern, value: NSNumber(5), range: NSRange(location: 7, length: 1)) + expected.addAttribute(.kern, value: NSNumber(0), range: NSRange(location: 8, length: 3)) + expected.addAttribute(.kern, value: NSNumber(5), range: NSRange(location: 11, length: 1)) + expected.addAttribute(.kern, value: NSNumber(0), range: NSRange(location: 12, length: 4)) + XCTAssertEqual(formatter.formattedText(from: "4242424242424242", with: [:]), expected) + XCTAssertEqual(formatter.formattedText(from: "4242 4242 4242 4242", with: [:]), expected) + + expected = NSMutableAttributedString(string: "4242") + expected.addAttribute(.kern, value: NSNumber(0), range: NSRange(location: 0, length: 4)) + XCTAssertEqual(formatter.formattedText(from: "4242", with: [:]), expected) + + expected = NSMutableAttributedString(string: "42424") + expected.addAttribute(.kern, value: NSNumber(0), range: NSRange(location: 0, length: 3)) + expected.addAttribute(.kern, value: NSNumber(5), range: NSRange(location: 3, length: 1)) + expected.addAttribute(.kern, value: NSNumber(0), range: NSRange(location: 4, length: 1)) + XCTAssertEqual(formatter.formattedText(from: "42424", with: [:]), expected) + + expected = NSMutableAttributedString(string: "378282246310005") // 4, 6, 5, + expected.addAttribute(.kern, value: NSNumber(0), range: NSRange(location: 0, length: 3)) + expected.addAttribute(.kern, value: NSNumber(5), range: NSRange(location: 3, length: 1)) + expected.addAttribute(.kern, value: NSNumber(0), range: NSRange(location: 4, length: 5)) + expected.addAttribute(.kern, value: NSNumber(5), range: NSRange(location: 9, length: 1)) + expected.addAttribute(.kern, value: NSNumber(0), range: NSRange(location: 10, length: 5)) + // expected.addAttribute(.kern, value: NSNumber(5), range: NSMakeRange(11, 1)) + // expected.addAttribute(.kern, value: NSNumber(0), range: NSMakeRange(12, 4)) + XCTAssertEqual(formatter.formattedText(from: "378282246310005", with: [:]), expected) + } + +} diff --git a/Stripe/StripeiOSTests/STPCardNumberInputTextFieldSnapshotTests.swift b/Stripe/StripeiOSTests/STPCardNumberInputTextFieldSnapshotTests.swift new file mode 100644 index 00000000..db9bf090 --- /dev/null +++ b/Stripe/StripeiOSTests/STPCardNumberInputTextFieldSnapshotTests.swift @@ -0,0 +1,61 @@ +// +// STPCardNumberInputTextFieldSnapshotTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/29/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPCardNumberInputTextFieldSnapshotTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() + // recordMode = true + } + + func testEmpty() { + let field = STPCardNumberInputTextField() + field.sizeToFit() + field.frame.size.width = 300 + + STPSnapshotVerifyView(field) + } + + func testIncomplete() { + let field = STPCardNumberInputTextField() + field.sizeToFit() + field.frame.size.width = 300 + field.text = "42" + field.textDidChange() + + STPSnapshotVerifyView(field) + } + + func testValid() { + let field = STPCardNumberInputTextField() + field.sizeToFit() + field.frame.size.width = 300 + field.text = "4242424242424242" + field.textDidChange() + + STPSnapshotVerifyView(field) + } + + func testInvalid() { + let field = STPCardNumberInputTextField() + field.sizeToFit() + field.frame.size.width = 300 + field.text = "4242424242424241" + field.textDidChange() + + STPSnapshotVerifyView(field) + } +} diff --git a/Stripe/StripeiOSTests/STPCardNumberInputTextFieldValidatorTests.swift b/Stripe/StripeiOSTests/STPCardNumberInputTextFieldValidatorTests.swift new file mode 100644 index 00000000..fb5ae08e --- /dev/null +++ b/Stripe/StripeiOSTests/STPCardNumberInputTextFieldValidatorTests.swift @@ -0,0 +1,207 @@ +// +// STPCardNumberInputTextFieldValidatorTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/29/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPCardNumberInputTextFieldValidatorTests: XCTestCase { + + static let cardData: [(STPCardBrand, String, STPValidatedInputState)] = { + return [ + ( + .visa, + "4242424242424242", + .valid(message: nil) + ), + ( + .visa, + "4242424242422", + .incomplete(description: "Your card number is incomplete.") + ), + ( + .visa, + "4012888888881881", + .valid(message: nil) + ), + ( + .visa, + "4000056655665556", + .valid(message: nil) + ), + ( + .mastercard, + "5555555555554444", + .valid(message: nil) + ), + ( + .mastercard, + "5200828282828210", + .valid(message: nil) + ), + ( + .mastercard, + "5105105105105100", + .valid(message: nil) + ), + ( + .mastercard, + "2223000010089800", + .valid(message: nil) + ), + ( + .amex, + "378282246310005", + .valid(message: nil) + ), + ( + .amex, + "371449635398431", + .valid(message: nil) + ), + ( + .discover, + "6011111111111117", + .valid(message: nil) + ), + ( + .discover, + "6011000990139424", + .valid(message: nil) + ), + ( + .dinersClub, + "36227206271667", + .valid(message: nil) + ), + ( + .dinersClub, + "3056930009020004", + .valid(message: nil) + ), + ( + .JCB, + "3530111333300000", + .valid(message: nil) + ), + ( + .JCB, + "3566002020360505", + .valid(message: nil) + ), + ( + .unknown, + "1234567812345678", + .invalid(errorMessage: "Your card number is invalid.") + ), + ] + }() + + func testValidation() { + // same tests as in STPCardValidatorTest#testNumberValidation + var tests: [(STPValidatedInputState, String, STPCardBrand)] = [] + + for card in STPCardNumberInputTextFieldValidatorTests.cardData { + tests.append((card.2, card.1, card.0)) + } + + tests.append((.valid(message: nil), "4242 4242 4242 4242", .visa)) + tests.append((.valid(message: nil), "4136000000008", .visa)) + + let badCardNumbers: [(String, STPCardBrand)] = [ + ("0000000000000000", .unknown), + ("9999999999999995", .unknown), + ("1", .unknown), + ("1234123412341234", .unknown), + ("xxx", .unknown), + ("9999999999999999999999", .unknown), + ("42424242424242424242", .visa), + ("4242-4242-4242-4242", .visa), + ] + + for card in badCardNumbers { + tests.append((.invalid(errorMessage: "Your card number is invalid."), card.0, card.1)) + } + + let possibleCardNumbers: [(String, STPCardBrand)] = [ + ("4242", .visa), ("5", .mastercard), ("3", .unknown), ("", .unknown), + (" ", .unknown), ("6011", .discover), ("4012888888881", .visa), + ] + + for card in possibleCardNumbers { + tests.append( + ( + .incomplete( + description: card.0.isEmpty ? nil : "Your card number is incomplete." + ), + card.0, card.1 + ) + ) + } + + let validator = STPCardNumberInputTextFieldValidator() + for test in tests { + let card = test.1 + validator.inputValue = card + let validationState = validator.validationState + let expected = test.0 + if !(validationState == expected) { + XCTFail("Expected \(expected), got \(validationState) for number \"\(card)\"") + } + let expectedCardBrand = test.2 + if !(validator.cardBrand == expectedCardBrand) { + XCTFail( + "Expected \(expectedCardBrand), got \(validator.cardBrand) for number \(card)" + ) + } + } + + validator.inputValue = "1" + XCTAssertEqual( + .invalid(errorMessage: "Your card number is invalid."), + validator.validationState + ) + + validator.inputValue = "0000000000000000" + XCTAssertEqual( + .invalid(errorMessage: "Your card number is invalid."), + validator.validationState + ) + + validator.inputValue = "9999999999999995" + XCTAssertEqual( + .invalid(errorMessage: "Your card number is invalid."), + validator.validationState + ) + + validator.inputValue = "0000000000000000000" + XCTAssertEqual( + .invalid(errorMessage: "Your card number is invalid."), + validator.validationState + ) + + validator.inputValue = "9999999999999999998" + XCTAssertEqual( + .invalid(errorMessage: "Your card number is invalid."), + validator.validationState + ) + + validator.inputValue = "4242424242424" + XCTAssertEqual( + .incomplete(description: "Your card number is incomplete."), + validator.validationState + ) + + validator.inputValue = nil + XCTAssertEqual(.incomplete(description: nil), validator.validationState) + } +} diff --git a/Stripe/StripeiOSTests/STPCardParamsTest.swift b/Stripe/StripeiOSTests/STPCardParamsTest.swift new file mode 100644 index 00000000..ea02a64d --- /dev/null +++ b/Stripe/StripeiOSTests/STPCardParamsTest.swift @@ -0,0 +1,180 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPCardParamsTest.m +// Stripe +// +// Created by Joey Dong on 6/19/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import XCTest + +class STPCardParamsTest: XCTestCase { + // MARK: - + + func testLast4ReturnsCardNumberLast4() { + let cardParams = STPCardParams() + cardParams.number = "4242424242424242" + XCTAssertEqual(cardParams.last4(), "4242") + } + + func testLast4ReturnsNilWhenNoCardNumberSet() { + let cardParams = STPCardParams() + XCTAssertNil(cardParams.last4()) + } + + func testLast4ReturnsNilWhenCardNumberIsLessThanLength4() { + let cardParams = STPCardParams() + cardParams.number = "123" + XCTAssertNil(cardParams.last4()) + } + + func testNameSharedWithAddress() { + let cardParams = STPCardParams() + + cardParams.name = "James" + XCTAssertEqual(cardParams.name, "James") + XCTAssertEqual(cardParams.address.name, "James") + + let address = STPAddress() + address.name = "Jim" + + cardParams.address = address + XCTAssertEqual(cardParams.name, "Jim") + XCTAssertEqual(cardParams.address.name, "Jim") + + // Doesn't update `name`, since mutation invisible to the STPCardParams + cardParams.address.name = "Smith" + XCTAssertEqual(cardParams.name, "Jim") + XCTAssertEqual(cardParams.address.name, "Smith") + } + + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + + func testAddress() { + let cardParams = STPCardParams() + cardParams.name = "John Smith" + cardParams.addressLine1 = "55 John St" + cardParams.addressLine2 = "#3B" + cardParams.addressCity = "New York" + cardParams.addressState = "NY" + cardParams.addressZip = "10002" + cardParams.addressCountry = "US" + + let address = cardParams.address + + XCTAssertEqual(address.name, "John Smith") + XCTAssertEqual(address.line1, "55 John St") + XCTAssertEqual(address.line2, "#3B") + XCTAssertEqual(address.city, "New York") + XCTAssertEqual(address.state, "NY") + XCTAssertEqual(address.postalCode, "10002") + XCTAssertEqual(address.country, "US") + } + + func testSetAddress() { + let address = STPAddress() + address.name = "John Smith" + address.line1 = "55 John St" + address.line2 = "#3B" + address.city = "New York" + address.state = "NY" + address.postalCode = "10002" + address.country = "US" + + let cardParams = STPCardParams() + cardParams.address = address + + XCTAssertEqual(cardParams.name, "John Smith") + XCTAssertEqual(cardParams.addressLine1, "55 John St") + XCTAssertEqual(cardParams.addressLine2, "#3B") + XCTAssertEqual(cardParams.addressCity, "New York") + XCTAssertEqual(cardParams.addressState, "NY") + XCTAssertEqual(cardParams.addressZip, "10002") + XCTAssertEqual(cardParams.addressCountry, "US") + } + + // #pragma clang diagnostic pop + + // MARK: - Description Tests + + func testDescription() { + let cardParams = STPCardParams() + XCTAssertNotNil(cardParams.description) + } + + // MARK: - STPFormEncodable Tests + + func testRootObjectName() { + XCTAssertEqual(STPCardParams.rootObjectName(), "card") + } + + func testPropertyNamesToFormFieldNamesMapping() { + let cardParams = STPCardParams() + + let mapping = STPCardParams.propertyNamesToFormFieldNamesMapping() + + for propertyName in mapping.keys { + guard let propertyName = propertyName as? String else { + continue + } + XCTAssertFalse(propertyName.contains(":")) + XCTAssert(cardParams.responds(to: NSSelectorFromString(propertyName))) + } + + for formFieldName in mapping.values { + guard let formFieldName = formFieldName as? String else { + continue + } + XCTAssert((formFieldName is NSString)) + XCTAssert(formFieldName.count > 0) + } + + XCTAssertEqual(mapping.values.count, Set(mapping.values).count) + } + + // MARK: - NSCopying Tests + + func testCopyWithZone() { + let cardParams = STPFixtures.cardParams() + cardParams.address = STPFixtures.address() + let copiedCardParams = cardParams.copy() as! STPCardParams + + // The property names we expect to *not* be equal objects + let notEqualProperties = [ + // these include the object's address, so they won't be the same across copies + "debugDescription", + "description", + "hash", + // STPAddress does not override isEqual:, so this is pointer comparison + "address", + ] + + // use runtime inspection to find the list of properties. If a new property is + // added to the fixture, but not the `copyWithZone:` implementation, this should catch it + for property in STPTestUtils.propertyNames(of: cardParams) { + if notEqualProperties.contains(property) { + XCTAssertNotEqual( + cardParams.value(forKey: property) as? NSObject, + copiedCardParams.value(forKey: property) as? NSObject) + } else { + XCTAssertEqual( + cardParams.value(forKey: property) as? NSObject, + copiedCardParams.value(forKey: property) as? NSObject) + } + } + } + + func testAddressIsNotCopied() { + let cardParams = STPFixtures.cardParams() + cardParams.address = STPFixtures.address() + let secondCardParams = STPCardParams() + + secondCardParams.address = cardParams.address + cardParams.address.line1 = "123 Main" + + XCTAssertEqual(cardParams.address.line1, "123 Main") + XCTAssertEqual(secondCardParams.address.line1, "123 Main") + } +} diff --git a/Stripe/StripeiOSTests/STPCardTest.swift b/Stripe/StripeiOSTests/STPCardTest.swift new file mode 100644 index 00000000..999d77d2 --- /dev/null +++ b/Stripe/StripeiOSTests/STPCardTest.swift @@ -0,0 +1,263 @@ +// +// STPCardTest.swift +// StripeiOS Tests +// +// Created by Saikat Chakrabarti on 11/5/12. +// Copyright © 2012 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPCardTest: XCTestCase { + // MARK: - STPCardBrand Tests + + // These are only intended to be deprecated publicly. + // When removed from public header, can remove these pragmas + func testBrandFromString() { + XCTAssertEqual(STPCard.brand(from: "visa"), .visa) + XCTAssertEqual(STPCard.brand(from: "VISA"), .visa) + + XCTAssertEqual(STPCard.brand(from: "american express"), .amex) + XCTAssertEqual(STPCard.brand(from: "AMERICAN EXPRESS"), .amex) + + XCTAssertEqual(STPCard.brand(from: "mastercard"), .mastercard) + XCTAssertEqual(STPCard.brand(from: "MASTERCARD"), .mastercard) + + XCTAssertEqual(STPCard.brand(from: "discover"), .discover) + XCTAssertEqual(STPCard.brand(from: "DISCOVER"), .discover) + + XCTAssertEqual(STPCard.brand(from: "jcb"), .JCB) + XCTAssertEqual(STPCard.brand(from: "JCB"), .JCB) + + XCTAssertEqual(STPCard.brand(from: "diners club"), .dinersClub) + XCTAssertEqual(STPCard.brand(from: "DINERS CLUB"), .dinersClub) + + XCTAssertEqual(STPCard.brand(from: "unionpay"), .unionPay) + XCTAssertEqual(STPCard.brand(from: "UNIONPAY"), .unionPay) + + XCTAssertEqual(STPCard.brand(from: "unknown"), .unknown) + XCTAssertEqual(STPCard.brand(from: "UNKNOWN"), .unknown) + + XCTAssertEqual(STPCard.brand(from: "garbage"), .unknown) + XCTAssertEqual(STPCard.brand(from: "GARBAGE"), .unknown) + } + + // MARK: - STPCardFundingType Tests + + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + // These are only intended to be deprecated publicly. + // When removed from public header, can remove these pragmas + func testFundingFromString() { + XCTAssertEqual(STPCard.funding(from: "credit"), .credit) + XCTAssertEqual(STPCard.funding(from: "CREDIT"), .credit) + + XCTAssertEqual(STPCard.funding(from: "debit"), .debit) + XCTAssertEqual(STPCard.funding(from: "DEBIT"), .debit) + + XCTAssertEqual(STPCard.funding(from: "prepaid"), .prepaid) + XCTAssertEqual(STPCard.funding(from: "PREPAID"), .prepaid) + + XCTAssertEqual(STPCard.funding(from: "other"), .other) + XCTAssertEqual(STPCard.funding(from: "OTHER"), .other) + + XCTAssertEqual(STPCard.funding(from: "unknown"), .other) + XCTAssertEqual(STPCard.funding(from: "UNKNOWN"), .other) + + XCTAssertEqual(STPCard.funding(from: "garbage"), .other) + XCTAssertEqual(STPCard.funding(from: "GARBAGE"), .other) + } + + // #pragma clang diagnostic pop + func testStringFromFunding() { + let values: [STPCardFundingType] = [ + .credit, + .debit, + .prepaid, + .other, + ] + + for funding in values { + let string = STPCard.string(fromFunding: funding) + + switch funding { + case .credit: + XCTAssertEqual(string, "credit") + case .debit: + XCTAssertEqual(string, "debit") + case .prepaid: + XCTAssertEqual(string, "prepaid") + case .other: + XCTAssertNil(string) + default: + break + } + } + } + + // MARK: - + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + // These tests can ber removed in the future, they should be covered by + // the equivalent response decodeable tests + func testInitWithIDBrandLast4ExpMonthExpYearFunding() { + let card = STPCard( + id: "card_1AVRojEOD54MuFwSxr93QJSx", + brand: .visa, + last4: "5556", + expMonth: 12, + expYear: 2034, + funding: .debit + ) + XCTAssertEqual(card.stripeID, "card_1AVRojEOD54MuFwSxr93QJSx") + XCTAssertEqual(card.brand, .visa) + XCTAssertEqual(card.last4, "5556") + XCTAssertEqual(card.expMonth, Int(12)) + XCTAssertEqual(card.expYear, Int(2034)) + XCTAssertEqual(card.funding, .debit) + } + + // #pragma clang diagnostic pop + func testIsApplePayCard() { + let card = STPFixtures.card() + + card.allResponseFields = [:] + XCTAssertFalse(card.isApplePayCard) + + card.allResponseFields = [ + "tokenization_method": "android_pay" + ] + XCTAssertFalse(card.isApplePayCard) + + card.allResponseFields = [ + "tokenization_method": "apple_pay" + ] + XCTAssertTrue(card.isApplePayCard) + + card.allResponseFields = [ + "tokenization_method": "garbage" + ] + XCTAssertFalse(card.isApplePayCard) + + card.allResponseFields = [ + "tokenization_method": "" + ] + XCTAssertFalse(card.isApplePayCard) + + // See: https://stripe.com/docs/api#card_object-tokenization_method + } + + func testAddressPopulated() { + let card = STPFixtures.card() + XCTAssertEqual(card.address?.name, "Jane Austen") + XCTAssertEqual(card.address?.line1, "123 Fake St") + XCTAssertEqual(card.address?.line2, "Apt 1") + XCTAssertEqual(card.address?.city, "Pittsburgh") + XCTAssertEqual(card.address?.state, "PA") + XCTAssertEqual(card.address?.postalCode, "19219") + XCTAssertEqual(card.address?.country, "US") + } + + // MARK: - Equality Tests + func testCardEquals() { + let card1 = STPFixtures.card() + let card2 = STPFixtures.card() + + XCTAssertEqual(card1, card1) + XCTAssertEqual(card1, card2) + + XCTAssertEqual(card1.hash, card1.hash) + XCTAssertEqual(card1.hash, card2.hash) + } + + // MARK: - STPAPIResponseDecodable Tests + func testDecodedObjectFromAPIResponseRequiredFields() { + let requiredFields = ["id", "last4", "brand", "exp_month", "exp_year"] + + for field in requiredFields { + var response = STPTestUtils.jsonNamed("Card") + response?.removeValue(forKey: field) + + XCTAssertNil(STPCard.decodedObject(fromAPIResponse: response)) + } + + XCTAssert((STPCard.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("Card")) != nil)) + } + + func testDecodedObjectFromAPIResponseMapping() { + let response = STPTestUtils.jsonNamed("Card")! + let card = STPCard.decodedObject(fromAPIResponse: response)! + + XCTAssertEqual(card.stripeID, "card_103kbR2eZvKYlo2CDczLmw4K") + + XCTAssertEqual(card.address?.city, "Pittsburgh") + XCTAssertEqual(card.address?.country, "US") + XCTAssertEqual(card.address?.line1, "123 Fake St") + XCTAssertEqual(card.address?.line2, "Apt 1") + XCTAssertEqual(card.address?.state, "PA") + XCTAssertEqual(card.address?.postalCode, "19219") + + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + + XCTAssertEqual(card.cardId, "card_103kbR2eZvKYlo2CDczLmw4K") + + XCTAssertEqual(card.addressCity, "Pittsburgh") + XCTAssertEqual(card.addressCountry, "US") + XCTAssertEqual(card.addressLine1, "123 Fake St") + XCTAssertEqual(card.addressLine2, "Apt 1") + XCTAssertEqual(card.addressState, "PA") + XCTAssertEqual(card.addressZip, "19219") + XCTAssertNil(card.metadata) + + // #pragma clang diagnostic pop + + XCTAssertEqual(card.brand, .visa) + XCTAssertEqual(card.country, "US") + XCTAssertEqual(card.currency, "usd") + XCTAssertEqual(card.dynamicLast4, "5678") + XCTAssertEqual(card.expMonth, Int(5)) + XCTAssertEqual(card.expYear, Int(2017)) + XCTAssertEqual(card.funding, .credit) + XCTAssertEqual(card.last4, "4242") + XCTAssertEqual(card.name, "Jane Austen") + + XCTAssertEqual(card.allResponseFields as NSDictionary, response as NSDictionary) + } + + // MARK: - STPSourceProtocol Tests + func testStripeID() { + let card = STPFixtures.card() + XCTAssertEqual(card.stripeID, "card_103kbR2eZvKYlo2CDczLmw4K") + } + + // MARK: - STPPaymentOption Tests + func testLabel() { + let card = STPCard.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("Card"))! + XCTAssertEqual(card.label, "Visa 4242") + } + + // MARK: - + func forEachBrand(_ block: @escaping (_ brand: STPCardBrand) -> Void) { + let values: [STPCardBrand] = [ + .amex, + .dinersClub, + .discover, + .JCB, + .mastercard, + .unionPay, + .visa, + .unknown, + ] + + for brand in values { + block(brand) + } + } +} diff --git a/Stripe/StripeiOSTests/STPCardValidatorTest.swift b/Stripe/StripeiOSTests/STPCardValidatorTest.swift new file mode 100644 index 00000000..ea3616f3 --- /dev/null +++ b/Stripe/StripeiOSTests/STPCardValidatorTest.swift @@ -0,0 +1,472 @@ +// +// STPCardValidatorTest.swift +// StripeiOS Tests +// +// Created by Jack Flintermann on 7/24/15. +// Copyright (c) 2015 Stripe, Inc. All rights reserved. +// + +import UIKit +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable import StripeCoreTestUtils +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPCardValidatorTest: XCTestCase { + static let cardData: [(STPCardBrand, String, STPCardValidationState)] = { + return [ + ( + .visa, + "4242424242424242", + .valid + ), + ( + .visa, + "4242424242422", + .incomplete + ), + ( + .visa, + "4012888888881881", + .valid + ), + ( + .visa, + "4000056655665556", + .valid + ), + ( + .mastercard, + "5555555555554444", + .valid + ), + ( + .mastercard, + "5200828282828210", + .valid + ), + ( + .mastercard, + "5105105105105100", + .valid + ), + ( + .mastercard, + "2223000010089800", + .valid + ), + ( + .amex, + "378282246310005", + .valid + ), + ( + .amex, + "371449635398431", + .valid + ), + ( + .discover, + "6011111111111117", + .valid + ), + ( + .discover, + "6011000990139424", + .valid + ), + ( + .dinersClub, + "36227206271667", + .valid + ), + ( + .dinersClub, + "3056930009020004", + .valid + ), + ( + .JCB, + "3530111333300000", + .valid + ), + ( + .JCB, + "3566002020360505", + .valid + ), + ( + .unknown, + "1234567812345678", + .invalid + ), + ] + }() + + func testNumberSanitization() { + let tests = [ + ["4242424242424242", "4242424242424242"], + ["XXXXXX", ""], + ["424242424242424X", "424242424242424"], + ["X4242", "4242"], + ["4242 4242 4242 4242", "4242424242424242"], + ] + for test in tests { + XCTAssertEqual(STPCardValidator.sanitizedNumericString(for: test[0]), test[1]) + } + } + + func testNumberValidation() { + var tests: [(STPCardValidationState, String)] = [] + + for card in STPCardValidatorTest.cardData { + tests.append((card.2, card.1)) + } + + tests.append((.valid, "4242 4242 4242 4242")) + tests.append((.valid, "4136000000008")) + + let badCardNumbers = [ + "0000000000000000", + "9999999999999995", + "1", + "1234123412341234", + "xxx", + "9999999999999999999999", + "42424242424242424242", + "4242-4242-4242-4242", + ] + + for card in badCardNumbers { + tests.append((.invalid, card)) + } + + let possibleCardNumbers = ["4242", "5", "3", "", " ", "6011", "4012888888881"] + + for card in possibleCardNumbers { + tests.append((.incomplete, card)) + } + + for test in tests { + let card = test.1 + let validationState = STPCardValidator.validationState( + forNumber: card, + validatingCardBrand: true + ) + let expected = test.0 + if !(validationState == expected) { + XCTFail("Expected \(expected), got \(validationState) for number \(card)") + } + } + + XCTAssertEqual( + .incomplete, + STPCardValidator.validationState(forNumber: "1", validatingCardBrand: false) + ) + XCTAssertEqual( + .incomplete, + STPCardValidator.validationState( + forNumber: "0000000000000000", + validatingCardBrand: false + ) + ) + XCTAssertEqual( + .incomplete, + STPCardValidator.validationState( + forNumber: "9999999999999995", + validatingCardBrand: false + ) + ) + XCTAssertEqual( + .valid, + STPCardValidator.validationState( + forNumber: "0000000000000000000", + validatingCardBrand: false + ) + ) + XCTAssertEqual( + .valid, + STPCardValidator.validationState( + forNumber: "9999999999999999998", + validatingCardBrand: false + ) + ) + XCTAssertEqual( + .incomplete, + STPCardValidator.validationState(forNumber: "4242424242424", validatingCardBrand: true) + ) + XCTAssertEqual( + .incomplete, + STPCardValidator.validationState(forNumber: nil, validatingCardBrand: true) + ) + } + + func testBrand() { + for test in STPCardValidatorTest.cardData { + XCTAssertEqual(STPCardValidator.brand(forNumber: test.1), test.0) + } + } + + func testLengthsForCardBrand() { + let tests: [(STPCardBrand, Set)] = [ + (.visa, Set([13, 16])), + (.mastercard, Set([16])), + (.amex, Set([15])), + (.discover, Set([16])), + (.dinersClub, Set([14, 16])), + (.JCB, Set([16])), + (.unionPay, Set([16, 19])), + (.unknown, Set([19])), + ] + for test in tests { + let lengths = STPCardValidator.lengths(for: test.0) as NSSet + let expected = test.1 as NSSet + if !lengths.isEqual(expected) { + XCTFail("Invalid lengths for brand \(test.0): expected \(expected), got \(lengths)") + } + } + } + + func testFragmentLength() { + let tests: [(STPCardBrand, Int)] = [ + (.visa, 4), + (.mastercard, 4), + (.amex, 5), + (.discover, 4), + (.dinersClub, 4), + (.JCB, 4), + (.unionPay, 4), + (.unknown, 4), + ] + for test in tests { + XCTAssertEqual(STPCardValidator.fragmentLength(for: test.0), test.1) + } + } + + func testMonthValidation() { + let tests: [(String, STPCardValidationState)] = [ + ("", .incomplete), + ("0", .incomplete), + ("1", .incomplete), + ("2", .valid), + ("9", .valid), + ("10", .valid), + ("12", .valid), + ("13", .invalid), + ("11a", .invalid), + ("x", .invalid), + ("100", .invalid), + ("00", .invalid), + ("13", .invalid), + ] + for test in tests { + XCTAssertEqual(STPCardValidator.validationState(forExpirationMonth: test.0), test.1) + } + } + + func testYearValidation() { + let tests: [(String, String, STPCardValidationState)] = [ + ("12", "15", .valid), + ("8", "15", .valid), + ("9", "15", .valid), + ("11", "16", .valid), + ("11", "99", .valid), + ("01", "99", .valid), + ("1", "99", .valid), + ("00", "99", .invalid), + ("12", "14", .invalid), + ("7", "15", .invalid), + ("12", "00", .invalid), + ("13", "16", .invalid), + ("12", "2", .incomplete), + ("12", "1", .incomplete), + ("12", "0", .incomplete), + ] + + for test in tests { + let state = STPCardValidator.validationState( + forExpirationYear: test.1, + inMonth: test.0, + inCurrentYear: 15, + currentMonth: 8 + ) + XCTAssertEqual(state, test.2) + } + } + + func testCVCLength() { + let tests: [(STPCardBrand, UInt)] = [ + (.visa, 3), + (.mastercard, 3), + (.amex, 4), + (.discover, 3), + (.dinersClub, 3), + (.JCB, 3), + (.unionPay, 3), + (.unknown, 4), + ] + for test in tests { + let maxCVCLength = STPCardValidator.maxCVCLength(for: test.0) + XCTAssertEqual(maxCVCLength, test.1) + } + } + + func testCVCValidation() { + let tests: [(String, STPCardBrand, STPCardValidationState)] = [ + ("x", .visa, .invalid), + ("", .visa, .incomplete), + ("1", .visa, .incomplete), + ("12", .visa, .incomplete), + ("1x3", .visa, .invalid), + ("123", .visa, .valid), + ("123", .amex, .valid), + ("123", .unknown, .valid), + ("1234", .visa, .invalid), + ("1234", .amex, .valid), + ("12345", .amex, .invalid), + ] + + for test in tests { + let state = STPCardValidator.validationState(forCVC: test.0, cardBrand: test.1) + XCTAssertEqual(state, test.2) + } + } + + func testCardValidation() { + // swiftlint:disable:next large_tuple + let tests: [(String, UInt, UInt, String, STPCardValidationState)] = [ + ( + "4242424242424242", + 12, + 15, + "123", + .valid + ), + ( + "4242424242424242", + 12, + 15, + "x", + .invalid + ), + ( + "4242424242424242", + 12, + 15, + "1", + .incomplete + ), + ( + "4242424242424242", + 12, + 14, + "123", + .invalid + ), + ( + "4242424242424242", + 21, + 15, + "123", + .invalid + ), + ( + "42424242", + 12, + 15, + "123", + .incomplete + ), + ( + "378282246310005", + 12, + 15, + "1234", + .valid + ), + ( + "378282246310005", + 12, + 15, + "123", + .valid + ), + ( + "378282246310005", + 12, + 15, + "12345", + .invalid + ), + ( + "1234567812345678", + 12, + 15, + "12345", + .invalid + ), + ] + for test in tests { + let card = STPCardParams() + card.number = test.0 + card.expMonth = test.1 + card.expYear = test.2 + card.cvc = test.3 + let state = STPCardValidator.validationState( + forCard: card, + inCurrentYear: 15, + currentMonth: 8 + ) + if state != test.4 { + XCTFail( + "Wrong validation state for \(String(describing: card.number)). Expected \(test.4), got \(state))" + ) + } + } + } + + func testCBCFetch() { + STPAPIClient.shared.publishableKey = STPTestingDefaultPublishableKey + let mcExp = expectation(description: "Mastercard/CBC") + let visaExp = expectation(description: "Visa/CBC") + let justVisaExp = expectation(description: "Visa Only") + let paramsExp = expectation(description: "Params") + let emptyParamsExp = expectation(description: "Empty Params") + STPCardValidator.possibleBrands(forNumber: "513130") { result in + let brands = try! result.get() + XCTAssertEqual(brands, [.cartesBancaires, .mastercard]) + mcExp.fulfill() + } + STPCardValidator.possibleBrands(forNumber: "455673") { result in + let brands = try! result.get() + XCTAssertEqual(brands, [.cartesBancaires, .visa]) + visaExp.fulfill() + } + STPCardValidator.possibleBrands(forNumber: "424242") { result in + let brands = try! result.get() + XCTAssertEqual(brands, [.visa]) + justVisaExp.fulfill() + } + + let params = STPPaymentMethodCardParams() + params.number = "5131301234" + STPCardValidator.possibleBrands(forCard: params) { result in + let brands = try! result.get() + XCTAssertEqual(brands, [.cartesBancaires, .mastercard]) + paramsExp.fulfill() + } + + let paramsEmpty = STPPaymentMethodCardParams() + STPCardValidator.possibleBrands(forCard: paramsEmpty) { result in + let brands = try! result.get() + XCTAssertEqual(brands, Set(STPCardBrand.allCases)) + emptyParamsExp.fulfill() + } + + wait(for: [mcExp, visaExp, justVisaExp, paramsExp, emptyParamsExp], timeout: STPTestingNetworkRequestTimeout) + } +} diff --git a/Stripe/StripeiOSTests/STPCertTest.swift b/Stripe/StripeiOSTests/STPCertTest.swift new file mode 100644 index 00000000..5280f9fe --- /dev/null +++ b/Stripe/StripeiOSTests/STPCertTest.swift @@ -0,0 +1,68 @@ +// +// STPCertTest.swift +// StripeiOS Tests +// +// Created by Phillip Cohen on 4/14/14. +// Copyright © 2014 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +let STPExamplePublishableKey = "bad_key" + +class STPCertTest: XCTestCase { + func testNoError() { + let expectation = self.expectation(description: "Token creation") + let client = STPAPIClient(publishableKey: STPExamplePublishableKey) + client.createToken( + withParameters: [:]) { token, error in + expectation.fulfill() + // Note that this API request *will* fail, but it will return error + // messages from the server and not be blocked by local cert checks + XCTAssertNil(token, "Expected no token") + XCTAssertNotNil(error, "Expected error") + } + waitForExpectations(timeout: 20.0, handler: nil) + } + + func testExpired() { + createToken( + withBaseURL: URL(string: "https://expired.badssl.com/") + ) { token, error in + XCTAssertNil(token, "Token should be nil.") + XCTAssertEqual((error as NSError?)?.domain, "NSURLErrorDomain") + XCTAssertNotNil( + (error as NSError?)?.userInfo["NSURLErrorFailingURLPeerTrustErrorKey"], + "There should be a secTustRef for Foundation HTTPS errors" + ) + } + } + + func testMismatched() { + createToken( + withBaseURL: URL(string: "https://mismatched.stripe.com") + ) { token, error in + XCTAssertNil(token, "Token should be nil.") + XCTAssertEqual((error as NSError?)?.domain, "NSURLErrorDomain") + } + } + + // helper method + func createToken(withBaseURL baseURL: URL?, completion: @escaping STPTokenCompletionBlock) { + let expectation = self.expectation(description: "Token creation") + let client = STPAPIClient(publishableKey: STPExamplePublishableKey) + client.apiURL = baseURL + client.createToken( + withParameters: [:]) { token, error in + expectation.fulfill() + completion(token, error) + } + waitForExpectations(timeout: 20.0, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPConfirmCardOptionsTest.swift b/Stripe/StripeiOSTests/STPConfirmCardOptionsTest.swift new file mode 100644 index 00000000..23a508af --- /dev/null +++ b/Stripe/StripeiOSTests/STPConfirmCardOptionsTest.swift @@ -0,0 +1,31 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPConfirmCardOptionsTest.m +// StripeiOS Tests +// +// Created by Cameron Sabol on 1/10/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +class STPConfirmCardOptionsTest: XCTestCase { + func testCVC() { + let cardOptions = STPConfirmCardOptions() + + XCTAssertNil(cardOptions.cvc, "Initial/default value should be nil.") + XCTAssertNil(cardOptions.network, "Initial/default value should be nil.") + + cardOptions.cvc = "123" + XCTAssertEqual(cardOptions.cvc, "123") + cardOptions.network = "visa" + XCTAssertEqual(cardOptions.network, "visa") + } + + func testEncoding() { + let propertyMap = STPConfirmCardOptions.propertyNamesToFormFieldNamesMapping() + let expected = [ + "cvc": "cvc", + "network": "network", + ] + XCTAssertEqual(propertyMap, expected) + } +} diff --git a/Stripe/StripeiOSTests/STPConfirmPaymentMethodOptionsTest.swift b/Stripe/StripeiOSTests/STPConfirmPaymentMethodOptionsTest.swift new file mode 100644 index 00000000..02cd737e --- /dev/null +++ b/Stripe/StripeiOSTests/STPConfirmPaymentMethodOptionsTest.swift @@ -0,0 +1,34 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPConfirmPaymentMethodOptionsTest.m +// StripeiOS Tests +// +// Created by Cameron Sabol on 1/10/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +class STPConfirmPaymentMethodOptionsTest: XCTestCase { + func testCardOptions() { + let paymentMethodOptions = STPConfirmPaymentMethodOptions() + + XCTAssertNil(paymentMethodOptions.cardOptions, "Default card value should be nil.") + + let cardOptions = STPConfirmCardOptions() + paymentMethodOptions.cardOptions = cardOptions + XCTAssertEqual(paymentMethodOptions.cardOptions, cardOptions, "Should hold reference to set cardOptions.") + } + + func testFormEncoding() { + let propertyToFieldMap = STPConfirmPaymentMethodOptions.propertyNamesToFormFieldNamesMapping() + let expected = [ + "cardOptions": "card", + "alipayOptions": "alipay", + "blikOptions": "blik", + "weChatPayOptions": "wechat_pay", + "usBankAccountOptions": "us_bank_account", + "konbiniOptions": "konbini", + ] + + XCTAssertEqual(propertyToFieldMap, expected) + } +} diff --git a/Stripe/StripeiOSTests/STPConnectAccountAddressTest.swift b/Stripe/StripeiOSTests/STPConnectAccountAddressTest.swift new file mode 100644 index 00000000..f000a1b5 --- /dev/null +++ b/Stripe/StripeiOSTests/STPConnectAccountAddressTest.swift @@ -0,0 +1,40 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPConnectAccountAddressTest.m +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 8/2/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +class STPConnectAccountAddressTest: XCTestCase { + // MARK: STPFormEncodable Tests + + func testRootObjectName() { + XCTAssertNil(STPConnectAccountAddress.rootObjectName()) + } + + func testPropertyNamesToFormFieldNamesMapping() { + let address = STPConnectAccountAddress() + + let mapping = STPConnectAccountAddress.propertyNamesToFormFieldNamesMapping() + + for propertyName in mapping.keys { + guard let propertyName = propertyName as? String else { + continue + } + XCTAssertFalse(propertyName.contains(":")) + XCTAssert(address.responds(to: NSSelectorFromString(propertyName))) + } + + for formFieldName in mapping.values { + guard let formFieldName = formFieldName as? String else { + continue + } + XCTAssert((formFieldName is NSString)) + XCTAssert(formFieldName.count > 0) + } + + XCTAssertEqual(mapping.values.count, Set(mapping.values).count) + } +} diff --git a/Stripe/StripeiOSTests/STPConnectAccountFunctionalTest.swift b/Stripe/StripeiOSTests/STPConnectAccountFunctionalTest.swift new file mode 100644 index 00000000..372986d1 --- /dev/null +++ b/Stripe/StripeiOSTests/STPConnectAccountFunctionalTest.swift @@ -0,0 +1,85 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPConnectAccountFunctionalTest.m +// StripeiOS Tests +// +// Created by Daniel Jackson on 1/8/18. +// Copyright © 2018 Stripe, Inc. All rights reserved. +// + +import StripeCore +import StripeCoreTestUtils + +class STPConnectAccountFunctionalTest: XCTestCase { + /// Client with test publishable key + var client: STPAPIClient! + var individual: STPConnectAccountIndividualParams! + var company: STPConnectAccountCompanyParams! + + override func setUp() { + super.setUp() + + client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + individual = STPConnectAccountIndividualParams() + individual.firstName = "Test" + var dob = DateComponents() + dob.day = 31 + dob.month = 8 + dob.year = 2006 + individual.dateOfBirth = dob + company = STPConnectAccountCompanyParams() + company.name = "Test" + } + + func testTokenCreation_terms_nil() { + XCTAssertNil( + STPConnectAccountParams( + tosShownAndAccepted: false, + individual: individual), + "Guard to prevent trying to call this with `NO`") + XCTAssertNil( + STPConnectAccountParams( + tosShownAndAccepted: false, + company: company), + "Guard to prevent trying to call this with `NO`") + } + + func testTokenCreation_customer() { + createToken( + STPConnectAccountParams(company: company), + shouldSucceed: true) + } + + func testTokenCreation_company() { + createToken( + STPConnectAccountParams(individual: individual), + shouldSucceed: true) + } + + func testTokenCreation_empty_init() { + createToken(STPConnectAccountParams(), shouldSucceed: true) + + } + + // MARK: - + + func createToken(_ params: STPConnectAccountParams?, shouldSucceed: Bool) { + let expectation = self.expectation(description: "Connect Account Token") + + client.createToken(withConnectAccount: params!) { token, error in + expectation.fulfill() + + if shouldSucceed { + XCTAssertNil(error) + XCTAssertNotNil(token) + XCTAssertNotNil(token?.tokenId) + XCTAssertEqual(token?.type, .account) + } else { + XCTAssertNil(token) + XCTAssertNotNil(error) + } + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPConnectAccountParamsTest.swift b/Stripe/StripeiOSTests/STPConnectAccountParamsTest.swift new file mode 100644 index 00000000..15d9a2bf --- /dev/null +++ b/Stripe/StripeiOSTests/STPConnectAccountParamsTest.swift @@ -0,0 +1,53 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPConnectAccountParamsTest.m +// StripeiOS Tests +// +// Created by Daniel Jackson on 1/10/18. +// Copyright © 2018 Stripe, Inc. All rights reserved. +// + +@testable import StripePayments + +class STPConnectAccountParamsTest: XCTestCase { + // MARK: - STPFormEncodable Tests + + func testRootObjectName() { + XCTAssertEqual(STPConnectAccountParams.rootObjectName(), "account") + } + + func testBusinessType() { + let individual = STPConnectAccountIndividualParams() + let company = STPConnectAccountCompanyParams() + + XCTAssertEqual(STPConnectAccountParams(individual: individual).businessType, .individual) + XCTAssertEqual(STPConnectAccountParams(tosShownAndAccepted: true, individual: individual)!.businessType, .individual) + + XCTAssertEqual(STPConnectAccountParams(company: company).businessType, .company) + XCTAssertEqual(STPConnectAccountParams(tosShownAndAccepted: true, company: company)!.businessType, .company) + } + + func testBusinessTypeString() { + XCTAssertEqual("individual", STPConnectAccountParams.string(from: .individual)) + XCTAssertEqual("company", STPConnectAccountParams.string(from: .company)) + XCTAssertEqual(nil, STPConnectAccountParams.string(from: .none)) + } + + func testPropertyNamesToFormFieldNamesMapping() { + let individual = STPConnectAccountIndividualParams() + let accountParams = STPConnectAccountParams(individual: individual) + + let mapping = STPConnectAccountParams.propertyNamesToFormFieldNamesMapping() + + for propertyName in mapping.keys { + XCTAssertFalse(propertyName.contains(":")) + XCTAssert(accountParams.responds(to: NSSelectorFromString(propertyName))) + } + + for formFieldName in mapping.values { + XCTAssert(formFieldName.count > 0) + } + + XCTAssertEqual(mapping.values.count, Set(mapping.values).count) + } +} diff --git a/Stripe/StripeiOSTests/STPCountryPickerInputFieldSnapshotTests.swift b/Stripe/StripeiOSTests/STPCountryPickerInputFieldSnapshotTests.swift new file mode 100644 index 00000000..00d4df24 --- /dev/null +++ b/Stripe/StripeiOSTests/STPCountryPickerInputFieldSnapshotTests.swift @@ -0,0 +1,31 @@ +// +// STPCountryPickerInputFieldSnapshotTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 12/2/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPCountryPickerInputFieldSnapshotTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() + // recordMode = true + } + + func testDefault() { + let field = STPCountryPickerInputField() + field.sizeToFit() + field.frame.size.width = 200 + + STPSnapshotVerifyView(field) + } +} diff --git a/Stripe/StripeiOSTests/STPCustomerContextTest.swift b/Stripe/StripeiOSTests/STPCustomerContextTest.swift new file mode 100644 index 00000000..e71eb707 --- /dev/null +++ b/Stripe/StripeiOSTests/STPCustomerContextTest.swift @@ -0,0 +1,674 @@ +// +// STPCustomerContextTest.swift +// StripeiOS Tests +// +// Created by David Estes on 9/20/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import OHHTTPStubs +import OHHTTPStubsSwift +import StripeCoreTestUtils + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments + +class MockEphemeralKeyManager: STPEphemeralKeyManagerProtocol { + var ephemeralKey: STPEphemeralKey? + var error: Error? + + init( + key: STPEphemeralKey?, + error: Error? + ) { + self.ephemeralKey = key + self.error = error + } + + func getOrCreateKey(_ completion: @escaping STPEphemeralKeyCompletionBlock) { + completion(ephemeralKey, error) + } +} + +class STPCustomerContextTests: APIStubbedTestCase { + func stubRetrieveCustomers( + key: STPEphemeralKey, + returningCustomerJSON: [AnyHashable: Any], + expectedCount: Int, + apiClient: STPAPIClient + ) { + let exp = expectation(description: "retrieveCustomer") + exp.expectedFulfillmentCount = expectedCount + + stub { urlRequest in + return urlRequest.url?.absoluteString.contains("/customers") ?? false + && urlRequest.httpMethod == "GET" + } response: { _ in + DispatchQueue.main.async { + // Fulfill after response is sent + exp.fulfill() + } + return HTTPStubsResponse( + jsonObject: returningCustomerJSON, + statusCode: 200, + headers: nil + ) + } + } + + func stubListPaymentMethods( + key: STPEphemeralKey, + paymentMethodJSONs: [[AnyHashable: Any]], + expectedCount: Int, + apiClient: STPAPIClient + ) { + let exp = expectation(description: "listPaymentMethod") + exp.expectedFulfillmentCount = expectedCount + stub { urlRequest in + if urlRequest.url?.absoluteString.contains("/payment_methods") ?? false + && urlRequest.httpMethod == "GET" + { + // Check to make sure we pass the ephemeral key correctly + let keyFromHeader = urlRequest.allHTTPHeaderFields!["Authorization"]? + .replacingOccurrences(of: "Bearer ", with: "") + XCTAssertEqual(keyFromHeader, key.secret) + return true + } + return false + } response: { _ in + let paymentMethodsJSON = """ + { + "object": "list", + "url": "/v1/payment_methods", + "has_more": false, + "data": [ + ] + } + """ + var pmList = + try! JSONSerialization.jsonObject( + with: paymentMethodsJSON.data(using: .utf8)!, + options: [] + ) as! [AnyHashable: Any] + pmList["data"] = paymentMethodJSONs + DispatchQueue.main.async { + // Fulfill after response is sent + exp.fulfill() + } + return HTTPStubsResponse(jsonObject: pmList, statusCode: 200, headers: nil) + } + } + + func testGetOrCreateKeyErrorForwardedToRetrieveCustomer() { + let exp = expectation(description: "retrieveCustomer") + let expectedError = NSError(domain: "test", code: 123, userInfo: nil) + let apiClient = stubbedAPIClient() + stub { urlRequest in + return urlRequest.url?.absoluteString.contains("/customers") ?? false + } response: { _ in + XCTFail("Retrieve customer should not be called") + return HTTPStubsResponse(error: NSError(domain: "test", code: 100, userInfo: nil)) + } + let ekm = MockEphemeralKeyManager(key: nil, error: expectedError) + let sut = STPCustomerContext(keyManager: ekm, apiClient: apiClient) + sut.retrieveCustomer { customer, error in + XCTAssertNil(customer) + XCTAssertEqual((error as NSError?)?.domain, expectedError.domain) + exp.fulfill() + } + waitForExpectations(timeout: 2, handler: nil) + } + + func testInitRetrievesResourceKeyAndCustomerAndPaymentMethods() { + let customerKey = STPFixtures.ephemeralKey() + let expectedCustomerJSON = STPFixtures.customerWithSingleCardTokenSourceJSON() + let apiClient = stubbedAPIClient() + stubRetrieveCustomers( + key: customerKey, + returningCustomerJSON: expectedCustomerJSON, + expectedCount: 1, + apiClient: apiClient + ) + stubListPaymentMethods( + key: customerKey, + paymentMethodJSONs: [], + expectedCount: 1, + apiClient: apiClient + ) + + let ekm = MockEphemeralKeyManager(key: customerKey, error: nil) + let sut = STPCustomerContext(keyManager: ekm, apiClient: apiClient) + XCTAssertNotNil(sut) + waitForExpectations(timeout: 2, handler: nil) + } + + func testRetrieveCustomerUsesCachedCustomerIfNotExpired() { + let customerKey = STPFixtures.ephemeralKey() + let expectedCustomer = STPFixtures.customerWithSingleCardTokenSource() + let expectedCustomerJSON = STPFixtures.customerWithSingleCardTokenSourceJSON() + let apiClient = stubbedAPIClient() + + // apiClient.retrieveCustomer should be called once, when the context is initialized. + // When sut.retrieveCustomer is called below, the cached customer will be used. + stubRetrieveCustomers( + key: customerKey, + returningCustomerJSON: expectedCustomerJSON, + expectedCount: 1, + apiClient: apiClient + ) + stubListPaymentMethods( + key: customerKey, + paymentMethodJSONs: [], + expectedCount: 1, + apiClient: apiClient + ) + let ekm = MockEphemeralKeyManager(key: customerKey, error: nil) + let sut = STPCustomerContext(keyManager: ekm, apiClient: apiClient) + // Give the mocked API request a little time to complete and cache the customer, then check cache + waitForExpectations(timeout: 2, handler: nil) + let exp2 = expectation(description: "retrieveCustomer again") + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + ohhttpDelay) { + sut.retrieveCustomer { customer, _ in + XCTAssertEqual(customer!.stripeID, expectedCustomer.stripeID) + exp2.fulfill() + } + } + waitForExpectations(timeout: 2, handler: nil) + } + + func testRetrieveCustomerDoesNotUseCachedCustomerIfExpired() { + let customerKey = STPFixtures.ephemeralKey() + let expectedCustomer = STPFixtures.customerWithSingleCardTokenSource() + let expectedCustomerJSON = STPFixtures.customerWithSingleCardTokenSourceJSON() + let apiClient = stubbedAPIClient() + + // apiClient.retrieveCustomer should be called twice: + // - when the context is initialized, + // - when sut.retrieveCustomer is called below, as the cached customer has expired. + stubRetrieveCustomers( + key: customerKey, + returningCustomerJSON: expectedCustomerJSON, + expectedCount: 2, + apiClient: apiClient + ) + stubListPaymentMethods( + key: customerKey, + paymentMethodJSONs: [], + expectedCount: 1, + apiClient: apiClient + ) + + let ekm = MockEphemeralKeyManager(key: customerKey, error: nil) + let sut = STPCustomerContext(keyManager: ekm, apiClient: apiClient) + // Give the mocked API request a little time to complete and cache the customer, then reset and check cache + let exp2 = expectation(description: "retrieveCustomer again") + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + ohhttpDelay) { + sut.customerRetrievedDate = Date(timeIntervalSinceNow: -70) + sut.retrieveCustomer { customer, _ in + XCTAssertEqual(customer!.stripeID, expectedCustomer.stripeID) + exp2.fulfill() + } + } + waitForExpectations(timeout: 2, handler: nil) + } + + func testRetrieveCustomerDoesNotUseCachedCustomerAfterClearingCache() { + let customerKey = STPFixtures.ephemeralKey() + let expectedCustomer = STPFixtures.customerWithSingleCardTokenSource() + let expectedCustomerJSON = STPFixtures.customerWithSingleCardTokenSourceJSON() + let apiClient = stubbedAPIClient() + + // apiClient.retrieveCustomer should be called twice: + // - when the context is initialized, + // - when sut.retrieveCustomer is called below, as the cached customer has been cleared. + stubRetrieveCustomers( + key: customerKey, + returningCustomerJSON: expectedCustomerJSON, + expectedCount: 2, + apiClient: apiClient + ) + stubListPaymentMethods( + key: customerKey, + paymentMethodJSONs: [], + expectedCount: 1, + apiClient: apiClient + ) + + let ekm = MockEphemeralKeyManager(key: customerKey, error: nil) + let sut = STPCustomerContext(keyManager: ekm, apiClient: apiClient) + // Give the mocked API request a little time to complete and cache the customer, then reset and check cache + let exp2 = expectation(description: "retrieveCustomer again") + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + ohhttpDelay) { + sut.clearCache() + sut.retrieveCustomer { customer, _ in + XCTAssertEqual(customer!.stripeID, expectedCustomer.stripeID) + exp2.fulfill() + } + } + waitForExpectations(timeout: 2, handler: nil) + } + + func testRetrievePaymentMethodsUsesCacheIfNotExpired() { + let customerKey = STPFixtures.ephemeralKey() + let expectedCustomerJSON = STPFixtures.customerWithSingleCardTokenSourceJSON() + let expectedPaymentMethods = [STPFixtures.paymentMethod()] + let expectedPaymentMethodsJSON = [STPFixtures.paymentMethodJSON()] + let apiClient = stubbedAPIClient() + + // apiClient.listPaymentMethods should be called once, when the context is initialized. + // When sut.listPaymentMethods is called below, the cached list will be used. + stubRetrieveCustomers( + key: customerKey, + returningCustomerJSON: expectedCustomerJSON, + expectedCount: 1, + apiClient: apiClient + ) + stubListPaymentMethods( + key: customerKey, + paymentMethodJSONs: expectedPaymentMethodsJSON, + expectedCount: 1, + apiClient: apiClient + ) + + let ekm = MockEphemeralKeyManager(key: customerKey, error: nil) + let sut = STPCustomerContext(keyManager: ekm, apiClient: apiClient) + // Give the mocked API request a little time to complete and cache the customer, then check cache + let exp2 = expectation(description: "listPaymentMethods") + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + ohhttpDelay) { + sut.listPaymentMethodsForCustomer { paymentMethods, _ in + XCTAssertEqual(paymentMethods!.count, expectedPaymentMethods.count) + exp2.fulfill() + } + } + waitForExpectations(timeout: 2, handler: nil) + } + + func testRetrievePaymentMethodsDoesNotUseCacheIfExpired() { + let customerKey = STPFixtures.ephemeralKey() + let expectedCustomerJSON = STPFixtures.customerWithSingleCardTokenSourceJSON() + let expectedPaymentMethods = [STPFixtures.paymentMethod()] + let expectedPaymentMethodsJSON = [STPFixtures.paymentMethodJSON()] + let apiClient = stubbedAPIClient() + + // apiClient.listPaymentMethods should be called twice: + // - when the context is initialized, + // - when sut.listPaymentMethods is called below, as the cached list has expired. + stubRetrieveCustomers( + key: customerKey, + returningCustomerJSON: expectedCustomerJSON, + expectedCount: 1, + apiClient: apiClient + ) + stubListPaymentMethods( + key: customerKey, + paymentMethodJSONs: expectedPaymentMethodsJSON, + expectedCount: 2, + apiClient: apiClient + ) + + let ekm = MockEphemeralKeyManager(key: customerKey, error: nil) + let sut = STPCustomerContext(keyManager: ekm, apiClient: apiClient) + // Give the mocked API request a little time to complete and cache the customer, then check cache + let exp2 = expectation(description: "listPaymentMethods") + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + ohhttpDelay) { + sut.paymentMethodsRetrievedDate = Date(timeIntervalSinceNow: -70) + sut.listPaymentMethodsForCustomer { paymentMethods, _ in + XCTAssertEqual(paymentMethods!.count, expectedPaymentMethods.count) + exp2.fulfill() + } + } + waitForExpectations(timeout: 2, handler: nil) + } + + func testRetrievePaymentMethodsDoesNotUseCacheAfterClearingCache() { + let customerKey = STPFixtures.ephemeralKey() + let expectedCustomerJSON = STPFixtures.customerWithSingleCardTokenSourceJSON() + let expectedPaymentMethods = [STPFixtures.paymentMethod()] + let expectedPaymentMethodsJSON = [STPFixtures.paymentMethodJSON()] + let apiClient = stubbedAPIClient() + + // apiClient.listPaymentMethods should be called twice: + // - when the context is initialized, + // - when sut.listPaymentMethods is called below, as the cached list has been cleared + stubRetrieveCustomers( + key: customerKey, + returningCustomerJSON: expectedCustomerJSON, + expectedCount: 1, + apiClient: apiClient + ) + stubListPaymentMethods( + key: customerKey, + paymentMethodJSONs: expectedPaymentMethodsJSON, + expectedCount: 2, + apiClient: apiClient + ) + + let ekm = MockEphemeralKeyManager(key: customerKey, error: nil) + let sut = STPCustomerContext(keyManager: ekm, apiClient: apiClient) + // Give the mocked API request a little time to complete and cache the customer, then check cache + let exp2 = expectation(description: "listPaymentMethods") + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + ohhttpDelay) { + sut.clearCache() + sut.listPaymentMethodsForCustomer { paymentMethods, _ in + XCTAssertEqual(paymentMethods!.count, expectedPaymentMethods.count) + exp2.fulfill() + } + } + waitForExpectations(timeout: 2, handler: nil) + } + + func testSetCustomerShippingCallsAPIClientCorrectly() { + let address = STPFixtures.address() + let customerKey = STPFixtures.ephemeralKey() + let expectedCustomerJSON = STPFixtures.customerWithSingleCardTokenSourceJSON() + let apiClient = stubbedAPIClient() + + stubRetrieveCustomers( + key: customerKey, + returningCustomerJSON: expectedCustomerJSON, + expectedCount: 1, + apiClient: apiClient + ) + stubListPaymentMethods( + key: customerKey, + paymentMethodJSONs: [], + expectedCount: 1, + apiClient: apiClient + ) + + let exp = expectation(description: "updateCustomer") + stub { urlRequest in + if urlRequest.url?.absoluteString.contains("/customers") ?? false + && urlRequest.httpMethod == "POST" + { + let state = urlRequest.queryItems?.first(where: { item in + item.name == "shipping[address][state]" + })! + XCTAssertEqual(state?.value, address.state) + return true + } + return false + } response: { _ in + exp.fulfill() + return HTTPStubsResponse( + jsonObject: expectedCustomerJSON, + statusCode: 200, + headers: nil + ) + } + + let ekm = MockEphemeralKeyManager(key: customerKey, error: nil) + let sut = STPCustomerContext(keyManager: ekm, apiClient: apiClient) + let exp2 = expectation(description: "updateCustomerWithShipping") + sut.updateCustomer(withShippingAddress: address) { error in + XCTAssertNil(error) + exp2.fulfill() + } + waitForExpectations(timeout: 2, handler: nil) + } + + func testAttachPaymentMethodCallsAPIClientCorrectly() { + let customerKey = STPFixtures.ephemeralKey() + let expectedCustomerJSON = STPFixtures.customerWithSingleCardTokenSourceJSON() + let apiClient = stubbedAPIClient() + let expectedPaymentMethod = STPFixtures.paymentMethod() + let expectedPaymentMethodJSON = STPFixtures.paymentMethodJSON() + let expectedPaymentMethods = [STPFixtures.paymentMethod()] + + stubRetrieveCustomers( + key: customerKey, + returningCustomerJSON: expectedCustomerJSON, + expectedCount: 1, + apiClient: apiClient + ) + stubListPaymentMethods( + key: customerKey, + paymentMethodJSONs: [], + expectedCount: 1, + apiClient: apiClient + ) + + let exp = expectation(description: "payment method attach") + // We're attaching 2 payment methods: + exp.expectedFulfillmentCount = 2 + stub { urlRequest in + if urlRequest.url?.absoluteString.contains("/payment_method") ?? false + && urlRequest.httpMethod == "POST" + { + return true + } + return false + } response: { _ in + exp.fulfill() + return HTTPStubsResponse( + jsonObject: expectedPaymentMethodJSON, + statusCode: 200, + headers: nil + ) + } + + let ekm = MockEphemeralKeyManager(key: customerKey, error: nil) + let sut = STPCustomerContext(keyManager: ekm, apiClient: apiClient) + let exp2 = expectation(description: "CustomerContext attachPaymentMethod") + sut.attachPaymentMethod(toCustomer: expectedPaymentMethods.first!) { error in + XCTAssertNil(error) + exp2.fulfill() + } + + let exp3 = expectation(description: "CustomerContext attachPaymentMethod with ID") + sut.attachPaymentMethodToCustomer(paymentMethodId: expectedPaymentMethod.stripeId) { + error in + XCTAssertNil(error) + exp3.fulfill() + } + + waitForExpectations(timeout: 2, handler: nil) + } + + func testDetachPaymentMethodCallsAPIClientCorrectly() { + let customerKey = STPFixtures.ephemeralKey() + let expectedCustomerJSON = STPFixtures.customerWithSingleCardTokenSourceJSON() + let apiClient = stubbedAPIClient() + let expectedPaymentMethod = STPFixtures.paymentMethod() + let expectedPaymentMethodJSON = STPFixtures.paymentMethodJSON() + let expectedPaymentMethods = [STPFixtures.paymentMethod()] + + stubRetrieveCustomers( + key: customerKey, + returningCustomerJSON: expectedCustomerJSON, + expectedCount: 1, + apiClient: apiClient + ) + stubListPaymentMethods( + key: customerKey, + paymentMethodJSONs: [], + expectedCount: 1, + apiClient: apiClient + ) + + let exp = expectation(description: "payment method detach") + // We're detaching 2 payment methods: + exp.expectedFulfillmentCount = 2 + stub { urlRequest in + if urlRequest.url?.absoluteString.contains("/payment_method") ?? false + && urlRequest.httpMethod == "POST" + { + return true + } + return false + } response: { _ in + exp.fulfill() + return HTTPStubsResponse( + jsonObject: expectedPaymentMethodJSON, + statusCode: 200, + headers: nil + ) + } + + let ekm = MockEphemeralKeyManager(key: customerKey, error: nil) + let sut = STPCustomerContext(keyManager: ekm, apiClient: apiClient) + let exp2 = expectation(description: "CustomerContext detachPaymentMethod") + sut.detachPaymentMethod(fromCustomer: expectedPaymentMethods.first!) { error in + XCTAssertNil(error) + exp2.fulfill() + } + + let exp3 = expectation(description: "CustomerContext detachPaymentMethod with ID") + sut.detachPaymentMethodFromCustomer(paymentMethodId: expectedPaymentMethod.stripeId) { + error in + XCTAssertNil(error) + exp3.fulfill() + } + + waitForExpectations(timeout: 2, handler: nil) + } + + func testFiltersApplePayPaymentMethodsByDefault() { + let customerKey = STPFixtures.ephemeralKey() + let expectedCustomerJSON = STPFixtures.customerWithSingleCardTokenSourceJSON() + let expectedPaymentMethodsJSON = [ + STPFixtures.paymentMethodJSON(), STPFixtures.applePayPaymentMethodJSON(), + ] + let apiClient = stubbedAPIClient() + + stubRetrieveCustomers( + key: customerKey, + returningCustomerJSON: expectedCustomerJSON, + expectedCount: 1, + apiClient: apiClient + ) + stubListPaymentMethods( + key: customerKey, + paymentMethodJSONs: expectedPaymentMethodsJSON, + expectedCount: 1, + apiClient: apiClient + ) + + let ekm = MockEphemeralKeyManager(key: customerKey, error: nil) + let sut = STPCustomerContext(keyManager: ekm, apiClient: apiClient) + // Give the mocked API request a little time to complete and cache the customer, then check cache + let exp2 = expectation(description: "listPaymentMethods") + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + ohhttpDelay) { + sut.listPaymentMethodsForCustomer { paymentMethods, _ in + // Apple Pay should be filtered out + XCTAssertEqual(paymentMethods!.count, 1) + exp2.fulfill() + } + } + waitForExpectations(timeout: 2, handler: nil) + } + + func testIncludesApplePayPaymentMethods() { + let customerKey = STPFixtures.ephemeralKey() + let expectedCustomerJSON = STPFixtures.customerWithSingleCardTokenSourceJSON() + let expectedPaymentMethodsJSON = [ + STPFixtures.paymentMethodJSON(), STPFixtures.applePayPaymentMethodJSON(), + ] + let apiClient = stubbedAPIClient() + + stubRetrieveCustomers( + key: customerKey, + returningCustomerJSON: expectedCustomerJSON, + expectedCount: 1, + apiClient: apiClient + ) + stubListPaymentMethods( + key: customerKey, + paymentMethodJSONs: expectedPaymentMethodsJSON, + expectedCount: 1, + apiClient: apiClient + ) + + let ekm = MockEphemeralKeyManager(key: customerKey, error: nil) + let sut = STPCustomerContext(keyManager: ekm, apiClient: apiClient) + sut.includeApplePayPaymentMethods = true + // Give the mocked API request a little time to complete and cache the customer, then check cache + let exp2 = expectation(description: "listPaymentMethods") + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + ohhttpDelay) { + sut.listPaymentMethodsForCustomer { paymentMethods, _ in + // Apple Pay should be included + XCTAssertEqual(paymentMethods!.count, 2) + exp2.fulfill() + } + } + waitForExpectations(timeout: 2, handler: nil) + } + + func testFiltersApplePaySourcesByDefault() { + let customerKey = STPFixtures.ephemeralKey() + let expectedCustomerJSON = STPFixtures.customerWithCardAndApplePaySourcesJSON() + let expectedPaymentMethods = [ + STPFixtures.paymentMethod(), STPFixtures.applePayPaymentMethod(), + ] + let expectedPaymentMethodsJSON = [ + STPFixtures.paymentMethodJSON(), STPFixtures.applePayPaymentMethodJSON(), + ] + let apiClient = stubbedAPIClient() + + stubRetrieveCustomers( + key: customerKey, + returningCustomerJSON: expectedCustomerJSON, + expectedCount: 1, + apiClient: apiClient + ) + stubListPaymentMethods( + key: customerKey, + paymentMethodJSONs: expectedPaymentMethodsJSON, + expectedCount: 1, + apiClient: apiClient + ) + + let ekm = MockEphemeralKeyManager(key: customerKey, error: nil) + let sut = STPCustomerContext(keyManager: ekm, apiClient: apiClient) + // Give the mocked API request a little time to complete and cache the customer, then check cache + let exp = expectation(description: "retrieveCustomer") + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + ohhttpDelay) { + sut.retrieveCustomer { customer, _ in + // Apple Pay should be filtered out + XCTAssertEqual(customer!.sources.count, 1) + exp.fulfill() + } + } + waitForExpectations(timeout: 2, handler: nil) + } + + func testIncludeApplePaySources() { + let customerKey = STPFixtures.ephemeralKey() + let expectedCustomerJSON = STPFixtures.customerWithCardAndApplePaySourcesJSON() + let expectedPaymentMethodsJSON = [ + STPFixtures.paymentMethodJSON(), STPFixtures.applePayPaymentMethodJSON(), + ] + let apiClient = stubbedAPIClient() + + stubRetrieveCustomers( + key: customerKey, + returningCustomerJSON: expectedCustomerJSON, + expectedCount: 1, + apiClient: apiClient + ) + stubListPaymentMethods( + key: customerKey, + paymentMethodJSONs: expectedPaymentMethodsJSON, + expectedCount: 1, + apiClient: apiClient + ) + + let ekm = MockEphemeralKeyManager(key: customerKey, error: nil) + let sut = STPCustomerContext(keyManager: ekm, apiClient: apiClient) + sut.includeApplePayPaymentMethods = true + // Give the mocked API request a little time to complete and cache the customer, then check cache + let exp = expectation(description: "retrieveCustomer") + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + ohhttpDelay) { + sut.retrieveCustomer { customer, _ in + // Apple Pay should be filtered out + XCTAssertEqual(customer!.sources.count, 2) + exp.fulfill() + } + } + waitForExpectations(timeout: 2, handler: nil) + } + + let ohhttpDelay = 0.1 +} diff --git a/Stripe/StripeiOSTests/STPCustomerTest.swift b/Stripe/StripeiOSTests/STPCustomerTest.swift new file mode 100644 index 00000000..54662c7a --- /dev/null +++ b/Stripe/StripeiOSTests/STPCustomerTest.swift @@ -0,0 +1,63 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPCustomerTest.m +// Stripe +// +// Created by Ben Guo on 7/14/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +class STPCustomerTest: XCTestCase { + func testDecoding_invalidJSON() { + let sut = STPCustomer.decodedObject(fromAPIResponse: [:]) + XCTAssertNil(sut) + } + + func testDecoding_validJSON() { + var card1 = STPTestUtils.jsonNamed("Card") + card1!["id"] = "card_123" + + var card2 = STPTestUtils.jsonNamed("Card") + card2!["id"] = "card_456" + + var applePayCard1 = STPTestUtils.jsonNamed("Card") + applePayCard1!["id"] = "card_apple_pay1" + applePayCard1!["tokenization_method"] = "apple_pay" + + var applePayCard2 = applePayCard1 + applePayCard2!["id"] = "card_apple_pay2" + + let cardSource = STPTestUtils.jsonNamed("CardSource") + let threeDSSource = STPTestUtils.jsonNamed("3DSSource") + + var customer = STPTestUtils.jsonNamed("Customer") + var sources = customer!["sources"] as? [AnyHashable: Any] + sources?["data"] = [applePayCard1, card1, applePayCard2, card2, cardSource, threeDSSource] + customer!["default_source"] = card1!["id"] + if let sources { + customer!["sources"] = sources + } + + guard let sut = STPCustomer.decodedObject(fromAPIResponse: customer) else { + XCTFail() + return + } + XCTAssertEqual(sut.stripeID, customer!["id"] as! String) + XCTAssertTrue(sut.sources.count == 4) + XCTAssertEqual(sut.sources[0].stripeID, card1!["id"] as! String) + XCTAssertEqual(sut.sources[1].stripeID, card2!["id"] as! String) + XCTAssertEqual(sut.defaultSource!.stripeID, card1!["id"] as! String) + XCTAssertEqual(sut.sources[2].stripeID, cardSource!["id"] as! String) + XCTAssertEqual(sut.sources[3].stripeID, threeDSSource!["id"] as! String) + + XCTAssertEqual(sut.shippingAddress!.name, (customer!["shipping"] as! [AnyHashable: Any])["name"] as? String) + XCTAssertEqual(sut.shippingAddress!.phone, (customer!["shipping"] as! [AnyHashable: Any])["phone"] as? String) + let addressDict = (customer!["shipping"] as! [AnyHashable: Any])["address"] as! [AnyHashable: String] + XCTAssertEqual(sut.shippingAddress!.city, addressDict["city"]) + XCTAssertEqual(sut.shippingAddress!.country, addressDict["country"]) + XCTAssertEqual(sut.shippingAddress!.line1, addressDict["line1"]) + XCTAssertEqual(sut.shippingAddress!.line2, addressDict["line2"]) + XCTAssertEqual(sut.shippingAddress!.postalCode, addressDict["postal_code"]) + XCTAssertEqual(sut.shippingAddress!.state, addressDict["state"]) + } +} diff --git a/Stripe/StripeiOSTests/STPE2ETest.swift b/Stripe/StripeiOSTests/STPE2ETest.swift new file mode 100644 index 00000000..2a55aef0 --- /dev/null +++ b/Stripe/StripeiOSTests/STPE2ETest.swift @@ -0,0 +1,155 @@ +// +// STPE2ETest.swift +// StripeiOS Tests +// +// Created by David Estes on 2/16/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Stripe +import XCTest + +class STPE2ETest: XCTestCase { + let E2ETestTimeout: TimeInterval = 120 + + struct E2EExpectation { + var amount: Int + var currency: String + var accountID: String + } + + class E2EBackend { + static let backendAPIURL = URL( + string: "https://stp-mobile-ci-test-backend-e1b3.stripedemos.com/e2e" + )! + + func createPaymentIntent( + completion: @escaping (STPPaymentIntentParams, E2EExpectation) -> Void + ) { + requestAPI("create_pi", method: "POST") { (json) in + let paymentIntentClientSecret = json["paymentIntent"] as! String + let expectedAmount = json["expectedAmount"] as! Int + let expectedCurrency = json["expectedCurrency"] as! String + let expectedAccountID = json["expectedAccountID"] as! String + let publishableKey = json["publishableKey"] as! String + STPAPIClient.shared.publishableKey = publishableKey + completion( + STPPaymentIntentParams(clientSecret: paymentIntentClientSecret), + E2EExpectation( + amount: expectedAmount, + currency: expectedCurrency, + accountID: expectedAccountID + ) + ) + } + } + + func fetchPaymentIntent(id: String, completion: @escaping (E2EExpectation) -> Void) { + requestAPI("fetch_pi", queryItems: [URLQueryItem(name: "pi", value: id)]) { (json) in + let resultAmount = json["amount"] as! Int + let resultCurrency = json["currency"] as! String + let resultAccountID = json["on_behalf_of"] as! String + completion( + E2EExpectation( + amount: resultAmount, + currency: resultCurrency, + accountID: resultAccountID + ) + ) + } + } + + private func requestAPI( + _ resource: String, + method: String = "GET", + queryItems: [URLQueryItem] = [], + completion: @escaping ([String: Any]) -> Void + ) { + var url = URLComponents( + url: Self.backendAPIURL.appendingPathComponent(resource), + resolvingAgainstBaseURL: false + )! + url.queryItems = queryItems + var request = URLRequest(url: url.url!) + request.httpMethod = method + let task = URLSession.shared.dataTask( + with: request, + completionHandler: { (data, _, error) in + guard let data = data, + let json = try? JSONSerialization.jsonObject(with: data, options: []) + as? [String: Any] + else { + XCTFail( + "Did not receive valid JSON response from E2E server. \(String(describing: error))" + ) + return + } + DispatchQueue.main.async { + completion(json) + } + } + ) + task.resume() + } + } + + static let TestPM: STPPaymentMethodParams = { + let testCard = STPPaymentMethodCardParams() + testCard.number = "4242424242424242" + testCard.expYear = 2050 + testCard.expMonth = 12 + testCard.cvc = "123" + return STPPaymentMethodParams(card: testCard, billingDetails: nil, metadata: nil) + }() + + // MARK: LOG.04.01c + // In this test, a PaymentIntent object is created from an example merchant backend, + // confirmed by the iOS SDK, and then retrieved to validate that the original amount, + // currency, and merchant are the same as the original inputs. + func testE2E() throws { + continueAfterFailure = false + let backend = E2EBackend() + let createPI = XCTestExpectation(description: "Create PaymentIntent") + let fetchPIBackend = XCTestExpectation( + description: "Fetch and check PaymentIntent via backend" + ) + let fetchPIClient = XCTestExpectation( + description: "Fetch and check PaymentIntent via client" + ) + let confirmPI = XCTestExpectation(description: "Confirm PaymentIntent") + + // Create a PaymentIntent + backend.createPaymentIntent { (pip, expected) in + createPI.fulfill() + + // Confirm the PaymentIntent using a test card + pip.paymentMethodParams = STPE2ETest.TestPM + STPAPIClient.shared.confirmPaymentIntent(with: pip) { (confirmedPI, confirmError) in + confirmPI.fulfill() + XCTAssertNotNil(confirmedPI) + XCTAssertNil(confirmError) + + // Check the PI information using the backend + backend.fetchPaymentIntent(id: pip.stripeId!) { (expectationResult) in + XCTAssertEqual(expectationResult.amount, expected.amount) + XCTAssertEqual(expectationResult.accountID, expected.accountID) + XCTAssertEqual(expectationResult.currency, expected.currency) + fetchPIBackend.fulfill() + } + + // Check the PI information using the client + STPAPIClient.shared.retrievePaymentIntent(withClientSecret: pip.clientSecret) { + (fetchedPI, fetchError) in + XCTAssertNil(fetchError) + let fetchedPI = fetchedPI! + XCTAssertEqual(fetchedPI.status, .succeeded) + XCTAssertEqual(fetchedPI.amount, expected.amount) + XCTAssertEqual(fetchedPI.currency, expected.currency) + // The client can't check the "on_behalf_of" field, so we check it via the merchant test above. + fetchPIClient.fulfill() + } + } + } + wait(for: [createPI, confirmPI, fetchPIBackend, fetchPIClient], timeout: E2ETestTimeout) + } +} diff --git a/Stripe/StripeiOSTests/STPElementsSessionTest.swift b/Stripe/StripeiOSTests/STPElementsSessionTest.swift new file mode 100644 index 00000000..c38705d2 --- /dev/null +++ b/Stripe/StripeiOSTests/STPElementsSessionTest.swift @@ -0,0 +1,55 @@ +// +// STPElementsSessionTest.swift +// StripeiOSTests +// +// Created by Nick Porter on 2/16/23. +// + +import Foundation +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet + +class STPElementsSessionTest: XCTestCase { + + // MARK: - Description Tests + func testDescription() { + let elementsSessionJson = STPTestUtils.jsonNamed("ElementsSession")! + let elementsSession = STPElementsSession.decodedObject(fromAPIResponse: elementsSessionJson)! + + XCTAssertNotNil(elementsSession) + let desc = elementsSession.description + XCTAssertTrue(desc.contains(NSStringFromClass(type(of: elementsSession).self))) + XCTAssertGreaterThan((desc.count), 500, "Custom description should be long") + } + + // MARK: - STPAPIResponseDecodable Tests + func testDecodedObjectFromAPIResponseMapping() { + let elementsSessionJson = STPTestUtils.jsonNamed("ElementsSession")! + let elementsSession = STPElementsSession.decodedObject(fromAPIResponse: elementsSessionJson)! + + XCTAssertEqual( + elementsSession.orderedPaymentMethodTypes, + [ + STPPaymentMethodType.card, + STPPaymentMethodType.link, + STPPaymentMethodType.USBankAccount, + STPPaymentMethodType.afterpayClearpay, + STPPaymentMethodType.klarna, + STPPaymentMethodType.cashApp, + STPPaymentMethodType.alipay, + STPPaymentMethodType.weChatPay, + ] + ) + + XCTAssertEqual( + elementsSession.unactivatedPaymentMethodTypes, + [STPPaymentMethodType.cashApp] + ) + + XCTAssertNotNil(elementsSession.linkSettings) + XCTAssertEqual(elementsSession.countryCode, "US") + XCTAssertEqual(elementsSession.merchantCountryCode, "US") + XCTAssertNotNil(elementsSession.paymentMethodSpecs) + } + +} diff --git a/Stripe/StripeiOSTests/STPEphemeralKeyManagerTest.swift b/Stripe/StripeiOSTests/STPEphemeralKeyManagerTest.swift new file mode 100644 index 00000000..90b507ee --- /dev/null +++ b/Stripe/StripeiOSTests/STPEphemeralKeyManagerTest.swift @@ -0,0 +1,155 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPEphemeralKeyManagerTest.m +// Stripe +// +// Created by Ben Guo on 5/9/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +@testable import Stripe + +class FakeEphemeralKeyProvider: NSObject, STPCustomerEphemeralKeyProvider { + var response: [AnyHashable: Any]? + var expectation: XCTestExpectation? + + init(response: [AnyHashable: Any]?, expectation: XCTestExpectation?) { + self.response = response + self.expectation = expectation + } + + func createCustomerKey(withAPIVersion apiVersion: String, completion: @escaping StripePayments.STPJSONResponseCompletionBlock) { + completion(response!, nil) + expectation?.fulfill() + } +} + +class FailingEphemeralKeyProvider: NSObject, STPCustomerEphemeralKeyProvider { + func createCustomerKey(withAPIVersion apiVersion: String, completion: @escaping StripePayments.STPJSONResponseCompletionBlock) { + XCTFail("createCustomerKey should not be called") + } +} + +class DelayingEphemeralKeyProvider: NSObject, STPCustomerEphemeralKeyProvider { + var response: [AnyHashable: Any] + var expectation: XCTestExpectation + + init(response: [AnyHashable: Any], expectation: XCTestExpectation) { + self.response = response + self.expectation = expectation + } + + func createCustomerKey(withAPIVersion apiVersion: String, completion: @escaping StripePayments.STPJSONResponseCompletionBlock) { + expectation.fulfill() + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double(Int64(0.1 * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC), execute: { + completion(self.response, nil) + }) + } +} + +class STPEphemeralKeyManagerTest: XCTestCase { + let apiVersion = "2015-03-03" + + override func setUp() { + super.setUp() + } + + func mockKeyProvider(withKeyResponse keyResponse: [AnyHashable: Any]?) -> Any? { + let exp = expectation(description: "createCustomerKey") + let mockKeyProvider = FakeEphemeralKeyProvider(response: keyResponse, expectation: exp) + return mockKeyProvider + } + + func testgetOrCreateKeyCreatesNewKeyAfterInit() { + let expectedKey = STPFixtures.ephemeralKey() + let keyResponse = expectedKey.allResponseFields + let mockKeyProvider = self.mockKeyProvider(withKeyResponse: keyResponse) + let sut = STPEphemeralKeyManager(keyProvider: mockKeyProvider, apiVersion: apiVersion, performsEagerFetching: true) + let exp = expectation(description: "getOrCreateKey") + sut.getOrCreateKey({ resourceKey, error in + XCTAssertEqual(resourceKey, expectedKey) + XCTAssertNil(error) + exp.fulfill() + }) + waitForExpectations(timeout: 2, handler: nil) + } + + func testgetOrCreateKeyUsesStoredKeyIfNotExpiring() { + let mockKeyProvider = FailingEphemeralKeyProvider() + let sut = STPEphemeralKeyManager(keyProvider: mockKeyProvider, apiVersion: apiVersion, performsEagerFetching: true) + let expectedKey = STPFixtures.ephemeralKey() + sut.ephemeralKey = expectedKey + let exp = expectation(description: "getOrCreateKey") + sut.getOrCreateKey({ resourceKey, error in + XCTAssertEqual(resourceKey, expectedKey) + XCTAssertNil(error) + exp.fulfill() + }) + waitForExpectations(timeout: 2, handler: nil) + } + + func testgetOrCreateKeyCreatesNewKeyIfExpiring() { + let expectedKey = STPFixtures.ephemeralKey() + let keyResponse = expectedKey.allResponseFields + let mockKeyProvider = self.mockKeyProvider(withKeyResponse: keyResponse) + let sut = STPEphemeralKeyManager(keyProvider: mockKeyProvider, apiVersion: apiVersion, performsEagerFetching: true) + sut.ephemeralKey = STPFixtures.expiringEphemeralKey() + let exp = expectation(description: "retrieve") + sut.getOrCreateKey({ resourceKey, error in + XCTAssertEqual(resourceKey, expectedKey) + XCTAssertNil(error) + exp.fulfill() + }) + waitForExpectations(timeout: 2, handler: nil) + } + + func testgetOrCreateKeyCoalescesRepeatCalls() { + let expectedKey = STPFixtures.ephemeralKey() + let keyResponse = expectedKey.allResponseFields + let createExp = expectation(description: "createKey") + createExp.assertForOverFulfill = true + + let mockKeyProvider = DelayingEphemeralKeyProvider(response: keyResponse, expectation: createExp) + let sut = STPEphemeralKeyManager(keyProvider: mockKeyProvider, apiVersion: apiVersion, performsEagerFetching: true) + let getExp1 = expectation(description: "getOrCreateKey") + sut.getOrCreateKey({ ephemeralKey, error in + XCTAssertEqual(ephemeralKey, expectedKey) + XCTAssertNil(error) + getExp1.fulfill() + }) + let getExp2 = expectation(description: "getOrCreateKey") + sut.getOrCreateKey({ ephemeralKey, error in + XCTAssertEqual(ephemeralKey, expectedKey) + XCTAssertNil(error) + getExp2.fulfill() + }) + + waitForExpectations(timeout: 2, handler: nil) + } + + func testEnterForegroundRefreshesResourceKeyIfExpiring() { + let key = STPFixtures.expiringEphemeralKey() + let keyResponse = key.allResponseFields + let mockKeyProvider = self.mockKeyProvider(withKeyResponse: keyResponse) + let sut = STPEphemeralKeyManager(keyProvider: mockKeyProvider, apiVersion: apiVersion, performsEagerFetching: true) + XCTAssertNotNil(sut) + NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) + + waitForExpectations(timeout: 2, handler: nil) + } + + func testEnterForegroundDoesNotRefreshResourceKeyIfNotExpiring() { + let mockKeyProvider = FailingEphemeralKeyProvider() + let sut = STPEphemeralKeyManager(keyProvider: mockKeyProvider, apiVersion: apiVersion, performsEagerFetching: true) + sut.ephemeralKey = STPFixtures.ephemeralKey() + NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) + } + + func testThrottlingEnterForegroundRefreshes() { + let mockKeyProvider = FailingEphemeralKeyProvider() + let sut = STPEphemeralKeyManager(keyProvider: mockKeyProvider, apiVersion: apiVersion, performsEagerFetching: true) + sut.ephemeralKey = STPFixtures.expiringEphemeralKey() + sut.lastEagerKeyRefresh = Date(timeIntervalSinceNow: -60) + NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPEphemeralKeyTest.swift b/Stripe/StripeiOSTests/STPEphemeralKeyTest.swift new file mode 100644 index 00000000..b424c92a --- /dev/null +++ b/Stripe/StripeiOSTests/STPEphemeralKeyTest.swift @@ -0,0 +1,32 @@ +// +// STPEphemeralKeyTest.swift +// StripeiOS Tests +// +// Created by Ben Guo on 5/17/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPEphemeralKeyTest: XCTestCase { + func testDecoding() { + let json = STPTestUtils.jsonNamed("EphemeralKey")! + let key = STPEphemeralKey.decodedObject(fromAPIResponse: json)! + XCTAssertEqual(key.stripeID, json["id"] as! String) + XCTAssertEqual(key.secret, json["secret"] as! String) + XCTAssertEqual( + key.created, + Date(timeIntervalSince1970: TimeInterval((json["created"] as! NSNumber).doubleValue)) + ) + XCTAssertEqual( + key.expires, + Date(timeIntervalSince1970: TimeInterval((json["expires"] as! NSNumber).doubleValue)) + ) + XCTAssertEqual(key.livemode, (json["livemode"] as! NSNumber).boolValue) + XCTAssertEqual(key.customerID, "cus_123") + } +} diff --git a/Stripe/StripeiOSTests/STPErrorBridgeTest.m b/Stripe/StripeiOSTests/STPErrorBridgeTest.m new file mode 100644 index 00000000..bdcc414d --- /dev/null +++ b/Stripe/StripeiOSTests/STPErrorBridgeTest.m @@ -0,0 +1,37 @@ +// +// STPErrorBridgeTest.m +// StripeiOS Tests +// +// Created by David Estes on 9/23/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@import Stripe; +@import XCTest; +@import PassKit; +@import StripePaymentsObjcTestUtils; + +@interface STPErrorBridgeTest : XCTestCase + +@end + +@implementation STPErrorBridgeTest + +- (void)testSTPErrorBridge { + // Grab a constant from each class, just to make sure we didn't forget to include the bridge: + XCTAssertEqual(STPInvalidRequestError, 50); + XCTAssertEqualObjects(STPError.errorMessageKey, @"com.stripe.lib:ErrorMessageKey"); + NSDictionary *json = @{ + @"error": @{ + @"type": @"invalid_request_error", + @"message": @"Your card number is incorrect.", + @"code": @"incorrect_number" + } + }; + + // Make sure we can parse a Stripe response + NSError *expectedError = [NSError stp_errorFromStripeResponse:json]; + XCTAssertEqualObjects(expectedError.domain, STPError.stripeDomain); +} + +@end diff --git a/Stripe/StripeiOSTests/STPFPXBankBrandTest.swift b/Stripe/StripeiOSTests/STPFPXBankBrandTest.swift new file mode 100644 index 00000000..a540deb2 --- /dev/null +++ b/Stripe/StripeiOSTests/STPFPXBankBrandTest.swift @@ -0,0 +1,104 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPFPXBankBrandTest.m +// StripeiOS Tests +// +// Created by David Estes on 8/26/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +class STPFPXBankBrandTest: XCTestCase { + func testStringFromBrand() { + let brands: [STPFPXBankBrand] = [ + .affinBank, + .allianceBank, + .ambank, + .bankIslam, + .bankMuamalat, + .bankRakyat, + .BSN, + .CIMB, + .hongLeongBank, + .HSBC, + .KFH, + .maybank2E, + .maybank2U, + .ocbc, + .publicBank, + .CIMB, + .RHB, + .standardChartered, + .UOB, + .unknown, + ] + + for brand in brands { + let brandName = STPFPXBank.stringFrom(brand) + let brandID = STPFPXBank.identifierFrom(brand) + let reverseTransformedBrand = STPFPXBank.brandFrom(brandID) + XCTAssertEqual(reverseTransformedBrand, brand) + + switch brand { + case .affinBank: + XCTAssertEqual(brandID, "affin_bank") + XCTAssertEqual(brandName, "Affin Bank") + case .allianceBank: + XCTAssertEqual(brandID, "alliance_bank") + XCTAssertEqual(brandName, "Alliance Bank") + case .ambank: + XCTAssertEqual(brandID, "ambank") + XCTAssertEqual(brandName, "AmBank") + case .bankIslam: + XCTAssertEqual(brandID, "bank_islam") + XCTAssertEqual(brandName, "Bank Islam") + case .bankMuamalat: + XCTAssertEqual(brandID, "bank_muamalat") + XCTAssertEqual(brandName, "Bank Muamalat") + case .bankRakyat: + XCTAssertEqual(brandID, "bank_rakyat") + XCTAssertEqual(brandName, "Bank Rakyat") + case .BSN: + XCTAssertEqual(brandID, "bsn") + XCTAssertEqual(brandName, "BSN") + case .CIMB: + XCTAssertEqual(brandID, "cimb") + XCTAssertEqual(brandName, "CIMB Clicks") + case .hongLeongBank: + XCTAssertEqual(brandID, "hong_leong_bank") + XCTAssertEqual(brandName, "Hong Leong Bank") + case .HSBC: + XCTAssertEqual(brandID, "hsbc") + XCTAssertEqual(brandName, "HSBC BANK") + case .KFH: + XCTAssertEqual(brandID, "kfh") + XCTAssertEqual(brandName, "KFH") + case .maybank2E: + XCTAssertEqual(brandID, "maybank2e") + XCTAssertEqual(brandName, "Maybank2E") + case .maybank2U: + XCTAssertEqual(brandID, "maybank2u") + XCTAssertEqual(brandName, "Maybank2U") + case .ocbc: + XCTAssertEqual(brandID, "ocbc") + XCTAssertEqual(brandName, "OCBC Bank") + case .publicBank: + XCTAssertEqual(brandID, "public_bank") + XCTAssertEqual(brandName, "Public Bank") + case .RHB: + XCTAssertEqual(brandID, "rhb") + XCTAssertEqual(brandName, "RHB Bank") + case .standardChartered: + XCTAssertEqual(brandID, "standard_chartered") + XCTAssertEqual(brandName, "Standard Chartered") + case .UOB: + XCTAssertEqual(brandID, "uob") + XCTAssertEqual(brandName, "UOB Bank") + case .unknown: + XCTAssertEqual(brandID, "unknown") + XCTAssertEqual(brandName, "Unknown") + @unknown default: + break + } + } + } +} diff --git a/Stripe/StripeiOSTests/STPFileFunctionalTest.swift b/Stripe/StripeiOSTests/STPFileFunctionalTest.swift new file mode 100644 index 00000000..65c140da --- /dev/null +++ b/Stripe/StripeiOSTests/STPFileFunctionalTest.swift @@ -0,0 +1,84 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPFileFunctionalTest.m +// Stripe +// +// Created by Charles Scalesse on 1/8/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import XCTest + +class STPFileFunctionalTest: XCTestCase { + func testImage() -> UIImage { + return UIImage( + named: "stp_test_upload_image.jpeg", + in: Bundle(for: STPFileFunctionalTest.self), + compatibleWith: nil)! + } + + func testCreateFileForIdentityDocument() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let expectation = self.expectation(description: "File creation for identity document") + + let image = testImage() + + client.uploadImage( + image, + purpose: .identityDocument) { file, error in + expectation.fulfill() + XCTAssertNil(error, "error should be nil") + + XCTAssertNotNil(file?.fileId) + XCTAssertNotNil(file?.created) + XCTAssertEqual(file?.purpose, .identityDocument) + XCTAssertNotNil(file?.size) + XCTAssertEqual("jpg", file?.type) + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateFileForDisputeEvidence() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let expectation = self.expectation(description: "File creation for dispute evidence") + + let image = testImage() + + client.uploadImage( + image, + purpose: .disputeEvidence) { file, error in + expectation.fulfill() + XCTAssertNil(error, "error should be nil") + + XCTAssertNotNil(file?.fileId) + XCTAssertNotNil(file?.created) + XCTAssertEqual(file?.purpose, .disputeEvidence) + XCTAssertNotNil(file?.size) + XCTAssertEqual("jpg", file?.type) + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testInvalidKey() { + let client = STPAPIClient(publishableKey: "not_a_valid_key_asdf") + + let expectation = self.expectation(description: "Bad file creation") + + let image = testImage() + + client.uploadImage( + image, + purpose: .identityDocument) { file, error in + expectation.fulfill() + XCTAssertNil(file, "file should be nil") + XCTAssertNotNil(error, "error should not be nil") + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPFileTest.swift b/Stripe/StripeiOSTests/STPFileTest.swift new file mode 100644 index 00000000..90383dcf --- /dev/null +++ b/Stripe/StripeiOSTests/STPFileTest.swift @@ -0,0 +1,99 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPFileTest.m +// Stripe +// +// Created by Charles Scalesse on 1/8/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +@testable import StripePayments +import XCTest + +class STPFileTest: XCTestCase { + // MARK: - STPFilePurpose Tests + + func testPurposeFromString() { + XCTAssertEqual(STPFile.purpose(from: "dispute_evidence"), .disputeEvidence) + XCTAssertEqual(STPFile.purpose(from: "DISPUTE_EVIDENCE"), .disputeEvidence) + + XCTAssertEqual(STPFile.purpose(from: "identity_document"), .identityDocument) + XCTAssertEqual(STPFile.purpose(from: "IDENTITY_DOCUMENT"), .identityDocument) + + XCTAssertEqual(STPFile.purpose(from: "unknown"), .unknown) + XCTAssertEqual(STPFile.purpose(from: "UNKNOWN"), .unknown) + + XCTAssertEqual(STPFile.purpose(from: "garbage"), .unknown) + XCTAssertEqual(STPFile.purpose(from: "GARBAGE"), .unknown) + } + + func testStringFromPurpose() { + let values: [STPFilePurpose] = [ + .disputeEvidence, + .identityDocument, + .unknown, + ] + + for purpose in values { + let string = STPFile.string(from: purpose) + + switch purpose { + case .disputeEvidence: + XCTAssertEqual(string, "dispute_evidence") + case .identityDocument: + XCTAssertEqual(string, "identity_document") + case .unknown: + XCTAssertNil(string) + default: + break + } + } + } + + // MARK: - Equality Tests + + func testFileEquals() { + let file1 = STPFile.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("FileUpload")) + let file2 = STPFile.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("FileUpload")) + + XCTAssertEqual(file1, file1) + XCTAssertEqual(file1, file2) + + XCTAssertEqual(file1?.hash, file1?.hash) + XCTAssertEqual(file1?.hash, file2?.hash) + } + + // MARK: - STPAPIResponseDecodable Tests + + func testDecodedObjectFromAPIResponseRequiredFields() { + let requiredFields = [ + "id", + "created", + "size", + "purpose", + "type", + ] + + for field in requiredFields { + var response = STPTestUtils.jsonNamed("FileUpload") + response!.removeValue(forKey: field) + + XCTAssertNil(STPFile.decodedObject(fromAPIResponse: response)) + } + + XCTAssertNotNil(STPFile.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("FileUpload"))) + } + + func testInitializingFileWithAttributeDictionary() { + let response = STPTestUtils.jsonNamed("FileUpload")! + let file = STPFile.decodedObject(fromAPIResponse: response)! + + XCTAssertEqual(file.fileId, "file_1AZl0o2eZvKYlo2CoIkwLzfd") + XCTAssertEqual(file.created, Date(timeIntervalSince1970: 1498674938)) + XCTAssertEqual(file.purpose, .disputeEvidence) + XCTAssertEqual(file.size, NSNumber(value: 34478)) + XCTAssertEqual(file.type, "jpg") + + XCTAssertEqual(file.allResponseFields as NSDictionary, response as NSDictionary) + } +} diff --git a/Stripe/StripeiOSTests/STPFloatingPlaceholderTextFieldSnapshotTests.swift b/Stripe/StripeiOSTests/STPFloatingPlaceholderTextFieldSnapshotTests.swift new file mode 100644 index 00000000..5c1dc326 --- /dev/null +++ b/Stripe/StripeiOSTests/STPFloatingPlaceholderTextFieldSnapshotTests.swift @@ -0,0 +1,443 @@ +// +// STPFloatingPlaceholderTextFieldSnapshotTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/9/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPFloatingPlaceholderTextFieldSnapshotTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() +// recordMode = true + } + + // MARK: Not Floating + + func testNotFloating_noBackground() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testNotFloating_whiteBackground() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.backgroundColor = .white + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testNotFloating_roundedRectBorderStyle() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.borderStyle = .roundedRect + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testNotFloating_bezelBorderStyle() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.borderStyle = .bezel + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testNotFloating_lineBorderStyle() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.borderStyle = .line + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + // MARK: Floating + + func testFloating_noBackground() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testFloating_whiteBackground() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.backgroundColor = .white + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testFloating_roundedRectBorderStyle() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.borderStyle = .roundedRect + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testFloating_bezelBorderStyle() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.borderStyle = .bezel + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testFloating_lineBorderStyle() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.borderStyle = .line + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + // MARK: Right/Left Views Not Floating + + func testNotFloating_noBackground_rightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testNotFloating_whiteBackground_rightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.backgroundColor = .white + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testNotFloating_roundedRectBorderStyle_rightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.borderStyle = .roundedRect + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testNotFloating_bezelBorderStyle_rightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.borderStyle = .bezel + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testNotFloating_lineBorderStyle_rightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.borderStyle = .line + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testNotFloating_noBackground_leftView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testNotFloating_whiteBackground_leftView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.backgroundColor = .white + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testNotFloating_roundedRectBorderStyle_leftView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.borderStyle = .roundedRect + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testNotFloating_bezelBorderStyle_leftView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.borderStyle = .bezel + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testNotFloating_lineBorderStyle_leftView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.borderStyle = .line + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testNotFloating_noBackground_leftRightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testNotFloating_whiteBackground_leftRightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.backgroundColor = .white + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testNotFloating_roundedRectBorderStyle_leftRightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.borderStyle = .roundedRect + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testNotFloating_bezelBorderStyle_leftRightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.borderStyle = .bezel + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testNotFloating_lineBorderStyle_leftRightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.borderStyle = .line + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + // MARK: Right/Left Views Floating + + func testFloating_noBackground_rightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testFloating_whiteBackground_rightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.backgroundColor = .white + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testFloating_roundedRectBorderStyle_rightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.borderStyle = .roundedRect + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testFloating_bezelBorderStyle_rightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.borderStyle = .bezel + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testFloating_lineBorderStyle_rightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.borderStyle = .line + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testFloating_noBackground_leftView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testFloating_whiteBackground_leftView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.backgroundColor = .white + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testFloating_roundedRectBorderStyle_leftView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.borderStyle = .roundedRect + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testFloating_bezelBorderStyle_leftView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.borderStyle = .bezel + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testFloating_lineBorderStyle_leftView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.borderStyle = .line + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testFloating_noBackground_leftRightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testFloating_whiteBackground_leftRightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.backgroundColor = .white + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testFloating_roundedRectBorderStyle_leftRightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.borderStyle = .roundedRect + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testFloating_bezelBorderStyle_leftRightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.borderStyle = .bezel + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } + + func testFloating_lineBorderStyle_leftRightView() { + let textField: STPFloatingPlaceholderTextField = STPFloatingPlaceholderTextField() + textField.placeholder = "Test Placeholder" + textField.text = "Input Text" + textField.leftView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.leftViewMode = .always + textField.rightView = UIImageView(image: STPImageLibrary.unknownCardCardImage()) + textField.rightViewMode = .always + textField.borderStyle = .line + textField.sizeToFit() + STPSnapshotVerifyView(textField) + } +} diff --git a/Stripe/StripeiOSTests/STPFormEncoderTest.swift b/Stripe/StripeiOSTests/STPFormEncoderTest.swift new file mode 100644 index 00000000..b66e07e0 --- /dev/null +++ b/Stripe/StripeiOSTests/STPFormEncoderTest.swift @@ -0,0 +1,210 @@ +// +// STPFormEncoderTest.swift +// StripeiOS Tests +// +// Created by Jack Flintermann on 1/8/15. +// Copyright © 2015 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPTestFormEncodableObject: NSObject, STPFormEncodable { + var additionalAPIParameters: [AnyHashable: Any] = [:] + + @objc var testProperty: String? + @objc var testIgnoredProperty: String? + @objc var testArrayProperty: [AnyHashable]? + @objc var testDictionaryProperty: [AnyHashable: Any]? + @objc var testNestedObjectProperty: STPTestFormEncodableObject? + + class func rootObjectName() -> String? { + return "test_object" + } + + class func propertyNamesToFormFieldNamesMapping() -> [String: String] { + return [ + "testProperty": "test_property", + "testArrayProperty": "test_array_property", + "testDictionaryProperty": "test_dictionary_property", + "testNestedObjectProperty": "test_nested_property", + ] + } +} + +class STPTestNilRootObjectFormEncodableObject: STPTestFormEncodableObject { + override class func rootObjectName() -> String? { + return nil + } +} + +class STPFormEncoderTest: XCTestCase { + // helper test method + func encode(_ object: STPTestFormEncodableObject?) -> String? { + let dictionary = STPFormEncoder.dictionary(forObject: object!) + return URLEncoder.queryString(from: dictionary) + } + + func testFormEncoding_emptyObject() { + let testObject = STPTestFormEncodableObject() + XCTAssertEqual(encode(testObject), "") + } + + func testFormEncoding_normalObject() { + let testObject = STPTestFormEncodableObject() + testObject.testProperty = "success" + testObject.testIgnoredProperty = "ignoreme" + XCTAssertEqual(encode(testObject), "test_object[test_property]=success") + } + + func testFormEncoding_additionalAttributes() { + let testObject = STPTestFormEncodableObject() + testObject.testProperty = "success" + testObject.additionalAPIParameters = [ + "foo": "bar", + "nested": [ + "nested_key": "nested_value" + ], + ] + XCTAssertEqual( + encode(testObject), + "test_object[foo]=bar&test_object[nested][nested_key]=nested_value&test_object[test_property]=success" + ) + } + + func testFormEncoding_arrayValue_empty() { + let testObject = STPTestFormEncodableObject() + testObject.testProperty = "success" + testObject.testArrayProperty = [] + XCTAssertEqual(encode(testObject), "test_object[test_property]=success") + } + + func testFormEncoding_arrayValue() { + let testObject = STPTestFormEncodableObject() + testObject.testProperty = "success" + testObject.testArrayProperty = [NSNumber(value: 1), NSNumber(value: 2), NSNumber(value: 3)] + XCTAssertEqual( + encode(testObject), + "test_object[test_array_property][0]=1&test_object[test_array_property][1]=2&test_object[test_array_property][2]=3&test_object[test_property]=success" + ) + } + + func testFormEncoding_BoolAndNumbers() { + let testObject = STPTestFormEncodableObject() + testObject.testArrayProperty = [ + NSNumber(value: 0), + NSNumber(value: 1), + NSNumber(value: false), + NSNumber(value: true), + NSNumber(value: true), + ] + XCTAssertEqual( + encode(testObject), + """ + test_object[test_array_property][0]=0\ + &test_object[test_array_property][1]=1\ + &test_object[test_array_property][2]=false\ + &test_object[test_array_property][3]=true\ + &test_object[test_array_property][4]=true + """ + ) + } + + func testFormEncoding_arrayOfEncodable() { + let testObject = STPTestFormEncodableObject() + + let inner1 = STPTestFormEncodableObject() + inner1.testProperty = "inner1" + let inner2 = STPTestFormEncodableObject() + inner2.testArrayProperty = ["inner2"] + + testObject.testArrayProperty = [inner1, inner2] + + XCTAssertEqual( + encode(testObject), + """ + test_object[test_array_property][0][test_property]=inner1\ + &test_object[test_array_property][1][test_array_property][0]=inner2 + """ + ) + } + + func testFormEncoding_dictionaryValue_empty() { + let testObject = STPTestFormEncodableObject() + testObject.testProperty = "success" + testObject.testDictionaryProperty = [:] + XCTAssertEqual(encode(testObject), "test_object[test_property]=success") + } + + func testFormEncoding_dictionaryValue() { + let testObject = STPTestFormEncodableObject() + testObject.testProperty = "success" + testObject.testDictionaryProperty = [ + "foo": "bar" + ] + XCTAssertEqual( + encode(testObject), + "test_object[test_dictionary_property][foo]=bar&test_object[test_property]=success" + ) + } + + func testFormEncoding_dictionaryOfEncodable() { + let testObject = STPTestFormEncodableObject() + + let inner1 = STPTestFormEncodableObject() + inner1.testProperty = "inner1" + let inner2 = STPTestFormEncodableObject() + inner2.testArrayProperty = ["inner2"] + + testObject.testDictionaryProperty = [ + "one": inner1, + "two": inner2, + ] + + XCTAssertEqual( + encode(testObject), + """ + test_object[test_dictionary_property][one][test_property]=inner1\ + &test_object[test_dictionary_property][two][test_array_property][0]=inner2 + """ + ) + } + + func testFormEncoding_setOfEncodable() { + let testObject = STPTestFormEncodableObject() + + let inner = STPTestFormEncodableObject() + inner.testProperty = "inner" + + testObject.testArrayProperty = [Set([inner])] + + XCTAssertEqual( + encode(testObject), + "test_object[test_array_property][0][test_property]=inner" + ) + } + + func testFormEncoding_nestedValue() { + let testObject1 = STPTestFormEncodableObject() + let testObject2 = STPTestFormEncodableObject() + testObject2.testProperty = "nested_object" + testObject1.testProperty = "success" + testObject1.testNestedObjectProperty = testObject2 + XCTAssertEqual( + encode(testObject1), + "test_object[test_nested_property][test_property]=nested_object&test_object[test_property]=success" + ) + } + + func testFormEncoding_nilRootObject() { + let testObject = STPTestNilRootObjectFormEncodableObject() + testObject.testProperty = "success" + XCTAssertEqual(encode(testObject), "test_property=success") + } +} diff --git a/Stripe/StripeiOSTests/STPFormTextFieldTest.swift b/Stripe/StripeiOSTests/STPFormTextFieldTest.swift new file mode 100644 index 00000000..83100b32 --- /dev/null +++ b/Stripe/StripeiOSTests/STPFormTextFieldTest.swift @@ -0,0 +1,62 @@ +// +// STPFormTextFieldTest.swift +// StripeiOS Tests +// +// Created by Ben Guo on 3/22/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPFormTextFieldTest: XCTestCase { + func testAutoFormattingBehavior_None() { + let sut = STPFormTextField() + sut.autoFormattingBehavior = .none + sut.text = "123456789" + XCTAssertEqual(sut.text, "123456789") + } + + func testAutoFormattingBehavior_PhoneNumbers() { + let sut = STPFormTextField() + sut.autoFormattingBehavior = .phoneNumbers + sut.text = "123456789" + XCTAssertEqual(sut.text, "(123) 456-789") + } + + func testAutoFormattingBehavior_CardNumbers() { + let sut = STPFormTextField() + sut.autoFormattingBehavior = .cardNumbers + sut.text = "4242424242424242" + XCTAssertEqual(sut.text, "4242424242424242") + var range = NSRange() + var value = sut.attributedText!.attribute(.kern, at: 0, effectiveRange: &range) as! Int + XCTAssertEqual(value, 0) + XCTAssertEqual(range.length, Int(3)) + value = sut.attributedText!.attribute(.kern, at: 3, effectiveRange: &range) as! Int + XCTAssertEqual(value, 5) + XCTAssertEqual(range.length, Int(1)) + value = sut.attributedText!.attribute(.kern, at: 4, effectiveRange: &range) as! Int + XCTAssertEqual(value, 0) + XCTAssertEqual(range.length, Int(3)) + value = sut.attributedText!.attribute(.kern, at: 7, effectiveRange: &range) as! Int + XCTAssertEqual(value, 5) + XCTAssertEqual(range.length, Int(1)) + value = sut.attributedText!.attribute(.kern, at: 8, effectiveRange: &range) as! Int + XCTAssertEqual(value, 0) + XCTAssertEqual(range.length, Int(3)) + value = sut.attributedText?.attribute(.kern, at: 11, effectiveRange: &range) as! Int + XCTAssertEqual(value, 5) + XCTAssertEqual(range.length, Int(1)) + value = sut.attributedText?.attribute(.kern, at: 12, effectiveRange: &range) as! Int + XCTAssertEqual(value, 0) + XCTAssertEqual(range.length, Int(4)) + XCTAssertEqual(sut.attributedText!.length, Int(16)) + + sut.placeholder = "enteracardnumber" + XCTAssertNil(sut.attributedPlaceholder!.attribute(.kern, at: 3, effectiveRange: &range)) + } +} diff --git a/Stripe/StripeiOSTests/STPFormViewSnapshotTests.swift b/Stripe/StripeiOSTests/STPFormViewSnapshotTests.swift new file mode 100644 index 00000000..69e17d36 --- /dev/null +++ b/Stripe/StripeiOSTests/STPFormViewSnapshotTests.swift @@ -0,0 +1,165 @@ +// +// STPFormViewSnapshotTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/23/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPFormViewSnapshotTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() + // recordMode = true + } + + func testSingleInput() { + let input = STPInputTextField( + formatter: STPInputTextFieldFormatter(), + validator: STPInputTextFieldValidator() + ) + input.placeholder = "Single input" + let section = STPFormView.Section(rows: [[input]], title: nil, accessoryButton: nil) + let formView = STPFormView(sections: [section]) + formView.frame.size = formView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + STPSnapshotVerifyView(formView) + } + + func testSingleInputPerRow() { + var rows = [[STPInputTextField]]() + for row in 0..<5 { + let input = STPInputTextField( + formatter: STPInputTextFieldFormatter(), + validator: STPInputTextFieldValidator() + ) + input.placeholder = "Row \(row)" + rows.append([input]) + } + let section = STPFormView.Section(rows: rows, title: nil, accessoryButton: nil) + let formView = STPFormView(sections: [section]) + formView.frame.size = formView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + STPSnapshotVerifyView(formView) + } + + func testMultiInputPerRow() { + var rows = [[STPInputTextField]]() + for row in 0..<5 { + var rowInputs = [STPInputTextField]() + for c in ["A", "B", "C"] { + let input = STPInputTextField( + formatter: STPInputTextFieldFormatter(), + validator: STPInputTextFieldValidator() + ) + input.placeholder = "Row \(row) \(c)" + rowInputs.append(input) + } + + rows.append(rowInputs) + } + let section = STPFormView.Section(rows: rows, title: nil, accessoryButton: nil) + let formView = STPFormView(sections: [section]) + formView.frame.size = formView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + STPSnapshotVerifyView(formView) + } + + func testMixSingleMultiInputPerRow() { + var rows = [[STPInputTextField]]() + for row in 0..<5 { + var rowInputs = [STPInputTextField]() + for c in ["A", "B", "C"] { + let input = STPInputTextField( + formatter: STPInputTextFieldFormatter(), + validator: STPInputTextFieldValidator() + ) + input.placeholder = "Row \(row) \(c)" + rowInputs.append(input) + if row % 2 == 0 { + break + } + } + + rows.append(rowInputs) + } + let section = STPFormView.Section(rows: rows, title: nil, accessoryButton: nil) + let formView = STPFormView(sections: [section]) + formView.frame.size = formView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + STPSnapshotVerifyView(formView) + } + + func testSingleSectionWithTitle() { + var rows = [[STPInputTextField]]() + for row in 0..<5 { + var rowInputs = [STPInputTextField]() + for c in ["A", "B", "C"] { + let input = STPInputTextField( + formatter: STPInputTextFieldFormatter(), + validator: STPInputTextFieldValidator() + ) + input.placeholder = "Row \(row) \(c)" + rowInputs.append(input) + } + + rows.append(rowInputs) + } + let section = STPFormView.Section(rows: rows, title: "Single Section", accessoryButton: nil) + let formView = STPFormView(sections: [section]) + formView.frame.size = formView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + STPSnapshotVerifyView(formView) + } + + func testMultiSection() { + var rows1 = [[STPInputTextField]]() + for row in 0..<5 { + var rowInputs = [STPInputTextField]() + for c in ["A", "B", "C"] { + let input = STPInputTextField( + formatter: STPInputTextFieldFormatter(), + validator: STPInputTextFieldValidator() + ) + input.placeholder = "Row \(row) \(c)" + rowInputs.append(input) + } + + rows1.append(rowInputs) + } + let section1 = STPFormView.Section( + rows: rows1, + title: "First Section", + accessoryButton: nil + ) + + var rows2 = [[STPInputTextField]]() + for row in 0..<5 { + var rowInputs = [STPInputTextField]() + for c in ["A", "B", "C"] { + let input = STPInputTextField( + formatter: STPInputTextFieldFormatter(), + validator: STPInputTextFieldValidator() + ) + input.placeholder = "Row \(row) \(c)" + rowInputs.append(input) + } + + rows2.append(rowInputs) + } + let section2 = STPFormView.Section( + rows: rows2, + title: "Second Section", + accessoryButton: nil + ) + + let formView = STPFormView(sections: [section1, section2]) + formView.frame.size = formView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + STPSnapshotVerifyView(formView) + } + +} diff --git a/Stripe/StripeiOSTests/STPGenericInputPickerFieldSnapshotTests.swift b/Stripe/StripeiOSTests/STPGenericInputPickerFieldSnapshotTests.swift new file mode 100644 index 00000000..ae04ee33 --- /dev/null +++ b/Stripe/StripeiOSTests/STPGenericInputPickerFieldSnapshotTests.swift @@ -0,0 +1,79 @@ +// +// STPGenericInputPickerFieldSnapshotTests.swift +// StripeiOS Tests +// +// Created by Mel Ludowise on 2/9/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +final class STPGenericInputPickerFieldSnapshotTests: FBSnapshotTestCase { + + private var field: STPGenericInputPickerField! + + override func setUp() { + super.setUp() + // recordMode = true + + field = STPGenericInputPickerField(dataSource: MockDataSource()) + field.placeholder = "Placeholder" + field.sizeToFit() + field.frame.size.width = 200 + } + + func testEmptySelection() { + STPSnapshotVerifyView(field) + } + + func testWithDefaultSelection() { + // The 0th row should be auto-selected when tapping into the field + field.delegate?.textFieldDidBeginEditing?(field) + + STPSnapshotVerifyView(field) + } + + func testWithExplicitSelection() { + let index = 5 + + // Explicitly select a row + field.pickerView.selectRow(index, inComponent: 0, animated: false) + + // Because we're interacting with the picker programatically, we need to explicitly + // call `resignFirstResponder` to commit the changes. + _ = field.resignFirstResponder() + + STPSnapshotVerifyView(field) + } +} + +/// Simple DataSource that displays numbers 0–9 +private final class MockDataSource: STPGenericInputPickerFieldDataSource { + func numberOfRows() -> Int { + return 10 + } + + func inputPickerField( + _ pickerField: STPGenericInputPickerField, + titleForRow row: Int + ) + -> String? + { + return "\(row)" + } + + func inputPickerField( + _ pickerField: STPGenericInputPickerField, + inputValueForRow row: Int + ) + -> String? + { + return "\(row)" + } +} diff --git a/Stripe/StripeiOSTests/STPGenericInputPickerFieldValidatorTest.swift b/Stripe/StripeiOSTests/STPGenericInputPickerFieldValidatorTest.swift new file mode 100644 index 00000000..0a04d8b8 --- /dev/null +++ b/Stripe/StripeiOSTests/STPGenericInputPickerFieldValidatorTest.swift @@ -0,0 +1,45 @@ +// +// STPGenericInputPickerFieldValidatorTest.swift +// StripeiOS Tests +// +// Created by Mel Ludowise on 2/9/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +final class STPGenericInputPickerFieldValidatorTest: XCTestCase { + + private var validator: STPGenericInputPickerField.Validator! + + override func setUp() { + super.setUp() + + validator = STPGenericInputPickerField.Validator() + } + + func testInitial() { + XCTAssertEqual(validator.validationState, .unknown) + } + + func testValidInput() { + validator.inputValue = "hello" + XCTAssertEqual(validator.validationState, .valid(message: nil)) + } + + func testEmptyInput() { + validator.inputValue = "" + XCTAssertEqual(validator.validationState, .incomplete(description: nil)) + } + + func testNilInput() { + validator.inputValue = nil + XCTAssertEqual(validator.validationState, .incomplete(description: nil)) + } +} diff --git a/Stripe/StripeiOSTests/STPGenericInputTextFieldSnapshotTests.swift b/Stripe/StripeiOSTests/STPGenericInputTextFieldSnapshotTests.swift new file mode 100644 index 00000000..8dc5e4ca --- /dev/null +++ b/Stripe/StripeiOSTests/STPGenericInputTextFieldSnapshotTests.swift @@ -0,0 +1,42 @@ +// +// STPGenericInputTextFieldSnapshotTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 12/2/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPGenericInputTextFieldSnapshotTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() + // recordMode = true + } + + func testEmpty() { + let field = STPGenericInputTextField(placeholder: "Empty") + field.sizeToFit() + field.frame.size.width = 200 + + STPSnapshotVerifyView(field) + } + + func testWithContent() { + let field = STPGenericInputTextField(placeholder: "Has Content") + field.sizeToFit() + field.frame.size.width = 200 + field.text = "Hello" + field.textDidChange() + + STPSnapshotVerifyView(field) + } + +} diff --git a/Stripe/StripeiOSTests/STPImageLibraryTest.swift b/Stripe/StripeiOSTests/STPImageLibraryTest.swift new file mode 100644 index 00000000..df0aace5 --- /dev/null +++ b/Stripe/StripeiOSTests/STPImageLibraryTest.swift @@ -0,0 +1,359 @@ +// +// STPImageLibraryTest.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 4/7/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI +@testable@_spi(STP) import StripeUICore + +class STPImageLibraryTestSwift: XCTestCase { + + static let cardBrands: [STPCardBrand] = [ + .amex, + .cartesBancaires, + .dinersClub, + .discover, + .JCB, + .mastercard, + .unionPay, + .unknown, + .visa, + ] + + func testCardIconMethods() { + STPAssertEqualImages( + STPImageLibrary.applePayCardImage(), + STPImageLibrary.safeImageNamed("stp_card_applepay", templateIfAvailable: false) + ) + STPAssertEqualImages( + STPImageLibrary.amexCardImage(), + STPImageLibrary.safeImageNamed("stp_card_amex", templateIfAvailable: false) + ) + STPAssertEqualImages( + STPImageLibrary.dinersClubCardImage(), + STPImageLibrary.safeImageNamed("stp_card_diners", templateIfAvailable: false) + ) + STPAssertEqualImages( + STPImageLibrary.discoverCardImage(), + STPImageLibrary.safeImageNamed("stp_card_discover", templateIfAvailable: false) + ) + STPAssertEqualImages( + STPImageLibrary.jcbCardImage(), + STPImageLibrary.safeImageNamed("stp_card_jcb", templateIfAvailable: false) + ) + STPAssertEqualImages( + STPImageLibrary.mastercardCardImage(), + STPImageLibrary.safeImageNamed("stp_card_mastercard", templateIfAvailable: false) + ) + STPAssertEqualImages( + STPImageLibrary.unionPayCardImage(), + STPImageLibrary.safeImageNamed("stp_card_unionpay", templateIfAvailable: false) + ) + STPAssertEqualImages( + STPImageLibrary.visaCardImage(), + STPImageLibrary.safeImageNamed("stp_card_visa", templateIfAvailable: false) + ) + STPAssertEqualImages( + STPImageLibrary.unknownCardCardImage(), + STPImageLibrary.safeImageNamed("stp_card_unknown", templateIfAvailable: false) + ) + } + + func testBrandImageForCardBrand() { + for brand in Self.cardBrands { + let image = STPImageLibrary.brandImage(for: brand, template: false) + + switch brand { + case .visa: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed("stp_card_visa", templateIfAvailable: false) + ) + case .amex: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed("stp_card_amex", templateIfAvailable: false) + ) + case .mastercard: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed( + "stp_card_mastercard", + templateIfAvailable: false + ) + ) + case .discover: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed("stp_card_discover", templateIfAvailable: false) + ) + case .JCB: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed("stp_card_jcb", templateIfAvailable: false) + ) + case .dinersClub: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed("stp_card_diners", templateIfAvailable: false) + ) + case .unionPay: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed("stp_card_unionpay", templateIfAvailable: false) + ) + case .cartesBancaires: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed("stp_card_cartes_bancaires", templateIfAvailable: false) + ) + case .unknown: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed("stp_card_unknown", templateIfAvailable: false) + ) + } + } + } + + func testTemplatedBrandImageForCardBrand() { + for brand in Self.cardBrands { + let image = STPImageLibrary.templatedBrandImage(for: brand) + + switch brand { + case .visa: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed( + "stp_card_visa_template", + templateIfAvailable: true + ) + ) + case .amex: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed( + "stp_card_amex_template", + templateIfAvailable: true + ) + ) + case .mastercard: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed( + "stp_card_mastercard_template", + templateIfAvailable: true + ) + ) + case .discover: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed( + "stp_card_discover_template", + templateIfAvailable: true + ) + ) + case .JCB: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed( + "stp_card_jcb_template", + templateIfAvailable: true + ) + ) + case .dinersClub: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed( + "stp_card_diners_template", + templateIfAvailable: true + ) + ) + case .unionPay: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed( + "stp_card_unionpay_template", + templateIfAvailable: true + ) + ) + case .cartesBancaires: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed( + "stp_card_cartes_bancaires_template", + templateIfAvailable: true + ) + ) + case .unknown: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed("stp_card_unknown", templateIfAvailable: true) + ) + } + } + } + + func testCVCImageForCardBrand() { + for brand in Self.cardBrands { + let image = STPImageLibrary.cvcImage(for: brand) + + switch brand { + case .amex: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed("stp_card_cvc_amex", templateIfAvailable: false) + ) + default: + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed("stp_card_cvc", templateIfAvailable: false) + ) + } + } + } + + func testErrorImageForCardBrand() { + for brand in Self.cardBrands { + let image = STPImageLibrary.errorImage(for: brand) + STPAssertEqualImages( + image, + STPImageLibrary.safeImageNamed("stp_card_error", templateIfAvailable: false) + ) + } + } + + func testMiscImages() { + STPAssertEqualImages( + STPLegacyImageLibrary.addIcon(), + STPLegacyImageLibrary.safeImageNamed("stp_icon_add", templateIfAvailable: false) + ) + STPAssertEqualImages( + STPImageLibrary.bankIcon(), + STPImageLibrary.safeImageNamed("stp_icon_bank", templateIfAvailable: false) + ) + STPAssertEqualImages( + STPLegacyImageLibrary.checkmarkIcon(), + STPLegacyImageLibrary.safeImageNamed("stp_icon_checkmark", templateIfAvailable: false) + ) + STPAssertEqualImages( + STPLegacyImageLibrary.largeCardFrontImage(), + STPLegacyImageLibrary.safeImageNamed("stp_card_form_front", templateIfAvailable: false) + ) + STPAssertEqualImages( + STPLegacyImageLibrary.largeCardBackImage(), + STPLegacyImageLibrary.safeImageNamed("stp_card_form_back", templateIfAvailable: false) + ) + STPAssertEqualImages( + STPLegacyImageLibrary.largeCardAmexCVCImage(), + STPLegacyImageLibrary.safeImageNamed( + "stp_card_form_amex_cvc", + templateIfAvailable: false + ) + ) + STPAssertEqualImages( + STPLegacyImageLibrary.largeShippingImage(), + STPLegacyImageLibrary.safeImageNamed("stp_shipping_form", templateIfAvailable: false) + ) + } + + func testFPXImages() { + // Probably better to make STPFPXBankBrand conform to CaseIterable, + // but let's not change behavior of a legacy product just for this test. + for i in 0...(STPFPXBankBrand.unknown.rawValue - 1) { + let brand = STPFPXBankBrand(rawValue: i)! + let bankIdentifier = STPFPXBank.identifierFrom(brand)! + let bankImageName = "stp_bank_fpx_" + bankIdentifier + STPAssertEqualImages( + STPLegacyImageLibrary.fpxBrandImage(for: brand), + STPLegacyImageLibrary.safeImageNamed(bankImageName, templateIfAvailable: false) + ) + + } + } + + func testBankIconCodeImagesExist() { + for iconCode in PaymentSheetImageLibrary.BankIconCodeRegexes.keys { + XCTAssertNotNil( + PaymentSheetImageLibrary.bankIcon(for: iconCode), + "Missing image for \(iconCode)" + ) + } + } + + func testBankNameToIconCode() { + // bank of america + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "bank of america"), "boa") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "BANK of AMERICA"), "boa") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "BANKof AMERICA"), "default") + + // capital one + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "capital one"), "capitalone") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "Capital One"), "capitalone") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "Capital One"), "default") + + // citibank + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "citibank"), "citibank") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "Citibank"), "citibank") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "Citi Bank"), "default") + + // compass + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "bbva"), "compass") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "BBVA"), "compass") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "compass"), "compass") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "b b v a"), "default") + + // morganchase + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "Morgan Chase"), "morganchase") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "morgan chase"), "morganchase") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "jp morgan"), "morganchase") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "JP Morgan"), "morganchase") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "Chase"), "morganchase") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "chase"), "morganchase") + + // pnc + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "pncbank"), "pnc") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "PNCBANK"), "pnc") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "pnc bank"), "pnc") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "PNC Bank"), "pnc") + + // suntrust + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "suntrust"), "suntrust") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "SUNTRUST"), "suntrust") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "suntrust bank"), "suntrust") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "Suntrust Bank"), "suntrust") + + // svb + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "Silicon Valley Bank"), "svb") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "SILICON VALLEY BANK"), "svb") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "SILICONVALLEYBANK"), "default") + + // usaa + XCTAssertEqual( + PaymentSheetImageLibrary.bankIconCode(for: "USAA Federal Savings Bank"), + "usaa" + ) + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "USAA Bank"), "usaa") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "USAA Savings Bank"), "default") + + // usbank + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "US Bank"), "usbank") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "U.S. Bank"), "usbank") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "u.s. Bank"), "usbank") + + // wellsfargo + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "Wells Fargo"), "wellsfargo") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "WELLS FARGO"), "wellsfargo") + XCTAssertEqual(PaymentSheetImageLibrary.bankIconCode(for: "Well's Fargo"), "default") + } + +} diff --git a/Stripe/StripeiOSTests/STPInputTextFieldFormatterTests.swift b/Stripe/StripeiOSTests/STPInputTextFieldFormatterTests.swift new file mode 100644 index 00000000..8be567ca --- /dev/null +++ b/Stripe/StripeiOSTests/STPInputTextFieldFormatterTests.swift @@ -0,0 +1,81 @@ +// +// STPInputTextFieldFormatterTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/28/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPInputTextFieldFormatterTests: XCTestCase { + + func testAllowsDeletion() { + let formatter = STPInputTextFieldFormatter() + let textField = UITextField() + XCTAssertTrue( + formatter.textField( + textField, + shouldChangeCharactersIn: NSRange(location: 0, length: 2), + replacementString: "" + ), + "Should allow deletion on empty" + ) + textField.text = "Hi" + XCTAssertTrue( + formatter.textField( + textField, + shouldChangeCharactersIn: NSRange(location: 0, length: 2), + replacementString: "" + ), + "Should allow full deletion" + ) + textField.text = "Hello" + XCTAssertTrue( + formatter.textField( + textField, + shouldChangeCharactersIn: NSRange(location: 4, length: 1), + replacementString: "" + ), + "Should allow partial deletion at end" + ) + textField.text = "Hello" + XCTAssertTrue( + formatter.textField( + textField, + shouldChangeCharactersIn: NSRange(location: 3, length: 1), + replacementString: "" + ), + "Should allow partial deletion in middle" + ) + textField.text = "Hello" + XCTAssertTrue( + formatter.textField( + textField, + shouldChangeCharactersIn: NSRange(location: 0, length: 1), + replacementString: "" + ), + "Should allow partial deletion at beginning" + ) + } + + func testAllowsInitialSpaceForAutofill() { + let formatter = STPInputTextFieldFormatter() + let textField = UITextField() + textField.textContentType = .nickname + XCTAssertTrue( + formatter.textField( + textField, + shouldChangeCharactersIn: NSRange(location: 0, length: 0), + replacementString: " " + ) + ) + } + +} diff --git a/Stripe/StripeiOSTests/STPInputTextFieldValidatorTests.swift b/Stripe/StripeiOSTests/STPInputTextFieldValidatorTests.swift new file mode 100644 index 00000000..542ff6b3 --- /dev/null +++ b/Stripe/StripeiOSTests/STPInputTextFieldValidatorTests.swift @@ -0,0 +1,64 @@ +// +// STPInputTextFieldValidatorTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/28/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPInputTextFieldValidatorTests: XCTestCase { + + class ObserverWithExpectation: NSObject, STPFormInputValidationObserver { + + let expectation: XCTestExpectation + init( + _ expectation: XCTestExpectation + ) { + self.expectation = expectation + super.init() + } + + func validationDidUpdate( + to state: STPValidatedInputState, + from previousState: STPValidatedInputState, + for unformattedInput: String?, + in input: STPFormInput + ) { + expectation.fulfill() + } + + } + + func testUpdatingObservers() { + let textField = STPInputTextField( + formatter: STPInputTextFieldFormatter(), + validator: STPInputTextFieldValidator() + ) + let expectationForNewValue = expectation(description: "Receives expectation with new value") + let observerForNewValue = ObserverWithExpectation(expectationForNewValue) + let validator = textField.validator + + validator.addObserver(observerForNewValue) + validator.validationState = STPValidatedInputState.valid(message: nil) + wait(for: [expectationForNewValue], timeout: 1) + validator.removeObserver(observerForNewValue) + + let expectationForSameValue = expectation( + description: "Receives expectation with same value" + ) + let observerForSameValue = ObserverWithExpectation(expectationForSameValue) + validator.validationState = .incomplete(description: nil) + validator.addObserver(observerForSameValue) + validator.validationState = .incomplete(description: nil) + wait(for: [expectationForSameValue], timeout: 1) + } + +} diff --git a/Stripe/StripeiOSTests/STPIntentActionAlipayHandleRedirectTest.swift b/Stripe/StripeiOSTests/STPIntentActionAlipayHandleRedirectTest.swift new file mode 100644 index 00000000..73a55ab7 --- /dev/null +++ b/Stripe/StripeiOSTests/STPIntentActionAlipayHandleRedirectTest.swift @@ -0,0 +1,55 @@ +// +// STPIntentActionAlipayHandleRedirectTest.swift +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 12/2/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPIntentActionAlipayHandleRedirectTest: XCTestCase { + func testMarlinReturnURL() throws { + let testJSONString = """ + { + "alipay_handle_redirect": { + "native_url": "alipay://alipayclient/?%7B%22dataString%22%3A%22_input_charset=utf-8%26app_pay=Y%26currency=USD%26forex_biz=FP%26notify_url=https%253A%252F%252Fhooks.stripe.com%252Falipay%252Falipay%252Fhook%252REDACTED%252Fsrc_REDACTED%26out_trade_no=src_REDACTED%26partner=REDACTED%26payment_type=1%26product_code=NEW_WAP_OVERSEAS_SELLER%26return_url=https%253A%252F%252Fhooks.stripe.com%252Fadapter%252Falipay%252Fredirect%252Fcomplete%252Fsrc_REDACTED%252Fsrc_client_secret_REDACTED%26secondary_merchant_id=acct_REDACTED%26secondary_merchant_industry=5734%26secondary_merchant_name=Yuki-Test%26sendFormat=normal%26service=create_forex_trade_wap%26sign=REDACTED%26sign_type=MD5%26subject=Yuki-Test%26supplier=Yuki-Test%26timeout_rule=20m%26total_fee=1.00%26bizcontext=%7B%5C%22av%5C%22%3A%5C%221.0%5C%22%2C%5C%22ty%5C%22%3A%5C%22ios_lite%5C%22%2C%5C%22appkey%5C%22%3A%5C%22123456789%5C%22%2C%5C%22sv%5C%22%3A%5C%22h.a.3.2.5%5C%22%2C%5C%22an%5C%22%3A%5C%22com.stripe.CustomSDKExample%5C%22%7D%22%2C%22fromAppUrlScheme%22%3A%22payments-example%22%2C%22requestType%22%3A%22SafePay%22%7D", + "return_url": "payments-example://safepay/", + "url": "https://hooks.stripe.com/redirect/authenticate/src_REDACTED?client_secret=src_client_secret_REDACTED" + }, + "type": "alipay_handle_redirect" + } + """ + guard + let testJSONData = testJSONString.data(using: .utf8), + let json = try? JSONSerialization.jsonObject( + with: testJSONData, + options: .allowFragments + ) as? [AnyHashable: Any], + let nextAction = STPIntentAction.decodedObject(fromAPIResponse: json), + let alipayRedirect = nextAction.alipayHandleRedirect + else { + XCTFail() + return + } + XCTAssertEqual( + alipayRedirect.nativeURL, + URL( + string: + "alipay://alipayclient/?%7B%22dataString%22%3A%22_input_charset=utf-8%26app_pay=Y%26currency=USD%26forex_biz=FP%26notify_url=https%253A%252F%252Fhooks.stripe.com%252Falipay%252Falipay%252Fhook%252REDACTED%252Fsrc_REDACTED%26out_trade_no=src_REDACTED%26partner=REDACTED%26payment_type=1%26product_code=NEW_WAP_OVERSEAS_SELLER%26return_url=https%253A%252F%252Fhooks.stripe.com%252Fadapter%252Falipay%252Fredirect%252Fcomplete%252Fsrc_REDACTED%252Fsrc_client_secret_REDACTED%26secondary_merchant_id=acct_REDACTED%26secondary_merchant_industry=5734%26secondary_merchant_name=Yuki-Test%26sendFormat=normal%26service=create_forex_trade_wap%26sign=REDACTED%26sign_type=MD5%26subject=Yuki-Test%26supplier=Yuki-Test%26timeout_rule=20m%26total_fee=1.00%26bizcontext=%7B%5C%22av%5C%22%3A%5C%221.0%5C%22%2C%5C%22ty%5C%22%3A%5C%22ios_lite%5C%22%2C%5C%22appkey%5C%22%3A%5C%22123456789%5C%22%2C%5C%22sv%5C%22%3A%5C%22h.a.3.2.5%5C%22%2C%5C%22an%5C%22%3A%5C%22com.stripe.CustomSDKExample%5C%22%7D%22%2C%22fromAppUrlScheme%22%3A%22payments-example%22%2C%22requestType%22%3A%22SafePay%22%7D" + ) + ) + XCTAssertEqual(alipayRedirect.returnURL, URL(string: "payments-example://safepay/")) + XCTAssertEqual( + alipayRedirect.marlinReturnURL, + URL( + string: + "https://hooks.stripe.com/adapter/alipay/redirect/complete/src_REDACTED/src_client_secret_REDACTED" + ) + ) + } +} diff --git a/Stripe/StripeiOSTests/STPIntentActionPayNowDisplayQrCodeTest.swift b/Stripe/StripeiOSTests/STPIntentActionPayNowDisplayQrCodeTest.swift new file mode 100644 index 00000000..c0ee7df8 --- /dev/null +++ b/Stripe/StripeiOSTests/STPIntentActionPayNowDisplayQrCodeTest.swift @@ -0,0 +1,37 @@ +// +// STPIntentActionPayNowDisplayQrCodeTest.swift +// StripeiOSTests +// +// Created by Nick Porter on 9/11/23. +// + +@testable@_spi(STP) import Stripe + +class STPIntentActionPayNowDisplayQrCodeTest: XCTestCase { + func testActionHostedUrl() throws { + let testJSONString = """ + { + "paynow_display_qr_code": { + "hosted_instructions_url": "stripe.com/test/paynow/qr", + }, + "type": "paynow_display_qr_code" + } + """ + guard + let testJSONData = testJSONString.data(using: .utf8), + let json = try? JSONSerialization.jsonObject( + with: testJSONData, + options: .allowFragments + ) as? [AnyHashable: Any], + let nextAction = STPIntentAction.decodedObject(fromAPIResponse: json), + let payNowDisplayQrCode = nextAction.payNowDisplayQrCode + else { + XCTFail() + return + } + XCTAssertEqual( + payNowDisplayQrCode.hostedInstructionsURL, + URL(string: "stripe.com/test/paynow/qr") + ) + } +} diff --git a/Stripe/StripeiOSTests/STPIntentActionPromptPayDisplayQrCodeTest.swift b/Stripe/StripeiOSTests/STPIntentActionPromptPayDisplayQrCodeTest.swift new file mode 100644 index 00000000..31d0272c --- /dev/null +++ b/Stripe/StripeiOSTests/STPIntentActionPromptPayDisplayQrCodeTest.swift @@ -0,0 +1,38 @@ +// +// STPIntentActionPromptPayDisplayQrCodeTest.swift +// StripeiOSTests +// +// Created by Nick Porter on 9/12/23. +// + +import Foundation +@testable@_spi(STP) import Stripe + +class STPIntentActionPromptPayDisplayQrCodeTest: XCTestCase { + func testActionHostedUrl() throws { + let testJSONString = """ + { + "promptpay_display_qr_code": { + "hosted_instructions_url": "stripe.com/test/promptpay/qr", + }, + "type": "promptpay_display_qr_code" + } + """ + guard + let testJSONData = testJSONString.data(using: .utf8), + let json = try? JSONSerialization.jsonObject( + with: testJSONData, + options: .allowFragments + ) as? [AnyHashable: Any], + let nextAction = STPIntentAction.decodedObject(fromAPIResponse: json), + let promptPayDisplayQrCode = nextAction.promptPayDisplayQrCode + else { + XCTFail() + return + } + XCTAssertEqual( + promptPayDisplayQrCode.hostedInstructionsURL, + URL(string: "stripe.com/test/promptpay/qr") + ) + } +} diff --git a/Stripe/StripeiOSTests/STPIntentActionTest.swift b/Stripe/StripeiOSTests/STPIntentActionTest.swift new file mode 100644 index 00000000..f581e67a --- /dev/null +++ b/Stripe/StripeiOSTests/STPIntentActionTest.swift @@ -0,0 +1,106 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPIntentActionTest.m +// StripeiOS Tests +// +// Created by Daniel Jackson on 11/7/18. +// Copyright © 2018 Stripe, Inc. All rights reserved. +// + +class STPIntentActionTest: XCTestCase { + func testDecodedObjectFromAPIResponseRedirectToURL() { + + let decode: (([AnyHashable: Any]?) -> STPIntentAction?) = { dict in + return .decodedObject(fromAPIResponse: dict) + } + + XCTAssertNil(decode(nil)) + XCTAssertNil(decode([:])) + XCTAssertNil( + decode([ + "redirect_to_url": [ + "url": "http://stripe.com" + ], + ]), + "fails without type") + + let missingDetails = decode( + [ + "type": "redirect_to_url" + ]) + XCTAssertNotNil(missingDetails) + XCTAssertEqual( + missingDetails!.type, + .unknown, + "Type becomes unknown if the redirect_to_url details are missing") + + let badURL = decode( + [ + "type": "redirect_to_url", + "redirect_to_url": [ + "url": "not a url" + ], + ]) + XCTAssertNotNil(badURL) + XCTAssertEqual( + badURL!.type, + .unknown, + "Type becomes unknown if the redirect_to_url details don't have a valid URL") + + let missingReturnURL = decode( + [ + "type": "redirect_to_url", + "redirect_to_url": [ + "url": "https://stripe.com/" + ], + ]) + XCTAssertNotNil(missingReturnURL) + XCTAssertEqual( + missingReturnURL!.type, + .redirectToURL, + "Missing return_url won't prevent it from decoding") + XCTAssertNotNil(missingReturnURL?.redirectToURL?.url) + XCTAssertEqual( + missingReturnURL?.redirectToURL?.url, + URL(string: "https://stripe.com/")) + XCTAssertNil(missingReturnURL?.redirectToURL?.returnURL) + + let badReturnURL = decode( + [ + "type": "redirect_to_url", + "redirect_to_url": [ + "url": "https://stripe.com/", + "return_url": "not a url", + ], + ]) + XCTAssertNotNil(badReturnURL) + XCTAssertEqual( + badReturnURL!.type, + .redirectToURL, + "invalid return_url won't prevent it from decoding") + XCTAssertNotNil(badReturnURL?.redirectToURL?.url) + XCTAssertEqual( + badReturnURL?.redirectToURL?.url, + URL(string: "https://stripe.com/")) + XCTAssertNil(badReturnURL?.redirectToURL?.returnURL) + + let complete = decode( + [ + "type": "redirect_to_url", + "redirect_to_url": [ + "url": "https://stripe.com/", + "return_url": "my-app://payment-complete", + ], + ]) + XCTAssertNotNil(complete) + XCTAssertEqual(complete?.type, .redirectToURL) + XCTAssertNotNil(complete?.redirectToURL?.url) + XCTAssertEqual( + complete?.redirectToURL?.url, + URL(string: "https://stripe.com/")) + XCTAssertNotNil(complete?.redirectToURL?.returnURL) + XCTAssertEqual( + complete?.redirectToURL?.returnURL, + URL(string: "my-app://payment-complete")) + } +} diff --git a/Stripe/StripeiOSTests/STPIntentActionTypeTest.swift b/Stripe/StripeiOSTests/STPIntentActionTypeTest.swift new file mode 100644 index 00000000..e1879c45 --- /dev/null +++ b/Stripe/StripeiOSTests/STPIntentActionTypeTest.swift @@ -0,0 +1,48 @@ +// +// STPIntentActionTypeTest.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 9/29/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPIntentActionTypeTest: XCTestCase { + + func testTypeFromString() { + XCTAssertEqual( + STPIntentActionType(string: "redirect_to_url"), + STPIntentActionType.redirectToURL + ) + XCTAssertEqual( + STPIntentActionType(string: "REDIRECT_TO_URL"), + STPIntentActionType.redirectToURL + ) + + XCTAssertEqual( + STPIntentActionType(string: "use_stripe_sdk"), + STPIntentActionType.useStripeSDK + ) + XCTAssertEqual( + STPIntentActionType(string: "USE_STRIPE_SDK"), + STPIntentActionType.useStripeSDK + ) + + XCTAssertEqual( + STPIntentActionType(string: "garbage"), + STPIntentActionType.unknown + ) + XCTAssertEqual( + STPIntentActionType(string: "GARBAGE"), + STPIntentActionType.unknown + ) + } + +} diff --git a/Stripe/StripeiOSTests/STPIntentActionWeChatPayRedirectToAppTest.swift b/Stripe/StripeiOSTests/STPIntentActionWeChatPayRedirectToAppTest.swift new file mode 100644 index 00000000..5e476ad1 --- /dev/null +++ b/Stripe/StripeiOSTests/STPIntentActionWeChatPayRedirectToAppTest.swift @@ -0,0 +1,44 @@ +// +// STPIntentActionWeChatPayRedirectToAppTest.swift +// StripeiOS Tests +// +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPIntentActionWeChatPayRedirectToAppTest: XCTestCase { + func testActionNativeURL() throws { + let testJSONString = """ + { + "wechat_pay_redirect_to_ios_app": { + "native_url": "weixin://app/value:wx12345a1234b1234c/pay/?package=Sign=WXPay&appid=wx12345a1234b1234c&partnerid=123456789&prepayid=wx12345a1234b1234c&noncestr=12345×tamp=12345&sign=12341234", + }, + "type": "wechat_pay_redirect_to_ios_app" + } + """ + guard + let testJSONData = testJSONString.data(using: .utf8), + let json = try? JSONSerialization.jsonObject( + with: testJSONData, + options: .allowFragments + ) as? [AnyHashable: Any], + let nextAction = STPIntentAction.decodedObject(fromAPIResponse: json), + let weChatPayRedirectToApp = nextAction.weChatPayRedirectToApp + else { + XCTFail() + return + } + XCTAssertEqual( + weChatPayRedirectToApp.nativeURL, + URL( + string: + "weixin://app/value:wx12345a1234b1234c/pay/?package=Sign=WXPay&appid=wx12345a1234b1234c&partnerid=123456789&prepayid=wx12345a1234b1234c&noncestr=12345×tamp=12345&sign=12341234" + ) + ) + } +} diff --git a/Stripe/StripeiOSTests/STPIntentWithPreferencesTest.swift b/Stripe/StripeiOSTests/STPIntentWithPreferencesTest.swift new file mode 100644 index 00000000..a08df93d --- /dev/null +++ b/Stripe/StripeiOSTests/STPIntentWithPreferencesTest.swift @@ -0,0 +1,179 @@ +// +// STPIntentWithPreferencesTest.swift +// StripeiOS Tests +// +// Created by Jaime Park on 6/23/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPIntentWithPreferencesTest: XCTestCase { + private let paymentIntentClientSecret = + "pi_1H5J4RFY0qyl6XeWFTpgue7g_secret_1SS59M0x65qWMaX2wEB03iwVE" + private let setupIntentClientSecret = + "seti_1GGCuIFY0qyl6XeWVfbQK6b3_secret_GnoX2tzX2JpvxsrcykRSVna2lrYLKew" + + func testPaymentIntentWithPreferences() async { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + do { + let paymentIntentWithPreferences = try await client.retrievePaymentIntentWithPreferences(withClientSecret: paymentIntentClientSecret) + // Check for required PI fields + XCTAssertEqual(paymentIntentWithPreferences.stripeId, "pi_1H5J4RFY0qyl6XeWFTpgue7g") + XCTAssertEqual( + paymentIntentWithPreferences.clientSecret, + self.paymentIntentClientSecret + ) + XCTAssertEqual(paymentIntentWithPreferences.amount, 2000) + XCTAssertEqual(paymentIntentWithPreferences.currency, "usd") + XCTAssertEqual( + paymentIntentWithPreferences.status, + STPPaymentIntentStatus.succeeded + ) + XCTAssertEqual(paymentIntentWithPreferences.livemode, false) + XCTAssertEqual( + paymentIntentWithPreferences.paymentMethodTypes, + STPPaymentMethod.types(from: ["card"]) + ) + // Check for ordered payment method types + XCTAssertNotNil(paymentIntentWithPreferences.orderedPaymentMethodTypes) + XCTAssertEqual( + paymentIntentWithPreferences.orderedPaymentMethodTypes, + [STPPaymentMethodType.card] + ) + } catch { + XCTFail() + print(error) + } + } + + func testSetupIntentWithPreferences() async { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + do { + let setupIntentWithPreferences = try await client.retrieveSetupIntentWithPreferences(withClientSecret: setupIntentClientSecret) + // Check required SI fields + XCTAssertEqual(setupIntentWithPreferences.stripeID, "seti_1GGCuIFY0qyl6XeWVfbQK6b3") + XCTAssertEqual( + setupIntentWithPreferences.clientSecret, + self.setupIntentClientSecret + ) + XCTAssertEqual(setupIntentWithPreferences.status, .requiresPaymentMethod) + XCTAssertEqual( + setupIntentWithPreferences.paymentMethodTypes, + STPPaymentMethod.types(from: ["card"]) + ) + // Check for ordered payment method types + XCTAssertNotNil(setupIntentWithPreferences.orderedPaymentMethodTypes) + XCTAssertEqual( + setupIntentWithPreferences.orderedPaymentMethodTypes, + [STPPaymentMethodType.card] + ) + } catch { + print(error) + XCTFail() + } + } + + func testRetrieveElementSession_deferredPayment() async { + let expectation = XCTestExpectation(description: "Retrieve ElementsSession") + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let intentConfig = PaymentSheet.IntentConfiguration(mode: .payment(amount: 2000, + currency: "USD", + setupFutureUsage: .onSession), + paymentMethodTypes: ["card", "cashapp"], + confirmHandler: { _, _, _ in }) + + do { + let elementsSession = try await client.retrieveElementsSession(withIntentConfig: intentConfig) + XCTAssertNotNil(elementsSession) + XCTAssertEqual(elementsSession.countryCode, "US") + XCTAssertNotNil(elementsSession.linkSettings) + XCTAssertNotNil(elementsSession.paymentMethodSpecs) + XCTAssertEqual( + elementsSession.orderedPaymentMethodTypes, + [STPPaymentMethodType.card, STPPaymentMethodType.cashApp] + ) + } catch { + print(error) + XCTFail() + } + } + + func testRetrieveElementSession_deferredSetup() async { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let intentConfig = PaymentSheet.IntentConfiguration(mode: .setup(currency: "USD", + setupFutureUsage: .offSession), + paymentMethodTypes: ["card", "cashapp"], + confirmHandler: { _, _, _ in }) + + do { + let elementsSession = try await client.retrieveElementsSession(withIntentConfig: intentConfig) + XCTAssertNotNil(elementsSession) + XCTAssertEqual(elementsSession.countryCode, "US") + XCTAssertNotNil(elementsSession.linkSettings) + XCTAssertNotNil(elementsSession.paymentMethodSpecs) + XCTAssertEqual( + elementsSession.orderedPaymentMethodTypes, + [STPPaymentMethodType.card, STPPaymentMethodType.cashApp] + ) + } catch { + print(error) + XCTFail() + } + } + + // MARK: PaymentSheet.IntentConfiguration+elementsSessionPayload tests + + func testElementsSessionPayload_Payment() throws { + let intentConfig = PaymentSheet.IntentConfiguration(mode: .payment(amount: 2000, + currency: "USD", + setupFutureUsage: .onSession, + captureMethod: .automaticAsync), + paymentMethodTypes: ["card", "cashapp"], + onBehalfOf: "acct_connect", + confirmHandler: { _, _, _ in }) + + let payload = intentConfig.elementsSessionParameters(publishableKey: "pk_test") + XCTAssertEqual(payload["key"] as? String, "pk_test") + XCTAssertEqual(payload["locale"] as? String, Locale.current.toLanguageTag()) + + let deferredIntent = try XCTUnwrap(payload["deferred_intent"] as? [String: Any]) + XCTAssertEqual(deferredIntent["payment_method_types"] as? [String], ["card", "cashapp"]) + XCTAssertEqual(deferredIntent["on_behalf_of"] as? String, "acct_connect") + XCTAssertEqual(deferredIntent["mode"] as? String, "payment") + XCTAssertEqual(deferredIntent["amount"] as? Int, 2000) + XCTAssertEqual(deferredIntent["currency"] as? String, "USD") + XCTAssertEqual(deferredIntent["setup_future_usage"] as? String, "on_session") + XCTAssertEqual(deferredIntent["capture_method"] as? String, "automatic_async") + } + + func testElementsSessionPayload_Setup() throws { + let intentConfig = PaymentSheet.IntentConfiguration(mode: .setup(currency: "USD", + setupFutureUsage: .offSession), + paymentMethodTypes: ["card", "cashapp"], + onBehalfOf: "acct_connect", + confirmHandler: { _, _, _ in }) + + let payload = intentConfig.elementsSessionParameters(publishableKey: "pk_test") + XCTAssertEqual(payload["key"] as? String, "pk_test") + XCTAssertEqual(payload["locale"] as? String, Locale.current.toLanguageTag()) + + let deferredIntent = try XCTUnwrap(payload["deferred_intent"] as? [String: Any]) + XCTAssertEqual(deferredIntent["payment_method_types"] as? [String], ["card", "cashapp"]) + XCTAssertEqual(deferredIntent["on_behalf_of"] as? String, "acct_connect") + XCTAssertEqual(deferredIntent["mode"] as? String, "setup") + XCTAssertEqual(deferredIntent["currency"] as? String, "USD") + XCTAssertEqual(deferredIntent["setup_future_usage"] as? String, "off_session") + } +} diff --git a/Stripe/StripeiOSTests/STPLabeledFormTextFieldViewSnapshotTests.swift b/Stripe/StripeiOSTests/STPLabeledFormTextFieldViewSnapshotTests.swift new file mode 100644 index 00000000..790fbce5 --- /dev/null +++ b/Stripe/StripeiOSTests/STPLabeledFormTextFieldViewSnapshotTests.swift @@ -0,0 +1,29 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPLabeledFormTextFieldViewSnapshotTests.m +// StripeiOS Tests +// +// Created by Cameron Sabol on 3/13/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCaseCore +@testable @_spi(STP) import StripePaymentsUI + +class STPLabeledFormTextFieldViewSnapshotTests: FBSnapshotTestCase { + override func setUp() { + super.setUp() + +// self.recordMode = true + } + + func testAppearance() { + let formTextField = STPFormTextField() + formTextField.placeholder = "A placeholder" + formTextField.placeholderColor = UIColor.lightGray + let labeledFormField = STPLabeledFormTextFieldView(formLabel: "Test Label", textField: formTextField) + labeledFormField.formBackgroundColor = UIColor.white + labeledFormField.frame = CGRect(x: 0.0, y: 0.0, width: 320.0, height: 44.0) + STPSnapshotVerifyView(labeledFormField, identifier: "STPLabeledFormTextFieldView.defaultAppearance") + } +} diff --git a/Stripe/StripeiOSTests/STPLabeledMultiFormTextFieldViewSnapshotTests.swift b/Stripe/StripeiOSTests/STPLabeledMultiFormTextFieldViewSnapshotTests.swift new file mode 100644 index 00000000..2a154fbe --- /dev/null +++ b/Stripe/StripeiOSTests/STPLabeledMultiFormTextFieldViewSnapshotTests.swift @@ -0,0 +1,44 @@ +// +// STPLabeledMultiFormTextFieldViewSnapshotTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 3/13/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPLabeledMultiFormTextFieldViewSnapshotTests: FBSnapshotTestCase { + override func setUp() { + super.setUp() + // self.recordMode = true + } + + func testAppearance() { + let formTextField1 = STPFormTextField() + formTextField1.placeholder = "Placeholder 1" + formTextField1.placeholderColor = UIColor.lightGray + + let formTextField2 = STPFormTextField() + formTextField2.placeholder = "Placeholder 2" + formTextField2.placeholderColor = UIColor.lightGray + + let labeledFormField = STPLabeledMultiFormTextFieldView( + formLabel: "Test Label", + firstTextField: formTextField1, + secondTextField: formTextField2 + ) + labeledFormField.formBackgroundColor = UIColor.white + labeledFormField.frame = CGRect(x: 0.0, y: 0.0, width: 320.0, height: 62.0) + STPSnapshotVerifyView( + labeledFormField, + identifier: "STPLabeledMultiFormTextFieldView.defaultAppearance" + ) + } +} diff --git a/Stripe/StripeiOSTests/STPMandateCustomerAcceptanceParamsTest.swift b/Stripe/StripeiOSTests/STPMandateCustomerAcceptanceParamsTest.swift new file mode 100644 index 00000000..66f1e0d5 --- /dev/null +++ b/Stripe/StripeiOSTests/STPMandateCustomerAcceptanceParamsTest.swift @@ -0,0 +1,44 @@ +// +// STPMandateCustomerAcceptanceParamsTest.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/18/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPMandateCustomerAcceptanceParamsTest: XCTestCase { + func testRootObjectName() { + XCTAssertEqual(STPMandateCustomerAcceptanceParams.rootObjectName(), "customer_acceptance") + } + + func testEncoding() { + let onlineParams = STPMandateOnlineParams(ipAddress: "", userAgent: "") + onlineParams.inferFromClient = NSNumber(value: true) + var params = STPMandateCustomerAcceptanceParams(type: .online, onlineParams: onlineParams)! + + var paramsAsDict = STPFormEncoder.dictionary(forObject: params) + var expected = [ + "customer_acceptance": [ + "type": "online", + "online": [ + "infer_from_client": NSNumber(value: true) + ], + ], + ] + XCTAssertEqual(paramsAsDict as NSDictionary, expected as NSDictionary) + + params = STPMandateCustomerAcceptanceParams(type: .offline, onlineParams: nil)! + paramsAsDict = STPFormEncoder.dictionary(forObject: params) + expected = [ + "customer_acceptance": [ + "type": "offline" + ], + ] + XCTAssertEqual(paramsAsDict as NSDictionary, expected as NSDictionary) + } +} diff --git a/Stripe/StripeiOSTests/STPMandateDataParamsTest.swift b/Stripe/StripeiOSTests/STPMandateDataParamsTest.swift new file mode 100644 index 00000000..587d8f17 --- /dev/null +++ b/Stripe/StripeiOSTests/STPMandateDataParamsTest.swift @@ -0,0 +1,42 @@ +// +// STPMandateDataParamsTest.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/18/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPMandateDataParamsTest: XCTestCase { + func testRootObjectName() { + XCTAssertEqual(STPMandateDataParams.rootObjectName(), "mandate_data") + } + + func testEncoding() { + let onlineParams = STPMandateOnlineParams(ipAddress: "", userAgent: "") + onlineParams.inferFromClient = NSNumber(value: true) + let customerAcceptanceParams = STPMandateCustomerAcceptanceParams( + type: .online, + onlineParams: onlineParams + )! + + let params = STPMandateDataParams(customerAcceptance: customerAcceptanceParams) + + let paramsAsDict = STPFormEncoder.dictionary(forObject: params) + let expected = [ + "mandate_data": [ + "customer_acceptance": [ + "type": "online", + "online": [ + "infer_from_client": true + ], + ], + ], + ] + XCTAssertEqual(paramsAsDict as NSDictionary, expected as NSDictionary) + } +} diff --git a/Stripe/StripeiOSTests/STPMandateOnlineParamsTest.swift b/Stripe/StripeiOSTests/STPMandateOnlineParamsTest.swift new file mode 100644 index 00000000..990e02b2 --- /dev/null +++ b/Stripe/StripeiOSTests/STPMandateOnlineParamsTest.swift @@ -0,0 +1,40 @@ +// +// STPMandateOnlineParamsTest.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/18/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPMandateOnlineParamsTest: XCTestCase { + func testRootObjectName() { + XCTAssertEqual(STPMandateOnlineParams.rootObjectName(), "online") + } + + func testEncoding() { + var params = STPMandateOnlineParams(ipAddress: "test_ip_address", userAgent: "a_user_agent") + var paramsAsDict = STPFormEncoder.dictionary(forObject: params) + var expected: [String: AnyHashable] = [ + "online": [ + "ip_address": "test_ip_address", + "user_agent": "a_user_agent", + ], + ] + XCTAssertEqual(paramsAsDict as NSDictionary, expected as NSDictionary) + + params = STPMandateOnlineParams(ipAddress: "", userAgent: "") + params.inferFromClient = NSNumber(value: true) + paramsAsDict = STPFormEncoder.dictionary(forObject: params) + expected = [ + "online": [ + "infer_from_client": NSNumber(value: true) + ], + ] + XCTAssertEqual(paramsAsDict as NSDictionary, expected as NSDictionary) + } +} diff --git a/Stripe/StripeiOSTests/STPMocks.h b/Stripe/StripeiOSTests/STPMocks.h new file mode 100644 index 00000000..1b782caa --- /dev/null +++ b/Stripe/StripeiOSTests/STPMocks.h @@ -0,0 +1,34 @@ +// +// STPMocks.h +// Stripe +// +// Created by Ben Guo on 4/5/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +#import +#import +@import Stripe; + +@interface STPMocks : NSObject + +/** + A stateless customer context that always retrieves the same customer object. + */ ++ (STPCustomerContext *)staticCustomerContext; + +/** + A static customer context that always retrieves the given customer and the given payment methods. + Selecting a default source and attaching a source have no effect. + */ ++ (STPCustomerContext *)staticCustomerContextWithCustomer:(STPCustomer *)customer paymentMethods:(NSArray *)paymentMethods; + +/** + A PaymentConfiguration object with a fake publishable key and a fake apple + merchant identifier that ignores the true value of [StripeAPI deviceSupportsApplePay] + and bases its `applePayEnabled` value solely on what is set + in `additionalPaymentOptions` + */ ++ (STPPaymentConfiguration *)paymentConfigurationWithApplePaySupportingDevice; + +@end diff --git a/Stripe/StripeiOSTests/STPMocks.m b/Stripe/StripeiOSTests/STPMocks.m new file mode 100644 index 00000000..63787637 --- /dev/null +++ b/Stripe/StripeiOSTests/STPMocks.m @@ -0,0 +1,55 @@ +// +// STPMocks.m +// Stripe +// +// Created by Ben Guo on 4/5/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +#import "STPMocks.h" + +@import StripePaymentsObjcTestUtils; +#import "StripeiOS_Tests-Swift.h" + +@interface STPPaymentConfiguration (STPMocks) + +/** + Mock apple pay enabled response to just be based on setting and not hardware + capability. + + `paymentConfigurationWithApplePaySupportingDevice` forwards calls to the + real method to this stub + */ +- (BOOL)stpmock_applePayEnabled; + +@end + +@implementation STPMocks + ++ (STPCustomerContext *)staticCustomerContext { + return [self staticCustomerContextWithCustomer:[STPFixtures customerWithSingleCardTokenSource] + paymentMethods:@[[STPFixtures paymentMethod]]]; +} + ++ (STPCustomerContext *)staticCustomerContextWithCustomer:(STPCustomer *)customer paymentMethods:(NSArray *)paymentMethods { + return [[Testing_StaticCustomerContext_Objc alloc] initWithCustomer:customer paymentMethods:paymentMethods]; +} + ++ (STPPaymentConfiguration *)paymentConfigurationWithApplePaySupportingDevice { + STPPaymentConfiguration *config = [STPPaymentConfiguration new]; + config.appleMerchantIdentifier = @"fake_apple_merchant_id"; + id partialMock = OCMPartialMock(config); + OCMStub([partialMock applePayEnabled]).andCall(partialMock, @selector(stpmock_applePayEnabled)); + return partialMock; +} + +@end + +@implementation STPPaymentConfiguration (STPMocks) + +- (BOOL)stpmock_applePayEnabled { + return self.applePayEnabled; +} + +@end + diff --git a/Stripe/StripeiOSTests/STPNumericDigitInputTextFormatterTests.swift b/Stripe/StripeiOSTests/STPNumericDigitInputTextFormatterTests.swift new file mode 100644 index 00000000..ea41c2f3 --- /dev/null +++ b/Stripe/StripeiOSTests/STPNumericDigitInputTextFormatterTests.swift @@ -0,0 +1,157 @@ +// +// STPNumericDigitInputTextFormatterTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/28/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPNumericDigitInputTextFormatterTests: XCTestCase { + + func testDisallowsNonDigits() { + let formatter = STPNumericDigitInputTextFormatter() + XCTAssertFalse( + formatter.isAllowedInput("a", to: "", at: NSRange(location: 0, length: 1)), + "Shouldn't allow non-digit in empty string" + ) + XCTAssertFalse( + formatter.isAllowedInput("1a", to: "", at: NSRange(location: 0, length: 1)), + "Shouldn't allow digit + non-digit in empty string" + ) + XCTAssertFalse( + formatter.isAllowedInput("a", to: "1", at: NSRange(location: 0, length: 1)), + "Shouldn't allow non-digit in digit string" + ) + XCTAssertFalse( + formatter.isAllowedInput("1a", to: "1", at: NSRange(location: 0, length: 1)), + "Shouldn't allow digit + non-digit in digit string" + ) + XCTAssertFalse( + formatter.isAllowedInput(" ", to: "1", at: NSRange(location: 0, length: 1)), + "Shouldn't allow spaces" + ) + // for now we only validate the input, not the result + XCTAssertTrue( + formatter.isAllowedInput("1", to: "a", at: NSRange(location: 0, length: 1)), + "Should allow digit added to non-digit string" + ) + } + + func testAllowsDigits() { + let formatter = STPNumericDigitInputTextFormatter() + XCTAssertTrue( + formatter.isAllowedInput("1", to: "", at: NSRange(location: 0, length: 1)), + "Should allow digit in empty string" + ) + XCTAssertTrue( + formatter.isAllowedInput("2", to: "1", at: NSRange(location: 0, length: 1)), + "Should allow digit insert at beginning of string" + ) + XCTAssertTrue( + formatter.isAllowedInput("3", to: "1", at: NSRange(location: 1, length: 1)), + "Should allow digit insert at end of string" + ) + XCTAssertTrue( + formatter.isAllowedInput("45", to: "1", at: NSRange(location: 0, length: 1)), + "Should allow multi-digit insert" + ) + } + + func testFormattingCharacterSet() { + let formatter = STPNumericDigitInputTextFormatter( + allowedFormattingCharacterSet: CharacterSet(charactersIn: "xy") + ) + XCTAssertTrue( + formatter.isAllowedInput("x", to: "", at: NSRange(location: 0, length: 1)), + "Should allow formatting character in empty string" + ) + XCTAssertFalse( + formatter.isAllowedInput("xa", to: "", at: NSRange(location: 0, length: 1)), + "Shouldn't allow formatting + non-formatting in empty string" + ) + XCTAssertTrue( + formatter.isAllowedInput("x", to: "1", at: NSRange(location: 0, length: 1)), + "Should allow formatting character in digit string" + ) + XCTAssertTrue( + formatter.isAllowedInput("1x", to: "1", at: NSRange(location: 0, length: 1)), + "Should allow digit + formatting in digit string" + ) + XCTAssertTrue( + formatter.isAllowedInput("xxxxyyy", to: "1", at: NSRange(location: 0, length: 6)), + "Should allow multiple formatting in digit string" + ) + } + + // MARK: - Inherited Tests + func testAllowsDeletion() { + let formatter = STPNumericDigitInputTextFormatter() + let textField = UITextField() + XCTAssertTrue( + formatter.textField( + textField, + shouldChangeCharactersIn: NSRange(location: 0, length: 2), + replacementString: "" + ), + "Should allow deletion on empty" + ) + textField.text = "12" + XCTAssertTrue( + formatter.textField( + textField, + shouldChangeCharactersIn: NSRange(location: 0, length: 2), + replacementString: "" + ), + "Should allow full deletion" + ) + textField.text = "12345" + XCTAssertTrue( + formatter.textField( + textField, + shouldChangeCharactersIn: NSRange(location: 4, length: 1), + replacementString: "" + ), + "Should allow partial deletion at end" + ) + textField.text = "12345" + XCTAssertTrue( + formatter.textField( + textField, + shouldChangeCharactersIn: NSRange(location: 3, length: 1), + replacementString: "" + ), + "Should allow partial deletion in middle" + ) + textField.text = "12345" + XCTAssertTrue( + formatter.textField( + textField, + shouldChangeCharactersIn: NSRange(location: 0, length: 1), + replacementString: "" + ), + "Should allow partial deletion at beginning" + ) + } + + func testAllowsInitialSpaceForAutofill() { + let formatter = STPNumericDigitInputTextFormatter() + let textField = UITextField() + textField.textContentType = .nickname + XCTAssertTrue( + formatter.textField( + textField, + shouldChangeCharactersIn: NSRange(location: 0, length: 0), + replacementString: " " + ) + ) + } + +} diff --git a/Stripe/StripeiOSTests/STPNumericStringValidatorTests.swift b/Stripe/StripeiOSTests/STPNumericStringValidatorTests.swift new file mode 100644 index 00000000..8f5cdccd --- /dev/null +++ b/Stripe/StripeiOSTests/STPNumericStringValidatorTests.swift @@ -0,0 +1,49 @@ +// +// STPNumericStringValidatorTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 3/13/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPNumericStringValidatorTests: XCTestCase { + func testNumberSanitization() { + let tests = [ + ["4242424242424242", "4242424242424242"], + ["XXXXXX", ""], + ["424242424242424X", "424242424242424"], + ["X4242", "4242"], + ["4242 4242 4242 4242", "4242424242424242"], + ["123-456-", "123456"], + ] + for test in tests { + XCTAssertEqual(STPNumericStringValidator.sanitizedNumericString(for: test[0]), test[1]) + } + } + + func testIsStringNumeric() { + let tests = [ + ["4242424242424242", NSNumber(value: true)], + ["XXXXXX", NSNumber(value: false)], + ["424242424242424X", NSNumber(value: false)], + ["X4242", NSNumber(value: false)], + ["4242 4242 4242 4242", NSNumber(value: false)], + ["123-456-", NSNumber(value: false)], + [" 1", NSNumber(value: false)], + ["", NSNumber(value: true)], + ] + for test in tests { + let first = STPNumericStringValidator.isStringNumeric(test[0] as! String) + let second = (test[1] as! NSNumber).boolValue + XCTAssertEqual(first, second) + } + } +} diff --git a/Stripe/StripeiOSTests/STPPIIFunctionalTest.swift b/Stripe/StripeiOSTests/STPPIIFunctionalTest.swift new file mode 100644 index 00000000..83141809 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPIIFunctionalTest.swift @@ -0,0 +1,45 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPIIFunctionalTest.m +// Stripe +// +// Created by Charles Scalesse on 1/8/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import XCTest + +class STPPIIFunctionalTest: XCTestCase { + func testCreatePersonallyIdentifiableInformationToken() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let expectation = self.expectation(description: "PII creation") + + client.createToken(withPersonalIDNumber: "0123456789") { token, error in + expectation.fulfill() + XCTAssertNil(error, "error should be nil \(error?.localizedDescription)") + XCTAssertNotNil(token, "token should not be nil") + XCTAssertNotNil(token?.tokenId) + XCTAssertEqual(token?.type, .PII) + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testSSNLast4Token() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let expectation = self.expectation(description: "PII creation") + + client.createToken(withSSNLast4: "1234") { token, error in + expectation.fulfill() + XCTAssertNil(error, "error should be nil \(error?.localizedDescription)") + XCTAssertNotNil(token, "token should not be nil") + XCTAssertNotNil(token?.tokenId) + XCTAssertEqual(token?.type, .PII) + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentCardTextFieldKVOTest.m b/Stripe/StripeiOSTests/STPPaymentCardTextFieldKVOTest.m new file mode 100644 index 00000000..aeb68e2f --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentCardTextFieldKVOTest.m @@ -0,0 +1,77 @@ +// +// STPPaymentCardTextFieldKVOTest.m +// Stripe +// +// Created by Jack Flintermann on 8/26/15. +// Copyright (c) 2015 Stripe, Inc. All rights reserved. +// + +@import UIKit; +@import XCTest; +@import OCMock; +@import StripeCoreTestUtils; +@import StripePaymentsObjcTestUtils; + +@interface STPPaymentCardTextField (Testing) +@property (nonatomic, readwrite, weak) UIImageView *brandImageView; +@property (nonatomic, readwrite, weak) STPFormTextField *numberField; +@property (nonatomic, readwrite, weak) STPFormTextField *expirationField; +@property (nonatomic, readwrite, weak) STPFormTextField *cvcField; +@property (nonatomic, readwrite, weak) STPFormTextField *postalCodeField; +@property (nonatomic, readonly, weak) STPFormTextField *currentFirstResponderField; +@property (nonatomic, copy) NSNumber *focusedTextFieldForLayout; ++ (UIImage *)cvcImageForCardBrand:(STPCardBrand)cardBrand; ++ (UIImage *)brandImageForCardBrand:(STPCardBrand)cardBrand; +@end + +@interface STPPaymentCardTextFieldKVOUITests : XCTestCase +@property (nonatomic) UIWindow *window; +@property (nonatomic) STPPaymentCardTextField *sut; +@end + +@implementation STPPaymentCardTextFieldKVOUITests + ++ (void)setUp { + [super setUp]; + [[STPAPIClient sharedClient] setPublishableKey:STPTestingDefaultPublishableKey]; +} + +- (void)setUp { + [super setUp]; + self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + STPPaymentCardTextField *textField = [[STPPaymentCardTextField alloc] initWithFrame:self.window.bounds]; + [self.window addSubview:textField]; + XCTAssertTrue([textField.numberField canBecomeFirstResponder], @"text field cannot become first responder"); + self.sut = textField; +} + +- (void)testIsValidKVO { + id observer = OCMClassMock([UIViewController class]); + self.sut.numberField.text = @"4242424242424242"; + self.sut.expirationField.text = @"10/99"; + self.sut.postalCodeField.text = @"90210"; + XCTAssertFalse(self.sut.isValid); + + NSString *expectedKeyPath = @"sut.isValid"; + [self addObserver:observer forKeyPath:expectedKeyPath options:NSKeyValueObservingOptionNew context:nil]; + XCTestExpectation *exp = [self expectationWithDescription:@"observeValue"]; + OCMStub([observer observeValueForKeyPath:[OCMArg any] ofObject:[OCMArg any] change:[OCMArg any] context:nil]) + .andDo(^(NSInvocation *invocation) { + NSString *keyPath; + NSDictionary *change; + [invocation getArgument:&keyPath atIndex:2]; + [invocation getArgument:&change atIndex:4]; + if ([keyPath isEqualToString:expectedKeyPath]) { + if ([change[@"new"] boolValue]) { + [exp fulfill]; + [self removeObserver:observer forKeyPath:@"sut.isValid"]; + } + } + }); + + self.sut.cvcField.text = @"123"; + + [self waitForExpectationsWithTimeout:TestConstants.STPTestingNetworkRequestTimeout handler:nil]; +} + +@end diff --git a/Stripe/StripeiOSTests/STPPaymentCardTextFieldTest.swift b/Stripe/StripeiOSTests/STPPaymentCardTextFieldTest.swift new file mode 100644 index 00000000..f21f8a0e --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentCardTextFieldTest.swift @@ -0,0 +1,1238 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentCardTextFieldTest.m +// Stripe +// +// Created by Jack Flintermann on 8/26/15. +// Copyright (c) 2015 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +@testable @_spi(STP) import StripePayments +@testable @_spi(STP) import StripePaymentsUI +import UIKit +import XCTest + +/// Class that implements STPPaymentCardTextFieldDelegate and uses a block for each delegate method. +class PaymentCardTextFieldBlockDelegate: NSObject, STPPaymentCardTextFieldDelegate { + var didChange: ((STPPaymentCardTextField) -> Void)? + var willEndEditingForReturn: ((STPPaymentCardTextField) -> Void)? + var didEndEditing: ((STPPaymentCardTextField) -> Void)? + // add more properties for other delegate methods as this test needs them + + func paymentCardTextFieldDidChange(_ textField: STPPaymentCardTextField) { + if let didChange { + didChange(textField) + } + } + + func paymentCardTextFieldWillEndEditing(forReturn textField: STPPaymentCardTextField) { + if let willEndEditingForReturn { + willEndEditingForReturn(textField) + } + } + + func paymentCardTextFieldDidEndEditing(_ textField: STPPaymentCardTextField) { + if let didEndEditing { + didEndEditing(textField) + } + } +} + +class STPPaymentCardTextFieldTest: XCTestCase { + override class func setUp() { + super.setUp() + STPAPIClient.shared.publishableKey = STPTestingDefaultPublishableKey + } + + func testIntrinsicContentSize() { + let textField = STPPaymentCardTextField() + + let iOS8SystemFont = UIFont(name: "HelveticaNeue", size: 18) + textField.font = iOS8SystemFont! + XCTAssertEqual(textField.intrinsicContentSize.height, 44, accuracy: 0.1) + XCTAssertEqual(textField.intrinsicContentSize.width, 241, accuracy: 0.1) + + let iOS9SystemFont = UIFont.systemFont(ofSize: 18) + textField.font = iOS9SystemFont + XCTAssertEqualWithAccuracy(textField.intrinsicContentSize.height, 44, accuracy: 0.1) + XCTAssertEqualWithAccuracy(textField.intrinsicContentSize.width, 253, accuracy: 1.0) + + textField.font = UIFont(name: "Avenir", size: 44)! + XCTAssertEqualWithAccuracy(textField.intrinsicContentSize.height, 62, accuracy: 0.1) + XCTAssertEqualWithAccuracy(textField.intrinsicContentSize.width, 472, accuracy: 0.1) + } + + func testSetCard_numberUnknown() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let number = "1" + card.number = number + let params = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + sut.paymentMethodParams = params + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.errorImage(for: .unknown)!.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.number.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.expirationField.text!.count, 0) + XCTAssertEqual(sut.cvcField.text!.count, 0) + XCTAssertNil(sut.currentFirstResponderField()) + } + + func testSetCard_expiration() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + card.expMonth = NSNumber(value: 10) + card.expYear = NSNumber(value: 99) + let params = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + sut.paymentMethodParams = params + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .unknown)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.number.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData! == imgData) + } + XCTAssertEqual(sut.numberField.text?.count, Int(0)) + XCTAssertEqual(sut.expirationField.text, "10/99") + XCTAssertEqual(sut.cvcField.text?.count, Int(0)) + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertFalse(sut.isValid) + } + + func testSetCard_CVC() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let cvc = "123" + card.cvc = cvc + let params = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + sut.paymentMethodParams = params + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .unknown)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.number.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text!.count, Int(0)) + XCTAssertEqual(sut.expirationField.text!.count, Int(0)) + XCTAssertEqual(sut.cvcField.text, cvc) + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertFalse(sut.isValid) + } + + func testSetCard_updatesCVCValidity() { + let sut = STPPaymentCardTextField() + sut.numberField.text = "378282246310005" + sut.cvcField.text = "1234" + sut.expirationField.text = "10/99" + XCTAssertTrue(sut.cvcField.validText) + sut.numberField.text = "4242424242424242" + XCTAssertFalse(sut.cvcField.validText) + } + + func testSetCard_numberVisa() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let number = "424242" + card.number = number + let params = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + sut.paymentMethodParams = params + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .visa)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.number.rawValue) + if let imgData { + XCTAssertNotNil(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.expirationField.text!.count, Int(0)) + XCTAssertEqual(sut.cvcField.text!.count, Int(0)) + XCTAssertEqual(sut.cvcField.placeholder, "CVC") + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertFalse(sut.isValid) + } + + func testSetCard_numberVisaInvalid() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let number = "4242111111111111" + card.number = number + let params = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + sut.paymentMethodParams = params + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.errorImage(for: .visa)!.pngData() + + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + } + + func testSetCard_numberAmex() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let number = "378282" + card.number = number + let params = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + sut.paymentMethodParams = params + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .amex)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.number.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.cvcField.text!.count, Int(0)) + XCTAssertEqual(sut.cvcField.placeholder, "CVV") + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertFalse(sut.isValid) + } + + func testSetCard_numberAmexInvalid() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let number = "378282246311111" + card.number = number + let params = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + sut.paymentMethodParams = params + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.errorImage(for: .amex)!.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.number.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + } + + func testSetCard_numberAndExpiration() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let number = "4242424242424242" + card.number = number + card.expMonth = NSNumber(value: 10) + card.expYear = NSNumber(value: 99) + let params = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + sut.paymentMethodParams = params + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .visa)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.expirationField.text, "10/99") + XCTAssertEqual(sut.cvcField.text!.count, Int(0)) + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertFalse(sut.isValid) + } + + func testSetCard_partialNumberAndExpiration() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let number = "424242" + card.number = number + card.expMonth = NSNumber(value: 10) + card.expYear = NSNumber(value: 99) + let params = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + sut.paymentMethodParams = params + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .visa)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.number.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.expirationField.text, "10/99") + XCTAssertEqual(sut.cvcField.text!.count, Int(0)) + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertFalse(sut.isValid) + } + + func testSetCard_numberAndCVC() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let number = "378282246310005" + let cvc = "123" + card.number = number + card.cvc = cvc + let params = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + sut.paymentMethodParams = params + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .amex)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.expirationField.text!.count, Int(0)) + XCTAssertEqual(sut.cvcField.text, cvc) + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertFalse(sut.isValid) + } + + func testSetCard_expirationAndCVC() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let cvc = "123" + card.expMonth = NSNumber(value: 10) + card.expYear = NSNumber(value: 99) + card.cvc = cvc + let params = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + sut.paymentMethodParams = params + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .unknown)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.number.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text!.count, Int(0)) + XCTAssertEqual(sut.expirationField.text, "10/99") + XCTAssertEqual(sut.cvcField.text, cvc) + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertFalse(sut.isValid) + } + + func testSetCard_completeCardCountryWithoutPostal() { + let sut = STPPaymentCardTextField() + sut.countryCode = "BZ" + let card = STPPaymentMethodCardParams() + let number = "4242424242424242" + let cvc = "123" + card.number = number + card.expMonth = NSNumber(value: 10) + card.expYear = NSNumber(value: 99) + card.cvc = cvc + let params = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + sut.paymentMethodParams = params + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .visa)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.expirationField.text, "10/99") + XCTAssertEqual(sut.cvcField.text, cvc) + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertTrue(sut.isValid) + } + + func testSetCard_completeCardNoPostal() { + let sut = STPPaymentCardTextField() + sut.postalCodeEntryEnabled = false + let card = STPPaymentMethodCardParams() + let number = "4242424242424242" + let cvc = "123" + card.number = number + card.expMonth = NSNumber(value: 10) + card.expYear = NSNumber(value: 99) + card.cvc = cvc + let params = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + sut.paymentMethodParams = params + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .visa)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.expirationField.text, "10/99") + XCTAssertEqual(sut.cvcField.text, cvc) + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertTrue(sut.isValid) + } + + func testSetCard_completeCard() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let number = "4242424242424242" + let cvc = "123" + card.number = number + card.expMonth = NSNumber(value: 10) + card.expYear = NSNumber(value: 99) + card.cvc = cvc + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.address = STPPaymentMethodAddress() + billingDetails.address!.postalCode = "90210" + let params = STPPaymentMethodParams(card: card, billingDetails: billingDetails, metadata: nil) + sut.paymentMethodParams = params + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .visa)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.expirationField.text, "10/99") + XCTAssertEqual(sut.cvcField.text, cvc) + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertTrue(sut.isValid) + } + + func testSetCard_empty() { + let sut = STPPaymentCardTextField() + sut.numberField.text = "4242424242424242" + sut.cvcField.text = "123" + sut.expirationField.text = "10/99" + let card = STPPaymentMethodCardParams() + let params = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + sut.paymentMethodParams = params + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .unknown)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.number.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text!.count, Int(0)) + XCTAssertEqual(sut.expirationField.text!.count, Int(0)) + XCTAssertEqual(sut.cvcField.text!.count, Int(0)) + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertFalse(sut.isValid) + } + + func testSettingTextUpdatesViewModelText() { + let sut = STPPaymentCardTextField() + sut.numberField.text = "4242424242424242" + XCTAssertEqual(sut.viewModel.cardNumber, sut.numberField.text) + + sut.cvcField.text = "123" + XCTAssertEqual(sut.viewModel.cvc, sut.cvcField.text) + + sut.expirationField.text = "10/99" + XCTAssertEqual(sut.viewModel.rawExpiration, sut.expirationField.text) + XCTAssertEqual(sut.viewModel.expirationMonth, "10") + XCTAssertEqual(sut.viewModel.expirationYear, "99") + } + + func testSettingTextUpdatesCardParams() { + let sut = STPPaymentCardTextField() + sut.numberField.text = "4242424242424242" + sut.cvcField.text = "123" + sut.expirationField.text = "10/99" + sut.postalCodeField.text = "90210" + + let card = sut.paymentMethodParams.card + XCTAssertNotNil(card) + XCTAssertEqual(card?.number, "4242424242424242") + XCTAssertEqual(card?.cvc, "123") + XCTAssertEqual(card?.expMonth?.intValue ?? 0, 10) + XCTAssertEqual(card?.expYear?.intValue ?? 0, 99) + XCTAssertEqual(sut.paymentMethodParams.billingDetails!.address!.postalCode, "90210") + } + + func testSettingBillingDetailsRetainsBillingDetails() { + let sut = STPPaymentCardTextField() + let params = STPPaymentMethodCardParams() + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Test test" + + sut.paymentMethodParams = STPPaymentMethodParams(card: params, billingDetails: billingDetails, metadata: nil) + let actual = sut.paymentMethodParams + + XCTAssertEqual("Test test", actual.billingDetails!.name) + } + + func testSettingMetadataRetainsMetadata() { + let sut = STPPaymentCardTextField() + let params = STPPaymentMethodCardParams() + sut.paymentMethodParams = STPPaymentMethodParams(card: params, billingDetails: nil, metadata: [ + "hello": "test" + ]) + let actual = sut.paymentMethodParams + + XCTAssertEqual([ + "hello": "test" + ], actual.metadata) + } + + func testSettingPostalCodeUpdatesCardParams() { + let sut = STPPaymentCardTextField() + sut.numberField.text = "4242424242424242" + sut.cvcField.text = "123" + sut.expirationField.text = "10/99" + sut.postalCodeField.text = "90210" + + let params = sut.paymentMethodParams.card + XCTAssertNotNil(params) + XCTAssertEqual(params?.number, "4242424242424242") + XCTAssertEqual(params?.cvc, "123") + XCTAssertEqual(params?.expMonth?.intValue ?? 0, 10) + XCTAssertEqual(params?.expYear?.intValue ?? 0, 99) + } + + func testEmptyPostalCodeVendsNilAddress() { + let sut = STPPaymentCardTextField() + sut.numberField.text = "4242424242424242" + sut.cvcField.text = "123" + sut.expirationField.text = "10/99" + + XCTAssertNil(sut.paymentMethodParams.billingDetails?.address?.postalCode) + let params = sut.paymentMethodParams.card + XCTAssertNotNil(params) + XCTAssertEqual(params?.number, "4242424242424242") + XCTAssertEqual(params?.cvc, "123") + XCTAssertEqual(params?.expMonth?.intValue ?? 0, 10) + XCTAssertEqual(params?.expYear?.intValue ?? 0, 99) + } + + func testAccessingCardParamsDuringSettingCardParams() { + let delegate = PaymentCardTextFieldBlockDelegate() + delegate.didChange = { textField in + // delegate reads the `cardParams` for any reason it wants + textField.paymentMethodParams.card + } + let sut = STPPaymentCardTextField() + sut.delegate = delegate + + let params = STPPaymentMethodCardParams() + params.number = "4242424242424242" + params.cvc = "123" + + sut.paymentMethodParams = STPPaymentMethodParams(card: params, billingDetails: nil, metadata: nil) + let actual = sut.paymentMethodParams.card + + XCTAssertEqual("4242424242424242", actual!.number) + XCTAssertEqual("123", actual!.cvc) + } + + func testSetCardParamsCopiesObject() { + let sut = STPPaymentCardTextField() + let params = STPPaymentMethodCardParams() + + params.number = "4242424242424242" // legit + sut.paymentMethodParams = STPPaymentMethodParams(card: params, billingDetails: nil, metadata: nil) + + // fetching `sut.cardParams` returns a copy, so edits happen to caller's copy + sut.paymentMethodParams.card!.number = "number 1" + + // `sut` copied `params` (& `params.address`) when set, so edits to original don't show up + params.number = "number 2" + + XCTAssertEqual("4242424242424242", sut.paymentMethodParams.card!.number) + + XCTAssertNotEqual("number 1", sut.paymentMethodParams.card!.number, "return value from cardParams cannot be edited inline") + + XCTAssertNotEqual("number 2", sut.paymentMethodParams.card!.number, "caller changed their copy after setCardParams:") + } + + // MARK: - paymentMethodParams + + func testSetCard_numberUnknown_pm() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let number = "1" + card.number = number + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.errorImage(for: .unknown)!.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.number.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.expirationField.text!.count, Int(0)) + XCTAssertEqual(sut.cvcField.text!.count, Int(0)) + XCTAssertNil(sut.currentFirstResponderField()) + } + + func testSetCard_expiration_pm() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + card.expMonth = NSNumber(value: 10) + card.expYear = NSNumber(value: 99) + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .unknown)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.number.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text!.count, Int(0)) + XCTAssertEqual(sut.expirationField.text, "10/99") + XCTAssertEqual(sut.cvcField.text!.count, Int(0)) + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertFalse(sut.isValid) + } + + func testSetCard_CVC_pm() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let cvc = "123" + card.cvc = cvc + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .unknown)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.number.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text!.count, Int(0)) + XCTAssertEqual(sut.expirationField.text!.count, Int(0)) + XCTAssertEqual(sut.cvcField.text, cvc) + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertFalse(sut.isValid) + } + + func testSetCard_numberVisa_pm() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let number = "424242" + card.number = number + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .visa)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.number.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.expirationField.text!.count, Int(0)) + XCTAssertEqual(sut.cvcField.text!.count, Int(0)) + XCTAssertEqual(sut.cvcField.placeholder, "CVC") + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertFalse(sut.isValid) + } + + func testSetCard_numberVisaInvalid_pm() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let number = "4242111111111111" + card.number = number + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.errorImage(for: .visa)!.pngData() + + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + } + + func testSetCard_numberAmex_pm() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let number = "378282" + card.number = number + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .amex)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.number.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.cvcField.text!.count, Int(0)) + XCTAssertEqual(sut.cvcField.placeholder, "CVV") + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertFalse(sut.isValid) + } + + func testSetCard_numberAmexInvalid_pm() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let number = "378282246311111" + card.number = number + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.errorImage(for: .amex)!.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.number.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + } + + func testSetCard_numberAndExpiration_pm() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let number = "4242424242424242" + card.number = number + card.expMonth = NSNumber(value: 10) + card.expYear = NSNumber(value: 99) + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .visa)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.expirationField.text, "10/99") + XCTAssertEqual(sut.cvcField.text!.count, Int(0)) + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertFalse(sut.isValid) + } + + func testSetCard_partialNumberAndExpiration_pm() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let number = "424242" + card.number = number + card.expMonth = NSNumber(value: 10) + card.expYear = NSNumber(value: 99) + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .visa)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.number.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.expirationField.text, "10/99") + XCTAssertEqual(sut.cvcField.text!.count, Int(0)) + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertFalse(sut.isValid) + } + + func testSetCard_numberAndCVC_pm() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let number = "378282246310005" + let cvc = "123" + card.number = number + card.cvc = cvc + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .amex)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.expirationField.text!.count, Int(0)) + XCTAssertEqual(sut.cvcField.text, cvc) + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertFalse(sut.isValid) + } + + func testSetCard_expirationAndCVC_pm() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let cvc = "123" + card.expMonth = NSNumber(value: 10) + card.expYear = NSNumber(value: 99) + card.cvc = cvc + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .unknown)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.number.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text!.count, Int(0)) + XCTAssertEqual(sut.expirationField.text, "10/99") + XCTAssertEqual(sut.cvcField.text, cvc) + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertFalse(sut.isValid) + } + + func testSetCard_completeCardCountryWithoutPostal_pm() { + let sut = STPPaymentCardTextField() + sut.countryCode = "BZ" + let card = STPPaymentMethodCardParams() + let number = "4242424242424242" + let cvc = "123" + card.number = number + card.expMonth = NSNumber(value: 10) + card.expYear = NSNumber(value: 99) + card.cvc = cvc + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .visa)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.expirationField.text, "10/99") + XCTAssertEqual(sut.cvcField.text, cvc) + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertTrue(sut.isValid) + } + + func testSetCard_completeCardNoPostal_pm() { + let sut = STPPaymentCardTextField() + sut.postalCodeEntryEnabled = false + let card = STPPaymentMethodCardParams() + let number = "4242424242424242" + let cvc = "123" + card.number = number + card.expMonth = NSNumber(value: 10) + card.expYear = NSNumber(value: 99) + card.cvc = cvc + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .visa)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.expirationField.text, "10/99") + XCTAssertEqual(sut.cvcField.text, cvc) + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertTrue(sut.isValid) + } + + func testSetCard_completeCard_pm() { + let sut = STPPaymentCardTextField() + let card = STPPaymentMethodCardParams() + let number = "4242424242424242" + let cvc = "123" + card.number = number + card.expMonth = NSNumber(value: 10) + card.expYear = NSNumber(value: 99) + card.cvc = cvc + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: STPPaymentMethodBillingDetails(postalCode: "90210", countryCode: "US"), metadata: nil) + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .visa)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.expirationField.text, "10/99") + XCTAssertEqual(sut.cvcField.text, cvc) + XCTAssertNil(sut.currentFirstResponderField()) + let isvalid = sut.isValid + XCTAssertTrue(isvalid) + + let paymentMethodParams = sut.paymentMethodParams + XCTAssertNotNil(paymentMethodParams) + + let sutCardParams = paymentMethodParams.card + XCTAssertNotNil(sutCardParams) + + XCTAssertEqual(sutCardParams?.number, card.number) + XCTAssertEqual(sutCardParams?.expMonth, card.expMonth) + XCTAssertEqual(sutCardParams?.expYear, card.expYear) + XCTAssertEqual(sutCardParams?.cvc, card.cvc) + + let sutBillingDetails = paymentMethodParams.billingDetails + XCTAssertNotNil(sutBillingDetails) + + let sutAddress = sutBillingDetails?.address + XCTAssertNotNil(sutAddress) + + XCTAssertEqual(sutAddress?.postalCode, "90210") + XCTAssertEqual(sutAddress?.country, "US") + } + + func testSetCard_empty_pm() { + let sut = STPPaymentCardTextField() + sut.numberField.text = "4242424242424242" + sut.cvcField.text = "123" + sut.expirationField.text = "10/99" + let card = STPPaymentMethodCardParams() + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .unknown)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.number.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text!.count, Int(0)) + XCTAssertEqual(sut.expirationField.text!.count, Int(0)) + XCTAssertEqual(sut.cvcField.text!.count, Int(0)) + XCTAssertNil(sut.currentFirstResponderField()) + XCTAssertFalse(sut.isValid) + } +} + +// N.B. It is eexpected for setting the card params to generate API response errors +// because we are calling to the card metadata service without configuration STPAPIClient +class STPPaymentCardTextFieldUITests: XCTestCase { + var window: UIWindow! + var sut: STPPaymentCardTextField! + + override class func setUp() { + super.setUp() + STPAPIClient.shared.publishableKey = STPTestingDefaultPublishableKey + } + + override func setUp() { + super.setUp() + window = UIWindow(frame: UIScreen.main.bounds) + let textField = STPPaymentCardTextField(frame: window.bounds) + window?.addSubview(textField) + XCTAssertTrue(textField.numberField.canBecomeFirstResponder, "text field cannot become first responder") + sut = textField + } + + // MARK: - UI Tests + + func testSetCard_allFields_whileEditingNumber() { + XCTAssertTrue(sut.numberField.becomeFirstResponder(), "text field is not first responder") + let card = STPPaymentMethodCardParams() + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.address = STPPaymentMethodAddress() + billingDetails.address!.postalCode = "90210" + let number = "4242424242424242" + let cvc = "123" + card.number = number + card.expMonth = NSNumber(value: 10) + card.expYear = NSNumber(value: 99) + card.cvc = cvc + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: billingDetails, metadata: nil) + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .visa)?.pngData() + + XCTAssertNil(sut.focusedTextFieldForLayout) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.expirationField.text, "10/99") + XCTAssertEqual(sut.cvcField.text, cvc) + XCTAssertEqual(sut.postalCode, "90210") + XCTAssertFalse(sut.isFirstResponder, "after `setCardParams:`, if all fields are valid, should resign firstResponder") + XCTAssertTrue(sut.isValid) + } + + func testSetCard_partialNumberAndExpiration_whileEditingExpiration() { + XCTAssertTrue(sut.expirationField.becomeFirstResponder(), "text field is not first responder") + let card = STPPaymentMethodCardParams() + let number = "42" + card.number = number + card.expMonth = NSNumber(value: 10) + card.expYear = NSNumber(value: 99) + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.cvcImage(for: .visa)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.CVC.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.expirationField.text, "10/99") + XCTAssertEqual(sut.cvcField.text!.count, Int(0)) + XCTAssertTrue(sut.cvcField.isFirstResponder, "after `setCardParams:`, when firstResponder becomes valid, first invalid field should become firstResponder") + XCTAssertFalse(sut.isValid) + } + + func testSetCard_number_whileEditingCVC() { + XCTAssertTrue(sut.cvcField.becomeFirstResponder(), "text field is not first responder") + let card = STPPaymentMethodCardParams() + let number = "4242424242424242" + card.number = number + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.cvcImage(for: .visa)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.CVC.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData == imgData) + } + XCTAssertEqual(sut.numberField.text, number) + XCTAssertEqual(sut.expirationField.text!.count, Int(0)) + XCTAssertEqual(sut.cvcField.text!.count, Int(0)) + XCTAssertTrue(sut.cvcField.isFirstResponder, "after `setCardParams:`, if firstResponder is invalid, it should remain firstResponder") + XCTAssertFalse(sut.isValid) + } + + func testSetCard_empty_whileEditingNumber() { + sut.numberField.text = "4242424242424242" + sut.cvcField.text = "123" + sut.expirationField.text = "10/99" + XCTAssertTrue(sut.numberField.becomeFirstResponder(), "text field is not first responder") + let card = STPPaymentMethodCardParams() + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + + let imgData = sut.brandImageView.image?.pngData() + let expectedImgData = STPPaymentCardTextField.brandImage(for: .unknown)?.pngData() + + XCTAssertNotNil(sut.focusedTextFieldForLayout) + XCTAssertTrue(sut.focusedTextFieldForLayout?.intValue ?? 0 == STPCardFieldType.number.rawValue) + if let imgData { + XCTAssertTrue(expectedImgData! == imgData) + } + XCTAssertEqual(sut.numberField.text!.count, Int(0)) + XCTAssertEqual(sut.expirationField.text!.count, Int(0)) + XCTAssertEqual(sut.cvcField.text!.count, Int(0)) + XCTAssertTrue(sut.numberField.isFirstResponder, "after `setCardParams:` that clears the text fields, the first invalid field should become firstResponder") + XCTAssertFalse(sut.isValid) + } + + func testBecomeFirstResponder() { + sut.postalCodeEntryEnabled = false + XCTAssertTrue(sut.canBecomeFirstResponder) + XCTAssertTrue(sut.becomeFirstResponder()) + XCTAssertTrue(sut.isFirstResponder) + + XCTAssertEqual(sut.numberField, sut.currentFirstResponderField()) + + sut.becomeFirstResponder() + XCTAssertEqual( + sut.numberField, + sut.currentFirstResponderField(), + "Repeated calls to becomeFirstResponder should not change the firstResponder") + + sut.numberField.text = """ + 4242\ + 4242\ + 4242\ + 4242 + """ + + // Don't unit test auto-advance from number field here because we don't know the cache state + + XCTAssertTrue(sut.cvcField.becomeFirstResponder()) + XCTAssertEqual( + sut.cvcField, + sut.currentFirstResponderField(), + "We don't block other fields from becoming firstResponder") + + XCTAssertTrue(sut.becomeFirstResponder()) + XCTAssertEqual( + sut.cvcField, + sut.currentFirstResponderField(), + "Calling becomeFirstResponder does not change the currentFirstResponder") + + sut.expirationField.text = "10/99" + sut.cvcField.text = "123" + + sut.resignFirstResponder() + XCTAssertTrue(sut.canBecomeFirstResponder) + XCTAssertTrue(sut.becomeFirstResponder()) + + XCTAssertEqual( + sut.cvcField, + sut.currentFirstResponderField(), + "When all fields are valid, the last one should be the preferred firstResponder") + + sut.postalCodeEntryEnabled = true + XCTAssertFalse(sut.isValid) + + sut.resignFirstResponder() + XCTAssertTrue(sut.becomeFirstResponder()) + XCTAssertEqual( + sut.postalCodeField, + sut.currentFirstResponderField(), + "When postalCodeEntryEnabled=YES, it should become firstResponder after other fields are valid") + + sut.expirationField.text = "" + sut.resignFirstResponder() + XCTAssertTrue(sut.becomeFirstResponder()) + XCTAssertEqual( + sut.expirationField, + sut.currentFirstResponderField(), + "Moves firstResponder back to expiration, because it's not valid anymore") + + sut.expirationField.text = "10/99" + sut.postalCodeField.text = "90210" + + sut.resignFirstResponder() + XCTAssertTrue(sut.becomeFirstResponder()) + XCTAssertEqual( + sut.postalCodeField, + sut.currentFirstResponderField(), + "When all fields are valid, the last one should be the preferred firstResponder") + } + + func testShouldReturnCyclesThroughFields() { + let delegate = PaymentCardTextFieldBlockDelegate() + delegate.willEndEditingForReturn = { _ in + XCTFail("Did not expect editing to end in this test") + } + sut.delegate = delegate + + sut.becomeFirstResponder() + XCTAssertTrue(sut.numberField.isFirstResponder) + + XCTAssertFalse(sut.numberField.delegate!.textFieldShouldReturn!(sut.numberField), "shouldReturn = NO") + XCTAssertTrue(sut.expirationField.isFirstResponder, "with side effect to move 1st responder to next field") + + XCTAssertFalse(sut.expirationField.delegate!.textFieldShouldReturn!(sut.expirationField), "shouldReturn = NO") + XCTAssertTrue(sut.cvcField.isFirstResponder, "with side effect to move 1st responder to next field") + + XCTAssertFalse(sut.cvcField.delegate!.textFieldShouldReturn!(sut.cvcField), "shouldReturn = NO") + XCTAssertTrue(sut.postalCodeField.isFirstResponder, "with side effect to move 1st responder to next field") + + XCTAssertFalse(sut.postalCodeField.delegate!.textFieldShouldReturn!(sut.postalCodeField), "shouldReturn = NO") + XCTAssertTrue(sut.numberField.isFirstResponder, "with side effect to move 1st responder from last field to first invalid field") + } + + func testShouldReturnCyclesThroughFieldsWithoutPostal() { + let delegate = PaymentCardTextFieldBlockDelegate() + delegate.willEndEditingForReturn = { _ in + XCTFail("Did not expect editing to end in this test") + } + sut.delegate = delegate + sut.postalCodeEntryEnabled = false + + sut.becomeFirstResponder() + XCTAssertTrue(sut.numberField.isFirstResponder) + + XCTAssertFalse(sut.numberField.delegate!.textFieldShouldReturn!(sut.numberField), "shouldReturn = NO") + + XCTAssertTrue(sut.expirationField.isFirstResponder, "with side effect to move 1st responder to next field") + + XCTAssertFalse(sut.expirationField.delegate!.textFieldShouldReturn!(sut.expirationField), "shouldReturn = NO") + XCTAssertTrue(sut.cvcField.isFirstResponder, "with side effect to move 1st responder to next field") + + XCTAssertFalse(sut.cvcField.delegate!.textFieldShouldReturn!(sut.cvcField), "shouldReturn = NO") + XCTAssertTrue(sut.numberField.isFirstResponder, "with side effect to move 1st responder from last field to first invalid field") + } + + func testShouldReturnDismissesWhenValidNoPostalCode() { + var hasReturned = false + var didEnd = false + + sut.postalCodeEntryEnabled = false + sut.paymentMethodParams = STPPaymentMethodParams(card: STPFixtures.paymentMethodCardParams(), billingDetails: nil, metadata: nil) + + let delegate = PaymentCardTextFieldBlockDelegate() + delegate.willEndEditingForReturn = { _ in + XCTAssertFalse(didEnd, "willEnd is called before didEnd") + XCTAssertFalse(hasReturned, "willEnd is only called once") + hasReturned = true + } + + delegate.didEndEditing = { _ in + XCTAssertTrue(hasReturned, "didEndEditing should be called after willEnd") + XCTAssertFalse(didEnd, "didEnd is only called once") + didEnd = true + } + + sut.delegate = delegate + sut.becomeFirstResponder() + XCTAssertTrue(sut.cvcField.isFirstResponder, "when textfield is filled out, default first responder is the last field") + + XCTAssertFalse(hasReturned, "willEndEditingForReturn delegate method should not have been called yet") + + XCTAssertFalse(sut.cvcField.delegate!.textFieldShouldReturn!(sut.cvcField), "shouldReturn = NO") + + XCTAssertNil(sut.currentFirstResponderField(), "Should have resigned first responder") + XCTAssertTrue(hasReturned, "delegate method has been invoked") + XCTAssertTrue(didEnd, "delegate method has been invoked") + } + + func testShouldReturnDismissesWhenValid() { + var hasReturned = false + var didEnd = false + + sut.paymentMethodParams = STPPaymentMethodParams(card: STPFixtures.paymentMethodCardParams(), billingDetails: nil, metadata: nil) + sut.postalCodeField.text = "90210" + let delegate = PaymentCardTextFieldBlockDelegate() + delegate.willEndEditingForReturn = { _ in + XCTAssertFalse(didEnd, "willEnd is called before didEnd") + XCTAssertFalse(hasReturned, "willEnd is only called once") + hasReturned = true + } + + delegate.didEndEditing = { _ in + XCTAssertTrue(hasReturned, "didEndEditing should be called after willEnd") + XCTAssertFalse(didEnd, "didEnd is only called once") + didEnd = true + } + + sut.delegate = delegate + sut.becomeFirstResponder() + XCTAssertTrue(sut.postalCodeField.isFirstResponder, "when textfield is filled out, default first responder is the last field") + + XCTAssertFalse(hasReturned, "willEndEditingForReturn delegate method should not have been called yet") + XCTAssertFalse(sut.postalCodeField.delegate!.textFieldShouldReturn!(sut.postalCodeField), "shouldReturn = NO") + + XCTAssertNil(sut.currentFirstResponderField(), "Should have resigned first responder") + XCTAssertTrue(hasReturned, "delegate method has been invoked") + XCTAssertTrue(didEnd, "delegate method has been invoked") + } + + func testValueUpdatesWhenDeletingOnEmptyField() { + let card = STPPaymentMethodCardParams() + let number = "4242424242424242" + card.number = number + sut.paymentMethodParams = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + var hasChanged = false + let delegate = PaymentCardTextFieldBlockDelegate() + delegate.didChange = { textField in + XCTAssertEqual(textField.numberField.text, "424242424242424") + XCTAssertEqual(textField.cardNumber, "424242424242424") + XCTAssertFalse(hasChanged, "didChange delegate method should not have been called yet") + hasChanged = true + } + + sut.delegate = delegate + sut.becomeFirstResponder() + sut.deleteBackward() + XCTAssertEqual(sut.numberField.text, "424242424242424") + XCTAssertEqual(sut.cardNumber, "424242424242424") + XCTAssertTrue(hasChanged, "delegate method has been invoked") + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentCardTextFieldTestsSwift.swift b/Stripe/StripeiOSTests/STPPaymentCardTextFieldTestsSwift.swift new file mode 100644 index 00000000..c914d4dd --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentCardTextFieldTestsSwift.swift @@ -0,0 +1,67 @@ +// +// STPPaymentCardTextFieldTestsSwift.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 8/24/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentCardTextFieldTestsSwift: XCTestCase { + + func testClearMaintainsPostalCodeEntryEnabled() { + let textField = STPPaymentCardTextField() + let postalCodeEntryDefaultEnabled = textField.postalCodeEntryEnabled + textField.clear() + XCTAssertEqual( + postalCodeEntryDefaultEnabled, + textField.postalCodeEntryEnabled, + "clear overrode default postalCodeEntryEnabled value" + ) + + // -- + textField.postalCodeEntryEnabled = false + textField.clear() + XCTAssertFalse( + textField.postalCodeEntryEnabled, + "clear overrode custom postalCodeEntryEnabled false value" + ) + + // -- + textField.postalCodeEntryEnabled = true + // The ORs in this test are to handle if these tests are run in an environment + // where the locale doesn't require postal codes, in which case the calculated + // value for postalCodeEntryEnabled can be different than the value set + // (this is a legacy API). + let stillTrueOrRequestedButNoPostal = + textField.postalCodeEntryEnabled + || (textField.viewModel.postalCodeRequested + && STPPostalCodeValidator.postalCodeIsRequired( + forCountryCode: textField.viewModel.postalCodeCountryCode + )) + XCTAssertTrue( + stillTrueOrRequestedButNoPostal, + "clear overrode custom postalCodeEntryEnabled true value" + ) + + } + + func testPostalCodeIsValidWhenExpirationIsNot() { + let cardTextField = STPPaymentCardTextField() + + // Old expiration date + cardTextField.expirationField.text = "10/10" + XCTAssertFalse(cardTextField.expirationField.validText) + + cardTextField.postalCode = "10001" + cardTextField.formTextFieldTextDidChange(cardTextField.postalCodeField) + XCTAssertTrue(cardTextField.postalCodeField.validText) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentCardTextFieldViewModelTest.swift b/Stripe/StripeiOSTests/STPPaymentCardTextFieldViewModelTest.swift new file mode 100644 index 00000000..bf18859d --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentCardTextFieldViewModelTest.swift @@ -0,0 +1,112 @@ +// +// STPPaymentCardTextFieldViewModelTest.swift +// StripeiOS Tests +// +// Created by Jack Flintermann on 7/16/15. +// Copyright (c) 2015 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentCardTextFieldViewModelTest: XCTestCase { + var viewModel: STPPaymentCardTextFieldViewModel? + + override func setUp() { + super.setUp() + viewModel = STPPaymentCardTextFieldViewModel() + } + + func testCardNumber() { + let tests = [ + ["", ""], + ["4242", "4242"], + ["4242424242424242", "4242424242424242"], + ["4242 4242 4242 4242", "4242424242424242"], + ["4242xxx4242", "42424242"], + ["12345678901234567890", "1234567890123456789"], + ] + for test in tests { + viewModel?.cardNumber = test[0] + XCTAssertEqual(viewModel?.cardNumber, test[1]) + } + } + + func testRawExpiration() { + // swiftlint:disable:next large_tuple + let tests: [(String, String, String, String, STPCardValidationState)] = [ + ("", "", "", "", .incomplete), + ("12/23", "12/23", "12", "23", .valid), + ("1223", "12/23", "12", "23", .valid), + ("1", "1", "1", "", .incomplete), + ("2", "02/", "02", "", .incomplete), + ("12", "12/", "12", "", .incomplete), + ("12/2", "12/2", "12", "2", .incomplete), + ("99/23", "99", "99", "23", .invalid), + ("10/12", "10/12", "10", "12", .invalid), + ("12*23", "12/23", "12", "23", .valid), + ("12/*", "12/", "12", "", .incomplete), + ("*", "", "", "", .incomplete), + ] + for test in tests { + viewModel?.rawExpiration = test.0 + XCTAssertEqual(viewModel?.rawExpiration, test.1) + XCTAssertEqual(viewModel?.expirationMonth, test.2) + XCTAssertEqual(viewModel?.expirationYear, test.3) + XCTAssertEqual(viewModel?.validationStateForExpiration(), test.4) + } + } + + func testCVC() { + let tests = [["1", "1"], ["1234", "1234"], ["12345", "1234"], ["1x", "1"]] + for test in tests { + viewModel?.cvc = test[0] + XCTAssertEqual(viewModel?.cvc, test[1]) + } + } + + func testValidity() { + viewModel?.cardNumber = "4242424242424242" + viewModel?.rawExpiration = "12/24" + viewModel?.cvc = "123" + XCTAssertTrue(viewModel!.isValid) + + viewModel?.cvc = "12" + XCTAssertFalse(viewModel!.isValid) + } + + func testCompressedCardNumber() { + viewModel?.cardNumber = nil + // Should use default placeholder + XCTAssertEqual(viewModel?.compressedCardNumber(withPlaceholder: nil), "4242") + XCTAssertEqual(viewModel?.compressedCardNumber(withPlaceholder: "1234567812345678"), "5678") + + viewModel?.cardNumber = "424212345678" + XCTAssertEqual(viewModel?.compressedCardNumber(withPlaceholder: nil), "5678") + viewModel?.cardNumber = "42421234567" + XCTAssertEqual(viewModel?.compressedCardNumber(withPlaceholder: nil), "567") + viewModel?.cardNumber = "4242123456" + XCTAssertEqual(viewModel?.compressedCardNumber(withPlaceholder: nil), "56") + viewModel?.cardNumber = "424212345" + XCTAssertEqual(viewModel?.compressedCardNumber(withPlaceholder: nil), "5") + viewModel?.cardNumber = "42421234" + XCTAssertEqual(viewModel?.compressedCardNumber(withPlaceholder: nil), "1234") + + viewModel?.cardNumber = "12" + XCTAssertEqual(viewModel?.compressedCardNumber(withPlaceholder: nil), "12") + + viewModel?.cardNumber = "36227206271667" + XCTAssertEqual(viewModel?.compressedCardNumber(withPlaceholder: nil), "1667") + viewModel?.cardNumber = "3622720627166" + XCTAssertEqual(viewModel?.compressedCardNumber(withPlaceholder: nil), "166") + viewModel?.cardNumber = "36227206271" + XCTAssertEqual(viewModel?.compressedCardNumber(withPlaceholder: nil), "1") + viewModel?.cardNumber = "3622720627" + XCTAssertEqual(viewModel?.compressedCardNumber(withPlaceholder: nil), "720627") + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentConfigurationTest.m b/Stripe/StripeiOSTests/STPPaymentConfigurationTest.m new file mode 100644 index 00000000..8f23a409 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentConfigurationTest.m @@ -0,0 +1,141 @@ +// +// STPPaymentConfigurationTest.m +// Stripe +// +// Created by Joey Dong on 7/18/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +#import +#import + + +@import StripeCore; + + + + +@interface STPPaymentConfigurationTest : XCTestCase + +@end + +@implementation STPPaymentConfigurationTest + +- (void)testSharedConfiguration { + XCTAssertEqual([STPPaymentConfiguration sharedConfiguration], [STPPaymentConfiguration sharedConfiguration]); +} + +- (void)testInit { + STPPaymentConfiguration *paymentConfiguration = [[STPPaymentConfiguration alloc] init]; + + XCTAssertFalse(paymentConfiguration.fpxEnabled); + XCTAssertEqual(paymentConfiguration.requiredBillingAddressFields, STPBillingAddressFieldsPostalCode); + XCTAssertNil(paymentConfiguration.requiredShippingAddressFields); + XCTAssert(paymentConfiguration.verifyPrefilledShippingAddress); + XCTAssertEqual(paymentConfiguration.shippingType, STPShippingTypeShipping); + XCTAssertEqualObjects(paymentConfiguration.companyName, @"xctest"); + XCTAssertNil(paymentConfiguration.appleMerchantIdentifier); + XCTAssert(paymentConfiguration.canDeletePaymentOptions); + XCTAssertFalse(paymentConfiguration.cardScanningEnabled); +} + +- (void)testApplePayEnabledSatisfied { + id stripeMock = OCMClassMock([StripeAPI class]); + OCMStub([stripeMock deviceSupportsApplePay]).andReturn(YES); + + STPPaymentConfiguration *paymentConfiguration = [[STPPaymentConfiguration alloc] init]; + paymentConfiguration.appleMerchantIdentifier = @"appleMerchantIdentifier"; + + XCTAssert([paymentConfiguration applePayEnabled]); +} + +- (void)testApplePayEnabledMissingAppleMerchantIdentifier { + id stripeMock = OCMClassMock([StripeAPI class]); + OCMStub([stripeMock deviceSupportsApplePay]).andReturn(YES); + + STPPaymentConfiguration *paymentConfiguration = [[STPPaymentConfiguration alloc] init]; + paymentConfiguration.appleMerchantIdentifier = nil; + + XCTAssertFalse([paymentConfiguration applePayEnabled]); +} + +- (void)testApplePayEnabledDisallowAdditionalPaymentOptions { + id stripeMock = OCMClassMock([StripeAPI class]); + OCMStub([stripeMock deviceSupportsApplePay]).andReturn(YES); + + STPPaymentConfiguration *paymentConfiguration = [[STPPaymentConfiguration alloc] init]; + paymentConfiguration.appleMerchantIdentifier = @"appleMerchantIdentifier"; + paymentConfiguration.applePayEnabled = false; + + XCTAssertFalse([paymentConfiguration applePayEnabled]); +} + +- (void)testApplePayEnabledMisisngDeviceSupport { + // Change the supported networks list to reset the applePayEnabled cache + StripeAPI.additionalEnabledApplePayNetworks = @[PKPaymentNetworkJCB]; + id paymentAuthControllerMock = OCMClassMock([PKPaymentAuthorizationController class]); + OCMStub([paymentAuthControllerMock canMakePaymentsUsingNetworks:[OCMArg any]]).andReturn(NO); + + STPPaymentConfiguration *paymentConfiguration = [[STPPaymentConfiguration alloc] init]; + paymentConfiguration.appleMerchantIdentifier = @"appleMerchantIdentifier"; + + XCTAssertFalse([paymentConfiguration applePayEnabled]); + [paymentAuthControllerMock stopMocking]; + // Re-reset cache: + StripeAPI.additionalEnabledApplePayNetworks = @[]; +} + +#pragma mark - Description + +- (void)testDescription { + STPPaymentConfiguration *paymentConfiguration = [[STPPaymentConfiguration alloc] init]; + XCTAssert(paymentConfiguration.description); +} + +#pragma mark - NSCopying + +- (void)testCopyWithZone { + NSSet *allFields = [NSSet setWithArray:@[STPContactField.postalAddress, + STPContactField.emailAddress, + STPContactField.phoneNumber, + STPContactField.name]]; + + STPPaymentConfiguration *paymentConfigurationA = [[STPPaymentConfiguration alloc] init]; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated" + paymentConfigurationA.publishableKey = @"publishableKey"; + paymentConfigurationA.stripeAccount = @"stripeAccount"; +#pragma clang diagnostic pop + paymentConfigurationA.applePayEnabled = YES; + paymentConfigurationA.requiredBillingAddressFields = STPBillingAddressFieldsFull; + paymentConfigurationA.requiredShippingAddressFields = allFields; + paymentConfigurationA.verifyPrefilledShippingAddress = NO; + paymentConfigurationA.availableCountries = [NSSet setWithArray:@[@"US", @"CA", @"BT"]]; + paymentConfigurationA.shippingType = STPShippingTypeDelivery; + paymentConfigurationA.companyName = @"companyName"; + paymentConfigurationA.appleMerchantIdentifier = @"appleMerchantIdentifier"; + paymentConfigurationA.canDeletePaymentOptions = NO; + paymentConfigurationA.cardScanningEnabled = NO; + + STPPaymentConfiguration *paymentConfigurationB = [paymentConfigurationA copy]; + XCTAssertNotEqual(paymentConfigurationA, paymentConfigurationB); + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertEqualObjects(paymentConfigurationB.publishableKey, @"publishableKey"); + XCTAssertEqualObjects(paymentConfigurationB.stripeAccount, @"stripeAccount"); +#pragma clang diagnostic pop + XCTAssertTrue(paymentConfigurationB.applePayEnabled); + XCTAssertEqual(paymentConfigurationB.requiredBillingAddressFields, STPBillingAddressFieldsFull); + XCTAssertEqualObjects(paymentConfigurationB.requiredShippingAddressFields, allFields); + XCTAssertFalse(paymentConfigurationB.verifyPrefilledShippingAddress); + XCTAssertEqual(paymentConfigurationB.shippingType, STPShippingTypeDelivery); + XCTAssertEqualObjects(paymentConfigurationB.companyName, @"companyName"); + XCTAssertEqualObjects(paymentConfigurationB.appleMerchantIdentifier, @"appleMerchantIdentifier"); + NSSet *availableCountries = [NSSet setWithArray:@[@"US", @"CA", @"BT"]]; + XCTAssertEqualObjects(paymentConfigurationB.availableCountries, availableCountries); + XCTAssertEqual(paymentConfigurationA.canDeletePaymentOptions, paymentConfigurationB.canDeletePaymentOptions); + XCTAssertEqual(paymentConfigurationA.cardScanningEnabled, paymentConfigurationB.cardScanningEnabled); +} + +@end diff --git a/Stripe/StripeiOSTests/STPPaymentContextApplePayTest.swift b/Stripe/StripeiOSTests/STPPaymentContextApplePayTest.swift new file mode 100644 index 00000000..1e317ef3 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentContextApplePayTest.swift @@ -0,0 +1,204 @@ +// +// STPPaymentContextApplePayTest.swift +// StripeiOS Tests +// +// Created by Brian Dorfman on 8/1/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +/// These tests cover STPPaymentContext's Apple Pay specific behavior: +/// - building a PKPaymentRequest +/// - determining paymentSummaryItems +class STPPaymentContextApplePayTest: XCTestCase { + func buildPaymentContext() -> STPPaymentContext { + let config = STPPaymentConfiguration() + config.appleMerchantIdentifier = "fake_merchant_id" + let theme = STPTheme.defaultTheme + let customerContext = Testing_StaticCustomerContext() + let paymentContext = STPPaymentContext( + customerContext: customerContext, + configuration: config, + theme: theme + ) + return paymentContext + } + + // MARK: - buildPaymentRequest + func testBuildPaymentRequest_totalAmount() { + let context = buildPaymentContext() + context.paymentAmount = 150 + let request = context.buildPaymentRequest() + + XCTAssertTrue( + (request?.paymentSummaryItems.last?.amount == NSDecimalNumber(string: "1.50")), + "PKPayment total is not equal to STPPaymentContext amount" + ) + } + + func testBuildPaymentRequest_USDDefault() { + let context = buildPaymentContext() + context.paymentAmount = 100 + let request = context.buildPaymentRequest() + + XCTAssertTrue( + (request?.currencyCode == "USD"), + "Default PKPaymentRequest currency code is not USD" + ) + } + + func testBuildPaymentRequest_currency() { + let context = buildPaymentContext() + context.paymentAmount = 100 + context.paymentCurrency = "GBP" + let request = context.buildPaymentRequest() + + XCTAssertTrue( + (request?.currencyCode == "GBP"), + "PKPaymentRequest currency code is not equal to STPPaymentContext currency" + ) + } + + func testBuildPaymentRequest_uppercaseCurrency() { + let context = buildPaymentContext() + context.paymentAmount = 100 + context.paymentCurrency = "eur" + let request = context.buildPaymentRequest() + + XCTAssertTrue( + (request?.currencyCode == "EUR"), + "PKPaymentRequest currency code is not uppercased" + ) + } + + func testSummaryItems() -> [PKPaymentSummaryItem]? { + return [ + PKPaymentSummaryItem( + label: "First item", + amount: NSDecimalNumber(mantissa: 20, exponent: 0, isNegative: false) + ), + PKPaymentSummaryItem( + label: "Second item", + amount: NSDecimalNumber(mantissa: 90, exponent: 0, isNegative: false) + ), + PKPaymentSummaryItem( + label: "Discount", + amount: NSDecimalNumber(mantissa: 10, exponent: 0, isNegative: true) + ), + PKPaymentSummaryItem( + label: "Total", + amount: NSDecimalNumber(mantissa: 100, exponent: 0, isNegative: false) + ), + ] + } + + func testBuildPaymentRequest_summaryItems() { + let context = buildPaymentContext() + context.paymentSummaryItems = testSummaryItems()! + let request = context.buildPaymentRequest() + + XCTAssertTrue((request?.paymentSummaryItems == context.paymentSummaryItems)) + } + + // MARK: - paymentSummaryItems + func testSetPaymentAmount_generateSummaryItems() { + let context = buildPaymentContext() + context.paymentAmount = 10000 + context.paymentCurrency = "USD" + let itemTotalAmount = context.paymentSummaryItems.last?.amount + let correctTotalAmount = NSDecimalNumber.stp_decimalNumber( + withAmount: context.paymentAmount, + currency: context.paymentCurrency + ) + + XCTAssertTrue((itemTotalAmount == correctTotalAmount)) + } + + func testSetPaymentAmount_generateSummaryItemsShippingMethod() { + let context = buildPaymentContext() + context.paymentAmount = 100 + context.configuration.companyName = "Foo Company" + let method = PKShippingMethod() + method.amount = NSDecimalNumber(string: "5.99") + method.label = "FedEx" + method.detail = "foo" + method.identifier = "123" + context.selectedShippingMethod = method + + let items = context.paymentSummaryItems + XCTAssertEqual(Int(items.count), 2) + let item1 = items[0] + XCTAssertEqual(item1.label, "FedEx") + XCTAssertEqual(item1.amount, NSDecimalNumber(string: "5.99")) + let item2 = items[1] + XCTAssertEqual(item2.label, "Foo Company") + XCTAssertEqual(item2.amount, NSDecimalNumber(string: "6.99")) + } + + func testSummaryItemsToSummaryItems_shippingMethod() { + let context = buildPaymentContext() + let item1 = PKPaymentSummaryItem() + item1.amount = NSDecimalNumber(string: "1.00") + item1.label = "foo" + let item2 = PKPaymentSummaryItem() + item2.amount = NSDecimalNumber(string: "9.00") + item2.label = "bar" + let item3 = PKPaymentSummaryItem() + item3.amount = NSDecimalNumber(string: "10.00") + item3.label = "baz" + context.paymentSummaryItems = [item1, item2, item3] + let method = PKShippingMethod() + method.amount = NSDecimalNumber(string: "5.99") + method.label = "FedEx" + method.detail = "foo" + method.identifier = "123" + context.selectedShippingMethod = method + + let items = context.paymentSummaryItems + XCTAssertEqual(Int(items.count), 4) + let resultItem1 = items[0] + XCTAssertEqual(resultItem1.label, "foo") + XCTAssertEqual(resultItem1.amount, NSDecimalNumber(string: "1.00")) + let resultItem2 = items[1] + XCTAssertEqual(resultItem2.label, "bar") + XCTAssertEqual(resultItem2.amount, NSDecimalNumber(string: "9.00")) + let resultItem3 = items[2] + XCTAssertEqual(resultItem3.label, "FedEx") + XCTAssertEqual(resultItem3.amount, NSDecimalNumber(string: "5.99")) + let resultItem4 = items[3] + XCTAssertEqual(resultItem4.label, "baz") + XCTAssertEqual(resultItem4.amount, NSDecimalNumber(string: "15.99")) + } + + func testAmountToAmount_shippingMethod_usd() { + let context = buildPaymentContext() + context.paymentAmount = 100 + let method = PKShippingMethod() + method.amount = NSDecimalNumber(string: "5.99") + method.label = "FedEx" + method.detail = "foo" + method.identifier = "123" + context.selectedShippingMethod = method + let amount = context.paymentAmount + XCTAssertEqual(amount, 699) + } + + func testSummaryItems_generateAmountDecimalCurrency() { + let context = buildPaymentContext() + context.paymentSummaryItems = testSummaryItems()! + context.paymentCurrency = "USD" + XCTAssertTrue(context.paymentAmount == 10000) + } + + func testSummaryItems_generateAmountNoDecimalCurrency() { + let context = buildPaymentContext() + context.paymentSummaryItems = testSummaryItems()! + context.paymentCurrency = "JPY" + XCTAssertTrue(context.paymentAmount == 100) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentContextSnapshotTests.swift b/Stripe/StripeiOSTests/STPPaymentContextSnapshotTests.swift new file mode 100644 index 00000000..77a3b4e1 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentContextSnapshotTests.swift @@ -0,0 +1,85 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentContextSnapshotTests.m +// StripeiOS Tests +// +// Created by Ben Guo on 12/13/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCaseCore + +class STPPaymentContextSnapshotTests: FBSnapshotTestCase { + var customerContext: STPCustomerContext? + var config: STPPaymentConfiguration? + var hostViewController: UINavigationController? + var paymentContext: STPPaymentContext? + + override func setUp() { + super.setUp() + let config = STPPaymentConfiguration() + config.companyName = "Test Company" + config.requiredBillingAddressFields = .full + config.shippingType = .shipping + self.config = config + let customerContext = Testing_StaticCustomerContext_Objc.init(customer: STPFixtures.customerWithCardTokenAndSourceSources(), paymentMethods: [STPFixtures.paymentMethod(), STPFixtures.paymentMethod()]) + self.customerContext = customerContext + + let viewController = UIViewController() + hostViewController = stp_navigationControllerForSnapshotTest(withRootVC: viewController) + +// self.recordMode = true + } + + func buildPaymentContext() { + let context = STPPaymentContext(customerContext: customerContext!) + context.hostViewController = hostViewController + context.configuration.requiredShippingAddressFields = Set([STPContactField.emailAddress]) + paymentContext = context + } + + func testPushPaymentOptionsSmallTitle() { + buildPaymentContext() + + hostViewController?.navigationBar.prefersLargeTitles = false + paymentContext?.largeTitleDisplayMode = UINavigationItem.LargeTitleDisplayMode.automatic + paymentContext?.pushPaymentOptionsViewController() + let view = stp_preparedAndSizedViewForSnapshotTest(from: hostViewController)! + STPSnapshotVerifyView(view, identifier: nil) + } + + // This test renders at a slightly larger size half the time. + // We're deprecating Basic Integration soon, and we've spent enough time on this, + // so these tests are being disabled for now. + // - (void)testPushPaymentOptionsLargeTitle { + // [self buildPaymentContext]; + // + // self.hostViewController.navigationBar.prefersLargeTitles = YES; + // self.paymentContext.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeAutomatic; + // [self.paymentContext pushPaymentOptionsViewController]; + // UIView *view = [self stp_preparedAndSizedViewForSnapshotTestFromNavigationController:self.hostViewController]; + // STPSnapshotVerifyView(view, nil); + // } + + func testPushShippingAddressSmallTitle() { + buildPaymentContext() + + hostViewController?.navigationBar.prefersLargeTitles = false + paymentContext?.largeTitleDisplayMode = UINavigationItem.LargeTitleDisplayMode.automatic + paymentContext?.pushShippingViewController() + let view = stp_preparedAndSizedViewForSnapshotTest(from: hostViewController)! + STPSnapshotVerifyView(view, identifier: nil) + } + // This test renders at a slightly larger size half the time. + // We're deprecating Basic Integration soon, and we've spent enough time on this, + // so these tests are being disabled for now. + // - (void)testPushShippingAddressLargeTitle { + // [self buildPaymentContext]; + // + // self.hostViewController.navigationBar.prefersLargeTitles = YES; + // self.paymentContext.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeAutomatic; + // [self.paymentContext pushShippingViewController]; + // UIView *view = [self stp_preparedAndSizedViewForSnapshotTestFromNavigationController:self.hostViewController]; + // STPSnapshotVerifyView(view, nil); + // } +} diff --git a/Stripe/StripeiOSTests/STPPaymentHandlerFunctionalTest.m b/Stripe/StripeiOSTests/STPPaymentHandlerFunctionalTest.m new file mode 100644 index 00000000..41b9c30b --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentHandlerFunctionalTest.m @@ -0,0 +1,133 @@ +// +// STPPaymentHandlerFunctionalTest.m +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 5/14/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +#import +@import Stripe; +#import +#import + +@import StripePaymentsObjcTestUtils; + +@interface STPPaymentHandlerFunctionalTest : XCTestCase +@property (nonatomic) id presentingViewController; +@property (nonatomic) id applicationMock; +@end + +@interface STPPaymentHandler (Test) +- (BOOL)_canPresentWithAuthenticationContext:(id)authenticationContext error:(NSError **)error; +@end + +@implementation STPPaymentHandlerFunctionalTest + +- (void)setUp { + self.presentingViewController = OCMClassMock([UIViewController class]); + // Mock UIApplication.shared, which is otherwise not available in XCTestCase, to always call its completion block with @NO (i.e. it couldn't open a native app with the URL) + self.applicationMock = OCMClassMock([UIApplication class]); + OCMStub([self.applicationMock sharedApplication]).andReturn(self.applicationMock); + OCMStub([self.applicationMock openURL:[OCMArg any] + options:[OCMArg any] + completionHandler:([OCMArg invokeBlockWithArgs:@NO, nil])]); + [STPAPIClient sharedClient].publishableKey = STPTestingDefaultPublishableKey; +} + +// N.B. Test mode alipay PaymentIntent's never have a native redirect so we can't test that here +- (void)testAlipayOpensWebviewAfterNativeURLUnavailable { + __block NSString *clientSecret = @"pi_123_secret_456"; + + id apiClient = OCMPartialMock(STPAPIClient.sharedClient); + STPPaymentIntent *paymentIntent = [STPFixtures paymentIntent]; + OCMStub([apiClient confirmPaymentIntentWithParams:[OCMArg any] expand:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + void (^handler)(STPPaymentIntent *paymentIntent, __unused NSError * _Nullable error); + [invocation getArgument:&handler atIndex:4]; + handler(paymentIntent, nil); + }); + + OCMStub([apiClient retrievePaymentIntentWithClientSecret:[OCMArg any] expand:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + void (^handler)(STPPaymentIntent *paymentIntent, __unused NSError * _Nullable error); + [invocation getArgument:&handler atIndex:4]; + handler(paymentIntent, nil); + }); + + id paymentHandler = OCMPartialMock(STPPaymentHandler.sharedHandler); + OCMStub([paymentHandler apiClient]).andReturn(apiClient); + + // Simulate the safari VC finishing after presenting it + OCMStub([self.presentingViewController presentViewController:[OCMArg any] animated:YES completion:[OCMArg any]]).andDo(^(__unused NSInvocation *_) { + [paymentHandler safariViewControllerDidFinish:self.presentingViewController]; + }); + + STPPaymentIntentParams *confirmParams = [[STPPaymentIntentParams alloc] initWithClientSecret:clientSecret]; + confirmParams.paymentMethodOptions = [STPConfirmPaymentMethodOptions new]; + confirmParams.paymentMethodOptions.alipayOptions = [STPConfirmAlipayOptions new]; + confirmParams.paymentMethodParams = [STPPaymentMethodParams paramsWithAlipay:[STPPaymentMethodAlipayParams new] billingDetails:nil metadata:nil]; + confirmParams.returnURL = @"foo://bar"; + + XCTestExpectation *e = [self expectationWithDescription:@""]; + [paymentHandler confirmPayment:confirmParams withAuthenticationContext:self completion:^(STPPaymentHandlerActionStatus status, STPPaymentIntent * __unused pi, __unused NSError * _Nullable error) { + // ...shouldn't attempt to open the native URL (ie the alipay app) + OCMReject([self.applicationMock openURL:[OCMArg any] + options:[OCMArg any] + completionHandler:[OCMArg isNotNil]]); + // ...and then open UIViewController + OCMVerify([self.presentingViewController presentViewController:[OCMArg any] animated:YES completion:[OCMArg any]]); + + // ...and since we didn't actually authenticate, the final state is canceled + XCTAssertEqual(status, STPPaymentHandlerActionStatusCanceled); + [e fulfill]; + }]; + [self waitForExpectationsWithTimeout:4 handler:nil]; + [paymentHandler stopMocking]; // paymentHandler is a singleton, so we need to manually call `stopMocking` +} + +- (void)test_oxxo_payment_intent_server_side_confirmation { + // OXXO is interesting b/c the PI status after handling next actions is requires_action, not succeeded. + id paymentHandler = OCMPartialMock(STPPaymentHandler.sharedHandler); + + // Simulate the safari VC finishing after presenting it + OCMStub([self.presentingViewController presentViewController:[OCMArg any] animated:YES completion:[OCMArg any]]).andDo(^(__unused NSInvocation *_) { + [paymentHandler safariViewControllerDidFinish:self.presentingViewController]; + }); + + STPAPIClient *apiClient = [[STPAPIClient alloc] initWithPublishableKey: STPTestingMEXPublishableKey]; + [STPAPIClient sharedClient].publishableKey = STPTestingMEXPublishableKey; + + STPPaymentMethodBillingDetails *billingDetails = [STPPaymentMethodBillingDetails new]; + billingDetails.name = @"Test Customer"; + billingDetails.email = @"test@example.com"; + + XCTestExpectation *e = [self expectationWithDescription:@""]; + STPPaymentMethodParams *params = [[STPPaymentMethodParams alloc] initWithOxxo:[STPPaymentMethodOXXOParams new] billingDetails:billingDetails metadata:nil]; + [apiClient createPaymentMethodWithParams:params completion:^(STPPaymentMethod * paymentMethod, NSError * error) { + XCTAssertNil(error); + NSDictionary *pi_params = @{ + @"confirm": @"true", + @"payment_method_types": @[@"oxxo"], + @"currency": @"mxn", + @"amount": @1099, + @"payment_method": paymentMethod.stripeId, + @"return_url": @"foo://z" + }; + [[STPTestingAPIClient new] createPaymentIntentWithParams:pi_params account:@"mex" apiVersion:nil completion:^(NSString * clientSecret, NSError * error2) { + XCTAssertNil(error2); + [paymentHandler handleNextActionForPayment:clientSecret withAuthenticationContext:self returnURL:@"foo://z" completion:^(STPPaymentHandlerActionStatus status, STPPaymentIntent * paymentIntent, NSError * error3) { + XCTAssertNil(error3); + XCTAssertEqual(paymentIntent.status, STPPaymentIntentStatusRequiresAction); + XCTAssertEqual(status, STPPaymentHandlerActionStatusSucceeded); + [e fulfill]; + }]; + }]; + }]; + [self waitForExpectationsWithTimeout:4 handler:nil]; + [paymentHandler stopMocking]; // paymentHandler is a singleton, so we need to manually call `stopMocking` +} + +- (UIViewController *)authenticationPresentingViewController { + return self.presentingViewController; +} + +@end diff --git a/Stripe/StripeiOSTests/STPPaymentHandlerFunctionalTest.swift b/Stripe/StripeiOSTests/STPPaymentHandlerFunctionalTest.swift new file mode 100644 index 00000000..1c72b112 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentHandlerFunctionalTest.swift @@ -0,0 +1,193 @@ +// +// STPPaymentHandlerFunctionalTest.swift +// StripeiOSTests +// +// Created by Yuki Tokuhiro on 4/24/23. +// + +@testable import Stripe +@_spi(STP) @testable import StripePayments +@_spi(STP) @testable import StripePaymentsTestUtils +import XCTest + +// You can add tests in here for payment methods that don't require customer actions (i.e. don't open webviews for customer authentication). +// If they require customer action, use STPPaymentHandlerFunctionalTest.m instead +final class STPPaymentHandlerFunctionalSwiftTest: XCTestCase, STPAuthenticationContext { + // MARK: - STPAuthenticationContext + func authenticationPresentingViewController() -> UIViewController { + return UIViewController() + } + + // MARK: - PaymentIntent tests + + func test_card_payment_intent_server_side_confirmation() { + let apiClient = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let e = self.expectation(description: "") + apiClient.createPaymentMethod(with: ._testValidCardValue()) { paymentMethod, error in + guard let paymentMethod = paymentMethod else { + XCTFail(String(describing: error)) + return + } + STPTestingAPIClient().createPaymentIntent(withParams: [ + "confirm": "true", + "payment_method_types": ["card"], + "currency": "usd", + "payment_method": paymentMethod.stripeId, + "return_url": "foo://z", + ]) { clientSecret, error in + guard let clientSecret = clientSecret else { + XCTFail(String(describing: error)) + return + } + let sut = STPPaymentHandler(apiClient: apiClient) + // Note: `waitForExpectations` can deadlock if this test is async. When we can use Xcode 14.3, we can switch this test to async and use fulfillment(of:) instead of waitForExpectations + sut.handleNextAction(forPayment: clientSecret, with: self, returnURL: "foo://z") { status, intent, _ in + XCTAssertEqual(sut.apiClient, apiClient) // Reference sut in the closure so it doesn't get deallocated + XCTAssertEqual(intent?.status, .succeeded) + XCTAssertEqual(status, .succeeded) + e.fulfill() + } + } + } + self.waitForExpectations(timeout: 10) + } + + func test_sepa_debit_payment_intent_server_side_confirmation() { + // SEPA Debit is a good payment method to test here because + // - it's a "delayed" or "asynchronous" payment method + // - it doesn't require customer actions (we can't simulate customer actions in XCTestCase) + + let apiClient = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "SEPA Test Customer" + billingDetails.email = "test@example.com" + + let sepaDebitDetails = STPPaymentMethodSEPADebitParams() + sepaDebitDetails.iban = "DE89370400440532013000" + + let e = self.expectation(description: "") + apiClient.createPaymentMethod(with: .init(sepaDebit: sepaDebitDetails, billingDetails: billingDetails, metadata: nil)) { paymentMethod, error in + guard let paymentMethod = paymentMethod else { + XCTFail(String(describing: error)) + return + } + STPTestingAPIClient().createPaymentIntent(withParams: [ + "confirm": "true", + "payment_method_types": ["sepa_debit"], + "currency": "eur", + "payment_method": paymentMethod.stripeId, + "return_url": "foo://z", + "mandate_data": [ + "customer_acceptance": [ + "type": "online", + "online": [ + "user_agent": "123", + "ip_address": "172.18.117.125", + ], + ], + ], + ]) { clientSecret, error in + guard let clientSecret = clientSecret else { + XCTFail(String(describing: error)) + return + } + let sut = STPPaymentHandler(apiClient: apiClient) + // Note: `waitForExpectations` can deadlock if this test is async. When we can use Xcode 14.3, we can switch this test to async and use fulfillment(of:) instead of waitForExpectations + sut.handleNextAction(forPayment: clientSecret, with: self, returnURL: "foo://z") { status, intent, _ in + XCTAssertEqual(sut.apiClient, apiClient) // Reference sut in the closure so it doesn't get deallocated + XCTAssertEqual(intent?.status, .processing) + XCTAssertEqual(status, .succeeded) + e.fulfill() + } + } + } + self.waitForExpectations(timeout: 10) + } + + // MARK: - SetupIntent tests + + func test_card_setup_intent_server_side_confirmation() { + let apiClient = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let e = self.expectation(description: "") + apiClient.createPaymentMethod(with: ._testValidCardValue()) { paymentMethod, error in + guard let paymentMethod = paymentMethod else { + XCTFail(String(describing: error)) + return + } + STPTestingAPIClient().createSetupIntent(withParams: [ + "confirm": "true", + "payment_method_types": ["card"], + "payment_method": paymentMethod.stripeId, + "return_url": "foo://z", + ]) { clientSecret, error in + guard let clientSecret = clientSecret else { + XCTFail(String(describing: error)) + return + } + let sut = STPPaymentHandler(apiClient: apiClient) + // Note: `waitForExpectations` can deadlock if this test is async. When we can use Xcode 14.3, we can switch this test to async and use fulfillment(of:) instead of waitForExpectations + sut.handleNextAction(forSetupIntent: clientSecret, with: self, returnURL: "foo://z") { status, intent, _ in + XCTAssertEqual(sut.apiClient, apiClient) // Reference sut in the closure so it doesn't get deallocated + XCTAssertEqual(intent?.status, .succeeded) + XCTAssertEqual(status, .succeeded) + e.fulfill() + } + } + } + self.waitForExpectations(timeout: 10) + } + + func test_sepa_debit_setup_intent_server_side_confirmation() { + // SEPA Debit is a good payment method to test here because + // - it's a "delayed" or "asynchronous" payment method + // - it doesn't require customer actions (we can't simulate customer actions in XCTestCase) + + let apiClient = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "SEPA Test Customer" + billingDetails.email = "test@example.com" + + let sepaDebitDetails = STPPaymentMethodSEPADebitParams() + sepaDebitDetails.iban = "DE89370400440532013000" + + let e = self.expectation(description: "") + apiClient.createPaymentMethod(with: .init(sepaDebit: sepaDebitDetails, billingDetails: billingDetails, metadata: nil)) { paymentMethod, error in + guard let paymentMethod = paymentMethod else { + XCTFail() + return + } + STPTestingAPIClient().createSetupIntent(withParams: [ + "confirm": "true", + "payment_method_types": ["sepa_debit"], + "payment_method": paymentMethod.stripeId, + "return_url": "foo://z", + "mandate_data": [ + "customer_acceptance": [ + "type": "online", + "online": [ + "user_agent": "123", + "ip_address": "172.18.117.125", + ], + ], + ], + ]) { clientSecret, error in + guard let clientSecret = clientSecret else { + XCTFail("\(String(describing: error))") + return + } + let sut = STPPaymentHandler(apiClient: apiClient) + // Note: `waitForExpectations` can deadlock if this test is async. When we can use Xcode 14.3, we can switch this test to async and use fulfillment(of:) instead of waitForExpectations + sut.handleNextAction(forSetupIntent: clientSecret, with: self, returnURL: "foo://z") { status, intent, _ in + XCTAssertEqual(sut.apiClient, apiClient) // Reference sut in the closure so it doesn't get deallocated + XCTAssertEqual(intent?.status, .succeeded) // Note: I think this should be .processing, but testmode disagrees + XCTAssertEqual(status, .succeeded) + e.fulfill() + } + } + } + self.waitForExpectations(timeout: 10) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentHandlerStubbedMockedFilesTests.swift b/Stripe/StripeiOSTests/STPPaymentHandlerStubbedMockedFilesTests.swift new file mode 100644 index 00000000..e930d71f --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentHandlerStubbedMockedFilesTests.swift @@ -0,0 +1,628 @@ +// +// STPPaymentHandlerStubbedMockedFilesTests.swift +// StripeiOS Tests +// +// Copyright © 2022 Stripe, Inc. All rights reserved. +// +import OHHTTPStubs +import OHHTTPStubsSwift +import StripeCoreTestUtils +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeApplePay +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentHandlerStubbedMockedFilesTests: APIStubbedTestCase, STPAuthenticationContext { + func testCallConfirmAfterpay_Redirect_thenCanceled() { + let nextActionData = """ + { + "redirect_to_url": { + "return_url": "payments-example://stripe-redirect", + "url": "https://hooks.stripe.com/afterpay_clearpay/acct_123/pa_nonce_321/redirect" + }, + "type": "redirect_to_url" + } + """ + let paymentMethod = """ + { + "id": "pm_123123123123123", + "object": "payment_method", + "afterpay_clearpay": {}, + "billing_details": { + "address": { + "city": "San Francisco", + "country": "AT", + "line1": "510 Townsend St.", + "line2": "", + "postal_code": "94102", + "state": null + }, + "email": "foo@bar.com", + "name": "Jane Doe", + "phone": null + }, + "created": 1658187899, + "customer": null, + "livemode": false, + "type": "afterpay_clearpay" + } + """ + let paymentHandler = stubbedPaymentHandler(formSpecProvider: formSpecProvider()) + stubConfirm( + fileMock: .paymentIntentResponse, + responseCallback: { data in + self.replaceData( + data: data, + variables: [ + "": nextActionData, + "": paymentMethod, + "": "\"requires_action\"", + ] + ) + } + ) + + let paymentIntentParams = STPPaymentIntentParams(clientSecret: "pi_123456_secret_654321") + paymentIntentParams.returnURL = "payments-example://stripe-redirect" + paymentIntentParams.paymentMethodParams = STPPaymentMethodParams( + afterpayClearpay: STPPaymentMethodAfterpayClearpayParams(), + billingDetails: STPPaymentMethodBillingDetails(), + metadata: nil + ) + paymentIntentParams.paymentMethodParams?.afterpayClearpay = + STPPaymentMethodAfterpayClearpayParams() + let didRedirect = expectation(description: "didRedirect") + paymentHandler._redirectShim = { redirectTo, returnToURL, isStandardRedirect in + XCTAssertEqual( + redirectTo.absoluteString, + "https://hooks.stripe.com/afterpay_clearpay/acct_123/pa_nonce_321/redirect" + ) + XCTAssertEqual(returnToURL?.absoluteString, "payments-example://stripe-redirect") + XCTAssert(isStandardRedirect) + didRedirect.fulfill() + } + let expectConfirmWasCanceled = expectation(description: "didCancel") + paymentHandler.confirmPayment(paymentIntentParams, with: self) { + status, + _, + _ in + if case .canceled = status { + expectConfirmWasCanceled.fulfill() + } + } + guard XCTWaiter.wait(for: [didRedirect], timeout: 2.0) != .timedOut else { + XCTFail("Unable to redirect") + return + } + + // Test the cancel case + stubRetrievePaymentIntent( + fileMock: .paymentIntentResponse, + responseCallback: { data in + self.replaceData( + data: data, + variables: [ + "": nextActionData, + "": paymentMethod, + "": "\"requires_action\"", + ] + ) + } + ) + paymentHandler._retrieveAndCheckIntentForCurrentAction() + wait(for: [expectConfirmWasCanceled], timeout: 2.0) + } + + func testCallConfirmAfterpay_Redirect_thenSucceeded() { + let nextActionData = """ + { + "redirect_to_url": { + "return_url": "payments-example://stripe-redirect", + "url": "https://hooks.stripe.com/afterpay_clearpay/acct_123/pa_nonce_321/redirect" + }, + "type": "redirect_to_url" + } + """ + let paymentMethodData = """ + { + "id": "pm_123123123123123", + "object": "payment_method", + "afterpay_clearpay": {}, + "billing_details": { + "address": { + "city": "San Francisco", + "country": "AT", + "line1": "510 Townsend St.", + "line2": "", + "postal_code": "94102", + "state": null + }, + "email": "foo@bar.com", + "name": "Jane Doe", + "phone": null + }, + "created": 1658187899, + "customer": null, + "livemode": false, + "type": "afterpay_clearpay" + } + """ + let paymentHandler = stubbedPaymentHandler(formSpecProvider: formSpecProvider()) + stubConfirm( + fileMock: .paymentIntentResponse, + responseCallback: { data in + self.replaceData( + data: data, + variables: [ + "": nextActionData, + "": paymentMethodData, + "": "\"requires_action\"", + ] + ) + } + ) + + let paymentIntentParams = STPPaymentIntentParams(clientSecret: "pi_123456_secret_654321") + paymentIntentParams.returnURL = "payments-example://stripe-redirect" + paymentIntentParams.paymentMethodParams = STPPaymentMethodParams( + afterpayClearpay: STPPaymentMethodAfterpayClearpayParams(), + billingDetails: STPPaymentMethodBillingDetails(), + metadata: nil + ) + paymentIntentParams.paymentMethodParams?.afterpayClearpay = + STPPaymentMethodAfterpayClearpayParams() + let didRedirect = expectation(description: "didRedirect") + paymentHandler._redirectShim = { redirectTo, returnToURL, isStandardRedirect in + XCTAssertEqual( + redirectTo.absoluteString, + "https://hooks.stripe.com/afterpay_clearpay/acct_123/pa_nonce_321/redirect" + ) + XCTAssertEqual(returnToURL?.absoluteString, "payments-example://stripe-redirect") + XCTAssert(isStandardRedirect) + didRedirect.fulfill() + } + confirmPaymentWithSucceed(nextActionData: nextActionData, + paymentMethodData: paymentMethodData, + didRedirect: didRedirect, + paymentHandler: paymentHandler, + paymentIntentParams: paymentIntentParams) + } + + func testRedirectStrategy_external_browser() { + let nextActionData = """ + { + "redirect_to_url": { + "return_url": "payments-example://stripe-redirect", + "url": "https://hooks.stripe.com/affirm/acct_123/pa_nonce_321/redirect" + }, + "type": "redirect_to_url" + } + """ + let paymentMethodData = """ + { + "id": "pm_123123123123123", + "object": "payment_method", + "affirm": {}, + "created": 1658187899, + "customer": null, + "livemode": false, + "type": "affirm" + } + """ + + let formSpecProvider = formSpecProvider() + let paymentHandler = stubbedPaymentHandler(formSpecProvider: formSpecProvider) + stubConfirm( + fileMock: .paymentIntentResponse, + responseCallback: { data in + self.replaceData( + data: data, + variables: [ + "": nextActionData, + "": paymentMethodData, + "": "\"requires_action\"", + ] + ) + } + ) + XCTAssertTrue(formSpecProvider.loadFrom(affirmSpec(redirectStrategy: "external_browser"))) + let paymentIntentParams = STPPaymentIntentParams(clientSecret: "pi_123456_secret_654321") + paymentIntentParams.returnURL = "payments-example://stripe-redirect" + paymentIntentParams.paymentMethodParams = STPPaymentMethodParams(affirm: STPPaymentMethodAffirmParams(), + metadata: nil) + let didRedirect = expectation(description: "didRedirect") + paymentHandler._redirectShim = { redirectTo, returnToURL, isStandardRedirect in + XCTAssertEqual( + redirectTo.absoluteString, + "https://hooks.stripe.com/affirm/acct_123/pa_nonce_321/redirect" + ) + XCTAssertEqual(returnToURL?.absoluteString, "payments-example://stripe-redirect") + XCTAssertFalse(isStandardRedirect) + didRedirect.fulfill() + } + confirmPaymentWithSucceed(nextActionData: nextActionData, + paymentMethodData: paymentMethodData, + didRedirect: didRedirect, + paymentHandler: paymentHandler, + paymentIntentParams: paymentIntentParams) + } + + func testRedirectStrategy_follow_redirects() { + let nextActionData = """ + { + "redirect_to_url": { + "return_url": "payments-example://stripe-redirect", + "url": "https://hooks.stripe.com/affirm/acct_123/pa_nonce_321/redirect" + }, + "type": "redirect_to_url" + } + """ + let paymentMethodData = """ + { + "id": "pm_123123123123123", + "object": "payment_method", + "affirm": {}, + "created": 1658187899, + "customer": null, + "livemode": false, + "type": "affirm" + } + """ + let formSpecProvider = formSpecProvider() + let paymentHandler = stubbedPaymentHandler(formSpecProvider: formSpecProvider) + stubConfirm( + fileMock: .paymentIntentResponse, + responseCallback: { data in + self.replaceData( + data: data, + variables: [ + "": nextActionData, + "": paymentMethodData, + "": "\"requires_action\"", + ] + ) + } + ) + stub { urlRequest in + return urlRequest.url?.absoluteString.contains("/affirm/acct_123") ?? false + } response: { _ in + let data = "<>".data(using: .utf8)! + return HTTPStubsResponse(data: data, statusCode: 302, headers: ["Location": "https://www.financial-partner.com/"]) + } + stub { urlRequest in + return urlRequest.url?.absoluteString.contains("financial-partner.com") ?? false + } response: { _ in + let data = "".data(using: .utf8)! + return HTTPStubsResponse(data: data, statusCode: 200, headers: nil) + } + + XCTAssertTrue(formSpecProvider.loadFrom(affirmSpec(redirectStrategy: "follow_redirects"))) + let paymentIntentParams = STPPaymentIntentParams(clientSecret: "pi_123456_secret_654321") + paymentIntentParams.returnURL = "payments-example://stripe-redirect" + paymentIntentParams.paymentMethodParams = STPPaymentMethodParams(affirm: STPPaymentMethodAffirmParams(), + metadata: nil) + let didRedirect = expectation(description: "didRedirect") + paymentHandler._redirectShim = { redirectTo, returnToURL, isStandardRedirect in + XCTAssertEqual(redirectTo.absoluteString, "https://www.financial-partner.com/") + XCTAssertEqual(returnToURL?.absoluteString, "payments-example://stripe-redirect") + XCTAssert(isStandardRedirect) + didRedirect.fulfill() + } + confirmPaymentWithSucceed(nextActionData: nextActionData, + paymentMethodData: paymentMethodData, + didRedirect: didRedirect, + paymentHandler: paymentHandler, + paymentIntentParams: paymentIntentParams) + } + + func testCallConfirmAfterpay_Redirect_thenSucceeded_withoutNextActionSpec() { + let formSpecProvider = formSpecProvider() + let paymentHandler = stubbedPaymentHandler(formSpecProvider: formSpecProvider) + // Validate affirm is read in with next action spec + guard let affirm = formSpecProvider.formSpec(for: "affirm"), + affirm.fields.count == 1, + affirm.fields.first == .affirm_header, + case .redirect_to_url = affirm.nextActionSpec?.confirmResponseStatusSpecs[ + "requires_action" + ]?.type, + case .finished = affirm.nextActionSpec?.postConfirmHandlingPiStatusSpecs?["succeeded"]? + .type, + case .canceled = affirm.nextActionSpec?.postConfirmHandlingPiStatusSpecs?[ + "requires_action" + ]?.type + else { + XCTFail() + return + } + // Override it with a spec that doesn't define a next action so that we force the SDK to default behavior + let updatedSpecJson = + """ + [{ + "type": "affirm", + "async": false, + "fields": [ + { + "type": "name" + } + ] + }] + """.data(using: .utf8)! + let formSpec = try! JSONSerialization.jsonObject(with: updatedSpecJson) as! [NSDictionary] + XCTAssert(formSpecProvider.loadFrom(formSpec)) + guard let affirmUpdated = formSpecProvider.formSpec(for: "affirm") else { + XCTFail() + return + } + XCTAssertNil(affirmUpdated.nextActionSpec) + + let nextActionData = """ + { + "redirect_to_url": { + "return_url": "payments-example://stripe-redirect", + "url": "https://hooks.stripe.com/affirm/acct_123/pa_nonce_321/redirect" + }, + "type": "redirect_to_url" + } + """ + let paymentMethodData = """ + { + "id": "pm_123123123123123", + "object": "payment_method", + "afterpay_clearpay": {}, + "billing_details": { + "address": { + "city": "San Francisco", + "country": "AT", + "line1": "510 Townsend St.", + "line2": "", + "postal_code": "94102", + "state": null + }, + "email": "foo@bar.com", + "name": "Jane Doe", + "phone": null + }, + "created": 1658187899, + "customer": null, + "livemode": false, + "type": "afterpay_clearpay" + } + """ + stubConfirm( + fileMock: .paymentIntentResponse, + responseCallback: { data in + self.replaceData( + data: data, + variables: [ + "": nextActionData, + "": paymentMethodData, + "": "\"requires_action\"", + ] + ) + } + ) + + let paymentIntentParams = STPPaymentIntentParams(clientSecret: "pi_123456_secret_654321") + paymentIntentParams.returnURL = "payments-example://stripe-redirect" + paymentIntentParams.paymentMethodParams = STPPaymentMethodParams( + afterpayClearpay: STPPaymentMethodAfterpayClearpayParams(), + billingDetails: STPPaymentMethodBillingDetails(), + metadata: nil + ) + paymentIntentParams.paymentMethodParams?.afterpayClearpay = + STPPaymentMethodAfterpayClearpayParams() + let didRedirect = expectation(description: "didRedirect") + paymentHandler._redirectShim = { redirectTo, returnToURL, isStandardRedirect in + XCTAssertEqual( + redirectTo.absoluteString, + "https://hooks.stripe.com/affirm/acct_123/pa_nonce_321/redirect" + ) + XCTAssertEqual(returnToURL?.absoluteString, "payments-example://stripe-redirect") + XCTAssert(isStandardRedirect) + didRedirect.fulfill() + } + confirmPaymentWithSucceed(nextActionData: nextActionData, + paymentMethodData: paymentMethodData, + didRedirect: didRedirect, + paymentHandler: paymentHandler, + paymentIntentParams: paymentIntentParams) + } + + func testCallConfirmBlikSucceeds() { + let nextActionData = """ + { + "type": "blik_authorize" + } + """ + let paymentMethodData = """ + { + "id": "pm_123123123123123", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "blik": {}, + "created": 1658187899, + "customer": null, + "livemode": false, + "type": "blik" + } + """ + let paymentHandler = stubbedPaymentHandler(formSpecProvider: formSpecProvider()) + let paymentIntentParams = STPPaymentIntentParams(clientSecret: "pi_123456_secret_654321") + paymentIntentParams.returnURL = "payments-example://stripe-redirect" + paymentIntentParams.paymentMethodParams = STPPaymentMethodParams( + blik: STPPaymentMethodBLIKParams(), + billingDetails: nil, + metadata: nil + ) + + stubRetrievePaymentIntent( + fileMock: .paymentIntentResponse, + responseCallback: { data in + self.replaceData( + data: data, + variables: [ + "": nextActionData, + "": paymentMethodData, + "": "\"requires_action\"", + ] + ) + } + ) + + let expectConfirmSucceeded = expectation(description: "didSucceed") + paymentHandler.confirmPayment( + paymentIntentParams, + with: self) { status, _, _ in + if case .succeeded = status { + expectConfirmSucceeded.fulfill() + } + } + waitForExpectations(timeout: 2.0) + } + + private func confirmPaymentWithSucceed( + nextActionData: String, + paymentMethodData: String, + didRedirect: XCTestExpectation, + paymentHandler: STPPaymentHandler, + paymentIntentParams: STPPaymentIntentParams + ) { + let expectConfirmSucceeded = expectation(description: "didSucceed") + paymentHandler.confirmPayment(paymentIntentParams, with: self) { + status, + _, + _ in + if case .succeeded = status { + expectConfirmSucceeded.fulfill() + } + } + + guard XCTWaiter.wait(for: [didRedirect], timeout: 2.0) != .timedOut else { + XCTFail("Unable to redirect") + return + } + + // Test status as succeeded + stubRetrievePaymentIntent( + fileMock: .paymentIntentResponse, + responseCallback: { data in + self.replaceData( + data: data, + variables: [ + "": nextActionData, + "": paymentMethodData, + "": "\"succeeded\"", + ] + ) + } + ) + paymentHandler._retrieveAndCheckIntentForCurrentAction() + wait(for: [expectConfirmSucceeded], timeout: 2.0) + } + + private func formSpecProvider() -> FormSpecProvider { + let expectation = expectation(description: "Load Specs") + let formSpecProvider = FormSpecProvider() + formSpecProvider.load { _ in + expectation.fulfill() + } + wait(for: [expectation], timeout: 5.0) + return formSpecProvider + } + + private func stubbedPaymentHandler(formSpecProvider: FormSpecProvider) -> STPPaymentHandler { + let stubbedAPIClient = stubbedAPIClient() + let paymentSheetFormSpecHandler = PaymentSheetFormSpecPaymentHandler(urlSession: stubbedAPIClient.urlSession, + formSpecProvider: formSpecProvider) + return STPPaymentHandler(apiClient: stubbedAPIClient, + formSpecPaymentHandler: paymentSheetFormSpecHandler) + } + + private func replaceData(data: Data, variables: [String: String]) -> Data { + var template = String(data: data, encoding: .utf8)! + for (templateKey, templateValue) in variables { + let translated = template.replacingOccurrences(of: templateKey, with: templateValue) + template = translated + } + return template.data(using: .utf8)! + } + + private func stubConfirm(fileMock: FileMock, responseCallback: ((Data) -> Data)? = nil) { + stub { urlRequest in + return urlRequest.url?.absoluteString.contains("/confirm") ?? false + } response: { _ in + let mockResponseData = try! fileMock.data() + let data = responseCallback?(mockResponseData) ?? mockResponseData + return HTTPStubsResponse(data: data, statusCode: 200, headers: nil) + } + } + private func stubRetrievePaymentIntent( + fileMock: FileMock, + responseCallback: ((Data) -> Data)? = nil + ) { + stub { urlRequest in + return urlRequest.url?.absoluteString.contains("/payment_intents") ?? false + } response: { _ in + let mockResponseData = try! fileMock.data() + let data = responseCallback?(mockResponseData) ?? mockResponseData + return HTTPStubsResponse(data: data, statusCode: 200, headers: nil) + } + } + private func affirmSpec(redirectStrategy: String) -> [NSDictionary] { + let formSpec = + """ + [{ + "type": "affirm", + "async": false, + "fields": [ + { + "type": "affirm_header" + } + ], + "next_action_spec": { + "confirm_response_status_specs": { + "requires_action": { + "type": "redirect_to_url", + "native_mobile_redirect_strategy": "\(redirectStrategy)" + } + }, + "post_confirm_handling_pi_status_specs": { + "succeeded": { + "type": "finished" + } + } + } + }] + """.data(using: .utf8)! + return try! JSONSerialization.jsonObject(with: formSpec) as! [NSDictionary] + } +} +extension STPPaymentHandlerStubbedMockedFilesTests { + func authenticationPresentingViewController() -> UIViewController { + return UIViewController() + } +} + +public class ClassForBundle {} +@_spi(STP) public enum FileMock: String, MockData { + public typealias ResponseType = StripeFile + public var bundle: Bundle { return Bundle(for: ClassForBundle.self) } + + case paymentIntentResponse = "MockFiles/paymentIntentResponse" +} diff --git a/Stripe/StripeiOSTests/STPPaymentHandlerTests.swift b/Stripe/StripeiOSTests/STPPaymentHandlerTests.swift new file mode 100644 index 00000000..28089106 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentHandlerTests.swift @@ -0,0 +1,265 @@ +// +// STPPaymentHandlerTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 3/8/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import OHHTTPStubs +import OHHTTPStubsSwift +import StripeCoreTestUtils +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import Stripe3DS2 +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable import StripePaymentsTestUtils +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentHandlerStubbedTests: STPNetworkStubbingTestCase { + override func setUp() { + self.recordingMode = false + super.setUp() + } + + func testCanPresentErrorsAreReported() { + let createPaymentIntentExpectation = expectation( + description: "createPaymentIntentExpectation" + ) + var retrievedClientSecret: String? + STPTestingAPIClient.shared.createPaymentIntent(withParams: nil) { + (createdPIClientSecret, _) in + if let createdPIClientSecret = createdPIClientSecret { + retrievedClientSecret = createdPIClientSecret + createPaymentIntentExpectation.fulfill() + } else { + XCTFail() + } + } + wait(for: [createPaymentIntentExpectation], timeout: 8) // STPTestingNetworkRequestTimeout + guard let clientSecret = retrievedClientSecret, + let currentYear = Calendar.current.dateComponents([.year], from: Date()).year + else { + XCTFail() + return + } + + let expiryYear = NSNumber(value: currentYear + 2) + let expiryMonth = NSNumber(1) + + let cardParams = STPPaymentMethodCardParams() + cardParams.number = "4000000000003220" + cardParams.expYear = expiryYear + cardParams.expMonth = expiryMonth + cardParams.cvc = "123" + + let address = STPPaymentMethodAddress() + address.postalCode = "12345" + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.address = address + + let paymentMethodParams = STPPaymentMethodParams.paramsWith( + card: cardParams, + billingDetails: billingDetails, + metadata: nil + ) + + let paymentIntentParams = STPPaymentIntentParams(clientSecret: clientSecret) + paymentIntentParams.paymentMethodParams = paymentMethodParams + + // STPTestingDefaultPublishableKey + STPAPIClient.shared.publishableKey = "pk_test_ErsyMEOTudSjQR8hh0VrQr5X008sBXGOu6" + + let paymentHandlerExpectation = expectation(description: "paymentHandlerExpectation") + STPPaymentHandler.shared().checkCanPresentInTest = true + STPPaymentHandler.shared().confirmPayment(paymentIntentParams, with: self) { + (status, paymentIntent, error) in + XCTAssertTrue(status == .failed) + XCTAssertNotNil(paymentIntent) + XCTAssertNotNil(error) + XCTAssertEqual( + error?.userInfo[STPError.errorMessageKey] as? String, + "authenticationPresentingViewController is not in the window hierarchy. You should probably return the top-most view controller instead." + ) + paymentHandlerExpectation.fulfill() + } + // 2*STPTestingNetworkRequestTimeout payment handler needs to make an ares for this + // test in addition to fetching the payment intent + wait(for: [paymentHandlerExpectation], timeout: 2 * 8) + } +} + +class STPPaymentHandlerTests: APIStubbedTestCase { + + func testPaymentHandlerRetriesWithBackoff() { + STPPaymentHandler.sharedHandler.apiClient = stubbedAPIClient() + + stub { urlRequest in + return urlRequest.url?.absoluteString.contains("3ds2/authenticate") ?? false + } response: { _ in + let jsonText = """ + { + "state": "challenge_required", + "livemode": "false", + "ares" : { + "dsTransID": "4e4750e7-6ab5-45a4-accf-9c668ed3b5a7", + "acsTransID": "fa695a82-a48c-455d-9566-a652058dda27", + "p_messageVersion": "1.0.5", + "acsOperatorID": "acsOperatorUL", + "sdkTransID": "D77EB83F-F317-4E29-9852-EBAAB55515B7", + "eci": "00", + "dsReferenceNumber": "3DS_LOA_DIS_PPFU_020100_00010", + "acsReferenceNumber": "3DS_LOA_ACS_PPFU_020100_00009", + "threeDSServerTransID": "fc7a39de-dc41-4b65-ba76-a322769b2efc", + "messageVersion": "2.1.0", + "authenticationValue": "AABBCCDDEEFFAABBCCDDEEFFAAA=", + "messageType": "pArs", + "transStatus": "C", + "acsChallengeMandated": "NO" + } + } + """ + return HTTPStubsResponse( + data: jsonText.data(using: .utf8)!, + statusCode: 200, + headers: nil + ) + } + + stub { urlRequest in + return urlRequest.url?.absoluteString.contains("3ds2/challenge_complete") ?? false + } response: { _ in + let errorResponse = [ + "error": + [ + "message": "This is intentionally failing for this test.", + "type": "invalid_request_error", + ], + ] + return HTTPStubsResponse(jsonObject: errorResponse, statusCode: 400, headers: nil) + } + + let paymentHandlerExpectation = expectation( + description: "paymentHandlerFinished" + ) + var inProgress = true + + // Meaningless cert, generated for this test + // Expires 3/2/2121: Apologies to future engineers! + let cert = """ + MIIBijCB9AIBATANBgkqhkiG9w0BAQUFADANMQswCQYDVQQGEwJVUzAgFw0yMTAz + MjYxODQyNDVaGA8yMTIxMDMwMjE4NDI0NVowDTELMAkGA1UEBhMCVVMwgZ8wDQYJ + KoZIhvcNAQEBBQADgY0AMIGJAoGBAL6rIW6t+8eo1exqhvYt8H1vM+TyHNNychlD + hILw745yXZQAy9ByRG3euYEydE3SFINgWBCUuwWmkNfsZUW7Uci1PBMglBFHJrE8 + 8ZvtuJgnPkqmu97a9JkyROiaqAmqoMDP95HiZG5i3a1E/QPpPyYA3VJ/El17Qqkl + aHN32qzjAgMBAAEwDQYJKoZIhvcNAQEFBQADgYEAUhxbGQ5sQMDUqFTvibU7RzqL + dTaFhdjTDBu5YeIbXXUrJSG2AydXRq7OacRksnQhvNYXimfcgfse46XQG7rKUCfj + kbazRiRxMZylTz8zbePAFcVq6zxJ+RBVrv51D+/JgbCcQ50nZiocllR0J9UL8CKZ + obaUC2OjBbSuCZwF8Ig= + """ + let rootCA = """ + MIIBkDCB+gIJAJ3pmjFOkxTXMA0GCSqGSIb3DQEBBQUAMA0xCzAJBgNVBAYTAlVT + MB4XDTIxMDMyNjE4NDEzMVoXDTIyMDMyNjE4NDEzMVowDTELMAkGA1UEBhMCVVMw + gZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAKmFDGPV77Fk/wgUMwbxjQk+bpUY + cTjNBsjK3xMaUWeE17Sry6IguO1iWaXVey9YJ1Dm83PNO/5i9nHh3gmFhEJmc55T + g+0tZQigjTcs5/BfmWtrfPYIWqKvIJqkkHrIEJnwavAS5OFGyDArHLwUtsgJbDmW + tIeQg3EH/8BSWR0BAgMBAAEwDQYJKoZIhvcNAQEFBQADgYEATY2aQvZZJLPgUr1/ + oDvRy6KZ6p7n3+jXF8DNvVOIaQRD4Ndk5NfStteIT5XvzfmD6QqpG3nlJ6Wy3oSP + 03KvO4GWIyP9cuP/QLaEmxJIYKwPrdxLkUHFfzyy8tN54xOWPxN4Up9gVN6pSdVk + KWrsPfhPs3G57wir370Q69lV/8A= + """ + let iauss = STPIntentActionUseStripeSDK( + encryptionInfo: [ + "certificate": cert, + "directory_server_id": "0000000000", + "root_certificate_authorities": [rootCA], + ], + directoryServerName: "none", + directoryServerKeyID: "none", + serverTransactionID: "none", + threeDSSourceID: "none", + publishableKeyOverride: nil, + threeDS2IntentOverride: nil, + allResponseFields: [:] + ) + let action = STPIntentAction( + type: .useStripeSDK, + redirectToURL: nil, + alipayHandleRedirect: nil, + useStripeSDK: iauss, + oxxoDisplayDetails: nil, + weChatPayRedirectToApp: nil, + boletoDisplayDetails: nil, + verifyWithMicrodeposits: nil, + cashAppRedirectToApp: nil, + payNowDisplayQrCode: nil, + konbiniDisplayDetails: nil, + promptPayDisplayQrCode: nil, + swishHandleRedirect: nil, + allResponseFields: [:] + ) + let setupIntent = STPSetupIntent( + stripeID: "test", + clientSecret: "test", + created: Date(), + countryCode: "US", + customerID: nil, + stripeDescription: nil, + linkSettings: nil, + livemode: false, + merchantCountryCode: "US", + nextAction: action, + orderedPaymentMethodTypes: [], + paymentMethodID: "test", + paymentMethod: nil, + paymentMethodOptions: nil, + paymentMethodTypes: [], + status: .requiresAction, + usage: .none, + lastSetupError: nil, + allResponseFields: [:], + unactivatedPaymentMethodTypes: [] + ) + + // We expect this request to retry a few times with exponential backoff before calling the completion handler. + STPPaymentHandler.sharedHandler._handleNextAction( + for: setupIntent, + with: self, + returnURL: nil + ) { (status, _, _) in + XCTAssertEqual(status, .failed) + inProgress = false + paymentHandlerExpectation.fulfill() + } + + let checkedStillInProgress = expectation( + description: "Checked that we're still in progress after 2s" + ) + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2.0) { + // Make sure we're still in progress after 2 seconds + // This shows that we're retrying the 3DS2 request a few times + // while applying an appropriate amount of backoff. + XCTAssertEqual(inProgress, true) + checkedStillInProgress.fulfill() + } + + wait(for: [paymentHandlerExpectation, checkedStillInProgress], timeout: 30) + STPPaymentHandler.sharedHandler.apiClient = STPAPIClient.shared + } +} + +extension STPPaymentHandlerTests: STPAuthenticationContext { + func authenticationPresentingViewController() -> UIViewController { + return UIViewController() + } +} + +extension STPPaymentHandlerStubbedTests: STPAuthenticationContext { + func authenticationPresentingViewController() -> UIViewController { + return UIViewController() + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentIntentEnumsTest.swift b/Stripe/StripeiOSTests/STPPaymentIntentEnumsTest.swift new file mode 100644 index 00000000..32ed6d52 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentIntentEnumsTest.swift @@ -0,0 +1,220 @@ +// +// STPPaymentIntentEnumsTest.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 9/28/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentIntentEnumsTest: XCTestCase { + + func textStatusFromString() { + + XCTAssertEqual( + STPPaymentIntentStatus.status(from: "requires_payment_method"), + .requiresPaymentMethod + ) + XCTAssertEqual( + STPPaymentIntentStatus.status(from: "REQUIRES_PAYMENT_METHOD"), + .requiresPaymentMethod + ) + + XCTAssertEqual( + STPPaymentIntentStatus.status(from: "requires_confirmation"), + .requiresConfirmation + ) + XCTAssertEqual( + STPPaymentIntentStatus.status(from: "REQUIRES_CONFIRMATION"), + .requiresConfirmation + ) + + XCTAssertEqual( + STPPaymentIntentStatus.status(from: "requires_action"), + .requiresAction + ) + XCTAssertEqual( + STPPaymentIntentStatus.status(from: "REQUIRES_ACTION"), + .requiresAction + ) + + XCTAssertEqual( + STPPaymentIntentStatus.status(from: "processing"), + .processing + ) + XCTAssertEqual( + STPPaymentIntentStatus.status(from: "PROCESSING"), + .processing + ) + + XCTAssertEqual( + STPPaymentIntentStatus.status(from: "succeeded"), + .succeeded + ) + XCTAssertEqual( + STPPaymentIntentStatus.status(from: "SUCCEEDED"), + .succeeded + ) + + XCTAssertEqual( + STPPaymentIntentStatus.status(from: "requires_capture"), + .requiresCapture + ) + XCTAssertEqual( + STPPaymentIntentStatus.status(from: "REQUIRES_CAPTURE"), + .requiresCapture + ) + + XCTAssertEqual( + STPPaymentIntentStatus.status(from: "canceled"), + .canceled + ) + XCTAssertEqual( + STPPaymentIntentStatus.status(from: "CANCELED"), + .canceled + ) + + XCTAssertEqual( + STPPaymentIntentStatus.status(from: "garbage"), + .unknown + ) + XCTAssertEqual( + STPPaymentIntentStatus.status(from: "GARBAGE"), + .unknown + ) + } + + func testStringFromStatus() { + + XCTAssertEqual( + STPPaymentIntentStatus.string(from: .requiresPaymentMethod), + "requires_payment_method" + ) + XCTAssertEqual( + STPPaymentIntentStatus.string(from: .requiresConfirmation), + "requires_confirmation" + ) + XCTAssertEqual( + STPPaymentIntentStatus.string(from: .requiresAction), + "requires_action" + ) + XCTAssertEqual( + STPPaymentIntentStatus.string(from: .processing), + "processing" + ) + XCTAssertEqual( + STPPaymentIntentStatus.string(from: .succeeded), + "succeeded" + ) + XCTAssertEqual( + STPPaymentIntentStatus.string(from: .requiresCapture), + "requires_capture" + ) + XCTAssertEqual( + STPPaymentIntentStatus.string(from: .canceled), + "canceled" + ) + XCTAssertEqual( + STPPaymentIntentStatus.string(from: .unknown), + "unknown" + ) + } + + func testCaptureMethodFromString() { + XCTAssertEqual( + STPPaymentIntentCaptureMethod.captureMethod(from: "manual"), + .manual + ) + XCTAssertEqual( + STPPaymentIntentCaptureMethod.captureMethod(from: "MANUAL"), + .manual + ) + + XCTAssertEqual( + STPPaymentIntentCaptureMethod.captureMethod(from: "automatic"), + .automatic + ) + XCTAssertEqual( + STPPaymentIntentCaptureMethod.captureMethod(from: "AUTOMATIC"), + .automatic + ) + + XCTAssertEqual( + STPPaymentIntentCaptureMethod.captureMethod(from: "garbage"), + .unknown + ) + XCTAssertEqual( + STPPaymentIntentCaptureMethod.captureMethod(from: "GARBAGE"), + .unknown + ) + } + + func testConfirmationMethodFromString() { + XCTAssertEqual( + STPPaymentIntentConfirmationMethod.confirmationMethod(from: "automatic"), + .automatic + ) + XCTAssertEqual( + STPPaymentIntentConfirmationMethod.confirmationMethod(from: "AUTOMATIC"), + .automatic + ) + + XCTAssertEqual( + STPPaymentIntentConfirmationMethod.confirmationMethod(from: "manual"), + .manual + ) + XCTAssertEqual( + STPPaymentIntentConfirmationMethod.confirmationMethod(from: "MANUAL"), + .manual + ) + + XCTAssertEqual( + STPPaymentIntentConfirmationMethod.confirmationMethod(from: "garbage"), + .unknown + ) + XCTAssertEqual( + STPPaymentIntentConfirmationMethod.confirmationMethod(from: "GARBAGE"), + .unknown + ) + } + + func testSetupFutureUsageFromString() { + XCTAssertEqual( + STPPaymentIntentSetupFutureUsage(string: "on_session"), + .onSession + ) + XCTAssertEqual( + STPPaymentIntentSetupFutureUsage(string: "ON_SESSION"), + .onSession + ) + + XCTAssertEqual( + STPPaymentIntentSetupFutureUsage(string: "off_session"), + .offSession + ) + XCTAssertEqual( + STPPaymentIntentSetupFutureUsage(string: "OFF_SESSION"), + .offSession + ) + + XCTAssertEqual( + STPPaymentIntentSetupFutureUsage(string: "garbage"), + .unknown + ) + XCTAssertEqual( + STPPaymentIntentSetupFutureUsage(string: "GARBAGE"), + .unknown + ) + } + + func testStringConvertible() { + XCTAssertEqual(String(describing: STPPaymentIntentStatus.requiresAction), "requiresAction") + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentIntentFunctionalTest.swift b/Stripe/StripeiOSTests/STPPaymentIntentFunctionalTest.swift new file mode 100644 index 00000000..bf6a2b36 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentIntentFunctionalTest.swift @@ -0,0 +1,1274 @@ +// +// STPPaymentIntentFunctionalTest.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 3/2/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +import StripeCoreTestUtils +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentIntentFunctionalTest: XCTestCase { + func testCreatePaymentIntentWithTestingServer() { + let expectation = self.expectation(description: "PaymentIntent create.") + STPTestingAPIClient.shared.createPaymentIntent( + withParams: nil) { clientSecret, error in + XCTAssertNotNil(clientSecret) + XCTAssertNil(error) + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreatePaymentIntentWithInvalidCurrency() { + let expectation = self.expectation(description: "PaymentIntent create.") + STPTestingAPIClient.shared.createPaymentIntent(withParams: [ + "payment_method_types": ["bancontact"], + ]) { clientSecret, error in + XCTAssertNil(clientSecret) + XCTAssertNotNil(error) + let errorString = (error! as NSError).userInfo[STPError.errorMessageKey] as! String + XCTAssertTrue(errorString.hasPrefix("Error creating PaymentIntent: The currency provided (usd) is invalid. Payments with bancontact support the following currencies: eur.")) + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testRetrievePreviousCreatedPaymentIntent() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Payment Intent retrieve") + + client.retrievePaymentIntent( + withClientSecret: "pi_1GGCGfFY0qyl6XeWbSAsh2hn_secret_jbhwsI0DGWhKreJs3CCrluUGe") { paymentIntent, error in + XCTAssertNil(error) + + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.stripeId, "pi_1GGCGfFY0qyl6XeWbSAsh2hn") + XCTAssertEqual(paymentIntent?.amount, 100) + XCTAssertEqual(paymentIntent?.currency, "usd") + XCTAssertFalse(paymentIntent!.livemode) + XCTAssertNil(paymentIntent?.sourceId) + XCTAssertNil(paymentIntent?.paymentMethodId) + XCTAssertEqual(paymentIntent?.status, .canceled) + XCTAssertEqual(paymentIntent?.setupFutureUsage, STPPaymentIntentSetupFutureUsage.none) + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertNil(paymentIntent?.nextSourceAction) + // #pragma clang diagnostic pop + XCTAssertNil(paymentIntent!.nextAction) + + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testRetrieveWithWrongSecret() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Payment Intent retrieve") + + client.retrievePaymentIntent( + withClientSecret: "pi_1GGCGfFY0qyl6XeWbSAsh2hn_secret_bad-secret") { paymentIntent, error in + XCTAssertNil(paymentIntent) + + XCTAssertNotNil(error) + XCTAssertEqual((error as NSError?)?.domain, STPError.stripeDomain) + XCTAssertEqual((error as NSError?)?.code, STPErrorCode.invalidRequestError.rawValue) + XCTAssertEqual( + (error as NSError?)?.userInfo[STPError.errorParameterKey] as! String, + "clientSecret") + + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testRetrieveMismatchedPublishableKey() { + // Given an API Client with a publishable key for a test account A... + let client = STPAPIClient(publishableKey: "pk_test_51JtgfQKG6vc7r7YCU0qQNOkDaaHrEgeHgGKrJMNfuWwaKgXMLzPUA1f8ZlCNPonIROLOnzpUnJK1C1xFH3M3Mz8X00Q6O4GfUt") + let expectation = self.expectation(description: "Payment Intent retrieve") + + // ...retrieving a PI attached to a *different* account + client.retrievePaymentIntent( + withClientSecret: "pi_1GGCGfFY0qyl6XeWbSAsh2hn_secret_jbhwsI0DGWhKreJs3CCrluUGe") { paymentIntent, error in + // ...should fail. + XCTAssertNil(paymentIntent) + + XCTAssertNotNil(error) + XCTAssertEqual((error as NSError?)?.domain, STPError.stripeDomain) + XCTAssertEqual((error as NSError?)?.code, STPErrorCode.invalidRequestError.rawValue) + XCTAssertEqual( + (error as NSError?)?.userInfo[STPError.errorParameterKey] as! String, + "intent") + + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testConfirmCanceledPaymentIntentFails() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Payment Intent confirm") + + let params = STPPaymentIntentParams(clientSecret: "pi_1GGCGfFY0qyl6XeWbSAsh2hn_secret_jbhwsI0DGWhKreJs3CCrluUGe") + params.sourceParams = cardSourceParams() + client.confirmPaymentIntent( + with: params) { paymentIntent, error in + XCTAssertNil(paymentIntent) + + XCTAssertNotNil(error) + XCTAssertEqual((error as NSError?)?.domain, STPError.stripeDomain) + XCTAssertEqual((error as NSError?)?.code, STPErrorCode.invalidRequestError.rawValue) + let errorString = (error! as NSError).userInfo[STPError.errorMessageKey] as! String + XCTAssertTrue(errorString.hasPrefix("This PaymentIntent's source could not be updated because it has a status of canceled. You may only update the source of a PaymentIntent with one of the following statuses: requires_payment_method, requires_confirmation, requires_action."), + "Expected error message to complain about status being canceled. Actual msg: \(errorString)" + ) + + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testConfirmPaymentIntentWith3DSCardSucceeds() { + + var clientSecret: String? + let createExpectation = self.expectation(description: "Create PaymentIntent.") + STPTestingAPIClient.shared.createPaymentIntent(withParams: nil) { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Payment Intent confirm") + + let params = STPPaymentIntentParams(clientSecret: clientSecret!) + params.sourceParams = cardSourceParams() + // returnURL must be passed in while confirming (not creation time) + params.returnURL = "example-app-scheme://authorized" + client.confirmPaymentIntent( + with: params) { paymentIntent, error in + XCTAssertNil(error, "With valid key + secret, should be able to confirm the intent") + + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.stripeId, params.stripeId) + XCTAssertFalse(paymentIntent!.livemode) + + // sourceParams is the 3DS-required test card + XCTAssertEqual(paymentIntent?.status, .requiresAction) + + // STPRedirectContext is relying on receiving returnURL + XCTAssertNotNil(paymentIntent!.nextAction!.redirectToURL!.returnURL) + XCTAssertEqual( + paymentIntent!.nextAction!.redirectToURL!.returnURL, + URL(string: "example-app-scheme://authorized")) + + // Test deprecated property still works too + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertNotNil(paymentIntent?.nextSourceAction?.authorizeWithURL?.returnURL) + XCTAssertEqual( + paymentIntent?.nextSourceAction?.authorizeWithURL?.returnURL, + URL(string: "example-app-scheme://authorized")) + // #pragma clang diagnostic pop + + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testConfirmPaymentIntentWith3DSCardPaymentMethodSucceeds() { + + var clientSecret: String? + let createExpectation = self.expectation(description: "Create PaymentIntent.") + STPTestingAPIClient.shared.createPaymentIntent(withParams: nil) { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Payment Intent confirm") + + let params = STPPaymentIntentParams(clientSecret: clientSecret!) + let cardParams = STPPaymentMethodCardParams() + cardParams.number = "4000000000003063" + cardParams.expMonth = NSNumber(value: 7) + cardParams.expYear = NSNumber(value: Calendar.current.component(.year, from: Date()) + 5) + + let billingDetails = STPPaymentMethodBillingDetails() + + params.paymentMethodParams = STPPaymentMethodParams( + card: cardParams, + billingDetails: billingDetails, + metadata: nil) + // returnURL must be passed in while confirming (not creation time) + params.returnURL = "example-app-scheme://authorized" + client.confirmPaymentIntent( + with: params) { paymentIntent, error in + XCTAssertNil(error, "With valid key + secret, should be able to confirm the intent") + + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.stripeId, params.stripeId) + XCTAssertFalse(paymentIntent!.livemode) + XCTAssertNotNil(paymentIntent?.paymentMethodId) + + // sourceParams is the 3DS-required test card + XCTAssertEqual(paymentIntent?.status, .requiresAction) + + // STPRedirectContext is relying on receiving returnURL + + XCTAssertNotNil(paymentIntent!.nextAction!.redirectToURL!.returnURL) + XCTAssertEqual( + paymentIntent!.nextAction!.redirectToURL!.returnURL, + URL(string: "example-app-scheme://authorized")) + + // Going to log all the fields so that you, the developer manually running this test, can inspect them + if let allResponseFields = paymentIntent?.allResponseFields { + print("Confirmed PaymentIntent: \(allResponseFields)") + } + + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testConfirmPaymentIntentWithShippingDetailsSucceeds() { + var clientSecret: String? + let createExpectation = self.expectation(description: "Create PaymentIntent.") + STPTestingAPIClient.shared.createPaymentIntent(withParams: nil) { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let params = STPPaymentIntentParams(clientSecret: clientSecret!) + let cardParams = STPPaymentMethodCardParams() + cardParams.number = "4242424242424242" + cardParams.expMonth = NSNumber(value: 7) + cardParams.expYear = NSNumber(value: Calendar.current.component(.year, from: Date()) + 5) + + let billingDetails = STPPaymentMethodBillingDetails() + + params.paymentMethodParams = STPPaymentMethodParams( + card: cardParams, + billingDetails: billingDetails, + metadata: nil) + + let addressParams = STPPaymentIntentShippingDetailsAddressParams(line1: "123 Main St") + addressParams.line2 = "Apt 2" + addressParams.city = "San Francisco" + addressParams.state = "CA" + addressParams.country = "US" + addressParams.postalCode = "94106" + params.shipping = STPPaymentIntentShippingDetailsParams(address: addressParams, name: "Jane") + params.shipping?.carrier = "UPS" + params.shipping?.phone = "555-555-5555" + params.shipping?.trackingNumber = "123abc" + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Payment Intent confirm") + client.confirmPaymentIntent( + with: params) { paymentIntent, error in + XCTAssertNil(error, "With valid key + secret, should be able to confirm the intent") + + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.stripeId, params.stripeId) + XCTAssertFalse(paymentIntent!.livemode) + XCTAssertNotNil(paymentIntent?.paymentMethodId) + + // Address + XCTAssertEqual(paymentIntent?.shipping!.address!.line1, "123 Main St") + XCTAssertEqual(paymentIntent?.shipping!.address!.line2, "Apt 2") + XCTAssertEqual(paymentIntent?.shipping!.address!.city, "San Francisco") + XCTAssertEqual(paymentIntent?.shipping!.address!.state, "CA") + XCTAssertEqual(paymentIntent?.shipping!.address!.country, "US") + XCTAssertEqual(paymentIntent?.shipping!.address!.postalCode, "94106") + + XCTAssertEqual(paymentIntent?.shipping!.name, "Jane") + XCTAssertEqual(paymentIntent?.shipping!.carrier, "UPS") + XCTAssertEqual(paymentIntent?.shipping!.phone, "555-555-5555") + XCTAssertEqual(paymentIntent?.shipping!.trackingNumber, "123abc") + + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testConfirmCardWithoutNetworkParam() { + var clientSecret: String? + let createExpectation = self.expectation(description: "Create PaymentIntent.") + STPTestingAPIClient.shared.createPaymentIntent(withParams: nil) { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Payment Intent confirm") + + let params = STPPaymentIntentParams(clientSecret: clientSecret!) + let cardParams = STPPaymentMethodCardParams() + cardParams.number = "4242424242424242" + cardParams.expMonth = NSNumber(value: 7) + cardParams.expYear = NSNumber(value: Calendar.current.component(.year, from: Date()) + 5) + + let billingDetails = STPPaymentMethodBillingDetails() + + params.paymentMethodParams = STPPaymentMethodParams( + card: cardParams, + billingDetails: billingDetails, + metadata: nil) + + client.confirmPaymentIntent( + with: params) { paymentIntent, error in + XCTAssertNil(error, "With valid key + secret, should be able to confirm the intent") + + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.stripeId, params.stripeId) + XCTAssertFalse(paymentIntent!.livemode) + XCTAssertNotNil(paymentIntent?.paymentMethodId) + + XCTAssertEqual(paymentIntent?.status, .succeeded) + + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testConfirmCardWithNetworkParam() { + var clientSecret: String? + let createExpectation = self.expectation(description: "Create PaymentIntent.") + STPTestingAPIClient.shared.createPaymentIntent(withParams: nil) { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Payment Intent confirm") + + let params = STPPaymentIntentParams(clientSecret: clientSecret!) + let cardParams = STPPaymentMethodCardParams() + cardParams.number = "4242424242424242" + cardParams.expMonth = NSNumber(value: 7) + cardParams.expYear = NSNumber(value: Calendar.current.component(.year, from: Date()) + 5) + + let billingDetails = STPPaymentMethodBillingDetails() + + params.paymentMethodParams = STPPaymentMethodParams( + card: cardParams, + billingDetails: billingDetails, + metadata: nil) + + let cardOptions = STPConfirmCardOptions() + cardOptions.network = "visa" + let paymentMethodOptions = STPConfirmPaymentMethodOptions() + paymentMethodOptions.cardOptions = cardOptions + params.paymentMethodOptions = paymentMethodOptions + + client.confirmPaymentIntent( + with: params) { paymentIntent, error in + XCTAssertNil(error, "With valid key + secret, should be able to confirm the intent") + + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.stripeId, params.stripeId) + XCTAssertFalse(paymentIntent!.livemode) + XCTAssertNotNil(paymentIntent?.paymentMethodId) + + XCTAssertEqual(paymentIntent?.status, .succeeded) + + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testConfirmCardWithInvalidNetworkParam() { + var clientSecret: String? + let createExpectation = self.expectation(description: "Create PaymentIntent.") + STPTestingAPIClient.shared.createPaymentIntent(withParams: nil) { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Payment Intent confirm") + + let params = STPPaymentIntentParams(clientSecret: clientSecret!) + let cardParams = STPPaymentMethodCardParams() + cardParams.number = "4242424242424242" + cardParams.expMonth = NSNumber(value: 7) + cardParams.expYear = NSNumber(value: Calendar.current.component(.year, from: Date()) + 5) + + let billingDetails = STPPaymentMethodBillingDetails() + + params.paymentMethodParams = STPPaymentMethodParams( + card: cardParams, + billingDetails: billingDetails, + metadata: nil) + + let cardOptions = STPConfirmCardOptions() + cardOptions.network = "fake_network" + let paymentMethodOptions = STPConfirmPaymentMethodOptions() + paymentMethodOptions.cardOptions = cardOptions + params.paymentMethodOptions = paymentMethodOptions + + client.confirmPaymentIntent( + with: params) { paymentIntent, error in + XCTAssertNotNil(error, "Confirming with invalid network should result in an error") + + XCTAssertNil(paymentIntent) + + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: - giropay + + func testConfirmPaymentIntentWithGiropay() { + var clientSecret: String? + let createExpectation = self.expectation(description: "Create PaymentIntent.") + STPTestingAPIClient.shared.createPaymentIntent( + withParams: [ + "payment_method_types": ["giropay"], + "currency": "eur", + ]) { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Payment Intent confirm") + + let paymentIntentParams = STPPaymentIntentParams(clientSecret: clientSecret!) + let giropayParams = STPPaymentMethodGiropayParams() + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jenny Rosen" + + paymentIntentParams.paymentMethodParams = STPPaymentMethodParams( + giropay: giropayParams, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value", + ]) + paymentIntentParams.returnURL = "example-app-scheme://authorized" + + client.confirmPaymentIntent( + with: paymentIntentParams) { paymentIntent, error in + XCTAssertNil(error, "With valid key + secret, should be able to confirm the intent") + + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.stripeId, paymentIntentParams.stripeId) + + XCTAssertFalse(paymentIntent!.livemode) + XCTAssertNotNil(paymentIntent?.paymentMethodId) + + // giropay requires a redirect + XCTAssertEqual(paymentIntent?.status, .requiresAction) + XCTAssertNotNil(paymentIntent!.nextAction!.redirectToURL!.returnURL) + XCTAssertEqual( + paymentIntent!.nextAction!.redirectToURL!.returnURL, + URL(string: "example-app-scheme://authorized")) + + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: - AU BECS Debit + + func testConfirmAUBECSDebitPaymentIntent() { + + var clientSecret: String? + let createExpectation = self.expectation(description: "Create PaymentIntent.") + STPTestingAPIClient.shared.createPaymentIntent( + withParams: [ + "currency": "aud", + "amount": NSNumber(value: 2000), + "payment_method_types": ["au_becs_debit"], + ], + account: "au") { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let becsParams = STPPaymentMethodAUBECSDebitParams() + becsParams.bsbNumber = "000000" // Stripe test bank + becsParams.accountNumber = "000123456" // test account + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jenny Rosen" + billingDetails.email = "jrosen@example.com" + + let params = STPPaymentMethodParams( + aubecsDebit: becsParams, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value", + ]) + + let paymentIntentParams = STPPaymentIntentParams(clientSecret: clientSecret!) + paymentIntentParams.paymentMethodParams = params + + let client = STPAPIClient(publishableKey: STPTestingAUPublishableKey) + let expectation = self.expectation(description: "Payment Intent confirm") + + client.confirmPaymentIntent( + with: paymentIntentParams) { paymentIntent, error in + XCTAssertNil(error, "With valid key + secret, should be able to confirm the intent") + + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.stripeId, paymentIntentParams.stripeId) + XCTAssertNotNil(paymentIntent?.paymentMethodId) + + // AU BECS Debit should be in Processing + XCTAssertEqual(paymentIntent?.status, .processing) + + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: - Przelewy24 + + func testConfirmPaymentIntentWithPrzelewy24() { + var clientSecret: String? + let createExpectation = self.expectation(description: "Create PaymentIntent.") + STPTestingAPIClient.shared.createPaymentIntent( + withParams: [ + "payment_method_types": ["p24"], + "currency": "eur", + ]) { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Payment Intent confirm") + + let paymentIntentParams = STPPaymentIntentParams(clientSecret: clientSecret!) + let przelewy24Params = STPPaymentMethodPrzelewy24Params() + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.email = "email@email.com" + + paymentIntentParams.paymentMethodParams = STPPaymentMethodParams( + przelewy24: przelewy24Params, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value", + ]) + paymentIntentParams.returnURL = "example-app-scheme://authorized" + client.confirmPaymentIntent( + with: paymentIntentParams) { paymentIntent, error in + XCTAssertNil(error, "With valid key + secret, should be able to confirm the intent") + + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.stripeId, paymentIntentParams.stripeId) + XCTAssertFalse(paymentIntent!.livemode) + XCTAssertNotNil(paymentIntent?.paymentMethodId) + + // Przelewy24 requires a redirect + XCTAssertEqual(paymentIntent?.status, .requiresAction) + XCTAssertNotNil(paymentIntent!.nextAction!.redirectToURL!.returnURL) + XCTAssertEqual( + paymentIntent!.nextAction!.redirectToURL!.returnURL, + URL(string: "example-app-scheme://authorized")) + + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: - Bancontact + + func testConfirmPaymentIntentWithBancontact() { + var clientSecret: String? + let createExpectation = self.expectation(description: "Create PaymentIntent.") + STPTestingAPIClient.shared.createPaymentIntent( + withParams: [ + "payment_method_types": ["bancontact"], + "currency": "eur", + ]) { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Payment Intent confirm") + + let paymentIntentParams = STPPaymentIntentParams(clientSecret: clientSecret!) + let bancontact = STPPaymentMethodBancontactParams() + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jane Doe" + + paymentIntentParams.paymentMethodParams = STPPaymentMethodParams( + bancontact: bancontact, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value", + ]) + paymentIntentParams.returnURL = "example-app-scheme://authorized" + client.confirmPaymentIntent( + with: paymentIntentParams) { paymentIntent, error in + XCTAssertNil(error, "With valid key + secret, should be able to confirm the intent") + + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.stripeId, paymentIntentParams.stripeId) + XCTAssertFalse(paymentIntent!.livemode) + XCTAssertNotNil(paymentIntent?.paymentMethodId) + + // Bancontact requires a redirect + XCTAssertEqual(paymentIntent?.status, .requiresAction) + XCTAssertNotNil(paymentIntent!.nextAction!.redirectToURL!.returnURL) + XCTAssertEqual( + paymentIntent!.nextAction!.redirectToURL!.returnURL, + URL(string: "example-app-scheme://authorized")) + + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: - OXXO + + func testConfirmPaymentIntentWithOXXO() { + var clientSecret: String? + let createExpectation = self.expectation(description: "Create PaymentIntent.") + STPTestingAPIClient.shared.createPaymentIntent( + withParams: [ + "payment_method_types": ["oxxo"], + "amount": NSNumber(value: 2000), + "currency": "mxn", + ], + account: "mex") { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let client = STPAPIClient(publishableKey: STPTestingMEXPublishableKey) + let expectation = self.expectation(description: "Payment Intent confirm") + + let paymentIntentParams = STPPaymentIntentParams(clientSecret: clientSecret!) + let oxxo = STPPaymentMethodOXXOParams() + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jane Doe" + billingDetails.email = "email@email.com" + + paymentIntentParams.paymentMethodParams = STPPaymentMethodParams( + oxxo: oxxo, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value", + ]) + client.confirmPaymentIntent( + with: paymentIntentParams) { paymentIntent, error in + XCTAssertNil(error, "With valid key + secret, should be able to confirm the intent") + + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.stripeId, paymentIntentParams.stripeId) + XCTAssertFalse(paymentIntent!.livemode) + XCTAssertNotNil(paymentIntent?.paymentMethodId) + + // OXXO requires display the voucher as next step + let oxxoDisplayDetails = paymentIntent!.nextAction!.allResponseFields["oxxo_display_details"] as? [AnyHashable: Any] + XCTAssertNotNil(oxxoDisplayDetails?["expires_after"]) + XCTAssertNotNil(oxxoDisplayDetails?["number"]) + XCTAssertEqual(paymentIntent?.status, .requiresAction) + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: - EPS + + func testConfirmPaymentIntentWithEPS() { + var clientSecret: String? + let createExpectation = self.expectation(description: "Create PaymentIntent.") + STPTestingAPIClient.shared.createPaymentIntent( + withParams: [ + "payment_method_types": ["eps"], + "currency": "eur", + ]) { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Payment Intent confirm") + + let paymentIntentParams = STPPaymentIntentParams(clientSecret: clientSecret!) + let epsParams = STPPaymentMethodEPSParams() + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jenny Rosen" + + paymentIntentParams.paymentMethodParams = STPPaymentMethodParams( + eps: epsParams, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value", + ]) + paymentIntentParams.returnURL = "example-app-scheme://authorized" + + client.confirmPaymentIntent( + with: paymentIntentParams) { paymentIntent, error in + XCTAssertNil(error, "With valid key + secret, should be able to confirm the intent") + + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.stripeId, paymentIntentParams.stripeId) + + XCTAssertFalse(paymentIntent!.livemode) + XCTAssertNotNil(paymentIntent?.paymentMethodId) + + // EPS requires a redirect + XCTAssertEqual(paymentIntent?.status, .requiresAction) + XCTAssertNotNil(paymentIntent!.nextAction!.redirectToURL!.returnURL) + XCTAssertEqual( + paymentIntent!.nextAction!.redirectToURL!.returnURL, + URL(string: "example-app-scheme://authorized")) + + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: - Alipay + + func testConfirmAlipayPaymentIntent() { + var clientSecret: String? + let createExpectation = self.expectation(description: "Create PaymentIntent.") + STPTestingAPIClient.shared.createPaymentIntent( + withParams: [ + "currency": "usd", + "amount": NSNumber(value: 2000), + "payment_method_types": ["alipay"], + ]) { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let params = STPPaymentMethodParams(alipay: STPPaymentMethodAlipayParams(), billingDetails: nil, metadata: nil) + let paymentIntentParams = STPPaymentIntentParams(clientSecret: clientSecret!) + paymentIntentParams.paymentMethodParams = params + paymentIntentParams.returnURL = "foo://bar" + paymentIntentParams.paymentMethodOptions = STPConfirmPaymentMethodOptions() + paymentIntentParams.paymentMethodOptions!.alipayOptions = STPConfirmAlipayOptions() + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Payment Intent confirm") + + client.confirmPaymentIntent( + with: paymentIntentParams) { paymentIntent, error in + XCTAssertNil(error, "With valid key + secret, should be able to confirm the intent") + + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.stripeId, paymentIntentParams.stripeId) + XCTAssertNotNil(paymentIntent?.paymentMethodId) + + XCTAssertEqual(paymentIntent?.status, .requiresAction) + XCTAssertEqual(paymentIntent!.nextAction?.type, .alipayHandleRedirect) + XCTAssertNotNil(paymentIntent!.nextAction) + + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: - GrabPay + + func testConfirmPaymentIntentWithGrabPay() { + var clientSecret: String? + let createExpectation = self.expectation(description: "Create PaymentIntent.") + STPTestingAPIClient.shared.createPaymentIntent( + withParams: [ + "payment_method_types": ["grabpay"], + "currency": "sgd", + ], + account: "sg") { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let client = STPAPIClient(publishableKey: STPTestingSGPublishableKey) + let expectation = self.expectation(description: "Payment Intent confirm") + + let paymentIntentParams = STPPaymentIntentParams(clientSecret: clientSecret!) + let grabpay = STPPaymentMethodGrabPayParams() + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jenny Rosen" + + paymentIntentParams.paymentMethodParams = STPPaymentMethodParams( + grabPay: grabpay, + billingDetails: billingDetails, + metadata: nil) + paymentIntentParams.returnURL = "example-app-scheme://authorized" + + client.confirmPaymentIntent( + with: paymentIntentParams) { paymentIntent, error in + XCTAssertNil(error, "With valid key + secret, should be able to confirm the intent") + + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.stripeId, paymentIntentParams.stripeId) + + XCTAssertFalse(paymentIntent!.livemode) + XCTAssertNotNil(paymentIntent?.paymentMethodId) + + // GrabPay requires a redirect + XCTAssertEqual(paymentIntent?.status, .requiresAction) + XCTAssertNotNil(paymentIntent!.nextAction?.redirectToURL?.returnURL) + XCTAssertEqual( + paymentIntent!.nextAction!.redirectToURL!.returnURL, + URL(string: "example-app-scheme://authorized")) + + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: - PayPal + + func testConfirmPaymentIntentWithPayPal() { + var clientSecret: String? + let createExpectation = self.expectation(description: "Create PaymentIntent.") + STPTestingAPIClient.shared.createPaymentIntent( + withParams: [ + "payment_method_types": ["paypal"], + "currency": "eur", + ], + account: "be") { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let client = STPAPIClient(publishableKey: STPTestingBEPublishableKey) + let expectation = self.expectation(description: "Payment Intent confirm") + + let paymentIntentParams = STPPaymentIntentParams(clientSecret: clientSecret!) + let payPal = STPPaymentMethodPayPalParams() + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jane Doe" + + paymentIntentParams.paymentMethodParams = STPPaymentMethodParams( + payPal: payPal, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value", + ]) + paymentIntentParams.returnURL = "example-app-scheme://authorized" + client.confirmPaymentIntent( + with: paymentIntentParams) { paymentIntent, error in + XCTAssertNil(error, "With valid key + secret, should be able to confirm the intent") + + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.stripeId, paymentIntentParams.stripeId) + XCTAssertFalse(paymentIntent!.livemode) + XCTAssertNotNil(paymentIntent?.paymentMethodId) + + // PayPal requires a redirect + XCTAssertEqual(paymentIntent?.status, .requiresAction) + XCTAssertNotNil(paymentIntent!.nextAction!.redirectToURL!.returnURL) + XCTAssertEqual( + paymentIntent!.nextAction!.redirectToURL!.returnURL, + URL(string: "example-app-scheme://authorized")) + + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: - BLIK + + func testConfirmPaymentIntentWithBLIK() { + var clientSecret: String? + let createExpectation = self.expectation(description: "Create PaymentIntent.") + STPTestingAPIClient.shared.createPaymentIntent( + withParams: [ + "payment_method_types": ["blik"], + "currency": "pln", + "amount": NSNumber(value: 1000), + ], + account: "be") { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let client = STPAPIClient(publishableKey: STPTestingBEPublishableKey) + let expectation = self.expectation(description: "Payment Intent confirm") + + let paymentIntentParams = STPPaymentIntentParams(clientSecret: clientSecret!) + let blik = STPPaymentMethodBLIKParams() + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jane Doe" + + paymentIntentParams.paymentMethodParams = STPPaymentMethodParams( + blik: blik, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value", + ]) + let options = STPConfirmPaymentMethodOptions() + options.blikOptions = STPConfirmBLIKOptions(code: "123456") + paymentIntentParams.paymentMethodOptions = options + paymentIntentParams.returnURL = "example-app-scheme://unused" + client.confirmPaymentIntent( + with: paymentIntentParams) { paymentIntent, error in + XCTAssertNil(error, "With valid key + secret, should be able to confirm the intent") + + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.stripeId, paymentIntentParams.stripeId) + XCTAssertFalse(paymentIntent!.livemode) + XCTAssertNotNil(paymentIntent?.paymentMethodId) + + // Blik transitions to requires_action until the customer authorizes the transaction or 1 minute passes + XCTAssertEqual(paymentIntent?.status, .requiresAction) + XCTAssertEqual(paymentIntent!.nextAction?.type, .BLIKAuthorize) + + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: - Affirm + + func testConfirmPaymentIntentWithAffirm() { + var clientSecret: String? + let createExpectation = self.expectation(description: "Create PaymentIntent.") + STPTestingAPIClient.shared.createPaymentIntent( + withParams: [ + "payment_method_types": ["affirm"], + "currency": "usd", + "amount": NSNumber(value: 6000), + ]) { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Payment Intent confirm") + + let paymentIntentParams = STPPaymentIntentParams(clientSecret: clientSecret!) + let affirm = STPPaymentMethodAffirmParams() + + paymentIntentParams.paymentMethodParams = STPPaymentMethodParams( + affirm: affirm, + metadata: [ + "test_key": "test_value", + ]) + + let addressParams = STPPaymentIntentShippingDetailsAddressParams(line1: "123 Main St") + addressParams.line2 = "Apt 2" + addressParams.city = "San Francisco" + addressParams.state = "CA" + addressParams.country = "US" + addressParams.postalCode = "94106" + paymentIntentParams.shipping = STPPaymentIntentShippingDetailsParams(address: addressParams, name: "Jane Doe") + + let options = STPConfirmPaymentMethodOptions() + paymentIntentParams.paymentMethodOptions = options + paymentIntentParams.returnURL = "example-app-scheme://unused" + client.confirmPaymentIntent( + with: paymentIntentParams) { paymentIntent, error in + XCTAssertNil(error, "With valid key + secret, should be able to confirm the intent") + + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.stripeId, paymentIntentParams.stripeId) + XCTAssertFalse(paymentIntent!.livemode) + XCTAssertNotNil(paymentIntent?.paymentMethodId) + + XCTAssertEqual(paymentIntent?.status, .requiresAction) + XCTAssertEqual(paymentIntent!.nextAction?.type, .redirectToURL) + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: - MobilePay + + func testConfirmPaymentIntentWithMobilePay() { + var clientSecret: String? + let createExpectation = self.expectation(description: "Create PaymentIntent.") + STPTestingAPIClient.shared.createPaymentIntent( + withParams: [ + "payment_method_types": ["mobilepay"], + "currency": "dkk", + "amount": NSNumber(value: 6000), + ], + account: "fr" + ) { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let client = STPAPIClient(publishableKey: STPTestingFRPublishableKey) + let expectation = self.expectation(description: "Payment Intent confirm") + + let paymentIntentParams = STPPaymentIntentParams(clientSecret: clientSecret!) + paymentIntentParams.paymentMethodParams = STPPaymentMethodParams( + mobilePay: STPPaymentMethodMobilePayParams(), + billingDetails: nil, + metadata: [ + "test_key": "test_value", + ] + ) + + let options = STPConfirmPaymentMethodOptions() + paymentIntentParams.paymentMethodOptions = options + paymentIntentParams.returnURL = "example-app-scheme://unused" + client.confirmPaymentIntent(with: paymentIntentParams) { paymentIntent, error in + XCTAssertNil(error, "With valid key + secret, should be able to confirm the intent") + + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.stripeId, paymentIntentParams.stripeId) + XCTAssertFalse(paymentIntent!.livemode) + XCTAssertNotNil(paymentIntent?.paymentMethodId) + + XCTAssertEqual(paymentIntent?.status, .requiresAction) + XCTAssertEqual(paymentIntent!.nextAction?.type, .redirectToURL) + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: - US Bank Account + func createAndConfirmPaymentIntentWithUSBankAccount( + paymentMethodOptions: STPConfirmUSBankAccountOptions? = nil, + completion: @escaping (String?) -> Void + ) { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + var clientSecret: String? + let createPIExpectation = expectation(description: "Create PaymentIntent") + STPTestingAPIClient.shared.createPaymentIntent( + withParams: [ + "payment_method_types": ["us_bank_account"], + "currency": "usd", + "amount": 1000, + ], + account: nil + ) { intentClientSecret, error in + XCTAssertNil(error) + XCTAssertNotNil(intentClientSecret) + clientSecret = intentClientSecret + createPIExpectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + guard let clientSecret = clientSecret else { + XCTFail("Failed to create PaymentIntent") + return + } + + let usBankAccountParams = STPPaymentMethodUSBankAccountParams() + usBankAccountParams.accountType = .checking + usBankAccountParams.accountHolderType = .individual + usBankAccountParams.accountNumber = "000123456789" + usBankAccountParams.routingNumber = "110000000" + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "iOS CI Tester" + billingDetails.email = "tester@example.com" + + let paymentMethodParams = STPPaymentMethodParams( + usBankAccount: usBankAccountParams, + billingDetails: billingDetails, + metadata: nil + ) + + let paymentIntentParams = STPPaymentIntentParams(clientSecret: clientSecret) + paymentIntentParams.paymentMethodParams = paymentMethodParams + if let paymentMethodOptions = paymentMethodOptions { + let pmo = STPConfirmPaymentMethodOptions() + pmo.usBankAccountOptions = paymentMethodOptions + paymentIntentParams.paymentMethodOptions = pmo + } + + let confirmPIExpectation = expectation(description: "Confirm PaymentIntent") + client.confirmPaymentIntent(with: paymentIntentParams, expand: ["payment_method"]) { + paymentIntent, + error in + XCTAssertNil(error) + XCTAssertNotNil(paymentIntent) + XCTAssertNotNil(paymentIntent?.paymentMethod) + XCTAssertNotNil(paymentIntent?.paymentMethod?.usBankAccount) + XCTAssertEqual(paymentIntent?.paymentMethod?.usBankAccount?.last4, "6789") + XCTAssertEqual(paymentIntent?.status, .requiresAction) + XCTAssertEqual(paymentIntent?.nextAction?.type, .verifyWithMicrodeposits) + if let paymentMethodOptions = paymentMethodOptions { + XCTAssertEqual( + paymentIntent?.paymentMethodOptions?.usBankAccount?.setupFutureUsage, + paymentMethodOptions.setupFutureUsage + ) + } + confirmPIExpectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + completion(clientSecret) + } + + func testConfirmPaymentIntentWithUSBankAccount_verifyWithAmounts() { + createAndConfirmPaymentIntentWithUSBankAccount { [self] clientSecret in + guard let clientSecret = clientSecret else { + XCTFail("Failed to create PaymentIntent") + return + } + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let verificationExpectation = expectation(description: "Verify with microdeposits") + client.verifyPaymentIntentWithMicrodeposits( + clientSecret: clientSecret, + firstAmount: 32, + secondAmount: 45 + ) { paymentIntent, error in + XCTAssertNil(error) + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.status, .processing) + verificationExpectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + } + } + + func testConfirmPaymentIntentWithUSBankAccount_verifyWithDescriptorCode() { + createAndConfirmPaymentIntentWithUSBankAccount { [self] clientSecret in + guard let clientSecret = clientSecret else { + XCTFail("Failed to create PaymentIntent") + return + } + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let verificationExpectation = expectation(description: "Verify with microdeposits") + client.verifyPaymentIntentWithMicrodeposits( + clientSecret: clientSecret, + descriptorCode: "SM11AA" + ) { paymentIntent, error in + XCTAssertNil(error) + XCTAssertNotNil(paymentIntent) + XCTAssertEqual(paymentIntent?.status, .processing) + verificationExpectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + } + } + + func testConfirmUSBankAccountWithPaymentMethodOptions() { + createAndConfirmPaymentIntentWithUSBankAccount( + paymentMethodOptions: STPConfirmUSBankAccountOptions(setupFutureUsage: .offSession) + ) { clientSecret in + XCTAssertNotNil(clientSecret) + } + } + + // MARK: - Helpers + + func cardSourceParams() -> STPSourceParams { + let card = STPCardParams() + card.number = "4000 0000 0000 3063" // Test 3DS required card + card.expMonth = 7 + card.expYear = UInt(Calendar.current.component(.year, from: Date()) + 5) + card.currency = "usd" + + return .cardParams(withCard: card) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentIntentLastPaymentErrorTest.swift b/Stripe/StripeiOSTests/STPPaymentIntentLastPaymentErrorTest.swift new file mode 100644 index 00000000..208b9a2d --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentIntentLastPaymentErrorTest.swift @@ -0,0 +1,60 @@ +// +// STPPaymentIntentLastPaymentErrorTest.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 9/29/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentIntentLastPaymentErrorTest: XCTestCase { + + func testErrorType() { + XCTAssertEqual( + STPPaymentIntentLastPaymentErrorType(string: "api_connection_error"), + .apiConnection + ) + XCTAssertEqual( + STPPaymentIntentLastPaymentErrorType(string: "API_CONNECTION_ERROR"), + .apiConnection + ) + XCTAssertEqual(STPPaymentIntentLastPaymentErrorType(string: "api_error"), .api) + XCTAssertEqual(STPPaymentIntentLastPaymentErrorType(string: "API_ERROR"), .api) + XCTAssertEqual( + STPPaymentIntentLastPaymentErrorType(string: "authentication_error"), + .authentication + ) + XCTAssertEqual( + STPPaymentIntentLastPaymentErrorType(string: "AUTHENTICATION_ERROR"), + .authentication + ) + XCTAssertEqual(STPPaymentIntentLastPaymentErrorType(string: "card_error"), .card) + XCTAssertEqual(STPPaymentIntentLastPaymentErrorType(string: "CARD_ERROR"), .card) + XCTAssertEqual( + STPPaymentIntentLastPaymentErrorType(string: "idempotency_error"), + .idempotency + ) + XCTAssertEqual( + STPPaymentIntentLastPaymentErrorType(string: "IDEMPOTENCY_ERROR"), + .idempotency + ) + XCTAssertEqual( + STPPaymentIntentLastPaymentErrorType(string: "invalid_request_error"), + .invalidRequest + ) + XCTAssertEqual( + STPPaymentIntentLastPaymentErrorType(string: "INVALID_REQUEST_ERROR"), + .invalidRequest + ) + XCTAssertEqual(STPPaymentIntentLastPaymentErrorType(string: "rate_limit_error"), .rateLimit) + XCTAssertEqual(STPPaymentIntentLastPaymentErrorType(string: "RATE_LIMIT_ERROR"), .rateLimit) + } + +} diff --git a/Stripe/StripeiOSTests/STPPaymentIntentParamsTest.swift b/Stripe/StripeiOSTests/STPPaymentIntentParamsTest.swift new file mode 100644 index 00000000..333101f3 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentIntentParamsTest.swift @@ -0,0 +1,218 @@ +// +// STPPaymentIntentParamsTest.swift +// StripeiOS Tests +// +// Created by Daniel Jackson on 7/5/18. +// Copyright © 2018 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentIntentParamsTest: XCTestCase { + func testInit() { + for params in [ + STPPaymentIntentParams(clientSecret: "secret"), + STPPaymentIntentParams(), + STPPaymentIntentParams(), + ] { + XCTAssertNotNil(params) + XCTAssertNotNil(params.clientSecret) + XCTAssertNotNil(params.additionalAPIParameters) + XCTAssertEqual(params.additionalAPIParameters.count, 0) + + XCTAssertNil(params.stripeId, "invalid secrets, no stripeId") + XCTAssertNil(params.sourceParams) + XCTAssertNil(params.sourceId) + XCTAssertNil(params.receiptEmail) + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertNil(params.saveSourceToCustomer) + // #pragma clang diagnostic pop + XCTAssertNil(params.savePaymentMethod) + XCTAssertNil(params.returnURL) + XCTAssertNil(params.setupFutureUsage) + XCTAssertNil(params.useStripeSDK) + XCTAssertNil(params.mandateData) + XCTAssertNil(params.paymentMethodOptions) + XCTAssertNil(params.shipping) + } + } + + func testDescription() { + let params = STPPaymentIntentParams() + XCTAssertNotNil(params.description) + } + + // MARK: Deprecated Property + + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + func testReturnURLRenaming() { + let params = STPPaymentIntentParams() + + XCTAssertNil(params.returnURL) + XCTAssertNil(params.returnUrl) + + params.returnURL = "set via new name" + XCTAssertEqual(params.returnUrl, "set via new name") + + params.returnUrl = "set via old name" + XCTAssertEqual(params.returnURL, "set via old name") + } + + func testSaveSourceToCustomerRenaming() { + let params = STPPaymentIntentParams() + + XCTAssertNil(params.saveSourceToCustomer) + XCTAssertNil(params.savePaymentMethod) + + params.savePaymentMethod = NSNumber(value: false) + XCTAssertEqual(params.saveSourceToCustomer, NSNumber(value: false)) + + params.saveSourceToCustomer = NSNumber(value: true) + XCTAssertEqual(params.savePaymentMethod, NSNumber(value: true)) + } + + func testDefaultMandateData() { + let params = STPPaymentIntentParams() + + // no configuration should have no mandateData + XCTAssertNil(params.mandateData) + + params.paymentMethodParams = STPPaymentMethodParams() + + params.paymentMethodParams!.rawTypeString = "card" + // card type should have no default mandateData + XCTAssertNil(params.mandateData) + + for type in ["sepa_debit", "au_becs_debit", "bacs_debit"] { + params.mandateData = nil + params.paymentMethodParams!.rawTypeString = type + // Mandate-required type should have mandateData + XCTAssertNotNil(params.mandateData) + XCTAssertEqual( + params.mandateData!.customerAcceptance.onlineParams!.inferFromClient, + NSNumber(value: true) + ) + + params.mandateData = STPMandateDataParams( + customerAcceptance: STPMandateCustomerAcceptanceParams( + type: .offline, + onlineParams: nil + )! + ) + // Default behavior should not override custom setting + XCTAssertNotNil(params.mandateData) + XCTAssertNil(params.mandateData!.customerAcceptance.onlineParams) + } + } + + // #pragma clang diagnostic pop + + // MARK: STPFormEncodable Tests + func testRootObjectName() { + XCTAssertNil(STPPaymentIntentParams.rootObjectName()) + } + + func testPropertyNamesToFormFieldNamesMapping() { + let params = STPPaymentIntentParams() + + let mapping = STPPaymentIntentParams.propertyNamesToFormFieldNamesMapping() + + for propertyName in mapping.keys { + XCTAssertFalse(propertyName.contains(":")) + XCTAssert(params.responds(to: NSSelectorFromString(propertyName))) + } + + for formFieldName in mapping.values { + XCTAssert(formFieldName.count > 0) + } + + XCTAssertEqual( + mapping.values.count, + NSSet(array: (mapping as NSDictionary).allValues).count + ) + } + + func testCopy() { + let params = STPPaymentIntentParams(clientSecret: "test_client_secret") + params.paymentMethodParams = STPPaymentMethodParams() + params.paymentMethodId = "test_payment_method_id" + params.savePaymentMethod = NSNumber(value: true) + params.returnURL = "fake://testing_only" + params.setupFutureUsage = STPPaymentIntentSetupFutureUsage( + rawValue: Int(truncating: NSNumber(value: 1)) + ) + params.useStripeSDK = NSNumber(value: true) + params.mandateData = STPMandateDataParams( + customerAcceptance: STPMandateCustomerAcceptanceParams( + type: .offline, + onlineParams: nil + )! + ) + params.paymentMethodOptions = STPConfirmPaymentMethodOptions() + params.additionalAPIParameters = [ + "other_param": "other_value" + ] + params.shipping = STPPaymentIntentShippingDetailsParams( + address: STPPaymentIntentShippingDetailsAddressParams(line1: ""), + name: "" + ) + + let paramsCopy = params.copy() as! STPPaymentIntentParams + XCTAssertEqual(params.clientSecret, paramsCopy.clientSecret) + XCTAssertEqual(params.paymentMethodId, paramsCopy.paymentMethodId) + + // assert equal, not equal objects, because this is a shallow copy + XCTAssertEqual(params.paymentMethodParams, paramsCopy.paymentMethodParams) + XCTAssertEqual(params.mandateData, paramsCopy.mandateData) + XCTAssertEqual(params.shipping, paramsCopy.shipping) + + XCTAssertEqual(params.setupFutureUsage, STPPaymentIntentSetupFutureUsage.none) + XCTAssertEqual(params.savePaymentMethod, paramsCopy.savePaymentMethod) + XCTAssertEqual(params.returnURL, paramsCopy.returnURL) + XCTAssertEqual(params.useStripeSDK, paramsCopy.useStripeSDK) + XCTAssertEqual(params.paymentMethodOptions, paramsCopy.paymentMethodOptions) + XCTAssertEqual( + params.additionalAPIParameters as NSDictionary, + paramsCopy.additionalAPIParameters as NSDictionary + ) + + } + + func testClientSecretValidation() { + XCTAssertFalse( + STPPaymentIntentParams.isClientSecretValid("pi_12345"), + "'pi_12345' is not a valid client secret." + ) + XCTAssertFalse( + STPPaymentIntentParams.isClientSecretValid("pi_12345_secret_"), + "'pi_12345_secret_' is not a valid client secret." + ) + XCTAssertFalse( + STPPaymentIntentParams.isClientSecretValid( + "pi_a1b2c3_secret_x7y8z9pi_a1b2c3_secret_x7y8z9" + ), + "'pi_a1b2c3_secret_x7y8z9pi_a1b2c3_secret_x7y8z9' is not a valid client secret." + ) + XCTAssertFalse( + STPPaymentIntentParams.isClientSecretValid("seti_a1b2c3_secret_x7y8z9"), + "'seti_a1b2c3_secret_x7y8z9' is not a valid client secret." + ) + + XCTAssertTrue( + STPPaymentIntentParams.isClientSecretValid("pi_a1b2c3_secret_x7y8z9"), + "'pi_a1b2c3_secret_x7y8z9' is a valid client secret." + ) + XCTAssertTrue( + STPPaymentIntentParams.isClientSecretValid( + "pi_1CkiBMLENEVhOs7YMtUehLau_secret_s4O8SDh7s6spSmHDw1VaYPGZA" + ), + "'pi_1CkiBMLENEVhOs7YMtUehLau_secret_s4O8SDh7s6spSmHDw1VaYPGZA' is a valid client secret." + ) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentIntentTest.swift b/Stripe/StripeiOSTests/STPPaymentIntentTest.swift new file mode 100644 index 00000000..849b94e7 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentIntentTest.swift @@ -0,0 +1,196 @@ +// +// STPPaymentIntentTest.swift +// StripeiOS Tests +// +// Created by Daniel Jackson on 6/27/18. +// Copyright © 2018 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentIntentTest: XCTestCase { + func testIdentifierFromSecret() { + XCTAssertEqual( + STPPaymentIntent.id(fromClientSecret: "pi_123_secret_XYZ"), + "pi_123" + ) + XCTAssertEqual( + STPPaymentIntent.id( + fromClientSecret: "pi_123_secret_RandomlyContains_secret_WhichIsFine" + ), + "pi_123" + ) + + XCTAssertNil(STPPaymentIntent.id(fromClientSecret: "")) + XCTAssertNil(STPPaymentIntent.id(fromClientSecret: "po_123_secret_HasBadPrefix")) + XCTAssertNil(STPPaymentIntent.id(fromClientSecret: "MissingSentinalForSplitting")) + } + + // MARK: - Description Tests + func testDescription() { + let paymentIntent = STPFixtures.paymentIntent() + + XCTAssertNotNil(paymentIntent) + let desc = paymentIntent.description + XCTAssertTrue(desc.contains(NSStringFromClass(type(of: paymentIntent).self))) + XCTAssertGreaterThan((desc.count), 500, "Custom description should be long") + } + + // MARK: - STPAPIResponseDecodable Tests + func testDecodedObjectFromAPIResponseRequiredFields() { + let fullJson = STPTestUtils.jsonNamed(STPTestJSONPaymentIntent) + + XCTAssertNotNil( + STPPaymentIntent.decodedObject(fromAPIResponse: fullJson), + "can decode with full json" + ) + + let requiredFields = ["id", "client_secret", "amount", "currency", "livemode", "status"] + + for field in requiredFields { + var partialJson = fullJson + + XCTAssertNotNil(partialJson?[field]) + partialJson?.removeValue(forKey: field) + + XCTAssertNil(STPPaymentIntent.decodedObject(fromAPIResponse: partialJson)) + } + } + + func testDecodedObjectFromAPIResponseMapping() { + let paymentIntentJson = STPTestUtils.jsonNamed("PaymentIntent")! + let orderedPaymentJson = ["card", "ideal", "sepa_debit"] + let paymentIntentResponse = + [ + "payment_intent": paymentIntentJson, + "ordered_payment_method_types": orderedPaymentJson, + ] as [String: Any] + let unactivatedPaymentMethodTypes = ["sepa_debit"] + let cardBrandChoice = ["eligible": true] + let response = + [ + "payment_method_preference": paymentIntentResponse, + "unactivated_payment_method_types": unactivatedPaymentMethodTypes, + "card_brand_choice": cardBrandChoice, + ] as [String: Any] + + let paymentIntent = STPPaymentIntent.decodedObject(fromAPIResponse: response)! + + XCTAssertEqual(paymentIntent.stripeId, "pi_1Cl15wIl4IdHmuTbCWrpJXN6") + XCTAssertEqual( + paymentIntent.clientSecret, + "pi_1Cl15wIl4IdHmuTbCWrpJXN6_secret_EkKtQ7Sg75hLDFKqFG8DtWcaK" + ) + XCTAssertEqual(paymentIntent.amount, 2345) + XCTAssertEqual(paymentIntent.canceledAt, Date(timeIntervalSince1970: 1_530_911_045)) + XCTAssertEqual(paymentIntent.captureMethod, .manual) + XCTAssertEqual(paymentIntent.confirmationMethod, .automatic) + XCTAssertEqual(paymentIntent.created, Date(timeIntervalSince1970: 1_530_911_040)) + XCTAssertEqual(paymentIntent.currency, "usd") + XCTAssertEqual(paymentIntent.stripeDescription, "My Sample PaymentIntent") + XCTAssertFalse(paymentIntent.livemode) + XCTAssertEqual(paymentIntent.receiptEmail, "danj@example.com") + + // Deprecated: `nextSourceAction` & `authorizeWithURL` should just be aliases for `nextAction` & `redirectToURL` + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertEqual( + paymentIntent.nextAction, + paymentIntent.nextAction, + "Should be the same object." + ) + XCTAssertEqual( + paymentIntent.nextAction!.redirectToURL!, + paymentIntent.nextAction!.redirectToURL, + "Should be the same object." + ) + // #pragma clang diagnostic pop + + // nextAction + XCTAssertNotNil(paymentIntent.nextAction) + XCTAssertEqual(paymentIntent.nextAction!.type, .redirectToURL) + XCTAssertNotNil(paymentIntent.nextAction!.redirectToURL) + XCTAssertNotNil(paymentIntent.nextAction!.redirectToURL!.url) + let returnURL = paymentIntent.nextAction!.redirectToURL!.returnURL + XCTAssertNotNil(returnURL) + XCTAssertEqual(returnURL, URL(string: "payments-example://stripe-redirect")) + let url = paymentIntent.nextAction!.redirectToURL!.url + XCTAssertNotNil(url) + + XCTAssertEqual( + url, + URL( + string: + "https://hooks.stripe.com/redirect/authenticate/src_1Cl1AeIl4IdHmuTb1L7x083A?client_secret=src_client_secret_DBNwUe9qHteqJ8qQBwNWiigk" + ) + ) + XCTAssertEqual(paymentIntent.sourceId, "src_1Cl1AdIl4IdHmuTbseiDWq6m") + XCTAssertEqual(paymentIntent.status, .requiresAction) + XCTAssertEqual(paymentIntent.setupFutureUsage, .none) + + XCTAssertEqual( + paymentIntent.paymentMethodTypes, + [NSNumber(value: STPPaymentMethodType.card.rawValue)] + ) + + // lastPaymentError + + XCTAssertNotNil(paymentIntent.lastPaymentError) + XCTAssertEqual( + paymentIntent.lastPaymentError!.code, + "payment_intent_authentication_failure" + ) + XCTAssertEqual( + paymentIntent.lastPaymentError!.docURL, + "https://stripe.com/docs/error-codes#payment-intent-authentication-failure" + ) + XCTAssertEqual( + paymentIntent.lastPaymentError!.message, + "The provided PaymentMethod has failed authentication. You can provide payment_method_data or a new PaymentMethod to attempt to fulfill this PaymentIntent again." + ) + XCTAssertNotNil(paymentIntent.lastPaymentError!.paymentMethod) + XCTAssertEqual(paymentIntent.lastPaymentError!.type, .invalidRequest) + + // Shipping + XCTAssertNotNil(paymentIntent.shipping) + XCTAssertEqual(paymentIntent.shipping!.carrier, "USPS") + XCTAssertEqual(paymentIntent.shipping!.name, "Dan") + XCTAssertEqual(paymentIntent.shipping!.phone, "1-415-555-1234") + XCTAssertEqual(paymentIntent.shipping!.trackingNumber, "xyz123abc") + XCTAssertNotNil(paymentIntent.shipping!.address) + XCTAssertEqual(paymentIntent.shipping!.address!.city, "San Francisco") + XCTAssertEqual(paymentIntent.shipping!.address!.country, "USA") + XCTAssertEqual(paymentIntent.shipping!.address!.line1, "123 Main St") + XCTAssertEqual(paymentIntent.shipping!.address!.line2, "Apt 456") + XCTAssertEqual(paymentIntent.shipping!.address!.postalCode, "94107") + XCTAssertEqual(paymentIntent.shipping!.address!.state, "CA") + + // Ordered Payment Method Types + XCTAssertEqual( + paymentIntent.orderedPaymentMethodTypes.map({ $0.displayName }), + ["Card", "iDEAL", "SEPA Debit"] + ) + + // Unactivated Payment Method Types + XCTAssertEqual( + paymentIntent.unactivatedPaymentMethodTypes.map({ $0.displayName }), + ["SEPA Debit"] + ) + + // Card brand choice + XCTAssertEqual(paymentIntent.cardBrandChoice?.eligible, true) + + var allResponseFields = paymentIntentJson + allResponseFields["ordered_payment_method_types"] = orderedPaymentJson + allResponseFields["unactivated_payment_method_types"] = unactivatedPaymentMethodTypes + allResponseFields["card_brand_choice"] = cardBrandChoice + XCTAssertEqual( + paymentIntent.allResponseFields as NSDictionary, + allResponseFields as NSDictionary + ) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodAUBECSDebitParamsTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodAUBECSDebitParamsTests.swift new file mode 100644 index 00000000..e1fb6d79 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodAUBECSDebitParamsTests.swift @@ -0,0 +1,55 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodAUBECSDebitParamsTests.m +// StripeiOS Tests +// +// Created by Cameron Sabol on 3/4/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils + +class STPPaymentMethodAUBECSDebitParamsTests: XCTestCase { + func testCreateAUBECSPaymentMethod() { + let client = STPAPIClient(publishableKey: STPTestingAUPublishableKey) + let becsParams = STPPaymentMethodAUBECSDebitParams() + becsParams.bsbNumber = "000000" // Stripe test bank + becsParams.accountNumber = "000123456" // test account + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jenny Rosen" + billingDetails.email = "jrosen@example.com" + + let params = STPPaymentMethodParams( + aubecsDebit: becsParams, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value" + ]) + + let expectation = self.expectation(description: "Payment Method AU BECS Debit create") + + client.createPaymentMethod( + with: params) { paymentMethod, error in + expectation.fulfill() + + XCTAssertNil(error, "Unexpected error creating AU BECS Debit PaymentMethod") + XCTAssertNotNil(paymentMethod, "Failed to create AU BECS Debit PaymentMethod") + XCTAssertNotNil(paymentMethod?.stripeId, "Missing stripeId") + XCTAssertNotNil(paymentMethod?.created, "Missing created") + XCTAssertFalse(paymentMethod!.liveMode, "Incorrect livemode") + XCTAssertEqual(paymentMethod?.type, .AUBECSDebit, "Incorrect PaymentMethod type") + + // Billing Details + XCTAssertEqual(paymentMethod?.billingDetails!.email, "jrosen@example.com") + XCTAssertEqual(paymentMethod?.billingDetails!.name, "Jenny Rosen") + + // AU BECS Debit + XCTAssertEqual(paymentMethod?.auBECSDebit!.bsbNumber, "000000") + XCTAssertEqual(paymentMethod?.auBECSDebit!.last4, "3456") + XCTAssertNotNil(paymentMethod?.auBECSDebit!.fingerprint, "Missing fingerprint") + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodAUBECSDebitTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodAUBECSDebitTests.swift new file mode 100644 index 00000000..efd29c98 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodAUBECSDebitTests.swift @@ -0,0 +1,84 @@ +// +// STPPaymentMethodAUBECSDebitTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 3/4/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +private var kAUBECSDebitPaymentIntentClientSecret = + "pi_1GaRLjF7QokQdxByYgFPQEi0_secret_z76otRQH2jjOIEQYsA9vxhuKn" +class STPPaymentMethodAUBECSDebitTests: XCTestCase { + private(set) var auBECSDebitJSON: [AnyHashable: Any]? + + func _retrieveAUBECSDebitJSON(_ completion: @escaping ([AnyHashable: Any]?) -> Void) { + if let auBECSDebitJSON = auBECSDebitJSON { + completion(auBECSDebitJSON) + } else { + let client = STPAPIClient(publishableKey: STPTestingAUPublishableKey) + client.retrievePaymentIntent( + withClientSecret: kAUBECSDebitPaymentIntentClientSecret, + expand: ["payment_method"] + ) { [self] paymentIntent, _ in + auBECSDebitJSON = paymentIntent?.paymentMethod?.auBECSDebit?.allResponseFields + completion(auBECSDebitJSON ?? [:]) + } + } + } + + func testCorrectParsing() { + let retrieveJSON = XCTestExpectation(description: "Retrieve JSON") + _retrieveAUBECSDebitJSON({ json in + let auBECSDebit = STPPaymentMethodAUBECSDebit.decodedObject(fromAPIResponse: json) + XCTAssertNotNil(auBECSDebit, "Failed to decode JSON") + retrieveJSON.fulfill() + }) + wait(for: [retrieveJSON], timeout: STPTestingNetworkRequestTimeout) + } + + func testFailWithoutRequired() { + var retrieveJSON = XCTestExpectation(description: "Retrieve JSON") + _retrieveAUBECSDebitJSON({ json in + var auBECSDebitJSON = json + auBECSDebitJSON?["bsb_number"] = nil + XCTAssertNil( + STPPaymentMethodAUBECSDebit.decodedObject(fromAPIResponse: auBECSDebitJSON), + "Should not intialize with missing `bsb_number`" + ) + retrieveJSON.fulfill() + }) + wait(for: [retrieveJSON], timeout: STPTestingNetworkRequestTimeout) + + retrieveJSON = XCTestExpectation(description: "Retrieve JSON") + _retrieveAUBECSDebitJSON({ json in + var auBECSDebitJSON = json + auBECSDebitJSON?["last4"] = nil + XCTAssertNil( + STPPaymentMethodAUBECSDebit.decodedObject(fromAPIResponse: auBECSDebitJSON), + "Should not intialize with missing `last4`" + ) + retrieveJSON.fulfill() + }) + wait(for: [retrieveJSON], timeout: STPTestingNetworkRequestTimeout) + + retrieveJSON = XCTestExpectation(description: "Retrieve JSON") + _retrieveAUBECSDebitJSON({ json in + var auBECSDebitJSON = json + auBECSDebitJSON?["fingerprint"] = nil + XCTAssertNil( + STPPaymentMethodAUBECSDebit.decodedObject(fromAPIResponse: auBECSDebitJSON), + "Should not intialize with missing `fingerprint`" + ) + retrieveJSON.fulfill() + }) + wait(for: [retrieveJSON], timeout: STPTestingNetworkRequestTimeout) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodAddressTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodAddressTest.swift new file mode 100644 index 00000000..da7d044a --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodAddressTest.swift @@ -0,0 +1,34 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodAddressTest.m +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 3/6/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +class STPPaymentMethodAddressTest: XCTestCase { + func testDecodedObjectFromAPIResponseRequiredFields() { + let requiredFields: [String]? = [] + + for field in requiredFields ?? [] { + var response = (STPTestUtils.jsonNamed(STPTestJSONPaymentMethodCard)["billing_details"] as! [AnyHashable: Any])["address"] as? [AnyHashable: Any] + response?.removeValue(forKey: field) + + XCTAssertNil(STPPaymentMethodAddress.decodedObject(fromAPIResponse: response)) + } + + XCTAssertNotNil(STPPaymentMethodAddress.decodedObject(fromAPIResponse: (STPTestUtils.jsonNamed(STPTestJSONPaymentMethodCard)["billing_details"] as? [AnyHashable: Any])!["address"] as? [AnyHashable: Any])) + } + + func testDecodedObjectFromAPIResponseMapping() { + let response = (STPTestUtils.jsonNamed(STPTestJSONPaymentMethodCard)["billing_details"] as? [AnyHashable: Any])!["address"] as? [AnyHashable: Any] + let address = STPPaymentMethodAddress.decodedObject(fromAPIResponse: response) + XCTAssertEqual(address?.city, "München") + XCTAssertEqual(address?.country, "DE") + XCTAssertEqual(address?.postalCode, "80337") + XCTAssertEqual(address?.line1, "Marienplatz") + XCTAssertEqual(address?.line2, "8") + XCTAssertEqual(address?.state, "Bayern") + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodAffirmParamsTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodAffirmParamsTest.swift new file mode 100644 index 00000000..29bb9990 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodAffirmParamsTest.swift @@ -0,0 +1,43 @@ +// +// STPPaymentMethodAffirmParamsTest.swift +// StripeiOS Tests +// +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +import StripeCoreTestUtils + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodAffirmParamsTests: XCTestCase { + + func testCreateAffirmPaymentMethod() throws { + let affirmParams = STPPaymentMethodAffirmParams() + + let params = STPPaymentMethodParams( + affirm: affirmParams, + metadata: nil + ) + + let exp = expectation(description: "Payment Method Affirm create") + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + client.createPaymentMethod(with: params) { + (paymentMethod: STPPaymentMethod?, error: Error?) in + exp.fulfill() + + XCTAssertNil(error) + XCTAssertNotNil(paymentMethod, "Payment method should be populated") + XCTAssertEqual(paymentMethod?.type, .affirm, "Incorrect PaymentMethod type") + XCTAssertNotNil(paymentMethod?.affirm, "The `affirm` property must be populated") + } + + self.waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + } + +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodAffirmTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodAffirmTests.swift new file mode 100644 index 00000000..83bb542e --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodAffirmTests.swift @@ -0,0 +1,46 @@ +// +// STPPaymentMethodAffirmTests.swift +// StripeiOS Tests +// +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodAffirmTests: XCTestCase { + + static let affirmPaymentIntentClientSecret = + "pi_3KUFbTFY0qyl6XeW1oDBbiQk_secret_8kdpLx37oa5WMrI2xoXThCK9s" + + func _retrieveAffirmJSON(_ completion: @escaping ([AnyHashable: Any]?) -> Void) { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + client.retrievePaymentIntent( + withClientSecret: Self.affirmPaymentIntentClientSecret, + expand: ["payment_method"] + ) { paymentIntent, _ in + let affirmJson = paymentIntent?.paymentMethod?.affirm?.allResponseFields + XCTAssertNotNil(paymentIntent?.paymentMethod?.affirm) + completion(affirmJson ?? [:]) + } + } + + func testObjectDecoding() { + let retrieveJSON = XCTestExpectation(description: "Retrieve JSON") + + _retrieveAffirmJSON({ json in + let affirm = STPPaymentMethodAffirm.decodedObject(fromAPIResponse: json) + XCTAssertNotNil(affirm, "Failed to decode JSON") + retrieveJSON.fulfill() + }) + + wait(for: [retrieveJSON], timeout: STPTestingNetworkRequestTimeout) + } + +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodAfterpayClearpayParamsTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodAfterpayClearpayParamsTest.swift new file mode 100644 index 00000000..562fddfe --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodAfterpayClearpayParamsTest.swift @@ -0,0 +1,61 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodAfterpayClearpayParamsTest.m +// StripeiOS Tests +// +// Created by Ali Riaz on 1/14/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils + +class STPPaymentMethodAfterpayClearpayParamsTest: XCTestCase { + func testCreateAfterpayClearpayPaymentMethod() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let afterpayClearpayParams = STPPaymentMethodAfterpayClearpayParams() + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jenny Rosen" + billingDetails.email = "jrosen@example.com" + billingDetails.address = STPPaymentMethodAddress() + billingDetails.address!.line1 = "510 Townsend St." + billingDetails.address!.postalCode = "94102" + billingDetails.address!.country = "US" + + let params = STPPaymentMethodParams( + afterpayClearpay: afterpayClearpayParams, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value" + ]) + + let expectation = self.expectation(description: "Payment Method AfterpayClearpay create") + + client.createPaymentMethod(with: params) { paymentMethod, error in + expectation.fulfill() + + XCTAssertNil(error, "Unexpected error creating AfterpayClearpay Payment Method") + XCTAssertNotNil(paymentMethod, "Failed to create AfterpayClearpay PaymentMethod") + XCTAssertNotNil(paymentMethod?.stripeId, "Missing stripeId") + XCTAssertNotNil(paymentMethod?.created, "Missing created") + XCTAssertFalse(paymentMethod!.liveMode, "Incorrect livemode") + XCTAssertEqual(paymentMethod?.type, .afterpayClearpay, "Incorrect PaymentMethod type") + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertNil(paymentMethod?.metadata, "Metadata is not returned.") + // #pragma clang diagnostic pop + + // Billing Details + XCTAssertEqual(paymentMethod?.billingDetails!.email, "jrosen@example.com") + XCTAssertEqual(paymentMethod?.billingDetails!.name, "Jenny Rosen") + XCTAssertEqual(paymentMethod?.billingDetails!.address!.line1, "510 Townsend St.") + XCTAssertEqual(paymentMethod?.billingDetails!.address!.postalCode, "94102") + XCTAssertEqual(paymentMethod?.billingDetails!.address!.country, "US") + + XCTAssertNotNil(paymentMethod?.afterpayClearpay, "") + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodAfterpayClearpayTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodAfterpayClearpayTest.swift new file mode 100644 index 00000000..b31d8c07 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodAfterpayClearpayTest.swift @@ -0,0 +1,37 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodAfterpayClearpayTest.m +// StripeiOS Tests +// +// Created by Ali Riaz on 1/14/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Stripe +import StripeCoreTestUtils + +class STPPaymentMethodAfterpayClearpayTest: XCTestCase { + var afterpayJSON: [AnyHashable: Any]? + + func _retrieveAfterpayJSON(_ completion: @escaping ([AnyHashable: Any]?) -> Void) { + if let afterpayJSON { + completion(afterpayJSON) + } else { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + client.retrievePaymentIntent(withClientSecret: "pi_1HbSAfFY0qyl6XeWRnlezJ7K_secret_t6Ju9Z0hxOvslawK34uC1Wm2b", expand: ["payment_method"]) { paymentIntent, _ in + self.afterpayJSON = paymentIntent?.paymentMethod?.afterpayClearpay?.allResponseFields + completion(self.afterpayJSON) + } + } + } + + func testCorrectParsing() { + let jsonExpectation = XCTestExpectation(description: "Fetch Afterpay Clearpay JSON") + _retrieveAfterpayJSON({ json in + let afterpay = STPPaymentMethodAfterpayClearpay.decodedObject(fromAPIResponse: json) + XCTAssertNotNil(afterpay, "Failed to decode JSON") + jsonExpectation.fulfill() + }) + wait(for: [jsonExpectation], timeout: STPTestingNetworkRequestTimeout) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodBacsDebitTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodBacsDebitTest.swift new file mode 100644 index 00000000..ceeb58b5 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodBacsDebitTest.swift @@ -0,0 +1,36 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodBacsDebitTest.m +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 1/28/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +class STPPaymentMethodBacsDebitTest: XCTestCase { + // MARK: - STPAPIResponseDecodable Tests + + func testDecodedObjectFromAPIResponseRequiredFields() { + let paymentMethodJSON = STPTestUtils.jsonNamed(STPTestJSONPaymentMethodBacsDebit) + let requiredFields: [String]? = [] + + for field in requiredFields ?? [] { + var response = paymentMethodJSON?["bacs_debit"] as? [AnyHashable: Any] + response?.removeValue(forKey: field) + + XCTAssertNil(STPPaymentMethodBacsDebit.decodedObject(fromAPIResponse: response)) + } + + let paymentMethod = STPPaymentMethod.decodedObject(fromAPIResponse: paymentMethodJSON) + XCTAssertNotNil(paymentMethod) + XCTAssertNotNil(paymentMethod?.bacsDebit) + } + + func testDecodedObjectFromAPIResponseMapping() { + let response = STPTestUtils.jsonNamed(STPTestJSONPaymentMethodBacsDebit)["bacs_debit"] as? [AnyHashable: Any] + let bacs = STPPaymentMethodBacsDebit.decodedObject(fromAPIResponse: response) + XCTAssertEqual(bacs?.fingerprint, "9eMbmctOrd8i7DYa") + XCTAssertEqual(bacs?.last4, "2345") + XCTAssertEqual(bacs?.sortCode, "108800") + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodBancontactParamsTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodBancontactParamsTests.swift new file mode 100644 index 00000000..4e9a5671 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodBancontactParamsTests.swift @@ -0,0 +1,49 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodBancontactParamsTests.m +// StripeiOS Tests +// +// Created by Vineet Shah on 4/29/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils + +class STPPaymentMethodBancontactParamsTests: XCTestCase { + func testCreateBancontactPaymentMethod() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let bancontactParams = STPPaymentMethodBancontactParams() + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jane Doe" + + let params = STPPaymentMethodParams( + bancontact: bancontactParams, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value" + ]) + + let expectation = self.expectation(description: "Payment Method Bancontact create") + + client.createPaymentMethod( + with: params) { paymentMethod, error in + expectation.fulfill() + + XCTAssertNil(error, "Unexpected error creating Bancontact PaymentMethod") + XCTAssertNotNil(paymentMethod, "Failed to create Bancontact PaymentMethod") + XCTAssertNotNil(paymentMethod?.stripeId, "Missing stripeId") + XCTAssertNotNil(paymentMethod?.created, "Missing created") + XCTAssertFalse(paymentMethod!.liveMode, "Incorrect livemode") + XCTAssertEqual(paymentMethod?.type, .bancontact, "Incorrect PaymentMethod type") + + // Billing Details + XCTAssertEqual(paymentMethod?.billingDetails!.name, "Jane Doe") + + // Bancontact Details + XCTAssertNotNil(paymentMethod?.bancontact, "Missing Bancontact") + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodBancontactTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodBancontactTests.swift new file mode 100644 index 00000000..16d299cd --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodBancontactTests.swift @@ -0,0 +1,44 @@ +// +// STPPaymentMethodBancontactTests.swift +// StripeiOS Tests +// +// Created by Vineet Shah on 4/29/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodBancontactTests: XCTestCase { + private(set) var bancontactJSON: [AnyHashable: Any]? + + func _retrieveBancontactJSON(_ completion: @escaping ([AnyHashable: Any]?) -> Void) { + if let bancontactJSON = bancontactJSON { + completion(bancontactJSON) + } else { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + client.retrievePaymentIntent( + withClientSecret: "pi_1GdPnbFY0qyl6XeW8Ezvxe87_secret_Fxi2EZBQ0nInHumvvezcTRWF4", + expand: ["payment_method"] + ) { [self] paymentIntent, _ in + bancontactJSON = paymentIntent?.paymentMethod?.bancontact?.allResponseFields + completion(bancontactJSON ?? [:]) + } + } + } + + func testCorrectParsing() { + let expectation = self.expectation(description: "Retrieve payment intent") + _retrieveBancontactJSON({ json in + let bancontact = STPPaymentMethodBancontact.decodedObject(fromAPIResponse: json) + XCTAssertNotNil(bancontact, "Failed to decode JSON") + expectation.fulfill() + }) + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodBillingDetailsTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodBillingDetailsTest.swift new file mode 100644 index 00000000..92c6546a --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodBillingDetailsTest.swift @@ -0,0 +1,34 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodBillingDetailsTest.m +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 3/6/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +class STPPaymentMethodBillingDetailsTest: XCTestCase { + // MARK: - STPAPIResponseDecodable Tests + + func testDecodedObjectFromAPIResponseRequiredFields() { + let requiredFields: [String]? = [] + + for field in requiredFields ?? [] { + var response = STPTestUtils.jsonNamed(STPTestJSONPaymentMethodCard)["billing_details"] as? [AnyHashable: Any] + response?.removeValue(forKey: field) + + XCTAssertNil(STPPaymentMethodBillingDetails.decodedObject(fromAPIResponse: response)) + } + + XCTAssertNotNil(STPPaymentMethodBillingDetails.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed(STPTestJSONPaymentMethodCard)["billing_details"] as? [AnyHashable: Any])) + } + + func testDecodedObjectFromAPIResponseMapping() { + let response = STPTestUtils.jsonNamed(STPTestJSONPaymentMethodCard)["billing_details"] as? [AnyHashable: Any] + let billingDetails = STPPaymentMethodBillingDetails.decodedObject(fromAPIResponse: response) + XCTAssertEqual(billingDetails?.email, "jenny@example.com") + XCTAssertEqual(billingDetails?.name, "jenny") + XCTAssertEqual(billingDetails?.phone, "+15555555555") + XCTAssertNotNil(billingDetails?.address) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodBillingDetailsTests+Link.swift b/Stripe/StripeiOSTests/STPPaymentMethodBillingDetailsTests+Link.swift new file mode 100644 index 00000000..577a3d55 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodBillingDetailsTests+Link.swift @@ -0,0 +1,41 @@ +// +// STPPaymentMethodBillingDetailsTests.swift +// StripeiOSTests +// +// Created by Eduardo Urias on 2/27/23. +// + +@testable import StripePaymentSheet +import XCTest + +// Link mapping tests +final class STPPaymentMethodBillingDetailsTests: XCTestCase { + func testConsumersAPIParamsMapping() { + var billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Name" + billingDetails.email = "Email" + billingDetails.phone = "Phone" + billingDetails.address = STPPaymentMethodAddress() + billingDetails.address?.line1 = "Line 1" + billingDetails.address?.line2 = "" + billingDetails.address?.city = "City" + billingDetails.address?.state = "State" + billingDetails.address?.country = "Country" + + let params = billingDetails.consumersAPIParams + XCTAssertEqual(params["name"] as? String, "Name") + XCTAssertEqual(params["line_1"] as? String, "Line 1") + XCTAssertNil(params["line_2"]) + XCTAssertEqual(params["locality"] as? String, "City") + XCTAssertEqual(params["administrative_area"] as? String, "State") + XCTAssertEqual(params["country_code"] as? String, "Country") + + XCTAssertNil(params["email"]) + XCTAssertNil(params["phone"]) + XCTAssertNil(params["line1"]) + XCTAssertNil(params["line2"]) + XCTAssertNil(params["city"]) + XCTAssertNil(params["state"]) + XCTAssertNil(params["country"]) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodBoletoParamsTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodBoletoParamsTests.swift new file mode 100644 index 00000000..91fe10a0 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodBoletoParamsTests.swift @@ -0,0 +1,71 @@ +// +// STPPaymentMethodBoletoParamsTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 9/9/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodBoletoParamsTests: XCTestCase { + + func testCreateBoletoPaymentMethod() throws { + let boletoParams = STPPaymentMethodBoletoParams() + boletoParams.taxID = "00.000.000/0001-91" + + let address = STPPaymentMethodAddress() + address.line1 = "Av. Do Brasil 1374" + address.city = "Sao Paulo" + address.state = "SP" + address.postalCode = "01310100" + address.country = "BR" + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jane Diaz" + billingDetails.email = "jane@example.com" + billingDetails.address = address + + let params = STPPaymentMethodParams( + boleto: boletoParams, + billingDetails: billingDetails, + metadata: nil + ) + + let exp = expectation(description: "Payment Method Boleto create") + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + client.createPaymentMethod(with: params) { + (paymentMethod: STPPaymentMethod?, error: Error?) in + exp.fulfill() + + XCTAssertNil(error) + XCTAssertNotNil(paymentMethod, "Payment method should be populated") + XCTAssertEqual(paymentMethod?.type, .boleto, "Incorrect PaymentMethod type") + + XCTAssertEqual( + paymentMethod?.billingDetails?.name, + "Jane Diaz", + "Billing name should match the name provided during creation" + ) + + XCTAssertEqual( + paymentMethod?.billingDetails?.email, + "jane@example.com", + "Billing email should match the name provided during creation" + ) + + XCTAssertNotNil(paymentMethod?.boleto, "The `boleto` property must be populated") + } + + self.waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + } + +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodBoletoTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodBoletoTests.swift new file mode 100644 index 00000000..ec93c6b9 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodBoletoTests.swift @@ -0,0 +1,53 @@ +// +// STPPaymentMethodBoletoTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 9/10/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodBoletoTests: XCTestCase { + + private(set) var boletoJSON: [AnyHashable: Any]? + + static let boletoPaymentIntentClientSecret = + "pi_3JYFj9JQVROkWvqT0d2HYaTk_secret_c2PniS4q2A7XhZ9mbFwOTpN08" + + func _retrieveBoletoJSON(_ completion: @escaping ([AnyHashable: Any]?) -> Void) { + if let boletoJSON = boletoJSON { + completion(boletoJSON) + } else { + let client = STPAPIClient(publishableKey: STPTestingBRPublishableKey) + client.retrievePaymentIntent( + withClientSecret: Self.boletoPaymentIntentClientSecret, + expand: ["payment_method"] + ) { [self] paymentIntent, _ in + boletoJSON = paymentIntent?.paymentMethod?.boleto?.allResponseFields + completion(boletoJSON ?? [:]) + } + } + } + + func testObjectDecoding() { + let retrieveJSON = XCTestExpectation(description: "Retrieve JSON") + + _retrieveBoletoJSON({ json in + let boleto = STPPaymentMethodBoleto.decodedObject(fromAPIResponse: json) + XCTAssertNotNil(boleto, "Failed to decode JSON") + XCTAssertEqual(boleto?.taxID, "00.000.000/0001-91", "It must properly decode `taxID`") + retrieveJSON.fulfill() + }) + + wait(for: [retrieveJSON], timeout: STPTestingNetworkRequestTimeout) + } + +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodCardChecksTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodCardChecksTest.swift new file mode 100644 index 00000000..164dde11 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodCardChecksTest.swift @@ -0,0 +1,45 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodCardChecksTest.m +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 3/5/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +@testable import StripePayments +import XCTest + +class STPPaymentMethodCardChecksTest: XCTestCase { + func testDecodedObjectFromAPIResponse() { + let response = [ + "address_line1_check": NSNull(), + "address_postal_code_check": NSNull(), + "cvc_check": NSNull(), + ] + let requiredFields: [String]? = [] + + for field in requiredFields ?? [] { + var mutableResponse = response + mutableResponse.removeValue(forKey: field) + XCTAssertNil(STPPaymentMethodCardChecks.decodedObject(fromAPIResponse: mutableResponse)) + } + let checks = STPPaymentMethodCardChecks.decodedObject(fromAPIResponse: response) + XCTAssertNotNil(checks) + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertEqual(checks?.addressLine1Check, .unknown) + XCTAssertEqual(checks?.addressPostalCodeCheck, .unknown) + XCTAssertEqual(checks?.cvcCheck, .unknown) + // #pragma clang diagnostic pop + } + + func testCheckResultFromString() { + XCTAssertEqual(STPPaymentMethodCardChecks.checkResult(from: "pass"), .pass) + XCTAssertEqual(STPPaymentMethodCardChecks.checkResult(from: "failed"), .failed) + XCTAssertEqual(STPPaymentMethodCardChecks.checkResult(from: "unavailable"), .unavailable) + XCTAssertEqual(STPPaymentMethodCardChecks.checkResult(from: "unchecked"), .unchecked) + XCTAssertEqual(STPPaymentMethodCardChecks.checkResult(from: "unknown_string"), .unknown) + XCTAssertEqual(STPPaymentMethodCardChecks.checkResult(from: nil), .unknown) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodCardParamsTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodCardParamsTest.swift new file mode 100644 index 00000000..b684214e --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodCardParamsTest.swift @@ -0,0 +1,99 @@ +// +// STPPaymentMethodCardParamsTest.swift +// StripeiOS Tests +// +// Created by David Estes on 2/10/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodCardParamsTest: XCTestCase { + func testEqualityCheck() { + let params1 = STPPaymentMethodCardParams() + params1.number = "4242424242424242" + params1.cvc = "123" + params1.expYear = 22 + params1.expMonth = 12 + params1.networks = .init(preferred: "visa") + let params2 = STPPaymentMethodCardParams() + params2.number = "4242424242424242" + params2.cvc = "123" + params2.expYear = 22 + params2.expMonth = 12 + params2.networks = .init(preferred: "visa") + XCTAssertEqual(params1, params2) + params1.additionalAPIParameters["test"] = "bla" + XCTAssertNotEqual(params1, params2) + params2.additionalAPIParameters["test"] = "bla" + XCTAssertEqual(params1, params2) + } + + func testCardParamsFromPaymentMethodParams() { + let pmCardParams = STPPaymentMethodCardParams() + pmCardParams.number = "4242424242424242" + pmCardParams.cvc = "123" + pmCardParams.expYear = 22 + pmCardParams.expMonth = 12 + let addressParams = STPPaymentMethodBillingDetails() + addressParams.name = "Tester McTestington" + let address = STPPaymentMethodAddress() + address.line1 = "123 Fake St" + address.line2 = "Apt 123" + address.city = "City" + address.state = "NY" + address.country = "US" + address.postalCode = "12345" + addressParams.address = address + let pmParams = STPPaymentMethodParams( + card: pmCardParams, + billingDetails: addressParams, + metadata: nil + ) + let cardParams = STPCardParams(paymentMethodParams: pmParams) + XCTAssertEqual(cardParams.number, "4242424242424242") + XCTAssertEqual(cardParams.cvc, "123") + XCTAssertEqual(cardParams.expYear, 22) + XCTAssertEqual(cardParams.expMonth, 12) + XCTAssertEqual(cardParams.name, "Tester McTestington") + XCTAssertEqual(cardParams.addressLine1, "123 Fake St") + XCTAssertEqual(cardParams.addressLine2, "Apt 123") + XCTAssertEqual(cardParams.addressCity, "City") + XCTAssertEqual(cardParams.addressState, "NY") + XCTAssertEqual(cardParams.addressCountry, "US") + XCTAssertEqual(cardParams.addressZip, "12345") + } + + func testPropertyNamesToFormFieldsMapping() { + // Test for STPPaymentMethodCardParams + let cardParams = STPPaymentMethodCardParams() + + let cardParamsExpectedMapping = [ + "number": "number", + "expMonth": "exp_month", + "expYear": "exp_year", + "cvc": "cvc", + "token": "token", + "networks": "networks", + ] + + let cardParamsMapping = type(of: cardParams).propertyNamesToFormFieldNamesMapping() + + XCTAssertEqual(cardParamsMapping, cardParamsExpectedMapping) + + // Test for STPPaymentMethodCardNetworksParams + let networksParams = STPPaymentMethodCardNetworksParams() + + let networksParamsExpectedMapping = [ + "preferred": "preferred", + ] + + let networksParamsMapping = type(of: networksParams).propertyNamesToFormFieldNamesMapping() + + XCTAssertEqual(networksParamsMapping, networksParamsExpectedMapping) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodCardTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodCardTest.swift new file mode 100644 index 00000000..b5a12964 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodCardTest.swift @@ -0,0 +1,125 @@ +// +// STPPaymentMethodCardTest.swift +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 3/6/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// +import StripeCoreTestUtils + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +private let kCardPaymentIntentClientSecret = + "pi_1H5J4RFY0qyl6XeWFTpgue7g_secret_1SS59M0x65qWMaX2wEB03iwVE" + +class STPPaymentMethodCardTest: XCTestCase { + private(set) var cardJSON: [AnyHashable: Any]? + + func _retrieveCardJSON(_ completion: @escaping ([AnyHashable: Any]?) -> Void) { + if let cardJSON = cardJSON { + completion(cardJSON) + } else { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + client.retrievePaymentIntent( + withClientSecret: kCardPaymentIntentClientSecret, + expand: ["payment_method"] + ) { [self] paymentIntent, _ in + cardJSON = paymentIntent?.paymentMethod?.card?.allResponseFields + completion(cardJSON ?? [:]) + } + } + } + + func testCorrectParsing() { + let retrieveJSON = XCTestExpectation(description: "Retrieve JSON") + _retrieveCardJSON({ json in + let card = STPPaymentMethodCard.decodedObject(fromAPIResponse: json) + XCTAssertNotNil(card, "Failed to decode JSON") + retrieveJSON.fulfill() + XCTAssertEqual(card?.brand, .visa) + XCTAssertEqual(card?.country, "US") + XCTAssertNotNil(card?.checks) + XCTAssertEqual(card?.expMonth, 7) + XCTAssertEqual(card?.expYear, 2021) + XCTAssertEqual(card?.funding, "credit") + XCTAssertEqual(card?.last4, "4242") + XCTAssertNotNil(card?.threeDSecureUsage) + XCTAssertEqual(card?.threeDSecureUsage?.supported, true) + XCTAssertNotNil(card?.networks) + XCTAssertEqual(card?.networks?.available, ["visa"]) + XCTAssertNil(card?.networks?.preferred) + }) + wait(for: [retrieveJSON], timeout: STPTestingNetworkRequestTimeout) + } + + func testDecodedObjectFromAPIResponseRequiredFields() { + let requiredFields: [String]? = [] + + for field in requiredFields ?? [] { + var response = + STPTestUtils.jsonNamed(STPTestJSONPaymentMethodCard)?["card"] as? [AnyHashable: Any] + response?.removeValue(forKey: field) + + XCTAssertNil(STPPaymentMethodCard.decodedObject(fromAPIResponse: response)) + } + let json = STPTestUtils.jsonNamed(STPTestJSONPaymentMethodCard)?["card"] + let decoded = STPPaymentMethodCard.decodedObject( + fromAPIResponse: json as? [AnyHashable: Any] + ) + XCTAssertNotNil(decoded) + } + + func testDecodedObjectFromAPIResponseMapping() { + let response = + STPTestUtils.jsonNamed(STPTestJSONPaymentMethodCard)?["card"] as? [AnyHashable: Any] + let card = STPPaymentMethodCard.decodedObject(fromAPIResponse: response) + XCTAssertEqual(card?.brand, .visa) + XCTAssertEqual(card?.country, "US") + XCTAssertNotNil(card?.checks) + XCTAssertEqual(card?.expMonth, 8) + XCTAssertEqual(card?.expYear, 2020) + XCTAssertEqual(card?.funding, "credit") + XCTAssertEqual(card?.last4, "4242") + XCTAssertEqual(card?.fingerprint, "6gVyxfIhqc8Z0g0X") + XCTAssertNotNil(card?.threeDSecureUsage) + XCTAssertEqual(card?.threeDSecureUsage?.supported, true) + XCTAssertNotNil(card?.wallet) + } + + func testBrandFromString() { + XCTAssertEqual(STPPaymentMethodCard.brand(from: "visa"), .visa) + XCTAssertEqual(STPPaymentMethodCard.brand(from: "VISA"), .visa) + + XCTAssertEqual(STPPaymentMethodCard.brand(from: "amex"), .amex) + XCTAssertEqual(STPPaymentMethodCard.brand(from: "AMEX"), .amex) + XCTAssertEqual(STPPaymentMethodCard.brand(from: "american_express"), .amex) + XCTAssertEqual(STPPaymentMethodCard.brand(from: "AMERICAN_EXPRESS"), .amex) + + XCTAssertEqual(STPPaymentMethodCard.brand(from: "mastercard"), .mastercard) + XCTAssertEqual(STPPaymentMethodCard.brand(from: "MASTERCARD"), .mastercard) + + XCTAssertEqual(STPPaymentMethodCard.brand(from: "discover"), .discover) + XCTAssertEqual(STPPaymentMethodCard.brand(from: "DISCOVER"), .discover) + + XCTAssertEqual(STPPaymentMethodCard.brand(from: "jcb"), .JCB) + XCTAssertEqual(STPPaymentMethodCard.brand(from: "JCB"), .JCB) + + XCTAssertEqual(STPPaymentMethodCard.brand(from: "diners"), .dinersClub) + XCTAssertEqual(STPPaymentMethodCard.brand(from: "DINERS"), .dinersClub) + XCTAssertEqual(STPPaymentMethodCard.brand(from: "diners_club"), .dinersClub) + XCTAssertEqual(STPPaymentMethodCard.brand(from: "DINERS_CLUB"), .dinersClub) + + XCTAssertEqual(STPPaymentMethodCard.brand(from: "unionpay"), .unionPay) + XCTAssertEqual(STPPaymentMethodCard.brand(from: "UNIONPAY"), .unionPay) + + XCTAssertEqual(STPPaymentMethodCard.brand(from: "unknown"), .unknown) + XCTAssertEqual(STPPaymentMethodCard.brand(from: "UNKNOWN"), .unknown) + + XCTAssertEqual(STPPaymentMethodCard.brand(from: "garbage"), .unknown) + XCTAssertEqual(STPPaymentMethodCard.brand(from: "GARBAGE"), .unknown) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodCardWalletMasterpassTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodCardWalletMasterpassTest.swift new file mode 100644 index 00000000..7054d01b --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodCardWalletMasterpassTest.swift @@ -0,0 +1,21 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodCardWalletMasterpassTest.m +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 3/9/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +class STPPaymentMethodCardWalletMasterpassTest: XCTestCase { + func testDecodedObjectFromAPIResponseMapping() { + // We reuse the visa checkout JSON because it's identical to the masterpass version + let response = ((STPTestUtils.jsonNamed(STPTestJSONPaymentMethodCard)["card"] as! [AnyHashable: Any])["wallet"] as! [AnyHashable: Any])["visa_checkout"] as? [AnyHashable: Any] + let masterpass = STPPaymentMethodCardWalletMasterpass.decodedObject(fromAPIResponse: response) + XCTAssertNotNil(masterpass) + XCTAssertEqual(masterpass?.name, "Jenny") + XCTAssertEqual(masterpass?.email, "jenny@example.com") + XCTAssertNotNil(masterpass?.billingAddress) + XCTAssertNotNil(masterpass?.shippingAddress) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodCardWalletTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodCardWalletTest.swift new file mode 100644 index 00000000..59b18820 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodCardWalletTest.swift @@ -0,0 +1,38 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodCardWalletTest.m +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 3/9/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +@testable import StripePayments + +class STPPaymentMethodCardWalletTest: XCTestCase { + // MARK: - STPPaymentMethodCardWalletType Tests + + func testTypeFromString() { + XCTAssertEqual(STPPaymentMethodCardWallet.type(from: "amex_express_checkout"), .amexExpressCheckout) + XCTAssertEqual(STPPaymentMethodCardWallet.type(from: "AMEX_EXPRESS_CHECKOUT"), .amexExpressCheckout) + XCTAssertEqual(STPPaymentMethodCardWallet.type(from: "apple_pay"), .applePay) + XCTAssertEqual(STPPaymentMethodCardWallet.type(from: "APPLE_PAY"), .applePay) + XCTAssertEqual(STPPaymentMethodCardWallet.type(from: "google_pay"), .googlePay) + XCTAssertEqual(STPPaymentMethodCardWallet.type(from: "GOOGLE_PAY"), .googlePay) + XCTAssertEqual(STPPaymentMethodCardWallet.type(from: "masterpass"), .masterpass) + XCTAssertEqual(STPPaymentMethodCardWallet.type(from: "MASTERPASS"), .masterpass) + XCTAssertEqual(STPPaymentMethodCardWallet.type(from: "samsung_pay"), .samsungPay) + XCTAssertEqual(STPPaymentMethodCardWallet.type(from: "SAMSUNG_PAY"), .samsungPay) + XCTAssertEqual(STPPaymentMethodCardWallet.type(from: "visa_checkout"), .visaCheckout) + XCTAssertEqual(STPPaymentMethodCardWallet.type(from: "VISA_CHECKOUT"), .visaCheckout) + } + + // MARK: - STPAPIResponseDecodable Tests + + func testDecodedObjectFromAPIResponseMapping() { + let response = (STPTestUtils.jsonNamed(STPTestJSONPaymentMethodCard)["card"] as! [AnyHashable: Any]) ["wallet"] as? [AnyHashable: Any] + let wallet = STPPaymentMethodCardWallet.decodedObject(fromAPIResponse: response) + XCTAssertNotNil(wallet) + XCTAssertEqual(wallet?.type, .visaCheckout) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodCardWalletVisaCheckoutTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodCardWalletVisaCheckoutTest.swift new file mode 100644 index 00000000..c389521f --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodCardWalletVisaCheckoutTest.swift @@ -0,0 +1,20 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodCardWalletVisaCheckoutTest.m +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 3/9/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +class STPPaymentMethodCardWalletVisaCheckoutTest: XCTestCase { + func testDecodedObjectFromAPIResponseMapping() { + let response = ((STPTestUtils.jsonNamed(STPTestJSONPaymentMethodCard)["card"] as! [AnyHashable: Any])["wallet"] as! [AnyHashable: Any])["visa_checkout"] as? [AnyHashable: Any] + let visaCheckout = STPPaymentMethodCardWalletVisaCheckout.decodedObject(fromAPIResponse: response) + XCTAssertNotNil(visaCheckout) + XCTAssertEqual(visaCheckout?.name, "Jenny") + XCTAssertEqual(visaCheckout?.email, "jenny@example.com") + XCTAssertNotNil(visaCheckout?.billingAddress) + XCTAssertNotNil(visaCheckout?.shippingAddress) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodCashAppParamsTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodCashAppParamsTests.swift new file mode 100644 index 00000000..c9674abc --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodCashAppParamsTests.swift @@ -0,0 +1,44 @@ +// +// STPPaymentMethodCashAppParamsTests.swift +// StripeiOSTests +// +// Created by Nick Porter on 1/4/23. +// + +import Foundation +import StripeCoreTestUtils + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodCashAppParamsTests: XCTestCase { + + func testCreateCashAppPaymentMethod() throws { + let cashAppParams = STPPaymentMethodCashAppParams() + + let params = STPPaymentMethodParams( + cashApp: cashAppParams, + billingDetails: nil, + metadata: nil + ) + + let exp = expectation(description: "Payment Method Cash App create") + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + client.createPaymentMethod(with: params) { + (paymentMethod: STPPaymentMethod?, error: Error?) in + exp.fulfill() + + XCTAssertNil(error) + XCTAssertNotNil(paymentMethod, "Payment method should be populated") + XCTAssertEqual(paymentMethod?.type, .cashApp, "Incorrect PaymentMethod type") + XCTAssertNotNil(paymentMethod?.cashApp, "The `cashApp` property must be populated") + } + + self.waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + } + +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodCashAppTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodCashAppTests.swift new file mode 100644 index 00000000..36502dd6 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodCashAppTests.swift @@ -0,0 +1,40 @@ +// +// STPPaymentMethodCashAppTests.swift +// StripeiOSTests +// +// Created by Nick Porter on 1/4/23. +// + +@testable import Stripe +import StripeCoreTestUtils +import XCTest + +class STPPaymentMethodCashAppTests: XCTestCase { + + static let cashAppPaymentIntentClientSecret = "pi_3MMa4NFY0qyl6XeW1FM3HOts_secret_b4HQ5YksK3mfe7zZaxBlWCark" + + func _retrieveCashAppJSON(_ completion: @escaping ([AnyHashable: Any]?) -> Void) { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + client.retrievePaymentIntent( + withClientSecret: Self.cashAppPaymentIntentClientSecret, + expand: ["payment_method"] + ) { paymentIntent, _ in + XCTAssertNotNil(paymentIntent?.paymentMethod?.allResponseFields["cashapp"]) + let cashAppJson = try? XCTUnwrap(paymentIntent?.paymentMethod?.cashApp?.allResponseFields) + completion(cashAppJson) + } + } + + func testObjectDecoding() { + let retrieveJSON = XCTestExpectation(description: "Retrieve JSON") + + _retrieveCashAppJSON({ json in + let cashApp = STPPaymentMethodCashApp.decodedObject(fromAPIResponse: json) + XCTAssertNotNil(cashApp, "Failed to decode JSON") + retrieveJSON.fulfill() + }) + + wait(for: [retrieveJSON], timeout: STPTestingNetworkRequestTimeout) + } + +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodEPSParamsTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodEPSParamsTests.swift new file mode 100644 index 00000000..aa169d93 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodEPSParamsTests.swift @@ -0,0 +1,50 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodEPSParamsTests.m +// StripeiOS Tests +// +// Created by Shengwei Wu on 5/15/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import StripeCore +import StripeCoreTestUtils + +class STPPaymentMethodEPSParamsTests: XCTestCase { + func testCreateEPSPaymentMethod() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let epsParams = STPPaymentMethodEPSParams() + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jenny Rosen" + + let params = STPPaymentMethodParams( + eps: epsParams, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value" + ]) + + let expectation = self.expectation(description: "Payment Method EPS create") + + client.createPaymentMethod( + with: params) { paymentMethod, error in + expectation.fulfill() + + XCTAssertNil(error, "Unexpected error creating EPS PaymentMethod") + XCTAssertNotNil(paymentMethod, "Failed to create EPS PaymentMethod") + XCTAssertNotNil(paymentMethod?.stripeId, "Missing stripeId") + XCTAssertNotNil(paymentMethod?.created, "Missing created") + XCTAssertFalse(paymentMethod!.liveMode, "Incorrect livemode") + XCTAssertEqual(paymentMethod?.type, .EPS, "Incorrect PaymentMethod type") + + // Billing Details + XCTAssertEqual(paymentMethod?.billingDetails!.name, "Jenny Rosen") + + // EPS Details + XCTAssertNotNil(paymentMethod?.eps, "Missing eps") + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodEPSTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodEPSTests.swift new file mode 100644 index 00000000..b5e4d814 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodEPSTests.swift @@ -0,0 +1,43 @@ +// +// STPPaymentMethodEPSTests.swift +// StripeiOS Tests +// +// Created by Shengwei Wu on 5/15/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// +import StripeCoreTestUtils + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodEPSTests: XCTestCase { + private(set) var epsJSON: [AnyHashable: Any]? + + func _retrieveEPSJSON(_ completion: @escaping ([AnyHashable: Any]?) -> Void) { + if let epsJSON = epsJSON { + completion(epsJSON) + } else { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + client.retrievePaymentIntent( + withClientSecret: "pi_1Gj0rqFY0qyl6XeWrug30CPz_secret_tKyf8QOKtiIrE3NSEkWCkBbyy", + expand: ["payment_method"] + ) { [self] paymentIntent, _ in + epsJSON = paymentIntent?.paymentMethod?.eps?.allResponseFields + completion(epsJSON ?? [:]) + } + } + } + + func testCorrectParsing() { + let jsonExpectation = XCTestExpectation(description: "Fetch EPS JSON") + _retrieveEPSJSON({ json in + let eps = STPPaymentMethodEPS.decodedObject(fromAPIResponse: json) + XCTAssertNotNil(eps, "Failed to decode JSON") + jsonExpectation.fulfill() + }) + wait(for: [jsonExpectation], timeout: STPTestingNetworkRequestTimeout) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodFPXTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodFPXTest.swift new file mode 100644 index 00000000..81a19c9c --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodFPXTest.swift @@ -0,0 +1,35 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodFPXTest.m +// StripeiOS Tests +// +// Created by David Estes on 8/26/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +class STPPaymentMethodFPXTest: XCTestCase { + func exampleJson() -> [AnyHashable: Any]? { + return [ + "bank": "maybank2u", + ] + } + + func testDecodedObjectFromAPIResponseRequiredFields() { + let requiredFields: [String]? = [] + + for field in requiredFields ?? [] { + var response = exampleJson() + response?.removeValue(forKey: field) + + XCTAssertNil(STPPaymentMethodFPX.decodedObject(fromAPIResponse: response)) + } + + XCTAssertNotNil(STPPaymentMethodFPX.decodedObject(fromAPIResponse: exampleJson())) + } + + func testDecodedObjectFromAPIResponseMapping() { + let response = exampleJson() + let fpx = STPPaymentMethodFPX.decodedObject(fromAPIResponse: response) + XCTAssertEqual(fpx?.bankIdentifierCode, "maybank2u") + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodFunctionalTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodFunctionalTest.swift new file mode 100644 index 00000000..741e1899 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodFunctionalTest.swift @@ -0,0 +1,171 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodFunctionalTest.m +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 3/6/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +import Stripe +import StripeCoreTestUtils + +class STPPaymentMethodFunctionalTest: XCTestCase { + override func setUp() { + super.setUp() + } + + func testCreateCardPaymentMethod() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let card = STPPaymentMethodCardParams() + card.number = "4242424242424242" + card.expMonth = NSNumber(value: 10) + card.expYear = NSNumber(value: 2028) + card.cvc = "100" + + let billingAddress = STPPaymentMethodAddress() + billingAddress.city = "San Francisco" + billingAddress.country = "US" + billingAddress.line1 = "150 Townsend St" + billingAddress.line2 = "4th Floor" + billingAddress.postalCode = "94103" + billingAddress.state = "CA" + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.address = billingAddress + billingDetails.email = "email@email.com" + billingDetails.name = "Isaac Asimov" + billingDetails.phone = "555-555-5555" + + let params = STPPaymentMethodParams( + card: card, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value", + ]) + let expectation = self.expectation(description: "Payment Method Card create") + client.createPaymentMethod( + with: params) { paymentMethod, error in + XCTAssertNil(error) + XCTAssertNotNil(paymentMethod) + XCTAssertNotNil(paymentMethod?.stripeId) + XCTAssertNotNil(paymentMethod?.created) + XCTAssertFalse(paymentMethod!.liveMode) + XCTAssertEqual(paymentMethod?.type, .card) + + // Billing Details + XCTAssertEqual(paymentMethod?.billingDetails!.email, "email@email.com") + XCTAssertEqual(paymentMethod?.billingDetails!.name, "Isaac Asimov") + XCTAssertEqual(paymentMethod?.billingDetails!.phone, "555-555-5555") + + // Billing Details Address + XCTAssertEqual(paymentMethod?.billingDetails!.address!.line1, "150 Townsend St") + XCTAssertEqual(paymentMethod?.billingDetails!.address!.line2, "4th Floor") + XCTAssertEqual(paymentMethod?.billingDetails!.address!.city, "San Francisco") + XCTAssertEqual(paymentMethod?.billingDetails!.address!.country, "US") + XCTAssertEqual(paymentMethod?.billingDetails!.address!.state, "CA") + XCTAssertEqual(paymentMethod?.billingDetails!.address!.postalCode, "94103") + + // Card + XCTAssertEqual(paymentMethod?.card!.brand, .visa) + XCTAssertEqual(paymentMethod?.card!.checks!.cvcCheck, .unknown) + XCTAssertEqual(paymentMethod?.card!.checks!.addressLine1Check, .unknown) + XCTAssertEqual(paymentMethod?.card!.checks!.addressPostalCodeCheck, .unknown) + XCTAssertEqual(paymentMethod?.card!.country, "US") + XCTAssertEqual(paymentMethod?.card!.expMonth, 10) + XCTAssertEqual(paymentMethod?.card!.expYear, 2028) + XCTAssertEqual(paymentMethod?.card!.funding, "credit") + XCTAssertEqual(paymentMethod?.card!.last4, "4242") + XCTAssertTrue(paymentMethod!.card!.threeDSecureUsage!.supported) + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateBacsPaymentMethod() { + let client = STPAPIClient(publishableKey: "pk_test_z6Ct4bpx0NUjHii0rsi4XZBf00jmM8qA28") + + let bacs = STPPaymentMethodBacsDebitParams() + bacs.sortCode = "108800" + bacs.accountNumber = "00012345" + + let billingAddress = STPPaymentMethodAddress() + billingAddress.city = "London" + billingAddress.country = "GB" + billingAddress.line1 = "Stripe, 7th Floor The Bower Warehouse" + billingAddress.postalCode = "EC1V 9NR" + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.address = billingAddress + billingDetails.email = "email@email.com" + billingDetails.name = "Isaac Asimov" + billingDetails.phone = "555-555-5555" + + let params = STPPaymentMethodParams(bacsDebit: bacs, billingDetails: billingDetails, metadata: nil) + let expectation = self.expectation(description: "Payment Method create") + client.createPaymentMethod( + with: params) { paymentMethod, error in + XCTAssertNil(error) + XCTAssertNotNil(paymentMethod) + XCTAssertEqual(paymentMethod?.type, .bacsDebit) + + // Bacs Debit + XCTAssertEqual(paymentMethod!.bacsDebit!.fingerprint, "UkSG0HfCGxxrja1H") + XCTAssertEqual(paymentMethod!.bacsDebit!.last4, "2345") + XCTAssertEqual(paymentMethod!.bacsDebit!.sortCode, "108800") + expectation.fulfill() + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCreateAlipayPaymentMethod() { + let client = STPAPIClient(publishableKey: "pk_test_JBVAMwnBuzCdmsgN34jfxbU700LRiPqVit") + + let params = STPPaymentMethodParams(alipay: STPPaymentMethodAlipayParams(), billingDetails: nil, metadata: nil) + + let expectation = self.expectation(description: "Payment Method create") + client.createPaymentMethod( + with: params) { paymentMethod, error in + XCTAssertNil(error) + XCTAssertNotNil(paymentMethod) + XCTAssertEqual(paymentMethod?.type, .alipay) + expectation.fulfill() + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCreateBLIKPaymentMethod() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let params = STPPaymentMethodParams(blik: STPPaymentMethodBLIKParams(), billingDetails: nil, metadata: nil) + + let expectation = self.expectation(description: "Payment Method create") + client.createPaymentMethod( + with: params) { paymentMethod, error in + XCTAssertNil(error) + XCTAssertNotNil(paymentMethod) + XCTAssertEqual(paymentMethod?.type, .blik + ) + XCTAssertNotNil(paymentMethod?.blik) + expectation.fulfill() + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCreateMobilePayPaymentMethod() { + let client = STPAPIClient(publishableKey: STPTestingFRPublishableKey) + let params = STPPaymentMethodParams(mobilePay: STPPaymentMethodMobilePayParams(), billingDetails: nil, metadata: nil) + let expectation = self.expectation(description: "Payment Method create") + client.createPaymentMethod(with: params) { paymentMethod, error in + XCTAssertNil(error) + XCTAssertNotNil(paymentMethod) + XCTAssertEqual(paymentMethod?.type, .mobilePay) + expectation.fulfill() + } + waitForExpectations(timeout: 5, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodGiropayParamsTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodGiropayParamsTests.swift new file mode 100644 index 00000000..1b050416 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodGiropayParamsTests.swift @@ -0,0 +1,49 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodGiropayParamsTests.m +// StripeiOS Tests +// +// Created by Cameron Sabol on 4/21/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils + +class STPPaymentMethodGiropayParamsTests: XCTestCase { + func testCreateGiropayPaymentMethod() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let giropayParams = STPPaymentMethodGiropayParams() + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jenny Rosen" + + let params = STPPaymentMethodParams( + giropay: giropayParams, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value" + ]) + + let expectation = self.expectation(description: "Payment Method giropay create") + + client.createPaymentMethod( + with: params) { paymentMethod, error in + expectation.fulfill() + + XCTAssertNil(error, "Unexpected error creating giropay PaymentMethod") + XCTAssertNotNil(paymentMethod, "Failed to create giropay PaymentMethod") + XCTAssertNotNil(paymentMethod?.stripeId, "Missing stripeId") + XCTAssertNotNil(paymentMethod?.created, "Missing created") + XCTAssertFalse(paymentMethod!.liveMode, "Incorrect livemode") + XCTAssertEqual(paymentMethod?.type, .giropay, "Incorrect PaymentMethod type") + + // Billing Details + XCTAssertEqual(paymentMethod?.billingDetails!.name, "Jenny Rosen") + + // giropay Details + XCTAssertNotNil(paymentMethod?.giropay, "Missing giropay") + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodGiropayTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodGiropayTests.swift new file mode 100644 index 00000000..5e421c4f --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodGiropayTests.swift @@ -0,0 +1,43 @@ +// +// STPPaymentMethodGiropayTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 4/21/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// +import StripeCoreTestUtils + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodGiropayTests: XCTestCase { + private(set) var giropayJSON: [AnyHashable: Any]? + + func _retrieveGiropayDebitJSON(_ completion: @escaping ([AnyHashable: Any]?) -> Void) { + if let giropayJSON = giropayJSON { + completion(giropayJSON) + } else { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + client.retrievePaymentIntent( + withClientSecret: "pi_1GfsdtFY0qyl6XeWLnepIXCI_secret_bLJRSeSY7fBjDXnwh9BUKilMW", + expand: ["payment_method"] + ) { [self] paymentIntent, _ in + giropayJSON = paymentIntent?.paymentMethod?.giropay?.allResponseFields + completion(giropayJSON ?? [:]) + } + } + } + + func testCorrectParsing() { + let jsonExpectation = XCTestExpectation(description: "Fetch Giropay JSON") + _retrieveGiropayDebitJSON({ json in + let giropay = STPPaymentMethodGiropay.decodedObject(fromAPIResponse: json) + XCTAssertNotNil(giropay, "Failed to decode JSON") + jsonExpectation.fulfill() + }) + wait(for: [jsonExpectation], timeout: STPTestingNetworkRequestTimeout) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodGrabPayParamsTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodGrabPayParamsTest.swift new file mode 100644 index 00000000..0d954d7f --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodGrabPayParamsTest.swift @@ -0,0 +1,50 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodGrabPayParamsTest.m +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 7/21/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import StripeCore +import StripeCoreTestUtils + +class STPPaymentMethodGrabPayParamsTest: XCTestCase { + func testCreateGrabPayPaymentMethod() { + let client = STPAPIClient(publishableKey: STPTestingSGPublishableKey) + let grabPayParams = STPPaymentMethodGrabPayParams() + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jenny Rosen" + + let params = STPPaymentMethodParams( + grabPay: grabPayParams, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value" + ]) + + let expectation = self.expectation(description: "Payment Method GrabPay create") + + client.createPaymentMethod( + with: params) { paymentMethod, error in + expectation.fulfill() + + XCTAssertNil(error, "Unexpected error creating GrabPay PaymentMethod") + XCTAssertNotNil(paymentMethod, "Failed to create GrabPay PaymentMethod") + XCTAssertNotNil(paymentMethod?.stripeId, "Missing stripeId") + XCTAssertNotNil(paymentMethod?.created, "Missing created") + XCTAssertFalse(paymentMethod!.liveMode, "Incorrect livemode") + XCTAssertEqual(paymentMethod?.type, .grabPay, "Incorrect PaymentMethod type") + + // Billing Details + XCTAssertEqual(paymentMethod?.billingDetails!.name, "Jenny Rosen") + + // GrabPay Details + XCTAssertNotNil(paymentMethod?.grabPay, "Missing grabPay") + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodKlarnaParamsTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodKlarnaParamsTests.swift new file mode 100644 index 00000000..93a87e7c --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodKlarnaParamsTests.swift @@ -0,0 +1,71 @@ +// +// STPPaymentMethodKlarnaParamsTests.swift +// StripeiOS Tests +// +// Created by Nick Porter on 10/20/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import StripeCoreTestUtils + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodKlarnaParamsTests: XCTestCase { + + func testCreateKlarnaPaymentMethod() throws { + let klarnaParams = STPPaymentMethodKlarnaParams() + + let address = STPPaymentMethodAddress() + address.line1 = "55 John St" + address.line2 = "#3B" + address.city = "New York" + address.state = "NY" + address.postalCode = "10002" + address.country = "US" + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "John Smith" + billingDetails.email = "foo@example.com" + billingDetails.address = address + + let params = STPPaymentMethodParams( + klarna: klarnaParams, + billingDetails: billingDetails, + metadata: nil + ) + + let exp = expectation(description: "Payment Method Klarna create") + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + client.createPaymentMethod(with: params) { + (paymentMethod: STPPaymentMethod?, error: Error?) in + exp.fulfill() + + XCTAssertNil(error) + XCTAssertNotNil(paymentMethod, "Payment method should be populated") + XCTAssertEqual(paymentMethod?.type, .klarna, "Incorrect PaymentMethod type") + + XCTAssertEqual( + paymentMethod?.billingDetails?.name, + "John Smith", + "Billing name should match the name provided during creation" + ) + + XCTAssertEqual( + paymentMethod?.billingDetails?.email, + "foo@example.com", + "Billing email should match the name provided during creation" + ) + + XCTAssertNotNil(paymentMethod?.klarna, "The `klarna` property must be populated") + } + + self.waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + } + +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodKlarnaTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodKlarnaTests.swift new file mode 100644 index 00000000..ba626aa7 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodKlarnaTests.swift @@ -0,0 +1,46 @@ +// +// STPPaymentMethodKlarnaTests.swift +// StripeiOS Tests +// +// Created by Nick Porter on 10/21/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodKlarnaTests: XCTestCase { + + static let klarnaPaymentIntentClientSecret = + "pi_3Jn3kUFY0qyl6XeW0mCp95UD_secret_28aNjjd1zsySFWvGoSzgcR5Qw" + + func _retrieveKlarnaJSON(_ completion: @escaping ([AnyHashable: Any]?) -> Void) { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + client.retrievePaymentIntent( + withClientSecret: Self.klarnaPaymentIntentClientSecret, + expand: ["payment_method"] + ) { paymentIntent, _ in + let klarnaJson = paymentIntent?.paymentMethod?.klarna?.allResponseFields + completion(klarnaJson ?? [:]) + } + } + + func testObjectDecoding() { + let retrieveJSON = XCTestExpectation(description: "Retrieve JSON") + + _retrieveKlarnaJSON({ json in + let klarna = STPPaymentMethodKlarna.decodedObject(fromAPIResponse: json) + XCTAssertNotNil(klarna, "Failed to decode JSON") + retrieveJSON.fulfill() + }) + + wait(for: [retrieveJSON], timeout: STPTestingNetworkRequestTimeout) + } + +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodNetBankingParamsTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodNetBankingParamsTest.swift new file mode 100644 index 00000000..7fadb1eb --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodNetBankingParamsTest.swift @@ -0,0 +1,46 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodNetBankingParamsTest.m +// StripeiOS +// +// Created by Anirudh Bhargava on 11/19/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import StripeCore +import StripeCoreTestUtils + +class STPPaymentMethodNetBankingParamsTests: XCTestCase { + func testCreateNetBankingPaymentMethod() { + let client = STPAPIClient(publishableKey: STPTestingINPublishableKey) + let netbankingParams = STPPaymentMethodNetBankingParams() + netbankingParams.bank = "icici" + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jenny Rosen" + + let params = STPPaymentMethodParams( + netBanking: netbankingParams, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value" + ]) + + let expectation = self.expectation(description: "Payment Method NetBanking create") + client.createPaymentMethod( + with: params) { paymentMethod, error in + expectation.fulfill() + XCTAssertNil(error, "Unexpected error creating NetBanking PaymentMethod") + XCTAssertNotNil(paymentMethod, "Failed to create NetBanking PaymentMethod") + XCTAssertNotNil(paymentMethod?.stripeId, "Missing stripeId") + XCTAssertNotNil(paymentMethod?.created, "Missing created") + XCTAssertFalse(paymentMethod!.liveMode, "Incorrect livemode") + XCTAssertEqual(paymentMethod?.type, .netBanking, "Incorrect PaymentMethod type") + // Billing Details + XCTAssertEqual(paymentMethod?.billingDetails!.name, "Jenny Rosen") + // UPI Details + XCTAssertNotNil(paymentMethod?.netBanking, "Missing NetBanking") + XCTAssertEqual(paymentMethod?.netBanking!.bank, "icici") + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodNetBankingTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodNetBankingTests.swift new file mode 100644 index 00000000..d42ef55a --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodNetBankingTests.swift @@ -0,0 +1,44 @@ +// +// STPPaymentMethodNetBankingTests.swift +// StripeiOS Tests +// +// Created by Anirudh Bhargava on 11/19/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodNetBankingTests: XCTestCase { + private(set) var netbankingJSON: [AnyHashable: Any]? + + func _retrieveNetBankingJSON(_ completion: @escaping ([AnyHashable: Any]?) -> Void) { + if let netbankingJSON = netbankingJSON { + completion(netbankingJSON) + } else { + let client = STPAPIClient(publishableKey: STPTestingINPublishableKey) + client.retrievePaymentIntent( + withClientSecret: "pi_1HoPqsBte6TMTRd4jX0PwrFa_secret_ThiIwyssre9qjJ6gtmghC21fk", + expand: ["payment_method"] + ) { [self] paymentIntent, _ in + netbankingJSON = paymentIntent?.paymentMethod?.netBanking?.allResponseFields + completion(netbankingJSON ?? [:]) + } + } + } + + func testCorrectParsing() { + let jsonExpectation = XCTestExpectation(description: "Fetch NetBanking JSON") + _retrieveNetBankingJSON({ json in + let netbanking = STPPaymentMethodNetBanking.decodedObject(fromAPIResponse: json) + XCTAssertNotNil(netbanking, "Failed to decode JSON") + jsonExpectation.fulfill() + }) + wait(for: [jsonExpectation], timeout: STPTestingNetworkRequestTimeout) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodOXXOParamsTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodOXXOParamsTests.swift new file mode 100644 index 00000000..57602de1 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodOXXOParamsTests.swift @@ -0,0 +1,52 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodOXXOParamsTests.m +// StripeiOS Tests +// +// Created by Polo Li on 6/16/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import StripeCore +import StripeCoreTestUtils + +class STPPaymentMethodOXXOParamsTests: XCTestCase { + func testCreateOXXOPaymentMethod() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let oxxoParams = STPPaymentMethodOXXOParams() + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jane Doe" + billingDetails.email = "test@test.com" + + let params = STPPaymentMethodParams( + oxxo: oxxoParams, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value" + ]) + + let expectation = self.expectation(description: "Payment Method OXXO create") + + client.createPaymentMethod( + with: params) { paymentMethod, error in + expectation.fulfill() + + XCTAssertNil(error, "Unexpected error creating OXXO PaymentMethod") + XCTAssertNotNil(paymentMethod, "Failed to create OXXO PaymentMethod") + XCTAssertNotNil(paymentMethod?.stripeId, "Missing stripeId") + XCTAssertNotNil(paymentMethod?.created, "Missing created") + XCTAssertFalse(paymentMethod!.liveMode, "Incorrect livemode") + XCTAssertEqual(paymentMethod?.type, .OXXO, "Incorrect PaymentMethod type") + + // Billing Details + XCTAssertEqual(paymentMethod?.billingDetails?.name, "Jane Doe") + XCTAssertEqual(paymentMethod?.billingDetails?.email, "test@test.com") + + // OXXO Details + XCTAssertNotNil(paymentMethod?.oxxo, "Missing OXXO") + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodOXXOTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodOXXOTests.swift new file mode 100644 index 00000000..488d3715 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodOXXOTests.swift @@ -0,0 +1,37 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodOXXOTests.m +// StripeiOS Tests +// +// Created by Polo Li on 6/16/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import StripeCore +import StripeCoreTestUtils + +class STPPaymentMethodOXXOTests: XCTestCase { + private(set) var oxxoJSON: [AnyHashable: Any]? + + func _retrieveOXXOJSON(_ completion: @escaping ([AnyHashable: Any]?) -> Void) { + if let oxxoJSON { + completion(oxxoJSON) + } else { + let client = STPAPIClient(publishableKey: STPTestingMEXPublishableKey) + client.retrievePaymentIntent(withClientSecret: "pi_1GvAdyHNG4o8pO5l0dr078gf_secret_h0tJE5mSX9BPEkmpKSh93jBXi", expand: ["payment_method"]) { paymentIntent, _ in + self.oxxoJSON = paymentIntent?.paymentMethod?.oxxo?.allResponseFields + completion(self.oxxoJSON) + } + } + } + + func testCorrectParsing() { + let expectation = self.expectation(description: "Retrieve payment intent") + _retrieveOXXOJSON({ json in + let oxxo = STPPaymentMethodOXXO.decodedObject(fromAPIResponse: json) + XCTAssertNotNil(oxxo, "Failed to decode JSON") + expectation.fulfill() + }) + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodOptionsTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodOptionsTest.swift new file mode 100644 index 00000000..dc746ee0 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodOptionsTest.swift @@ -0,0 +1,132 @@ +// +// STPPaymentMethodOptionsTest.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 4/8/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +import StripeCoreTestUtils +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodOptionsTest: XCTestCase { + + func testUSBankAccountOptions_PaymentIntent() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let verificationMethods = [ + "skip", + "automatic", + "instant", + "microdeposits", + "instant_or_skip", + ] + for verificationMethod in verificationMethods { + var clientSecret: String? + let createPIExpectation = expectation(description: "Create PaymentIntent") + STPTestingAPIClient.shared.createPaymentIntent( + withParams: [ + "payment_method_types": ["us_bank_account"], + "payment_method_options": [ + "us_bank_account": ["verification_method": verificationMethod] + ], + "currency": "usd", + "amount": 1000, + ], + account: nil + ) { intentClientSecret, error in + XCTAssertNil(error) + XCTAssertNotNil(intentClientSecret) + clientSecret = intentClientSecret + createPIExpectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + guard let clientSecret = clientSecret else { + XCTFail("Failed to create PaymentIntent") + continue + } + + let retrievePIExpectation = expectation(description: "Retrieve PaymentIntent") + client.retrievePaymentIntent(withClientSecret: clientSecret) { paymentIntent, error in + XCTAssertNil(error) + XCTAssertNotNil(paymentIntent) + + XCTAssertNotNil( + paymentIntent?.paymentMethodOptions?.usBankAccount?.verificationMethod + ) + XCTAssertEqual( + paymentIntent?.paymentMethodOptions?.usBankAccount?.verificationMethod, + STPPaymentMethodOptions.USBankAccount.VerificationMethod( + rawValue: verificationMethod + ) + ) + retrievePIExpectation.fulfill() + + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + } + + } + + func testUSBankAccountOptions_SetupIntent() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let verificationMethods = [ + "skip", + "automatic", + "instant", + "microdeposits", + "instant_or_skip", + ] + for verificationMethod in verificationMethods { + var clientSecret: String? + let createPIExpectation = expectation(description: "Create SetupIntent") + STPTestingAPIClient.shared.createSetupIntent( + withParams: [ + "payment_method_types": ["us_bank_account"], + "payment_method_options": [ + "us_bank_account": ["verification_method": verificationMethod] + ], + ], + account: nil + ) { intentClientSecret, error in + XCTAssertNil(error) + XCTAssertNotNil(intentClientSecret) + clientSecret = intentClientSecret + createPIExpectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + guard let clientSecret = clientSecret else { + XCTFail("Failed to create SetupIntent") + continue + } + + let retrievePIExpectation = expectation(description: "Retrieve PaymentIntent") + client.retrieveSetupIntent(withClientSecret: clientSecret) { setupIntent, error in + XCTAssertNil(error) + XCTAssertNotNil(setupIntent) + + XCTAssertNotNil( + setupIntent?.paymentMethodOptions?.usBankAccount?.verificationMethod + ) + XCTAssertEqual( + setupIntent?.paymentMethodOptions?.usBankAccount?.verificationMethod, + STPPaymentMethodOptions.USBankAccount.VerificationMethod( + rawValue: verificationMethod + ) + ) + retrievePIExpectation.fulfill() + + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + } + + } + +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodParamsTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodParamsTest.swift new file mode 100644 index 00000000..e995d482 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodParamsTest.swift @@ -0,0 +1,40 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodParamsTest.m +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 3/7/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +class STPPaymentMethodParamsTest: XCTestCase { + // MARK: STPFormEncodable Tests + + func testRootObjectName() { + XCTAssertNil(STPPaymentMethodParams.rootObjectName()) + } + + func testPropertyNamesToFormFieldNamesMapping() { + let params = STPPaymentMethodParams() + + let mapping = STPPaymentMethodParams.propertyNamesToFormFieldNamesMapping() + + for propertyName in mapping.keys { + guard let propertyName = propertyName as? String else { + continue + } + XCTAssertFalse(propertyName.contains(":")) + XCTAssert(params.responds(to: NSSelectorFromString(propertyName))) + } + + for formFieldName in mapping.values { + guard let formFieldName = formFieldName as? String else { + continue + } + XCTAssert((formFieldName is NSString)) + XCTAssert(formFieldName.count > 0) + } + + XCTAssertEqual(mapping.values.count, Set(mapping.values).count) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodPayPalParamsTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodPayPalParamsTests.swift new file mode 100644 index 00000000..04be8dfd --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodPayPalParamsTests.swift @@ -0,0 +1,49 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodPayPalParamsTests.m +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/7/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils + +class STPPaymentMethodPayPalParamsTests: XCTestCase { + func testCreatePayPalPaymentMethod() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let payPalParams = STPPaymentMethodPayPalParams() + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jane Doe" + + let params = STPPaymentMethodParams( + payPal: payPalParams, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value" + ]) + + let expectation = self.expectation(description: "Payment Method PayPal create") + + client.createPaymentMethod( + with: params) { paymentMethod, error in + expectation.fulfill() + + XCTAssertNil(error, "Unexpected error creating PayPal PaymentMethod") + XCTAssertNotNil(paymentMethod, "Failed to create PayPal PaymentMethod") + XCTAssertNotNil(paymentMethod?.stripeId, "Missing stripeId") + XCTAssertNotNil(paymentMethod?.created, "Missing created") + XCTAssertFalse(paymentMethod!.liveMode, "Incorrect livemode") + XCTAssertEqual(paymentMethod?.type, .payPal, "Incorrect PaymentMethod type") + + // Billing Details + XCTAssertEqual(paymentMethod?.billingDetails!.name, "Jane Doe") + + // PayPal Details + XCTAssertNotNil(paymentMethod?.payPal, "Missing PayPal") + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodPayPalTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodPayPalTests.swift new file mode 100644 index 00000000..0f7655fe --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodPayPalTests.swift @@ -0,0 +1,36 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodPayPalTests.m +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/7/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils + +class STPPaymentMethodPayPalTests: XCTestCase { + var payPalJSON: [AnyHashable: Any]? + + func _retrievePayPalJSON(_ completion: @escaping ([AnyHashable: Any]?) -> Void) { + if let payPalJSON { + completion(payPalJSON) + } else { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + client.retrievePaymentIntent(withClientSecret: "pi_1HcI17FY0qyl6XeWcFAAbZCw_secret_oAZ9OCoeyIg8EPeBEdF96ZJOT", expand: ["payment_method"]) { paymentIntent, _ in + self.payPalJSON = paymentIntent?.lastPaymentError?.paymentMethod?.payPal?.allResponseFields + completion(self.payPalJSON) + } + } + } + + func testCorrectParsing() { + let expectation = self.expectation(description: "Retrieve payment intent") + _retrievePayPalJSON({ json in + let payPal = STPPaymentMethodPayPal.decodedObject(fromAPIResponse: json) + XCTAssertNotNil(payPal, "Failed to decode JSON") + expectation.fulfill() + }) + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodPrzelewy24ParamsTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodPrzelewy24ParamsTests.swift new file mode 100644 index 00000000..6bd41f1d --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodPrzelewy24ParamsTests.swift @@ -0,0 +1,50 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodPrzelewy24ParamsTests.m +// StripeiOS Tests +// +// Created by Vineet Shah on 4/23/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import StripeCore +import StripeCoreTestUtils + +class STPPaymentMethodPrzelewy24ParamsTests: XCTestCase { + func testCreatePrzelewy24PaymentMethod() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let przelewy24Params = STPPaymentMethodPrzelewy24Params() + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.email = "email@email.com" + + let params = STPPaymentMethodParams( + przelewy24: przelewy24Params, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value" + ]) + + let expectation = self.expectation(description: "Payment Method Przelewy24 create") + + client.createPaymentMethod( + with: params) { paymentMethod, error in + expectation.fulfill() + + XCTAssertNil(error, "Unexpected error creating Przelewy24 PaymentMethod") + XCTAssertNotNil(paymentMethod, "Failed to create Przelewy24 PaymentMethod") + XCTAssertNotNil(paymentMethod?.stripeId, "Missing stripeId") + XCTAssertNotNil(paymentMethod?.created, "Missing created") + XCTAssertFalse(paymentMethod!.liveMode, "Incorrect livemode") + XCTAssertEqual(paymentMethod?.type, .przelewy24, "Incorrect PaymentMethod type") + + // Billing Details + XCTAssertEqual(paymentMethod?.billingDetails!.email, "email@email.com") + + // Przelewy24 Details + XCTAssertNotNil(paymentMethod?.przelewy24, "Missing Przelewy24") + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodPrzelewy24Tests.swift b/Stripe/StripeiOSTests/STPPaymentMethodPrzelewy24Tests.swift new file mode 100644 index 00000000..933474ba --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodPrzelewy24Tests.swift @@ -0,0 +1,43 @@ +// +// STPPaymentMethodPrzelewy24Tests.swift +// StripeiOS Tests +// +// Created by Vineet Shah on 4/23/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// +import StripeCoreTestUtils + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodPrzelewy24Tests: XCTestCase { + private(set) var przelewy24JSON: [AnyHashable: Any]? + + func _retrievePrzelewy24JSON(_ completion: @escaping ([AnyHashable: Any]?) -> Void) { + if let przelewy24JSON = przelewy24JSON { + completion(przelewy24JSON) + } else { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + client.retrievePaymentIntent( + withClientSecret: "pi_1GciHFFY0qyl6XeWp9RdhmFF_secret_rFeERcidL1O5o1lwQUcIjLEZz", + expand: ["payment_method"] + ) { [self] paymentIntent, _ in + przelewy24JSON = paymentIntent?.paymentMethod?.przelewy24?.allResponseFields + completion(przelewy24JSON ?? [:]) + } + } + } + + func testCorrectParsing() { + let expectation = self.expectation(description: "Retrieve payment intent") + _retrievePrzelewy24JSON({ json in + let przelewy24 = STPPaymentMethodPrzelewy24.decodedObject(fromAPIResponse: json) + XCTAssertNotNil(przelewy24, "Failed to decode JSON") + expectation.fulfill() + }) + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodRevolutPayParamsTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodRevolutPayParamsTests.swift new file mode 100644 index 00000000..675fb45f --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodRevolutPayParamsTests.swift @@ -0,0 +1,42 @@ +// +// STPPaymentMethodRevolutPayParamsTests.swift +// StripeiOSTests +// + +import Foundation +import StripeCoreTestUtils + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodRevolutPayParamsTests: XCTestCase { + + func testCreateRevolutPayPaymentMethod() throws { + let revolutPayParams = STPPaymentMethodRevolutPayParams() + + let params = STPPaymentMethodParams( + revolutPay: revolutPayParams, + billingDetails: nil, + metadata: nil + ) + + let exp = expectation(description: "Payment Method Revolut Pay create") + + let client = STPAPIClient(publishableKey: STPTestingGBPublishableKey) + client.createPaymentMethod(with: params) { + (paymentMethod: STPPaymentMethod?, error: Error?) in + exp.fulfill() + + XCTAssertNil(error) + XCTAssertNotNil(paymentMethod, "Payment method should be populated") + XCTAssertEqual(paymentMethod?.type, .revolutPay, "Incorrect PaymentMethod type") + XCTAssertNotNil(paymentMethod?.revolutPay, "The `revolut_pay` property must be populated") + } + + self.waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + } + +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodRevolutPayTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodRevolutPayTests.swift new file mode 100644 index 00000000..cfc3d251 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodRevolutPayTests.swift @@ -0,0 +1,38 @@ +// +// STPPaymentMethodRevolutPayTests.swift +// StripeiOSTests +// + +@testable import Stripe +import StripeCoreTestUtils +import XCTest + +class STPPaymentMethodRevolutPayTests: XCTestCase { + + static let revolutPayPaymentIntentClientSecret = "pi_3NqgBBGoesj9fw9Q1TkY7iBp_secret_Ha7VfLCwaAuhEOshZiNnIDjh6" + + func _retrieveRevolutPayJSON(_ completion: @escaping ([AnyHashable: Any]?) -> Void) { + let client = STPAPIClient(publishableKey: STPTestingGBPublishableKey) + client.retrievePaymentIntent( + withClientSecret: Self.revolutPayPaymentIntentClientSecret, + expand: ["payment_method"] + ) { paymentIntent, _ in + XCTAssertNotNil(paymentIntent?.paymentMethod?.allResponseFields["revolut_pay"]) + let revolutPayJson = try? XCTUnwrap(paymentIntent?.paymentMethod?.revolutPay?.allResponseFields) + completion(revolutPayJson) + } + } + + func testObjectDecoding() { + let retrieveJSON = XCTestExpectation(description: "Retrieve JSON") + + _retrieveRevolutPayJSON({ json in + let revolutPay = STPPaymentMethodRevolutPay.decodedObject(fromAPIResponse: json) + XCTAssertNotNil(revolutPay, "Failed to decode JSON") + retrieveJSON.fulfill() + }) + + wait(for: [retrieveJSON], timeout: STPTestingNetworkRequestTimeout) + } + +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodSEPADebitTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodSEPADebitTest.swift new file mode 100644 index 00000000..5496c5e3 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodSEPADebitTest.swift @@ -0,0 +1,39 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodSEPADebitTest.m +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/7/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +class STPPaymentMethodSEPADebitTest: XCTestCase { + // MARK: - STPAPIResponseDecodable Tests + + func testDecodedObjectFromAPIResponseRequiredFields() { + let requiredFields: [String]? = [] + + for field in requiredFields ?? [] { + var response = STPTestUtils.jsonNamed("SEPADebitSource")["sepa_debit"] as? [AnyHashable: Any] + response?.removeValue(forKey: field) + + XCTAssertNil(STPPaymentMethodSEPADebit.decodedObject(fromAPIResponse: response)) + } + + XCTAssertNotNil(STPPaymentMethodSEPADebit.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("SEPADebitSource")["sepa_debit"] as? [AnyHashable: Any])) + } + + func testDecodedObjectFromAPIResponseMapping() { + let response = STPTestUtils.jsonNamed("SEPADebitSource")["sepa_debit"] as? [AnyHashable: Any] + let sepaDebit = STPPaymentMethodSEPADebit.decodedObject(fromAPIResponse: response) + + XCTAssertEqual(sepaDebit?.bankCode, "37040044") + XCTAssertEqual(sepaDebit?.branchCode, "a_branch") + XCTAssertEqual(sepaDebit?.country, "DE") + XCTAssertEqual(sepaDebit?.fingerprint, "NxdSyRegc9PsMkWy") + XCTAssertEqual(sepaDebit?.last4, "3001") + XCTAssertEqual(sepaDebit?.mandate, "NXDSYREGC9PSMKWY") + + XCTAssertEqual(sepaDebit?.allResponseFields as! NSDictionary, response as! NSDictionary) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodSofortParamsTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodSofortParamsTests.swift new file mode 100644 index 00000000..ec6dd84c --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodSofortParamsTests.swift @@ -0,0 +1,51 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodSofortParamsTests.m +// StripeiOS Tests +// +// Created by David Estes on 8/7/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import StripeCore +import StripeCoreTestUtils + +class STPPaymentMethodSofortParamsTests: XCTestCase { + func testCreateSofortPaymentMethod() { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let sofortParams = STPPaymentMethodSofortParams() + sofortParams.country = "DE" + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jenny Rosen" + + let params = STPPaymentMethodParams( + sofort: sofortParams, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value" + ]) + + let expectation = self.expectation(description: "Payment Method Sofort create") + + client.createPaymentMethod( + with: params) { paymentMethod, error in + expectation.fulfill() + + XCTAssertNil(error, "Unexpected error creating Sofort PaymentMethod") + XCTAssertNotNil(paymentMethod, "Failed to create Sofort PaymentMethod") + XCTAssertNotNil(paymentMethod?.stripeId, "Missing stripeId") + XCTAssertNotNil(paymentMethod?.created, "Missing created") + XCTAssertFalse(paymentMethod!.liveMode, "Incorrect livemode") + XCTAssertEqual(paymentMethod?.type, .sofort, "Incorrect PaymentMethod type") + + // Billing Details + XCTAssertEqual(paymentMethod?.billingDetails!.name, "Jenny Rosen") + + // Sofort Details + XCTAssertNotNil(paymentMethod?.sofort, "Missing Sofort") + XCTAssertEqual(paymentMethod?.sofort!.country, "DE") + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodSofortTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodSofortTests.swift new file mode 100644 index 00000000..cce7b33d --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodSofortTests.swift @@ -0,0 +1,43 @@ +// +// STPPaymentMethodSofortTests.swift +// StripeiOS Tests +// +// Created by David Estes on 8/7/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// +import StripeCoreTestUtils + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodSofortTests: XCTestCase { + private(set) var sofortJSON: [AnyHashable: Any]? + + func _retrieveSofortJSON(_ completion: @escaping ([AnyHashable: Any]?) -> Void) { + if let sofortJSON = sofortJSON { + completion(sofortJSON) + } else { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + client.retrievePaymentIntent( + withClientSecret: "pi_1HDdfSFY0qyl6XeWjk7ogYVV_secret_5ikjoct7F271A4Bp6t7HkHwUo", + expand: ["payment_method"] + ) { [self] paymentIntent, _ in + sofortJSON = paymentIntent?.paymentMethod?.sofort?.allResponseFields + completion(sofortJSON ?? [:]) + } + } + } + + func testCorrectParsing() { + let jsonExpectation = XCTestExpectation(description: "Fetch Sofort JSON") + _retrieveSofortJSON({ json in + let sofort = STPPaymentMethodSofort.decodedObject(fromAPIResponse: json) + XCTAssertNotNil(sofort, "Failed to decode JSON") + jsonExpectation.fulfill() + }) + wait(for: [jsonExpectation], timeout: STPTestingNetworkRequestTimeout) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodSwishParamsTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodSwishParamsTests.swift new file mode 100644 index 00000000..d4302761 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodSwishParamsTests.swift @@ -0,0 +1,39 @@ +// +// STPPaymentMethodSwishParamsTests.swift +// StripeiOSTests +// +// Created by Eduardo Urias on 9/21/23. +// + +import Foundation +@testable @_spi(STP) import StripeCoreTestUtils +@testable @_spi(STP) import StripePayments + +class STPPaymentMethodSwishParamsTests: XCTestCase { + + func testCreateSwishPaymentMethod() throws { + let swishParams = STPPaymentMethodSwishParams() + + let params = STPPaymentMethodParams( + swish: swishParams, + billingDetails: nil, + metadata: nil + ) + + let exp = expectation(description: "Payment Method Swish create") + + let client = STPAPIClient(publishableKey: STPTestingFRPublishableKey) + client.createPaymentMethod(with: params) { + (paymentMethod: STPPaymentMethod?, error: Error?) in + exp.fulfill() + + XCTAssertNil(error) + XCTAssertNotNil(paymentMethod, "Payment method should be populated") + XCTAssertEqual(paymentMethod?.type, .swish, "Incorrect PaymentMethod type") + XCTAssertNotNil(paymentMethod?.swish, "The `swish` property must be populated") + } + + self.waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + } + +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodSwishTests.swift b/Stripe/StripeiOSTests/STPPaymentMethodSwishTests.swift new file mode 100644 index 00000000..5139b97b --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodSwishTests.swift @@ -0,0 +1,41 @@ +// +// STPPaymentMethodSwishTests.swift +// StripeiOSTests +// +// Created by Eduardo Urias on 9/21/23. +// + +import Foundation + +@testable @_spi(STP) import StripeCoreTestUtils +@testable @_spi(STP) import StripePayments + +class STPPaymentMethodSwishTests: XCTestCase { + + static let swishPaymentIntentClientSecret = + "pi_3Nsu6oKG6vc7r7YC1FJJPNjg_secret_wQtTkgmjgOMSqN7lje5RCtzrm" + + func _retrieveSwishJSON(_ completion: @escaping ([AnyHashable: Any]?) -> Void) { + let client = STPAPIClient(publishableKey: STPTestingFRPublishableKey) + client.retrievePaymentIntent( + withClientSecret: Self.swishPaymentIntentClientSecret, + expand: ["payment_method"] + ) { paymentIntent, _ in + let swishJson = paymentIntent?.paymentMethod?.swish?.allResponseFields + completion(swishJson) + } + } + + func testObjectDecoding() { + let retrieveJSON = XCTestExpectation(description: "Retrieve JSON") + + _retrieveSwishJSON({ json in + let klarna = STPPaymentMethodSwish.decodedObject(fromAPIResponse: json) + XCTAssertNotNil(klarna, "Failed to decode JSON") + retrieveJSON.fulfill() + }) + + wait(for: [retrieveJSON], timeout: STPTestingNetworkRequestTimeout) + } + +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodTest.swift new file mode 100644 index 00000000..6a1292d7 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodTest.swift @@ -0,0 +1,161 @@ +// +// STPPaymentMethodTest.swift +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 3/6/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodTest: XCTestCase { + // MARK: - STPPaymentMethodType Tests + func testTypeFromString() { + XCTAssertEqual( + STPPaymentMethod.type(from: "au_becs_debit"), + STPPaymentMethodType.AUBECSDebit + ) + XCTAssertEqual( + STPPaymentMethod.type(from: "AU_BECS_DEBIT"), + STPPaymentMethodType.AUBECSDebit + ) + XCTAssertEqual(STPPaymentMethod.type(from: "BACS_DEBIT"), STPPaymentMethodType.bacsDebit) + XCTAssertEqual(STPPaymentMethod.type(from: "bacs_debit"), STPPaymentMethodType.bacsDebit) + XCTAssertEqual(STPPaymentMethod.type(from: "BACS_DEBIT"), STPPaymentMethodType.bacsDebit) + XCTAssertEqual(STPPaymentMethod.type(from: "card"), STPPaymentMethodType.card) + XCTAssertEqual(STPPaymentMethod.type(from: "CARD"), STPPaymentMethodType.card) + XCTAssertEqual(STPPaymentMethod.type(from: "ideal"), STPPaymentMethodType.iDEAL) + XCTAssertEqual(STPPaymentMethod.type(from: "IDEAL"), STPPaymentMethodType.iDEAL) + XCTAssertEqual(STPPaymentMethod.type(from: "fpx"), STPPaymentMethodType.FPX) + XCTAssertEqual(STPPaymentMethod.type(from: "FPX"), STPPaymentMethodType.FPX) + XCTAssertEqual(STPPaymentMethod.type(from: "sepa_debit"), STPPaymentMethodType.SEPADebit) + XCTAssertEqual(STPPaymentMethod.type(from: "SEPA_DEBIT"), STPPaymentMethodType.SEPADebit) + XCTAssertEqual( + STPPaymentMethod.type(from: "card_present"), + STPPaymentMethodType.cardPresent + ) + XCTAssertEqual( + STPPaymentMethod.type(from: "CARD_PRESENT"), + STPPaymentMethodType.cardPresent + ) + XCTAssertEqual(STPPaymentMethod.type(from: "unknown_string"), STPPaymentMethodType.unknown) + } + + func testTypesFromStrings() { + let rawTypes = [ + "card", + "ideal", + "card_present", + "fpx", + "sepa_debit", + "bacs_debit", + "au_becs_debit", + ] + let expectedTypes: [STPPaymentMethodType] = [ + .card, + .iDEAL, + .cardPresent, + .FPX, + .SEPADebit, + .bacsDebit, + .AUBECSDebit, + ] + XCTAssertEqual(STPPaymentMethod.paymentMethodTypes(from: rawTypes), expectedTypes) + } + + func testStringFromType() { + let values: [STPPaymentMethodType] = [ + .card, + .iDEAL, + .cardPresent, + .FPX, + .SEPADebit, + .bacsDebit, + .AUBECSDebit, + .OXXO, + .alipay, + .payPal, + .giropay, + .unknown, + ] + for type in values { + let string = STPPaymentMethod.string(from: type) + + switch type { + case .card: + XCTAssertEqual(string, "card") + case .iDEAL: + XCTAssertEqual(string, "ideal") + case .cardPresent: + XCTAssertEqual(string, "card_present") + case .FPX: + XCTAssertEqual(string, "fpx") + case .SEPADebit: + XCTAssertEqual(string, "sepa_debit") + case .bacsDebit: + XCTAssertEqual(string, "bacs_debit") + case .AUBECSDebit: + XCTAssertEqual(string, "au_becs_debit") + case .giropay: + XCTAssertEqual(string, "giropay") + case .przelewy24: + XCTAssertEqual(string, "p24") + case .bancontact: + XCTAssertEqual(string, "bancontact") + case .EPS: + XCTAssertEqual(string, "eps") + case .OXXO: + XCTAssertEqual(string, "oxxo") + case .sofort: + XCTAssertEqual(string, "sofort") + case .alipay: + XCTAssertEqual(string, "alipay") + case .payPal: + XCTAssertEqual(string, "paypal") + case .unknown: + XCTAssertNil(string) + case .grabPay: + XCTAssertEqual(string, "grabpay") + default: + break + } + } + } + + // MARK: - STPAPIResponseDecodable Tests + func testDecodedObjectFromAPIResponseRequiredFields() { + let fullJson = STPTestUtils.jsonNamed(STPTestJSONPaymentMethodCard) + + XCTAssertNotNil( + STPPaymentMethod.decodedObject(fromAPIResponse: fullJson), + "can decode with full json" + ) + + let requiredFields = ["id"] + + for field in requiredFields { + var partialJson = fullJson + + XCTAssertNotNil(partialJson?[field]) + partialJson?.removeValue(forKey: field) + + XCTAssertNil(STPPaymentIntent.decodedObject(fromAPIResponse: partialJson)) + } + } + + func testDecodedObjectFromAPIResponseMapping() { + let response = STPTestUtils.jsonNamed(STPTestJSONPaymentMethodCard) + let paymentMethod = STPPaymentMethod.decodedObject(fromAPIResponse: response) + XCTAssertEqual(paymentMethod?.stripeId, "pm_123456789") + XCTAssertEqual(paymentMethod?.created, Date(timeIntervalSince1970: 123_456_789)) + XCTAssertEqual(paymentMethod!.liveMode, false) + XCTAssertEqual(paymentMethod?.type, .card) + XCTAssertNotNil(paymentMethod?.billingDetails) + XCTAssertNotNil(paymentMethod?.card) + XCTAssertNil(paymentMethod?.customerId) + XCTAssertEqual(paymentMethod!.allResponseFields as NSDictionary, response! as NSDictionary) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodThreeDSecureUsageTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodThreeDSecureUsageTest.swift new file mode 100644 index 00000000..37627145 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodThreeDSecureUsageTest.swift @@ -0,0 +1,27 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodThreeDSecureUsageTest.m +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 3/5/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +import XCTest + +class STPPaymentMethodThreeDSecureUsageTest: XCTestCase { + func testDecodedObjectFromAPIResponse() { + let response = [ + "supported": NSNumber(value: true) + ] + let requiredFields = ["supported"] + + for field in requiredFields { + var mutableResponse = response + mutableResponse.removeValue(forKey: field) + + XCTAssertNil(STPPaymentMethodThreeDSecureUsage.decodedObject(fromAPIResponse: mutableResponse)) + } + XCTAssertNotNil(STPPaymentMethodThreeDSecureUsage.decodedObject(fromAPIResponse: response)) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodUPIParamsTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodUPIParamsTest.swift new file mode 100644 index 00000000..340c6337 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodUPIParamsTest.swift @@ -0,0 +1,51 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodUPIParamsTest.m +// StripeiOS Tests +// +// Created by Anirudh Bhargava on 11/6/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import StripeCore +import StripeCoreTestUtils + +class STPPaymentMethodUPIParamsTests: XCTestCase { + func testCreateUPIPaymentMethod() { + let client = STPAPIClient(publishableKey: STPTestingINPublishableKey) + let upiParams = STPPaymentMethodUPIParams() + upiParams.vpa = "somevpa@hdfcbank" + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jenny Rosen" + + let params = STPPaymentMethodParams( + upi: upiParams, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value" + ]) + + let expectation = self.expectation(description: "Payment Method UPI create") + + client.createPaymentMethod( + with: params) { paymentMethod, error in + expectation.fulfill() + + XCTAssertNil(error, "Unexpected error creating UPI PaymentMethod") + XCTAssertNotNil(paymentMethod, "Failed to create UPI PaymentMethod") + XCTAssertNotNil(paymentMethod?.stripeId, "Missing stripeId") + XCTAssertNotNil(paymentMethod?.created, "Missing created") + XCTAssertFalse(paymentMethod!.liveMode, "Incorrect livemode") + XCTAssertEqual(paymentMethod?.type, .UPI, "Incorrect PaymentMethod type") + + // Billing Details + XCTAssertEqual(paymentMethod?.billingDetails!.name, "Jenny Rosen") + + // UPI Details + XCTAssertNotNil(paymentMethod?.upi, "Missing UPI") + XCTAssertEqual(paymentMethod?.upi!.vpa, "somevpa@hdfcbank") + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodUPITests.swift b/Stripe/StripeiOSTests/STPPaymentMethodUPITests.swift new file mode 100644 index 00000000..9340d4cd --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodUPITests.swift @@ -0,0 +1,44 @@ +// +// STPPaymentMethodUPITests.swift +// StripeiOS Tests +// +// Created by Anirudh Bhargava on 11/10/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodUPITests: XCTestCase { + private(set) var upiJSON: [AnyHashable: Any]? + + func _retrieveUPIJSON(_ completion: @escaping ([AnyHashable: Any]?) -> Void) { + if let upiJSON = upiJSON { + completion(upiJSON) + } else { + let client = STPAPIClient(publishableKey: STPTestingINPublishableKey) + client.retrievePaymentIntent( + withClientSecret: "pi_1HlYxxBte6TMTRd48W66zjTJ_secret_TgB7p7e7aTRbr22UT6N6KNrSm", + expand: ["payment_method"] + ) { [self] paymentIntent, _ in + upiJSON = paymentIntent?.paymentMethod?.upi?.allResponseFields + completion(upiJSON ?? [:]) + } + } + } + + func testCorrectParsing() { + let jsonExpectation = XCTestExpectation(description: "Fetch UPI JSON") + _retrieveUPIJSON({ json in + let upi = STPPaymentMethodUPI.decodedObject(fromAPIResponse: json) + XCTAssertNotNil(upi, "Failed to decode JSON") + jsonExpectation.fulfill() + }) + wait(for: [jsonExpectation], timeout: STPTestingNetworkRequestTimeout) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodUSBankAccountParamsStubbedTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodUSBankAccountParamsStubbedTest.swift new file mode 100644 index 00000000..e58023d6 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodUSBankAccountParamsStubbedTest.swift @@ -0,0 +1,302 @@ +// +// STPPaymentMethodUSBankAccountParamsStubbedTest.swift +// StripeiOS Tests +// +// Created by John Woo on 3/24/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import OHHTTPStubs +import OHHTTPStubsSwift +import StripeCoreTestUtils +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeApplePay +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePaymentSheet + +class STPPaymentMethodUSBankAccountParamsStubbedTest: APIStubbedTestCase { + func testus_bank_account_withoutNetworks() { + let stubbedApiClient = stubbedAPIClient() + stub { urlRequest in + return urlRequest.url?.absoluteString.contains("/payment_methods") ?? false + } response: { _ in + let jsonText = """ + { + "id": "pm_1KgvOfFY0qyl6XeW7RfvDvxE", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "tester@example.com", + "name": "iOS CI Tester", + "phone": null + }, + "created": 1648146513, + "customer": null, + "livemode": false, + "type": "us_bank_account", + "us_bank_account": { + "account_holder_type": "individual", + "account_type": "checking", + "bank_name": "STRIPE TEST BANK", + "fingerprint": "ickfX9sbxIyAlbuh", + "last4": "6789", + "linked_account": null, + "routing_number": "110000000" + } + } + """ + return HTTPStubsResponse( + data: jsonText.data(using: .utf8)!, + statusCode: 200, + headers: nil + ) + } + + let usBankAccountParams = STPPaymentMethodUSBankAccountParams() + usBankAccountParams.accountType = .checking + usBankAccountParams.accountHolderType = .individual + usBankAccountParams.accountNumber = "000123456789" + usBankAccountParams.routingNumber = "110000000" + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "iOS CI Tester" + billingDetails.email = "tester@example.com" + + let params = STPPaymentMethodParams( + usBankAccount: usBankAccountParams, + billingDetails: billingDetails, + metadata: nil + ) + + let exp = expectation(description: "Payment Method US Bank Account create") + stubbedApiClient.createPaymentMethod(with: params) { + (paymentMethod: STPPaymentMethod?, error: Error?) in + XCTAssertNil(error) + XCTAssertNotNil(paymentMethod, "Payment method should be populated") + XCTAssertEqual(paymentMethod?.type, .USBankAccount, "Incorrect PaymentMethod type") + XCTAssertNotNil( + paymentMethod?.usBankAccount, + "The `usBankAccount` property must be populated" + ) + XCTAssertEqual( + paymentMethod?.usBankAccount?.accountHolderType, + .individual, + "`accountHolderType` should be individual" + ) + XCTAssertEqual( + paymentMethod?.usBankAccount?.accountType, + .checking, + "`accountType` should be checking" + ) + XCTAssertEqual(paymentMethod?.usBankAccount?.last4, "6789", "`last4` should be 6789") + XCTAssertNil(paymentMethod?.usBankAccount?.networks) + exp.fulfill() + + } + self.waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + } + func testus_bank_account_networksInPayloadWithPreferred() { + + let stubbedApiClient = stubbedAPIClient() + stub { urlRequest in + return urlRequest.url?.absoluteString.contains("/payment_methods") ?? false + } response: { _ in + let jsonText = """ + { + "id": "pm_1KgvOfFY0qyl6XeW7RfvDvxE", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "tester@example.com", + "name": "iOS CI Tester", + "phone": null + }, + "created": 1648146513, + "customer": null, + "livemode": false, + "type": "us_bank_account", + "us_bank_account": { + "account_holder_type": "individual", + "account_type": "checking", + "bank_name": "STRIPE TEST BANK", + "fingerprint": "ickfX9sbxIyAlbuh", + "last4": "6789", + "linked_account": null, + "networks": { + "preferred": "ach", + "supported": [ + "ach" + ] + }, + "routing_number": "110000000" + } + } + """ + return HTTPStubsResponse( + data: jsonText.data(using: .utf8)!, + statusCode: 200, + headers: nil + ) + } + + let usBankAccountParams = STPPaymentMethodUSBankAccountParams() + usBankAccountParams.accountType = .checking + usBankAccountParams.accountHolderType = .individual + usBankAccountParams.accountNumber = "000123456789" + usBankAccountParams.routingNumber = "110000000" + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "iOS CI Tester" + billingDetails.email = "tester@example.com" + + let params = STPPaymentMethodParams( + usBankAccount: usBankAccountParams, + billingDetails: billingDetails, + metadata: nil + ) + + let exp = expectation(description: "Payment Method US Bank Account create") + + stubbedApiClient.createPaymentMethod(with: params) { + (paymentMethod: STPPaymentMethod?, error: Error?) in + + XCTAssertNil(error) + XCTAssertNotNil(paymentMethod, "Payment method should be populated") + XCTAssertEqual(paymentMethod?.type, .USBankAccount, "Incorrect PaymentMethod type") + XCTAssertNotNil( + paymentMethod?.usBankAccount, + "The `usBankAccount` property must be populated" + ) + XCTAssertEqual( + paymentMethod?.usBankAccount?.accountHolderType, + .individual, + "`accountHolderType` should be individual" + ) + XCTAssertEqual( + paymentMethod?.usBankAccount?.accountType, + .checking, + "`accountType` should be checking" + ) + XCTAssertEqual(paymentMethod?.usBankAccount?.last4, "6789", "`last4` should be 6789") + XCTAssertEqual(paymentMethod?.usBankAccount?.networks?.preferred, "ach") + XCTAssertEqual(paymentMethod?.usBankAccount?.networks?.supported.count, 1) + XCTAssertEqual(paymentMethod?.usBankAccount?.networks?.supported.first, "ach") + exp.fulfill() + + } + self.waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + } + func testus_bank_account_networksInPayloadWithoutPreferred() { + + let stubbedApiClient = stubbedAPIClient() + stub { urlRequest in + return urlRequest.url?.absoluteString.contains("/payment_methods") ?? false + } response: { _ in + let jsonText = """ + { + "id": "pm_1KgvOfFY0qyl6XeW7RfvDvxE", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "tester@example.com", + "name": "iOS CI Tester", + "phone": null + }, + "created": 1648146513, + "customer": null, + "livemode": false, + "type": "us_bank_account", + "us_bank_account": { + "account_holder_type": "individual", + "account_type": "checking", + "bank_name": "STRIPE TEST BANK", + "fingerprint": "ickfX9sbxIyAlbuh", + "last4": "6789", + "linked_account": null, + "networks": { + "supported": [ + "ach" + ] + }, + "routing_number": "110000000" + } + } + """ + return HTTPStubsResponse( + data: jsonText.data(using: .utf8)!, + statusCode: 200, + headers: nil + ) + } + + let usBankAccountParams = STPPaymentMethodUSBankAccountParams() + usBankAccountParams.accountType = .checking + usBankAccountParams.accountHolderType = .individual + usBankAccountParams.accountNumber = "000123456789" + usBankAccountParams.routingNumber = "110000000" + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "iOS CI Tester" + billingDetails.email = "tester@example.com" + + let params = STPPaymentMethodParams( + usBankAccount: usBankAccountParams, + billingDetails: billingDetails, + metadata: nil + ) + + let exp = expectation(description: "Payment Method US Bank Account create") + + stubbedApiClient.createPaymentMethod(with: params) { + (paymentMethod: STPPaymentMethod?, error: Error?) in + + XCTAssertNil(error) + XCTAssertNotNil(paymentMethod, "Payment method should be populated") + XCTAssertEqual(paymentMethod?.type, .USBankAccount, "Incorrect PaymentMethod type") + XCTAssertNotNil( + paymentMethod?.usBankAccount, + "The `usBankAccount` property must be populated" + ) + XCTAssertEqual( + paymentMethod?.usBankAccount?.accountHolderType, + .individual, + "`accountHolderType` should be individual" + ) + XCTAssertEqual( + paymentMethod?.usBankAccount?.accountType, + .checking, + "`accountType` should be checking" + ) + XCTAssertEqual(paymentMethod?.usBankAccount?.last4, "6789", "`last4` should be 6789") + XCTAssertNil(paymentMethod?.usBankAccount?.networks?.preferred) + XCTAssertEqual(paymentMethod?.usBankAccount?.networks?.supported.count, 1) + XCTAssertEqual(paymentMethod?.usBankAccount?.networks?.supported.first, "ach") + exp.fulfill() + + } + self.waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodUSBankAccountParamsTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodUSBankAccountParamsTest.swift new file mode 100644 index 00000000..dcbe4ef2 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodUSBankAccountParamsTest.swift @@ -0,0 +1,233 @@ +// +// STPPaymentMethodUSBankAccountParamsTest.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 3/2/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +import StripeCoreTestUtils +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodUSBankAccountParamsTest: XCTestCase { + + let apiClient: STPAPIClient = { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + return client + }() + + func testCreateUSBankAccountPaymentMethod_checking_individual() { + let usBankAccountParams = STPPaymentMethodUSBankAccountParams() + usBankAccountParams.accountType = .checking + XCTAssertEqual(usBankAccountParams.accountTypeString, "checking") + usBankAccountParams.accountHolderType = .individual + XCTAssertEqual(usBankAccountParams.accountHolderTypeString, "individual") + usBankAccountParams.accountNumber = "000123456789" + usBankAccountParams.routingNumber = "110000000" + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "iOS CI Tester" + billingDetails.email = "tester@example.com" + + let params = STPPaymentMethodParams( + usBankAccount: usBankAccountParams, + billingDetails: billingDetails, + metadata: nil + ) + + let exp = expectation(description: "Payment Method US Bank Account create") + + apiClient.createPaymentMethod(with: params) { + (paymentMethod: STPPaymentMethod?, error: Error?) in + + XCTAssertNil(error) + XCTAssertNotNil(paymentMethod, "Payment method should be populated") + XCTAssertEqual(paymentMethod?.type, .USBankAccount, "Incorrect PaymentMethod type") + XCTAssertNotNil( + paymentMethod?.usBankAccount, + "The `usBankAccount` property must be populated" + ) + XCTAssertEqual( + paymentMethod?.usBankAccount?.accountHolderType, + .individual, + "`accountHolderType` should be individual" + ) + XCTAssertEqual( + paymentMethod?.usBankAccount?.accountType, + .checking, + "`accountType` should be checking" + ) + XCTAssertEqual(paymentMethod?.usBankAccount?.last4, "6789", "`last4` should be 6789") + + exp.fulfill() + + } + + self.waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + } + + func testCreateUSBankAccountPaymentMethod_savings_company() { + let usBankAccountParams = STPPaymentMethodUSBankAccountParams() + usBankAccountParams.accountType = .savings + XCTAssertEqual(usBankAccountParams.accountTypeString, "savings") + usBankAccountParams.accountHolderType = .company + XCTAssertEqual(usBankAccountParams.accountHolderTypeString, "company") + usBankAccountParams.accountNumber = "000123456789" + usBankAccountParams.routingNumber = "110000000" + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "iOS CI Tester" + billingDetails.email = "tester@example.com" + + let params = STPPaymentMethodParams( + usBankAccount: usBankAccountParams, + billingDetails: billingDetails, + metadata: nil + ) + + let exp = expectation(description: "Payment Method US Bank Account create") + + apiClient.createPaymentMethod(with: params) { + (paymentMethod: STPPaymentMethod?, error: Error?) in + + XCTAssertNil(error) + XCTAssertNotNil(paymentMethod, "Payment method should be populated") + XCTAssertEqual(paymentMethod?.type, .USBankAccount, "Incorrect PaymentMethod type") + XCTAssertNotNil( + paymentMethod?.usBankAccount, + "The `usBankAccount` property must be populated" + ) + XCTAssertEqual( + paymentMethod?.usBankAccount?.accountHolderType, + .company, + "`accountHolderType` should be company" + ) + XCTAssertEqual( + paymentMethod?.usBankAccount?.accountType, + .savings, + "`accountType` should be savings" + ) + XCTAssertEqual(paymentMethod?.usBankAccount?.last4, "6789", "`last4` should be 6789") + + exp.fulfill() + } + + self.waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + } + + func testCreateUSBankAccountPaymentMethod_checking_individual_set_with_string() { + let usBankAccountParams = STPPaymentMethodUSBankAccountParams() + usBankAccountParams.accountTypeString = "checking" + XCTAssertEqual(usBankAccountParams.accountType, .checking) + usBankAccountParams.accountHolderTypeString = "individual" + XCTAssertEqual(usBankAccountParams.accountHolderType, .individual) + usBankAccountParams.accountNumber = "000123456789" + usBankAccountParams.routingNumber = "110000000" + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "iOS CI Tester" + billingDetails.email = "tester@example.com" + + let params = STPPaymentMethodParams( + usBankAccount: usBankAccountParams, + billingDetails: billingDetails, + metadata: nil + ) + + let exp = expectation(description: "Payment Method US Bank Account create") + + apiClient.createPaymentMethod(with: params) { + (paymentMethod: STPPaymentMethod?, error: Error?) in + + XCTAssertNil(error) + XCTAssertNotNil(paymentMethod, "Payment method should be populated") + XCTAssertEqual(paymentMethod?.type, .USBankAccount, "Incorrect PaymentMethod type") + XCTAssertNotNil( + paymentMethod?.usBankAccount, + "The `usBankAccount` property must be populated" + ) + XCTAssertEqual( + paymentMethod?.usBankAccount?.accountHolderType, + .individual, + "`accountHolderType` should be individual" + ) + XCTAssertEqual( + paymentMethod?.usBankAccount?.accountType, + .checking, + "`accountType` should be checking" + ) + XCTAssertEqual(paymentMethod?.usBankAccount?.last4, "6789", "`last4` should be 6789") + + exp.fulfill() + } + + self.waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + } + + func testCreateUSBankAccountPaymentMethod_savings_company_set_with_string() { + let usBankAccountParams = STPPaymentMethodUSBankAccountParams() + usBankAccountParams.accountTypeString = "savings" + XCTAssertEqual(usBankAccountParams.accountType, .savings) + usBankAccountParams.accountHolderTypeString = "company" + XCTAssertEqual(usBankAccountParams.accountHolderType, .company) + usBankAccountParams.accountNumber = "000123456789" + usBankAccountParams.routingNumber = "110000000" + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "iOS CI Tester" + billingDetails.email = "tester@example.com" + + let params = STPPaymentMethodParams( + usBankAccount: usBankAccountParams, + billingDetails: billingDetails, + metadata: nil + ) + + let exp = expectation(description: "Payment Method US Bank Account create") + + apiClient.createPaymentMethod(with: params) { + (paymentMethod: STPPaymentMethod?, error: Error?) in + + XCTAssertNil(error) + XCTAssertNotNil(paymentMethod, "Payment method should be populated") + XCTAssertEqual(paymentMethod?.type, .USBankAccount, "Incorrect PaymentMethod type") + XCTAssertNotNil( + paymentMethod?.usBankAccount, + "The `usBankAccount` property must be populated" + ) + XCTAssertEqual( + paymentMethod?.usBankAccount?.accountHolderType, + .company, + "`accountHolderType` should be company" + ) + XCTAssertEqual( + paymentMethod?.usBankAccount?.accountType, + .savings, + "`accountType` should be savings" + ) + XCTAssertEqual(paymentMethod?.usBankAccount?.last4, "6789", "`last4` should be 6789") + + exp.fulfill() + } + + self.waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + } + + func testEncodingWithLinkAccountSessionID() { + let usBankAccountParams = STPPaymentMethodUSBankAccountParams() + usBankAccountParams.linkAccountSessionID = "las_test" + let encoded = STPFormEncoder.dictionary(forObject: usBankAccountParams) + XCTAssertEqual( + (encoded["us_bank_account"] as? [AnyHashable: Any])?["link_account_session"] as? String, + "las_test" + ) + } + +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodUSBankAccountTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodUSBankAccountTest.swift new file mode 100644 index 00000000..cd6fc6c5 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodUSBankAccountTest.swift @@ -0,0 +1,52 @@ +// +// STPPaymentMethodUSBankAccountTest.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 3/2/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import StripeCoreTestUtils +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentMethodUSBankAccountTest: XCTestCase { + + static let usBankAccountPaymentIntentClientSecret = + "pi_3KhHLqFY0qyl6XeW1X2ZMsOT_secret_k5bOLoKJEW8ZhQFpokL0OrpbU" + + func retrieveUSBankAccountJSON(_ completion: @escaping ([AnyHashable: Any]?) -> Void) { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + client.retrievePaymentIntent( + withClientSecret: Self.usBankAccountPaymentIntentClientSecret, + expand: ["payment_method"] + ) { paymentIntent, _ in + let klarnaJson = paymentIntent?.paymentMethod?.usBankAccount?.allResponseFields + completion(klarnaJson ?? [:]) + } + } + + func testObjectDecoding() { + let retrieveJSON = XCTestExpectation(description: "Retrieve JSON") + + retrieveUSBankAccountJSON({ json in + let usBankAccount = STPPaymentMethodUSBankAccount.decodedObject(fromAPIResponse: json) + XCTAssertNotNil(usBankAccount, "Failed to decode JSON") + XCTAssertEqual(usBankAccount?.last4, "6789") + XCTAssertEqual(usBankAccount?.routingNumber, "110000000") + XCTAssertEqual(usBankAccount?.bankName, "STRIPE TEST BANK") + XCTAssertEqual(usBankAccount?.accountHolderType, .individual) + XCTAssertEqual(usBankAccount?.accountType, .checking) + XCTAssertNotNil(usBankAccount?.fingerprint) + retrieveJSON.fulfill() + }) + + wait(for: [retrieveJSON], timeout: STPTestingNetworkRequestTimeout) + } + +} diff --git a/Stripe/StripeiOSTests/STPPaymentMethodiDEALTest.swift b/Stripe/StripeiOSTests/STPPaymentMethodiDEALTest.swift new file mode 100644 index 00000000..24f7cc65 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentMethodiDEALTest.swift @@ -0,0 +1,37 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPPaymentMethodiDEALTest.m +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 3/9/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +class STPPaymentMethodiDEALTest: XCTestCase { + func exampleJson() -> [AnyHashable: Any]? { + return [ + "bank": "Rabobank", + "bic": "RABONL2U", + ] + } + + func testDecodedObjectFromAPIResponseRequiredFields() { + let requiredFields: [String]? = [] + + for field in requiredFields ?? [] { + var response = exampleJson() + response?.removeValue(forKey: field) + + XCTAssertNil(STPPaymentMethodiDEAL.decodedObject(fromAPIResponse: response)) + } + + XCTAssertNotNil(STPPaymentMethodiDEAL.decodedObject(fromAPIResponse: exampleJson())) + } + + func testDecodedObjectFromAPIResponseMapping() { + let response = exampleJson() + let ideal = STPPaymentMethodiDEAL.decodedObject(fromAPIResponse: response) + XCTAssertEqual(ideal?.bankName, "Rabobank") + XCTAssertEqual(ideal?.bankIdentifierCode, "RABONL2U") + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentOptionsViewControllerLocalizationTests.swift b/Stripe/StripeiOSTests/STPPaymentOptionsViewControllerLocalizationTests.swift new file mode 100644 index 00000000..b4ad29f1 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentOptionsViewControllerLocalizationTests.swift @@ -0,0 +1,106 @@ +// +// STPPaymentOptionsViewControllerLocalizationTests.swift +// StripeiOS Tests +// +// Created by Brian Dorfman on 10/17/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class MockSTPPaymentOptionsViewControllerDelegate: NSObject, STPPaymentOptionsViewControllerDelegate +{ + func paymentOptionsViewController( + _ paymentOptionsViewController: STPPaymentOptionsViewController, + didFailToLoadWithError error: Error + ) { + } + + func paymentOptionsViewControllerDidFinish( + _ paymentOptionsViewController: STPPaymentOptionsViewController + ) { + } + + func paymentOptionsViewControllerDidCancel( + _ paymentOptionsViewController: STPPaymentOptionsViewController + ) { + } + +} + +class STPPaymentOptionsViewControllerLocalizationTests: FBSnapshotTestCase { + override func setUp() { + super.setUp() + + // self.recordMode = true; + } + + func performSnapshotTest(forLanguage language: String?) { + let config = STPPaymentConfiguration() + config.companyName = "Test Company" + config.requiredBillingAddressFields = .full + let theme = STPTheme.defaultTheme + let paymentMethods = [STPFixtures.paymentMethod(), STPFixtures.paymentMethod()] + let customerContext = Testing_StaticCustomerContext.init( + customer: STPFixtures.customerWithCardTokenAndSourceSources(), + paymentMethods: paymentMethods + ) + let delegate = MockSTPPaymentOptionsViewControllerDelegate() + STPLocalizationUtils.overrideLanguage(to: language) + let paymentOptionsVC = STPPaymentOptionsViewController( + configuration: config, + theme: theme, + customerContext: customerContext, + delegate: delegate + ) + let didLoadExpectation = expectation(description: "VC did load") + + paymentOptionsVC.loadingPromise?.onSuccess({ (_) in + didLoadExpectation.fulfill() + }) + wait(for: [didLoadExpectation].compactMap { $0 }, timeout: 2) + + let viewToTest = stp_preparedAndSizedViewForSnapshotTest(from: paymentOptionsVC)! + + STPSnapshotVerifyView(viewToTest, identifier: nil) + STPLocalizationUtils.overrideLanguage(to: nil) + } + + func testGerman() { + performSnapshotTest(forLanguage: "de") + } + + func testEnglish() { + performSnapshotTest(forLanguage: "en") + } + + func testSpanish() { + performSnapshotTest(forLanguage: "es") + } + + func testFrench() { + performSnapshotTest(forLanguage: "fr") + } + + func testItalian() { + performSnapshotTest(forLanguage: "it") + } + + func testJapanese() { + performSnapshotTest(forLanguage: "ja") + } + + func testDutch() { + performSnapshotTest(forLanguage: "nl") + } + + func testChinese() { + performSnapshotTest(forLanguage: "zh-Hans") + } +} diff --git a/Stripe/StripeiOSTests/STPPaymentOptionsViewControllerTest.swift b/Stripe/StripeiOSTests/STPPaymentOptionsViewControllerTest.swift new file mode 100644 index 00000000..9a487ae9 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPaymentOptionsViewControllerTest.swift @@ -0,0 +1,351 @@ +// +// STPPaymentOptionsViewControllerTest.swift +// StripeiOS Tests +// +// Created by Brian Dorfman on 10/10/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import OCMock + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPaymentOptionsViewControllerTest: XCTestCase { + class MockSTPPaymentOptionsViewControllerDelegate: NSObject, + STPPaymentOptionsViewControllerDelegate + { + var didFail = false + func paymentOptionsViewController( + _ paymentOptionsViewController: STPPaymentOptionsViewController, + didFailToLoadWithError error: Error + ) { + didFail = true + } + + var didFinish = false + func paymentOptionsViewControllerDidFinish( + _ paymentOptionsViewController: STPPaymentOptionsViewController + ) { + didFinish = true + } + + var didCancel = false + func paymentOptionsViewControllerDidCancel( + _ paymentOptionsViewController: STPPaymentOptionsViewController + ) { + didCancel = true + } + + var didSelect = false + func paymentOptionsViewController( + _ paymentOptionsViewController: STPPaymentOptionsViewController, + didSelect paymentOption: STPPaymentOption + ) { + didSelect = true + } + } + + func buildViewController( + with customer: STPCustomer, + paymentMethods: [STPPaymentMethod], + configuration config: STPPaymentConfiguration, + delegate: STPPaymentOptionsViewControllerDelegate + ) -> STPPaymentOptionsViewController { + let mockCustomerContext = Testing_StaticCustomerContext( + customer: customer, + paymentMethods: paymentMethods + ) + return buildViewController( + with: mockCustomerContext, + configuration: config, + delegate: delegate + ) + } + + func buildViewController( + with customerContext: STPCustomerContext, + configuration config: STPPaymentConfiguration, + delegate: STPPaymentOptionsViewControllerDelegate + ) -> STPPaymentOptionsViewController { + let theme = STPTheme.defaultTheme + let vc = STPPaymentOptionsViewController( + configuration: config, + theme: theme, + customerContext: customerContext, + delegate: delegate + ) + let didLoadExpectation = expectation(description: "VC did load") + vc.loadingPromise?.onSuccess({ (_) in + didLoadExpectation.fulfill() + }) + + wait(for: [didLoadExpectation], timeout: 2) + + return vc + } + + /// When the customer has no sources, and card is the sole available payment + /// method, STPAddCardViewController should be shown. + func testInitWithNoSourcesAndConfigWithUseSourcesOffAndCardAvailable() { + let customer = STPFixtures.customerWithNoSources() + let config = STPPaymentConfiguration() + config.applePayEnabled = false + let delegate = MockSTPPaymentOptionsViewControllerDelegate() + let sut = buildViewController( + with: customer, + paymentMethods: [], + configuration: config, + delegate: delegate + ) + XCTAssertTrue((sut.internalViewController is STPAddCardViewController)) + } + + /// When the customer has a single card token source and the available payment methods + /// are card and apple pay, STPPaymentOptionsInternalVC should be shown. + func testInitWithSingleCardTokenSourceAndCardAvailable() { + let customer = STPFixtures.customerWithSingleCardTokenSource() + let paymentMethods = [STPFixtures.paymentMethod()] + let config = STPPaymentConfiguration() + let delegate = MockSTPPaymentOptionsViewControllerDelegate() + let sut = buildViewController( + with: customer, + paymentMethods: paymentMethods.compactMap { $0 }, + configuration: config, + delegate: delegate + ) + XCTAssertTrue((sut.internalViewController is STPPaymentOptionsInternalViewController)) + } + + /// When the customer has a single card source source and the available payment methods + /// are card only, STPPaymentOptionsInternalVC should be shown. + func testInitWithSingleCardSourceSourceAndCardAvailable() { + let customer = STPFixtures.customerWithSingleCardSourceSource() + let paymentMethods = [STPFixtures.paymentMethod()] + let config = STPPaymentConfiguration() + config.applePayEnabled = false + let delegate = MockSTPPaymentOptionsViewControllerDelegate() + let sut = buildViewController( + with: customer, + paymentMethods: paymentMethods.compactMap { $0 }, + configuration: config, + delegate: delegate + ) + XCTAssertTrue((sut.internalViewController is STPPaymentOptionsInternalViewController)) + } + + /// Tapping cancel in an internal AddCard view controller should result in a call to + /// didCancel: + func testAddCardCancelForwardsToDelegate() { + let customer = STPFixtures.customerWithNoSources() + let config = STPPaymentConfiguration() + let delegate = MockSTPPaymentOptionsViewControllerDelegate() + let sut = buildViewController( + with: customer, + paymentMethods: [], + configuration: config, + delegate: delegate + ) + XCTAssertTrue((sut.internalViewController is STPAddCardViewController)) + let cancelButton = sut.internalViewController?.navigationItem.leftBarButtonItem + cancelButton?.target?.perform(cancelButton?.action, with: cancelButton) + + XCTAssertTrue(delegate.didCancel) + } + + /// Tapping cancel in an internal PaymentOptionsInternal view controller should + /// result in a call to didCancel: + func testInternalCancelForwardsToDelegate() { + let customer = STPFixtures.customerWithSingleCardTokenSource() + let paymentMethods = [STPFixtures.paymentMethod()] + let config = STPPaymentConfiguration() + let delegate = MockSTPPaymentOptionsViewControllerDelegate() + let sut = buildViewController( + with: customer, + paymentMethods: paymentMethods.compactMap { $0 }, + configuration: config, + delegate: delegate + ) + XCTAssertTrue((sut.internalViewController is STPPaymentOptionsInternalViewController)) + let cancelButton = sut.internalViewController?.navigationItem.leftBarButtonItem + _ = cancelButton?.target?.perform(cancelButton?.action, with: cancelButton) + + XCTAssertTrue(delegate.didCancel) + } + + /// When an AddCard view controller creates a card payment method, it should be attached to the + /// customer and the correct delegate methods should be called. + func testAddCardAttachesToCustomerAndFinishes() { + let config = STPPaymentConfiguration() + let customer = STPFixtures.customerWithNoSources() + let mockCustomerContext = Testing_StaticCustomerContext( + customer: customer, + paymentMethods: [] + ) + let delegate = MockSTPPaymentOptionsViewControllerDelegate() + let sut = buildViewController( + with: mockCustomerContext, + configuration: config, + delegate: delegate + ) + XCTAssertNotNil(sut.view) + XCTAssertTrue((sut.internalViewController is STPAddCardViewController)) + + let internalVC = sut.internalViewController as? STPAddCardViewController + let exp = expectation(description: "completion") + let expectedPaymentMethod = STPFixtures.paymentMethod() + internalVC?.delegate?.addCardViewController( + internalVC!, + didCreatePaymentMethod: expectedPaymentMethod + ) { error in + XCTAssertNil(error) + exp.fulfill() + } + + let _: ((Any?) -> Bool)? = { obj in + let paymentMethod = obj as? STPPaymentMethod + return paymentMethod?.stripeId == expectedPaymentMethod.stripeId + } + XCTAssertTrue(mockCustomerContext.didAttach) + XCTAssertTrue(delegate.didSelect) + XCTAssertTrue(delegate.didFinish) + waitForExpectations(timeout: 2, handler: nil) + } + + // Tests for race condition where the promise for fetching payment methods + // finishes in the context of intializing the sut, and `addCardViewControllerFooterView` + // is set directly after init, while internalViewController is `STPAddCardViewController` + func testSetAfterInit_addCardViewControllerFooterView_STPAddCardViewController() { + let customer = STPFixtures.customerWithNoSources() + let config = STPPaymentConfiguration() + config.applePayEnabled = false + let delegate = MockSTPPaymentOptionsViewControllerDelegate() + let sut = buildViewController( + with: customer, + paymentMethods: [], + configuration: config, + delegate: delegate + ) + sut.addCardViewControllerFooterView = UIView() + guard let payMethodsInternal = sut.internalViewController as? STPAddCardViewController else { + XCTFail() + return + } + XCTAssertNotNil(payMethodsInternal.customFooterView) + } + + // Tests for race condition where the promise for fetching payment methods + // finishes in the context of intializing the sut, and the `paymentOptionsViewControllerFooterView` + // is set directly after init, while internalViewController is `STPPaymentOptionsInternalViewController` + func testSetAfterInit_paymentOptionsViewControllerFooterView_STPPaymentOptionsInternalViewController() { + let customer = STPFixtures.customerWithSingleCardTokenSource() + let paymentMethods = [STPFixtures.paymentMethod()] + let config = STPPaymentConfiguration() + let delegate = MockSTPPaymentOptionsViewControllerDelegate() + let sut = buildViewController( + with: customer, + paymentMethods: paymentMethods.compactMap { $0 }, + configuration: config, + delegate: delegate + ) + sut.paymentOptionsViewControllerFooterView = UIView() + guard let payMethodsInternal = sut.internalViewController as? STPPaymentOptionsInternalViewController else { + XCTFail() + return + } +#if compiler(>=5.7) + XCTAssertNotNil(payMethodsInternal.customFooterView) +#endif + } + + // Tests for race condition where the promise for fetching payment methods + // finishes in the context of init the sut, and the `addCardViewControllerFooterView` + // is set directly after init, while internalViewController is `STPPaymentOptionsInternalViewController` + func testSetAfterInit_addCardViewControllerFooterView_STPPaymentOptionsInternalViewController() { + let customer = STPFixtures.customerWithSingleCardTokenSource() + let paymentMethods = [STPFixtures.paymentMethod()] + let config = STPPaymentConfiguration() + let delegate = MockSTPPaymentOptionsViewControllerDelegate() + let sut = buildViewController( + with: customer, + paymentMethods: paymentMethods.compactMap { $0 }, + configuration: config, + delegate: delegate + ) + sut.addCardViewControllerFooterView = UIView() + guard let payMethodsInternal = sut.internalViewController as? STPPaymentOptionsInternalViewController else { + XCTFail() + return + } +#if compiler(>=5.7) + XCTAssertNotNil(payMethodsInternal.addCardViewControllerCustomFooterView) +#endif + } + + // Tests for race condition where the promise for fetching payment methods + // finishes in the context of init the sut, and the `prefilledInformation` + // is set directly after init, while internalViewController is `STPPaymentOptionsInternalViewController` + func testSetAfterInit_prefilledInformation_STPPaymentOptionsInternalViewController() { + let customer = STPFixtures.customerWithSingleCardTokenSource() + let paymentMethods = [STPFixtures.paymentMethod()] + let config = STPPaymentConfiguration() + let delegate = MockSTPPaymentOptionsViewControllerDelegate() + let sut = buildViewController( + with: customer, + paymentMethods: paymentMethods.compactMap { $0 }, + configuration: config, + delegate: delegate + ) + let userInformation = STPUserInformation() + let address = STPAddress() + address.name = "John Doe" + address.line1 = "123 Main" + address.city = "Seattle" + address.state = "Washington" + address.postalCode = "98104" + address.phone = "2065551234" + userInformation.billingAddress = address + sut.prefilledInformation = userInformation + guard let payMethodsInternal = sut.internalViewController as? STPPaymentOptionsInternalViewController else { + XCTFail() + return + } +#if compiler(>=5.7) + XCTAssertNotNil(payMethodsInternal.prefilledInformation) +#endif + } + + // Tests for race condition where the promise for fetching payment methods + // finishes in the context of init the sut, and the `prefilledInformation` + // is set directly after init, while internalViewController is `STPAddCardViewController` + func testSetAfterInit_prefilledInformation_STPAddCardViewController() { + let customer = STPFixtures.customerWithNoSources() + let config = STPPaymentConfiguration() + config.applePayEnabled = false + let delegate = MockSTPPaymentOptionsViewControllerDelegate() + let sut = buildViewController( + with: customer, + paymentMethods: [], + configuration: config, + delegate: delegate + ) + let userInformation = STPUserInformation() + let address = STPAddress() + address.name = "John Doe" + address.line1 = "123 Main" + address.city = "Seattle" + address.state = "Washington" + address.postalCode = "98104" + address.phone = "2065551234" + userInformation.billingAddress = address + sut.prefilledInformation = userInformation + guard let payMethodsInternal = sut.internalViewController as? STPAddCardViewController else { + XCTFail() + return + } + XCTAssertNotNil(payMethodsInternal.prefilledInformation) + } +} diff --git a/Stripe/StripeiOSTests/STPPhoneNumberValidatorTest.swift b/Stripe/StripeiOSTests/STPPhoneNumberValidatorTest.swift new file mode 100644 index 00000000..a1baab25 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPhoneNumberValidatorTest.swift @@ -0,0 +1,137 @@ +// +// STPPhoneNumberValidatorTest.swift +// StripeiOS Tests +// +// Created by Ben Guo on 3/22/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +private let kUSCountryCode = "US" +private let kUKCountryCode = "UK" +class STPPhoneNumberValidatorTest: XCTestCase { + func testValidPhoneNumbers() { + XCTAssertTrue( + STPPhoneNumberValidator.stringIsValidPhoneNumber( + "555-555-5555", + forCountryCode: kUSCountryCode + ) + ) + XCTAssertTrue( + STPPhoneNumberValidator.stringIsValidPhoneNumber( + "5555555555", + forCountryCode: kUSCountryCode + ) + ) + XCTAssertTrue( + STPPhoneNumberValidator.stringIsValidPhoneNumber( + "(555) 555-5555", + forCountryCode: kUSCountryCode + ) + ) + } + + func testInvalidPhoneNumbers() { + XCTAssertFalse( + STPPhoneNumberValidator.stringIsValidPhoneNumber("", forCountryCode: kUSCountryCode) + ) + XCTAssertFalse( + STPPhoneNumberValidator.stringIsValidPhoneNumber( + "555-555-555", + forCountryCode: kUSCountryCode + ) + ) + XCTAssertFalse( + STPPhoneNumberValidator.stringIsValidPhoneNumber( + "555-555-A555", + forCountryCode: kUSCountryCode + ) + ) + XCTAssertTrue( + STPPhoneNumberValidator.stringIsValidPhoneNumber( + "55555555555", + forCountryCode: kUSCountryCode + ) + ) + } + + func testFormattedSanitizedPhoneNumberForString() { + XCTAssertEqual( + STPPhoneNumberValidator.formattedSanitizedPhoneNumber( + for: "55", + forCountryCode: kUSCountryCode + ), + "55" + ) + XCTAssertEqual( + STPPhoneNumberValidator.formattedSanitizedPhoneNumber( + for: "555", + forCountryCode: kUSCountryCode + ), + "(555) " + ) + XCTAssertEqual( + STPPhoneNumberValidator.formattedSanitizedPhoneNumber( + for: "55555", + forCountryCode: kUSCountryCode + ), + "(555) 55" + ) + XCTAssertEqual( + STPPhoneNumberValidator.formattedSanitizedPhoneNumber( + for: "A-55555", + forCountryCode: kUSCountryCode + ), + "(555) 55" + ) + XCTAssertEqual( + STPPhoneNumberValidator.formattedSanitizedPhoneNumber( + for: "5555555", + forCountryCode: kUSCountryCode + ), + "(555) 555-5" + ) + XCTAssertEqual( + STPPhoneNumberValidator.formattedSanitizedPhoneNumber( + for: "5555555555", + forCountryCode: kUSCountryCode + ), + "(555) 555-5555" + ) + XCTAssertEqual( + STPPhoneNumberValidator.formattedSanitizedPhoneNumber( + for: "5555555555123", + forCountryCode: kUSCountryCode + ), + "(555) 555-5555" + ) + XCTAssertEqual( + STPPhoneNumberValidator.formattedSanitizedPhoneNumber( + for: "5555555555123", + forCountryCode: kUKCountryCode + ), + "5555555555123" + ) + } + + func testFormattedRedactedPhoneNumberForString() { + XCTAssertEqual( + STPPhoneNumberValidator.formattedRedactedPhoneNumber( + for: "+1******1234", + forCountryCode: kUSCountryCode + ), + "+1 (•••) •••-1234" + ) + XCTAssertEqual( + STPPhoneNumberValidator.formattedRedactedPhoneNumber( + for: "+86******1234", + forCountryCode: kUKCountryCode + ), + "+86 ••••••1234" + ) + } +} diff --git a/Stripe/StripeiOSTests/STPPinManagementServiceFunctionalTest.swift b/Stripe/StripeiOSTests/STPPinManagementServiceFunctionalTest.swift new file mode 100644 index 00000000..f778ed90 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPinManagementServiceFunctionalTest.swift @@ -0,0 +1,104 @@ +// +// STPPinManagementServiceFunctionalTest.swift +// StripeiOS Tests +// +// Created by Arnaud Cavailhez on 4/29/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +import PassKit +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable import StripePaymentsTestUtils +@testable@_spi(STP) import StripePaymentsUI + +class TestEphemeralKeyProvider: NSObject, STPIssuingCardEphemeralKeyProvider { + func createIssuingCardKey( + withAPIVersion apiVersion: String, + completion: STPJSONResponseCompletionBlock + ) { + print("apiVersion \(apiVersion)") + let response = + [ + "id": "ephkey_token", + "object": "ephemeral_key", + "associated_objects": [ + [ + "type": "issuing.card", + "id": "ic_token", + ], + ], + "created": NSNumber(value: 1_556_656_558), + "expires": NSNumber(value: 1_556_660_158), + "livemode": NSNumber(value: true), + "secret": "ek_live_secret", + ] as [String: Any] + completion(response, nil) + } +} + +class STPPinManagementServiceFunctionalTest: STPNetworkStubbingTestCase { + override func setUp() { + // self.recordingMode = YES; + super.setUp() + } + + func testRetrievePin() { + let keyProvider = TestEphemeralKeyProvider() + let service = STPPinManagementService(keyProvider: keyProvider) + + let expectation = self.expectation(description: "Received PIN") + + service.retrievePin( + "ic_token", + verificationId: "iv_token", + oneTimeCode: "123456" + ) { cardPin, status, error in + if error == nil && status == .success && (cardPin?.pin == "2345") { + expectation.fulfill() + } + } + waitForExpectations(timeout: 5.0, handler: nil) + } + + func testUpdatePin() { + let keyProvider = TestEphemeralKeyProvider() + let service = STPPinManagementService(keyProvider: keyProvider) + + let expectation = self.expectation(description: "Received PIN") + + service.updatePin( + "ic_token", + newPin: "3456", + verificationId: "iv_token", + oneTimeCode: "123-456" + ) { cardPin, status, error in + if error == nil && status == .success && (cardPin?.pin == "3456") { + expectation.fulfill() + } + } + waitForExpectations(timeout: 5.0, handler: nil) + } + + func testRetrievePinWithError() { + let keyProvider = TestEphemeralKeyProvider() + let service = STPPinManagementService(keyProvider: keyProvider) + + let expectation = self.expectation(description: "Received Error") + + service.retrievePin( + "ic_token", + verificationId: "iv_token", + oneTimeCode: "123456" + ) { _, status, _ in + if status == .errorVerificationAlreadyRedeemed { + expectation.fulfill() + } + } + waitForExpectations(timeout: 5.0, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPPostalCodeInputTextFieldFormatterTests.swift b/Stripe/StripeiOSTests/STPPostalCodeInputTextFieldFormatterTests.swift new file mode 100644 index 00000000..5a664d9e --- /dev/null +++ b/Stripe/StripeiOSTests/STPPostalCodeInputTextFieldFormatterTests.swift @@ -0,0 +1,58 @@ +// +// STPPostalCodeInputTextFieldFormatterTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/30/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPostalCodeInputTextFieldFormatterTests: XCTestCase { + + func testIsAllowedInput() { + let formatter = STPPostalCodeInputTextFieldFormatter() + formatter.countryCode = "US" + XCTAssertTrue(formatter.isAllowedInput("10002", to: "", at: NSRange(location: 0, length: 0))) + XCTAssertTrue(formatter.isAllowedInput("21218", to: "", at: NSRange(location: 0, length: 0))) + XCTAssertFalse(formatter.isAllowedInput("10002-1234", to: "", at: NSRange(location: 0, length: 0))) + XCTAssertFalse(formatter.isAllowedInput("100021234", to: "", at: NSRange(location: 0, length: 0))) + XCTAssertFalse(formatter.isAllowedInput("ABC10002", to: "", at: NSRange(location: 0, length: 0))) + + XCTAssertFalse(formatter.isAllowedInput("1", to: "100021234", at: NSRange(location: 10, length: 0))) + XCTAssertFalse(formatter.isAllowedInput("1", to: "10002", at: NSRange(location: 4, length: 0))) + XCTAssertTrue(formatter.isAllowedInput("1", to: "1000", at: NSRange(location: 4, length: 0))) + + formatter.countryCode = "UK" + XCTAssertTrue(formatter.isAllowedInput("10002-1234", to: "", at: NSRange(location: 0, length: 0))) + XCTAssertTrue(formatter.isAllowedInput("100021234", to: "", at: NSRange(location: 0, length: 0))) + XCTAssertTrue(formatter.isAllowedInput("ABC10002", to: "", at: NSRange(location: 0, length: 0))) + } + + func testFormattedString() { + let formatter = STPPostalCodeInputTextFieldFormatter() + formatter.countryCode = "US" + + XCTAssertEqual( + NSAttributedString(string: ""), + formatter.formattedText(from: "- ", with: [:]) + ) + XCTAssertEqual( + NSAttributedString(string: "10002"), + formatter.formattedText(from: "10002-1234", with: [:]) + ) + + formatter.countryCode = "UK" + XCTAssertEqual( + NSAttributedString(string: "A B C D E F G"), + formatter.formattedText(from: " a b c d e f g", with: [:]) + ) + } + +} diff --git a/Stripe/StripeiOSTests/STPPostalCodeInputTextFieldSnapshotTests.swift b/Stripe/StripeiOSTests/STPPostalCodeInputTextFieldSnapshotTests.swift new file mode 100644 index 00000000..63540878 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPostalCodeInputTextFieldSnapshotTests.swift @@ -0,0 +1,77 @@ +// +// STPPostalCodeInputTextFieldSnapshotTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/30/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPostalCodeInputTextFieldSnapshotTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() + // recordMode = true + } + + func testEmpty() { + let field = STPPostalCodeInputTextField(postalCodeRequirement: .standard) + field.sizeToFit() + field.frame.size.width = 200 + + STPSnapshotVerifyView(field) + } + + func testIncomplete() { + let field = STPPostalCodeInputTextField(postalCodeRequirement: .standard) + field.sizeToFit() + field.frame.size.width = 200 + field.countryCode = "US" + field.text = "1" + field.textDidChange() + + STPSnapshotVerifyView(field) + } + + func testValidUS() { + let field = STPPostalCodeInputTextField(postalCodeRequirement: .standard) + field.sizeToFit() + field.frame.size.width = 200 + field.countryCode = "US" + field.text = "12345" + field.textDidChange() + + STPSnapshotVerifyView(field) + } + + func testValidUK() { + let field = STPPostalCodeInputTextField(postalCodeRequirement: .standard) + field.sizeToFit() + field.frame.size.width = 200 + field.countryCode = "UK" + field.text = "abcdef" + field.textDidChange() + + STPSnapshotVerifyView(field) + } + + func testInvalid() { + let field = STPPostalCodeInputTextField(postalCodeRequirement: .standard) + field.sizeToFit() + field.frame.size.width = 200 + field.countryCode = "US" + field.text = "12-3456789" + field.textDidChange() + // manually set because the formatter prevents setting invalid text + field.validator.validationState = .invalid(errorMessage: nil) + + STPSnapshotVerifyView(field) + } +} diff --git a/Stripe/StripeiOSTests/STPPostalCodeInputTextFieldTests.swift b/Stripe/StripeiOSTests/STPPostalCodeInputTextFieldTests.swift new file mode 100644 index 00000000..d9ce772a --- /dev/null +++ b/Stripe/StripeiOSTests/STPPostalCodeInputTextFieldTests.swift @@ -0,0 +1,69 @@ +// +// STPPostalCodeInputTextFieldTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 9/3/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPostalCodeInputTextFieldTests: XCTestCase { + + func testClearingInvalidPostalCodeAfterCountryChange() { + let postalCodeField = STPPostalCodeInputTextField(postalCodeRequirement: .standard) + postalCodeField.countryCode = "UK" + postalCodeField.text = "DL12" // valid UK post code, invalid US ZIP Code + + // Change country + postalCodeField.countryCode = "US" + + XCTAssertEqual( + postalCodeField.text, + "", + "Postal code field should clear its value if no longer valid after country change" + ) + } + + func testPreservingValidPostalCodeAfterCountryChange() { + let postalCodeField = STPPostalCodeInputTextField(postalCodeRequirement: .standard) + postalCodeField.countryCode = "US" + postalCodeField.text = "10010" // valid US and HR ZIP/postal code + + // Change country + postalCodeField.countryCode = "HR" + + XCTAssertEqual( + postalCodeField.text, + "10010", + "Postal code field should preserve its value if it is still valid after country change" + ) + } + + func testChangeToNonRequiredPostalCodeIsValid() { + let postalCodeField = STPPostalCodeInputTextField(postalCodeRequirement: .upe) + // given that the postal code field is empty... + + // when + postalCodeField.countryCode = "US" + if case .incomplete = postalCodeField.validationState { + // pass + } else { + XCTFail("Empty postal code should be incomplete for US") + } + + // when + postalCodeField.countryCode = "FR" + if case .valid = postalCodeField.validationState { + // pass + } else { + XCTFail("Empty postal code should be valid for non-required country") + } + } +} diff --git a/Stripe/StripeiOSTests/STPPostalCodeInputTextFieldValidatorTests.swift b/Stripe/StripeiOSTests/STPPostalCodeInputTextFieldValidatorTests.swift new file mode 100644 index 00000000..edee1d92 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPostalCodeInputTextFieldValidatorTests.swift @@ -0,0 +1,79 @@ +// +// STPPostalCodeInputTextFieldValidatorTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/30/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPostalCodeInputTextFieldValidatorTests: XCTestCase { + + func testValidation() { + let validator = STPPostalCodeInputTextFieldValidator(postalCodeRequirement: .standard) + validator.countryCode = "US" + + validator.inputValue = nil + XCTAssertEqual( + STPValidatedInputState.incomplete(description: nil), + validator.validationState + ) + + validator.inputValue = "" + XCTAssertEqual( + STPValidatedInputState.incomplete(description: nil), + validator.validationState + ) + + validator.inputValue = "1234" + XCTAssertEqual( + STPValidatedInputState.incomplete(description: "Your ZIP is incomplete."), + validator.validationState + ) + + validator.inputValue = "12345" + XCTAssertEqual(STPValidatedInputState.valid(message: nil), validator.validationState) + + validator.inputValue = "12345678" + XCTAssertEqual( + STPValidatedInputState.incomplete(description: "Your ZIP is incomplete."), + validator.validationState + ) + + validator.inputValue = "123456789" + XCTAssertEqual(STPValidatedInputState.valid(message: nil), validator.validationState) + + validator.inputValue = "12345-6789" + XCTAssertEqual(STPValidatedInputState.valid(message: nil), validator.validationState) + + validator.inputValue = "12-3456789" + XCTAssertEqual( + STPValidatedInputState.invalid(errorMessage: "Your ZIP is invalid."), + validator.validationState + ) + + validator.inputValue = "12345-" + XCTAssertEqual( + STPValidatedInputState.incomplete(description: "Your ZIP is incomplete."), + validator.validationState + ) + + validator.inputValue = "hi" + XCTAssertEqual( + STPValidatedInputState.invalid(errorMessage: "Your ZIP is invalid."), + validator.validationState + ) + + validator.countryCode = "UK" + validator.inputValue = "hi" + XCTAssertEqual(STPValidatedInputState.valid(message: nil), validator.validationState) + } + +} diff --git a/Stripe/StripeiOSTests/STPPostalCodeValidatorTest.swift b/Stripe/StripeiOSTests/STPPostalCodeValidatorTest.swift new file mode 100644 index 00000000..04c47d73 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPostalCodeValidatorTest.swift @@ -0,0 +1,103 @@ +// +// STPPostalCodeValidatorTest.swift +// StripeiOS Tests +// +// Created by Ben Guo on 4/14/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPPostalCodeValidatorTest: XCTestCase { + func testValidUSPostalCodes() { + let codes = ["10002", "10002-1234", "100021234", "21218"] + for code in codes { + XCTAssertEqual( + STPPostalCodeValidator.validationState( + forPostalCode: code, + countryCode: "US" + ), + .valid + ) + } + } + + func testInvalidUSPostalCodes() { + let codes = ["100A03", "12345-12345", "1234512345", "$$$$$", "foo"] + for code in codes { + XCTAssertEqual( + STPPostalCodeValidator.validationState( + forPostalCode: code, + countryCode: "US" + ), + .invalid + ) + } + } + + func testIncompleteUSPostalCodes() { + let codes = ["", "123", "12345-", "12345-12"] + for code in codes { + XCTAssertEqual( + STPPostalCodeValidator.validationState( + forPostalCode: code, + countryCode: "US" + ), + .incomplete + ) + } + } + + func testValidGenericPostalCodes() { + let codes = ["ABC10002", "10002-ABCD", "ABCDE"] + for code in codes { + XCTAssertEqual( + STPPostalCodeValidator.validationState( + forPostalCode: code, + countryCode: "UK" + ), + .valid + ) + } + } + + func testIncompleteGenericPostalCodes() { + let codes = [""] + for code in codes { + XCTAssertEqual( + STPPostalCodeValidator.validationState( + forPostalCode: code, + countryCode: "UK" + ), + .incomplete + ) + } + } + + func testPostalCodeIsRequiredForUPE_nil() { + XCTAssertFalse(STPPostalCodeValidator.postalCodeIsRequiredForUPE(forCountryCode: nil)) + } + + func testPostalCodeIsRequiredForUPE_empty() { + XCTAssertFalse(STPPostalCodeValidator.postalCodeIsRequiredForUPE(forCountryCode: "")) + } + + func testPostalCodeIsRequiredForUPE_CA() { + XCTAssertTrue(STPPostalCodeValidator.postalCodeIsRequiredForUPE(forCountryCode: "CA")) + } + + func testPostalCodeIsRequiredForUPE_GB() { + XCTAssertTrue(STPPostalCodeValidator.postalCodeIsRequiredForUPE(forCountryCode: "GB")) + } + + func testPostalCodeIsRequiredForUPE_US() { + XCTAssertTrue(STPPostalCodeValidator.postalCodeIsRequiredForUPE(forCountryCode: "CA")) + } + + func testPostalCodeIsRequiredForUPE_DK() { + XCTAssertFalse(STPPostalCodeValidator.postalCodeIsRequiredForUPE(forCountryCode: "DK")) + } +} diff --git a/Stripe/StripeiOSTests/STPPushProvisioningDetailsFunctionalTest.swift b/Stripe/StripeiOSTests/STPPushProvisioningDetailsFunctionalTest.swift new file mode 100644 index 00000000..4ddd4cc0 --- /dev/null +++ b/Stripe/StripeiOSTests/STPPushProvisioningDetailsFunctionalTest.swift @@ -0,0 +1,57 @@ +// +// STPPushProvisioningDetailsFunctionalTest.swift +// StripeiOS Tests +// +// Created by Jack Flintermann on 11/30/18. +// Copyright © 2018 Stripe, Inc. All rights reserved. +// + +import Stripe + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable import StripePaymentsTestUtils +@testable@_spi(STP) import StripePaymentsUI + +class STPPushProvisioningDetailsFunctionalTest: STPNetworkStubbingTestCase { + override func setUp() { + super.setUp() + } + + func testRetrievePushProvisioningDetails() { + // this API requires a secret key - replace the key below if you need to re-record the network traffic. + let client = STPAPIClient(publishableKey: "pk_test_REPLACEME") + let cardId = "ic_1C0Xig4JYtv6MPZK91WoXa9u" + let cert1 = + "MIID/TCCA6OgAwIBAgIIGM2CpiS9WyYwCgYIKoZIzj0EAwIwgYAxNDAyBgNVBAMMK0FwcGxlIFdvcmxkd2lkZSBEZXZlbG9wZXIgUmVsYXRpb25zIENBIC0gRzIxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzAeFw0xODA2MDEyMjE0MTVaFw0yMDA2MzAyMjE0MTVaMGwxMjAwBgNVBAMMKWVjYy1jcnlwdG8tc2VydmljZXMtZW5jaXBoZXJtZW50X1VDNi1QUk9EMRQwEgYDVQQLDAtpT1MgU3lzdGVtczETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASzCVyQGX3syyW2aI6nyfNQe+vjjzjU4rLO0ZiWiVZZSmEzYfACFI8tuDFiDLv9XWrHEeX0/yNtGVjwAzpanWb/o4ICGDCCAhQwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBSEtoTMOoZichZZlOgao71I3zrfCzBHBggrBgEFBQcBAQQ7MDkwNwYIKwYBBQUHMAGGK2h0dHA6Ly9vY3NwLmFwcGxlLmNvbS9vY3NwMDMtYXBwbGV3d2RyY2EyMDUwggEdBgNVHSAEggEUMIIBEDCCAQwGCSqGSIb3Y2QFATCB/jCBwwYIKwYBBQUHAgIwgbYMgbNSZWxpYW5jZSBvbiB0aGlzIGNlcnRpZmljYXRlIGJ5IGFueSBwYXJ0eSBhc3N1bWVzIGFjY2VwdGFuY2Ugb2YgdGhlIHRoZW4gYXBwbGljYWJsZSBzdGFuZGFyZCB0ZXJtcyBhbmQgY29uZGl0aW9ucyBvZiB1c2UsIGNlcnRpZmljYXRlIHBvbGljeSBhbmQgY2VydGlmaWNhdGlvbiBwcmFjdGljZSBzdGF0ZW1lbnRzLjA2BggrBgEFBQcCARYqaHR0cDovL3d3dy5hcHBsZS5jb20vY2VydGlmaWNhdGVhdXRob3JpdHkvMDYGA1UdHwQvMC0wK6ApoCeGJWh0dHA6Ly9jcmwuYXBwbGUuY29tL2FwcGxld3dkcmNhMi5jcmwwHQYDVR0OBBYEFI5aYtQKaJCRpvI1Dgh+Ra4x2iCrMA4GA1UdDwEB/wQEAwIDKDASBgkqhkiG92NkBicBAf8EAgUAMAoGCCqGSM49BAMCA0gAMEUCIAY/9gwN/KAAw3EtW3NyeX1UVM3fO+wVt0cbeHL8eM/mAiEAppLm5O/2Ox8uHkxI4U/kU5vDhJA21DRbzm2rsYN+EcQ=" + let cert2 = + "MIIC9zCCAnygAwIBAgIIb+/Y9emjp+4wCgYIKoZIzj0EAwIwZzEbMBkGA1UEAwwSQXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcNMTQwNTA2MjM0MzI0WhcNMjkwNTA2MjM0MzI0WjCBgDE0MDIGA1UEAwwrQXBwbGUgV29ybGR3aWRlIERldmVsb3BlciBSZWxhdGlvbnMgQ0EgLSBHMjEmMCQGA1UECwwdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE3fC3BkvP3XMEE8RDiQOTgPte9nStQmFSWAImUxnIYyIHCVJhysTZV+9tJmiLdJGMxPmAaCj8CWjwENrp0C7JGqOB9zCB9DBGBggrBgEFBQcBAQQ6MDgwNgYIKwYBBQUHMAGGKmh0dHA6Ly9vY3NwLmFwcGxlLmNvbS9vY3NwMDQtYXBwbGVyb290Y2FnMzAdBgNVHQ4EFgQUhLaEzDqGYnIWWZToGqO9SN863wswDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBS7sN6hWDOImqSKmd6+veuv2sskqzA3BgNVHR8EMDAuMCygKqAohiZodHRwOi8vY3JsLmFwcGxlLmNvbS9hcHBsZXJvb3RjYWczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEAYKKoZIhvdjZAYCDwQCBQAwCgYIKoZIzj0EAwIDaQAwZgIxANmxxzHGI/ZPTdDZR8V9GGkRh3En02it4Jtlmr5s3z9GppAJvm6hOyywUYlBPIfSvwIxAPxkUolLPF2/axzCiZgvcq61m6oaCyNUd1ToFUOixRLal1BzfF7QbrJcYlDXUfE6Wg==" + let nonce = "ea85a73a" + let nonceSignature = + "QBfCqTvDhmRcwqxJF3fDqzhXezIpwrpHFcOMw7/DvGVBwpfCuicwwqHCmMKYMD06w754wrjChcObwqjDr8K9wqxxUydQaMOyfsKGZMK4AcKMwqNfwoHDlcKLHsO5w7JqQiHDln7Du8KUNMOnwqpGwq/CqcKswo1Lw7s=" + let certs: [Data] = [ + Data(base64Encoded: cert1, options: [])!, + Data(base64Encoded: cert2, options: [])!, + ] + let expectation = self.expectation(description: "Push provisioning details") + let params = STPPushProvisioningDetailsParams( + cardId: "ic_1C0Xig4JYtv6MPZK91WoXa9u", + certificates: certs, + nonce: Data(base64Encoded: nonce, options: [])!, + nonceSignature: Data(base64Encoded: nonceSignature, options: [])! + ) + // To re-record this test, get an ephemeral key for the above Issuing card and pass that instead of [STPFixtures ephemeralKey] + let ephemeralKey = STPFixtures.ephemeralKey() + client.retrievePushProvisioningDetails(with: params, ephemeralKey: ephemeralKey) { + details, + error in + expectation.fulfill() + XCTAssertNil(error) + XCTAssert((details?.cardId == cardId)) + XCTAssertEqual(details, details) + } + waitForExpectations(timeout: 5.0, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPRadarSessionFunctionalTest.swift b/Stripe/StripeiOSTests/STPRadarSessionFunctionalTest.swift new file mode 100644 index 00000000..52f94747 --- /dev/null +++ b/Stripe/StripeiOSTests/STPRadarSessionFunctionalTest.swift @@ -0,0 +1,56 @@ +// +// STPRadarSessionFunctionalTest.swift +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 5/20/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPRadarSessionFunctionalTest: XCTestCase { + func testCreateWithoutInitialFraudDetection() { + // When fraudDetectionData is empty... + FraudDetectionData.shared.reset() + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let exp1 = expectation(description: "Create RadarSession") + client.createRadarSession { session, error in + // ...creates a Radar Session + XCTAssertNil(error) + guard let session = session else { + XCTFail() + return + } + XCTAssertTrue(session.id.count > 0) + exp1.fulfill() + } + + waitForExpectations(timeout: 10, handler: nil) + + // Now that fraudDetectionData is populated... + XCTAssertNotNil(FraudDetectionData.shared.sid) + XCTAssertNotNil(FraudDetectionData.shared.muid) + XCTAssertNotNil(FraudDetectionData.shared.guid) + + let exp2 = expectation(description: "Create RadarSession again") + client.createRadarSession { session, error in + // ...still creates a Radar Session + XCTAssertNil(error) + guard let session = session else { + XCTFail() + return + } + XCTAssertTrue(session.id.count > 0) + exp2.fulfill() + } + + waitForExpectations(timeout: 10, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPRedirectContextTest.swift b/Stripe/StripeiOSTests/STPRedirectContextTest.swift new file mode 100644 index 00000000..608bd52a --- /dev/null +++ b/Stripe/StripeiOSTests/STPRedirectContextTest.swift @@ -0,0 +1,625 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPRedirectContextTest.m +// Stripe +// +// Created by Ben Guo on 4/6/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import SafariServices +@_spi(STP) @testable import StripeCore +@testable import StripePayments + +class MockUIViewController: UIViewController { + var presentChecker: (UIViewController) -> Bool = { _ in return true } + var presentCalled: Bool = false + var dismiss: () -> Void = { } + override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { + presentCalled = true + if !presentChecker(viewControllerToPresent) { + XCTFail("checker failed") + } + super.present(viewControllerToPresent, animated: flag, completion: completion) + } + + override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { + super.dismiss(animated: flag, completion: completion) + dismiss() + } +} + +class MockUIApplication: UIApplicationProtocol { + var openHandler: (URL, ((Bool) -> Void)?) -> Void = { _, completion in completion?(true) } + var openCalled: Bool = false + + func _open(_ url: URL, options: [UIApplication.OpenExternalURLOptionsKey: Any], completionHandler completion: ((Bool) -> Void)?) { + openCalled = true + openHandler(url, completion) + } +} + +/* + NOTE: + + If you are adding a test make sure your context unsubscribes from notifications + before your test ends. Otherwise notifications fired from other tests can cause + a reaction in an earlier, completed test and cause strange failures. + + Possible ways to do this: + 1. Your sut should already be calling unsubscribe, verified by OCMVerify + - you're good + 2. Your sut doesn't call unsubscribe as part of the test but it's not explicitly + disallowed - call [sut unsubscribeFromNotifications] at the end of your test + 3. Your sut doesn't call unsubscribe and you explicitly OCMReject it firing + - call [self unsubscribeContext:context] at the end of your test (use + the original context object here and _NOT_ the sut or it will not work). + */ +class STPRedirectContextTest: XCTestCase { + weak var weak_sut: STPRedirectContext? + + /// Use this to unsubscribe a context from notifications without calling + /// `sut.unsubscrbeFromNotifications` if you have OCMReject'd that method and thus + /// can't call it. + /// Note: You MUST pass in the actual context object here and not the mock or the + /// unsubscibe will silently fail. + func unsubscribeContext(_ context: STPRedirectContext?) { + if let context { + NotificationCenter.default.removeObserver( + context, + name: UIApplication.didBecomeActiveNotification, + object: nil) + STPURLCallbackHandler.shared().unregisterListener(context) + } + } + + func testInitWithNonRedirectSourceReturnsNil() { + let source = STPFixtures.cardSource() + let sut = STPRedirectContext(source: source) { _, _, _ in + XCTFail("completion was called") + } + XCTAssertNil(sut) + } + + func testInitWithConsumedSourceReturnsNil() { + var json = STPTestUtils.jsonNamed(STPTestJSONSourceCard) + json?["status"] = "consumed" + let source = STPSource.decodedObject(fromAPIResponse: json)! + let sut = STPRedirectContext(source: source) { _, _, _ in + XCTFail("completion was called") + } + XCTAssertNil(sut) + } + + func testInitWithSource() { + let source = STPFixtures.iDEALSource() + var completionCalled = false + let fakeError = NSError(domain: NSCocoaErrorDomain, code: 0, userInfo: nil) + + let sut = STPRedirectContext(source: source) { sourceID, clientSecret, error in + XCTAssertEqual(source.stripeID, sourceID) + XCTAssertEqual(source.clientSecret, clientSecret) + XCTAssertEqual(error! as NSError, fakeError, "Should be the same NSError object passed to completion() below") + completionCalled = true + } + + // Make sure the initWithSource: method pulled out the right values from the Source + XCTAssertNil(sut?.nativeRedirectURL) + XCTAssertEqual(sut?.redirectURL, source.redirect?.url) + XCTAssertEqual(sut?.returnURL, source.redirect?.returnURL) + + // and make sure the completion calls the completion block above + sut?.completion(fakeError) + XCTAssertTrue(completionCalled) + } + + func testInitWithSourceWithNativeURL() { + let source = STPFixtures.alipaySourceWithNativeURL() + var completionCalled = false + let nativeURL = URL(string: source.details?["native_url"] as? String ?? "") + let fakeError = NSError(domain: NSCocoaErrorDomain, code: 0, userInfo: nil) + + let sut = STPRedirectContext(source: source) { sourceID, clientSecret, error in + XCTAssertEqual(source.stripeID, sourceID) + XCTAssertEqual(source.clientSecret, clientSecret) + XCTAssertEqual(error! as NSError, fakeError, "Should be the same NSError object passed to completion() below") + completionCalled = true + } + + // Make sure the initWithSource: method pulled out the right values from the Source + XCTAssertEqual(sut?.nativeRedirectURL, nativeURL) + XCTAssertEqual(sut?.redirectURL, source.redirect?.url) + XCTAssertEqual(sut?.returnURL, source.redirect?.returnURL) + + // and make sure the completion calls the completion block above + sut?.completion(fakeError) + XCTAssertTrue(completionCalled) + } + + func testInitWithPaymentIntent() { + let paymentIntent = STPFixtures.paymentIntent() + var completionCalled = false + let fakeError = NSError(domain: NSCocoaErrorDomain, code: 0, userInfo: nil) + + let sut = STPRedirectContext(paymentIntent: paymentIntent) { clientSecret, error in + XCTAssertEqual(paymentIntent.clientSecret, clientSecret) + XCTAssertEqual(error! as NSError, fakeError, "Should be the same NSError object passed to completion() below") + completionCalled = true + } + + // Make sure the initWithPaymentIntent: method pulled out the right values from the PaymentIntent + XCTAssertNil(sut?.nativeRedirectURL) + XCTAssertEqual( + sut?.redirectURL?.absoluteString, + "https://hooks.stripe.com/redirect/authenticate/src_1Cl1AeIl4IdHmuTb1L7x083A?client_secret=src_client_secret_DBNwUe9qHteqJ8qQBwNWiigk") + + // `nextSourceAction` & `authorizeWithURL` should just be aliases for `nextAction` & `redirectToURL`, already tested in `STPPaymentIntentTest` + XCTAssertNotNil(paymentIntent.nextAction?.redirectToURL?.returnURL) + XCTAssertEqual(sut?.returnURL, paymentIntent.nextAction?.redirectToURL?.returnURL) + + // and make sure the completion calls the completion block above + XCTAssertNotNil(sut) + sut?.completion(fakeError) + XCTAssertTrue(completionCalled) + } + + func testInitWithPaymentIntentFailures() { + // Note next_action has been renamed to next_source_action in the API, but both still get sent down in the 2015-10-12 API + let unusedCompletion: ((String?, Error?) -> Void) = { _, _ in + XCTFail("should not be constructed, definitely not completed") + } + + let create: (([AnyHashable: Any]) -> STPRedirectContext?) = { json in + let paymentIntent = STPPaymentIntent.decodedObject(fromAPIResponse: json)! + return STPRedirectContext( + paymentIntent: paymentIntent, + completion: unusedCompletion) + } + + var json = STPTestUtils.jsonNamed(STPTestJSONPaymentIntent)! + XCTAssertNotNil(create(json), "before mutation of json, creation should succeed") + + json["status"] = "processing" + XCTAssertNil(create(json), "not created with wrong status") + json["status"] = "requires_action" + + json[jsonDict: "next_action"]?["type"] = "not_redirect_to_url" + XCTAssertNil(create(json), "not created with wrong next_action.type") + json[jsonDict: "next_action"]?["type"] = "redirect_to_url" + + let correctURL = json[jsonDict: "next_action"]?[jsonDict: "redirect_to_url"]?["url"] as? String + json[jsonDict: "next_action"]?[jsonDict: "redirect_to_url"]?["url"] = "not a valid URL" + XCTAssertNil(create(json), "not created with an invalid URL in next_action.redirect_to_url.url") + json[jsonDict: "next_action"]?[jsonDict: "redirect_to_url"]?["url"] = correctURL ?? "" + + let correctReturnURL = json[jsonDict: "next_action"]?[jsonDict: "redirect_to_url"]?["return_url"] as? String + json[jsonDict: "next_action"]?[jsonDict: "redirect_to_url"]?["return_url"] = "not a url" + XCTAssertNil(create(json), "not created with invalid returnUrl") + json[jsonDict: "next_action"]?[jsonDict: "redirect_to_url"]?["return_url"] = correctReturnURL ?? "" + + XCTAssertNotNil(create(json), "works again when everything is back to normal") + } + + /// After starting a SafariViewController redirect flow, + /// when a DidBecomeActive notification is posted, RedirectContext's completion + /// block and dismiss method should _NOT_ be called. + func testSafariViewControllerRedirectFlow_activeNotification() { + let mockVC = MockUIViewController() + mockVC.presentChecker = { vc in + if vc is SFSafariViewController { + return true + } + return false + } + + let source = STPFixtures.iDEALSource() + + let sut = STPRedirectContext(source: source) { _, _, _ in + XCTFail("completion called") + }! + + sut.startSafariViewControllerRedirectFlow(from: mockVC) + NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) + unsubscribeContext(sut) + XCTAssertFalse(sut._unsubscribeFromNotificationsCalled) + XCTAssertFalse(sut._dismissPresentedViewControllerCalled) + XCTAssertTrue(mockVC.presentCalled) + } + + /// After starting a SafariViewController redirect flow, + /// when the shared URLCallbackHandler is called with a valid URL, + /// RedirectContext's completion block and dismiss method should be called. + func testSafariViewControllerRedirectFlow_callbackHandlerCalledValidURL() { + let mockVC = MockUIViewController() + let source = STPFixtures.iDEALSource() + mockVC.presentChecker = { vc in + if vc is SFSafariViewController { + let url = source.redirect!.returnURL + let comps = NSURLComponents(url: url, resolvingAgainstBaseURL: false)! + comps.stp_queryItemsDictionary = + [ + "source": source.stripeID, + "client_secret": source.clientSecret!, + ] + STPURLCallbackHandler.shared().handleURLCallback(comps.url!) + return true + } + return false + } + let exp = expectation(description: "completion") + let sut = STPRedirectContext(source: source) { sourceID, clientSecret, error in + XCTAssertEqual(sourceID, source.stripeID) + XCTAssertEqual(clientSecret, source.clientSecret) + XCTAssertNil(error) + exp.fulfill() + }! + XCTAssertEqual(source.redirect?.returnURL, sut.returnURL) + sut._handleRedirectCompletionWithErrorHook = { shouldDismissViewController in + if shouldDismissViewController { + sut.safariViewControllerDidCompleteDismissal(SFSafariViewController(url: URL(string: "https://www.stripe.com")!)) + } + } + + sut.startSafariViewControllerRedirectFlow(from: mockVC) + XCTAssertTrue(sut._unsubscribeFromNotificationsCalled) + XCTAssertTrue(sut._dismissPresentedViewControllerCalled) + XCTAssertTrue(mockVC.presentCalled) + waitForExpectations(timeout: 2, handler: nil) + } + + /// After starting a SafariViewController redirect flow, + /// when the shared URLCallbackHandler is called with an invalid URL, + /// RedirectContext's completion block and dismiss method should not be called. + func testSafariViewControllerRedirectFlow_callbackHandlerCalledInvalidURL() { + let mockVC = MockUIViewController() + let source = STPFixtures.iDEALSource() + let sut = STPRedirectContext(source: source) { _, _, _ in + XCTFail("completion called") + }! + + sut.startSafariViewControllerRedirectFlow(from: mockVC) + + mockVC.presentChecker = { vc in + if vc is SFSafariViewController { + let url = URL(string: "my-app://some_path")! + XCTAssertNotEqual(url, sut.returnURL) + STPURLCallbackHandler.shared().handleURLCallback(url) + return true + } + return false + } + + XCTAssertFalse(sut._unsubscribeFromNotificationsCalled) + XCTAssertFalse(sut._dismissPresentedViewControllerCalled) + XCTAssertTrue(mockVC.presentCalled) + unsubscribeContext(sut) + } + + /// After starting a SafariViewController redirect flow, + /// when SafariViewController finishes, RedirectContext's completion block + /// should be called. + func testSafariViewControllerRedirectFlow_didFinish() { + let mockVC = MockUIViewController() + let source = STPFixtures.iDEALSource() + + let exp = expectation(description: "completion") + let sut: STPRedirectContext = STPRedirectContext(source: source) { sourceID, clientSecret, error in + XCTAssertEqual(sourceID, source.stripeID) + XCTAssertEqual(clientSecret, source.clientSecret) + // because we are manually invoking the dismissal, we report this as a cancelation + let error = error! as NSError + XCTAssertEqual(error.domain, STPError.stripeDomain) + XCTAssertEqual(error.code, STPErrorCode.cancellationError.rawValue) + exp.fulfill() + }! + + sut._handleRedirectCompletionWithErrorHook = { shouldDismissViewController in + if !shouldDismissViewController { + sut.safariViewControllerDidCompleteDismissal(SFSafariViewController(url: URL(string: "https://www.stripe.com")!)) + } + } + mockVC.presentChecker = { vc in + if vc is SFSafariViewController { + let sfvc = vc as? SFSafariViewController + if let sfvc { + sfvc.delegate?.safariViewControllerDidFinish?(sfvc) + } + return true + } + return false + } + + sut.startSafariViewControllerRedirectFlow(from: mockVC) + + // dismiss should not be called – SafariVC dismisses itself when Done is tapped + XCTAssertFalse(sut._dismissPresentedViewControllerCalled) + XCTAssertTrue(sut._unsubscribeFromNotificationsCalled) + waitForExpectations(timeout: 2, handler: nil) + } + + /// After starting a SafariViewController redirect flow, + /// when SafariViewController fails to load the initial page (on iOS 11+ & without redirects), + /// RedirectContext's completion block and dismiss method should be called. + func testSafariViewControllerRedirectFlow_failedInitialLoad_iOS11Plus() { + + let mockVC = MockUIViewController() + let source = STPFixtures.iDEALSource() + let exp = expectation(description: "completion") + let sut = STPRedirectContext(source: source) { sourceID, clientSecret, error in + XCTAssertEqual(sourceID, source.stripeID) + XCTAssertEqual(clientSecret, source.clientSecret) + let expectedError = NSError.stp_genericConnectionError() + XCTAssertEqual(error! as NSError, expectedError) + exp.fulfill() + }! + + sut._handleRedirectCompletionWithErrorHook = { shouldDismissViewController in + if shouldDismissViewController { + sut.safariViewControllerDidCompleteDismissal(SFSafariViewController(url: URL(string: "https://www.stripe.com")!)) + } + } + + mockVC.presentChecker = { vc in + if vc is SFSafariViewController { + let sfvc = vc as? SFSafariViewController + if let sfvc { + sfvc.delegate?.safariViewController?(sfvc, didCompleteInitialLoad: false) + } + return true + } + return false + } + sut.startSafariViewControllerRedirectFlow(from: mockVC) + + XCTAssertTrue(sut._unsubscribeFromNotificationsCalled) + XCTAssertTrue(sut._dismissPresentedViewControllerCalled) + + waitForExpectations(timeout: 2, handler: nil) + } + + /// After starting a SafariViewController redirect flow, + /// when SafariViewController fails to load the initial page (on iOS 11+ after redirecting to non-Stripe page), + /// RedirectContext's completion block should not be called (SFVC keeps loading) + func testSafariViewControllerRedirectFlow_failedInitialLoadAfterRedirect_iOS11Plus() { + let mockVC = MockUIViewController() + let source = STPFixtures.iDEALSource() + let sut = STPRedirectContext(source: source) { _, _, _ in + XCTFail("completion called") + }! + + XCTAssertFalse(sut._unsubscribeFromNotificationsCalled) // move + XCTAssertFalse(sut._dismissPresentedViewControllerCalled) // move + + sut.startSafariViewControllerRedirectFlow(from: mockVC) + + mockVC.presentChecker = { vc in + if vc is SFSafariViewController { + let sfvc = vc as? SFSafariViewController + // before initial load is done, SFVC was redirected to a non-stripe.com domain + if let sfvc, let url = URL(string: "https://girogate.de") { + sfvc.delegate?.safariViewController?( + sfvc, + initialLoadDidRedirectTo: url) + } + // Tell the delegate that the initial load failed. + // on iOS 11, with the redirect, this is a no-op + if let sfvc { + sfvc.delegate?.safariViewController?(sfvc, didCompleteInitialLoad: false) + } + return true + } + return false + } + unsubscribeContext(sut) + } + + /// After starting a SafariViewController redirect flow, + /// when the RedirectContext is cancelled, its dismiss method should be called. + func testSafariViewControllerRedirectFlow_cancel() { + let mockVC = MockUIViewController() + let source = STPFixtures.iDEALSource() + let sut = STPRedirectContext(source: source) { _, _, _ in + XCTFail("completion called") + }! + + sut.startSafariViewControllerRedirectFlow(from: mockVC) + sut.cancel() + + XCTAssertTrue(mockVC.presentCalled) + XCTAssertTrue(sut._unsubscribeFromNotificationsCalled) + XCTAssertTrue(sut._dismissPresentedViewControllerCalled) + } + + /// After starting a SafariViewController redirect flow, + /// if no action is taken, nothing should be called. + func testSafariViewControllerRedirectFlow_noAction() { + let mockVC = MockUIViewController() + let source = STPFixtures.iDEALSource() + let sut = STPRedirectContext(source: source) { _, _, _ in + XCTFail("completion called") + }! + + sut.startSafariViewControllerRedirectFlow(from: mockVC) + XCTAssertTrue(mockVC.presentCalled) + XCTAssertFalse(sut._unsubscribeFromNotificationsCalled) + XCTAssertFalse(sut._dismissPresentedViewControllerCalled) + + unsubscribeContext(sut) + } + + /// After starting a Safari app redirect flow, + /// when a DidBecomeActive notification is posted, RedirectContext's completion + /// block and dismiss method should be called. + func testSafariAppRedirectFlow_activeNotification() { + let source = STPFixtures.iDEALSource() + let exp = expectation(description: "completion") + let sut = STPRedirectContext(source: source) { sourceID, clientSecret, error in + XCTAssertEqual(sourceID, source.stripeID) + XCTAssertEqual(clientSecret, source.clientSecret) + XCTAssertNil(error) + + exp.fulfill() + }! + + sut.startSafariAppRedirectFlow() + NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) + + waitForExpectations(timeout: 2, handler: nil) + XCTAssertTrue(sut._unsubscribeFromNotificationsCalled) + } + + /// After starting a Safari app redirect flow, + /// if no notification is posted, nothing should be called. + func testSafariAppRedirectFlow_noNotification() { + let source = STPFixtures.iDEALSource() + let sut = STPRedirectContext(source: source) { _, _, _ in + XCTFail("completion called") + }! + + sut.startSafariAppRedirectFlow() + XCTAssertFalse(sut._unsubscribeFromNotificationsCalled) + XCTAssertFalse(sut._dismissPresentedViewControllerCalled) + + unsubscribeContext(sut) + } + + /// If a source type that supports native redirect is used and it contains a native + /// url, an app to app redirect should attempt to be initiated. + func testNativeRedirectSupportingSourceFlow_validNativeURL() { + let source = STPFixtures.alipaySourceWithNativeURL() + let sourceURL = URL(string: source.details?["native_url"] as! String)! + + let sut = STPRedirectContext( + source: source) { _, _, _ in + XCTFail("completion called") + }! + + XCTAssertNotNil(sut.nativeRedirectURL) + XCTAssertEqual(sut.nativeRedirectURL, sourceURL) + + let applicationMock = MockUIApplication() + sut.application = applicationMock + applicationMock.openHandler = { url, completion in + XCTAssertTrue(url == sourceURL) + completion?(true) + } + + let mockVC = MockUIViewController() + sut.startRedirectFlow(from: mockVC) + XCTAssertFalse(sut._startSafariAppRedirectFlowCalled) + XCTAssertTrue(applicationMock.openCalled) + sut.unsubscribeFromNotifications() + } + + /// If a source type that supports native redirect is used and it does not + /// contain a native url, standard web view redirect should be attempted + func testNativeRedirectSupportingSourceFlow_invalidNativeURL() { + let source = STPFixtures.alipaySource() + let sut = STPRedirectContext( + source: source) { _, _, _ in + XCTFail("completion called") + }! + XCTAssertNil(sut.nativeRedirectURL) + + let applicationMock = MockUIApplication() + sut.application = applicationMock + + let mockVC = MockUIViewController() + mockVC.presentChecker = { $0 is SFSafariViewController } + sut.startRedirectFlow(from: mockVC) + + let expectation = self.expectation(description: "Waiting 100ms for SafariServices") + + // Hack: Wait ~100ms to call sut back before unsubscribing from notifications. Otherwise the Safari thread doesn't get the unsubscribe request in time and calls the deallocated sut, crashing the app. + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double(Int64(0.1 * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC), execute: { + expectation.fulfill() + }) + + waitForExpectations(timeout: 1, handler: nil) + XCTAssertFalse(applicationMock.openCalled) + XCTAssertTrue(mockVC.presentCalled) + sut.unsubscribeFromNotifications() + } + + // MARK: - WeChat Pay + + /// If a WeChat source type is used, we should attempt an app redirect. + func testWeChatPaySource_appRedirectSucceeds() { + let source = STPFixtures.weChatPaySource() + let sourceURL = URL(string: source.weChatPayDetails!.weChatAppURL!)! + + let sut = STPRedirectContext( + source: source) { _, _, _ in + XCTFail("completion called") + }! + + XCTAssertNotNil(sut.nativeRedirectURL) + XCTAssertEqual(sut.nativeRedirectURL, sourceURL) + XCTAssertNil(sut.redirectURL) + XCTAssertNotNil(sut.returnURL) + + let applicationMock = MockUIApplication() + applicationMock.openHandler = { url, completion in + XCTAssertEqual(url, sourceURL) + completion?(true) + } + sut.application = applicationMock + let mockVC = MockUIViewController() + sut.startRedirectFlow(from: mockVC) + let expectation = self.expectation(description: "Waiting 100ms for SafariServices") + + // Hack: Wait ~100ms to call sut back before unsubscribing from notifications. Otherwise the Safari thread doesn't get the unsubscribe request in time and calls the deallocated sut, crashing the app. + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double(Int64(0.1 * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC), execute: { + expectation.fulfill() + }) + + waitForExpectations(timeout: 1, handler: nil) + XCTAssertFalse(sut._startSafariAppRedirectFlowCalled) + XCTAssertFalse(sut.isSafariVCPresented()) + XCTAssertTrue(applicationMock.openCalled) + sut.unsubscribeFromNotifications() + } + + /// If a WeChat source type is used, we should attempt an app redirect. + /// If app redirect fails, expect an error. + func testWeChatPaySource_appRedirectFails() { + let source = STPFixtures.weChatPaySource() + let sourceURL = URL(string: source.weChatPayDetails!.weChatAppURL!)! + + let expectation = self.expectation(description: "Completion block called") + let sut = STPRedirectContext(source: source) { _, _, error in + guard let error = error as? NSError else { + XCTFail() + return + } + XCTAssertEqual(error.domain, STPRedirectContext.STPRedirectContextErrorDomain) + XCTAssertEqual(error.code, STPRedirectContextError.appRedirectError.rawValue) + expectation.fulfill() + }! + + XCTAssertNotNil(sut.nativeRedirectURL) + XCTAssertEqual(sut.nativeRedirectURL, sourceURL) + XCTAssertNil(sut.redirectURL) + XCTAssertNotNil(sut.returnURL) + + let applicationMock = MockUIApplication() + applicationMock.openHandler = { url, completion in + XCTAssertEqual(url, sourceURL) + completion?(false) + } + sut.application = applicationMock + let mockVC = MockUIViewController() + sut.startRedirectFlow(from: mockVC) + let safariWaitExpectation = self.expectation(description: "Waiting 100ms for SafariServices") + + // Hack: Wait ~100ms to call sut back before unsubscribing from notifications. Otherwise the Safari thread doesn't get the unsubscribe request in time and calls the deallocated sut, crashing the app. + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double(Int64(0.1 * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC), execute: { + safariWaitExpectation.fulfill() + }) + waitForExpectations(timeout: 10, handler: nil) + XCTAssertFalse(sut._startSafariAppRedirectFlowCalled) + XCTAssertFalse(sut.isSafariVCPresented()) + XCTAssertTrue(applicationMock.openCalled) + sut.unsubscribeFromNotifications() + } +} diff --git a/Stripe/StripeiOSTests/STPSetupIntentConfirmParamsTest.swift b/Stripe/StripeiOSTests/STPSetupIntentConfirmParamsTest.swift new file mode 100644 index 00000000..788be811 --- /dev/null +++ b/Stripe/StripeiOSTests/STPSetupIntentConfirmParamsTest.swift @@ -0,0 +1,156 @@ +// +// STPSetupIntentConfirmParamsTest.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 7/15/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPSetupIntentConfirmParamsTest: XCTestCase { + func testInit() { + for params in [ + STPSetupIntentConfirmParams(clientSecret: "secret"), + STPSetupIntentConfirmParams(), + STPSetupIntentConfirmParams(), + ] { + XCTAssertNotNil(params) + XCTAssertNotNil(params.clientSecret) + XCTAssertNotNil(params.additionalAPIParameters) + XCTAssertEqual(params.additionalAPIParameters.count, 0) + XCTAssertNil(params.paymentMethodID) + XCTAssertNil(params.returnURL) + XCTAssertNil(params.useStripeSDK) + XCTAssertNil(params.mandateData) + } + } + + func testDescription() { + let params = STPSetupIntentConfirmParams() + XCTAssertNotNil(params.description) + } + + func testDefaultMandateData() { + let params = STPSetupIntentConfirmParams() + + // no configuration should have no mandateData + XCTAssertNil(params.mandateData) + + params.paymentMethodParams = STPPaymentMethodParams() + + params.paymentMethodParams?.rawTypeString = "card" + // card type should have no default mandateData + XCTAssertNil(params.mandateData) + + for type in ["sepa_debit", "au_becs_debit", "bacs_debit"] { + params.mandateData = nil + params.paymentMethodParams?.rawTypeString = type + // Mandate-required type should have mandateData + XCTAssertNotNil(params.mandateData) + XCTAssertEqual( + params.mandateData?.customerAcceptance.onlineParams?.inferFromClient, + NSNumber(value: true) + ) + + let customerAcceptance = STPMandateCustomerAcceptanceParams( + type: .offline, + onlineParams: nil + ) + params.mandateData = STPMandateDataParams(customerAcceptance: customerAcceptance!) + // Default behavior should not override custom setting + XCTAssertNotNil(params.mandateData) + XCTAssertNil(params.mandateData?.customerAcceptance.onlineParams) + } + } + + // MARK: STPFormEncodable Tests + func testRootObjectName() { + XCTAssertNil(STPSetupIntentConfirmParams.rootObjectName()) + } + + func testPropertyNamesToFormFieldNamesMapping() { + let params = STPSetupIntentConfirmParams() + + let mapping = STPSetupIntentConfirmParams.propertyNamesToFormFieldNamesMapping() + + for propertyName in mapping.keys { + XCTAssertFalse(propertyName.contains(":")) + XCTAssert(params.responds(to: NSSelectorFromString(propertyName))) + } + + for formFieldName in mapping.values { + XCTAssert(formFieldName.count > 0) + } + + XCTAssertEqual(mapping.values.count, Set(mapping.values).count) + } + + func testCopy() { + let params = STPSetupIntentConfirmParams(clientSecret: "test_client_secret") + params.paymentMethodParams = STPPaymentMethodParams() + params.paymentMethodID = "test_payment_method_id" + params.returnURL = "fake://testing_only" + params.useStripeSDK = NSNumber(value: true) + params.mandateData = STPMandateDataParams( + customerAcceptance: STPMandateCustomerAcceptanceParams( + type: .offline, + onlineParams: nil + )! + ) + params.additionalAPIParameters = [ + "other_param": "other_value" + ] + + let paramsCopy = params.copy() as! STPSetupIntentConfirmParams + XCTAssertEqual(params.clientSecret, paramsCopy.clientSecret) + XCTAssertEqual(params.paymentMethodID, paramsCopy.paymentMethodID) + + // assert equal, not equal objects, because this is a shallow copy + XCTAssertEqual(params.paymentMethodParams, paramsCopy.paymentMethodParams) + XCTAssertEqual(params.mandateData, paramsCopy.mandateData) + + XCTAssertEqual(params.returnURL, paramsCopy.returnURL) + XCTAssertEqual(params.useStripeSDK, paramsCopy.useStripeSDK) + XCTAssertEqual( + params.additionalAPIParameters as NSDictionary, + paramsCopy.additionalAPIParameters as NSDictionary + ) + + } + + func testClientSecretValidation() { + XCTAssertFalse( + STPSetupIntentConfirmParams.isClientSecretValid("seti_12345"), + "'seti_12345' is not a valid client secret." + ) + XCTAssertFalse( + STPSetupIntentConfirmParams.isClientSecretValid("seti_12345_secret_"), + "'seti_12345_secret_' is not a valid client secret." + ) + XCTAssertFalse( + STPSetupIntentConfirmParams.isClientSecretValid( + "seti_a1b2c3_secret_x7y8z9seti_a1b2c3_secret_x7y8z9" + ), + "'seti_a1b2c3_secret_x7y8z9seti_a1b2c3_secret_x7y8z9' is not a valid client secret." + ) + XCTAssertFalse( + STPSetupIntentConfirmParams.isClientSecretValid("pi_a1b2c3_secret_x7y8z9"), + "'pi_a1b2c3_secret_x7y8z9' is not a valid client secret." + ) + + XCTAssertTrue( + STPSetupIntentConfirmParams.isClientSecretValid("seti_a1b2c3_secret_x7y8z9"), + "'seti_a1b2c3_secret_x7y8z9' is a valid client secret." + ) + XCTAssertTrue( + STPSetupIntentConfirmParams.isClientSecretValid( + "seti_1Eq5kyGMT9dGPIDGxiSp4cce_secret_FKlHb3yTI0YZWe4iqghS8ZXqwwMoMmy" + ), + "'seti_1Eq5kyGMT9dGPIDGxiSp4cce_secret_FKlHb3yTI0YZWe4iqghS8ZXqwwMoMmy' is a valid client secret." + ) + } +} diff --git a/Stripe/StripeiOSTests/STPSetupIntentFunctionalTest.swift b/Stripe/StripeiOSTests/STPSetupIntentFunctionalTest.swift new file mode 100644 index 00000000..9fdc6f10 --- /dev/null +++ b/Stripe/StripeiOSTests/STPSetupIntentFunctionalTest.swift @@ -0,0 +1,261 @@ +// +// STPSetupIntentFunctionalTest.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 3/2/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +import StripeCoreTestUtils +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPSetupIntentFunctionalTestSwift: XCTestCase { + + // MARK: - US Bank Account + func createAndConfirmSetupIntentWithUSBankAccount(completion: @escaping (String?) -> Void) { + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + var clientSecret: String? + let createSIExpectation = expectation(description: "Create SetupIntent") + STPTestingAPIClient.shared.createSetupIntent( + withParams: ["payment_method_types": ["us_bank_account"]], + account: nil + ) { intentClientSecret, error in + XCTAssertNil(error) + XCTAssertNotNil(intentClientSecret) + clientSecret = intentClientSecret + createSIExpectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + guard let clientSecret = clientSecret else { + XCTFail("Failed to create SetupIntent") + return + } + + let usBankAccountParams = STPPaymentMethodUSBankAccountParams() + usBankAccountParams.accountType = .checking + usBankAccountParams.accountHolderType = .individual + usBankAccountParams.accountNumber = "000123456789" + usBankAccountParams.routingNumber = "110000000" + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "iOS CI Tester" + billingDetails.email = "tester@example.com" + + let paymentMethodParams = STPPaymentMethodParams( + usBankAccount: usBankAccountParams, + billingDetails: billingDetails, + metadata: nil + ) + + let setupIntentParams = STPSetupIntentConfirmParams(clientSecret: clientSecret) + setupIntentParams.paymentMethodParams = paymentMethodParams + + let confirmSIExpectation = expectation(description: "Confirm SetupIntent") + client.confirmSetupIntent(with: setupIntentParams, expand: ["payment_method"]) { + setupIntent, + error in + XCTAssertNil(error) + guard let setupIntent else { XCTFail(); return } + XCTAssertNotNil(setupIntent.paymentMethod) + XCTAssertNotNil(setupIntent.paymentMethod?.usBankAccount) + XCTAssertEqual(setupIntent.paymentMethod?.usBankAccount?.last4, "6789") + XCTAssertEqual(setupIntent.status, .requiresAction) + XCTAssertEqual(setupIntent.nextAction?.type, .verifyWithMicrodeposits) + confirmSIExpectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + completion(clientSecret) + } + + func testConfirmSetupIntentWithUSBankAccount_verifyWithAmounts() { + createAndConfirmSetupIntentWithUSBankAccount { [self] clientSecret in + guard let clientSecret = clientSecret else { + XCTFail("Failed to create SetupIntent") + return + } + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let verificationExpectation = expectation(description: "Verify with microdeposits") + client.verifySetupIntentWithMicrodeposits( + clientSecret: clientSecret, + firstAmount: 32, + secondAmount: 45 + ) { setupIntent, error in + XCTAssertNil(error) + XCTAssertNotNil(setupIntent) + XCTAssertEqual(setupIntent?.status, .succeeded) + verificationExpectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + } + } + + func testConfirmSetupIntentWithUSBankAccount_verifyWithDescriptorCode() { + createAndConfirmSetupIntentWithUSBankAccount { [self] clientSecret in + guard let clientSecret = clientSecret else { + XCTFail("Failed to create SetupIntent") + return + } + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + + let verificationExpectation = expectation(description: "Verify with microdeposits") + client.verifySetupIntentWithMicrodeposits( + clientSecret: clientSecret, + descriptorCode: "SM11AA" + ) { setupIntent, error in + XCTAssertNil(error) + XCTAssertNotNil(setupIntent) + XCTAssertEqual(setupIntent?.status, .succeeded) + verificationExpectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout) + } + } +} + +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +extension STPSetupIntentFunctionalTestSwift { + func testCreateSetupIntentWithTestingServer() { + let expectation = self.expectation(description: "SetupIntent create.") + STPTestingAPIClient.shared.createSetupIntent( + withParams: nil) { clientSecret, error in + XCTAssertNotNil(clientSecret) + XCTAssertNil(error) + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testRetrieveSetupIntentSucceeds() { + // Tests retrieving a previously created SetupIntent succeeds + let setupIntentClientSecret = "seti_1GGCuIFY0qyl6XeWVfbQK6b3_secret_GnoX2tzX2JpvxsrcykRSVna2lrYLKew" + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Setup Intent retrieve") + + client.retrieveSetupIntent( + withClientSecret: setupIntentClientSecret) { setupIntent, error in + XCTAssertNil(error) + guard let setupIntent else { XCTFail(); return } + XCTAssertNotNil(setupIntent) + XCTAssertEqual(setupIntent.stripeID, "seti_1GGCuIFY0qyl6XeWVfbQK6b3") + XCTAssertEqual(setupIntent.clientSecret, setupIntentClientSecret) + XCTAssertEqual(setupIntent.created, Date(timeIntervalSince1970: 1582673622)) + XCTAssertNil(setupIntent.customerID) + XCTAssertNil(setupIntent.stripeDescription) + XCTAssertFalse(setupIntent.livemode) + XCTAssertNil(setupIntent.nextAction) + XCTAssertNil(setupIntent.paymentMethodID) + XCTAssertEqual(setupIntent.paymentMethodTypes, [NSNumber(value: STPPaymentMethodType.card.rawValue)]) + XCTAssertEqual(setupIntent.status, STPSetupIntentStatus.requiresPaymentMethod) + XCTAssertEqual(setupIntent.usage, STPSetupIntentUsage.offSession) + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testConfirmSetupIntentSucceeds() { + + var clientSecret: String? + let createExpectation = self.expectation(description: "Create SetupIntent.") + STPTestingAPIClient.shared.createSetupIntent(withParams: nil) { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "SetupIntent confirm") + let params = STPSetupIntentConfirmParams(clientSecret: clientSecret!) + params.returnURL = "example-app-scheme://authorized" + // Confirm using a card requiring 3DS1 authentication (ie requires next steps) + params.paymentMethodID = "pm_card_authenticationRequired" + client.confirmSetupIntent( + with: params) { setupIntent, error in + XCTAssertNil(error, "With valid key + secret, should be able to confirm the intent") + guard let setupIntent else { XCTFail(); return } + + XCTAssertNotNil(setupIntent) + XCTAssertEqual(setupIntent.stripeID, STPSetupIntent.id(fromClientSecret: params.clientSecret)) + XCTAssertEqual(setupIntent.clientSecret, clientSecret) + XCTAssertFalse(setupIntent.livemode) + + XCTAssertEqual(setupIntent.status, STPSetupIntentStatus.requiresAction) + XCTAssertNotNil(setupIntent.nextAction) + XCTAssertEqual(setupIntent.nextAction?.type, STPIntentActionType.redirectToURL) + XCTAssertEqual(setupIntent.nextAction?.redirectToURL?.returnURL, URL(string: "example-app-scheme://authorized")) + XCTAssertNotNil(setupIntent.paymentMethodID) + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // MARK: - AU BECS Debit + + func testConfirmAUBECSDebitSetupIntent() { + + var clientSecret: String? + let createExpectation = self.expectation(description: "Create PaymentIntent.") + STPTestingAPIClient.shared.createSetupIntent( + withParams: [ + "payment_method_types": ["au_becs_debit"], + ], + account: "au") { createdClientSecret, creationError in + XCTAssertNotNil(createdClientSecret) + XCTAssertNil(creationError) + createExpectation.fulfill() + clientSecret = createdClientSecret + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + XCTAssertNotNil(clientSecret) + + let becsParams = STPPaymentMethodAUBECSDebitParams() + becsParams.bsbNumber = "000000" // Stripe test bank + becsParams.accountNumber = "000123456" // test account + + let billingDetails = STPPaymentMethodBillingDetails() + billingDetails.name = "Jenny Rosen" + billingDetails.email = "jrosen@example.com" + + let params = STPPaymentMethodParams( + aubecsDebit: becsParams, + billingDetails: billingDetails, + metadata: [ + "test_key": "test_value", + ]) + + let setupIntentParams = STPSetupIntentConfirmParams(clientSecret: clientSecret!) + setupIntentParams.paymentMethodParams = params + + let client = STPAPIClient(publishableKey: STPTestingAUPublishableKey) + let expectation = self.expectation(description: "Setup Intent confirm") + + client.confirmSetupIntent( + with: setupIntentParams) { setupIntent, error in + XCTAssertNil(error, "With valid key + secret, should be able to confirm the intent") + guard let setupIntent else { XCTFail(); return } + + XCTAssertNotNil(setupIntent) + XCTAssertEqual(setupIntent.stripeID, STPSetupIntent.id(fromClientSecret: setupIntentParams.clientSecret)) + XCTAssertNotNil(setupIntent.paymentMethodID) + XCTAssertEqual(setupIntent.status, STPSetupIntentStatus.succeeded) + + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/Stripe/StripeiOSTests/STPSetupIntentLastSetupErrorTest.swift b/Stripe/StripeiOSTests/STPSetupIntentLastSetupErrorTest.swift new file mode 100644 index 00000000..6bd56371 --- /dev/null +++ b/Stripe/StripeiOSTests/STPSetupIntentLastSetupErrorTest.swift @@ -0,0 +1,32 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPSetupIntentLastSetupErrorTest.m +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 8/9/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +@testable import StripePayments + +class STPSetupIntentLastSetupErrorTest: XCTestCase { + func testTypeFromString() { + XCTAssertEqual(STPSetupIntentLastSetupError.type(from: "api_connection_error"), STPSetupIntentLastSetupErrorType.apiConnection) + XCTAssertEqual(STPSetupIntentLastSetupError.type(from: "API_CONNECTION_ERROR"), STPSetupIntentLastSetupErrorType.apiConnection) + XCTAssertEqual(STPSetupIntentLastSetupError.type(from: "api_error"), STPSetupIntentLastSetupErrorType.API) + XCTAssertEqual(STPSetupIntentLastSetupError.type(from: "API_ERROR"), STPSetupIntentLastSetupErrorType.API) + XCTAssertEqual(STPSetupIntentLastSetupError.type(from: "authentication_error"), STPSetupIntentLastSetupErrorType.authentication) + XCTAssertEqual(STPSetupIntentLastSetupError.type(from: "AUTHENTICATION_ERROR"), STPSetupIntentLastSetupErrorType.authentication) + XCTAssertEqual(STPSetupIntentLastSetupError.type(from: "card_error"), STPSetupIntentLastSetupErrorType.card) + XCTAssertEqual(STPSetupIntentLastSetupError.type(from: "CARD_ERROR"), STPSetupIntentLastSetupErrorType.card) + XCTAssertEqual(STPSetupIntentLastSetupError.type(from: "idempotency_error"), STPSetupIntentLastSetupErrorType.idempotency) + XCTAssertEqual(STPSetupIntentLastSetupError.type(from: "IDEMPOTENCY_ERROR"), STPSetupIntentLastSetupErrorType.idempotency) + XCTAssertEqual(STPSetupIntentLastSetupError.type(from: "invalid_request_error"), STPSetupIntentLastSetupErrorType.invalidRequest) + XCTAssertEqual(STPSetupIntentLastSetupError.type(from: "INVALID_REQUEST_ERROR"), STPSetupIntentLastSetupErrorType.invalidRequest) + XCTAssertEqual(STPSetupIntentLastSetupError.type(from: "rate_limit_error"), STPSetupIntentLastSetupErrorType.rateLimit) + XCTAssertEqual(STPSetupIntentLastSetupError.type(from: "RATE_LIMIT_ERROR"), STPSetupIntentLastSetupErrorType.rateLimit) + } + // MARK: - STPAPIResponseDecodable Tests + + // STPSetupIntentLastError is a sub-object of STPSetupIntent, see STPSetupIntentTest +} diff --git a/Stripe/StripeiOSTests/STPSetupIntentTest.swift b/Stripe/StripeiOSTests/STPSetupIntentTest.swift new file mode 100644 index 00000000..0a440df3 --- /dev/null +++ b/Stripe/StripeiOSTests/STPSetupIntentTest.swift @@ -0,0 +1,98 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPSetupIntentTest.m +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 6/27/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// +@_spi(STP) @testable import StripePayments + +class STPSetupIntentTest: XCTestCase { + // MARK: - Description Tests + + func testDescription() { + let setupIntent = STPFixtures.setupIntent() + + XCTAssertNotNil(setupIntent) + let desc = setupIntent.description + XCTAssertTrue(desc.contains(NSStringFromClass(type(of: setupIntent).self))) + XCTAssertGreaterThan(desc.count, 500, "Custom description should be long") + } + + // MARK: - STPAPIResponseDecodable Tests + + func testDecodedObjectFromAPIResponseRequiredFields() { + let fullJson = STPTestUtils.jsonNamed(STPTestJSONSetupIntent) + + XCTAssertNotNil(STPSetupIntent.decodedObject(fromAPIResponse: fullJson), "can decode with full json") + + let requiredFields = [ + "id", + "client_secret", + "livemode", + "status", + ] + + for field in requiredFields { + var partialJson = fullJson! as [AnyHashable: Any] + + XCTAssertNotNil(partialJson[field]) + partialJson.removeValue(forKey: field) + + XCTAssertNil(STPSetupIntent.decodedObject(fromAPIResponse: partialJson)) + } + } + + func testDecodedObjectFromAPIResponseMapping() { + let setupIntentJson = STPTestUtils.jsonNamed("SetupIntent")! + let orderedPaymentJson = ["card", "ideal", "sepa_debit"] + let setupIntentResponse = [ + "setup_intent": setupIntentJson, + "ordered_payment_method_types": orderedPaymentJson, + ] as [String: Any] + let unactivatedPaymentMethodTypes = ["sepa_debit"] + let response = [ + "payment_method_preference": setupIntentResponse, + "unactivated_payment_method_types": unactivatedPaymentMethodTypes, + ] as [String: Any] + + guard let setupIntent = STPSetupIntent.decodedObject(fromAPIResponse: response) else { XCTFail(); return } + + XCTAssertEqual(setupIntent.stripeID, "seti_123456789") + XCTAssertEqual(setupIntent.clientSecret, "seti_123456789_secret_123456789") + XCTAssertEqual(setupIntent.created, Date(timeIntervalSince1970: 123456789)) + XCTAssertEqual(setupIntent.customerID, "cus_123456") + XCTAssertEqual(setupIntent.paymentMethodID, "pm_123456") + XCTAssertEqual(setupIntent.stripeDescription, "My Sample SetupIntent") + XCTAssertFalse(setupIntent.livemode) + // nextAction + XCTAssertNotNil(setupIntent.nextAction) + XCTAssertEqual(setupIntent.nextAction?.type, STPIntentActionType.redirectToURL) + XCTAssertNotNil(setupIntent.nextAction?.redirectToURL) + XCTAssertNotNil(setupIntent.nextAction?.redirectToURL?.url) + let returnURL = setupIntent.nextAction?.redirectToURL?.returnURL + XCTAssertNotNil(returnURL) + XCTAssertEqual(returnURL, URL(string: "payments-example://stripe-redirect")) + let url = setupIntent.nextAction?.redirectToURL?.url + XCTAssertNotNil(url) + + XCTAssertEqual(url, URL(string: "https://hooks.stripe.com/redirect/authenticate/src_1Cl1AeIl4IdHmuTb1L7x083A?client_secret=src_client_secret_DBNwUe9qHteqJ8qQBwNWiigk")) + XCTAssertEqual(setupIntent.paymentMethodID, "pm_123456") + XCTAssertEqual(setupIntent.status, STPSetupIntentStatus.requiresAction) + XCTAssertEqual(setupIntent.usage, STPSetupIntentUsage.offSession) + + XCTAssertEqual(setupIntent.paymentMethodTypes, [NSNumber(value: STPPaymentMethodType.card.rawValue)]) + + // lastSetupError + + XCTAssertNotNil(setupIntent.lastSetupError) + XCTAssertEqual(setupIntent.lastSetupError?.code, "setup_intent_authentication_failure") + XCTAssertEqual(setupIntent.lastSetupError?.docURL, "https://stripe.com/docs/error-codes#setup-intent-authentication-failure") + XCTAssertEqual(setupIntent.lastSetupError?.message, "The latest attempt to set up the payment method has failed because authentication failed.") + XCTAssertNotNil(setupIntent.lastSetupError?.paymentMethod) + XCTAssertEqual(setupIntent.lastSetupError?.type, STPSetupIntentLastSetupErrorType.invalidRequest) + + XCTAssertEqual(setupIntent.unactivatedPaymentMethodTypes, [.SEPADebit]) + } +} diff --git a/Stripe/StripeiOSTests/STPShippingAddressViewControllerLocalizationTests.swift b/Stripe/StripeiOSTests/STPShippingAddressViewControllerLocalizationTests.swift new file mode 100644 index 00000000..4f861ca6 --- /dev/null +++ b/Stripe/StripeiOSTests/STPShippingAddressViewControllerLocalizationTests.swift @@ -0,0 +1,116 @@ +// +// STPShippingAddressViewControllerLocalizationTests.swift +// StripeiOS Tests +// +// Created by Ben Guo on 11/3/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPShippingAddressViewControllerLocalizationTests: FBSnapshotTestCase { + override func setUp() { + super.setUp() + // self.recordMode = true + } + + func performSnapshotTest( + forLanguage language: String?, + shippingType: STPShippingType, + contact: Bool + ) { + var identifier = (shippingType == .shipping) ? "shipping" : "delivery" + let config = STPPaymentConfiguration() + config.companyName = "Test Company" + config.requiredShippingAddressFields = Set([ + .postalAddress, + .emailAddress, + .phoneNumber, + .name, + ]) + if contact { + config.requiredShippingAddressFields = Set([.emailAddress]) + identifier = "contact" + } + config.shippingType = shippingType + + STPLocalizationUtils.overrideLanguage(to: language) + let info = STPUserInformation() + info.billingAddress = STPAddress() + info.billingAddress!.email = "@" // trigger "use billing address" button + + let shippingVC = STPShippingAddressViewController( + configuration: config, + theme: STPTheme.defaultTheme, + currency: nil, + shippingAddress: nil, + selectedShippingMethod: nil, + prefilledInformation: info + ) + + /// This method rejects nil or empty country codes to stop strange looking behavior + /// when scrolling to the top "unset" position in the picker, so put in + /// an invalid country code instead to test seeing the "Country" placeholder + shippingVC.addressViewModel.addressFieldTableViewCountryCode = "INVALID" + + let viewToTest = stp_preparedAndSizedViewForSnapshotTest(from: shippingVC)! + + STPSnapshotVerifyView(viewToTest, identifier: identifier) + + STPLocalizationUtils.overrideLanguage(to: nil) + } + + func testGerman() { + performSnapshotTest(forLanguage: "de", shippingType: .shipping, contact: false) + performSnapshotTest(forLanguage: "de", shippingType: .shipping, contact: true) + performSnapshotTest(forLanguage: "de", shippingType: .delivery, contact: false) + } + + func testEnglish() { + performSnapshotTest(forLanguage: "en", shippingType: .shipping, contact: false) + performSnapshotTest(forLanguage: "en", shippingType: .shipping, contact: true) + performSnapshotTest(forLanguage: "en", shippingType: .delivery, contact: false) + } + + func testSpanish() { + performSnapshotTest(forLanguage: "es", shippingType: .shipping, contact: false) + performSnapshotTest(forLanguage: "es", shippingType: .shipping, contact: true) + performSnapshotTest(forLanguage: "es", shippingType: .delivery, contact: false) + } + + func testFrench() { + performSnapshotTest(forLanguage: "fr", shippingType: .shipping, contact: false) + performSnapshotTest(forLanguage: "fr", shippingType: .shipping, contact: true) + performSnapshotTest(forLanguage: "fr", shippingType: .delivery, contact: false) + } + + func testItalian() { + performSnapshotTest(forLanguage: "it", shippingType: .shipping, contact: false) + performSnapshotTest(forLanguage: "it", shippingType: .shipping, contact: true) + performSnapshotTest(forLanguage: "it", shippingType: .delivery, contact: false) + } + + func testJapanese() { + performSnapshotTest(forLanguage: "ja", shippingType: .shipping, contact: false) + performSnapshotTest(forLanguage: "ja", shippingType: .shipping, contact: true) + performSnapshotTest(forLanguage: "ja", shippingType: .delivery, contact: false) + } + + func testDutch() { + performSnapshotTest(forLanguage: "nl", shippingType: .shipping, contact: false) + performSnapshotTest(forLanguage: "nl", shippingType: .shipping, contact: true) + performSnapshotTest(forLanguage: "nl", shippingType: .delivery, contact: false) + } + + func testChinese() { + performSnapshotTest(forLanguage: "zh-Hans", shippingType: .shipping, contact: false) + performSnapshotTest(forLanguage: "zh-Hans", shippingType: .shipping, contact: true) + performSnapshotTest(forLanguage: "zh-Hans", shippingType: .delivery, contact: false) + } +} diff --git a/Stripe/StripeiOSTests/STPShippingAddressViewControllerTest.swift b/Stripe/StripeiOSTests/STPShippingAddressViewControllerTest.swift new file mode 100644 index 00000000..cc473049 --- /dev/null +++ b/Stripe/StripeiOSTests/STPShippingAddressViewControllerTest.swift @@ -0,0 +1,114 @@ +// +// STPShippingAddressViewControllerTest.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 8/7/18. +// Copyright © 2018 Stripe, Inc. All rights reserved. +// + +import Stripe + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPShippingAddressViewControllerTest: XCTestCase { + func testPrefilledBillingAddress_removeAddress() { + let config = STPPaymentConfiguration() + config.requiredShippingAddressFields = Set([.postalAddress]) + + let address = STPAddress() + address.name = "John Smith Doe" + address.phone = "8885551212" + address.email = "foo@example.com" + address.line1 = "55 John St" + address.city = "Harare" + address.postalCode = "10002" + // Zimbabwe does not require zip codes, while the default locale for tests (US) does + address.country = "ZW" + // Sanity checks + XCTAssertFalse(STPPostalCodeValidator.postalCodeIsRequired(forCountryCode: "ZW")) + XCTAssertTrue(STPPostalCodeValidator.postalCodeIsRequired(forCountryCode: "US")) + + let sut = STPShippingAddressViewController( + configuration: config, + theme: STPTheme.defaultTheme, + currency: nil, + shippingAddress: address, + selectedShippingMethod: nil, + prefilledInformation: nil + ) + + XCTAssertNoThrow(sut.loadView()) + XCTAssertNoThrow(sut.viewDidLoad()) + } + + func testPrefilledBillingAddress_addAddressWithLimitedCountries() { + // Zimbabwe does not require zip codes, while the default locale for tests (US) does + NSLocale.stp_withLocale(as: NSLocale(localeIdentifier: "en_ZW")) { + // Sanity checks + XCTAssertFalse(STPPostalCodeValidator.postalCodeIsRequired(forCountryCode: "ZW")) + XCTAssertTrue(STPPostalCodeValidator.postalCodeIsRequired(forCountryCode: "US")) + let config = STPPaymentConfiguration() + config.requiredShippingAddressFields = Set([.postalAddress]) + config.availableCountries = Set(["CA", "BT"]) + + let address = STPAddress() + address.name = "John Smith Doe" + address.phone = "8885551212" + address.email = "foo@example.com" + address.line1 = "55 John St" + address.city = "New York" + address.state = "NY" + address.postalCode = "10002" + address.country = "US" + + let sut = STPShippingAddressViewController( + configuration: config, + theme: STPTheme.defaultTheme, + currency: nil, + shippingAddress: address, + selectedShippingMethod: nil, + prefilledInformation: nil + ) + + XCTAssertNoThrow(sut.loadView()) + XCTAssertNoThrow(sut.viewDidLoad()) + } + } + + func testPrefilledBillingAddress_addAddress() { + // Zimbabwe does not require zip codes, while the default locale for tests (US) does + NSLocale.stp_withLocale(as: NSLocale(localeIdentifier: "en_ZW")) { + // Sanity checks + XCTAssertFalse(STPPostalCodeValidator.postalCodeIsRequired(forCountryCode: "ZW")) + XCTAssertTrue(STPPostalCodeValidator.postalCodeIsRequired(forCountryCode: "US")) + let config = STPPaymentConfiguration() + config.requiredShippingAddressFields = Set([.postalAddress]) + + let address = STPAddress() + address.name = "John Smith Doe" + address.phone = "8885551212" + address.email = "foo@example.com" + address.line1 = "55 John St" + address.city = "New York" + address.state = "NY" + address.postalCode = "10002" + address.country = "US" + + let sut = STPShippingAddressViewController( + configuration: config, + theme: STPTheme.defaultTheme, + currency: nil, + shippingAddress: address, + selectedShippingMethod: nil, + prefilledInformation: nil + ) + + XCTAssertNoThrow(sut.loadView()) + XCTAssertNoThrow(sut.viewDidLoad()) + } + } +} diff --git a/Stripe/StripeiOSTests/STPShippingMethodsViewControllerLocalizationTests.swift b/Stripe/StripeiOSTests/STPShippingMethodsViewControllerLocalizationTests.swift new file mode 100644 index 00000000..ccdf3cd2 --- /dev/null +++ b/Stripe/StripeiOSTests/STPShippingMethodsViewControllerLocalizationTests.swift @@ -0,0 +1,80 @@ +// +// STPShippingMethodsViewControllerLocalizationTests.swift +// StripeiOS Tests +// +// Created by Ben Guo on 11/3/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPShippingMethodsViewControllerLocalizationTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() + // self.recordMode = true + } + + func performSnapshotTest(forLanguage language: String?) { + STPLocalizationUtils.overrideLanguage(to: language) + + let method1 = PKShippingMethod() + method1.label = "UPS Ground" + method1.detail = "Arrives in 3-5 days" + method1.amount = NSDecimalNumber(string: "0.00") + method1.identifier = "ups_ground" + let method2 = PKShippingMethod() + method2.label = "FedEx" + method2.detail = "Arrives tomorrow" + method2.amount = NSDecimalNumber(string: "5.99") + method2.identifier = "fedex" + + let shippingVC = STPShippingMethodsViewController( + shippingMethods: [method1, method2], + selectedShippingMethod: method1, + currency: "usd", + theme: STPTheme.defaultTheme + ) + let viewToTest = stp_preparedAndSizedViewForSnapshotTest(from: shippingVC)! + STPSnapshotVerifyView(viewToTest, identifier: nil) + STPLocalizationUtils.overrideLanguage(to: nil) + } + + func testGerman() { + performSnapshotTest(forLanguage: "de") + } + + func testEnglish() { + performSnapshotTest(forLanguage: "en") + } + + func testSpanish() { + performSnapshotTest(forLanguage: "es") + } + + func testFrench() { + performSnapshotTest(forLanguage: "fr") + } + + func testItalian() { + performSnapshotTest(forLanguage: "it") + } + + func testJapanese() { + performSnapshotTest(forLanguage: "ja") + } + + func testDutch() { + performSnapshotTest(forLanguage: "nl") + } + + func testChinese() { + performSnapshotTest(forLanguage: "zh-Hans") + } +} diff --git a/Stripe/StripeiOSTests/STPSourceCardDetailsTest.swift b/Stripe/StripeiOSTests/STPSourceCardDetailsTest.swift new file mode 100644 index 00000000..5bddcdc6 --- /dev/null +++ b/Stripe/StripeiOSTests/STPSourceCardDetailsTest.swift @@ -0,0 +1,116 @@ +// +// STPSourceCardDetailsTest.swift +// StripeiOS Tests +// +// Created by Joey Dong on 6/21/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPSourceCardDetailsTest: XCTestCase { + // MARK: - STPSourceCard3DSecureStatus Tests + func testThreeDSecureStatusFromString() { + XCTAssertEqual(STPSourceCardDetails.threeDSecureStatus(from: "required"), .required) + XCTAssertEqual(STPSourceCardDetails.threeDSecureStatus(from: "REQUIRED"), .required) + + XCTAssertEqual(STPSourceCardDetails.threeDSecureStatus(from: "optional"), .optional) + XCTAssertEqual(STPSourceCardDetails.threeDSecureStatus(from: "OPTIONAL"), .optional) + + XCTAssertEqual( + STPSourceCardDetails.threeDSecureStatus(from: "not_supported"), + .notSupported + ) + XCTAssertEqual( + STPSourceCardDetails.threeDSecureStatus(from: "NOT_SUPPORTED"), + .notSupported + ) + + XCTAssertEqual(STPSourceCardDetails.threeDSecureStatus(from: "recommended"), .recommended) + XCTAssertEqual(STPSourceCardDetails.threeDSecureStatus(from: "RECOMMENDED"), .recommended) + + XCTAssertEqual(STPSourceCardDetails.threeDSecureStatus(from: "unknown"), .unknown) + XCTAssertEqual(STPSourceCardDetails.threeDSecureStatus(from: "UNKNOWN"), .unknown) + + XCTAssertEqual(STPSourceCardDetails.threeDSecureStatus(from: "garbage"), .unknown) + XCTAssertEqual(STPSourceCardDetails.threeDSecureStatus(from: "GARBAGE"), .unknown) + } + + func testStringFromThreeDSecureStatus() { + let values: [STPSourceCard3DSecureStatus] = [ + .required, + .optional, + .notSupported, + .recommended, + .unknown, + ] + + for threeDSecureStatus in values { + let string = STPSourceCardDetails.string(fromThreeDSecureStatus: threeDSecureStatus) + + switch threeDSecureStatus { + case .required: + XCTAssertEqual(string, "required") + case .optional: + XCTAssertEqual(string, "optional") + case .notSupported: + XCTAssertEqual(string, "not_supported") + case .recommended: + XCTAssertEqual(string, "recommended") + case .unknown: + XCTAssertNil(string) + default: + break + } + } + } + + // MARK: - Description Tests + func testDescription() { + let cardDetails = STPSourceCardDetails.decodedObject( + fromAPIResponse: STPTestUtils.jsonNamed("CardSource")!["card"] as? [AnyHashable: Any] + ) + XCTAssert(cardDetails?.description != nil) + } + + // MARK: - STPAPIResponseDecodable Tests + func testDecodedObjectFromAPIResponseRequiredFields() { + let requiredFields: [String]? = [] + + for field in requiredFields ?? [] { + var response = STPTestUtils.jsonNamed("CardSource")?["card"] as? [AnyHashable: Any] + response?.removeValue(forKey: field) + + XCTAssertNil(STPSourceCardDetails.decodedObject(fromAPIResponse: response)) + } + + XCTAssert( + (STPSourceCardDetails.decodedObject( + fromAPIResponse: STPTestUtils.jsonNamed("CardSource")!["card"] + as? [AnyHashable: Any] + ) + != nil) + ) + } + + func testDecodedObjectFromAPIResponseMapping() { + let response = STPTestUtils.jsonNamed("CardSource")?["card"] as? [AnyHashable: Any] + let cardDetails = STPSourceCardDetails.decodedObject(fromAPIResponse: response)! + + XCTAssertEqual(cardDetails.brand, .visa) + XCTAssertEqual(cardDetails.country, "US") + XCTAssertEqual(cardDetails.expMonth, UInt(12)) + XCTAssertEqual(cardDetails.expYear, UInt(2034)) + XCTAssertEqual(cardDetails.funding, .debit) + XCTAssertEqual(cardDetails.last4, "5556") + XCTAssertEqual(cardDetails.threeDSecure, .notSupported) + + XCTAssertEqual(cardDetails.allResponseFields as NSDictionary, response! as NSDictionary) + } +} diff --git a/Stripe/StripeiOSTests/STPSourceFunctionalTest.swift b/Stripe/StripeiOSTests/STPSourceFunctionalTest.swift new file mode 100644 index 00000000..791bdd4a --- /dev/null +++ b/Stripe/StripeiOSTests/STPSourceFunctionalTest.swift @@ -0,0 +1,715 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPSourceFunctionalTest.m +// Stripe +// +// Created by Ben Guo on 1/23/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +@_spi(STP) @testable import StripeCore +@testable import StripeCoreTestUtils +@_spi(STP) @testable import StripePayments +import XCTest + +class STPSourceFunctionalTest: XCTestCase { + func testCreateSource_bancontact() { + let params = STPSourceParams.bancontactParams( + withAmount: 1099, + name: "Jenny Rosen", + returnURL: "https://shop.example.com/crtABC", + statementDescriptor: "ORDER AT123") + params.metadata = [ + "foo": "bar", + ] + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Source creation") + client.createSource(with: params) { source, error in + XCTAssertNil(error) + XCTAssertNotNil(source) + XCTAssertEqual(source?.type, STPSourceType.bancontact) + XCTAssertEqual(source?.amount, params.amount) + XCTAssertEqual(source?.currency, params.currency) + XCTAssertEqual(source?.owner?.name, params.owner?["name"] as? String) + XCTAssertEqual(source?.redirect?.status, STPSourceRedirectStatus.pending) + XCTAssertEqual(source?.redirect?.returnURL, URL(string: "https://shop.example.com/crtABC?redirect_merchant_name=xctest")) + XCTAssertNotNil(source?.redirect?.url) + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertNil(source?.metadata, "Metadata is not returned.") + // #pragma clang diagnostic pop + + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateSource_card() { + let card = STPCardParams() + card.number = "4242 4242 4242 4242" + card.expMonth = 6 + card.expYear = 2024 + card.currency = "usd" + card.name = "Jenny Rosen" + card.address.line1 = "123 Fake Street" + card.address.line2 = "Apartment 4" + card.address.city = "New York" + card.address.state = "NY" + card.address.country = "USA" + card.address.postalCode = "10002" + let params = STPSourceParams.cardParams(withCard: card) + params.metadata = [ + "foo": "bar", + ] + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Source creation") + client.createSource(with: params) { source, error in + XCTAssertNil(error) + XCTAssertNotNil(source) + XCTAssertEqual(source?.type, STPSourceType.card) + XCTAssertEqual(source?.cardDetails?.last4, "4242") + XCTAssertEqual(source?.cardDetails?.expMonth, card.expMonth) + XCTAssertEqual(source?.cardDetails?.expYear, card.expYear) + XCTAssertEqual(source?.owner?.name, card.name) + let address = source?.owner?.address + XCTAssertEqual(address?.line1, card.address.line1) + XCTAssertEqual(address?.line2, card.address.line2) + XCTAssertEqual(address?.city, card.address.city) + XCTAssertEqual(address?.state, card.address.state) + XCTAssertEqual(address?.country, card.address.country) + XCTAssertEqual(address?.postalCode, card.address.postalCode) + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertNil(source?.metadata, "Metadata is not returned.") + // #pragma clang diagnostic pop + + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateSource_giropay() { + let params = STPSourceParams.giropayParams( + withAmount: 1099, + name: "Jenny Rosen", + returnURL: "https://shop.example.com/crtABC", + statementDescriptor: "ORDER AT123") + params.metadata = [ + "foo": "bar", + ] + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Source creation") + client.createSource(with: params) { source, error in + XCTAssertNil(error) + XCTAssertNotNil(source) + XCTAssertEqual(source?.type, STPSourceType.giropay) + XCTAssertEqual(source?.amount, params.amount) + XCTAssertEqual(source?.currency, params.currency) + XCTAssertEqual(source?.owner?.name, params.owner?["name"] as? String) + XCTAssertEqual(source?.redirect?.status, STPSourceRedirectStatus.pending) + XCTAssertEqual(source?.redirect?.returnURL, URL(string: "https://shop.example.com/crtABC?redirect_merchant_name=xctest")) + XCTAssertNotNil(source?.redirect?.url) + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertNil(source?.metadata, "Metadata is not returned.") + // #pragma clang diagnostic pop + + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateSource_ideal() { + let params = STPSourceParams.idealParams( + withAmount: 1099, + name: "Jenny Rosen", + returnURL: "https://shop.example.com/crtABC", + statementDescriptor: "ORDER AT123", + bank: "ing") + params.metadata = [ + "foo": "bar", + ] + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Source creation") + client.createSource(with: params) { source, error in + XCTAssertNil(error) + XCTAssertNotNil(source) + XCTAssertEqual(source?.type, STPSourceType.iDEAL) + XCTAssertEqual(source?.amount, params.amount) + XCTAssertEqual(source?.currency, params.currency) + XCTAssertEqual(source?.owner?.name, params.owner?["name"] as? String) + XCTAssertEqual(source?.redirect?.status, STPSourceRedirectStatus.pending) + XCTAssertEqual(source?.redirect?.returnURL, URL(string: "https://shop.example.com/crtABC?redirect_merchant_name=xctest")) + XCTAssertNotNil(source?.redirect?.url) + XCTAssertEqual(source?.details?["bank"] as? String, "ing") + XCTAssertEqual(source?.details?["statement_descriptor"] as? String, "ORDER AT123") + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertNil(source?.metadata, "Metadata is not returned.") + // #pragma clang diagnostic pop + + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateSource_ideal_missingOptionalFields() { + let params = STPSourceParams.idealParams( + withAmount: 1099, + name: nil, + returnURL: "https://shop.example.com/crtABC", + statementDescriptor: nil, + bank: nil) + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Source creation") + client.createSource(with: params) { source, error in + XCTAssertNil(error) + XCTAssertNotNil(source) + XCTAssertEqual(source?.type, STPSourceType.iDEAL) + XCTAssertEqual(source?.amount, params.amount) + XCTAssertEqual(source?.currency, params.currency) + XCTAssertNil(source?.owner?.name) + XCTAssertEqual(source?.redirect?.status, STPSourceRedirectStatus.pending) + XCTAssertEqual(source?.redirect?.returnURL, URL(string: "https://shop.example.com/crtABC?redirect_merchant_name=xctest")) + XCTAssertNotNil(source?.redirect?.url) + XCTAssertNil(source?.details?["bank"]) + XCTAssertNil(source?.details?["statement_descriptor"]) + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertNil(source?.metadata, "Metadata is not returned.") + // #pragma clang diagnostic pop + + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateSource_ideal_emptyOptionalFields() { + let params = STPSourceParams.idealParams( + withAmount: 1099, + name: "", + returnURL: "https://shop.example.com/crtABC", + statementDescriptor: "", + bank: "") + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Source creation") + client.createSource(with: params) { source, error in + XCTAssertNil(error) + XCTAssertNotNil(source) + XCTAssertEqual(source?.type, STPSourceType.iDEAL) + XCTAssertEqual(source?.amount, params.amount) + XCTAssertEqual(source?.currency, params.currency) + XCTAssertNil(source?.owner?.name) + XCTAssertEqual(source?.redirect?.status, STPSourceRedirectStatus.pending) + XCTAssertEqual(source?.redirect?.returnURL, URL(string: "https://shop.example.com/crtABC?redirect_merchant_name=xctest")) + XCTAssertNotNil(source?.redirect?.url) + XCTAssertNil(source?.details?["bank"]) + XCTAssertNil(source?.details?["statement_descriptor"]) + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertNil(source?.metadata, "Metadata is not returned.") + // #pragma clang diagnostic pop + + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateSource_sepaDebit() { + let params = STPSourceParams.sepaDebitParams( + withName: "Jenny Rosen", + iban: "DE89370400440532013000", + addressLine1: "Nollendorfstraße 27", + city: "Berlin", + postalCode: "10777", + country: "DE") + params.metadata = [ + "foo": "bar", + ] + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Source creation") + client.createSource(with: params) { source, error in + XCTAssertNil(error) + XCTAssertNotNil(source) + XCTAssertEqual(source?.type, STPSourceType.SEPADebit) + XCTAssertNil(source?.amount) + XCTAssertEqual(source?.currency, params.currency) + XCTAssertEqual(source?.owner?.name, params.owner?["name"] as? String) + XCTAssertEqual(source?.owner?.address?.city, "Berlin") + XCTAssertEqual(source?.owner?.address?.line1, "Nollendorfstraße 27") + XCTAssertEqual(source?.owner?.address?.country, "DE") + XCTAssertEqual(source?.sepaDebitDetails?.country, "DE") + XCTAssertEqual(source?.sepaDebitDetails?.last4, "3000") + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertNil(source?.metadata, "Metadata is not returned.") + // #pragma clang diagnostic pop + + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateSource_sepaDebit_NoAddress() { + let params = STPSourceParams.sepaDebitParams( + withName: "Jenny Rosen", + iban: "DE89370400440532013000", + addressLine1: nil, + city: nil, + postalCode: nil, + country: nil) + params.metadata = [ + "foo": "bar", + ] + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Source creation") + client.createSource(with: params) { source, error in + XCTAssertNil(error) + XCTAssertNotNil(source) + XCTAssertEqual(source?.type, STPSourceType.SEPADebit) + XCTAssertNil(source?.amount) + XCTAssertEqual(source?.currency, params.currency) + XCTAssertEqual(source?.owner?.name, params.owner?["name"] as? String) + XCTAssertNil(source?.owner?.address?.city) + XCTAssertNil(source?.owner?.address?.line1) + XCTAssertNil(source?.owner?.address?.country) + XCTAssertEqual(source?.sepaDebitDetails?.country, "DE") // German IBAN so sepa tells us country here even though we didnt pass it up as owner info + XCTAssertEqual(source?.sepaDebitDetails?.last4, "3000") + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertNil(source?.metadata, "Metadata is not returned.") + // #pragma clang diagnostic pop + + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateSource_sofort() { + let params = STPSourceParams.sofortParams( + withAmount: 1099, + returnURL: "https://shop.example.com/crtABC", + country: "DE", + statementDescriptor: "ORDER AT11990") + params.metadata = [ + "foo": "bar", + ] + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Source creation") + client.createSource(with: params) { source, error in + XCTAssertNil(error) + XCTAssertNotNil(source) + XCTAssertEqual(source?.type, STPSourceType.sofort) + XCTAssertEqual(source?.amount, params.amount) + XCTAssertEqual(source?.currency, params.currency) + XCTAssertEqual(source?.redirect?.status, STPSourceRedirectStatus.pending) + XCTAssertEqual(source?.redirect?.returnURL, URL(string: "https://shop.example.com/crtABC?redirect_merchant_name=xctest")) + XCTAssertNotNil(source?.redirect?.url) + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertNil(source?.metadata, "Metadata is not returned.") + // #pragma clang diagnostic pop + XCTAssertEqual(source?.details?["country"] as? String, "DE") + + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateSource_threeDSecure() { + let card = STPCardParams() + card.number = "4000000000003063" + card.expMonth = 6 + card.expYear = 2024 + card.currency = "usd" + card.address.line1 = "123 Fake Street" + card.address.line2 = "Apartment 4" + card.address.city = "New York" + card.address.state = "NY" + card.address.country = "USA" + card.address.postalCode = "10002" + let cardParams = STPSourceParams.cardParams(withCard: card) + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let cardExp = expectation(description: "Card Source creation") + let threeDSExp = expectation(description: "3DS Source creation") + client.createSource(with: cardParams) { source, error1 in + XCTAssertNil(error1) + guard let source else { XCTFail(); return } + XCTAssertEqual(source.cardDetails?.threeDSecure, STPSourceCard3DSecureStatus.required) + cardExp.fulfill() + + let params = STPSourceParams.threeDSecureParams( + withAmount: 1099, + currency: "eur", + returnURL: "https://shop.example.com/crtABC", + card: source.stripeID) + params.metadata = [ + "foo": "bar", + ] + client.createSource(with: params) { source2, error2 in + XCTAssertNil(error2) + XCTAssertNotNil(source2) + XCTAssertEqual(source2?.type, STPSourceType.threeDSecure) + XCTAssertEqual(source2?.amount, params.amount) + XCTAssertEqual(source2?.currency, params.currency) + XCTAssertEqual(source2?.redirect?.status, STPSourceRedirectStatus.pending) + XCTAssertEqual(source2?.redirect?.returnURL, URL(string: "https://shop.example.com/crtABC?redirect_merchant_name=xctest")) + XCTAssertNotNil(source2?.redirect?.url) + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertNil(source2?.metadata, "Metadata is not returned.") + // #pragma clang diagnostic pop + threeDSExp.fulfill() + } + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func skip_testCreateSourceVisaCheckout() { + // The SDK does not have a means of generating Visa Checkout params for testing. Supply your own + // callId, and the correct publishable key, and you can run this test case + // manually after removing the `skip_` prefix. It'll log the source's stripeID, and that + // can be verified in dashboard. + let params = STPSourceParams.visaCheckoutParams(withCallId: "") + let client = STPAPIClient(publishableKey: "pk_") + client.apiURL = URL(string: "https://api.stripe.com/v1") + + let sourceExp = expectation(description: "VCO source created") + client.createSource(with: params) { source, error in + sourceExp.fulfill() + + XCTAssertNil(error) + XCTAssertNotNil(source) + XCTAssertEqual(source?.type, STPSourceType.card) + XCTAssertEqual(source?.flow, STPSourceFlow.none) + XCTAssertEqual(source?.status, STPSourceStatus.chargeable) + XCTAssertEqual(source?.usage, STPSourceUsage.reusable) + XCTAssertTrue(source!.stripeID.hasPrefix("src_")) + if let stripeID = source?.stripeID { + print("Created a VCO source \(stripeID)") + } + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func skip_testCreateSourceMasterpass() { + // The SDK does not have a means of generating Masterpass params for testing. Supply your own + // cartId & transactionId, and the correct publishable key, and you can run this test case + // manually after removing the `skip_` prefix. It'll log the source's stripeID, and that + // can be verified in dashboard. + let params = STPSourceParams.masterpassParams(withCartId: "", transactionId: "") + let client = STPAPIClient(publishableKey: "pk_") + client.apiURL = URL(string: "https://api.stripe.com/v1") + + let sourceExp = expectation(description: "Masterpass source created") + client.createSource(with: params) { source, error in + sourceExp.fulfill() + + XCTAssertNil(error) + XCTAssertNotNil(source) + XCTAssertEqual(source?.type, STPSourceType.card) + XCTAssertEqual(source?.flow, STPSourceFlow.none) + XCTAssertEqual(source?.status, STPSourceStatus.chargeable) + XCTAssertEqual(source?.usage, STPSourceUsage.singleUse) + XCTAssertTrue(source!.stripeID.hasPrefix("src_")) + if let stripeID = source?.stripeID { + print("Created a Masterpass source \(stripeID)") + } + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateSource_alipay() { + let params = STPSourceParams.alipayParams( + withAmount: 1099, + currency: "usd", + returnURL: "https://shop.example.com/crtABC") + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Alipay Source creation") + + params.metadata = [ + "foo": "bar", + ] + client.createSource(with: params) { source, error2 in + XCTAssertNil(error2) + XCTAssertNotNil(source) + XCTAssertEqual(source?.type, STPSourceType.alipay) + XCTAssertEqual(source?.amount, params.amount) + XCTAssertEqual(source?.currency, params.currency) + XCTAssertEqual(source?.redirect?.status, STPSourceRedirectStatus.pending) + XCTAssertEqual(source?.redirect?.returnURL, URL(string: "https://shop.example.com/crtABC?redirect_merchant_name=xctest")) + XCTAssertNotNil(source?.redirect?.url) + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertNil(source?.metadata, "Metadata is not returned.") + // #pragma clang diagnostic pop + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateSource_p24() { + let params = STPSourceParams.p24Params( + withAmount: 1099, + currency: "eur", + email: "user@example.com", + name: "Jenny Rosen", + returnURL: "https://shop.example.com/crtABC") + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "P24 Source creation") + + params.metadata = [ + "foo": "bar", + ] + client.createSource(with: params) { source, error2 in + XCTAssertNil(error2) + XCTAssertNotNil(source) + XCTAssertEqual(source?.type, STPSourceType.P24) + XCTAssertEqual(source?.amount, params.amount) + XCTAssertEqual(source?.currency, params.currency) + XCTAssertEqual(source?.owner?.email, params.owner?["email"] as? String) + XCTAssertEqual(source?.owner?.name, params.owner?["name"] as? String) + XCTAssertEqual(source?.redirect?.status, STPSourceRedirectStatus.pending) + XCTAssertEqual(source?.redirect?.returnURL, URL(string: "https://shop.example.com/crtABC?redirect_merchant_name=xctest")) + XCTAssertNotNil(source?.redirect?.url) + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertNil(source?.metadata, "Metadata is not returned.") + // #pragma clang diagnostic pop + expectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testRetrieveSource_sofort() { + let client = STPAPIClient(publishableKey: "pk_test_vOo1umqsYxSrP5UXfOeL3ecm") + let params = STPSourceParams() + params.type = STPSourceType.sofort + params.amount = NSNumber(value: 1099) + params.currency = "eur" + params.redirect = [ + "return_url": "https://shop.example.com/crtA6B28E1", + ] + params.metadata = [ + "foo": "bar", + ] + params.additionalAPIParameters = [ + "sofort": [ + "country": "DE", + ], + ] + let createExp = expectation(description: "Source creation") + let retrieveExp = expectation(description: "Source retrieval") + client.createSource(with: params) { source, error in + XCTAssertNil(error) + guard let source else { XCTFail(); return } + createExp.fulfill() + client.retrieveSource( + withId: source.stripeID, + clientSecret: source.clientSecret!) { source2, error2 in + XCTAssertNil(error2) + XCTAssertNotNil(source2) + XCTAssertEqual(source, source2) + retrieveExp.fulfill() + } + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateSource_eps() { + let params = STPSourceParams.epsParams( + withAmount: 1099, + name: "Jenny Rosen", + returnURL: "https://shop.example.com/crtABC", + statementDescriptor: "ORDER AT123") + params.metadata = [ + "foo": "bar", + ] + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Source creation") + client.createSource(with: params) { source, error in + XCTAssertNil(error) + XCTAssertNotNil(source) + XCTAssertEqual(source?.type, STPSourceType.EPS) + XCTAssertEqual(source?.amount, params.amount) + XCTAssertEqual(source?.currency, params.currency) + XCTAssertEqual(source?.owner?.name, params.owner?["name"] as? String) + XCTAssertEqual(source?.redirect?.status, STPSourceRedirectStatus.pending) + XCTAssertEqual(source?.redirect?.returnURL, URL(string: "https://shop.example.com/crtABC?redirect_merchant_name=xctest")) + XCTAssertNotNil(source?.redirect?.url) + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertNil(source?.metadata, "Metadata is not returned.") + // #pragma clang diagnostic pop + XCTAssertEqual(source?.allResponseFields["statement_descriptor"] as! String, "ORDER AT123") + + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateSource_eps_no_statement_descriptor() { + let params = STPSourceParams.epsParams( + withAmount: 1099, + name: "Jenny Rosen", + returnURL: "https://shop.example.com/crtABC", + statementDescriptor: nil) + params.metadata = [ + "foo": "bar", + ] + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Source creation") + client.createSource(with: params) { source, error in + XCTAssertNil(error) + XCTAssertNotNil(source) + XCTAssertEqual(source?.type, STPSourceType.EPS) + XCTAssertEqual(source?.amount, params.amount) + XCTAssertEqual(source?.currency, params.currency) + XCTAssertEqual(source?.owner?.name, params.owner?["name"] as? String) + XCTAssertEqual(source?.redirect?.status, STPSourceRedirectStatus.pending) + XCTAssertEqual(source?.redirect?.returnURL, URL(string: "https://shop.example.com/crtABC?redirect_merchant_name=xctest")) + XCTAssertNotNil(source?.redirect?.url) + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertNil(source?.metadata, "Metadata is not returned.") + // #pragma clang diagnostic pop + XCTAssertNil(source?.allResponseFields["statement_descriptor"]) + + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateSource_multibanco() { + let params = STPSourceParams.multibancoParams( + withAmount: 1099, + returnURL: "https://shop.example.com/crtABC", + email: "user@example.com") + params.metadata = [ + "foo": "bar", + ] + + let client = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + let expectation = self.expectation(description: "Source creation") + client.createSource(with: params) { source, error in + XCTAssertNil(error) + XCTAssertNotNil(source) + XCTAssertEqual(source?.type, STPSourceType.multibanco) + XCTAssertEqual(source?.amount, params.amount) + XCTAssertEqual(source?.redirect?.status, STPSourceRedirectStatus.pending) + XCTAssertEqual(source?.redirect?.returnURL, URL(string: "https://shop.example.com/crtABC?redirect_merchant_name=xctest")) + XCTAssertNotNil(source?.redirect?.url) + // #pragma clang diagnostic push + // #pragma clang diagnostic ignored "-Wdeprecated" + XCTAssertNil(source?.metadata, "Metadata is not returned.") + // #pragma clang diagnostic pop + + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateSource_klarna() { + let lineItems = [ + STPKlarnaLineItem(itemType: STPKlarnaLineItemType.SKU, itemDescription: "Test Item", quantity: NSNumber(value: 2), totalAmount: NSNumber(value: 500)), + STPKlarnaLineItem(itemType: STPKlarnaLineItemType.tax, itemDescription: "Tax", quantity: NSNumber(value: 1), totalAmount: NSNumber(value: 100)), + ] + let address = STPAddress() + address.line1 = "29 Arlington Avenue" + address.email = "test@example.com" + address.city = "London" + address.postalCode = "N1 7BE" + address.country = "GB" + address.phone = "02012267709" + let dob = STPDateOfBirth() + dob.day = 11 + dob.month = 3 + dob.year = 1952 + let params = STPSourceParams.klarnaParams(withReturnURL: "https://shop.example.com/return", currency: "GBP", purchaseCountry: "GB", items: lineItems, customPaymentMethods: [STPKlarnaPaymentMethods.none], billingAddress: address, billingFirstName: "Arthur", billingLastName: "Dent", billingDOB: dob) + + let client = STPAPIClient(publishableKey: STPTestingGBPublishableKey) + let expectation = self.expectation(description: "Source creation") + client.createSource(with: params) { source, error in + XCTAssertNil(error) + XCTAssertNotNil(source) + XCTAssertEqual(source?.type, STPSourceType.klarna) + XCTAssertEqual(source?.amount, NSNumber(value: 600)) + XCTAssertEqual(source?.owner?.address?.line1, address.line1) + XCTAssertEqual(source?.klarnaDetails?.purchaseCountry, "GB") + XCTAssertEqual(source?.redirect?.status, STPSourceRedirectStatus.pending) + XCTAssertEqual(source?.redirect?.returnURL, URL(string: "https://shop.example.com/return?redirect_merchant_name=xctest")) + XCTAssertNotNil(source?.redirect?.url) + + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + // 7/5/2023: + // Previously, we were allowed to use live keys and test w/ wechat on sources would generated "ios_native_url" + // however, this is no longer possible. Therefore, to get ample test coverage, we will have two tests: + // - testCreateSource_wechatPay_testMode - run in test mode, to ensure we can still call sources + // - testCreateSource_wechatPay_mocked - run a mocked version which is what we would expect in live mode + func testCreateSource_wechatPay_testMode() { + let params = STPSourceParams.wechatPay( + withAmount: 1010, + currency: "usd", + appId: "wxa0df51ec63e578ce", + statementDescriptor: nil) + let client = STPAPIClient(publishableKey: "pk_test_h0JFD5q63mLThM5JVSbrREmR") + let expectation = self.expectation(description: "Source creation") + client.createSource(with: params) { source, error in + XCTAssertNil(error) + XCTAssertNotNil(source) + XCTAssertEqual(source?.type, STPSourceType.weChatPay) + XCTAssertEqual(source?.status, STPSourceStatus.pending) + XCTAssertEqual(source?.amount, params.amount) + XCTAssertNil(source?.redirect) + + let wechat = source?.weChatPayDetails + XCTAssertNotNil(wechat) + // Will not be generated in test mode + // XCTAssertNotNil(wechat.weChatAppURL); + + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateSource_wechatPay_mocked() { + let params = STPSourceParams.wechatPay( + withAmount: 1010, + currency: "usd", + appId: "wxa0df51ec63e578ce", + statementDescriptor: nil) + + let source = STPFixtures.weChatPaySource() + XCTAssertEqual(source.type, STPSourceType.weChatPay) + XCTAssertEqual(source.status, STPSourceStatus.pending) + XCTAssertEqual(source.amount, params.amount) + XCTAssertNil(source.redirect) + + let wechat = source.weChatPayDetails + XCTAssertNotNil(wechat) + XCTAssertNotNil(wechat?.weChatAppURL) + } +} diff --git a/Stripe/StripeiOSTests/STPSourceOwnerTest.swift b/Stripe/StripeiOSTests/STPSourceOwnerTest.swift new file mode 100644 index 00000000..b2d4f930 --- /dev/null +++ b/Stripe/StripeiOSTests/STPSourceOwnerTest.swift @@ -0,0 +1,57 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPSourceOwnerTest.m +// Stripe +// +// Created by Joey Dong on 6/23/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import XCTest + +class STPSourceOwnerTest: XCTestCase { + // MARK: - STPAPIResponseDecodable Tests + + func testDecodedObjectFromAPIResponseRequiredFields() { + let requiredFields: [String]? = [] + + for field in requiredFields ?? [] { + var response = STPTestUtils.jsonNamed(STPTestJSONSource3DS)["owner"] as? [AnyHashable: Any] + response?.removeValue(forKey: field) + + XCTAssertNil(STPSourceOwner.decodedObject(fromAPIResponse: response)) + } + + XCTAssertNotNil(STPSourceOwner.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed(STPTestJSONSource3DS)["owner"] as? [AnyHashable: Any])) + } + + func testDecodedObjectFromAPIResponseMapping() { + guard + let response = STPTestUtils.jsonNamed(STPTestJSONSource3DS)["owner"] as? NSDictionary, + let owner = STPSourceOwner.decodedObject(fromAPIResponse: response as? [AnyHashable: Any]) else { + XCTFail() + return + } + + XCTAssertEqual(owner.address?.city, "Pittsburgh") + XCTAssertEqual(owner.address?.country, "US") + XCTAssertEqual(owner.address?.line1, "123 Fake St") + XCTAssertEqual(owner.address?.line2, "Apt 1") + XCTAssertEqual(owner.address?.postalCode, "19219") + XCTAssertEqual(owner.address?.state, "PA") + XCTAssertEqual(owner.email, "jenny.rosen@example.com") + XCTAssertEqual(owner.name, "Jenny Rosen") + XCTAssertEqual(owner.phone, "555-867-5309") + XCTAssertEqual(owner.verifiedAddress?.city, "Pittsburgh") + XCTAssertEqual(owner.verifiedAddress?.country, "US") + XCTAssertEqual(owner.verifiedAddress?.line1, "123 Fake St") + XCTAssertEqual(owner.verifiedAddress?.line2, "Apt 1") + XCTAssertEqual(owner.verifiedAddress?.postalCode, "19219") + XCTAssertEqual(owner.verifiedAddress?.state, "PA") + XCTAssertEqual(owner.verifiedEmail, "jenny.rosen@example.com") + XCTAssertEqual(owner.verifiedName, "Jenny Rosen") + XCTAssertEqual(owner.verifiedPhone, "555-867-5309") + + XCTAssertEqual(owner.allResponseFields as NSDictionary, response) + } +} diff --git a/Stripe/StripeiOSTests/STPSourceParamsTest.swift b/Stripe/StripeiOSTests/STPSourceParamsTest.swift new file mode 100644 index 00000000..0534c5b8 --- /dev/null +++ b/Stripe/StripeiOSTests/STPSourceParamsTest.swift @@ -0,0 +1,317 @@ +// +// STPSourceParamsTest.swift +// StripeiOS Tests +// +// Created by Ben Guo on 1/25/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPSourceParamsTest: XCTestCase { + // MARK: - + func testInit() { + let sourceParams = STPSourceParams() + XCTAssertEqual(sourceParams.rawTypeString, "") + XCTAssertEqual(sourceParams.flow, .unknown) + XCTAssertEqual(sourceParams.usage, .unknown) + XCTAssertEqual(sourceParams.additionalAPIParameters as NSDictionary, [:] as NSDictionary) + } + + func testType() { + let sourceParams = STPSourceParams() + XCTAssertEqual(sourceParams.type, .unknown) + + sourceParams.rawTypeString = "bancontact" + XCTAssertEqual(sourceParams.type, .bancontact) + + sourceParams.rawTypeString = "card" + XCTAssertEqual(sourceParams.type, .card) + + sourceParams.rawTypeString = "giropay" + XCTAssertEqual(sourceParams.type, .giropay) + + sourceParams.rawTypeString = "ideal" + XCTAssertEqual(sourceParams.type, .iDEAL) + + sourceParams.rawTypeString = "sepa_debit" + XCTAssertEqual(sourceParams.type, .SEPADebit) + + sourceParams.rawTypeString = "sofort" + XCTAssertEqual(sourceParams.type, .sofort) + + sourceParams.rawTypeString = "three_d_secure" + XCTAssertEqual(sourceParams.type, .threeDSecure) + + sourceParams.rawTypeString = "alipay" + XCTAssertEqual(sourceParams.type, .alipay) + + sourceParams.rawTypeString = "p24" + XCTAssertEqual(sourceParams.type, .P24) + + sourceParams.rawTypeString = "eps" + XCTAssertEqual(sourceParams.type, .EPS) + + sourceParams.rawTypeString = "multibanco" + XCTAssertEqual(sourceParams.type, .multibanco) + + sourceParams.rawTypeString = "klarna" + XCTAssertEqual(sourceParams.type, .klarna) + + sourceParams.rawTypeString = "unknown" + XCTAssertEqual(sourceParams.type, .unknown) + + sourceParams.rawTypeString = "garbage" + XCTAssertEqual(sourceParams.type, .unknown) + } + + func testSetType() { + let sourceParams = STPSourceParams() + XCTAssertEqual(sourceParams.type, .unknown) + + sourceParams.type = .bancontact + XCTAssertEqual(sourceParams.rawTypeString, "bancontact") + + sourceParams.type = .card + XCTAssertEqual(sourceParams.rawTypeString, "card") + + sourceParams.type = .giropay + XCTAssertEqual(sourceParams.rawTypeString, "giropay") + + sourceParams.type = .iDEAL + XCTAssertEqual(sourceParams.rawTypeString, "ideal") + + sourceParams.type = .SEPADebit + XCTAssertEqual(sourceParams.rawTypeString, "sepa_debit") + + sourceParams.type = .sofort + XCTAssertEqual(sourceParams.rawTypeString, "sofort") + + sourceParams.type = .threeDSecure + XCTAssertEqual(sourceParams.rawTypeString, "three_d_secure") + + sourceParams.type = .alipay + XCTAssertEqual(sourceParams.rawTypeString, "alipay") + + sourceParams.type = .P24 + XCTAssertEqual(sourceParams.rawTypeString, "p24") + + sourceParams.type = .EPS + XCTAssertEqual(sourceParams.rawTypeString, "eps") + + sourceParams.type = .multibanco + XCTAssertEqual(sourceParams.rawTypeString, "multibanco") + + sourceParams.type = .klarna + XCTAssertEqual(sourceParams.rawTypeString, "klarna") + + sourceParams.type = .unknown + XCTAssertNil(sourceParams.rawTypeString) + } + + func testSetTypePreserveUnknownRawTypeString() { + let sourceParams = STPSourceParams() + sourceParams.rawTypeString = "money" + sourceParams.type = .unknown + XCTAssertEqual(sourceParams.rawTypeString, "money") + } + + func testRawTypeString() { + let sourceParams = STPSourceParams() + + // Check defaults to unknown + XCTAssertEqual(sourceParams.type, .unknown) + + // Check changing type sets rawTypeString + sourceParams.type = .card + XCTAssertEqual(sourceParams.rawTypeString, STPSource.string(from: .card)) + + // Check changing to unknown raw string sets type to unknown + sourceParams.rawTypeString = "new_source_type" + XCTAssertEqual(sourceParams.type, .unknown) + + // Check once unknown that setting type to unknown doesnt clobber string + sourceParams.type = .unknown + XCTAssertEqual(sourceParams.rawTypeString, "new_source_type") + + // Check setting string to known type sets type correctly + sourceParams.rawTypeString = STPSource.string(from: .iDEAL) + XCTAssertEqual(sourceParams.type, .iDEAL) + } + + func testFlowString() { + let sourceParams = STPSourceParams() + XCTAssertNil(sourceParams.flowString()) + + sourceParams.flow = .redirect + XCTAssertEqual(sourceParams.flowString(), "redirect") + + sourceParams.flow = .receiver + XCTAssertEqual(sourceParams.flowString(), "receiver") + + sourceParams.flow = .codeVerification + XCTAssertEqual(sourceParams.flowString(), "code_verification") + + sourceParams.flow = .none + XCTAssertEqual(sourceParams.flowString(), "none") + } + + // MARK: - Constructors Tests + func testCardParamsWithCard() { + let card = STPCardParams() + card.number = "4242 4242 4242 4242" + card.cvc = "123" + card.expMonth = 6 + card.expYear = 2024 + card.currency = "usd" + card.name = "Jenny Rosen" + card.address.line1 = "123 Fake Street" + card.address.line2 = "Apartment 4" + card.address.city = "New York" + card.address.state = "NY" + card.address.country = "USA" + card.address.postalCode = "10002" + + let source = STPSourceParams.cardParams(withCard: card) + let sourceCard = source.additionalAPIParameters["card"] as! [String: AnyHashable] + XCTAssertEqual(sourceCard["number"], card.number) + XCTAssertEqual(sourceCard["cvc"], card.cvc) + XCTAssertEqual(sourceCard["exp_month"], NSNumber(value: card.expMonth)) + XCTAssertEqual(sourceCard["exp_year"], NSNumber(value: card.expYear)) + XCTAssertEqual(source.owner!["name"] as? String, card.name) + let sourceAddress = source.owner!["address"] as! [AnyHashable: Any] + XCTAssertEqual(sourceAddress["line1"] as? String, card.address.line1) + XCTAssertEqual(sourceAddress["line2"] as? String, card.address.line2) + XCTAssertEqual(sourceAddress["city"] as? String, card.address.city) + XCTAssertEqual(sourceAddress["state"] as? String, card.address.state) + XCTAssertEqual(sourceAddress["postal_code"] as? String, card.address.postalCode) + XCTAssertEqual(sourceAddress["country"] as? String, card.address.country) + } + + func testParamsWithVisaCheckout() { + let params = STPSourceParams.visaCheckoutParams(withCallId: "12345678") + + XCTAssertEqual(params.type, .card) + let sourceCard = params.additionalAPIParameters["card"] as? [AnyHashable: Any] + XCTAssertNotNil(sourceCard) + let sourceVisaCheckout = sourceCard!["visa_checkout"] as? [AnyHashable: Any] + XCTAssertNotNil(sourceVisaCheckout) + XCTAssertEqual(sourceVisaCheckout!["callid"] as! String, "12345678") + } + + func testParamsWithMasterPass() { + let params = STPSourceParams.masterpassParams( + withCartId: "12345678", + transactionId: "87654321" + ) + + XCTAssertEqual(params.type, .card) + let sourceCard = params.additionalAPIParameters["card"] as? [AnyHashable: Any] + XCTAssertNotNil(sourceCard) + let sourceMasterpass = sourceCard!["masterpass"] as? [AnyHashable: Any] + XCTAssertNotNil(sourceMasterpass) + XCTAssertEqual(sourceMasterpass!["cart_id"] as! String, "12345678") + XCTAssertEqual(sourceMasterpass!["transaction_id"] as! String, "87654321") + } + + func testKlarnaParams() { + let params = STPSourceParams.klarnaParams( + withReturnURL: "return_url", + currency: "USD", + purchaseCountry: "US", + items: [], + customPaymentMethods: [.none], + billingAddress: nil, + billingFirstName: nil, + billingLastName: nil, + billingDOB: nil + ) + + XCTAssertNotNil(params) + } + + // MARK: - Redirect Dictionary Tests + func redirectMerchantNameQueryItemValue(fromURLString urlString: String?) -> String? { + let components = NSURLComponents(string: urlString ?? "") + for item in components!.queryItems! { + if item.name == "redirect_merchant_name" { + return item.value + } + } + return nil + } + + func testRedirectMerchantNameURL() { + var sourceParams = STPSourceParams.sofortParams( + withAmount: 1000, + returnURL: "test://foo?value=baz", + country: "DE", + statementDescriptor: nil + ) + + var params = STPFormEncoder.dictionary(forObject: sourceParams) + // Should be nil because we have no app name in tests + XCTAssertNil( + redirectMerchantNameQueryItemValue( + fromURLString: (params["redirect"] as! [String: AnyHashable])["return_url"] + as? String + ) + ) + + sourceParams.redirectMerchantName = "bar" + params = STPFormEncoder.dictionary(forObject: sourceParams) + XCTAssertEqual( + redirectMerchantNameQueryItemValue( + fromURLString: (params["redirect"] as! [String: AnyHashable])["return_url"] + as? String + ), + "bar" + ) + + sourceParams = STPSourceParams.sofortParams( + withAmount: 1000, + returnURL: "test://foo?redirect_merchant_name=Manual%20Custom%20Name", + country: "DE", + statementDescriptor: nil + ) + sourceParams.redirectMerchantName = "bar" + params = STPFormEncoder.dictionary(forObject: sourceParams) + // Don't override names set by the user directly in the url + XCTAssertEqual( + redirectMerchantNameQueryItemValue( + fromURLString: (params["redirect"] as! [String: AnyHashable])["return_url"] + as? String + ), + "Manual Custom Name" + ) + + } + + // MARK: - STPFormEncodable Tests + func testRootObjectName() { + XCTAssertNil(STPSourceParams.rootObjectName()) + } + + func testPropertyNamesToFormFieldNamesMapping() { + let sourceParams = STPSourceParams() + + let mapping = STPSourceParams.propertyNamesToFormFieldNamesMapping() + + for propertyName in mapping.keys { + XCTAssertFalse(propertyName.contains(":")) + XCTAssert(sourceParams.responds(to: NSSelectorFromString(propertyName))) + } + + for formFieldName in mapping.values { + XCTAssert(formFieldName.count > 0) + } + + XCTAssertEqual(mapping.values.count, Set(mapping.values).count) + } +} diff --git a/Stripe/StripeiOSTests/STPSourceReceiverTest.swift b/Stripe/StripeiOSTests/STPSourceReceiverTest.swift new file mode 100644 index 00000000..90a8abf0 --- /dev/null +++ b/Stripe/StripeiOSTests/STPSourceReceiverTest.swift @@ -0,0 +1,48 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPSourceReceiverTest.m +// Stripe +// +// Created by Joey Dong on 6/26/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import XCTest + +class STPSourceReceiverTest: XCTestCase { + // MARK: - Description Tests + + func testDescription() { + let receiver = STPSourceReceiver.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed(STPTestJSONSource3DS)["receiver"] as? [AnyHashable: Any]) + XCTAssertNotNil(receiver?.description) + } + + // MARK: - STPAPIResponseDecodable Tests + + func testDecodedObjectFromAPIResponseRequiredFields() { + let requiredFields = [ + "address", + ] + + for field in requiredFields { + var response = STPTestUtils.jsonNamed(STPTestJSONSource3DS)["receiver"] as? [AnyHashable: Any] + response?.removeValue(forKey: field) + + XCTAssertNil(STPSourceReceiver.decodedObject(fromAPIResponse: response)) + } + + XCTAssertNotNil(STPSourceReceiver.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed(STPTestJSONSource3DS)["receiver"] as? [AnyHashable: Any])) + } + + func testDecodedObjectFromAPIResponseMapping() { + let response = STPTestUtils.jsonNamed(STPTestJSONSource3DS)["receiver"] as? [AnyHashable: Any] + let receiver = STPSourceReceiver.decodedObject(fromAPIResponse: response) + + XCTAssertEqual(receiver?.address, "test_1MBhWS3uv4ynCfQXF3xQjJkzFPukr4K56N") + XCTAssertEqual(receiver?.amountCharged, NSNumber(value: 300)) + XCTAssertEqual(receiver?.amountReceived, NSNumber(value: 200)) + XCTAssertEqual(receiver?.amountReturned, NSNumber(value: 100)) + + XCTAssertEqual(receiver?.allResponseFields as! NSDictionary, response as! NSDictionary) + } +} diff --git a/Stripe/StripeiOSTests/STPSourceRedirectTest.swift b/Stripe/StripeiOSTests/STPSourceRedirectTest.swift new file mode 100644 index 00000000..1752f753 --- /dev/null +++ b/Stripe/StripeiOSTests/STPSourceRedirectTest.swift @@ -0,0 +1,100 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPSourceRedirectTest.m +// Stripe +// +// Created by Joey Dong on 6/21/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +@testable import StripePayments +import XCTest + +class STPSourceRedirectTest: XCTestCase { + // MARK: - STPSourceRedirectStatus Tests + + func testStatusFromString() { + XCTAssertEqual(STPSourceRedirect.status(from: "pending"), STPSourceRedirectStatus.pending) + XCTAssertEqual(STPSourceRedirect.status(from: "PENDING"), STPSourceRedirectStatus.pending) + + XCTAssertEqual(STPSourceRedirect.status(from: "succeeded"), STPSourceRedirectStatus.succeeded) + XCTAssertEqual(STPSourceRedirect.status(from: "SUCCEEDED"), STPSourceRedirectStatus.succeeded) + + XCTAssertEqual(STPSourceRedirect.status(from: "failed"), STPSourceRedirectStatus.failed) + XCTAssertEqual(STPSourceRedirect.status(from: "FAILED"), STPSourceRedirectStatus.failed) + + XCTAssertEqual(STPSourceRedirect.status(from: "unknown"), STPSourceRedirectStatus.unknown) + XCTAssertEqual(STPSourceRedirect.status(from: "UNKNOWN"), STPSourceRedirectStatus.unknown) + + XCTAssertEqual(STPSourceRedirect.status(from: "not_required"), STPSourceRedirectStatus.notRequired) + XCTAssertEqual(STPSourceRedirect.status(from: "NOT_REQUIRED"), STPSourceRedirectStatus.notRequired) + + XCTAssertEqual(STPSourceRedirect.status(from: "garbage"), STPSourceRedirectStatus.unknown) + XCTAssertEqual(STPSourceRedirect.status(from: "GARBAGE"), STPSourceRedirectStatus.unknown) + } + + func testStringFromStatus() { + let values = [ + STPSourceRedirectStatus.pending, + STPSourceRedirectStatus.succeeded, + STPSourceRedirectStatus.failed, + STPSourceRedirectStatus.unknown, + ] + + for status in values { + let string = STPSourceRedirect.string(from: status) + + switch status { + case STPSourceRedirectStatus.pending: + XCTAssertEqual(string, "pending") + case STPSourceRedirectStatus.succeeded: + XCTAssertEqual(string, "succeeded") + case STPSourceRedirectStatus.failed: + XCTAssertEqual(string, "failed") + case STPSourceRedirectStatus.notRequired: + XCTAssertEqual(string, "not_required") + case STPSourceRedirectStatus.unknown: + XCTAssertNil(string) + default: + break + } + } + } + + // MARK: - Description Tests + + func testDescription() { + let redirect = STPSourceRedirect.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("3DSSource")["redirect"] as? [AnyHashable: Any]) + XCTAssertNotNil(redirect?.description) + } + + // MARK: - STPAPIResponseDecodable Tests + + func testDecodedObjectFromAPIResponseRequiredFields() { + let requiredFields = [ + "return_url", + "status", + "url", + ] + + for field in requiredFields { + var response = STPTestUtils.jsonNamed("3DSSource")["redirect"] as? [AnyHashable: Any] + response?.removeValue(forKey: field) + + XCTAssertNil(STPSourceRedirect.decodedObject(fromAPIResponse: response)) + } + + XCTAssertNotNil(STPSourceRedirect.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("3DSSource")["redirect"] as? [AnyHashable: Any])) + } + + func testDecodedObjectFromAPIResponseMapping() { + let response = STPTestUtils.jsonNamed("3DSSource")["redirect"] as? [AnyHashable: Any] + let redirect = STPSourceRedirect.decodedObject(fromAPIResponse: response) + + XCTAssertEqual(redirect?.returnURL, URL(string: "exampleappschema://stripe_callback")) + XCTAssertEqual(redirect?.status, STPSourceRedirectStatus.pending) + XCTAssertEqual(redirect?.url, URL(string: "https://hooks.stripe.com/redirect/authenticate/src_19YlvWAHEMiOZZp1QQlOD79v?client_secret=src_client_secret_kBwCSm6Xz5MQETiJ43hUH8qv")) + + XCTAssertEqual(redirect?.allResponseFields as! NSDictionary, response as! NSDictionary) + } +} diff --git a/Stripe/StripeiOSTests/STPSourceSEPADebitDetailsTest.swift b/Stripe/StripeiOSTests/STPSourceSEPADebitDetailsTest.swift new file mode 100644 index 00000000..77890b0e --- /dev/null +++ b/Stripe/StripeiOSTests/STPSourceSEPADebitDetailsTest.swift @@ -0,0 +1,49 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPSourceSEPADebitDetails.m +// Stripe +// +// Created by Joey Dong on 6/26/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +@testable import StripePayments +import XCTest + +class STPSourceSEPADebitDetailsTest: XCTestCase { + // MARK: - Description Tests + + func testDescription() { + let sepaDebitDetails = STPSourceSEPADebitDetails.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("SEPADebitSource")["sepa_debit"] as? [AnyHashable: Any]) + XCTAssertNotNil(sepaDebitDetails?.description) + } + + // MARK: - STPAPIResponseDecodable Tests + + func testDecodedObjectFromAPIResponseRequiredFields() { + let requiredFields: [String]? = [] + + for field in requiredFields ?? [] { + var response = STPTestUtils.jsonNamed("SEPADebitSource")["sepa_debit"] as? [AnyHashable: Any] + response?.removeValue(forKey: field) + + XCTAssertNil(STPSourceSEPADebitDetails.decodedObject(fromAPIResponse: response)) + } + + XCTAssertNotNil(STPSourceSEPADebitDetails.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("SEPADebitSource")["sepa_debit"] as? [AnyHashable: Any])) + } + + func testDecodedObjectFromAPIResponseMapping() { + let response = STPTestUtils.jsonNamed("SEPADebitSource")["sepa_debit"] as? [AnyHashable: Any] + let sepaDebitDetails = STPSourceSEPADebitDetails.decodedObject(fromAPIResponse: response) + + XCTAssertEqual(sepaDebitDetails?.bankCode, "37040044") + XCTAssertEqual(sepaDebitDetails?.country, "DE") + XCTAssertEqual(sepaDebitDetails?.fingerprint, "NxdSyRegc9PsMkWy") + XCTAssertEqual(sepaDebitDetails?.last4, "3001") + XCTAssertEqual(sepaDebitDetails?.mandateReference, "NXDSYREGC9PSMKWY") + XCTAssertEqual(sepaDebitDetails?.mandateURL, URL(string: "https://hooks.stripe.com/adapter/sepa_debit/file/src_18HgGjHNCLa1Vra6Y9TIP6tU/src_client_secret_XcBmS94nTg5o0xc9MSliSlDW")) + + XCTAssertEqual(sepaDebitDetails?.allResponseFields as! NSDictionary, response as! NSDictionary) + } +} diff --git a/Stripe/StripeiOSTests/STPSourceTest.swift b/Stripe/StripeiOSTests/STPSourceTest.swift new file mode 100644 index 00000000..6e358141 --- /dev/null +++ b/Stripe/StripeiOSTests/STPSourceTest.swift @@ -0,0 +1,495 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPSourceTest.m +// Stripe +// +// Created by Ben Guo on 1/24/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +@testable import StripePayments +@testable import StripePaymentsUI +import XCTest + +class STPSourceTest: XCTestCase { + // MARK: - STPSourceType Tests + + func testTypeFromString() { + XCTAssertEqual(STPSource.type(from: "bancontact"), STPSourceType.bancontact) + XCTAssertEqual(STPSource.type(from: "BANCONTACT"), STPSourceType.bancontact) + + XCTAssertEqual(STPSource.type(from: "card"), STPSourceType.card) + XCTAssertEqual(STPSource.type(from: "CARD"), STPSourceType.card) + + XCTAssertEqual(STPSource.type(from: "giropay"), STPSourceType.giropay) + XCTAssertEqual(STPSource.type(from: "GIROPAY"), STPSourceType.giropay) + + XCTAssertEqual(STPSource.type(from: "ideal"), STPSourceType.iDEAL) + XCTAssertEqual(STPSource.type(from: "IDEAL"), STPSourceType.iDEAL) + + XCTAssertEqual(STPSource.type(from: "sepa_debit"), STPSourceType.SEPADebit) + XCTAssertEqual(STPSource.type(from: "SEPA_DEBIT"), STPSourceType.SEPADebit) + + XCTAssertEqual(STPSource.type(from: "sofort"), STPSourceType.sofort) + XCTAssertEqual(STPSource.type(from: "Sofort"), STPSourceType.sofort) + + XCTAssertEqual(STPSource.type(from: "three_d_secure"), STPSourceType.threeDSecure) + XCTAssertEqual(STPSource.type(from: "THREE_D_SECURE"), STPSourceType.threeDSecure) + + XCTAssertEqual(STPSource.type(from: "alipay"), STPSourceType.alipay) + XCTAssertEqual(STPSource.type(from: "ALIPAY"), STPSourceType.alipay) + + XCTAssertEqual(STPSource.type(from: "p24"), STPSourceType.P24) + XCTAssertEqual(STPSource.type(from: "P24"), STPSourceType.P24) + + XCTAssertEqual(STPSource.type(from: "eps"), STPSourceType.EPS) + XCTAssertEqual(STPSource.type(from: "EPS"), STPSourceType.EPS) + + XCTAssertEqual(STPSource.type(from: "multibanco"), STPSourceType.multibanco) + XCTAssertEqual(STPSource.type(from: "MULTIBANCO"), STPSourceType.multibanco) + + XCTAssertEqual(STPSource.type(from: "unknown"), STPSourceType.unknown) + XCTAssertEqual(STPSource.type(from: "UNKNOWN"), STPSourceType.unknown) + + XCTAssertEqual(STPSource.type(from: "garbage"), STPSourceType.unknown) + XCTAssertEqual(STPSource.type(from: "GARBAGE"), STPSourceType.unknown) + } + + func testStringFromType() { + let values = [ + STPSourceType.bancontact, + STPSourceType.card, + STPSourceType.giropay, + STPSourceType.iDEAL, + STPSourceType.SEPADebit, + STPSourceType.sofort, + STPSourceType.threeDSecure, + STPSourceType.alipay, + STPSourceType.P24, + STPSourceType.EPS, + STPSourceType.multibanco, + STPSourceType.unknown, + ] + + for type in values { + let string = STPSource.string(from: type) + + switch type { + case STPSourceType.bancontact: + XCTAssertEqual(string, "bancontact") + case STPSourceType.card: + XCTAssertEqual(string, "card") + case STPSourceType.giropay: + XCTAssertEqual(string, "giropay") + case STPSourceType.iDEAL: + XCTAssertEqual(string, "ideal") + case STPSourceType.SEPADebit: + XCTAssertEqual(string, "sepa_debit") + case STPSourceType.sofort: + XCTAssertEqual(string, "sofort") + case STPSourceType.threeDSecure: + XCTAssertEqual(string, "three_d_secure") + case STPSourceType.alipay: + XCTAssertEqual(string, "alipay") + case STPSourceType.P24: + XCTAssertEqual(string, "p24") + case STPSourceType.EPS: + XCTAssertEqual(string, "eps") + case STPSourceType.multibanco: + XCTAssertEqual(string, "multibanco") + case STPSourceType.weChatPay: + XCTAssertEqual(string, "wechat") + case STPSourceType.klarna: + XCTAssertEqual(string, "klarna") + case STPSourceType.unknown: + XCTAssertNil(string) + default: + break + } + } + } + + // MARK: - STPSourceFlow Tests + + func testFlowFromString() { + XCTAssertEqual(STPSource.flow(from: "redirect"), .redirect) + XCTAssertEqual(STPSource.flow(from: "REDIRECT"), .redirect) + + XCTAssertEqual(STPSource.flow(from: "receiver"), .receiver) + XCTAssertEqual(STPSource.flow(from: "RECEIVER"), .receiver) + + XCTAssertEqual(STPSource.flow(from: "code_verification"), .codeVerification) + XCTAssertEqual(STPSource.flow(from: "CODE_VERIFICATION"), .codeVerification) + + XCTAssertEqual(STPSource.flow(from: "none"), .none) + XCTAssertEqual(STPSource.flow(from: "NONE"), .none) + + XCTAssertEqual(STPSource.flow(from: "garbage"), .unknown) + XCTAssertEqual(STPSource.flow(from: "GARBAGE"), .unknown) + } + + func testStringFromFlow() { + let values: [STPSourceFlow] = [ + .redirect, + .receiver, + .codeVerification, + .none, + .unknown, + ] + + for flow in values { + let string = STPSource.string(from: flow) + + switch flow { + case .redirect: + XCTAssertEqual(string, "redirect") + case .receiver: + XCTAssertEqual(string, "receiver") + case .codeVerification: + XCTAssertEqual(string, "code_verification") + case .none: + XCTAssertEqual(string, "none") + case .unknown: + XCTAssertNil(string) + default: + break + } + } + } + + // MARK: - STPSourceStatus Tests + + func testStatusFromString() { + XCTAssertEqual(STPSource.status(from: "pending"), STPSourceStatus.pending) + XCTAssertEqual(STPSource.status(from: "PENDING"), STPSourceStatus.pending) + + XCTAssertEqual(STPSource.status(from: "chargeable"), STPSourceStatus.chargeable) + XCTAssertEqual(STPSource.status(from: "CHARGEABLE"), STPSourceStatus.chargeable) + + XCTAssertEqual(STPSource.status(from: "consumed"), STPSourceStatus.consumed) + XCTAssertEqual(STPSource.status(from: "CONSUMED"), STPSourceStatus.consumed) + + XCTAssertEqual(STPSource.status(from: "canceled"), STPSourceStatus.canceled) + XCTAssertEqual(STPSource.status(from: "CANCELED"), STPSourceStatus.canceled) + + XCTAssertEqual(STPSource.status(from: "failed"), STPSourceStatus.failed) + XCTAssertEqual(STPSource.status(from: "FAILED"), STPSourceStatus.failed) + + XCTAssertEqual(STPSource.status(from: "garbage"), STPSourceStatus.unknown) + XCTAssertEqual(STPSource.status(from: "GARBAGE"), STPSourceStatus.unknown) + } + + func testStringFromStatus() { + let values = [ + STPSourceStatus.pending, + STPSourceStatus.chargeable, + STPSourceStatus.consumed, + STPSourceStatus.canceled, + STPSourceStatus.failed, + STPSourceStatus.unknown, + ] + + for status in values { + let string = STPSource.string(from: status) + + switch status { + case STPSourceStatus.pending: + XCTAssertEqual(string, "pending") + case STPSourceStatus.chargeable: + XCTAssertEqual(string, "chargeable") + case STPSourceStatus.consumed: + XCTAssertEqual(string, "consumed") + case STPSourceStatus.canceled: + XCTAssertEqual(string, "canceled") + case STPSourceStatus.failed: + XCTAssertEqual(string, "failed") + case STPSourceStatus.unknown: + XCTAssertNil(string) + default: + break + } + } + } + + // MARK: - STPSourceUsage Tests + + func testUsageFromString() { + XCTAssertEqual(STPSource.usage(from: "reusable"), STPSourceUsage.reusable) + XCTAssertEqual(STPSource.usage(from: "REUSABLE"), STPSourceUsage.reusable) + + XCTAssertEqual(STPSource.usage(from: "single_use"), STPSourceUsage.singleUse) + XCTAssertEqual(STPSource.usage(from: "SINGLE_USE"), STPSourceUsage.singleUse) + + XCTAssertEqual(STPSource.usage(from: "garbage"), STPSourceUsage.unknown) + XCTAssertEqual(STPSource.usage(from: "GARBAGE"), STPSourceUsage.unknown) + } + + func testStringFromUsage() { + let values = [ + STPSourceUsage.reusable, + STPSourceUsage.singleUse, + STPSourceUsage.unknown, + ] + + for usage in values { + let string = STPSource.string(from: usage) + + switch usage { + case STPSourceUsage.reusable: + XCTAssertEqual(string, "reusable") + case STPSourceUsage.singleUse: + XCTAssertEqual(string, "single_use") + case STPSourceUsage.unknown: + XCTAssertNil(string) + default: + break + } + } + } + + // MARK: - Equality Tests + + func testSourceEquals() { + let source1 = STPSource.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("AlipaySource")) + let source2 = STPSource.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("AlipaySource")) + + XCTAssertEqual(source1, source1) + XCTAssertEqual(source1, source2) + + XCTAssertEqual(source1?.hash, source1?.hash) + XCTAssertEqual(source1?.hash, source2?.hash) + } + + // MARK: - Description Tests + + func testDescription() { + let source = STPSource.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("AlipaySource")) + XCTAssertNotNil(source?.description) + } + + // MARK: - STPAPIResponseDecodable Tests + + func testDecodedObjectFromAPIResponseRequiredFields() { + let requiredFields = [ + "id", + "livemode", + "status", + "type", + ] + + for field in requiredFields { + var response = STPTestUtils.jsonNamed("AlipaySource") + response?.removeValue(forKey: field) + + XCTAssertNil(STPSource.decodedObject(fromAPIResponse: response)) + } + + XCTAssertNotNil(STPSource.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("AlipaySource"))) + } + + func testDecodingSource_3ds() { + let response = STPTestUtils.jsonNamed(STPTestJSONSource3DS) + let source = STPSource.decodedObject(fromAPIResponse: response) + XCTAssertEqual(source?.stripeID, "src_456") + XCTAssertEqual(source?.amount, NSNumber(value: 1099)) + XCTAssertEqual(source?.clientSecret, "src_client_secret_456") + XCTAssertEqual(source?.created?.timeIntervalSince1970 ?? 0, 1483663790.0, accuracy: 1.0) + XCTAssertEqual(source?.currency, "eur") + XCTAssertEqual(source?.flow, .redirect) + XCTAssertEqual(source?.livemode, false) + XCTAssertNil(source?.metadata) + XCTAssertNotNil(source?.owner) // STPSourceOwnerTest + XCTAssertNotNil(source?.receiver) // STPSourceReceiverTest + XCTAssertNotNil(source?.redirect) // STPSourceRedirectTest + XCTAssertEqual(source?.status, STPSourceStatus.pending) + XCTAssertEqual(source?.type, STPSourceType.threeDSecure) + XCTAssertEqual(source?.usage, STPSourceUsage.singleUse) + XCTAssertNil(source?.verification) + var threedsecure = response?["three_d_secure"] as? [AnyHashable: Any] + threedsecure?.removeValue(forKey: "customer") // should be nil +// XCTAssertEqual(source?.details, threedsecure) + XCTAssertNotNil(source?.details) + XCTAssertNil(source?.cardDetails) // STPSourceCardDetailsTest + XCTAssertNil(source?.sepaDebitDetails) // STPSourceSEPADebitDetailsTest + } + + func testDecodingSource_alipay() { + let response = STPTestUtils.jsonNamed(STPTestJSONSourceAlipay) + let source = STPSource.decodedObject(fromAPIResponse: response) + XCTAssertEqual(source?.stripeID, "src_123") + XCTAssertEqual(source?.amount, NSNumber(value: 1099)) + XCTAssertEqual(source?.clientSecret, "src_client_secret_123") + XCTAssertEqual(source?.created?.timeIntervalSince1970 ?? 0, 1445277809.0, accuracy: 1.0) + XCTAssertEqual(source?.currency, "usd") + XCTAssertEqual(source?.flow, .redirect) + XCTAssertEqual(source?.livemode, true) + XCTAssertNil(source?.metadata) + XCTAssertNotNil(source?.owner) // STPSourceOwnerTest + XCTAssertNil(source?.receiver) // STPSourceReceiverTest + XCTAssertNotNil(source?.redirect) // STPSourceRedirectTest + XCTAssertEqual(source?.status, STPSourceStatus.pending) + XCTAssertEqual(source?.type, STPSourceType.alipay) + XCTAssertEqual(source?.usage, STPSourceUsage.singleUse) + XCTAssertNil(source?.verification) + var alipayResponse = response!["alipay"] as? [AnyHashable: Any] + alipayResponse?.removeValue(forKey: "native_url") // should be nil + alipayResponse?.removeValue(forKey: "statement_descriptor") // should be nil + XCTAssertEqual(source?.details as! NSDictionary, alipayResponse as! NSDictionary) + XCTAssertNil(source?.cardDetails) // STPSourceCardDetailsTest + XCTAssertNil(source?.sepaDebitDetails) // STPSourceSEPADebitDetailsTest + } + + func testDecodingSource_card() { + let response = STPTestUtils.jsonNamed(STPTestJSONSourceCard) + let source = STPSource.decodedObject(fromAPIResponse: response) + XCTAssertEqual(source?.stripeID, "src_123") + XCTAssertNil(source?.amount) + XCTAssertEqual(source?.clientSecret, "src_client_secret_123") + XCTAssertEqual(source?.created?.timeIntervalSince1970 ?? 0, 1483575790.0, accuracy: 1.0) + XCTAssertNil(source?.currency) + XCTAssertEqual(source?.flow, STPSourceFlow.none) + XCTAssertEqual(source?.livemode, false) + XCTAssertNil(source?.metadata) + XCTAssertNotNil(source?.owner) // STPSourceOwnerTest + XCTAssertNil(source?.receiver) // STPSourceReceiverTest + XCTAssertNil(source?.redirect) // STPSourceRedirectTest + XCTAssertEqual(source?.status, STPSourceStatus.chargeable) + XCTAssertEqual(source?.type, STPSourceType.card) + XCTAssertEqual(source?.usage, STPSourceUsage.reusable) + XCTAssertNil(source?.verification) + XCTAssertEqual(source?.details as! NSDictionary, response!["card"] as! NSDictionary) + XCTAssertNotNil(source?.cardDetails) // STPSourceCardDetailsTest + XCTAssertNil(source?.sepaDebitDetails) // STPSourceSEPADebitDetailsTest + } + + func testDecodingSource_ideal() { + let response = STPTestUtils.jsonNamed(STPTestJSONSourceiDEAL) + let source = STPSource.decodedObject(fromAPIResponse: response) + XCTAssertEqual(source?.stripeID, "src_123") + XCTAssertEqual(source?.amount, NSNumber(value: 1099)) + XCTAssertEqual(source?.clientSecret, "src_client_secret_123") + XCTAssertEqual(source?.created?.timeIntervalSince1970 ?? 0, 1445277809.0, accuracy: 1.0) + XCTAssertEqual(source?.currency, "eur") + XCTAssertEqual(source?.flow, .redirect) + XCTAssertEqual(source?.livemode, true) + XCTAssertNil(source?.metadata) + XCTAssertNotNil(source?.owner) // STPSourceOwnerTest + XCTAssertNil(source?.receiver) // STPSourceReceiverTest + XCTAssertNotNil(source?.redirect) // STPSourceRedirectTest + XCTAssertEqual(source?.status, STPSourceStatus.pending) + XCTAssertEqual(source?.type, STPSourceType.iDEAL) + XCTAssertEqual(source?.usage, STPSourceUsage.singleUse) + XCTAssertNil(source?.verification) + XCTAssertEqual(source?.details as! NSDictionary, response!["ideal"] as! NSDictionary) + XCTAssertNil(source?.cardDetails) // STPSourceCardDetailsTest + XCTAssertNil(source?.sepaDebitDetails) // STPSourceSEPADebitDetailsTest + } + + func testDecodingSource_sepa_debit() { + let response = STPTestUtils.jsonNamed(STPTestJSONSourceSEPADebit) + let source = STPSource.decodedObject(fromAPIResponse: response) + XCTAssertEqual(source?.stripeID, "src_18HgGjHNCLa1Vra6Y9TIP6tU") + XCTAssertNil(source?.amount) + XCTAssertEqual(source?.clientSecret, "src_client_secret_XcBmS94nTg5o0xc9MSliSlDW") + XCTAssertEqual(source?.created?.timeIntervalSince1970 ?? 0, 1464803577.0, accuracy: 1.0) + XCTAssertEqual(source?.currency, "eur") + XCTAssertEqual(source?.flow, STPSourceFlow.none) + XCTAssertEqual(source?.livemode, false) + XCTAssertNil(source?.metadata) + XCTAssertEqual(source?.owner?.name, "Jenny Rosen") + XCTAssertNotNil(source?.owner) // STPSourceOwnerTest + XCTAssertNil(source?.receiver) // STPSourceReceiverTest + XCTAssertNil(source?.redirect) // STPSourceRedirectTest + XCTAssertEqual(source?.status, STPSourceStatus.chargeable) + XCTAssertEqual(source?.type, STPSourceType.SEPADebit) + XCTAssertEqual(source?.usage, STPSourceUsage.reusable) + XCTAssertEqual(source?.verification?.attemptsRemaining, NSNumber(value: 5)) + XCTAssertEqual(source?.verification?.status, STPSourceVerificationStatus.pending) + XCTAssertEqual(source?.details as! NSDictionary, response!["sepa_debit"] as! NSDictionary) + XCTAssertNil(source?.cardDetails) // STPSourceCardDetailsTest + XCTAssertNotNil(source?.sepaDebitDetails) // STPSourceSEPADebitDetailsTest + } + + // MARK: - STPPaymentOption Tests + + func possibleAPIResponses() -> [[AnyHashable: Any]] { + return [ + STPTestUtils.jsonNamed(STPTestJSONSourceCard), + STPTestUtils.jsonNamed(STPTestJSONSource3DS), + STPTestUtils.jsonNamed(STPTestJSONSourceAlipay), + STPTestUtils.jsonNamed(STPTestJSONSourceBancontact), + STPTestUtils.jsonNamed(STPTestJSONSourceEPS), + STPTestUtils.jsonNamed(STPTestJSONSourceGiropay), + STPTestUtils.jsonNamed(STPTestJSONSourceiDEAL), + STPTestUtils.jsonNamed(STPTestJSONSourceMultibanco), + STPTestUtils.jsonNamed(STPTestJSONSourceP24), + STPTestUtils.jsonNamed(STPTestJSONSourceSEPADebit), + STPTestUtils.jsonNamed(STPTestJSONSourceSofort), + ] + } + + func testPaymentOptionImage() { + for response in possibleAPIResponses() { + let source = STPSource.decodedObject(fromAPIResponse: response) + + switch source!.type { + case STPSourceType.card: + STPAssertEqualImages(source?.image, STPImageLibrary.cardBrandImage(for: source?.cardDetails?.brand ?? .unknown)) + default: + STPAssertEqualImages(source?.image, STPImageLibrary.cardBrandImage(for: STPCardBrand.unknown)) + } + } + } + + func testPaymentOptionTemplateImage() { + for response in possibleAPIResponses() { + let source = STPSource.decodedObject(fromAPIResponse: response) + + switch source!.type { + case STPSourceType.card: + STPAssertEqualImages(source?.templateImage, STPImageLibrary.templatedBrandImage(for: source?.cardDetails?.brand ?? .unknown)) + default: + STPAssertEqualImages(source?.templateImage, STPImageLibrary.templatedBrandImage(for: STPCardBrand.unknown)) + } + } + } + + func testPaymentOptionLabel() { + for response in possibleAPIResponses() { + let source = STPSource.decodedObject(fromAPIResponse: response) + + switch source!.type { + case STPSourceType.bancontact: + XCTAssertEqual(source?.label, "Bancontact") + case STPSourceType.card: + XCTAssertEqual(source?.label, "Visa 5556") + case STPSourceType.giropay: + XCTAssertEqual(source?.label, "giropay") + case STPSourceType.iDEAL: + XCTAssertEqual(source?.label, "iDEAL") + case STPSourceType.SEPADebit: + XCTAssertEqual(source?.label, "SEPA Debit") + case STPSourceType.sofort: + XCTAssertEqual(source?.label, "Sofort") + case STPSourceType.threeDSecure: + XCTAssertEqual(source?.label, "3D Secure") + case STPSourceType.alipay: + XCTAssertEqual(source?.label, "Alipay") + case STPSourceType.P24: + XCTAssertEqual(source?.label, "Przelewy24") + case STPSourceType.EPS: + XCTAssertEqual(source?.label, "EPS") + case STPSourceType.multibanco: + XCTAssertEqual(source?.label, "Multibanco") + case STPSourceType.weChatPay: + XCTAssertEqual(source?.label, "WeChat Pay") + case STPSourceType.klarna: + XCTAssertEqual(source?.label, "Klarna") + case STPSourceType.unknown: + XCTAssertEqual(source?.label, STPCard.string(from: .unknown)) + default: + break + } + } + } +} diff --git a/Stripe/StripeiOSTests/STPSourceVerificationTest.swift b/Stripe/StripeiOSTests/STPSourceVerificationTest.swift new file mode 100644 index 00000000..cce05ead --- /dev/null +++ b/Stripe/StripeiOSTests/STPSourceVerificationTest.swift @@ -0,0 +1,92 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPSourceVerificationTest.m +// Stripe +// +// Created by Joey Dong on 6/21/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +@testable import StripePayments +import XCTest + +class STPSourceVerificationTest: XCTestCase { + // MARK: - STPSourceVerificationStatus Tests + + func testStatusFromString() { + XCTAssertEqual(STPSourceVerification.status(from: "pending"), STPSourceVerificationStatus.pending) + XCTAssertEqual(STPSourceVerification.status(from: "pending"), STPSourceVerificationStatus.pending) + + XCTAssertEqual(STPSourceVerification.status(from: "succeeded"), STPSourceVerificationStatus.succeeded) + XCTAssertEqual(STPSourceVerification.status(from: "SUCCEEDED"), STPSourceVerificationStatus.succeeded) + + XCTAssertEqual(STPSourceVerification.status(from: "failed"), STPSourceVerificationStatus.failed) + XCTAssertEqual(STPSourceVerification.status(from: "FAILED"), STPSourceVerificationStatus.failed) + + XCTAssertEqual(STPSourceVerification.status(from: "unknown"), STPSourceVerificationStatus.unknown) + XCTAssertEqual(STPSourceVerification.status(from: "UNKNOWN"), STPSourceVerificationStatus.unknown) + + XCTAssertEqual(STPSourceVerification.status(from: "garbage"), STPSourceVerificationStatus.unknown) + XCTAssertEqual(STPSourceVerification.status(from: "GARBAGE"), STPSourceVerificationStatus.unknown) + } + + func testStringFromStatus() { + let values = [ + STPSourceVerificationStatus.pending, + STPSourceVerificationStatus.succeeded, + STPSourceVerificationStatus.failed, + STPSourceVerificationStatus.unknown, + ] + + for status in values { + let string = STPSourceVerification.string(from: status) + + switch status { + case STPSourceVerificationStatus.pending: + XCTAssertEqual(string, "pending") + case STPSourceVerificationStatus.succeeded: + XCTAssertEqual(string, "succeeded") + case STPSourceVerificationStatus.failed: + XCTAssertEqual(string, "failed") + case STPSourceVerificationStatus.unknown: + XCTAssertNil(string) + default: + break + } + } + } + + // MARK: - Description Tests + + func testDescription() { + let verification = STPSourceVerification.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("SEPADebitSource")["verification"] as? [AnyHashable: Any]) + XCTAssertNotNil(verification?.description) + } + + // MARK: - STPAPIResponseDecodable Tests + + func testDecodedObjectFromAPIResponseRequiredFields() { + let requiredFields = [ + "status", + ] + + for field in requiredFields { + var response = STPTestUtils.jsonNamed("SEPADebitSource")["verification"] as? [AnyHashable: Any] + response?.removeValue(forKey: field) + + XCTAssertNil(STPSourceVerification.decodedObject(fromAPIResponse: response)) + } + + XCTAssertNotNil(STPSourceVerification.decodedObject(fromAPIResponse: STPTestUtils.jsonNamed("SEPADebitSource")["verification"] as? [AnyHashable: Any])) + } + + func testDecodedObjectFromAPIResponseMapping() { + let response = STPTestUtils.jsonNamed("SEPADebitSource")["verification"] as? [AnyHashable: Any] + let verification = STPSourceVerification.decodedObject(fromAPIResponse: response) + + XCTAssertEqual(verification?.attemptsRemaining, NSNumber(value: 5)) + XCTAssertEqual(verification?.status, STPSourceVerificationStatus.pending) + + XCTAssertEqual(verification?.allResponseFields as? NSDictionary, response as? NSDictionary) + } +} diff --git a/Stripe/StripeiOSTests/STPStackViewWithSeparatorSnapshotTests.swift b/Stripe/StripeiOSTests/STPStackViewWithSeparatorSnapshotTests.swift new file mode 100644 index 00000000..da3aa600 --- /dev/null +++ b/Stripe/StripeiOSTests/STPStackViewWithSeparatorSnapshotTests.swift @@ -0,0 +1,218 @@ +// +// STPStackViewWithSeparatorSnapshotTests.swift +// StripeiOS Tests +// +// Created by Cameron Sabol on 10/23/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase +@_spi(STP) import StripeUICore + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPStackViewWithSeparatorSnapshotTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() + // recordMode = true + } + + func embedInRenderableView(_ stackView: StackViewWithSeparator) -> UIView { + let containingView = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 400)) + containingView.addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: containingView.leadingAnchor), + containingView.trailingAnchor.constraint(equalTo: stackView.trailingAnchor), + stackView.topAnchor.constraint(equalTo: containingView.topAnchor), + containingView.bottomAnchor.constraint(equalTo: stackView.bottomAnchor), + ]) + containingView.frame.size = containingView.systemLayoutSizeFitting( + UIView.layoutFittingCompressedSize + ) + return containingView + } + + func testHorizontal() { + let label1 = UILabel() + label1.text = "Label 1" + let label2 = UILabel() + label2.text = "Label 2" + let label3 = UILabel() + label3.text = "Label 3" + let stackView = StackViewWithSeparator(arrangedSubviews: [label1, label2, label3]) + stackView.axis = .horizontal + stackView.distribution = .fillEqually + stackView.spacing = 1 + stackView.separatorColor = .lightGray + + STPSnapshotVerifyView(embedInRenderableView(stackView)) + } + + func testVertical() { + let label1 = UILabel() + label1.text = "Label 1" + let label2 = UILabel() + label2.text = "Label 2" + let label3 = UILabel() + label3.text = "Label 3" + let stackView = StackViewWithSeparator(arrangedSubviews: [label1, label2, label3]) + stackView.axis = .vertical + stackView.distribution = .fillEqually + stackView.spacing = 1 + stackView.separatorColor = .lightGray + + STPSnapshotVerifyView(embedInRenderableView(stackView)) + } + + func testSingleArrangedSubviewHorizontal() { + let label1 = UILabel() + label1.text = "Label 1" + let stackView = StackViewWithSeparator(arrangedSubviews: [label1]) + stackView.axis = .horizontal + stackView.distribution = .fillEqually + stackView.spacing = 1 + stackView.separatorColor = .lightGray + + STPSnapshotVerifyView(embedInRenderableView(stackView)) + } + + func testSingleArrangedSubviewVertical() { + let label1 = UILabel() + label1.text = "Label 1" + let stackView = StackViewWithSeparator(arrangedSubviews: [label1]) + stackView.axis = .vertical + stackView.distribution = .fillEqually + stackView.spacing = 1 + stackView.separatorColor = .lightGray + + STPSnapshotVerifyView(embedInRenderableView(stackView)) + } + + func testCustomColorHorizontal() { + let label1 = UILabel() + label1.text = "Label 1" + let label2 = UILabel() + label2.text = "Label 2" + let label3 = UILabel() + label3.text = "Label 3" + let stackView = StackViewWithSeparator(arrangedSubviews: [label1, label2, label3]) + stackView.axis = .horizontal + stackView.distribution = .fillEqually + stackView.spacing = 1 + stackView.separatorColor = .red + + STPSnapshotVerifyView(embedInRenderableView(stackView)) + } + + func testCustomColorVertical() { + let label1 = UILabel() + label1.text = "Label 1" + let label2 = UILabel() + label2.text = "Label 2" + let label3 = UILabel() + label3.text = "Label 3" + let stackView = StackViewWithSeparator(arrangedSubviews: [label1, label2, label3]) + stackView.axis = .vertical + stackView.distribution = .fillEqually + stackView.spacing = 1 + stackView.separatorColor = .red + + STPSnapshotVerifyView(embedInRenderableView(stackView)) + } + + func testDisabledColor() { + let label1 = UILabel() + label1.text = "Label 1" + let label2 = UILabel() + label2.text = "Label 2" + let label3 = UILabel() + label3.text = "Label 3" + let stackView = StackViewWithSeparator(arrangedSubviews: [label1, label2, label3]) + stackView.axis = .horizontal + stackView.distribution = .fillEqually + stackView.spacing = 1 + stackView.separatorColor = .lightGray + stackView.drawBorder = true + stackView.isUserInteractionEnabled = false + + STPSnapshotVerifyView(embedInRenderableView(stackView)) + } + + func testCustomBackgroundColor() { + let label1 = UILabel() + label1.text = "Label 1" + let label2 = UILabel() + label2.text = "Label 2" + let label3 = UILabel() + label3.text = "Label 3" + let stackView = StackViewWithSeparator(arrangedSubviews: [label1, label2, label3]) + stackView.axis = .horizontal + stackView.distribution = .fillEqually + stackView.spacing = 1 + stackView.separatorColor = .lightGray + stackView.drawBorder = true + stackView.customBackgroundColor = .green + + STPSnapshotVerifyView(embedInRenderableView(stackView)) + } + + func testCustomDisabledColor() { + let label1 = UILabel() + label1.text = "Label 1" + let label2 = UILabel() + label2.text = "Label 2" + let label3 = UILabel() + label3.text = "Label 3" + let stackView = StackViewWithSeparator(arrangedSubviews: [label1, label2, label3]) + stackView.axis = .horizontal + stackView.distribution = .fillEqually + stackView.spacing = 1 + stackView.separatorColor = .lightGray + stackView.customBackgroundDisabledColor = .green + stackView.drawBorder = true + stackView.isUserInteractionEnabled = false + + STPSnapshotVerifyView(embedInRenderableView(stackView)) + } + + func testPartialSeparatorHorizontal() { + let label1 = UILabel() + label1.text = "Label 1" + let label2 = UILabel() + label2.text = "Label 2" + let label3 = UILabel() + label3.text = "Label 3" + let stackView = StackViewWithSeparator(arrangedSubviews: [label1, label2, label3]) + stackView.axis = .horizontal + stackView.distribution = .fillEqually + stackView.spacing = 1 + stackView.separatorColor = .lightGray + stackView.separatorStyle = .partial + + STPSnapshotVerifyView(embedInRenderableView(stackView)) + } + + func testPartialSeparatorVertical() { + let label1 = UILabel() + label1.text = "Label 1" + let label2 = UILabel() + label2.text = "Label 2" + let label3 = UILabel() + label3.text = "Label 3" + let stackView = StackViewWithSeparator(arrangedSubviews: [label1, label2, label3]) + stackView.axis = .vertical + stackView.distribution = .fillEqually + stackView.spacing = 1 + stackView.separatorColor = .lightGray + stackView.separatorStyle = .partial + + STPSnapshotVerifyView(embedInRenderableView(stackView)) + } + +} diff --git a/Stripe/StripeiOSTests/STPStringUtilsTest.swift b/Stripe/StripeiOSTests/STPStringUtilsTest.swift new file mode 100644 index 00000000..82846334 --- /dev/null +++ b/Stripe/StripeiOSTests/STPStringUtilsTest.swift @@ -0,0 +1,110 @@ +// +// STPStringUtilsTest.swift +// StripeiOS Tests +// +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPStringUtilsTest: XCTestCase { + func testParseRangeSingleTagSuccess1() { + let exp = self.expectation(description: "Parsed") + STPStringUtils.parseRange( + from: "Test string", + withTag: "b" + ) { string, range in + XCTAssertTrue(NSEqualRanges(range, NSRange(location: 5, length: 6))) + XCTAssertEqual(string, "Test string") + exp.fulfill() + } + waitForExpectations(timeout: 1) + } + + func testParseRangeSingleTagSuccess2() { + let exp = self.expectation(description: "Parsed") + STPStringUtils.parseRange( + from: "Test string", + withTag: "b" + ) { string, range in + XCTAssertTrue(NSEqualRanges(range, NSRange(location: 8, length: 10))) + XCTAssertEqual(string, "Test string") + exp.fulfill() + } + waitForExpectations(timeout: 1) + } + + func testParseRangeSingleTagFailure1() { + let exp = self.expectation(description: "Parsed") + STPStringUtils.parseRange( + from: "Test string", + withTag: "a" + ) { string, range in + XCTAssertEqual(range.location, NSNotFound) + XCTAssertEqual(string, "Test string") + exp.fulfill() + } + waitForExpectations(timeout: 1) + } + + func testParseRangeSingleTagFailure2() { + let exp = self.expectation(description: "Parsed") + STPStringUtils.parseRange( + from: "Test string", + withTag: "b" + ) { string, range in + XCTAssertEqual(range.location, NSNotFound) + XCTAssertEqual(string, "Test string") + exp.fulfill() + } + waitForExpectations(timeout: 1) + } + + func testParseRangeMultiTag1() { + let exp = self.expectation(description: "Parsed") + STPStringUtils.parseRanges( + from: "Test string", + withTags: Set(["a", "b", "c"]) + ) { string, tagMap in + XCTAssertTrue(NSEqualRanges(tagMap["a"]!.rangeValue, NSRange(location: 0, length: 4))) + XCTAssertTrue(NSEqualRanges(tagMap["b"]!.rangeValue, NSRange(location: 5, length: 6))) + XCTAssertEqual(tagMap["c"]!.rangeValue.location, NSNotFound) + XCTAssertEqual(string, "Test string") + exp.fulfill() + } + waitForExpectations(timeout: 1) + } + + func testParseRangeMultiTag2() { + let exp = self.expectation(description: "Parsed") + STPStringUtils.parseRanges(from: "Test string", withTags: Set(["a", "b", "c"])) { + string, + tagMap in + XCTAssertEqual(tagMap["a"]!.rangeValue.location, NSNotFound) + XCTAssertEqual(tagMap["b"]!.rangeValue.location, NSNotFound) + XCTAssertEqual(tagMap["c"]!.rangeValue.location, NSNotFound) + XCTAssertEqual(string, "Test string") + exp.fulfill() + } + waitForExpectations(timeout: 1) + } + + func testExpirationDateStrings() { + XCTAssertEqual(STPStringUtils.expirationDateString(from: "12/1995"), "12/95") + XCTAssertEqual(STPStringUtils.expirationDateString(from: "12 / 1995"), "12 / 95") + XCTAssertEqual(STPStringUtils.expirationDateString(from: "12 /1995"), "12 /95") + XCTAssertEqual(STPStringUtils.expirationDateString(from: "1295"), "1295") + XCTAssertEqual(STPStringUtils.expirationDateString(from: "12/95"), "12/95") + XCTAssertEqual(STPStringUtils.expirationDateString(from: "08/2001"), "08/01") + XCTAssertEqual(STPStringUtils.expirationDateString(from: " 08/a 2001"), " 08/a 2001") + XCTAssertEqual(STPStringUtils.expirationDateString(from: "20/2022"), "20/22") + XCTAssertEqual(STPStringUtils.expirationDateString(from: "20/202222"), "20/22") + XCTAssertEqual(STPStringUtils.expirationDateString(from: ""), "") + XCTAssertEqual(STPStringUtils.expirationDateString(from: " "), " ") + XCTAssertEqual(STPStringUtils.expirationDateString(from: "12/"), "12/") + } +} diff --git a/Stripe/StripeiOSTests/STPSwiftFixtures.swift b/Stripe/StripeiOSTests/STPSwiftFixtures.swift new file mode 100644 index 00000000..f522531e --- /dev/null +++ b/Stripe/StripeiOSTests/STPSwiftFixtures.swift @@ -0,0 +1,107 @@ +// +// STPSwiftFixtures.swift +// StripeiOS Tests +// +// Created by David Estes on 10/2/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import Foundation + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +@_exported @testable import StripePaymentsObjcTestUtils + +extension STPFixtures { + /// A customer-scoped ephemeral key that expires in 100 seconds. + class func ephemeralKey() -> STPEphemeralKey { + var response = STPTestUtils.jsonNamed("EphemeralKey") + let interval: TimeInterval = 100 + response!["expires"] = NSNumber(value: Date(timeIntervalSinceNow: interval).timeIntervalSince1970) + return .decodedObject(fromAPIResponse: response)! + } + + /// A customer-scoped ephemeral key that expires in 10 seconds. + class func expiringEphemeralKey() -> STPEphemeralKey { + var response = STPTestUtils.jsonNamed("EphemeralKey") + let interval: TimeInterval = 10 + response!["expires"] = NSNumber(value: Date(timeIntervalSinceNow: interval).timeIntervalSince1970) + return .decodedObject(fromAPIResponse: response)! + } +} + +class MockEphemeralKeyProvider: NSObject, STPCustomerEphemeralKeyProvider { + func createCustomerKey( + withAPIVersion apiVersion: String, + completion: @escaping STPJSONResponseCompletionBlock + ) { + completion(STPFixtures.ephemeralKey().allResponseFields, nil) + } +} + +@objcMembers +@objc class Testing_StaticCustomerContext_Objc: Testing_StaticCustomerContext { + +} + +@objcMembers +class Testing_StaticCustomerContext: STPCustomerContext { + var customer: STPCustomer + var paymentMethods: [STPPaymentMethod] + convenience init() { + let customer = STPFixtures.customerWithSingleCardTokenSource() + let paymentMethods = [STPFixtures.paymentMethod()].compactMap { $0 } + self.init( + customer: customer, + paymentMethods: paymentMethods + ) + } + init( + customer: STPCustomer, + paymentMethods: [STPPaymentMethod] + ) { + self.customer = customer + self.paymentMethods = paymentMethods + super.init( + keyManager: STPEphemeralKeyManager( + keyProvider: MockEphemeralKeyProvider(), + apiVersion: "1", + performsEagerFetching: false + ), + apiClient: STPAPIClient.shared + ) + } + + override func retrieveCustomer(_ completion: STPCustomerCompletionBlock?) { + if let completion = completion { + completion(customer, nil) + } + } + + override func listPaymentMethodsForCustomer(completion: STPPaymentMethodsCompletionBlock?) { + if let completion = completion { + completion(paymentMethods, nil) + } + } + + var didAttach = false + override func attachPaymentMethod( + toCustomer paymentMethod: STPPaymentMethod, + completion: STPErrorBlock? + ) { + didAttach = true + if let completion = completion { + completion(nil) + } + } + + override func retrieveLastSelectedPaymentMethodIDForCustomer( + completion: @escaping (String?, Error?) -> Void + ) { + completion(nil, nil) + } +} diff --git a/Stripe/StripeiOSTests/STPTextFieldDelegateProxyTests.swift b/Stripe/StripeiOSTests/STPTextFieldDelegateProxyTests.swift new file mode 100644 index 00000000..31c53a7c --- /dev/null +++ b/Stripe/StripeiOSTests/STPTextFieldDelegateProxyTests.swift @@ -0,0 +1,37 @@ +// +// STPTextFieldDelegateProxyTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 11/29/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPTextFieldDelegateProxyTests: XCTestCase { + + func testProxyShouldDeleteLeadingWhitespace() { + let textField = STPFormTextField() + textField.autoFormattingBehavior = .cardNumbers + textField.text = " " // space + + let sut = STPTextFieldDelegateProxy() + + let result = sut.textField( + textField, + shouldChangeCharactersIn: NSRange(location: 0, length: 1), + replacementString: "" + ) + + // Proxy should handle the deletion and return `false`. + XCTAssertFalse(result) + XCTAssertEqual(textField.text, "") + } + +} diff --git a/Stripe/StripeiOSTests/STPThreeDSButtonCustomizationTest.swift b/Stripe/StripeiOSTests/STPThreeDSButtonCustomizationTest.swift new file mode 100644 index 00000000..85a2e8fa --- /dev/null +++ b/Stripe/StripeiOSTests/STPThreeDSButtonCustomizationTest.swift @@ -0,0 +1,40 @@ +// +// STPThreeDSButtonCustomizationTest.swift +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 6/17/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPThreeDSButtonCustomizationTest: XCTestCase { + func testPropertiesAreForwarded() { + let customization = STPThreeDSButtonCustomization.defaultSettings(for: .next) + customization.backgroundColor = UIColor.red + customization.cornerRadius = -1 + customization.titleStyle = .lowercase + customization.font = UIFont.italicSystemFont(ofSize: 1) + customization.textColor = UIColor.blue + + let stdsCustomization = customization.buttonCustomization + XCTAssertEqual(UIColor.red, stdsCustomization.backgroundColor) + XCTAssertEqual(stdsCustomization.backgroundColor, customization.backgroundColor) + + XCTAssertEqual(-1, stdsCustomization.cornerRadius, accuracy: 0.1) + XCTAssertEqual(stdsCustomization.cornerRadius, customization.cornerRadius, accuracy: 0.1) + + XCTAssertEqual(.lowercase, stdsCustomization.titleStyle) + XCTAssertEqual(stdsCustomization.titleStyle.rawValue, customization.titleStyle.rawValue) + + XCTAssertEqual(UIFont.italicSystemFont(ofSize: 1), stdsCustomization.font) + XCTAssertEqual(stdsCustomization.font, customization.font) + + XCTAssertEqual(UIColor.blue, stdsCustomization.textColor) + XCTAssertEqual(stdsCustomization.textColor, customization.textColor) + } +} diff --git a/Stripe/StripeiOSTests/STPThreeDSFooterCustomizationTest.swift b/Stripe/StripeiOSTests/STPThreeDSFooterCustomizationTest.swift new file mode 100644 index 00000000..81447030 --- /dev/null +++ b/Stripe/StripeiOSTests/STPThreeDSFooterCustomizationTest.swift @@ -0,0 +1,45 @@ +// +// STPThreeDSFooterCustomizationTest.swift +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 6/17/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPThreeDSFooterCustomizationTest: XCTestCase { + func testPropertiesAreForwarded() { + let customization = STPThreeDSFooterCustomization.defaultSettings() + customization.backgroundColor = UIColor.red + customization.chevronColor = UIColor.blue + customization.headingTextColor = UIColor.green + customization.headingFont = UIFont.systemFont(ofSize: 1) + customization.font = UIFont.systemFont(ofSize: 2) + customization.textColor = UIColor.magenta + + let stdsCustomization = customization.footerCustomization + + XCTAssertEqual(UIColor.red, stdsCustomization.backgroundColor) + XCTAssertEqual(stdsCustomization.backgroundColor, customization.backgroundColor) + + XCTAssertEqual(UIColor.blue, stdsCustomization.chevronColor) + XCTAssertEqual(stdsCustomization.chevronColor, customization.chevronColor) + + XCTAssertEqual(UIColor.green, stdsCustomization.headingTextColor) + XCTAssertEqual(stdsCustomization.headingTextColor, customization.headingTextColor) + + XCTAssertEqual(UIFont.systemFont(ofSize: 1), stdsCustomization.headingFont) + XCTAssertEqual(stdsCustomization.headingFont, customization.headingFont) + + XCTAssertEqual(UIFont.systemFont(ofSize: 2), stdsCustomization.font) + XCTAssertEqual(stdsCustomization.font, customization.font) + + XCTAssertEqual(UIColor.magenta, stdsCustomization.textColor) + XCTAssertEqual(stdsCustomization.textColor, customization.textColor) + } +} diff --git a/Stripe/StripeiOSTests/STPThreeDSLabelCustomizationTest.swift b/Stripe/StripeiOSTests/STPThreeDSLabelCustomizationTest.swift new file mode 100644 index 00000000..bd546dfc --- /dev/null +++ b/Stripe/StripeiOSTests/STPThreeDSLabelCustomizationTest.swift @@ -0,0 +1,37 @@ +// +// STPThreeDSLabelCustomizationTest.swift +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 6/17/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPThreeDSLabelCustomizationTest: XCTestCase { + func testPropertiesAreForwarded() { + let customization = STPThreeDSLabelCustomization.defaultSettings() + customization.headingFont = UIFont.systemFont(ofSize: 1) + customization.headingTextColor = UIColor.red + customization.font = UIFont.systemFont(ofSize: 2) + customization.textColor = UIColor.blue + + let stdsCustomization = customization.labelCustomization + + XCTAssertEqual(UIFont.systemFont(ofSize: 1), stdsCustomization.headingFont) + XCTAssertEqual(stdsCustomization.headingFont, customization.headingFont) + + XCTAssertEqual(UIColor.red, stdsCustomization.headingTextColor) + XCTAssertEqual(stdsCustomization.headingTextColor, customization.headingTextColor) + + XCTAssertEqual(UIFont.systemFont(ofSize: 2), stdsCustomization.font) + XCTAssertEqual(stdsCustomization.font, customization.font) + + XCTAssertEqual(UIColor.blue, stdsCustomization.textColor) + XCTAssertEqual(stdsCustomization.textColor, customization.textColor) + } +} diff --git a/Stripe/StripeiOSTests/STPThreeDSNavigationBarCustomizationTest.swift b/Stripe/StripeiOSTests/STPThreeDSNavigationBarCustomizationTest.swift new file mode 100644 index 00000000..a2dcdf5b --- /dev/null +++ b/Stripe/StripeiOSTests/STPThreeDSNavigationBarCustomizationTest.swift @@ -0,0 +1,48 @@ +// +// STPThreeDSNavigationBarCustomizationTest.swift +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 6/17/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPThreeDSNavigationBarCustomizationTest: XCTestCase { + func testPropertiesAreForwarded() { + let customization = STPThreeDSNavigationBarCustomization.defaultSettings() + customization.font = UIFont.italicSystemFont(ofSize: 1) + customization.textColor = UIColor.blue + customization.barTintColor = UIColor.red + customization.barStyle = UIBarStyle.blackOpaque + customization.translucent = false + customization.headerText = "foo" + customization.buttonText = "bar" + + let stdsCustomization = customization.navigationBarCustomization + XCTAssertEqual(UIFont.italicSystemFont(ofSize: 1), stdsCustomization.font) + XCTAssertEqual(stdsCustomization.font, customization.font) + + XCTAssertEqual(UIColor.blue, stdsCustomization.textColor) + XCTAssertEqual(stdsCustomization.textColor, customization.textColor) + + XCTAssertEqual(UIColor.red, stdsCustomization.barTintColor) + XCTAssertEqual(stdsCustomization.barTintColor, customization.barTintColor) + + XCTAssertEqual(UIBarStyle.blackOpaque, stdsCustomization.barStyle) + XCTAssertEqual(stdsCustomization.barStyle, customization.barStyle) + + XCTAssertEqual(false, stdsCustomization.translucent) + XCTAssertEqual(stdsCustomization.translucent, customization.translucent) + + XCTAssertEqual("foo", stdsCustomization.headerText) + XCTAssertEqual(stdsCustomization.headerText, customization.headerText) + + XCTAssertEqual("bar", stdsCustomization.buttonText) + XCTAssertEqual(stdsCustomization.buttonText, customization.buttonText) + } +} diff --git a/Stripe/StripeiOSTests/STPThreeDSSelectionCustomizationTest.swift b/Stripe/StripeiOSTests/STPThreeDSSelectionCustomizationTest.swift new file mode 100644 index 00000000..344f04e1 --- /dev/null +++ b/Stripe/StripeiOSTests/STPThreeDSSelectionCustomizationTest.swift @@ -0,0 +1,42 @@ +// +// STPThreeDSSelectionCustomizationTest.swift +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 6/18/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPThreeDSSelectionCustomizationTest: XCTestCase { + func testPropertiesAreForwarded() { + let customization = STPThreeDSSelectionCustomization.defaultSettings() + customization.primarySelectedColor = UIColor.red + customization.secondarySelectedColor = UIColor.blue + customization.unselectedBorderColor = UIColor.brown + customization.unselectedBackgroundColor = UIColor.cyan + + let stdsCustomization = customization.selectionCustomization + XCTAssertEqual(UIColor.red, stdsCustomization.primarySelectedColor) + XCTAssertEqual(stdsCustomization.primarySelectedColor, customization.primarySelectedColor) + + XCTAssertEqual(UIColor.blue, stdsCustomization.secondarySelectedColor) + XCTAssertEqual( + stdsCustomization.secondarySelectedColor, + customization.secondarySelectedColor + ) + + XCTAssertEqual(UIColor.brown, stdsCustomization.unselectedBorderColor) + XCTAssertEqual(stdsCustomization.unselectedBorderColor, customization.unselectedBorderColor) + + XCTAssertEqual(UIColor.cyan, stdsCustomization.unselectedBackgroundColor) + XCTAssertEqual( + stdsCustomization.unselectedBackgroundColor, + customization.unselectedBackgroundColor + ) + } +} diff --git a/Stripe/StripeiOSTests/STPThreeDSTextFieldCustomizationTest.swift b/Stripe/StripeiOSTests/STPThreeDSTextFieldCustomizationTest.swift new file mode 100644 index 00000000..2bc87ac9 --- /dev/null +++ b/Stripe/StripeiOSTests/STPThreeDSTextFieldCustomizationTest.swift @@ -0,0 +1,48 @@ +// +// STPThreeDSTextFieldCustomizationTest.swift +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 6/18/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPThreeDSTextFieldCustomizationTest: XCTestCase { + func testPropertiesAreForwarded() { + let customization = STPThreeDSTextFieldCustomization.defaultSettings() + customization.font = UIFont.italicSystemFont(ofSize: 1) + customization.textColor = UIColor.blue + customization.borderWidth = -1 + customization.borderColor = UIColor.red + customization.cornerRadius = -8 + customization.keyboardAppearance = .alert + customization.placeholderTextColor = UIColor.green + + let stdsCustomization = customization.textFieldCustomization + XCTAssertEqual(UIFont.italicSystemFont(ofSize: 1), stdsCustomization.font) + XCTAssertEqual(stdsCustomization.font, customization.font) + + XCTAssertEqual(UIColor.blue, stdsCustomization.textColor) + XCTAssertEqual(stdsCustomization.textColor, customization.textColor) + + XCTAssertEqual(-1, stdsCustomization.borderWidth, accuracy: 0.1) + XCTAssertEqual(stdsCustomization.borderWidth, customization.borderWidth, accuracy: 0.1) + + XCTAssertEqual(UIColor.red, stdsCustomization.borderColor) + XCTAssertEqual(stdsCustomization.borderColor, customization.borderColor) + + XCTAssertEqual(-8, stdsCustomization.cornerRadius, accuracy: 0.1) + XCTAssertEqual(stdsCustomization.cornerRadius, customization.cornerRadius, accuracy: 0.1) + + XCTAssertEqual(UIKeyboardAppearance.alert, stdsCustomization.keyboardAppearance) + XCTAssertEqual(stdsCustomization.keyboardAppearance, customization.keyboardAppearance) + + XCTAssertEqual(UIColor.green, stdsCustomization.placeholderTextColor) + XCTAssertEqual(stdsCustomization.placeholderTextColor, customization.placeholderTextColor) + } +} diff --git a/Stripe/StripeiOSTests/STPThreeDSUICustomizationTest.swift b/Stripe/StripeiOSTests/STPThreeDSUICustomizationTest.swift new file mode 100644 index 00000000..392c8422 --- /dev/null +++ b/Stripe/StripeiOSTests/STPThreeDSUICustomizationTest.swift @@ -0,0 +1,137 @@ +// +// STPThreeDSUICustomizationTest.swift +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 6/17/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class STPThreeDSUICustomizationTest: XCTestCase { + func testPropertiesPassedThrough() { + let customization = STPThreeDSUICustomization.defaultSettings() + + // Maintains button customization objects + customization.buttonCustomization(for: .next).backgroundColor = UIColor.cyan + customization.buttonCustomization(for: .resend).backgroundColor = UIColor.cyan + customization.buttonCustomization(for: .submit).backgroundColor = UIColor.cyan + customization.buttonCustomization(for: .continue).backgroundColor = UIColor.cyan + customization.buttonCustomization(for: .cancel).backgroundColor = UIColor.cyan + XCTAssertEqual( + customization.uiCustomization.buttonCustomization(for: .next).backgroundColor, + UIColor.cyan + ) + XCTAssertEqual( + customization.uiCustomization.buttonCustomization(for: .resend).backgroundColor, + UIColor.cyan + ) + XCTAssertEqual( + customization.uiCustomization.buttonCustomization(for: .submit).backgroundColor, + UIColor.cyan + ) + XCTAssertEqual( + customization.uiCustomization.buttonCustomization(for: .continue).backgroundColor, + UIColor.cyan + ) + XCTAssertEqual( + customization.uiCustomization.buttonCustomization(for: .cancel).backgroundColor, + UIColor.cyan + ) + + let buttonCustomization = STPThreeDSButtonCustomization.defaultSettings(for: .next) + customization.setButtonCustomization(buttonCustomization, for: .next) + XCTAssertEqual( + customization.uiCustomization.buttonCustomization(for: .next), + buttonCustomization.buttonCustomization + ) + + // Footer + customization.footerCustomization.backgroundColor = UIColor.cyan + XCTAssertEqual( + customization.uiCustomization.footerCustomization.backgroundColor, + UIColor.cyan + ) + + let footerCustomization = STPThreeDSFooterCustomization.defaultSettings() + customization.footerCustomization = footerCustomization + XCTAssertEqual( + customization.uiCustomization.footerCustomization, + footerCustomization.footerCustomization + ) + + // Label + customization.labelCustomization.textColor = UIColor.cyan + XCTAssertEqual(customization.uiCustomization.labelCustomization.textColor, UIColor.cyan) + + let labelCustomization = STPThreeDSLabelCustomization.defaultSettings() + customization.labelCustomization = labelCustomization + XCTAssertEqual( + customization.uiCustomization.labelCustomization, + labelCustomization.labelCustomization + ) + + // Navigation Bar + customization.navigationBarCustomization.textColor = UIColor.cyan + XCTAssertEqual( + customization.uiCustomization.navigationBarCustomization.textColor, + UIColor.cyan + ) + + let navigationBar = STPThreeDSNavigationBarCustomization.defaultSettings() + customization.navigationBarCustomization = navigationBar + XCTAssertEqual( + customization.uiCustomization.navigationBarCustomization, + navigationBar.navigationBarCustomization + ) + + // Selection + customization.selectionCustomization.primarySelectedColor = UIColor.cyan + XCTAssertEqual( + customization.uiCustomization.selectionCustomization.primarySelectedColor, + UIColor.cyan + ) + + let selection = STPThreeDSSelectionCustomization.defaultSettings() + customization.selectionCustomization = selection + XCTAssertEqual( + customization.uiCustomization.selectionCustomization, + selection.selectionCustomization + ) + + // Text Field + customization.textFieldCustomization.textColor = UIColor.cyan + XCTAssertEqual(customization.uiCustomization.textFieldCustomization.textColor, UIColor.cyan) + + let textField = STPThreeDSTextFieldCustomization.defaultSettings() + customization.textFieldCustomization = textField + XCTAssertEqual( + customization.uiCustomization.textFieldCustomization, + textField.textFieldCustomization + ) + + // Other + customization.backgroundColor = UIColor.red + customization.activityIndicatorViewStyle = UIActivityIndicatorView.Style.whiteLarge + customization.blurStyle = UIBlurEffect.Style.dark + + XCTAssertEqual(UIColor.red, customization.backgroundColor) + XCTAssertEqual(customization.backgroundColor, customization.uiCustomization.backgroundColor) + + XCTAssertEqual( + UIActivityIndicatorView.Style.whiteLarge, + customization.activityIndicatorViewStyle + ) + XCTAssertEqual( + customization.activityIndicatorViewStyle, + customization.uiCustomization.activityIndicatorViewStyle + ) + + XCTAssertEqual(UIBlurEffect.Style.dark, customization.blurStyle) + XCTAssertEqual(customization.blurStyle, customization.uiCustomization.blurStyle) + } +} diff --git a/Stripe/StripeiOSTests/STPTokenTest.swift b/Stripe/StripeiOSTests/STPTokenTest.swift new file mode 100644 index 00000000..e4c03570 --- /dev/null +++ b/Stripe/StripeiOSTests/STPTokenTest.swift @@ -0,0 +1,67 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPTokenTest.m +// Stripe +// +// Created by Saikat Chakrabarti on 11/9/12. +// +// + +import Stripe +import XCTest + +class STPTokenTest: XCTestCase { + func buildTokenResponse() -> [AnyHashable: Any]? { + let cardDict = [ + "id": "card_123", + "exp_month": "12", + "exp_year": "2013", + "name": "Smerlock Smolmes", + "address_line1": "221A Baker Street", + "address_city": "New York", + "address_state": "NY", + "address_zip": "12345", + "address_country": "US", + "last4": "1234", + "brand": "Visa", + "fingerprint": "Fingolfin", + "country": "JP", + ] + + let tokenDict = [ + "id": "id_for_token", + "object": "token", + "livemode": NSNumber(value: false), + "created": NSNumber(value: 1353025450.0), + "used": NSNumber(value: false), + "card": cardDict, + "type": "card", + ] as [String: Any] + return tokenDict + } + + func testCreatingTokenWithAttributeDictionarySetsAttributes() { + guard + let token = STPToken.decodedObject(fromAPIResponse: buildTokenResponse()), + let timeInterval = token.created?.timeIntervalSince1970 + else { + XCTFail() + return + } + XCTAssertEqual(token.tokenId, "id_for_token") + XCTAssertEqual(token.livemode, false, "Generated token has the correct livemode") + XCTAssertEqual(token.type, STPTokenType.card, "Generated token has incorrect type") + + XCTAssertEqual(timeInterval, 1353025450.0, accuracy: 1.0, "Generated token has the correct created time") + } + + func testCreatingTokenSetsAdditionalResponseFields() { + var tokenResponse = buildTokenResponse() + tokenResponse?["foo"] = "bar" + let token = STPToken.decodedObject(fromAPIResponse: tokenResponse) + let allResponseFields = token?.allResponseFields + XCTAssertEqual(allResponseFields?["foo"] as? String, "bar") + XCTAssertEqual(allResponseFields?["livemode"] as? NSNumber, NSNumber(value: false)) + XCTAssertNil(allResponseFields?["baz"]) + } +} diff --git a/Stripe/StripeiOSTests/STPUIVCStripeParentViewControllerTests.swift b/Stripe/StripeiOSTests/STPUIVCStripeParentViewControllerTests.swift new file mode 100644 index 00000000..63f00064 --- /dev/null +++ b/Stripe/StripeiOSTests/STPUIVCStripeParentViewControllerTests.swift @@ -0,0 +1,39 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPUIVCStripeParentViewControllerTests.m +// Stripe +// +// Created by Jack Flintermann on 1/19/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +@testable import Stripe +import UIKit +import XCTest + +class TestViewController: UIViewController { +} + +class STPUIVCStripeParentViewControllerTests: XCTestCase { + func testNilParent() { + let vc = UIViewController() + XCTAssertNil(vc.stp_parentViewControllerOf(UIViewController.self)) + } + + func testNavigationController() { + let vc = UIViewController() + let nav = UINavigationController(rootViewController: vc) + let parent = vc.stp_parentViewControllerOf(UINavigationController.self) as? UINavigationController + XCTAssertEqual(nav, parent) + } + + func testDeepHeirarchy() { + let topLevel = TestViewController() + let vc = UIViewController() + let nav = UINavigationController(rootViewController: vc) + topLevel.addChild(nav) + nav.didMove(toParent: topLevel) + let parent = vc.stp_parentViewControllerOf(TestViewController.self) as? TestViewController + XCTAssertEqual(topLevel, parent) + } +} diff --git a/Stripe/StripeiOSTests/STPViewWithSeparatorSnapshotTests.swift b/Stripe/StripeiOSTests/STPViewWithSeparatorSnapshotTests.swift new file mode 100644 index 00000000..a4bc6568 --- /dev/null +++ b/Stripe/StripeiOSTests/STPViewWithSeparatorSnapshotTests.swift @@ -0,0 +1,31 @@ +// Converted to Swift 5.8.1 by Swiftify v5.8.28463 - https://swiftify.com/ +// +// STPViewWithSeparatorSnapshotTests.m +// StripeiOS Tests +// +// Created by Cameron Sabol on 3/13/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCaseCore +@testable import StripePaymentsUI + +class STPViewWithSeparatorSnapshotTests: FBSnapshotTestCase { + override func setUp() { + super.setUp() +// self.recordMode = true + } + + func testDefaultAppearance() { + let view = STPViewWithSeparator(frame: CGRect(x: 0.0, y: 0.0, width: 320.0, height: 44.0)) + view.backgroundColor = UIColor.white + STPSnapshotVerifyView(view, identifier: "STPViewWithSeparator.defaultAppearance") + } + + func testHiddenTopSeparator() { + let view = STPViewWithSeparator(frame: CGRect(x: 0.0, y: 0.0, width: 320.0, height: 44.0)) + view.backgroundColor = UIColor.white + view.topSeparatorHidden = true + STPSnapshotVerifyView(view, identifier: "STPViewWithSeparator.hiddenTopSeparator") + } +} diff --git a/Stripe/StripeiOSTests/ServerErrorMapperTest.swift b/Stripe/StripeiOSTests/ServerErrorMapperTest.swift new file mode 100644 index 00000000..8087ec8b --- /dev/null +++ b/Stripe/StripeiOSTests/ServerErrorMapperTest.swift @@ -0,0 +1,94 @@ +// +// ServerErrorMapperTest.swift +// StripeiOS Tests +// +// Created by Nick Porter on 9/13/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class ServerErrorMapperTest: XCTestCase { + + func testFromMissingPublishableKey() { + let serverErrorMessage = + "You did not provide an API key. You need to provide your API key in the Authorization header, using Bearer auth (e.g. \'Authorization: Bearer YOUR_SECRET_KEY\'). See https://stripe.com/docs/api#authentication for details, or we can help at https://support.stripe.com/." + + XCTAssertTrue( + ServerErrorMapper.mobileErrorMessage( + from: serverErrorMessage, + httpResponse: HTTPURLResponse() + )!.hasPrefix("No valid API key provided. Set `STPAPIClient.shared") + ) + } + + func testFromInvalidPublishableKey() { + let serverErrorMessage = "Invalid API Key provided: pk_test" + + XCTAssertTrue( + ServerErrorMapper.mobileErrorMessage( + from: serverErrorMessage, + httpResponse: HTTPURLResponse() + )!.hasPrefix("No valid API key provided. Set `STPAPIClient.shared") + ) + } + + func testFromInvalidCustEphKey() { + let serverErrorMessage = "Invalid API Key provided: badCustEphKey" + + let url = URL(string: "https://api.stripe.com/v1/payment_methods?customer=")! + let httpResponse = HTTPURLResponse( + url: url, + statusCode: 401, + httpVersion: nil, + headerFields: nil + ) + XCTAssertTrue( + ServerErrorMapper.mobileErrorMessage( + from: serverErrorMessage, + httpResponse: httpResponse + )!.hasPrefix("Invalid customer ephemeral key secret.") + ) + } + + func testFromNoSuchPaymentIntent() { + let serverErrorMessage = "No such payment_intent: pi_123" + + XCTAssertTrue( + ServerErrorMapper.mobileErrorMessage( + from: serverErrorMessage, + httpResponse: HTTPURLResponse() + )!.hasPrefix("No matching PaymentIntent could") + ) + } + + func testFromNoSuchSetupIntent() { + let serverErrorMessage = "No such setup_intent: si_123" + + XCTAssertTrue( + ServerErrorMapper.mobileErrorMessage( + from: serverErrorMessage, + httpResponse: HTTPURLResponse() + )!.hasPrefix("No matching SetupIntent could") + ) + } + + func testFromUnknownErrorMessage() { + let serverErrorMessage = + "This error message is not known to ServerErrorMapper, should return nil." + + XCTAssertNil( + ServerErrorMapper.mobileErrorMessage( + from: serverErrorMessage, + httpResponse: HTTPURLResponse() + ) + ) + } + +} diff --git a/Stripe/StripeiOSTests/StripeErrorTest.swift b/Stripe/StripeiOSTests/StripeErrorTest.swift new file mode 100644 index 00000000..c9c9750f --- /dev/null +++ b/Stripe/StripeiOSTests/StripeErrorTest.swift @@ -0,0 +1,254 @@ +// +// StripeErrorTest.swift +// StripeiOS Tests +// +// Created by Ben Guo on 4/14/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import Foundation +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI + +class StripeErrorTest: XCTestCase { + func testEmptyResponse() { + let response: [AnyHashable: Any] = [:] + let error = NSError.stp_error(fromStripeResponse: response) + XCTAssertNil(error) + } + + func testResponseWithUnknownTypeAndNoMessage() { + let response = [ + "error": [ + "type": "foo", + "code": "error_code", + ], + ] + let error = NSError.stp_error(fromStripeResponse: response)! + XCTAssertEqual(error.domain, STPError.stripeDomain) + XCTAssertEqual(error.code, STPErrorCode.apiError.rawValue) + XCTAssertEqual( + error.userInfo[NSLocalizedDescriptionKey] as! String, + NSError.stp_unexpectedErrorMessage() + ) + XCTAssertEqual( + error.userInfo[STPError.stripeErrorTypeKey] as! String, + response["error"]!["type"]! + ) + XCTAssertEqual( + error.userInfo[STPError.stripeErrorCodeKey] as! String, + response["error"]!["code"]! + ) + XCTAssertTrue( + (error.userInfo[STPError.errorMessageKey]! as! String).hasPrefix( + "Could not interpret the error response" + ) + ) + } + + func testAPIError() { + let response = [ + "error": [ + "type": "api_error", + "message": "some message", + ], + ] + let error = NSError.stp_error(fromStripeResponse: response)! + XCTAssertEqual(error.domain, STPError.stripeDomain) + XCTAssertEqual(error.code, STPErrorCode.apiError.rawValue) + XCTAssertEqual( + error.userInfo[NSLocalizedDescriptionKey] as! String?, + NSError.stp_unexpectedErrorMessage() + ) + XCTAssertEqual( + error.userInfo[STPError.errorMessageKey] as! String?, + response["error"]!["message"] + ) + XCTAssertEqual( + error.userInfo[STPError.stripeErrorTypeKey] as! String?, + response["error"]!["type"] + ) + } + + func testInvalidRequestErrorMissingParameter() { + let response = [ + "error": [ + "type": "invalid_request_error", + "message": "The payment method `card` requires the parameter: card[exp_year].", + "param": "card[exp_year]", + ], + ] + let error = NSError.stp_error(fromStripeResponse: response)! + XCTAssertEqual(error.domain, STPError.stripeDomain) + XCTAssertEqual(error.code, STPErrorCode.invalidRequestError.rawValue) + XCTAssertEqual( + error.userInfo[NSLocalizedDescriptionKey] as? String, + NSError.stp_unexpectedErrorMessage() + ) + XCTAssertEqual( + error.userInfo[STPError.errorMessageKey] as? String, + response["error"]!["message"] + ) + XCTAssertEqual( + error.userInfo[STPError.stripeErrorTypeKey] as? String, + response["error"]!["type"] + ) + XCTAssertEqual(error.userInfo[STPError.errorParameterKey] as! String, "card[expYear]") + } + + func testAuthenticationError() { + // Given an `invalid_request_error` response + let response = [ + "error": [ + "type": "invalid_request_error", + "message": "Invalid API Key provided: pk_test_***************************00", + ], + ] + + // with a `401` HTTP status code + let httpResponse = HTTPURLResponse( + url: URL(string: "https://api.stripe.com/v1/payment_intents")!, + statusCode: 401, + httpVersion: "1.1", + headerFields: nil + ) + + let error = NSError.stp_error(fromStripeResponse: response, httpResponse: httpResponse)! + + XCTAssertEqual(error.domain, STPError.stripeDomain) + XCTAssertEqual( + error.code, + STPErrorCode.authenticationError.rawValue, + "`error.code` should be equals to `STPErrorCode.authenticationError`" + ) + } + + func testAuthenticationErrorDueToExpiredKey() { + // Given an `invalid_request_error` response due to an expired key + let response = [ + "error": [ + "code": "api_key_expired", + "type": "invalid_request_error", + "message": "Expired API Key provided: pk_test_***************************00", + ], + ] + + // with a `401` HTTP status code + let httpResponse = HTTPURLResponse( + url: URL(string: "https://api.stripe.com/v1/payment_intents")!, + statusCode: 401, + httpVersion: "1.1", + headerFields: nil + ) + + let error = NSError.stp_error(fromStripeResponse: response, httpResponse: httpResponse)! + + XCTAssertEqual(error.domain, STPError.stripeDomain) + XCTAssertEqual( + error.code, + STPErrorCode.authenticationError.rawValue, + "`error.code` should be equals to `STPErrorCode.authenticationError`" + ) + } + + func testInvalidRequestErrorIncorrectNumber() { + let response = [ + "error": [ + "type": "invalid_request_error", + "message": "Your card number is incorrect.", + "code": "incorrect_number", + ], + ] + let error = NSError.stp_error(fromStripeResponse: response)! + XCTAssertEqual(error.domain, STPError.stripeDomain) + XCTAssertEqual(error.code, STPErrorCode.invalidRequestError.rawValue) + // Error type is not `card_error`, so `NSLocalizedDescription` will be a generic error. + XCTAssertEqual( + error.userInfo[NSLocalizedDescriptionKey] as! String, + NSError.stp_unexpectedErrorMessage() + ) + XCTAssertEqual( + error.userInfo[STPError.cardErrorCodeKey] as! String, + STPError.incorrectNumber + ) + XCTAssertEqual( + error.userInfo[STPError.stripeErrorTypeKey] as? String, + response["error"]!["type"] + ) + XCTAssertEqual( + error.userInfo[STPError.stripeErrorCodeKey] as? String, + response["error"]!["code"] + ) + XCTAssertEqual( + error.userInfo[STPError.errorMessageKey] as? String, + response["error"]!["message"] + ) + } + + func testCardErrorIncorrectNumber() { + let response = [ + "error": [ + "type": "card_error", + "message": "Your card number is incorrect.", + "code": "incorrect_number", + ], + ] + let error = NSError.stp_error(fromStripeResponse: response)! + XCTAssertEqual(error.domain, STPError.stripeDomain) + XCTAssertEqual(error.code, STPErrorCode.cardError.rawValue) + XCTAssertEqual( + error.userInfo[NSLocalizedDescriptionKey] as! String, + "Your card number is incorrect." + ) + XCTAssertEqual( + error.userInfo[STPError.cardErrorCodeKey] as! String, + STPError.incorrectNumber + ) + XCTAssertEqual( + error.userInfo[STPError.stripeErrorTypeKey] as? String, + response["error"]!["type"] + ) + XCTAssertEqual( + error.userInfo[STPError.stripeErrorCodeKey] as? String, + response["error"]!["code"] + ) + XCTAssertEqual( + error.userInfo[STPError.errorMessageKey] as? String, + response["error"]!["message"] + ) + } + + func testCardDeclinedError() { + let response = [ + "error": [ + "type": "card_error", + "code": "card_declined", + "decline_code": "insufficient_funds", + ], + ] + guard let error = NSError.stp_error(fromStripeResponse: response) else { + XCTFail() + return + } + XCTAssertEqual(error.domain, STPError.stripeDomain) + XCTAssertEqual(error.code, STPErrorCode.cardError.rawValue) + XCTAssertEqual( + error.userInfo[STPError.cardErrorCodeKey] as? String, + STPCardErrorCode.cardDeclined.rawValue + ) + // Response didn't include a message, so a built in message will be used. + XCTAssertEqual( + error.userInfo[NSLocalizedDescriptionKey] as? String, + NSError.stp_cardErrorDeclinedUserMessage() + ) + XCTAssertEqual( + error.userInfo[STPError.stripeDeclineCodeKey] as? String, + "insufficient_funds" + ) + } +} diff --git a/Stripe/StripeiOSTests/StripeTests-Prefix.pch b/Stripe/StripeiOSTests/StripeTests-Prefix.pch new file mode 100644 index 00000000..0540b07f --- /dev/null +++ b/Stripe/StripeiOSTests/StripeTests-Prefix.pch @@ -0,0 +1,21 @@ +// +// StripeTests-Prefix.pch +// StripeiOS Tests +// +// Created by David Estes on 10/8/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +#ifndef StripeTests_Prefix_pch +#define StripeTests_Prefix_pch + +// Include any system framework and library headers here that should be included in all compilation units. +// You will also need to set the Prefix Header build setting of one or more of your targets to reference this file. + +#import "STPBlocks.h" +@import StripeApplePay; +@import Stripe; +@import StripePayments; +@import StripePaymentsUI; + +#endif /* StripeTests_Prefix_pch */ diff --git a/Stripe/StripeiOSTests/StripeiOS Tests-Bridging-Header.h b/Stripe/StripeiOSTests/StripeiOS Tests-Bridging-Header.h new file mode 100644 index 00000000..8a95b39a --- /dev/null +++ b/Stripe/StripeiOSTests/StripeiOS Tests-Bridging-Header.h @@ -0,0 +1,7 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// + +@import StripePaymentsObjcTestUtils; + +#import "STPMocks.h" diff --git a/Stripe/StripeiOSTests/TextFieldElement+IBANTest.swift b/Stripe/StripeiOSTests/TextFieldElement+IBANTest.swift new file mode 100644 index 00000000..01b65d5c --- /dev/null +++ b/Stripe/StripeiOSTests/TextFieldElement+IBANTest.swift @@ -0,0 +1,140 @@ +// +// TextFieldElement+IBANTest.swift +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 5/23/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsUI +@testable@_spi(STP) import StripeUICore + +class TextFieldElementIBANTest: XCTestCase { + typealias IBANError = TextFieldElement.IBANError + typealias Error = TextFieldElement.Error + + func testValidation() throws { + let testcases: [String: TextFieldElement.ValidationState] = [ + "": .invalid(Error.empty), + "G": .invalid(IBANError.incomplete), + "GB": .invalid(IBANError.incomplete), + "GB1": .invalid(IBANError.incomplete), + "GB12": .invalid(IBANError.incomplete), + + "1": .invalid(IBANError.shouldStartWithCountryCode), + "12": .invalid(IBANError.shouldStartWithCountryCode), + "Z1": .invalid(IBANError.shouldStartWithCountryCode), + "🤦🏻🇺🇸": .invalid(IBANError.shouldStartWithCountryCode), + + "ZZ": .invalid(IBANError.invalidCountryCode(countryCode: "ZZ")), + + "GB82WEST12345698765432🇺🇸": .invalid(IBANError.invalidFormat), + // https://www.iban.com/testibans + "GB94BARC20201530093459": .invalid(IBANError.invalidFormat), + + "GB33BUKB20201555555555": .valid, + "GB94BARC10201530093459": .valid, + "SK6902000000001933504555": .valid, + "BG09STSA93000021741508": .valid, + "FR1420041010050500013M02606": .valid, + "AT611904300234573201": .valid, + "AT861904300235473202": .valid, + ] + + let config = TextFieldElement.IBANConfiguration(defaultValue: nil) + for (text, expected) in testcases { + let actual = config.validate(text: text, isOptional: false) + XCTAssertTrue( + actual == expected, + "Input \"\(text)\": expected \(expected) but got \(actual)" + ) + } + } + + func testValidateCountryCode() { + let testcases: [String: TextFieldElement.ValidationState] = [ + "": .invalid(IBANError.incomplete), + "A": .invalid(IBANError.incomplete), + "D": .invalid(IBANError.incomplete), + + "ū": .invalid(IBANError.shouldStartWithCountryCode), + "1": .invalid(IBANError.shouldStartWithCountryCode), + ".": .invalid(IBANError.shouldStartWithCountryCode), + + "AT": .valid, + "DE": .valid, + ] + for (test, expected) in testcases { + let actual = TextFieldElement.IBANConfiguration.validateCountryCode(test) + XCTAssertTrue(actual == expected) + } + } + + func testTransformToASCIIDigits() { + let testcases: [String: String] = [ + "": "", + "1234": "1234", + "GB82": "161182", + "AAAA": "10101010", + "ZZZZ": "35353535", + ] + for (test, expected) in testcases { + let actual = TextFieldElement.IBANConfiguration.transformToASCIIDigits(test) + XCTAssertTrue(actual == expected) + } + } + + func testMod97() { + let testcases: [String: Int?] = [ + "0": 0, + "97": 0, + "96": 96, + "00001": 1, + "13985713857180375018375081735081735": 15, + "13985713857180375018375081735081720": 0, + ] + for (test, expected) in testcases { + let actual = TextFieldElement.IBANConfiguration.mod97(test) + XCTAssertTrue(actual == expected) + } + + for _ in 0...100 { + let test = Int.random(in: 0...Int.max) + let actual = TextFieldElement.IBANConfiguration.mod97(String(test)) + XCTAssertTrue(actual == test % 97) + } + } +} + +// MARK: - Helpers + +// TODO(mludowise): These should get migrated to a shared StripeUICoreTestUtils target + +extension TextFieldElement.ValidationState: Equatable { + public static func == ( + lhs: TextFieldElement.ValidationState, + rhs: TextFieldElement.ValidationState + ) -> Bool { + switch (lhs, rhs) { + case (.valid, .valid): + return true + case (.invalid(let lhsError), .invalid(let rhsError)): + return lhsError == rhsError + default: + return false + } + } +} + +func == (lhs: TextFieldValidationError, rhs: TextFieldValidationError) -> Bool { + guard String(describing: lhs) == String(describing: rhs) else { + return false + } + return (lhs as NSError).isEqual(rhs as NSError) +} diff --git a/Stripe/StripeiOSTests/UINavigationBar+StripeTest.m b/Stripe/StripeiOSTests/UINavigationBar+StripeTest.m new file mode 100644 index 00000000..bc5a2bd8 --- /dev/null +++ b/Stripe/StripeiOSTests/UINavigationBar+StripeTest.m @@ -0,0 +1,52 @@ +// +// UINavigationBar+StripeTest.m +// Stripe +// +// Created by Brian Dorfman on 12/9/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +#import +#import +@import Stripe; +#import "STPMocks.h" +@import StripePaymentsObjcTestUtils; + +@interface UINavigationBar_StripeTest : XCTestCase + +@end + +@implementation UINavigationBar_StripeTest + +- (STPPaymentOptionsViewController *)buildPaymentOptionsViewController { + id customerContext = [STPMocks staticCustomerContext]; + STPPaymentConfiguration *config = [STPPaymentConfiguration new]; + STPTheme *theme = [STPTheme defaultTheme]; + id delegate = OCMProtocolMock(@protocol(STPPaymentOptionsViewControllerDelegate)); + STPPaymentOptionsViewController *paymentOptionsVC = [[STPPaymentOptionsViewController alloc] initWithConfiguration:config + theme:theme + customerContext:customerContext + delegate:delegate]; + return paymentOptionsVC; +} + +- (void)testVCUsesNavigationBarColor { + STPPaymentOptionsViewController *paymentOptionsVC = [self buildPaymentOptionsViewController]; + STPTheme *navTheme = [STPTheme new]; + navTheme.accentColor = [UIColor purpleColor]; + + UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:paymentOptionsVC]; + navController.navigationBar.stp_theme = navTheme; + __unused UIView *view = paymentOptionsVC.view; + XCTAssertEqualObjects(paymentOptionsVC.navigationItem.leftBarButtonItem.tintColor, [UIColor purpleColor]); +} + +- (void)testVCDoesNotUseNavigationBarColor { + STPPaymentOptionsViewController *paymentOptionsVC = [self buildPaymentOptionsViewController]; + __unused UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:paymentOptionsVC]; + __unused UIView *view = paymentOptionsVC.view; + XCTAssertEqualObjects(paymentOptionsVC.navigationItem.leftBarButtonItem.tintColor, [STPTheme defaultTheme].accentColor); +} + + +@end diff --git a/Stripe/StripeiOSTests/UserDefaults+StripeTest.swift b/Stripe/StripeiOSTests/UserDefaults+StripeTest.swift new file mode 100644 index 00000000..4803d5df --- /dev/null +++ b/Stripe/StripeiOSTests/UserDefaults+StripeTest.swift @@ -0,0 +1,33 @@ +// +// UserDefaults+StripeTest.swift +// StripeiOS Tests +// +// Created by Yuki Tokuhiro on 5/21/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import XCTest + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentsUI + +class UserDefaults_StripeTest: XCTestCase { + func testFraudDetectionData() throws { + let fraudDetectionData = FraudDetectionData( + sid: UUID().uuidString, + muid: UUID().uuidString, + guid: UUID().uuidString, + sidCreationDate: Date() + ) + UserDefaults.standard.fraudDetectionData = fraudDetectionData + XCTAssertEqual(UserDefaults.standard.fraudDetectionData, fraudDetectionData) + } + + func testCustomerToLastSelectedPaymentMethod() throws { + let c = [UUID().uuidString: UUID().uuidString] + UserDefaults.standard.customerToLastSelectedPaymentMethod = c + XCTAssertEqual(UserDefaults.standard.customerToLastSelectedPaymentMethod, c) + } +} diff --git a/Stripe/StripeiOSTests/WalletHeaderViewSnapshotTests.swift b/Stripe/StripeiOSTests/WalletHeaderViewSnapshotTests.swift new file mode 100644 index 00000000..ef749ce6 --- /dev/null +++ b/Stripe/StripeiOSTests/WalletHeaderViewSnapshotTests.swift @@ -0,0 +1,198 @@ +// +// WalletHeaderViewSnapshotTests.swift +// StripeiOS Tests +// +// Created by Ramon Torres on 12/9/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase +import StripeCoreTestUtils +import UIKit + +@testable@_spi(STP) import Stripe +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePaymentSheet + +class WalletHeaderViewSnapshotTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() + // self.recordMode = true + } + + func testApplePayButton() { + let headerView = PaymentSheetViewController.WalletHeaderView( + options: .applePay, + delegate: nil + ) + verify(headerView) + } + + func testApplePayButtonWithCustomCta() { + let headerView = PaymentSheetViewController.WalletHeaderView( + options: .applePay, + applePayButtonType: .buy, + delegate: nil + ) + verify(headerView) + } + + func testLinkButton() { + let headerView = PaymentSheetViewController.WalletHeaderView( + options: .link, + delegate: nil + ) + verify(headerView) + } + + // Tests UI elements that adapt their color based on the `PaymentSheet.Appearance` + func testAdaptiveElements() { + var darkMode = false + + var appearance = PaymentSheet.Appearance() + appearance.colors.background = UIColor.init(dynamicProvider: { _ in + if darkMode { + return .black + } + + return .white + }) + + appearance.cornerRadius = 0 + let headerView = PaymentSheetViewController.WalletHeaderView( + options: .applePay, + appearance: appearance, + delegate: nil + ) + + verify(headerView, identifier: "Light") + + darkMode = true + headerView.traitCollectionDidChange(nil) + + verify(headerView, identifier: "Dark") + } + + // Tests UI elements that adapt their color based on the `PaymentSheet.Appearance` + func testAdaptiveElementsWithCustomApplePayCta() { + var darkMode = false + + var appearance = PaymentSheet.Appearance() + appearance.colors.background = UIColor.init(dynamicProvider: { _ in + if darkMode { + return .black + } + + return .white + }) + + appearance.cornerRadius = 0 + let headerView = PaymentSheetViewController.WalletHeaderView( + options: .applePay, + appearance: appearance, + applePayButtonType: .buy, + delegate: nil + ) + + verify(headerView, identifier: "Light") + + darkMode = true + headerView.traitCollectionDidChange(nil) + + verify(headerView, identifier: "Dark") + } + + func testAllButtons() { + let headerView = PaymentSheetViewController.WalletHeaderView( + options: [.applePay, .link], + delegate: nil + ) + verify(headerView) + + headerView.showsCardPaymentMessage = true + verify(headerView, identifier: "Card only") + } + + func testAllButtonsWithCustomApplePayCta() { + let headerView = PaymentSheetViewController.WalletHeaderView( + options: [.applePay, .link], + applePayButtonType: .buy, + delegate: nil + ) + verify(headerView) + + headerView.showsCardPaymentMessage = true + verify(headerView, identifier: "Card only") + } + + func testCustomFont() throws { + var appearance = PaymentSheet.Appearance.default + appearance.font.base = try XCTUnwrap(UIFont(name: "AmericanTypewriter", size: 12.0)) + + let headerView = PaymentSheetViewController.WalletHeaderView( + options: [.applePay, .link], + appearance: appearance, + delegate: nil + ) + + verify(headerView) + } + + func testCustomFontScales() throws { + var appearance = PaymentSheet.Appearance.default + appearance.font.base = try XCTUnwrap(UIFont(name: "AmericanTypewriter", size: 12.0)) + appearance.font.sizeScaleFactor = 1.25 + + let headerView = PaymentSheetViewController.WalletHeaderView( + options: [.applePay, .link], + appearance: appearance, + delegate: nil + ) + + verify(headerView) + } + + func testCustomCornerRadius() { + var appearance = PaymentSheet.Appearance.default + appearance.cornerRadius = 14.5 + + let headerView = PaymentSheetViewController.WalletHeaderView( + options: [.applePay, .link], + appearance: appearance, + delegate: nil + ) + + verify(headerView) + } + + func verify( + _ view: UIView, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + view.autosizeHeight(width: 300) + STPSnapshotVerifyView(view, identifier: identifier, file: file, line: line) + } +} + +extension WalletHeaderViewSnapshotTests { + fileprivate struct LinkAccountStub: PaymentSheetLinkAccountInfoProtocol { + let email: String + let redactedPhoneNumber: String? + let lastPM: LinkPMDisplayDetails? + let isRegistered: Bool + let isLoggedIn: Bool + } + + fileprivate func makeLinkAccountStub() -> LinkAccountStub { + return LinkAccountStub( + email: "customer@example.com", + redactedPhoneNumber: "+1********55", + lastPM: nil, + isRegistered: true, + isLoggedIn: true + ) + } +} diff --git a/Stripe3DS2/BuildConfigurations/Project-Debug.xcconfig b/Stripe3DS2/BuildConfigurations/Project-Debug.xcconfig new file mode 100644 index 00000000..039738ae --- /dev/null +++ b/Stripe3DS2/BuildConfigurations/Project-Debug.xcconfig @@ -0,0 +1,15 @@ +// +// Project-Debug.xcconfig +// +// Generated by BuildSettingExtractor on 12/5/22 +// https://buildsettingextractor.com +// + +#include "Project-Shared.xcconfig" + +ENABLE_TESTABILITY = YES +GCC_DYNAMIC_NO_PIC = NO +GCC_OPTIMIZATION_LEVEL = 0 +GCC_PREPROCESSOR_DEFINITIONS = DEBUG=1 $(inherited) +MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE +ONLY_ACTIVE_ARCH = YES \ No newline at end of file diff --git a/Stripe3DS2/BuildConfigurations/Project-Release.xcconfig b/Stripe3DS2/BuildConfigurations/Project-Release.xcconfig new file mode 100644 index 00000000..e2ec59ca --- /dev/null +++ b/Stripe3DS2/BuildConfigurations/Project-Release.xcconfig @@ -0,0 +1,12 @@ +// +// Project-Release.xcconfig +// +// Generated by BuildSettingExtractor on 12/5/22 +// https://buildsettingextractor.com +// + +#include "Project-Shared.xcconfig" + +ENABLE_NS_ASSERTIONS = NO +MTL_ENABLE_DEBUG_INFO = NO +VALIDATE_PRODUCT = YES \ No newline at end of file diff --git a/Stripe3DS2/BuildConfigurations/Project-Shared.xcconfig b/Stripe3DS2/BuildConfigurations/Project-Shared.xcconfig new file mode 100644 index 00000000..cca99b2c --- /dev/null +++ b/Stripe3DS2/BuildConfigurations/Project-Shared.xcconfig @@ -0,0 +1,63 @@ +// +// Project-Shared.xcconfig +// +// Generated by BuildSettingExtractor on 12/5/22 +// https://buildsettingextractor.com +// + +ALWAYS_SEARCH_USER_PATHS = NO +CLANG_ANALYZER_NONNULL = YES +CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE +CLANG_CXX_LANGUAGE_STANDARD = gnu++14 +CLANG_CXX_LIBRARY = libc++ +CLANG_ENABLE_MODULES = YES +CLANG_ENABLE_OBJC_ARC = YES +CLANG_ENABLE_OBJC_WEAK = YES +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_ASSIGN_ENUM = YES +CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES +CLANG_WARN_BOOL_CONVERSION = YES +CLANG_WARN_COMMA = YES +CLANG_WARN_CONSTANT_CONVERSION = YES +CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES +CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR +CLANG_WARN_DOCUMENTATION_COMMENTS = YES +CLANG_WARN_EMPTY_BODY = YES +CLANG_WARN_ENUM_CONVERSION = YES +CLANG_WARN_IMPLICIT_SIGN_CONVERSION = YES +CLANG_WARN_INFINITE_RECURSION = YES +CLANG_WARN_INT_CONVERSION = YES +CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +CLANG_WARN_OBJC_INTERFACE_IVARS = YES +CLANG_WARN_OBJC_LITERAL_CONVERSION = YES +CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR +CLANG_WARN_RANGE_LOOP_ANALYSIS = YES +CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION = YES +CLANG_WARN_SUSPICIOUS_MOVE = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN_UNREACHABLE_CODE = YES +COPY_PHASE_STRIP = NO +CURRENT_PROJECT_VERSION = 1.0 +DEBUG_INFORMATION_FORMAT = dwarf-with-dsym +ENABLE_STRICT_OBJC_MSGSEND = YES +GCC_C_LANGUAGE_STANDARD = gnu11 +GCC_NO_COMMON_BLOCKS = YES +GCC_TREAT_INCOMPATIBLE_POINTER_TYPE_WARNINGS_AS_ERRORS = YES +GCC_TREAT_WARNINGS_AS_ERRORS = YES +GCC_WARN_64_TO_32_BIT_CONVERSION = YES +GCC_WARN_ABOUT_MISSING_NEWLINE = YES +GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR +GCC_WARN_SIGN_COMPARE = YES +GCC_WARN_UNDECLARED_SELECTOR = YES +GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE +GCC_WARN_UNUSED_FUNCTION = YES +GCC_WARN_UNUSED_VARIABLE = YES +IPHONEOS_DEPLOYMENT_TARGET = 13.0 +MTL_FAST_MATH = YES +SDKROOT = iphoneos +SWIFT_VERSION=5.0 +VERSION_INFO_PREFIX = +VERSIONING_SYSTEM = apple-generic diff --git a/Stripe3DS2/BuildConfigurations/Stripe3DS2-Debug.xcconfig b/Stripe3DS2/BuildConfigurations/Stripe3DS2-Debug.xcconfig new file mode 100644 index 00000000..34a46e79 --- /dev/null +++ b/Stripe3DS2/BuildConfigurations/Stripe3DS2-Debug.xcconfig @@ -0,0 +1,12 @@ +// +// Stripe3DS2-Debug.xcconfig +// +// Generated by BuildSettingExtractor on 12/5/22 +// https://buildsettingextractor.com +// + +#include "Stripe3DS2-Shared.xcconfig" + +//********************************************// +//* Currently no build settings in this file *// +//********************************************// \ No newline at end of file diff --git a/Stripe3DS2/BuildConfigurations/Stripe3DS2-Release.xcconfig b/Stripe3DS2/BuildConfigurations/Stripe3DS2-Release.xcconfig new file mode 100644 index 00000000..1885c226 --- /dev/null +++ b/Stripe3DS2/BuildConfigurations/Stripe3DS2-Release.xcconfig @@ -0,0 +1,12 @@ +// +// Stripe3DS2-Release.xcconfig +// +// Generated by BuildSettingExtractor on 12/5/22 +// https://buildsettingextractor.com +// + +#include "Stripe3DS2-Shared.xcconfig" + +//********************************************// +//* Currently no build settings in this file *// +//********************************************// \ No newline at end of file diff --git a/Stripe3DS2/BuildConfigurations/Stripe3DS2-Shared.xcconfig b/Stripe3DS2/BuildConfigurations/Stripe3DS2-Shared.xcconfig new file mode 100644 index 00000000..02d8fe27 --- /dev/null +++ b/Stripe3DS2/BuildConfigurations/Stripe3DS2-Shared.xcconfig @@ -0,0 +1,23 @@ +// +// Stripe3DS2-Shared.xcconfig +// +// Generated by BuildSettingExtractor on 12/5/22 +// https://buildsettingextractor.com +// + +APPLICATION_EXTENSION_API_ONLY = YES +BUILD_LIBRARY_FOR_DISTRIBUTION = YES +CODE_SIGN_STYLE = Automatic +DEFINES_MODULE = YES +DEPLOYMENT_POSTPROCESSING = YES +DYLIB_COMPATIBILITY_VERSION = 1 +DYLIB_CURRENT_VERSION = 1 +DYLIB_INSTALL_NAME_BASE = @rpath +GCC_PREFIX_HEADER = $(SRCROOT)/Stripe3DS2/include/Stripe3DS2-Prefix.pch +INSTALL_PATH = $(LOCAL_LIBRARY_DIR)/Frameworks +IPHONEOS_DEPLOYMENT_TARGET = 13.0 +OTHER_LDFLAGS = +SKIP_INSTALL = YES +STRIP_STYLE = non-global +TARGETED_DEVICE_FAMILY = 1,2 +LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks @loader_path/Frameworks diff --git a/Stripe3DS2/BuildConfigurations/Stripe3DS2DemoUI-Debug.xcconfig b/Stripe3DS2/BuildConfigurations/Stripe3DS2DemoUI-Debug.xcconfig new file mode 100644 index 00000000..bd71fac1 --- /dev/null +++ b/Stripe3DS2/BuildConfigurations/Stripe3DS2DemoUI-Debug.xcconfig @@ -0,0 +1,12 @@ +// +// Stripe3DS2DemoUI-Debug.xcconfig +// +// Generated by BuildSettingExtractor on 12/5/22 +// https://buildsettingextractor.com +// + +#include "Stripe3DS2DemoUI-Shared.xcconfig" + +//********************************************// +//* Currently no build settings in this file *// +//********************************************// \ No newline at end of file diff --git a/Stripe3DS2/BuildConfigurations/Stripe3DS2DemoUI-Release.xcconfig b/Stripe3DS2/BuildConfigurations/Stripe3DS2DemoUI-Release.xcconfig new file mode 100644 index 00000000..baf74545 --- /dev/null +++ b/Stripe3DS2/BuildConfigurations/Stripe3DS2DemoUI-Release.xcconfig @@ -0,0 +1,12 @@ +// +// Stripe3DS2DemoUI-Release.xcconfig +// +// Generated by BuildSettingExtractor on 12/5/22 +// https://buildsettingextractor.com +// + +#include "Stripe3DS2DemoUI-Shared.xcconfig" + +//********************************************// +//* Currently no build settings in this file *// +//********************************************// \ No newline at end of file diff --git a/Stripe3DS2/BuildConfigurations/Stripe3DS2DemoUI-Shared.xcconfig b/Stripe3DS2/BuildConfigurations/Stripe3DS2DemoUI-Shared.xcconfig new file mode 100644 index 00000000..fb8baac4 --- /dev/null +++ b/Stripe3DS2/BuildConfigurations/Stripe3DS2DemoUI-Shared.xcconfig @@ -0,0 +1,13 @@ +// +// Stripe3DS2DemoUI-Shared.xcconfig +// +// Generated by BuildSettingExtractor on 12/5/22 +// https://buildsettingextractor.com +// + +ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon +CODE_SIGN_STYLE = Automatic +IPHONEOS_DEPLOYMENT_TARGET = 13.0 +TARGETED_DEVICE_FAMILY = 1,2 +DYLIB_INSTALL_NAME_BASE = @rpath +LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks @loader_path/Frameworks diff --git a/Stripe3DS2/BuildConfigurations/Stripe3DS2DemoUITests-Debug.xcconfig b/Stripe3DS2/BuildConfigurations/Stripe3DS2DemoUITests-Debug.xcconfig new file mode 100644 index 00000000..53c3f9ff --- /dev/null +++ b/Stripe3DS2/BuildConfigurations/Stripe3DS2DemoUITests-Debug.xcconfig @@ -0,0 +1,12 @@ +// +// Stripe3DS2DemoUITests-Debug.xcconfig +// +// Generated by BuildSettingExtractor on 12/5/22 +// https://buildsettingextractor.com +// + +#include "Stripe3DS2DemoUITests-Shared.xcconfig" + +//********************************************// +//* Currently no build settings in this file *// +//********************************************// \ No newline at end of file diff --git a/Stripe3DS2/BuildConfigurations/Stripe3DS2DemoUITests-Release.xcconfig b/Stripe3DS2/BuildConfigurations/Stripe3DS2DemoUITests-Release.xcconfig new file mode 100644 index 00000000..7251a5cc --- /dev/null +++ b/Stripe3DS2/BuildConfigurations/Stripe3DS2DemoUITests-Release.xcconfig @@ -0,0 +1,12 @@ +// +// Stripe3DS2DemoUITests-Release.xcconfig +// +// Generated by BuildSettingExtractor on 12/5/22 +// https://buildsettingextractor.com +// + +#include "Stripe3DS2DemoUITests-Shared.xcconfig" + +//********************************************// +//* Currently no build settings in this file *// +//********************************************// \ No newline at end of file diff --git a/Stripe3DS2/BuildConfigurations/Stripe3DS2DemoUITests-Shared.xcconfig b/Stripe3DS2/BuildConfigurations/Stripe3DS2DemoUITests-Shared.xcconfig new file mode 100644 index 00000000..e520a8af --- /dev/null +++ b/Stripe3DS2/BuildConfigurations/Stripe3DS2DemoUITests-Shared.xcconfig @@ -0,0 +1,13 @@ +// +// Stripe3DS2DemoUITests-Shared.xcconfig +// +// Generated by BuildSettingExtractor on 12/5/22 +// https://buildsettingextractor.com +// + +BUNDLE_LOADER = $(TEST_HOST) +CODE_SIGN_STYLE = Automatic +TARGETED_DEVICE_FAMILY = 1,2 +TEST_HOST = $(BUILT_PRODUCTS_DIR)/Stripe3DS2DemoUI.app/Stripe3DS2DemoUI +DYLIB_INSTALL_NAME_BASE = @rpath +LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks @loader_path/Frameworks diff --git a/Stripe3DS2/BuildConfigurations/Stripe3DS2Tests-Debug.xcconfig b/Stripe3DS2/BuildConfigurations/Stripe3DS2Tests-Debug.xcconfig new file mode 100644 index 00000000..1744b025 --- /dev/null +++ b/Stripe3DS2/BuildConfigurations/Stripe3DS2Tests-Debug.xcconfig @@ -0,0 +1,12 @@ +// +// Stripe3DS2Tests-Debug.xcconfig +// +// Generated by BuildSettingExtractor on 12/5/22 +// https://buildsettingextractor.com +// + +#include "Stripe3DS2Tests-Shared.xcconfig" + +//********************************************// +//* Currently no build settings in this file *// +//********************************************// \ No newline at end of file diff --git a/Stripe3DS2/BuildConfigurations/Stripe3DS2Tests-Release.xcconfig b/Stripe3DS2/BuildConfigurations/Stripe3DS2Tests-Release.xcconfig new file mode 100644 index 00000000..f03b2ac4 --- /dev/null +++ b/Stripe3DS2/BuildConfigurations/Stripe3DS2Tests-Release.xcconfig @@ -0,0 +1,12 @@ +// +// Stripe3DS2Tests-Release.xcconfig +// +// Generated by BuildSettingExtractor on 12/5/22 +// https://buildsettingextractor.com +// + +#include "Stripe3DS2Tests-Shared.xcconfig" + +//********************************************// +//* Currently no build settings in this file *// +//********************************************// \ No newline at end of file diff --git a/Stripe3DS2/BuildConfigurations/Stripe3DS2Tests-Shared.xcconfig b/Stripe3DS2/BuildConfigurations/Stripe3DS2Tests-Shared.xcconfig new file mode 100644 index 00000000..2e597a2e --- /dev/null +++ b/Stripe3DS2/BuildConfigurations/Stripe3DS2Tests-Shared.xcconfig @@ -0,0 +1,12 @@ +// +// Stripe3DS2Tests-Shared.xcconfig +// +// Generated by BuildSettingExtractor on 12/5/22 +// https://buildsettingextractor.com +// + +CODE_SIGN_STYLE = Automatic +OTHER_LDFLAGS = -ObjC +TARGETED_DEVICE_FAMILY = 1,2 +DYLIB_INSTALL_NAME_BASE = @rpath +LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks @loader_path/Frameworks diff --git a/Stripe3DS2/Project.swift b/Stripe3DS2/Project.swift new file mode 100644 index 00000000..f9d34aac --- /dev/null +++ b/Stripe3DS2/Project.swift @@ -0,0 +1,126 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project( + name: "Stripe3DS2", + options: .options( + automaticSchemesOptions: .disabled, + disableBundleAccessors: true, + disableSynthesizedResourceAccessors: true + ), + packages: [ + .remote( + url: "https://github.com/uber/ios-snapshot-test-case", + requirement: .upToNextMajor(from: "8.0.0") + ), + ], + settings: .settings( + configurations: [ + .debug( + name: "Debug", + xcconfig: "BuildConfigurations/Project-Debug.xcconfig" + ), + .release( + name: "Release", + xcconfig: "BuildConfigurations/Project-Release.xcconfig" + ), + ], + defaultSettings: .none + ), + targets: [ + Target( + name: "Stripe3DS2", + platform: .iOS, + product: .framework, + bundleId: "com.stripe.stripe-3ds2", + infoPlist: "Stripe3DS2/Info.plist", + sources: "Stripe3DS2/**/*.m", + resources: "Stripe3DS2/Resources/**", + headers: .headers( + public: [ + "Stripe3DS2/include/*.h", + ], + project: "Stripe3DS2/*.h" + ), + settings: .stripeTargetSettings( + baseXcconfigFilePath: "BuildConfigurations/Stripe3DS2" + ) + ), + Target( + name: "Stripe3DS2Tests", + platform: .iOS, + product: .unitTests, + bundleId: "com.stripe.Stripe3DS2Tests", + infoPlist: "Stripe3DS2Tests/Info.plist", + sources: "Stripe3DS2Tests/**/*.m", + resources: "Stripe3DS2Tests/JSON/**", + headers: .headers( + project: "Stripe3DS2/**/*.h" + ), + dependencies: [ + .xctest, + .target(name: "Stripe3DS2"), + ], + settings: .stripeTargetSettings( + baseXcconfigFilePath: "BuildConfigurations/Stripe3DS2Tests" + ) + ), + Target( + name: "Stripe3DS2DemoUI", + platform: .iOS, + product: .app, + bundleId: "com.stripe.Stripe3DS2DemoUI", + infoPlist: "Stripe3DS2DemoUI/Info.plist", + sources: "Stripe3DS2DemoUI/Sources/**/*.m", + resources: "Stripe3DS2DemoUI/Resources/**", + headers: .headers( + project: "Stripe3DS2DemoUI/Sources/**/*.h" + ), + dependencies: [ + .target(name: "Stripe3DS2"), + ], + settings: .stripeTargetSettings( + baseXcconfigFilePath: "BuildConfigurations/Stripe3DS2DemoUI" + ) + ), + Target( + name: "Stripe3DS2DemoUITests", + platform: .iOS, + product: .unitTests, + bundleId: "com.stripe.Stripe3DS2DemoUITests", + infoPlist: "Stripe3DS2DemoUITests/Info.plist", + sources: "Stripe3DS2DemoUITests/**/*.m", + dependencies: [ + .xctest, + .target(name: "Stripe3DS2"), + .target(name: "Stripe3DS2DemoUI"), + .package(product: "iOSSnapshotTestCase"), + ], + settings: .stripeTargetSettings( + baseXcconfigFilePath: "BuildConfigurations/Stripe3DS2DemoUITests" + ) + ), + ], + schemes: [ + Scheme( + name: "Stripe3DS2", + buildAction: .buildAction(targets: ["Stripe3DS2"]), + testAction: .targets(["Stripe3DS2Tests"]) + ), + Scheme( + name: "Stripe3DS2DemoUI", + buildAction: .buildAction(targets: ["Stripe3DS2DemoUI"]), + testAction: .targets( + ["Stripe3DS2DemoUITests"], + arguments: Arguments( + environment: [ + "FB_REFERENCE_IMAGE_DIR": + "$(SOURCE_ROOT)/../Tests/ReferenceImages", + ] + ), + expandVariableFromTarget: "Stripe3DS2DemoUITests" + ), + runAction: .runAction(executable: "Stripe3DS2DemoUI") + ), + ] +) diff --git a/Stripe3DS2/Stripe3DS2.xcodeproj/project.pbxproj b/Stripe3DS2/Stripe3DS2.xcodeproj/project.pbxproj new file mode 100644 index 00000000..0fc1f8fe --- /dev/null +++ b/Stripe3DS2/Stripe3DS2.xcodeproj/project.pbxproj @@ -0,0 +1,1712 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + 01706B4660728A5BFAC12840 /* STDSRuntimeException.m in Sources */ = {isa = PBXBuildFile; fileRef = CD33421F13A675DCFE597FFB /* STDSRuntimeException.m */; }; + 05647ABCFBDBE1209D5044AC /* STDSEphemeralKeyPair.m in Sources */ = {isa = PBXBuildFile; fileRef = F9D521B45783D36C8E83F0EA /* STDSEphemeralKeyPair.m */; }; + 0615DD02C0B022AE207DADF0 /* STDSUICustomization.h in Headers */ = {isa = PBXBuildFile; fileRef = 210B22FF4DCB0C6E7C763EAB /* STDSUICustomization.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 081347606D48F64161D95B45 /* mastercard.der in Resources */ = {isa = PBXBuildFile; fileRef = 1104D5855D28E49E104CA004 /* mastercard.der */; }; + 089DAFECA444861762781974 /* NSError+Stripe3DS2.m in Sources */ = {isa = PBXBuildFile; fileRef = 26C20D4B77372845627D6466 /* NSError+Stripe3DS2.m */; }; + 08ECC7E70E7478E76793ED1E /* UIViewController+Stripe3DS2.m in Sources */ = {isa = PBXBuildFile; fileRef = 99451064136843047C7881AD /* UIViewController+Stripe3DS2.m */; }; + 09DFAF7B38EAB76BC66AC9C8 /* STDSChallengeResponseObject.m in Sources */ = {isa = PBXBuildFile; fileRef = 24CE086326AF3DE336BF4F4C /* STDSChallengeResponseObject.m */; }; + 09F0B1945CC12FB1215119FD /* STDSProcessingView.h in Headers */ = {isa = PBXBuildFile; fileRef = 95641ECBE1AE1CC198013405 /* STDSProcessingView.h */; }; + 0A2BC6A9E388B242C46C09D9 /* UIButton+CustomInitialization.h in Headers */ = {isa = PBXBuildFile; fileRef = 4DD55BECC3FB85216FE46218 /* UIButton+CustomInitialization.h */; }; + 0BA0F0CADC4CFF419E1F7EFC /* STDSLabelCustomization.h in Headers */ = {isa = PBXBuildFile; fileRef = 6932DD26C7C16938DFA0E198 /* STDSLabelCustomization.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 0D3D9C95CB14A610EAAAEF6E /* STDSJSONEncoder.m in Sources */ = {isa = PBXBuildFile; fileRef = 0681C043F732148587737233 /* STDSJSONEncoder.m */; }; + 0D4B42EC5019D213BDBC84E7 /* NSDictionary+DecodingHelpers.m in Sources */ = {isa = PBXBuildFile; fileRef = C566D6E444FCA4BCEC65F3D2 /* NSDictionary+DecodingHelpers.m */; }; + 0EEFDE04392FD017EA0A017A /* STDSDeviceInformationParameter.m in Sources */ = {isa = PBXBuildFile; fileRef = 25C7D18868DDADEF4CB6220C /* STDSDeviceInformationParameter.m */; }; + 1134F81C7D015BA5EA52BFD1 /* NSDictionary+DecodingHelpers.h in Headers */ = {isa = PBXBuildFile; fileRef = C78AA1D7AD2BAB52EE58D078 /* NSDictionary+DecodingHelpers.h */; }; + 1156B25EBE0135B627E174D2 /* STDSProgressViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 84967ED0D9B206B3F5AA4F02 /* STDSProgressViewController.m */; }; + 125427F1322501AA12EAEBE6 /* visa.der in Resources */ = {isa = PBXBuildFile; fileRef = A70071543985284E0D9DAC64 /* visa.der */; }; + 128B64380D6724F016EE8D56 /* STDSDemoViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 382C583385FAECA91366769E /* STDSDemoViewController.m */; }; + 136255493F3F0E930FBA8606 /* STDSImageLoader.m in Sources */ = {isa = PBXBuildFile; fileRef = A09A89BBE7360F93195641C9 /* STDSImageLoader.m */; }; + 1469551DB874336B68F6C39D /* Stripe3DS2.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE4030C50383B488C97F4B56 /* Stripe3DS2.framework */; }; + 149FE0C2910F08910B250BD8 /* STDSException+Internal.h in Headers */ = {isa = PBXBuildFile; fileRef = 461F938CDE7ED6D06D9D2700 /* STDSException+Internal.h */; }; + 14BDBD98F8E15576403255C9 /* STDSChallengeInformationView.m in Sources */ = {isa = PBXBuildFile; fileRef = D24777EE9931075405F370DB /* STDSChallengeInformationView.m */; }; + 15D4A2F07EA477B84CD48B15 /* STDSRuntimeErrorEvent.h in Headers */ = {isa = PBXBuildFile; fileRef = DE7D85369E012B15702D8DF9 /* STDSRuntimeErrorEvent.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 1767509FDC216602A5E43F0E /* STDSErrorMessageTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 278F2C40987F5218BD031E3D /* STDSErrorMessageTest.m */; }; + 1B20B454D0C309613A1FAF68 /* STDSThreeDSProtocolVersion.m in Sources */ = {isa = PBXBuildFile; fileRef = 8AC648332A706809F1BDFD59 /* STDSThreeDSProtocolVersion.m */; }; + 206B93BDE91CFED74A053B87 /* STDSStackView.h in Headers */ = {isa = PBXBuildFile; fileRef = B7AE3B4732D203134FE096FE /* STDSStackView.h */; }; + 2086DFD1FC02783FB106AD35 /* UIColor+DefaultColors.m in Sources */ = {isa = PBXBuildFile; fileRef = 869733153BCA6BE2EB7A452F /* UIColor+DefaultColors.m */; }; + 2224463CE5FB99D4BEE6A479 /* STDSJSONEncoder.h in Headers */ = {isa = PBXBuildFile; fileRef = 06C5207432FB07BD71703BCC /* STDSJSONEncoder.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 24D49F833E1ED28DE557F219 /* STDSException.h in Headers */ = {isa = PBXBuildFile; fileRef = B71A1C110DCC23A9CE929837 /* STDSException.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 24F626CD0F93B7AF59B1D6AA /* STDSThreeDS2Service.m in Sources */ = {isa = PBXBuildFile; fileRef = 77646CEC7EE754F47EDBDA4F /* STDSThreeDS2Service.m */; }; + 27F3EA1BB7E7B56A1A8524BA /* UIColor+ThirteenSupport.h in Headers */ = {isa = PBXBuildFile; fileRef = 9A8B0001705400C661678617 /* UIColor+ThirteenSupport.h */; }; + 284F72D3FD8C06C0E5974086 /* STDSTextFieldCustomization.m in Sources */ = {isa = PBXBuildFile; fileRef = 97163B5E9598CA3A298920B9 /* STDSTextFieldCustomization.m */; }; + 28AD7EC6F6DA2AECD7F61BA7 /* NSLayoutConstraint+LayoutSupport.h in Headers */ = {isa = PBXBuildFile; fileRef = E0949399E96663964091C0EF /* NSLayoutConstraint+LayoutSupport.h */; }; + 2938D9CC41899DC78D01EF9F /* STDSDeviceInformationParameter.h in Headers */ = {isa = PBXBuildFile; fileRef = 172AF5C85EDBD92A1030E361 /* STDSDeviceInformationParameter.h */; }; + 2970D4880EBFAEFABA2E8EBD /* STDSChallengeRequestParameters.m in Sources */ = {isa = PBXBuildFile; fileRef = 60693BC560C4927ACFD2E6C3 /* STDSChallengeRequestParameters.m */; }; + 2AB06EC20B22306F1DDF9F4B /* STDSChallengeResponseImageObject.m in Sources */ = {isa = PBXBuildFile; fileRef = 3A9F828840A3B361842E38DE /* STDSChallengeResponseImageObject.m */; }; + 2B09B7FDF8C4AA551F7EE18E /* STDSDeviceInformationParameterTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FC52C3C844BDA981EE34BAB9 /* STDSDeviceInformationParameterTests.m */; }; + 2B2787A7103C0D0AB5F00B78 /* STDSAuthenticationResponseObject.h in Headers */ = {isa = PBXBuildFile; fileRef = 138E82072FC6572612A8E248 /* STDSAuthenticationResponseObject.h */; }; + 2BBA82878804711E06CBFCCB /* STDSErrorMessage.h in Headers */ = {isa = PBXBuildFile; fileRef = 645D0BB927B7F8A0D37EE04D /* STDSErrorMessage.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 2C5621AABECF6192F92A20CF /* STDSStripe3DS2Error.m in Sources */ = {isa = PBXBuildFile; fileRef = 9F4376BB07FD1EE783249AED /* STDSStripe3DS2Error.m */; }; + 2C6DB6516699B4EFADDF3360 /* STDSRuntimeException.h in Headers */ = {isa = PBXBuildFile; fileRef = B8619CA38E2A5B49DBF8546B /* STDSRuntimeException.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 2CB6DF5CCCBC1EB9ABD03143 /* STDSNotInitializedException.h in Headers */ = {isa = PBXBuildFile; fileRef = 2FCABBF51CBA7F9FEF757B4C /* STDSNotInitializedException.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 2D42EA44FC01C834209CFED4 /* Stripe3DS2.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9E1CB633CA5917B2168BB928 /* Stripe3DS2.xcassets */; }; + 2E2022723BC7A4B2DD5ECE76 /* STDSErrorMessage+Internal.m in Sources */ = {isa = PBXBuildFile; fileRef = 2CB591796654445B8434A501 /* STDSErrorMessage+Internal.m */; }; + 30632F7C730BF8D23B607CF7 /* STDSWarningTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 474F39AA3F47D84F8D54E5B7 /* STDSWarningTests.m */; }; + 30B25163BEDEB99CDD101B5F /* NSString+EmptyChecking.h in Headers */ = {isa = PBXBuildFile; fileRef = A8A77DB1C711B8696B2E5FEF /* NSString+EmptyChecking.h */; }; + 3192FCC841152A7E5E6F19D6 /* STDSAlreadyInitializedException.h in Headers */ = {isa = PBXBuildFile; fileRef = 1F1FD11407319293954C6D81 /* STDSAlreadyInitializedException.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 31C636A25BF83316EE7EB57D /* STDSThreeDS2Service.h in Headers */ = {isa = PBXBuildFile; fileRef = 5EAA444FA7C82BDA4AD907BF /* STDSThreeDS2Service.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 326AD1DD7BB8A2A6DA66EC67 /* STDSThreeDSProtocolVersion+Private.h in Headers */ = {isa = PBXBuildFile; fileRef = 47C29E9F3D2A62676B424CD1 /* STDSThreeDSProtocolVersion+Private.h */; }; + 32BE38D04DD76CA4490389C6 /* STDSConfigParametersTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 5F551808E65CDADD9B3A9CB8 /* STDSConfigParametersTests.m */; }; + 3343DA94A5032627843A343C /* STDSEphemeralKeyPair+Testing.h in Headers */ = {isa = PBXBuildFile; fileRef = 52D6E4F76CBA258163E57DF3 /* STDSEphemeralKeyPair+Testing.h */; }; + 335553A547A3CA80B3901CCF /* STDSStripe3DS2Error.h in Headers */ = {isa = PBXBuildFile; fileRef = AE53BAA72835AFA3B35C58D3 /* STDSStripe3DS2Error.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 34256504F0E0E519D586B7CE /* STDSErrorMessage+Internal.h in Headers */ = {isa = PBXBuildFile; fileRef = 5463EB1CC8E85BE3BD12DEFF /* STDSErrorMessage+Internal.h */; }; + 3617B07ABD719F1A5F8302FA /* NSError+Stripe3DS2.h in Headers */ = {isa = PBXBuildFile; fileRef = A6778F8CE36F769DF608F932 /* NSError+Stripe3DS2.h */; }; + 3844F0E21742F43BCB32499A /* STDSDeviceInformationParameter+Private.h in Headers */ = {isa = PBXBuildFile; fileRef = 4869D6E14F01EDB960CB3065 /* STDSDeviceInformationParameter+Private.h */; }; + 390691CA0EAF81418B0C65DB /* UIView+LayoutSupport.m in Sources */ = {isa = PBXBuildFile; fileRef = 8B0F0485B7048DDAA7DA8F9B /* UIView+LayoutSupport.m */; }; + 3909D8028AC485BCCF17B48B /* STDSStackView.m in Sources */ = {isa = PBXBuildFile; fileRef = 1D6269F8B91341C387A911DB /* STDSStackView.m */; }; + 3B430F172A3AD6AA9AFDFC04 /* STDSNavigationBarCustomization.h in Headers */ = {isa = PBXBuildFile; fileRef = E1630D876436E43547FF69CE /* STDSNavigationBarCustomization.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 3C6D16D8E7B5BC865D956A0B /* iOSSnapshotTestCase in Frameworks */ = {isa = PBXBuildFile; productRef = 0118969F6608A92583CC6C98 /* iOSSnapshotTestCase */; }; + 3C88AE37A760B91E93D423CF /* STDSException.m in Sources */ = {isa = PBXBuildFile; fileRef = 6F030410FDABFF833CD8FE46 /* STDSException.m */; }; + 3C8C3BCDDDDEF6B9E150D4E1 /* STDSChallengeResponseSelectionInfo.h in Headers */ = {isa = PBXBuildFile; fileRef = 52EEBC7D74623E1A1D7E3219 /* STDSChallengeResponseSelectionInfo.h */; }; + 3D674B4EE5ABACD65EB21A1E /* discover.der in Resources */ = {isa = PBXBuildFile; fileRef = 04AB48F014A8D5BA56FEF463 /* discover.der */; }; + 3F2624C33291FCE00D596768 /* STDSDeviceInformationManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 9580A82A59EB2B0103EDF47A /* STDSDeviceInformationManager.m */; }; + 3F30A8F418870D176A626F00 /* STDSSpacerView.m in Sources */ = {isa = PBXBuildFile; fileRef = 51E4A57094A671EB14380882 /* STDSSpacerView.m */; }; + 41246FCC0695BABE1489C53E /* UIViewController+Stripe3DS2.h in Headers */ = {isa = PBXBuildFile; fileRef = 8F72413F99F8E50FD0FA085D /* UIViewController+Stripe3DS2.h */; }; + 4142A3AB3E384F91A1E5550B /* STDSExpandableInformationView.m in Sources */ = {isa = PBXBuildFile; fileRef = AE1BBF3A441B1F782B766F61 /* STDSExpandableInformationView.m */; }; + 425849564519D6454DDA3424 /* NSData+JWEHelpers.h in Headers */ = {isa = PBXBuildFile; fileRef = 5B05A609753EE71502409082 /* NSData+JWEHelpers.h */; }; + 435EFC9119D4B42B2C6A6F88 /* STDSAuthenticationResponse.h in Headers */ = {isa = PBXBuildFile; fileRef = 4EA4AB3BA2FB41B3D5F38978 /* STDSAuthenticationResponse.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 4518AD83580FC222B883DAFB /* STDSSimulatorChecker.m in Sources */ = {isa = PBXBuildFile; fileRef = 14D60BDF245487FE3362BE07 /* STDSSimulatorChecker.m */; }; + 458BC5A3D0E7B4D05549474F /* STDSBrandingView.h in Headers */ = {isa = PBXBuildFile; fileRef = 2CFAC8D74AFFC2E61BAD82A5 /* STDSBrandingView.h */; }; + 480CD62EC91F4796D1D8D8DC /* STDSSecTypeUtilitiesTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 30D22985091381DFCDF077E9 /* STDSSecTypeUtilitiesTests.m */; }; + 48316C1AB1D9151C205A59A4 /* STDSChallengeResponseMessageExtensionObject.h in Headers */ = {isa = PBXBuildFile; fileRef = BAAC56EDE0AAD2E50E02E9AE /* STDSChallengeResponseMessageExtensionObject.h */; }; + 4A9423CC79312BC3A0DEC38B /* STDSChallengeRequestParametersTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 4EAD29E57D206B5D6BAF9099 /* STDSChallengeRequestParametersTest.m */; }; + 4C0E13298F08D391FE8600CF /* STDSJSONWebSignatureTests.m in Sources */ = {isa = PBXBuildFile; fileRef = E4C668442518B446D573AD24 /* STDSJSONWebSignatureTests.m */; }; + 4C267E9A30ADC18E71408ED5 /* STDSTextFieldCustomization.h in Headers */ = {isa = PBXBuildFile; fileRef = 68ED2A0E11E1B27980E0C60A /* STDSTextFieldCustomization.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 4C573A44AB0A9CB28D76C621 /* STDSJSONWebEncryption.h in Headers */ = {isa = PBXBuildFile; fileRef = 76EA9DF8572C992E30BCF8A3 /* STDSJSONWebEncryption.h */; }; + 4D73C2FBAC9B96ECB45BDC94 /* Stripe3DS2.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE4030C50383B488C97F4B56 /* Stripe3DS2.framework */; }; + 4D79F0CFC54E8B923E9238CF /* Stripe3DS2.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CE4030C50383B488C97F4B56 /* Stripe3DS2.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 4E35267801D94FB84816C519 /* STDSInvalidInputException.h in Headers */ = {isa = PBXBuildFile; fileRef = 853A361AB630A2BD402D1B47 /* STDSInvalidInputException.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 4E4E4BC1E2D041FA530336EE /* STDSSwiftTryCatch.m in Sources */ = {isa = PBXBuildFile; fileRef = 9946D28A86E85A6C84EB6C7B /* STDSSwiftTryCatch.m */; }; + 4FE8C076102C195D609977F5 /* STDSChallengeSelectionView.m in Sources */ = {isa = PBXBuildFile; fileRef = A4D460893CB5DDE52AA0AB85 /* STDSChallengeSelectionView.m */; }; + 511513A9180F9835B08CBE56 /* STDSProgressViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = E629F85B783E42244E37DFA9 /* STDSProgressViewController.h */; }; + 574A7976213046F02F7F60C4 /* STDSProcessingView.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AC1C5F6F1311F50D9C37291 /* STDSProcessingView.m */; }; + 57E58A3B88D9EEB9FAE9B383 /* STDSDeviceInformation.h in Headers */ = {isa = PBXBuildFile; fileRef = 1FBE88F6E2C3153A8315EDC6 /* STDSDeviceInformation.h */; }; + 59756023104E4FB886B48936 /* STDSTextChallengeView.h in Headers */ = {isa = PBXBuildFile; fileRef = 82C41E6DC2CC247ECC17F2B9 /* STDSTextChallengeView.h */; }; + 5B16918BF5F67B0F5FB1AFD5 /* ARes.json in Resources */ = {isa = PBXBuildFile; fileRef = 237977B021FE538E5E4FA35C /* ARes.json */; }; + 5C83A3A4631E51EEECBEAA0F /* STDSEllipticCurvePoint.h in Headers */ = {isa = PBXBuildFile; fileRef = 5049A72A6BEB558D8559A5EB /* STDSEllipticCurvePoint.h */; }; + 5DAC25EBAB19555229654E44 /* STDSAuthenticationResponseTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 28E32DA72CAFBF2AF8099151 /* STDSAuthenticationResponseTests.m */; }; + 5FE5FB933E2651C8D84FA477 /* STDSAuthenticationResponseObject.m in Sources */ = {isa = PBXBuildFile; fileRef = 5043E1CF6955176EC3D43F88 /* STDSAuthenticationResponseObject.m */; }; + 602C526C0B52DF81846DA664 /* STDSOSVersionChecker.m in Sources */ = {isa = PBXBuildFile; fileRef = F363439353A6DD554FC44AC2 /* STDSOSVersionChecker.m */; }; + 613EF855524F7861A6DDD8C1 /* STDSImageLoader.h in Headers */ = {isa = PBXBuildFile; fileRef = ECC649704CDDA50E73DB3C10 /* STDSImageLoader.h */; }; + 63AFB3C739DD987EE56F801F /* STDSDirectoryServerCertificate.h in Headers */ = {isa = PBXBuildFile; fileRef = 069990DA025BE9F33602E96A /* STDSDirectoryServerCertificate.h */; }; + 64D05353B50FAF3BB76D6527 /* STDSRuntimeErrorEvent.m in Sources */ = {isa = PBXBuildFile; fileRef = 1501A7A3010FFE53D00E318F /* STDSRuntimeErrorEvent.m */; }; + 67D431374DECE36B2606D944 /* STDSProtocolErrorEvent.h in Headers */ = {isa = PBXBuildFile; fileRef = 3A6D20D1DAB7D769E5E97528 /* STDSProtocolErrorEvent.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 68F314D63323954EFE309E49 /* STDSOSVersionChecker.h in Headers */ = {isa = PBXBuildFile; fileRef = FAE7026135B5049B732CEDEB /* STDSOSVersionChecker.h */; }; + 6B3AA85EE71FC42EF9BD999F /* STDSEllipticCurvePoint.m in Sources */ = {isa = PBXBuildFile; fileRef = 2EF5AA537586EF706E4056FD /* STDSEllipticCurvePoint.m */; }; + 6BA3F565B6C0B78F6DED8826 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F31A6580385C6390910AFD93 /* Localizable.strings */; }; + 6DB54DBAEFD08967367B6BF7 /* STDSChallengeParameters.m in Sources */ = {isa = PBXBuildFile; fileRef = 23C929BF760735CC01E1E8F5 /* STDSChallengeParameters.m */; }; + 6E727D2B738D7A3527952D28 /* STDSTransaction.m in Sources */ = {isa = PBXBuildFile; fileRef = 5FDE2481364C454058BF2377 /* STDSTransaction.m */; }; + 72F430506E684D61BD5CFE3B /* STDSChallengeStatusReceiver.h in Headers */ = {isa = PBXBuildFile; fileRef = B7A75140FB62A71261CAF5EC /* STDSChallengeStatusReceiver.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7337FB20DC0EF491B5922913 /* STDSChallengeResponse.h in Headers */ = {isa = PBXBuildFile; fileRef = 315AEE1534F9D9974DC1674B /* STDSChallengeResponse.h */; }; + 75150C4678B8207AA6E7D969 /* STDSBrandingView.m in Sources */ = {isa = PBXBuildFile; fileRef = F9C39A42ECBA58571EFA03C5 /* STDSBrandingView.m */; }; + 75CDFDABD7F9813563E3F618 /* Stripe3DS2-Bridging-Header.h in Headers */ = {isa = PBXBuildFile; fileRef = 0F024DEAD6628A0229856656 /* Stripe3DS2-Bridging-Header.h */; }; + 793D61AD55630DD336A141A7 /* STDSChallengeResponseMessageExtension.h in Headers */ = {isa = PBXBuildFile; fileRef = AA8BE982004398CF8AAA7D1E /* STDSChallengeResponseMessageExtension.h */; }; + 79557307ECD48D17C566997A /* STDSFooterCustomization.h in Headers */ = {isa = PBXBuildFile; fileRef = BA96C9FFA48B85F9C42E8C68 /* STDSFooterCustomization.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 7C20B044ACFD3A50FD4AC9A8 /* acs_challenge.html in Resources */ = {isa = PBXBuildFile; fileRef = 7971EB56716D5E2F59053B6D /* acs_challenge.html */; }; + 7C7073B54628ED836F422F1D /* STDSJailbreakChecker.h in Headers */ = {isa = PBXBuildFile; fileRef = 94943BEAB94E949AF9830EFA /* STDSJailbreakChecker.h */; }; + 7EC8E5523F29B0F01FE827DB /* STDSWebView.h in Headers */ = {isa = PBXBuildFile; fileRef = 581EEE576D24047004C828DF /* STDSWebView.h */; }; + 7ED98325E7E54A54BFE54D6C /* STDSAuthenticationRequestParameters.m in Sources */ = {isa = PBXBuildFile; fileRef = AB871F64FC72EBC7A91F96C1 /* STDSAuthenticationRequestParameters.m */; }; + 81B1C12449852CEB3C59793F /* ul-test.der in Resources */ = {isa = PBXBuildFile; fileRef = B6275E7099E55F0C04688890 /* ul-test.der */; }; + 8233B9F131602DD7143FF96F /* STDSBundleLocator.m in Sources */ = {isa = PBXBuildFile; fileRef = 9564F43EF7CF4F60C3C202CD /* STDSBundleLocator.m */; }; + 825ACBE0734BDCE746E66AB0 /* STDSWhitelistView.h in Headers */ = {isa = PBXBuildFile; fileRef = 33CEADC8006E456FD82CE134 /* STDSWhitelistView.h */; }; + 8322973CD09F2E1C88B6046E /* UIColor+DefaultColors.h in Headers */ = {isa = PBXBuildFile; fileRef = 4A567E08748C86FCE2A75FEA /* UIColor+DefaultColors.h */; }; + 83878337274D8C43D492EC45 /* STDSUICustomizationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CB3154597040B803ADE14F9E /* STDSUICustomizationTests.m */; }; + 847926A68A88BEB58C92D9BC /* STDSThreeDSProtocolVersion.h in Headers */ = {isa = PBXBuildFile; fileRef = 5E6A9565E97DF2544254AA94 /* STDSThreeDSProtocolVersion.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 88197445522F39DB1E9F6AE7 /* NSString+JWEHelpers.m in Sources */ = {isa = PBXBuildFile; fileRef = 43CB8F416E4F4CEA34391481 /* NSString+JWEHelpers.m */; }; + 884D59AF7FD70889276AFC02 /* STDSWebView.m in Sources */ = {isa = PBXBuildFile; fileRef = AA85BC717C46C1B95AF8C1E3 /* STDSWebView.m */; }; + 890E22971E6F7B0FA1C8D649 /* STDSJSONEncodable.h in Headers */ = {isa = PBXBuildFile; fileRef = E6A3C5D4C46E681B7D76B2BA /* STDSJSONEncodable.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 893713F4464A88FAB92BC2C6 /* Stripe3DS2.h in Headers */ = {isa = PBXBuildFile; fileRef = 28C6B4F6BA431B9342970FD6 /* Stripe3DS2.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 8A0872706BF4CDE72EF0C480 /* STDSConfigParameters.m in Sources */ = {isa = PBXBuildFile; fileRef = 89B5ED899439D1C1054CB448 /* STDSConfigParameters.m */; }; + 8A64AFBB2AC15E0E0F57ED25 /* STDSNotInitializedException.m in Sources */ = {isa = PBXBuildFile; fileRef = B7BD1E24EA9427121E148DFC /* STDSNotInitializedException.m */; }; + 8C9BD4B150F384111CBD3BD3 /* STDSErrorMessage.m in Sources */ = {isa = PBXBuildFile; fileRef = 6DDB6222A2EF31FDB7592368 /* STDSErrorMessage.m */; }; + 8E0501C3BB849C2D2D99F34E /* STDSLocalizedString.h in Headers */ = {isa = PBXBuildFile; fileRef = 41E0F638E776A7F3456BDA3E /* STDSLocalizedString.h */; }; + 8E27285FE76F8BECFD70286D /* STDSCompletionEvent.h in Headers */ = {isa = PBXBuildFile; fileRef = 10DB3CD8F2541AC06681F2C8 /* STDSCompletionEvent.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 8E833B7FCBC3A99072582FEA /* UIFont+DefaultFonts.h in Headers */ = {isa = PBXBuildFile; fileRef = 1E844798B2733C8511AC080E /* UIFont+DefaultFonts.h */; }; + 9146B2E3B89EF3F489D7E233 /* STDSIPAddress.m in Sources */ = {isa = PBXBuildFile; fileRef = CB00E968CF4DD07FB8BF2AAA /* STDSIPAddress.m */; }; + 955CBE0C979291F89567D8A7 /* STDSJSONWebEncryption.m in Sources */ = {isa = PBXBuildFile; fileRef = 99392D3F702EC6BF7CE15291 /* STDSJSONWebEncryption.m */; }; + 95A2D58051AADDBE16CCA0B6 /* STDSChallengeResponseImage.h in Headers */ = {isa = PBXBuildFile; fileRef = 10C45EE3433B731253E21E24 /* STDSChallengeResponseImage.h */; }; + 9673946B78A7763070E05CD0 /* STDSAuthenticationRequestParameters.h in Headers */ = {isa = PBXBuildFile; fileRef = C91B239583899192C7B26FCF /* STDSAuthenticationRequestParameters.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 97CE8B57A97D037E977E62F3 /* STDSBundleLocator.h in Headers */ = {isa = PBXBuildFile; fileRef = A137A31BEFCCB646FB754EE2 /* STDSBundleLocator.h */; }; + 98075195759ABB91B0D19847 /* STDSJSONWebEncryptionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 97505E93BE83F233E69BB5A9 /* STDSJSONWebEncryptionTests.m */; }; + 9843F6AABF7B0A623E7DF979 /* STDSDeviceInformationManager.h in Headers */ = {isa = PBXBuildFile; fileRef = 7A637DA708BC866C2903E0EA /* STDSDeviceInformationManager.h */; }; + 987347CA74818CEB716B9DB3 /* UIFont+DefaultFonts.m in Sources */ = {isa = PBXBuildFile; fileRef = 634095EA97A4E48BF7CAA03F /* UIFont+DefaultFonts.m */; }; + 9B32E9A4CD2BDA61C730F5EB /* STDSJSONDecodable.h in Headers */ = {isa = PBXBuildFile; fileRef = B219360BFB325779485BF702 /* STDSJSONDecodable.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 9B7081D378D1441B98B81207 /* STDSTransactionTest.m in Sources */ = {isa = PBXBuildFile; fileRef = A4A3CCBE9A2E8C1A73D8214E /* STDSTransactionTest.m */; }; + 9C225B9C556C20D1A6F97180 /* STDSChallengeResponseSelectionInfoObject.h in Headers */ = {isa = PBXBuildFile; fileRef = 4CCA15E0819F9A9D89FEF9EA /* STDSChallengeResponseSelectionInfoObject.h */; }; + 9DC5612697835A383DC6606E /* STDSTransaction.h in Headers */ = {isa = PBXBuildFile; fileRef = F40CC91AA69F5296B0AA985C /* STDSTransaction.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 9E46777893FA2D6691F496D7 /* STDSChallengeResponseViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = E45303DCFFDC390971E6E122 /* STDSChallengeResponseViewController.h */; }; + 9ED9C602C10682CD5DD91C12 /* NSLayoutConstraint+LayoutSupport.m in Sources */ = {isa = PBXBuildFile; fileRef = F99B290CF2B4987A4CFE5DCE /* NSLayoutConstraint+LayoutSupport.m */; }; + 9F0F6085FBD892BF5F14CF86 /* STDSWarning.h in Headers */ = {isa = PBXBuildFile; fileRef = 0BEB5006261C5E070FEE62BF /* STDSWarning.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 9F52F67A1A3A2199AEA598FC /* STDSEllipticCurvePointTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 0DA1EE83D91993BCAF13EC72 /* STDSEllipticCurvePointTests.m */; }; + 9F9BB9E7FA18FEDA44F7042E /* STDSDirectoryServerCertificate.m in Sources */ = {isa = PBXBuildFile; fileRef = 9754FA4F3CC6AA921787916B /* STDSDirectoryServerCertificate.m */; }; + A026717FFF299A7C1C51565F /* UIView+LayoutSupport.h in Headers */ = {isa = PBXBuildFile; fileRef = 99CB2B66DBD356B5D222EDA9 /* UIView+LayoutSupport.h */; }; + A33A549CF12209280E3B1DBA /* STDSAuthenticationRequestParametersTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 969DE2FA3845BB7939D3CD2E /* STDSAuthenticationRequestParametersTest.m */; }; + A39CFB56BF33B21A89987F9C /* STDSChallengeResponseObject+TestObjects.m in Sources */ = {isa = PBXBuildFile; fileRef = 99DB24A9B44979EA19347A76 /* STDSChallengeResponseObject+TestObjects.m */; }; + A4CFDBFC3099BD7E1A694E3E /* UIButton+CustomInitialization.m in Sources */ = {isa = PBXBuildFile; fileRef = AA29B74DD3963B1205AA1C0C /* UIButton+CustomInitialization.m */; }; + A5193DADCD0F48911F775D88 /* NSString+JWEHelpers.h in Headers */ = {isa = PBXBuildFile; fileRef = 018F4AA25D84E9CBD2546BB5 /* NSString+JWEHelpers.h */; }; + A56AE2CD941D7ADAC7FC8BCC /* STDSBase64URLEncodingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 4B1E3760B1369DB1A33C9DFE /* STDSBase64URLEncodingTests.m */; }; + A6909D6C1A68963504733939 /* STDSDebuggerChecker.m in Sources */ = {isa = PBXBuildFile; fileRef = C29F78CC37253B0D33FF86F2 /* STDSDebuggerChecker.m */; }; + A78F19DA3D146A258FB9DE3C /* STDSACSNetworkingManager.h in Headers */ = {isa = PBXBuildFile; fileRef = D827473D1858B2BED85BDFA1 /* STDSACSNetworkingManager.h */; }; + A8A86B9BCE9702D74E0D2DB6 /* Stripe3DS2.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE4030C50383B488C97F4B56 /* Stripe3DS2.framework */; }; + A8CAD8276ED8057A760147CB /* NSData+JWEHelpers.m in Sources */ = {isa = PBXBuildFile; fileRef = 1FC15225A5E83948EF546B71 /* NSData+JWEHelpers.m */; }; + A97E1DF84D381952A0FB4C1C /* amex.der in Resources */ = {isa = PBXBuildFile; fileRef = D8B7EE0F935EF44EB2EF7D45 /* amex.der */; }; + AA4D157F31A246890D446981 /* STDSChallengeResponseSelectionInfoObject.m in Sources */ = {isa = PBXBuildFile; fileRef = F046DE266BAAE3DA0AC43785 /* STDSChallengeResponseSelectionInfoObject.m */; }; + AA94519687902E34E5243AEA /* STDSSpacerView.h in Headers */ = {isa = PBXBuildFile; fileRef = 72DBE992B33F45C059EDB597 /* STDSSpacerView.h */; }; + AC474013841CE1DED97F3FA8 /* STDSSimulatorChecker.h in Headers */ = {isa = PBXBuildFile; fileRef = A0ADFB365AD2DE9E84873BB1 /* STDSSimulatorChecker.h */; }; + AE1894FEA510AC394DE73C4B /* STDSTransaction+Private.h in Headers */ = {isa = PBXBuildFile; fileRef = 8A9F5C315222AFCD14C9342F /* STDSTransaction+Private.h */; }; + B012C4A8ACA1D064C40A1B63 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C22D410C297E2C3AFE727A6 /* main.m */; }; + B1C2AA6259CE34B0042A1FB1 /* STDSDeviceInformationManagerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A4050F381183D6FDCC990D01 /* STDSDeviceInformationManagerTests.m */; }; + B31168F5FEC3464AC212DB85 /* STDSACSNetworkingManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 2D6004F2D9880055C0C0BCF1 /* STDSACSNetworkingManager.m */; }; + B426123CC7FCA337E13304F9 /* NSDictionary+DecodingHelpersTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 5EE8BF51F49E161737406B3A /* NSDictionary+DecodingHelpersTest.m */; }; + B51D1C218F460F67B58A1F0D /* STDSProtocolErrorEvent.m in Sources */ = {isa = PBXBuildFile; fileRef = 3255706D2304FB551C97305F /* STDSProtocolErrorEvent.m */; }; + B84ED7FBD49865F23B774067 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 8B59CC3349FD9BBF37624667 /* AppDelegate.m */; }; + B94E5A5BF5F22998E4CA9ED3 /* STDSEphemeralKeyPair.h in Headers */ = {isa = PBXBuildFile; fileRef = 20533E2C69C4B80BB33A4766 /* STDSEphemeralKeyPair.h */; }; + B9A456E52E62E9DAE9F424A7 /* STDSChallengeResponseMessageExtensionObject.m in Sources */ = {isa = PBXBuildFile; fileRef = 7D02FAFC403BC901931A629F /* STDSChallengeResponseMessageExtensionObject.m */; }; + BA00D11D792C99B7D5749F59 /* STDSJailbreakChecker.m in Sources */ = {isa = PBXBuildFile; fileRef = 1BFBB92DBE97E16DE9D18B4A /* STDSJailbreakChecker.m */; }; + BA65731CDB75C3124834586C /* STDSSynchronousLocationManagerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F02456AB660E709F332C0C7D /* STDSSynchronousLocationManagerTests.m */; }; + BB379E68231A7DA62F87E628 /* STDSNavigationBarCustomization.m in Sources */ = {isa = PBXBuildFile; fileRef = E9F1A5A8E5922748A9BEC724 /* STDSNavigationBarCustomization.m */; }; + BF06B99FECD98C26F7B64665 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 08D577934984712C20C5E903 /* XCTest.framework */; }; + BF2651A9F3A31CB1AFEC44BB /* STDSCustomization.m in Sources */ = {isa = PBXBuildFile; fileRef = A8E62EC973FC5C9905A40CA8 /* STDSCustomization.m */; }; + BF342613EE89845EB1C4B72A /* STDSChallengeRequestParameters.h in Headers */ = {isa = PBXBuildFile; fileRef = 762DEC6662DF8240FE19CFFC /* STDSChallengeRequestParameters.h */; }; + BFC5852E14724750E70DA2A6 /* STDSEphemeralKeyPairTests.m in Sources */ = {isa = PBXBuildFile; fileRef = BF80634E06E8D6F598CFC667 /* STDSEphemeralKeyPairTests.m */; }; + C2BA2E6ABDD8E80963FFC671 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 08D577934984712C20C5E903 /* XCTest.framework */; }; + C430332104547BA508416B41 /* STDSButtonCustomization.m in Sources */ = {isa = PBXBuildFile; fileRef = 5489DCAA65624C6F966816B6 /* STDSButtonCustomization.m */; }; + C4BA75544A7F51355BF4B5F8 /* STDSDirectoryServer.h in Headers */ = {isa = PBXBuildFile; fileRef = 6AA43E7E90E4002DD56B7C3D /* STDSDirectoryServer.h */; }; + CA617B6F5CDFB8FDEEE38636 /* STDSJSONWebSignature.h in Headers */ = {isa = PBXBuildFile; fileRef = 3AA2E6DA60350400C5270E08 /* STDSJSONWebSignature.h */; }; + CCC376FE7424029342A8D15E /* STDSChallengeResponseViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = B064352BB2876CA7266C410A /* STDSChallengeResponseViewController.m */; }; + CEA1EB91858043A837638FE0 /* STDSSecTypeUtilities.h in Headers */ = {isa = PBXBuildFile; fileRef = C8F0E11AA6794BFB1715685E /* STDSSecTypeUtilities.h */; }; + D1427392DA8AD8F5B4118781 /* STDSACSNetworkingManagerTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 6118F98576E9A39A7292C6C6 /* STDSACSNetworkingManagerTest.m */; }; + D189220981C7705913D93687 /* STDSJSONEncoderTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 6B9B49A5CF64DCB23CD0AEDE /* STDSJSONEncoderTest.m */; }; + D3432C5BF5211A60D5C15D9E /* STDSIntegrityChecker.h in Headers */ = {isa = PBXBuildFile; fileRef = C02DF839637771684BC57B3A /* STDSIntegrityChecker.h */; }; + D3AA14D1E059E90F5A965CC4 /* STDSSelectionCustomization.m in Sources */ = {isa = PBXBuildFile; fileRef = 43540AB7D13C0C9A563C3F4D /* STDSSelectionCustomization.m */; }; + D41F091BE1579B801D1BBC20 /* STDSSelectionButton.m in Sources */ = {isa = PBXBuildFile; fileRef = B458FCA7DFDBF75E62A43BA1 /* STDSSelectionButton.m */; }; + D5706ACF84F0A993CE1885D4 /* STDSTextChallengeView.m in Sources */ = {isa = PBXBuildFile; fileRef = 04E6B6D3CAE363114723472F /* STDSTextChallengeView.m */; }; + D63E3DDC92DD46062A265D86 /* STDSChallengeParametersTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 89ED2133B61092FE1B478204 /* STDSChallengeParametersTests.m */; }; + D819C341ECEE1EFE6FE66085 /* STDSChallengeResponseImageObject.h in Headers */ = {isa = PBXBuildFile; fileRef = 14F83E51E99D453DF9C2DDE1 /* STDSChallengeResponseImageObject.h */; }; + D83D2FF20387FACC383A2A0F /* STDSAlreadyInitializedException.m in Sources */ = {isa = PBXBuildFile; fileRef = 27AC486DA2A80A43C1F517B2 /* STDSAlreadyInitializedException.m */; }; + D927789F89B413C08DE4D388 /* STDSThreeDS2ServiceTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 7FF8ACC073E814B7DEBA7647 /* STDSThreeDS2ServiceTests.m */; }; + D9F8BA88953A91DC082FA6DA /* STDSSelectionButton.h in Headers */ = {isa = PBXBuildFile; fileRef = C4777F15603AC755362BD846 /* STDSSelectionButton.h */; }; + DB96817598FCE73F5131437E /* STDSDirectoryServerCertificate+Internal.h in Headers */ = {isa = PBXBuildFile; fileRef = B015B937DD3657D62C61CE58 /* STDSDirectoryServerCertificate+Internal.h */; }; + DBDB8B56FFF5C4234705E698 /* STDSSecTypeUtilities.m in Sources */ = {isa = PBXBuildFile; fileRef = CF3DED43BD4E0226BFB0759D /* STDSSecTypeUtilities.m */; }; + DC0D9A57F2E312663C088FB8 /* STDSChallengeParameters.h in Headers */ = {isa = PBXBuildFile; fileRef = D03B98D5861739833B197D8F /* STDSChallengeParameters.h */; settings = {ATTRIBUTES = (Public, ); }; }; + DC32281BF0847A98B100C3A1 /* STDSConfigParameters.h in Headers */ = {isa = PBXBuildFile; fileRef = A1BFB8640032796CDA016765 /* STDSConfigParameters.h */; settings = {ATTRIBUTES = (Public, ); }; }; + DDB98914E1135E8D445545C8 /* STDSIPAddress.h in Headers */ = {isa = PBXBuildFile; fileRef = A3DFB001DB5BA7F1AFA7353A /* STDSIPAddress.h */; }; + DE32EAA8F5C2645652FCFB48 /* STDSSwiftTryCatch.h in Headers */ = {isa = PBXBuildFile; fileRef = E5BC34A661D9080A2EE239F8 /* STDSSwiftTryCatch.h */; settings = {ATTRIBUTES = (Public, ); }; }; + DEEF260365A4D9B0D83E557D /* NSString+EmptyChecking.m in Sources */ = {isa = PBXBuildFile; fileRef = D8D309E4FBE7E73D0BB83BF5 /* NSString+EmptyChecking.m */; }; + DF4D38DC0AF89EAAA8718AAE /* STDSUICustomization.m in Sources */ = {isa = PBXBuildFile; fileRef = A55B2474CD1A39D70869B75B /* STDSUICustomization.m */; }; + E0644CE0260178023E643B23 /* STDSSynchronousLocationManager.m in Sources */ = {isa = PBXBuildFile; fileRef = A5DBDDEB5C5DAE2EA8C95CC3 /* STDSSynchronousLocationManager.m */; }; + E08AF96E690D75F6AB52021F /* STDSWarning.m in Sources */ = {isa = PBXBuildFile; fileRef = A502CCC5190F6B642EEC0CE6 /* STDSWarning.m */; }; + E19C8104FC1A9BAEA0BE8A9D /* UIColor+ThirteenSupport.m in Sources */ = {isa = PBXBuildFile; fileRef = 37ED28756222539D37554CC7 /* UIColor+ThirteenSupport.m */; }; + E4DA2CE8DC8C60DE40222185 /* STDSDirectoryServerCertificateTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 6AA75957DD13FA92FA866AF3 /* STDSDirectoryServerCertificateTests.m */; }; + E630DCBBEFAAD0435BEF989C /* STDSDeviceInformation.m in Sources */ = {isa = PBXBuildFile; fileRef = 56B1A52548593FE78D801734 /* STDSDeviceInformation.m */; }; + E70CFDC59731BA62DD89906C /* STDSWhitelistView.m in Sources */ = {isa = PBXBuildFile; fileRef = 615F1BD510661B38D6825128 /* STDSWhitelistView.m */; }; + E73028FD4537F6E48F927127 /* NSString+EmptyCheckingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 57B8822D21AADE7DA51931CC /* NSString+EmptyCheckingTests.m */; }; + E7A72BB7CC8DE8DA7FA0DEC5 /* ErrorMessage.json in Resources */ = {isa = PBXBuildFile; fileRef = E12798864D5CFFB7157D4CF5 /* ErrorMessage.json */; }; + E7E424C5264A4DE4A1EC19B6 /* STDSChallengeSelectionView.h in Headers */ = {isa = PBXBuildFile; fileRef = EB910E98EDD6D3E00802793C /* STDSChallengeSelectionView.h */; }; + E90749131EDA0C86BBBFCE5C /* STDSChallengeResponseObject.h in Headers */ = {isa = PBXBuildFile; fileRef = EB1BDF12DBD6264C6199FFA7 /* STDSChallengeResponseObject.h */; }; + EA19A7AEC298F3EC010D1199 /* STDSTestJSONUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 5FF3235EDD908CC1500399A1 /* STDSTestJSONUtils.m */; }; + EA89021709AFB5F04961EB78 /* cartes-bancaires.der in Resources */ = {isa = PBXBuildFile; fileRef = 83838563F29179FB1E9F2E66 /* cartes-bancaires.der */; }; + EB791827A943AA61976D8C29 /* STDSIntegrityChecker.m in Sources */ = {isa = PBXBuildFile; fileRef = BB065D6900E95C5E90864C16 /* STDSIntegrityChecker.m */; }; + EC51189277D7D68D08C2A65C /* STDSButtonCustomization.h in Headers */ = {isa = PBXBuildFile; fileRef = BD94CAE01A17E083E5617E56 /* STDSButtonCustomization.h */; settings = {ATTRIBUTES = (Public, ); }; }; + ECF8755D2602E0E06DCB3210 /* STDSChallengeResponseObjectTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 5E401845EE5759CCD30786AA /* STDSChallengeResponseObjectTest.m */; }; + EE3854BC2A14F87E8D51B0D0 /* STDSInvalidInputException.m in Sources */ = {isa = PBXBuildFile; fileRef = 6FDAC320458399141EB161E2 /* STDSInvalidInputException.m */; }; + EEE77034728D2CFE38CC5A85 /* STDSLabelCustomization.m in Sources */ = {isa = PBXBuildFile; fileRef = 01D320CD5ADA49F17B8BC1B0 /* STDSLabelCustomization.m */; }; + EFC72E766F9FD4EE18D309BC /* STDSJSONWebSignature.m in Sources */ = {isa = PBXBuildFile; fileRef = A1DC6B47C06B522B22EB19FF /* STDSJSONWebSignature.m */; }; + F04482C663F6DF8E522B0833 /* STDSCustomization.h in Headers */ = {isa = PBXBuildFile; fileRef = AFF187D1D58405C736474042 /* STDSCustomization.h */; settings = {ATTRIBUTES = (Public, ); }; }; + F0B9EFD359B1AB28A695CB48 /* STDSSynchronousLocationManager.h in Headers */ = {isa = PBXBuildFile; fileRef = C12AE31D386D4A193C00004B /* STDSSynchronousLocationManager.h */; }; + F13A032A4AF15BB61EA18A8D /* STDSChallengeInformationView.h in Headers */ = {isa = PBXBuildFile; fileRef = C893FD867C964775E68AE85F /* STDSChallengeInformationView.h */; }; + F28AD6CA8AB1A5DEE7A8F429 /* STDSDebuggerChecker.h in Headers */ = {isa = PBXBuildFile; fileRef = D122FAE093BC4F649CC6E7AB /* STDSDebuggerChecker.h */; }; + F35963DB86D90320CFA7BCB9 /* STDSExpandableInformationView.h in Headers */ = {isa = PBXBuildFile; fileRef = 939C45AE068CF4F5BEB4C138 /* STDSExpandableInformationView.h */; }; + F4E8353A470750D9BCEA3CD8 /* CRes.json in Resources */ = {isa = PBXBuildFile; fileRef = 8C6D262891811FB3FE0C947E /* CRes.json */; }; + F5513045A25210C524A05DF5 /* STDSSelectionCustomization.h in Headers */ = {isa = PBXBuildFile; fileRef = 7CD753D9BBFC378C1B491B00 /* STDSSelectionCustomization.h */; settings = {ATTRIBUTES = (Public, ); }; }; + F569B584D357DC5C55542015 /* STDSFooterCustomization.m in Sources */ = {isa = PBXBuildFile; fileRef = 6CFF141AC98F213122D037C6 /* STDSFooterCustomization.m */; }; + F939E05536AE838DC489C3AA /* STDSChallengeResponseViewControllerSnapshotTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 466DF80D33CEC20DF4E844A9 /* STDSChallengeResponseViewControllerSnapshotTests.m */; }; + FD6456BD86F07C446B9FB6C8 /* ec_test.der in Resources */ = {isa = PBXBuildFile; fileRef = 3E4C02D9D6978AD5C3D7E3D2 /* ec_test.der */; }; + FD8A81873C3BC7579ED07460 /* STDSCompletionEvent.m in Sources */ = {isa = PBXBuildFile; fileRef = 3B6B71C61CF5ADC7796441F5 /* STDSCompletionEvent.m */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 35B6793A7F3FB130F40209F1 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8D219187B704575AD3CD3EC5 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5907C55B1F111921112DF2BF; + remoteInfo = Stripe3DS2DemoUI; + }; + 52E1EB51359DA6E75D851D71 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8D219187B704575AD3CD3EC5 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 57AEC53510AE0DC0539730F3; + remoteInfo = Stripe3DS2; + }; + 6C355B127D670833C76237D3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8D219187B704575AD3CD3EC5 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 57AEC53510AE0DC0539730F3; + remoteInfo = Stripe3DS2; + }; + 947ED275EAB25AD4CD701936 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8D219187B704575AD3CD3EC5 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 57AEC53510AE0DC0539730F3; + remoteInfo = Stripe3DS2; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 00E415680C602ED1D6D8E71F /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 4D79F0CFC54E8B923E9238CF /* Stripe3DS2.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + 43B749EC915FB6D2FF5ABEE0 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + 58FA2FE74AE67CD3CDAFD7E5 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + 726DD7176204D90BD7552B84 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 00658E077ECB223C90484AF7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 018F4AA25D84E9CBD2546BB5 /* NSString+JWEHelpers.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSString+JWEHelpers.h"; sourceTree = ""; }; + 01D320CD5ADA49F17B8BC1B0 /* STDSLabelCustomization.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSLabelCustomization.m; sourceTree = ""; }; + 02D762271DBCC1AE80E0F9D4 /* Stripe3DS2DemoUI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Stripe3DS2DemoUI.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 037C294011A01E4B4DCE5BB7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 04AB48F014A8D5BA56FEF463 /* discover.der */ = {isa = PBXFileReference; path = discover.der; sourceTree = ""; }; + 04E6B6D3CAE363114723472F /* STDSTextChallengeView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSTextChallengeView.m; sourceTree = ""; }; + 0681C043F732148587737233 /* STDSJSONEncoder.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSJSONEncoder.m; sourceTree = ""; }; + 069990DA025BE9F33602E96A /* STDSDirectoryServerCertificate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSDirectoryServerCertificate.h; sourceTree = ""; }; + 06C5207432FB07BD71703BCC /* STDSJSONEncoder.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSJSONEncoder.h; sourceTree = ""; }; + 08D577934984712C20C5E903 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; + 0BEB5006261C5E070FEE62BF /* STDSWarning.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSWarning.h; sourceTree = ""; }; + 0DA1EE83D91993BCAF13EC72 /* STDSEllipticCurvePointTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSEllipticCurvePointTests.m; sourceTree = ""; }; + 0E38A775DB9F529427E900CF /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/Localizable.strings; sourceTree = ""; }; + 0E3C05BE55CF3086738AA182 /* Stripe3DS2DemoUITests-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Stripe3DS2DemoUITests-Debug.xcconfig"; sourceTree = ""; }; + 0F024DEAD6628A0229856656 /* Stripe3DS2-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Stripe3DS2-Bridging-Header.h"; sourceTree = ""; }; + 10C45EE3433B731253E21E24 /* STDSChallengeResponseImage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSChallengeResponseImage.h; sourceTree = ""; }; + 10DB3CD8F2541AC06681F2C8 /* STDSCompletionEvent.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSCompletionEvent.h; sourceTree = ""; }; + 1104D5855D28E49E104CA004 /* mastercard.der */ = {isa = PBXFileReference; path = mastercard.der; sourceTree = ""; }; + 138E82072FC6572612A8E248 /* STDSAuthenticationResponseObject.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSAuthenticationResponseObject.h; sourceTree = ""; }; + 13ED1CC076C6B3FB49C9197A /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; + 14D60BDF245487FE3362BE07 /* STDSSimulatorChecker.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSSimulatorChecker.m; sourceTree = ""; }; + 14F83E51E99D453DF9C2DDE1 /* STDSChallengeResponseImageObject.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSChallengeResponseImageObject.h; sourceTree = ""; }; + 1501A7A3010FFE53D00E318F /* STDSRuntimeErrorEvent.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSRuntimeErrorEvent.m; sourceTree = ""; }; + 157970FF5B4A817041E3D668 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; + 172AF5C85EDBD92A1030E361 /* STDSDeviceInformationParameter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSDeviceInformationParameter.h; sourceTree = ""; }; + 18D88DE3E34106F934015BF9 /* mt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mt; path = mt.lproj/Localizable.strings; sourceTree = ""; }; + 1BBADD412A7C2D1FF35A7C86 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + 1BFBB92DBE97E16DE9D18B4A /* STDSJailbreakChecker.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSJailbreakChecker.m; sourceTree = ""; }; + 1D6269F8B91341C387A911DB /* STDSStackView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSStackView.m; sourceTree = ""; }; + 1E844798B2733C8511AC080E /* UIFont+DefaultFonts.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIFont+DefaultFonts.h"; sourceTree = ""; }; + 1F0DADEABA0B043A6CA36DD6 /* STDSDemoViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSDemoViewController.h; sourceTree = ""; }; + 1F1FD11407319293954C6D81 /* STDSAlreadyInitializedException.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSAlreadyInitializedException.h; sourceTree = ""; }; + 1FBE88F6E2C3153A8315EDC6 /* STDSDeviceInformation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSDeviceInformation.h; sourceTree = ""; }; + 1FC15225A5E83948EF546B71 /* NSData+JWEHelpers.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSData+JWEHelpers.m"; sourceTree = ""; }; + 20533E2C69C4B80BB33A4766 /* STDSEphemeralKeyPair.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSEphemeralKeyPair.h; sourceTree = ""; }; + 210B22FF4DCB0C6E7C763EAB /* STDSUICustomization.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSUICustomization.h; sourceTree = ""; }; + 2192708AD201F80F6E1A39F7 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; + 237977B021FE538E5E4FA35C /* ARes.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ARes.json; sourceTree = ""; }; + 23A3DDCE911F8C43CF74CDF4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 23C929BF760735CC01E1E8F5 /* STDSChallengeParameters.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSChallengeParameters.m; sourceTree = ""; }; + 24CE086326AF3DE336BF4F4C /* STDSChallengeResponseObject.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSChallengeResponseObject.m; sourceTree = ""; }; + 25C7D18868DDADEF4CB6220C /* STDSDeviceInformationParameter.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSDeviceInformationParameter.m; sourceTree = ""; }; + 26C20D4B77372845627D6466 /* NSError+Stripe3DS2.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSError+Stripe3DS2.m"; sourceTree = ""; }; + 278F2C40987F5218BD031E3D /* STDSErrorMessageTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSErrorMessageTest.m; sourceTree = ""; }; + 27AC486DA2A80A43C1F517B2 /* STDSAlreadyInitializedException.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSAlreadyInitializedException.m; sourceTree = ""; }; + 28C6B4F6BA431B9342970FD6 /* Stripe3DS2.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Stripe3DS2.h; sourceTree = ""; }; + 28E32DA72CAFBF2AF8099151 /* STDSAuthenticationResponseTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSAuthenticationResponseTests.m; sourceTree = ""; }; + 2C2666DF7D23EBC3FFE7CCF1 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; + 2CB591796654445B8434A501 /* STDSErrorMessage+Internal.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "STDSErrorMessage+Internal.m"; sourceTree = ""; }; + 2CFAC8D74AFFC2E61BAD82A5 /* STDSBrandingView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSBrandingView.h; sourceTree = ""; }; + 2D6004F2D9880055C0C0BCF1 /* STDSACSNetworkingManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSACSNetworkingManager.m; sourceTree = ""; }; + 2EF5AA537586EF706E4056FD /* STDSEllipticCurvePoint.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSEllipticCurvePoint.m; sourceTree = ""; }; + 2FCABBF51CBA7F9FEF757B4C /* STDSNotInitializedException.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSNotInitializedException.h; sourceTree = ""; }; + 30D22985091381DFCDF077E9 /* STDSSecTypeUtilitiesTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSSecTypeUtilitiesTests.m; sourceTree = ""; }; + 315AEE1534F9D9974DC1674B /* STDSChallengeResponse.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSChallengeResponse.h; sourceTree = ""; }; + 3255706D2304FB551C97305F /* STDSProtocolErrorEvent.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSProtocolErrorEvent.m; sourceTree = ""; }; + 33CEADC8006E456FD82CE134 /* STDSWhitelistView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSWhitelistView.h; sourceTree = ""; }; + 371F8076630A8839C1F6A508 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; + 37ED28756222539D37554CC7 /* UIColor+ThirteenSupport.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIColor+ThirteenSupport.m"; sourceTree = ""; }; + 382C583385FAECA91366769E /* STDSDemoViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSDemoViewController.m; sourceTree = ""; }; + 38572D4E32BF3670F1C83409 /* lv-LV */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "lv-LV"; path = "lv-LV.lproj/Localizable.strings"; sourceTree = ""; }; + 38F3A7D625E4B0D3506A37F6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; + 3A6D20D1DAB7D769E5E97528 /* STDSProtocolErrorEvent.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSProtocolErrorEvent.h; sourceTree = ""; }; + 3A8CD68A006F970DDC54469A /* Project-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Debug.xcconfig"; sourceTree = ""; }; + 3A9F828840A3B361842E38DE /* STDSChallengeResponseImageObject.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSChallengeResponseImageObject.m; sourceTree = ""; }; + 3AA2E6DA60350400C5270E08 /* STDSJSONWebSignature.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSJSONWebSignature.h; sourceTree = ""; }; + 3B6B71C61CF5ADC7796441F5 /* STDSCompletionEvent.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSCompletionEvent.m; sourceTree = ""; }; + 3B9A821F35B93898A7AC1ADF /* sl-SI */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sl-SI"; path = "sl-SI.lproj/Localizable.strings"; sourceTree = ""; }; + 3C22D410C297E2C3AFE727A6 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; + 3E4C02D9D6978AD5C3D7E3D2 /* ec_test.der */ = {isa = PBXFileReference; path = ec_test.der; sourceTree = ""; }; + 3F017BF6ECE0EBE4E0DFCF9C /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; + 406D88ADED27D64DED2002A2 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/Localizable.strings"; sourceTree = ""; }; + 40DC4913154C3D3A27E35207 /* ca-ES */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ca-ES"; path = "ca-ES.lproj/Localizable.strings"; sourceTree = ""; }; + 41E0F638E776A7F3456BDA3E /* STDSLocalizedString.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSLocalizedString.h; sourceTree = ""; }; + 43540AB7D13C0C9A563C3F4D /* STDSSelectionCustomization.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSSelectionCustomization.m; sourceTree = ""; }; + 43CB8F416E4F4CEA34391481 /* NSString+JWEHelpers.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSString+JWEHelpers.m"; sourceTree = ""; }; + 461F938CDE7ED6D06D9D2700 /* STDSException+Internal.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "STDSException+Internal.h"; sourceTree = ""; }; + 461FDC130846625171164A9E /* et-EE */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "et-EE"; path = "et-EE.lproj/Localizable.strings"; sourceTree = ""; }; + 466DF80D33CEC20DF4E844A9 /* STDSChallengeResponseViewControllerSnapshotTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSChallengeResponseViewControllerSnapshotTests.m; sourceTree = ""; }; + 474F39AA3F47D84F8D54E5B7 /* STDSWarningTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSWarningTests.m; sourceTree = ""; }; + 47C29E9F3D2A62676B424CD1 /* STDSThreeDSProtocolVersion+Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "STDSThreeDSProtocolVersion+Private.h"; sourceTree = ""; }; + 4869D6E14F01EDB960CB3065 /* STDSDeviceInformationParameter+Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "STDSDeviceInformationParameter+Private.h"; sourceTree = ""; }; + 49EF79D69693F937FEC46906 /* fil */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fil; path = fil.lproj/Localizable.strings; sourceTree = ""; }; + 4A567E08748C86FCE2A75FEA /* UIColor+DefaultColors.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIColor+DefaultColors.h"; sourceTree = ""; }; + 4B1E3760B1369DB1A33C9DFE /* STDSBase64URLEncodingTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSBase64URLEncodingTests.m; sourceTree = ""; }; + 4BC4D9FD1D3869D491CC2CF8 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; + 4C158B552B0CBE635A2AF7BB /* el-GR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "el-GR"; path = "el-GR.lproj/Localizable.strings"; sourceTree = ""; }; + 4CCA15E0819F9A9D89FEF9EA /* STDSChallengeResponseSelectionInfoObject.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSChallengeResponseSelectionInfoObject.h; sourceTree = ""; }; + 4DB91F58CF23E72636C69C41 /* Project-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Release.xcconfig"; sourceTree = ""; }; + 4DD55BECC3FB85216FE46218 /* UIButton+CustomInitialization.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIButton+CustomInitialization.h"; sourceTree = ""; }; + 4EA4AB3BA2FB41B3D5F38978 /* STDSAuthenticationResponse.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSAuthenticationResponse.h; sourceTree = ""; }; + 4EAD29E57D206B5D6BAF9099 /* STDSChallengeRequestParametersTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSChallengeRequestParametersTest.m; sourceTree = ""; }; + 4EB746D4E46ABD2452A154AE /* Stripe3DS2Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Stripe3DS2Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 5043E1CF6955176EC3D43F88 /* STDSAuthenticationResponseObject.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSAuthenticationResponseObject.m; sourceTree = ""; }; + 5049A72A6BEB558D8559A5EB /* STDSEllipticCurvePoint.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSEllipticCurvePoint.h; sourceTree = ""; }; + 51E4A57094A671EB14380882 /* STDSSpacerView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSSpacerView.m; sourceTree = ""; }; + 52D6E4F76CBA258163E57DF3 /* STDSEphemeralKeyPair+Testing.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "STDSEphemeralKeyPair+Testing.h"; sourceTree = ""; }; + 52EEBC7D74623E1A1D7E3219 /* STDSChallengeResponseSelectionInfo.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSChallengeResponseSelectionInfo.h; sourceTree = ""; }; + 5316D0759F7F2ED0BF1D3B2E /* Stripe3DS2DemoUI-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Stripe3DS2DemoUI-Debug.xcconfig"; sourceTree = ""; }; + 5463EB1CC8E85BE3BD12DEFF /* STDSErrorMessage+Internal.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "STDSErrorMessage+Internal.h"; sourceTree = ""; }; + 5489DCAA65624C6F966816B6 /* STDSButtonCustomization.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSButtonCustomization.m; sourceTree = ""; }; + 56B1A52548593FE78D801734 /* STDSDeviceInformation.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSDeviceInformation.m; sourceTree = ""; }; + 57B8822D21AADE7DA51931CC /* NSString+EmptyCheckingTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSString+EmptyCheckingTests.m"; sourceTree = ""; }; + 581EEE576D24047004C828DF /* STDSWebView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSWebView.h; sourceTree = ""; }; + 58C38FF9205C9B7D79C51A37 /* sk-SK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sk-SK"; path = "sk-SK.lproj/Localizable.strings"; sourceTree = ""; }; + 5B05A609753EE71502409082 /* NSData+JWEHelpers.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSData+JWEHelpers.h"; sourceTree = ""; }; + 5D93713CADD5589B5E6F6A0D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 5E401845EE5759CCD30786AA /* STDSChallengeResponseObjectTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSChallengeResponseObjectTest.m; sourceTree = ""; }; + 5E6A9565E97DF2544254AA94 /* STDSThreeDSProtocolVersion.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSThreeDSProtocolVersion.h; sourceTree = ""; }; + 5EAA444FA7C82BDA4AD907BF /* STDSThreeDS2Service.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSThreeDS2Service.h; sourceTree = ""; }; + 5EE8BF51F49E161737406B3A /* NSDictionary+DecodingHelpersTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSDictionary+DecodingHelpersTest.m"; sourceTree = ""; }; + 5F551808E65CDADD9B3A9CB8 /* STDSConfigParametersTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSConfigParametersTests.m; sourceTree = ""; }; + 5FDE2481364C454058BF2377 /* STDSTransaction.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSTransaction.m; sourceTree = ""; }; + 5FF3235EDD908CC1500399A1 /* STDSTestJSONUtils.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSTestJSONUtils.m; sourceTree = ""; }; + 60693BC560C4927ACFD2E6C3 /* STDSChallengeRequestParameters.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSChallengeRequestParameters.m; sourceTree = ""; }; + 6118F98576E9A39A7292C6C6 /* STDSACSNetworkingManagerTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSACSNetworkingManagerTest.m; sourceTree = ""; }; + 615F1BD510661B38D6825128 /* STDSWhitelistView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSWhitelistView.m; sourceTree = ""; }; + 62773D5C85C567F28BCE0DA5 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; + 634095EA97A4E48BF7CAA03F /* UIFont+DefaultFonts.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIFont+DefaultFonts.m"; sourceTree = ""; }; + 645D0BB927B7F8A0D37EE04D /* STDSErrorMessage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSErrorMessage.h; sourceTree = ""; }; + 6690476C1BEA59C37576FB36 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; + 68ED2A0E11E1B27980E0C60A /* STDSTextFieldCustomization.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSTextFieldCustomization.h; sourceTree = ""; }; + 6932DD26C7C16938DFA0E198 /* STDSLabelCustomization.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSLabelCustomization.h; sourceTree = ""; }; + 6940D75CA36F7DB9FB56196B /* Stripe3DS2DemoUI-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Stripe3DS2DemoUI-Release.xcconfig"; sourceTree = ""; }; + 694DF6F47767A651907D55D4 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + 6AA43E7E90E4002DD56B7C3D /* STDSDirectoryServer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSDirectoryServer.h; sourceTree = ""; }; + 6AA75957DD13FA92FA866AF3 /* STDSDirectoryServerCertificateTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSDirectoryServerCertificateTests.m; sourceTree = ""; }; + 6B487D8DD39DAC17042A3F04 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; + 6B9B49A5CF64DCB23CD0AEDE /* STDSJSONEncoderTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSJSONEncoderTest.m; sourceTree = ""; }; + 6CFF141AC98F213122D037C6 /* STDSFooterCustomization.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSFooterCustomization.m; sourceTree = ""; }; + 6DDB6222A2EF31FDB7592368 /* STDSErrorMessage.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSErrorMessage.m; sourceTree = ""; }; + 6F030410FDABFF833CD8FE46 /* STDSException.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSException.m; sourceTree = ""; }; + 6FDAC320458399141EB161E2 /* STDSInvalidInputException.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSInvalidInputException.m; sourceTree = ""; }; + 71494F94F36F4B731A27D182 /* lt-LT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "lt-LT"; path = "lt-LT.lproj/Localizable.strings"; sourceTree = ""; }; + 72DBE992B33F45C059EDB597 /* STDSSpacerView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSSpacerView.h; sourceTree = ""; }; + 762DEC6662DF8240FE19CFFC /* STDSChallengeRequestParameters.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSChallengeRequestParameters.h; sourceTree = ""; }; + 76EA9DF8572C992E30BCF8A3 /* STDSJSONWebEncryption.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSJSONWebEncryption.h; sourceTree = ""; }; + 77646CEC7EE754F47EDBDA4F /* STDSThreeDS2Service.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSThreeDS2Service.m; sourceTree = ""; }; + 7859D635964B2854A07B5285 /* cs-CZ */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "cs-CZ"; path = "cs-CZ.lproj/Localizable.strings"; sourceTree = ""; }; + 7971EB56716D5E2F59053B6D /* acs_challenge.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = acs_challenge.html; sourceTree = ""; }; + 7A637DA708BC866C2903E0EA /* STDSDeviceInformationManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSDeviceInformationManager.h; sourceTree = ""; }; + 7AC1C5F6F1311F50D9C37291 /* STDSProcessingView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSProcessingView.m; sourceTree = ""; }; + 7CD753D9BBFC378C1B491B00 /* STDSSelectionCustomization.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSSelectionCustomization.h; sourceTree = ""; }; + 7D02FAFC403BC901931A629F /* STDSChallengeResponseMessageExtensionObject.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSChallengeResponseMessageExtensionObject.m; sourceTree = ""; }; + 7FF8ACC073E814B7DEBA7647 /* STDSThreeDS2ServiceTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSThreeDS2ServiceTests.m; sourceTree = ""; }; + 82C41E6DC2CC247ECC17F2B9 /* STDSTextChallengeView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSTextChallengeView.h; sourceTree = ""; }; + 83838563F29179FB1E9F2E66 /* cartes-bancaires.der */ = {isa = PBXFileReference; path = "cartes-bancaires.der"; sourceTree = ""; }; + 83BDD516620860DC08B36B96 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; + 84967ED0D9B206B3F5AA4F02 /* STDSProgressViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSProgressViewController.m; sourceTree = ""; }; + 853A361AB630A2BD402D1B47 /* STDSInvalidInputException.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSInvalidInputException.h; sourceTree = ""; }; + 869733153BCA6BE2EB7A452F /* UIColor+DefaultColors.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIColor+DefaultColors.m"; sourceTree = ""; }; + 89B5ED899439D1C1054CB448 /* STDSConfigParameters.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSConfigParameters.m; sourceTree = ""; }; + 89ED2133B61092FE1B478204 /* STDSChallengeParametersTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSChallengeParametersTests.m; sourceTree = ""; }; + 8A6035A8211B3D50D10EB947 /* bg-BG */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "bg-BG"; path = "bg-BG.lproj/Localizable.strings"; sourceTree = ""; }; + 8A9F5C315222AFCD14C9342F /* STDSTransaction+Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "STDSTransaction+Private.h"; sourceTree = ""; }; + 8AC648332A706809F1BDFD59 /* STDSThreeDSProtocolVersion.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSThreeDSProtocolVersion.m; sourceTree = ""; }; + 8B0F0485B7048DDAA7DA8F9B /* UIView+LayoutSupport.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIView+LayoutSupport.m"; sourceTree = ""; }; + 8B59CC3349FD9BBF37624667 /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; + 8B8D429F1C03603DB218700F /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; + 8C6D262891811FB3FE0C947E /* CRes.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = CRes.json; sourceTree = ""; }; + 8F72413F99F8E50FD0FA085D /* UIViewController+Stripe3DS2.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIViewController+Stripe3DS2.h"; sourceTree = ""; }; + 939C45AE068CF4F5BEB4C138 /* STDSExpandableInformationView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSExpandableInformationView.h; sourceTree = ""; }; + 94943BEAB94E949AF9830EFA /* STDSJailbreakChecker.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSJailbreakChecker.h; sourceTree = ""; }; + 95641ECBE1AE1CC198013405 /* STDSProcessingView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSProcessingView.h; sourceTree = ""; }; + 9564F43EF7CF4F60C3C202CD /* STDSBundleLocator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSBundleLocator.m; sourceTree = ""; }; + 9580A82A59EB2B0103EDF47A /* STDSDeviceInformationManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSDeviceInformationManager.m; sourceTree = ""; }; + 969DE2FA3845BB7939D3CD2E /* STDSAuthenticationRequestParametersTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSAuthenticationRequestParametersTest.m; sourceTree = ""; }; + 97163B5E9598CA3A298920B9 /* STDSTextFieldCustomization.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSTextFieldCustomization.m; sourceTree = ""; }; + 97505E93BE83F233E69BB5A9 /* STDSJSONWebEncryptionTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSJSONWebEncryptionTests.m; sourceTree = ""; }; + 9754FA4F3CC6AA921787916B /* STDSDirectoryServerCertificate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSDirectoryServerCertificate.m; sourceTree = ""; }; + 99392D3F702EC6BF7CE15291 /* STDSJSONWebEncryption.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSJSONWebEncryption.m; sourceTree = ""; }; + 99451064136843047C7881AD /* UIViewController+Stripe3DS2.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIViewController+Stripe3DS2.m"; sourceTree = ""; }; + 9946D28A86E85A6C84EB6C7B /* STDSSwiftTryCatch.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSSwiftTryCatch.m; sourceTree = ""; }; + 99CB2B66DBD356B5D222EDA9 /* UIView+LayoutSupport.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIView+LayoutSupport.h"; sourceTree = ""; }; + 99DB24A9B44979EA19347A76 /* STDSChallengeResponseObject+TestObjects.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "STDSChallengeResponseObject+TestObjects.m"; sourceTree = ""; }; + 9A8B0001705400C661678617 /* UIColor+ThirteenSupport.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIColor+ThirteenSupport.h"; sourceTree = ""; }; + 9E1CB633CA5917B2168BB928 /* Stripe3DS2.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Stripe3DS2.xcassets; sourceTree = ""; }; + 9F4376BB07FD1EE783249AED /* STDSStripe3DS2Error.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSStripe3DS2Error.m; sourceTree = ""; }; + 9F5C194C81AF1F5578698A27 /* STDSChallengeResponseObject+TestObjects.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "STDSChallengeResponseObject+TestObjects.h"; sourceTree = ""; }; + A09A89BBE7360F93195641C9 /* STDSImageLoader.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSImageLoader.m; sourceTree = ""; }; + A0ADFB365AD2DE9E84873BB1 /* STDSSimulatorChecker.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSSimulatorChecker.h; sourceTree = ""; }; + A137A31BEFCCB646FB754EE2 /* STDSBundleLocator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSBundleLocator.h; sourceTree = ""; }; + A1BFB8640032796CDA016765 /* STDSConfigParameters.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSConfigParameters.h; sourceTree = ""; }; + A1DC6B47C06B522B22EB19FF /* STDSJSONWebSignature.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSJSONWebSignature.m; sourceTree = ""; }; + A3DFB001DB5BA7F1AFA7353A /* STDSIPAddress.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSIPAddress.h; sourceTree = ""; }; + A4050F381183D6FDCC990D01 /* STDSDeviceInformationManagerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSDeviceInformationManagerTests.m; sourceTree = ""; }; + A4620559FBB8A42E15044E32 /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; + A4A3CCBE9A2E8C1A73D8214E /* STDSTransactionTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSTransactionTest.m; sourceTree = ""; }; + A4D460893CB5DDE52AA0AB85 /* STDSChallengeSelectionView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSChallengeSelectionView.m; sourceTree = ""; }; + A502CCC5190F6B642EEC0CE6 /* STDSWarning.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSWarning.m; sourceTree = ""; }; + A55B2474CD1A39D70869B75B /* STDSUICustomization.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSUICustomization.m; sourceTree = ""; }; + A5DBDDEB5C5DAE2EA8C95CC3 /* STDSSynchronousLocationManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSSynchronousLocationManager.m; sourceTree = ""; }; + A6778F8CE36F769DF608F932 /* NSError+Stripe3DS2.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSError+Stripe3DS2.h"; sourceTree = ""; }; + A70071543985284E0D9DAC64 /* visa.der */ = {isa = PBXFileReference; path = visa.der; sourceTree = ""; }; + A8A77DB1C711B8696B2E5FEF /* NSString+EmptyChecking.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSString+EmptyChecking.h"; sourceTree = ""; }; + A8E62EC973FC5C9905A40CA8 /* STDSCustomization.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSCustomization.m; sourceTree = ""; }; + A9CD7EC589C5CC6CE5CF9310 /* Stripe3DS2-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Stripe3DS2-Debug.xcconfig"; sourceTree = ""; }; + AA29B74DD3963B1205AA1C0C /* UIButton+CustomInitialization.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIButton+CustomInitialization.m"; sourceTree = ""; }; + AA85BC717C46C1B95AF8C1E3 /* STDSWebView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSWebView.m; sourceTree = ""; }; + AA8A113E655E23381CB3BDA4 /* Stripe3DS2DemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Stripe3DS2DemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + AA8BE982004398CF8AAA7D1E /* STDSChallengeResponseMessageExtension.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSChallengeResponseMessageExtension.h; sourceTree = ""; }; + AB871F64FC72EBC7A91F96C1 /* STDSAuthenticationRequestParameters.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSAuthenticationRequestParameters.m; sourceTree = ""; }; + AE1BBF3A441B1F782B766F61 /* STDSExpandableInformationView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSExpandableInformationView.m; sourceTree = ""; }; + AE53BAA72835AFA3B35C58D3 /* STDSStripe3DS2Error.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSStripe3DS2Error.h; sourceTree = ""; }; + AF389752CCEB6FA734AAE31E /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; + AFF187D1D58405C736474042 /* STDSCustomization.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSCustomization.h; sourceTree = ""; }; + B015B937DD3657D62C61CE58 /* STDSDirectoryServerCertificate+Internal.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "STDSDirectoryServerCertificate+Internal.h"; sourceTree = ""; }; + B064352BB2876CA7266C410A /* STDSChallengeResponseViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSChallengeResponseViewController.m; sourceTree = ""; }; + B1A7D41498B79E35BD724681 /* ms-MY */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ms-MY"; path = "ms-MY.lproj/Localizable.strings"; sourceTree = ""; }; + B219360BFB325779485BF702 /* STDSJSONDecodable.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSJSONDecodable.h; sourceTree = ""; }; + B458FCA7DFDBF75E62A43BA1 /* STDSSelectionButton.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSSelectionButton.m; sourceTree = ""; }; + B6275E7099E55F0C04688890 /* ul-test.der */ = {isa = PBXFileReference; path = "ul-test.der"; sourceTree = ""; }; + B71A1C110DCC23A9CE929837 /* STDSException.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSException.h; sourceTree = ""; }; + B7A75140FB62A71261CAF5EC /* STDSChallengeStatusReceiver.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSChallengeStatusReceiver.h; sourceTree = ""; }; + B7AE3B4732D203134FE096FE /* STDSStackView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSStackView.h; sourceTree = ""; }; + B7BD1E24EA9427121E148DFC /* STDSNotInitializedException.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSNotInitializedException.m; sourceTree = ""; }; + B8619CA38E2A5B49DBF8546B /* STDSRuntimeException.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSRuntimeException.h; sourceTree = ""; }; + B97D91CB54F48F64CAC9E378 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; + BA96C9FFA48B85F9C42E8C68 /* STDSFooterCustomization.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSFooterCustomization.h; sourceTree = ""; }; + BAAC56EDE0AAD2E50E02E9AE /* STDSChallengeResponseMessageExtensionObject.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSChallengeResponseMessageExtensionObject.h; sourceTree = ""; }; + BB065D6900E95C5E90864C16 /* STDSIntegrityChecker.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSIntegrityChecker.m; sourceTree = ""; }; + BC3EE020DC9358FF56389BBE /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Localizable.strings"; sourceTree = ""; }; + BD94CAE01A17E083E5617E56 /* STDSButtonCustomization.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSButtonCustomization.h; sourceTree = ""; }; + BF80634E06E8D6F598CFC667 /* STDSEphemeralKeyPairTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSEphemeralKeyPairTests.m; sourceTree = ""; }; + C02DF839637771684BC57B3A /* STDSIntegrityChecker.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSIntegrityChecker.h; sourceTree = ""; }; + C12AE31D386D4A193C00004B /* STDSSynchronousLocationManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSSynchronousLocationManager.h; sourceTree = ""; }; + C29F78CC37253B0D33FF86F2 /* STDSDebuggerChecker.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSDebuggerChecker.m; sourceTree = ""; }; + C4777F15603AC755362BD846 /* STDSSelectionButton.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSSelectionButton.h; sourceTree = ""; }; + C566D6E444FCA4BCEC65F3D2 /* NSDictionary+DecodingHelpers.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSDictionary+DecodingHelpers.m"; sourceTree = ""; }; + C78AA1D7AD2BAB52EE58D078 /* NSDictionary+DecodingHelpers.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSDictionary+DecodingHelpers.h"; sourceTree = ""; }; + C893FD867C964775E68AE85F /* STDSChallengeInformationView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSChallengeInformationView.h; sourceTree = ""; }; + C8F0E11AA6794BFB1715685E /* STDSSecTypeUtilities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSSecTypeUtilities.h; sourceTree = ""; }; + C91B239583899192C7B26FCF /* STDSAuthenticationRequestParameters.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSAuthenticationRequestParameters.h; sourceTree = ""; }; + CB00E968CF4DD07FB8BF2AAA /* STDSIPAddress.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSIPAddress.m; sourceTree = ""; }; + CB3154597040B803ADE14F9E /* STDSUICustomizationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSUICustomizationTests.m; sourceTree = ""; }; + CD33421F13A675DCFE597FFB /* STDSRuntimeException.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSRuntimeException.m; sourceTree = ""; }; + CE4030C50383B488C97F4B56 /* Stripe3DS2.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Stripe3DS2.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + CECC1E22D0F0336039B435DE /* zh-HK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-HK"; path = "zh-HK.lproj/Localizable.strings"; sourceTree = ""; }; + CF3DED43BD4E0226BFB0759D /* STDSSecTypeUtilities.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSSecTypeUtilities.m; sourceTree = ""; }; + D03B98D5861739833B197D8F /* STDSChallengeParameters.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSChallengeParameters.h; sourceTree = ""; }; + D122FAE093BC4F649CC6E7AB /* STDSDebuggerChecker.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSDebuggerChecker.h; sourceTree = ""; }; + D24777EE9931075405F370DB /* STDSChallengeInformationView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSChallengeInformationView.m; sourceTree = ""; }; + D3F5A6D5A680F008C00A2D69 /* nn-NO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "nn-NO"; path = "nn-NO.lproj/Localizable.strings"; sourceTree = ""; }; + D7A9D36FEF7E97CF735D82B8 /* Stripe3DS2DemoUITests-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Stripe3DS2DemoUITests-Release.xcconfig"; sourceTree = ""; }; + D827473D1858B2BED85BDFA1 /* STDSACSNetworkingManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSACSNetworkingManager.h; sourceTree = ""; }; + D8B7EE0F935EF44EB2EF7D45 /* amex.der */ = {isa = PBXFileReference; path = amex.der; sourceTree = ""; }; + D8D309E4FBE7E73D0BB83BF5 /* NSString+EmptyChecking.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSString+EmptyChecking.m"; sourceTree = ""; }; + DE7D85369E012B15702D8DF9 /* STDSRuntimeErrorEvent.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSRuntimeErrorEvent.h; sourceTree = ""; }; + E0949399E96663964091C0EF /* NSLayoutConstraint+LayoutSupport.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSLayoutConstraint+LayoutSupport.h"; sourceTree = ""; }; + E12798864D5CFFB7157D4CF5 /* ErrorMessage.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ErrorMessage.json; sourceTree = ""; }; + E1630D876436E43547FF69CE /* STDSNavigationBarCustomization.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSNavigationBarCustomization.h; sourceTree = ""; }; + E45303DCFFDC390971E6E122 /* STDSChallengeResponseViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSChallengeResponseViewController.h; sourceTree = ""; }; + E4C668442518B446D573AD24 /* STDSJSONWebSignatureTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSJSONWebSignatureTests.m; sourceTree = ""; }; + E5BC34A661D9080A2EE239F8 /* STDSSwiftTryCatch.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSSwiftTryCatch.h; sourceTree = ""; }; + E629F85B783E42244E37DFA9 /* STDSProgressViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSProgressViewController.h; sourceTree = ""; }; + E64C700DED163CB77DCDEA78 /* fr-CA */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "fr-CA"; path = "fr-CA.lproj/Localizable.strings"; sourceTree = ""; }; + E6A3C5D4C46E681B7D76B2BA /* STDSJSONEncodable.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSJSONEncodable.h; sourceTree = ""; }; + E9F1A5A8E5922748A9BEC724 /* STDSNavigationBarCustomization.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSNavigationBarCustomization.m; sourceTree = ""; }; + EB1BDF12DBD6264C6199FFA7 /* STDSChallengeResponseObject.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSChallengeResponseObject.h; sourceTree = ""; }; + EB910E98EDD6D3E00802793C /* STDSChallengeSelectionView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSChallengeSelectionView.h; sourceTree = ""; }; + ECC649704CDDA50E73DB3C10 /* STDSImageLoader.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSImageLoader.h; sourceTree = ""; }; + EF5219762616ACF204F08C19 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + F02456AB660E709F332C0C7D /* STDSSynchronousLocationManagerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSSynchronousLocationManagerTests.m; sourceTree = ""; }; + F046DE266BAAE3DA0AC43785 /* STDSChallengeResponseSelectionInfoObject.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSChallengeResponseSelectionInfoObject.m; sourceTree = ""; }; + F112967E9FE8EE02FFFDC313 /* en-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-GB"; path = "en-GB.lproj/Localizable.strings"; sourceTree = ""; }; + F139E48FDEFD921FF410892F /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; + F2BC7014D26158AC9D74CC67 /* Stripe3DS2Tests-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Stripe3DS2Tests-Debug.xcconfig"; sourceTree = ""; }; + F363439353A6DD554FC44AC2 /* STDSOSVersionChecker.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSOSVersionChecker.m; sourceTree = ""; }; + F40CC91AA69F5296B0AA985C /* STDSTransaction.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSTransaction.h; sourceTree = ""; }; + F4D6DB90E3D01495C1F9BFE9 /* ro-RO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ro-RO"; path = "ro-RO.lproj/Localizable.strings"; sourceTree = ""; }; + F5686FB8EFA3AE3B39F85BBC /* Stripe3DS2Tests-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Stripe3DS2Tests-Release.xcconfig"; sourceTree = ""; }; + F86F34D1339F7467D0FDAC75 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; + F8F6FFED1A0966A49B78F252 /* Stripe3DS2-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Stripe3DS2-Release.xcconfig"; sourceTree = ""; }; + F99B290CF2B4987A4CFE5DCE /* NSLayoutConstraint+LayoutSupport.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSLayoutConstraint+LayoutSupport.m"; sourceTree = ""; }; + F9C39A42ECBA58571EFA03C5 /* STDSBrandingView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSBrandingView.m; sourceTree = ""; }; + F9D521B45783D36C8E83F0EA /* STDSEphemeralKeyPair.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSEphemeralKeyPair.m; sourceTree = ""; }; + FAE7026135B5049B732CEDEB /* STDSOSVersionChecker.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = STDSOSVersionChecker.h; sourceTree = ""; }; + FC52C3C844BDA981EE34BAB9 /* STDSDeviceInformationParameterTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STDSDeviceInformationParameterTests.m; sourceTree = ""; }; + FE1DE9D978E6E682CB094128 /* pl-PL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pl-PL"; path = "pl-PL.lproj/Localizable.strings"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 16B0A41D6C0F157DDF229397 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C2BA2E6ABDD8E80963FFC671 /* XCTest.framework in Frameworks */, + 4D73C2FBAC9B96ECB45BDC94 /* Stripe3DS2.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8ECF69FED0DDA991C821CF6A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1469551DB874336B68F6C39D /* Stripe3DS2.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A56B40D43D552FDE77670CB5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BF06B99FECD98C26F7B64665 /* XCTest.framework in Frameworks */, + A8A86B9BCE9702D74E0D2DB6 /* Stripe3DS2.framework in Frameworks */, + 3C6D16D8E7B5BC865D956A0B /* iOSSnapshotTestCase in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C65BFA70E847549921E39F4E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 148D1BA6A2E8B2B60CA9951F /* Stripe3DS2 */ = { + isa = PBXGroup; + children = ( + E4F3BE80D5F61EAC1694F954 /* include */, + A3DDF4E193616FA951B1B120 /* Resources */, + 5D93713CADD5589B5E6F6A0D /* Info.plist */, + 5B05A609753EE71502409082 /* NSData+JWEHelpers.h */, + 1FC15225A5E83948EF546B71 /* NSData+JWEHelpers.m */, + C78AA1D7AD2BAB52EE58D078 /* NSDictionary+DecodingHelpers.h */, + C566D6E444FCA4BCEC65F3D2 /* NSDictionary+DecodingHelpers.m */, + A6778F8CE36F769DF608F932 /* NSError+Stripe3DS2.h */, + 26C20D4B77372845627D6466 /* NSError+Stripe3DS2.m */, + E0949399E96663964091C0EF /* NSLayoutConstraint+LayoutSupport.h */, + F99B290CF2B4987A4CFE5DCE /* NSLayoutConstraint+LayoutSupport.m */, + A8A77DB1C711B8696B2E5FEF /* NSString+EmptyChecking.h */, + D8D309E4FBE7E73D0BB83BF5 /* NSString+EmptyChecking.m */, + 018F4AA25D84E9CBD2546BB5 /* NSString+JWEHelpers.h */, + 43CB8F416E4F4CEA34391481 /* NSString+JWEHelpers.m */, + D827473D1858B2BED85BDFA1 /* STDSACSNetworkingManager.h */, + 2D6004F2D9880055C0C0BCF1 /* STDSACSNetworkingManager.m */, + 138E82072FC6572612A8E248 /* STDSAuthenticationResponseObject.h */, + 5043E1CF6955176EC3D43F88 /* STDSAuthenticationResponseObject.m */, + 2CFAC8D74AFFC2E61BAD82A5 /* STDSBrandingView.h */, + F9C39A42ECBA58571EFA03C5 /* STDSBrandingView.m */, + A137A31BEFCCB646FB754EE2 /* STDSBundleLocator.h */, + 9564F43EF7CF4F60C3C202CD /* STDSBundleLocator.m */, + C893FD867C964775E68AE85F /* STDSChallengeInformationView.h */, + D24777EE9931075405F370DB /* STDSChallengeInformationView.m */, + 762DEC6662DF8240FE19CFFC /* STDSChallengeRequestParameters.h */, + 60693BC560C4927ACFD2E6C3 /* STDSChallengeRequestParameters.m */, + 315AEE1534F9D9974DC1674B /* STDSChallengeResponse.h */, + 10C45EE3433B731253E21E24 /* STDSChallengeResponseImage.h */, + 14F83E51E99D453DF9C2DDE1 /* STDSChallengeResponseImageObject.h */, + 3A9F828840A3B361842E38DE /* STDSChallengeResponseImageObject.m */, + AA8BE982004398CF8AAA7D1E /* STDSChallengeResponseMessageExtension.h */, + BAAC56EDE0AAD2E50E02E9AE /* STDSChallengeResponseMessageExtensionObject.h */, + 7D02FAFC403BC901931A629F /* STDSChallengeResponseMessageExtensionObject.m */, + EB1BDF12DBD6264C6199FFA7 /* STDSChallengeResponseObject.h */, + 24CE086326AF3DE336BF4F4C /* STDSChallengeResponseObject.m */, + 52EEBC7D74623E1A1D7E3219 /* STDSChallengeResponseSelectionInfo.h */, + 4CCA15E0819F9A9D89FEF9EA /* STDSChallengeResponseSelectionInfoObject.h */, + F046DE266BAAE3DA0AC43785 /* STDSChallengeResponseSelectionInfoObject.m */, + E45303DCFFDC390971E6E122 /* STDSChallengeResponseViewController.h */, + B064352BB2876CA7266C410A /* STDSChallengeResponseViewController.m */, + EB910E98EDD6D3E00802793C /* STDSChallengeSelectionView.h */, + A4D460893CB5DDE52AA0AB85 /* STDSChallengeSelectionView.m */, + D122FAE093BC4F649CC6E7AB /* STDSDebuggerChecker.h */, + C29F78CC37253B0D33FF86F2 /* STDSDebuggerChecker.m */, + 1FBE88F6E2C3153A8315EDC6 /* STDSDeviceInformation.h */, + 56B1A52548593FE78D801734 /* STDSDeviceInformation.m */, + 7A637DA708BC866C2903E0EA /* STDSDeviceInformationManager.h */, + 9580A82A59EB2B0103EDF47A /* STDSDeviceInformationManager.m */, + 172AF5C85EDBD92A1030E361 /* STDSDeviceInformationParameter.h */, + 25C7D18868DDADEF4CB6220C /* STDSDeviceInformationParameter.m */, + 4869D6E14F01EDB960CB3065 /* STDSDeviceInformationParameter+Private.h */, + 6AA43E7E90E4002DD56B7C3D /* STDSDirectoryServer.h */, + 069990DA025BE9F33602E96A /* STDSDirectoryServerCertificate.h */, + 9754FA4F3CC6AA921787916B /* STDSDirectoryServerCertificate.m */, + B015B937DD3657D62C61CE58 /* STDSDirectoryServerCertificate+Internal.h */, + 5049A72A6BEB558D8559A5EB /* STDSEllipticCurvePoint.h */, + 2EF5AA537586EF706E4056FD /* STDSEllipticCurvePoint.m */, + 20533E2C69C4B80BB33A4766 /* STDSEphemeralKeyPair.h */, + F9D521B45783D36C8E83F0EA /* STDSEphemeralKeyPair.m */, + 52D6E4F76CBA258163E57DF3 /* STDSEphemeralKeyPair+Testing.h */, + 5463EB1CC8E85BE3BD12DEFF /* STDSErrorMessage+Internal.h */, + 2CB591796654445B8434A501 /* STDSErrorMessage+Internal.m */, + 461F938CDE7ED6D06D9D2700 /* STDSException+Internal.h */, + 939C45AE068CF4F5BEB4C138 /* STDSExpandableInformationView.h */, + AE1BBF3A441B1F782B766F61 /* STDSExpandableInformationView.m */, + ECC649704CDDA50E73DB3C10 /* STDSImageLoader.h */, + A09A89BBE7360F93195641C9 /* STDSImageLoader.m */, + C02DF839637771684BC57B3A /* STDSIntegrityChecker.h */, + BB065D6900E95C5E90864C16 /* STDSIntegrityChecker.m */, + A3DFB001DB5BA7F1AFA7353A /* STDSIPAddress.h */, + CB00E968CF4DD07FB8BF2AAA /* STDSIPAddress.m */, + 94943BEAB94E949AF9830EFA /* STDSJailbreakChecker.h */, + 1BFBB92DBE97E16DE9D18B4A /* STDSJailbreakChecker.m */, + 76EA9DF8572C992E30BCF8A3 /* STDSJSONWebEncryption.h */, + 99392D3F702EC6BF7CE15291 /* STDSJSONWebEncryption.m */, + 3AA2E6DA60350400C5270E08 /* STDSJSONWebSignature.h */, + A1DC6B47C06B522B22EB19FF /* STDSJSONWebSignature.m */, + 41E0F638E776A7F3456BDA3E /* STDSLocalizedString.h */, + FAE7026135B5049B732CEDEB /* STDSOSVersionChecker.h */, + F363439353A6DD554FC44AC2 /* STDSOSVersionChecker.m */, + 95641ECBE1AE1CC198013405 /* STDSProcessingView.h */, + 7AC1C5F6F1311F50D9C37291 /* STDSProcessingView.m */, + E629F85B783E42244E37DFA9 /* STDSProgressViewController.h */, + 84967ED0D9B206B3F5AA4F02 /* STDSProgressViewController.m */, + C8F0E11AA6794BFB1715685E /* STDSSecTypeUtilities.h */, + CF3DED43BD4E0226BFB0759D /* STDSSecTypeUtilities.m */, + C4777F15603AC755362BD846 /* STDSSelectionButton.h */, + B458FCA7DFDBF75E62A43BA1 /* STDSSelectionButton.m */, + A0ADFB365AD2DE9E84873BB1 /* STDSSimulatorChecker.h */, + 14D60BDF245487FE3362BE07 /* STDSSimulatorChecker.m */, + 72DBE992B33F45C059EDB597 /* STDSSpacerView.h */, + 51E4A57094A671EB14380882 /* STDSSpacerView.m */, + B7AE3B4732D203134FE096FE /* STDSStackView.h */, + 1D6269F8B91341C387A911DB /* STDSStackView.m */, + C12AE31D386D4A193C00004B /* STDSSynchronousLocationManager.h */, + A5DBDDEB5C5DAE2EA8C95CC3 /* STDSSynchronousLocationManager.m */, + 82C41E6DC2CC247ECC17F2B9 /* STDSTextChallengeView.h */, + 04E6B6D3CAE363114723472F /* STDSTextChallengeView.m */, + 8AC648332A706809F1BDFD59 /* STDSThreeDSProtocolVersion.m */, + 47C29E9F3D2A62676B424CD1 /* STDSThreeDSProtocolVersion+Private.h */, + 8A9F5C315222AFCD14C9342F /* STDSTransaction+Private.h */, + 581EEE576D24047004C828DF /* STDSWebView.h */, + AA85BC717C46C1B95AF8C1E3 /* STDSWebView.m */, + 33CEADC8006E456FD82CE134 /* STDSWhitelistView.h */, + 615F1BD510661B38D6825128 /* STDSWhitelistView.m */, + 0F024DEAD6628A0229856656 /* Stripe3DS2-Bridging-Header.h */, + 4DD55BECC3FB85216FE46218 /* UIButton+CustomInitialization.h */, + AA29B74DD3963B1205AA1C0C /* UIButton+CustomInitialization.m */, + 4A567E08748C86FCE2A75FEA /* UIColor+DefaultColors.h */, + 869733153BCA6BE2EB7A452F /* UIColor+DefaultColors.m */, + 9A8B0001705400C661678617 /* UIColor+ThirteenSupport.h */, + 37ED28756222539D37554CC7 /* UIColor+ThirteenSupport.m */, + 1E844798B2733C8511AC080E /* UIFont+DefaultFonts.h */, + 634095EA97A4E48BF7CAA03F /* UIFont+DefaultFonts.m */, + 99CB2B66DBD356B5D222EDA9 /* UIView+LayoutSupport.h */, + 8B0F0485B7048DDAA7DA8F9B /* UIView+LayoutSupport.m */, + 8F72413F99F8E50FD0FA085D /* UIViewController+Stripe3DS2.h */, + 99451064136843047C7881AD /* UIViewController+Stripe3DS2.m */, + ); + path = Stripe3DS2; + sourceTree = ""; + }; + 18B6E1F5666C797A11E2BE03 = { + isa = PBXGroup; + children = ( + EB6FA022FDF5831C6D162E11 /* Project */, + 280289821F799A8274082E14 /* Frameworks */, + 398BBCD5E4B06CA5006CCC40 /* Products */, + ); + sourceTree = ""; + }; + 20B0FECAB4F18569649603E9 /* BuildConfigurations */ = { + isa = PBXGroup; + children = ( + 3A8CD68A006F970DDC54469A /* Project-Debug.xcconfig */, + 4DB91F58CF23E72636C69C41 /* Project-Release.xcconfig */, + A9CD7EC589C5CC6CE5CF9310 /* Stripe3DS2-Debug.xcconfig */, + F8F6FFED1A0966A49B78F252 /* Stripe3DS2-Release.xcconfig */, + 5316D0759F7F2ED0BF1D3B2E /* Stripe3DS2DemoUI-Debug.xcconfig */, + 6940D75CA36F7DB9FB56196B /* Stripe3DS2DemoUI-Release.xcconfig */, + 0E3C05BE55CF3086738AA182 /* Stripe3DS2DemoUITests-Debug.xcconfig */, + D7A9D36FEF7E97CF735D82B8 /* Stripe3DS2DemoUITests-Release.xcconfig */, + F2BC7014D26158AC9D74CC67 /* Stripe3DS2Tests-Debug.xcconfig */, + F5686FB8EFA3AE3B39F85BBC /* Stripe3DS2Tests-Release.xcconfig */, + ); + path = BuildConfigurations; + sourceTree = ""; + }; + 280289821F799A8274082E14 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 08D577934984712C20C5E903 /* XCTest.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 398BBCD5E4B06CA5006CCC40 /* Products */ = { + isa = PBXGroup; + children = ( + CE4030C50383B488C97F4B56 /* Stripe3DS2.framework */, + 02D762271DBCC1AE80E0F9D4 /* Stripe3DS2DemoUI.app */, + AA8A113E655E23381CB3BDA4 /* Stripe3DS2DemoUITests.xctest */, + 4EB746D4E46ABD2452A154AE /* Stripe3DS2Tests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 47CD3B0E4EAA05F75DA05252 /* CertificateFiles */ = { + isa = PBXGroup; + children = ( + D8B7EE0F935EF44EB2EF7D45 /* amex.der */, + 83838563F29179FB1E9F2E66 /* cartes-bancaires.der */, + 04AB48F014A8D5BA56FEF463 /* discover.der */, + 3E4C02D9D6978AD5C3D7E3D2 /* ec_test.der */, + 1104D5855D28E49E104CA004 /* mastercard.der */, + B6275E7099E55F0C04688890 /* ul-test.der */, + A70071543985284E0D9DAC64 /* visa.der */, + ); + path = CertificateFiles; + sourceTree = ""; + }; + 79E03C9F2FB6092200EE737F /* Stripe3DS2Tests */ = { + isa = PBXGroup; + children = ( + AD446D5D3D8D07D491373E24 /* JSON */, + 23A3DDCE911F8C43CF74CDF4 /* Info.plist */, + 5EE8BF51F49E161737406B3A /* NSDictionary+DecodingHelpersTest.m */, + 57B8822D21AADE7DA51931CC /* NSString+EmptyCheckingTests.m */, + 6118F98576E9A39A7292C6C6 /* STDSACSNetworkingManagerTest.m */, + 969DE2FA3845BB7939D3CD2E /* STDSAuthenticationRequestParametersTest.m */, + 28E32DA72CAFBF2AF8099151 /* STDSAuthenticationResponseTests.m */, + 4B1E3760B1369DB1A33C9DFE /* STDSBase64URLEncodingTests.m */, + 89ED2133B61092FE1B478204 /* STDSChallengeParametersTests.m */, + 4EAD29E57D206B5D6BAF9099 /* STDSChallengeRequestParametersTest.m */, + 5E401845EE5759CCD30786AA /* STDSChallengeResponseObjectTest.m */, + 5F551808E65CDADD9B3A9CB8 /* STDSConfigParametersTests.m */, + A4050F381183D6FDCC990D01 /* STDSDeviceInformationManagerTests.m */, + FC52C3C844BDA981EE34BAB9 /* STDSDeviceInformationParameterTests.m */, + 6AA75957DD13FA92FA866AF3 /* STDSDirectoryServerCertificateTests.m */, + 0DA1EE83D91993BCAF13EC72 /* STDSEllipticCurvePointTests.m */, + BF80634E06E8D6F598CFC667 /* STDSEphemeralKeyPairTests.m */, + 278F2C40987F5218BD031E3D /* STDSErrorMessageTest.m */, + 6B9B49A5CF64DCB23CD0AEDE /* STDSJSONEncoderTest.m */, + 97505E93BE83F233E69BB5A9 /* STDSJSONWebEncryptionTests.m */, + E4C668442518B446D573AD24 /* STDSJSONWebSignatureTests.m */, + 30D22985091381DFCDF077E9 /* STDSSecTypeUtilitiesTests.m */, + F02456AB660E709F332C0C7D /* STDSSynchronousLocationManagerTests.m */, + 5FF3235EDD908CC1500399A1 /* STDSTestJSONUtils.m */, + 7FF8ACC073E814B7DEBA7647 /* STDSThreeDS2ServiceTests.m */, + A4A3CCBE9A2E8C1A73D8214E /* STDSTransactionTest.m */, + CB3154597040B803ADE14F9E /* STDSUICustomizationTests.m */, + 474F39AA3F47D84F8D54E5B7 /* STDSWarningTests.m */, + ); + path = Stripe3DS2Tests; + sourceTree = ""; + }; + 82BC8EB365BF993731432954 /* Sources */ = { + isa = PBXGroup; + children = ( + A4620559FBB8A42E15044E32 /* AppDelegate.h */, + 8B59CC3349FD9BBF37624667 /* AppDelegate.m */, + 3C22D410C297E2C3AFE727A6 /* main.m */, + 9F5C194C81AF1F5578698A27 /* STDSChallengeResponseObject+TestObjects.h */, + 99DB24A9B44979EA19347A76 /* STDSChallengeResponseObject+TestObjects.m */, + 1F0DADEABA0B043A6CA36DD6 /* STDSDemoViewController.h */, + 382C583385FAECA91366769E /* STDSDemoViewController.m */, + ); + path = Sources; + sourceTree = ""; + }; + 8CF6AFFC9FD5638E0D1E988E /* Stripe3DS2DemoUI */ = { + isa = PBXGroup; + children = ( + 98160E2F8248A228E5EC73F8 /* Resources */, + 82BC8EB365BF993731432954 /* Sources */, + 037C294011A01E4B4DCE5BB7 /* Info.plist */, + ); + path = Stripe3DS2DemoUI; + sourceTree = ""; + }; + 98160E2F8248A228E5EC73F8 /* Resources */ = { + isa = PBXGroup; + children = ( + 7971EB56716D5E2F59053B6D /* acs_challenge.html */, + ); + path = Resources; + sourceTree = ""; + }; + A3DDF4E193616FA951B1B120 /* Resources */ = { + isa = PBXGroup; + children = ( + 47CD3B0E4EAA05F75DA05252 /* CertificateFiles */, + F31A6580385C6390910AFD93 /* Localizable.strings */, + 9E1CB633CA5917B2168BB928 /* Stripe3DS2.xcassets */, + ); + path = Resources; + sourceTree = ""; + }; + AD446D5D3D8D07D491373E24 /* JSON */ = { + isa = PBXGroup; + children = ( + 237977B021FE538E5E4FA35C /* ARes.json */, + 8C6D262891811FB3FE0C947E /* CRes.json */, + E12798864D5CFFB7157D4CF5 /* ErrorMessage.json */, + ); + path = JSON; + sourceTree = ""; + }; + C169A9439FD628D620B218AA /* Stripe3DS2DemoUITests */ = { + isa = PBXGroup; + children = ( + 00658E077ECB223C90484AF7 /* Info.plist */, + 466DF80D33CEC20DF4E844A9 /* STDSChallengeResponseViewControllerSnapshotTests.m */, + ); + path = Stripe3DS2DemoUITests; + sourceTree = ""; + }; + E4F3BE80D5F61EAC1694F954 /* include */ = { + isa = PBXGroup; + children = ( + 1F1FD11407319293954C6D81 /* STDSAlreadyInitializedException.h */, + 27AC486DA2A80A43C1F517B2 /* STDSAlreadyInitializedException.m */, + C91B239583899192C7B26FCF /* STDSAuthenticationRequestParameters.h */, + AB871F64FC72EBC7A91F96C1 /* STDSAuthenticationRequestParameters.m */, + 4EA4AB3BA2FB41B3D5F38978 /* STDSAuthenticationResponse.h */, + BD94CAE01A17E083E5617E56 /* STDSButtonCustomization.h */, + 5489DCAA65624C6F966816B6 /* STDSButtonCustomization.m */, + D03B98D5861739833B197D8F /* STDSChallengeParameters.h */, + 23C929BF760735CC01E1E8F5 /* STDSChallengeParameters.m */, + B7A75140FB62A71261CAF5EC /* STDSChallengeStatusReceiver.h */, + 10DB3CD8F2541AC06681F2C8 /* STDSCompletionEvent.h */, + 3B6B71C61CF5ADC7796441F5 /* STDSCompletionEvent.m */, + A1BFB8640032796CDA016765 /* STDSConfigParameters.h */, + 89B5ED899439D1C1054CB448 /* STDSConfigParameters.m */, + AFF187D1D58405C736474042 /* STDSCustomization.h */, + A8E62EC973FC5C9905A40CA8 /* STDSCustomization.m */, + 645D0BB927B7F8A0D37EE04D /* STDSErrorMessage.h */, + 6DDB6222A2EF31FDB7592368 /* STDSErrorMessage.m */, + B71A1C110DCC23A9CE929837 /* STDSException.h */, + 6F030410FDABFF833CD8FE46 /* STDSException.m */, + BA96C9FFA48B85F9C42E8C68 /* STDSFooterCustomization.h */, + 6CFF141AC98F213122D037C6 /* STDSFooterCustomization.m */, + 853A361AB630A2BD402D1B47 /* STDSInvalidInputException.h */, + 6FDAC320458399141EB161E2 /* STDSInvalidInputException.m */, + B219360BFB325779485BF702 /* STDSJSONDecodable.h */, + E6A3C5D4C46E681B7D76B2BA /* STDSJSONEncodable.h */, + 06C5207432FB07BD71703BCC /* STDSJSONEncoder.h */, + 0681C043F732148587737233 /* STDSJSONEncoder.m */, + 6932DD26C7C16938DFA0E198 /* STDSLabelCustomization.h */, + 01D320CD5ADA49F17B8BC1B0 /* STDSLabelCustomization.m */, + E1630D876436E43547FF69CE /* STDSNavigationBarCustomization.h */, + E9F1A5A8E5922748A9BEC724 /* STDSNavigationBarCustomization.m */, + 2FCABBF51CBA7F9FEF757B4C /* STDSNotInitializedException.h */, + B7BD1E24EA9427121E148DFC /* STDSNotInitializedException.m */, + 3A6D20D1DAB7D769E5E97528 /* STDSProtocolErrorEvent.h */, + 3255706D2304FB551C97305F /* STDSProtocolErrorEvent.m */, + DE7D85369E012B15702D8DF9 /* STDSRuntimeErrorEvent.h */, + 1501A7A3010FFE53D00E318F /* STDSRuntimeErrorEvent.m */, + B8619CA38E2A5B49DBF8546B /* STDSRuntimeException.h */, + CD33421F13A675DCFE597FFB /* STDSRuntimeException.m */, + 7CD753D9BBFC378C1B491B00 /* STDSSelectionCustomization.h */, + 43540AB7D13C0C9A563C3F4D /* STDSSelectionCustomization.m */, + AE53BAA72835AFA3B35C58D3 /* STDSStripe3DS2Error.h */, + 9F4376BB07FD1EE783249AED /* STDSStripe3DS2Error.m */, + E5BC34A661D9080A2EE239F8 /* STDSSwiftTryCatch.h */, + 9946D28A86E85A6C84EB6C7B /* STDSSwiftTryCatch.m */, + 68ED2A0E11E1B27980E0C60A /* STDSTextFieldCustomization.h */, + 97163B5E9598CA3A298920B9 /* STDSTextFieldCustomization.m */, + 5EAA444FA7C82BDA4AD907BF /* STDSThreeDS2Service.h */, + 77646CEC7EE754F47EDBDA4F /* STDSThreeDS2Service.m */, + 5E6A9565E97DF2544254AA94 /* STDSThreeDSProtocolVersion.h */, + F40CC91AA69F5296B0AA985C /* STDSTransaction.h */, + 5FDE2481364C454058BF2377 /* STDSTransaction.m */, + 210B22FF4DCB0C6E7C763EAB /* STDSUICustomization.h */, + A55B2474CD1A39D70869B75B /* STDSUICustomization.m */, + 0BEB5006261C5E070FEE62BF /* STDSWarning.h */, + A502CCC5190F6B642EEC0CE6 /* STDSWarning.m */, + 28C6B4F6BA431B9342970FD6 /* Stripe3DS2.h */, + ); + path = include; + sourceTree = ""; + }; + EB6FA022FDF5831C6D162E11 /* Project */ = { + isa = PBXGroup; + children = ( + 20B0FECAB4F18569649603E9 /* BuildConfigurations */, + 148D1BA6A2E8B2B60CA9951F /* Stripe3DS2 */, + 8CF6AFFC9FD5638E0D1E988E /* Stripe3DS2DemoUI */, + C169A9439FD628D620B218AA /* Stripe3DS2DemoUITests */, + 79E03C9F2FB6092200EE737F /* Stripe3DS2Tests */, + ); + name = Project; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + A8117E5A795D5342E5AEF24C /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 3192FCC841152A7E5E6F19D6 /* STDSAlreadyInitializedException.h in Headers */, + 9673946B78A7763070E05CD0 /* STDSAuthenticationRequestParameters.h in Headers */, + 435EFC9119D4B42B2C6A6F88 /* STDSAuthenticationResponse.h in Headers */, + EC51189277D7D68D08C2A65C /* STDSButtonCustomization.h in Headers */, + DC0D9A57F2E312663C088FB8 /* STDSChallengeParameters.h in Headers */, + 72F430506E684D61BD5CFE3B /* STDSChallengeStatusReceiver.h in Headers */, + 8E27285FE76F8BECFD70286D /* STDSCompletionEvent.h in Headers */, + DC32281BF0847A98B100C3A1 /* STDSConfigParameters.h in Headers */, + F04482C663F6DF8E522B0833 /* STDSCustomization.h in Headers */, + 2BBA82878804711E06CBFCCB /* STDSErrorMessage.h in Headers */, + 24D49F833E1ED28DE557F219 /* STDSException.h in Headers */, + 79557307ECD48D17C566997A /* STDSFooterCustomization.h in Headers */, + 4E35267801D94FB84816C519 /* STDSInvalidInputException.h in Headers */, + 9B32E9A4CD2BDA61C730F5EB /* STDSJSONDecodable.h in Headers */, + 890E22971E6F7B0FA1C8D649 /* STDSJSONEncodable.h in Headers */, + 2224463CE5FB99D4BEE6A479 /* STDSJSONEncoder.h in Headers */, + 0BA0F0CADC4CFF419E1F7EFC /* STDSLabelCustomization.h in Headers */, + 3B430F172A3AD6AA9AFDFC04 /* STDSNavigationBarCustomization.h in Headers */, + 2CB6DF5CCCBC1EB9ABD03143 /* STDSNotInitializedException.h in Headers */, + 67D431374DECE36B2606D944 /* STDSProtocolErrorEvent.h in Headers */, + 15D4A2F07EA477B84CD48B15 /* STDSRuntimeErrorEvent.h in Headers */, + 2C6DB6516699B4EFADDF3360 /* STDSRuntimeException.h in Headers */, + F5513045A25210C524A05DF5 /* STDSSelectionCustomization.h in Headers */, + 335553A547A3CA80B3901CCF /* STDSStripe3DS2Error.h in Headers */, + DE32EAA8F5C2645652FCFB48 /* STDSSwiftTryCatch.h in Headers */, + 4C267E9A30ADC18E71408ED5 /* STDSTextFieldCustomization.h in Headers */, + 31C636A25BF83316EE7EB57D /* STDSThreeDS2Service.h in Headers */, + 847926A68A88BEB58C92D9BC /* STDSThreeDSProtocolVersion.h in Headers */, + 9DC5612697835A383DC6606E /* STDSTransaction.h in Headers */, + 0615DD02C0B022AE207DADF0 /* STDSUICustomization.h in Headers */, + 9F0F6085FBD892BF5F14CF86 /* STDSWarning.h in Headers */, + 893713F4464A88FAB92BC2C6 /* Stripe3DS2.h in Headers */, + 425849564519D6454DDA3424 /* NSData+JWEHelpers.h in Headers */, + 1134F81C7D015BA5EA52BFD1 /* NSDictionary+DecodingHelpers.h in Headers */, + 3617B07ABD719F1A5F8302FA /* NSError+Stripe3DS2.h in Headers */, + 28AD7EC6F6DA2AECD7F61BA7 /* NSLayoutConstraint+LayoutSupport.h in Headers */, + 30B25163BEDEB99CDD101B5F /* NSString+EmptyChecking.h in Headers */, + A5193DADCD0F48911F775D88 /* NSString+JWEHelpers.h in Headers */, + A78F19DA3D146A258FB9DE3C /* STDSACSNetworkingManager.h in Headers */, + 2B2787A7103C0D0AB5F00B78 /* STDSAuthenticationResponseObject.h in Headers */, + 458BC5A3D0E7B4D05549474F /* STDSBrandingView.h in Headers */, + 97CE8B57A97D037E977E62F3 /* STDSBundleLocator.h in Headers */, + F13A032A4AF15BB61EA18A8D /* STDSChallengeInformationView.h in Headers */, + BF342613EE89845EB1C4B72A /* STDSChallengeRequestParameters.h in Headers */, + 7337FB20DC0EF491B5922913 /* STDSChallengeResponse.h in Headers */, + 95A2D58051AADDBE16CCA0B6 /* STDSChallengeResponseImage.h in Headers */, + D819C341ECEE1EFE6FE66085 /* STDSChallengeResponseImageObject.h in Headers */, + 793D61AD55630DD336A141A7 /* STDSChallengeResponseMessageExtension.h in Headers */, + 48316C1AB1D9151C205A59A4 /* STDSChallengeResponseMessageExtensionObject.h in Headers */, + E90749131EDA0C86BBBFCE5C /* STDSChallengeResponseObject.h in Headers */, + 3C8C3BCDDDDEF6B9E150D4E1 /* STDSChallengeResponseSelectionInfo.h in Headers */, + 9C225B9C556C20D1A6F97180 /* STDSChallengeResponseSelectionInfoObject.h in Headers */, + 9E46777893FA2D6691F496D7 /* STDSChallengeResponseViewController.h in Headers */, + E7E424C5264A4DE4A1EC19B6 /* STDSChallengeSelectionView.h in Headers */, + F28AD6CA8AB1A5DEE7A8F429 /* STDSDebuggerChecker.h in Headers */, + 57E58A3B88D9EEB9FAE9B383 /* STDSDeviceInformation.h in Headers */, + 9843F6AABF7B0A623E7DF979 /* STDSDeviceInformationManager.h in Headers */, + 3844F0E21742F43BCB32499A /* STDSDeviceInformationParameter+Private.h in Headers */, + 2938D9CC41899DC78D01EF9F /* STDSDeviceInformationParameter.h in Headers */, + C4BA75544A7F51355BF4B5F8 /* STDSDirectoryServer.h in Headers */, + DB96817598FCE73F5131437E /* STDSDirectoryServerCertificate+Internal.h in Headers */, + 63AFB3C739DD987EE56F801F /* STDSDirectoryServerCertificate.h in Headers */, + 5C83A3A4631E51EEECBEAA0F /* STDSEllipticCurvePoint.h in Headers */, + 3343DA94A5032627843A343C /* STDSEphemeralKeyPair+Testing.h in Headers */, + B94E5A5BF5F22998E4CA9ED3 /* STDSEphemeralKeyPair.h in Headers */, + 34256504F0E0E519D586B7CE /* STDSErrorMessage+Internal.h in Headers */, + 149FE0C2910F08910B250BD8 /* STDSException+Internal.h in Headers */, + F35963DB86D90320CFA7BCB9 /* STDSExpandableInformationView.h in Headers */, + DDB98914E1135E8D445545C8 /* STDSIPAddress.h in Headers */, + 613EF855524F7861A6DDD8C1 /* STDSImageLoader.h in Headers */, + D3432C5BF5211A60D5C15D9E /* STDSIntegrityChecker.h in Headers */, + 4C573A44AB0A9CB28D76C621 /* STDSJSONWebEncryption.h in Headers */, + CA617B6F5CDFB8FDEEE38636 /* STDSJSONWebSignature.h in Headers */, + 7C7073B54628ED836F422F1D /* STDSJailbreakChecker.h in Headers */, + 8E0501C3BB849C2D2D99F34E /* STDSLocalizedString.h in Headers */, + 68F314D63323954EFE309E49 /* STDSOSVersionChecker.h in Headers */, + 09F0B1945CC12FB1215119FD /* STDSProcessingView.h in Headers */, + 511513A9180F9835B08CBE56 /* STDSProgressViewController.h in Headers */, + CEA1EB91858043A837638FE0 /* STDSSecTypeUtilities.h in Headers */, + D9F8BA88953A91DC082FA6DA /* STDSSelectionButton.h in Headers */, + AC474013841CE1DED97F3FA8 /* STDSSimulatorChecker.h in Headers */, + AA94519687902E34E5243AEA /* STDSSpacerView.h in Headers */, + 206B93BDE91CFED74A053B87 /* STDSStackView.h in Headers */, + F0B9EFD359B1AB28A695CB48 /* STDSSynchronousLocationManager.h in Headers */, + 59756023104E4FB886B48936 /* STDSTextChallengeView.h in Headers */, + 326AD1DD7BB8A2A6DA66EC67 /* STDSThreeDSProtocolVersion+Private.h in Headers */, + AE1894FEA510AC394DE73C4B /* STDSTransaction+Private.h in Headers */, + 7EC8E5523F29B0F01FE827DB /* STDSWebView.h in Headers */, + 825ACBE0734BDCE746E66AB0 /* STDSWhitelistView.h in Headers */, + 75CDFDABD7F9813563E3F618 /* Stripe3DS2-Bridging-Header.h in Headers */, + 0A2BC6A9E388B242C46C09D9 /* UIButton+CustomInitialization.h in Headers */, + 8322973CD09F2E1C88B6046E /* UIColor+DefaultColors.h in Headers */, + 27F3EA1BB7E7B56A1A8524BA /* UIColor+ThirteenSupport.h in Headers */, + 8E833B7FCBC3A99072582FEA /* UIFont+DefaultFonts.h in Headers */, + A026717FFF299A7C1C51565F /* UIView+LayoutSupport.h in Headers */, + 41246FCC0695BABE1489C53E /* UIViewController+Stripe3DS2.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 57AEC53510AE0DC0539730F3 /* Stripe3DS2 */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5C9BBB6FD4175090CFCAA126 /* Build configuration list for PBXNativeTarget "Stripe3DS2" */; + buildPhases = ( + A8117E5A795D5342E5AEF24C /* Headers */, + 559A957A4AA29D178C3E3D8F /* Sources */, + 563147EAF54FC4A425A06D46 /* Resources */, + 726DD7176204D90BD7552B84 /* Embed Frameworks */, + C65BFA70E847549921E39F4E /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Stripe3DS2; + productName = Stripe3DS2; + productReference = CE4030C50383B488C97F4B56 /* Stripe3DS2.framework */; + productType = "com.apple.product-type.framework"; + }; + 5907C55B1F111921112DF2BF /* Stripe3DS2DemoUI */ = { + isa = PBXNativeTarget; + buildConfigurationList = 37237B2A5629D2257C454AD1 /* Build configuration list for PBXNativeTarget "Stripe3DS2DemoUI" */; + buildPhases = ( + 5D618F152EF19018F5C7E579 /* Sources */, + C4175027D57AF53025CB9719 /* Resources */, + 00E415680C602ED1D6D8E71F /* Embed Frameworks */, + 8ECF69FED0DDA991C821CF6A /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + C7515969CD69027C276886AB /* PBXTargetDependency */, + ); + name = Stripe3DS2DemoUI; + productName = Stripe3DS2DemoUI; + productReference = 02D762271DBCC1AE80E0F9D4 /* Stripe3DS2DemoUI.app */; + productType = "com.apple.product-type.application"; + }; + 7DA168BC86CE957505FA091B /* Stripe3DS2DemoUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5F64A3CED17753DA2643AA63 /* Build configuration list for PBXNativeTarget "Stripe3DS2DemoUITests" */; + buildPhases = ( + 7A43CA17E1E2D826DCBDD88D /* Sources */, + F3AE035E64AB50AB8B90A58E /* Resources */, + 58FA2FE74AE67CD3CDAFD7E5 /* Embed Frameworks */, + A56B40D43D552FDE77670CB5 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 0AE2FB81071258D69C9FCAD4 /* PBXTargetDependency */, + 026B55E92602E24EE7139E1F /* PBXTargetDependency */, + ); + name = Stripe3DS2DemoUITests; + packageProductDependencies = ( + 0118969F6608A92583CC6C98 /* iOSSnapshotTestCase */, + ); + productName = Stripe3DS2DemoUITests; + productReference = AA8A113E655E23381CB3BDA4 /* Stripe3DS2DemoUITests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + F2D50FA32F27498F56CD08DD /* Stripe3DS2Tests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 271DFDD5ABBB719F3BD1AB56 /* Build configuration list for PBXNativeTarget "Stripe3DS2Tests" */; + buildPhases = ( + 0D8CCC419762E2FD4F945EB0 /* Sources */, + 4FDEAB55B960B360B76B3F41 /* Resources */, + 43B749EC915FB6D2FF5ABEE0 /* Embed Frameworks */, + 16B0A41D6C0F157DDF229397 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 798525809F1F67D0C6634927 /* PBXTargetDependency */, + ); + name = Stripe3DS2Tests; + productName = Stripe3DS2Tests; + productReference = 4EB746D4E46ABD2452A154AE /* Stripe3DS2Tests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 8D219187B704575AD3CD3EC5 /* Project object */ = { + isa = PBXProject; + attributes = { + TargetAttributes = { + 7DA168BC86CE957505FA091B = { + TestTargetID = 5907C55B1F111921112DF2BF; + }; + }; + }; + buildConfigurationList = 57B08C966701355644CF5CEB /* Build configuration list for PBXProject "Stripe3DS2" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + "bg-BG", + "ca-ES", + "cs-CZ", + da, + de, + "el-GR", + en, + "en-GB", + es, + "es-419", + "et-EE", + fi, + fil, + fr, + "fr-CA", + hr, + hu, + id, + it, + ja, + ko, + "lt-LT", + "lv-LV", + "ms-MY", + mt, + nb, + nl, + "nn-NO", + "pl-PL", + "pt-BR", + "pt-PT", + "ro-RO", + ru, + "sk-SK", + "sl-SI", + sv, + tr, + vi, + "zh-HK", + "zh-Hans", + "zh-Hant", + ); + mainGroup = 18B6E1F5666C797A11E2BE03; + packageReferences = ( + 176A29DF97FAFE939C7F667B /* XCRemoteSwiftPackageReference "ios-snapshot-test-case" */, + ); + productRefGroup = 398BBCD5E4B06CA5006CCC40 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 57AEC53510AE0DC0539730F3 /* Stripe3DS2 */, + F2D50FA32F27498F56CD08DD /* Stripe3DS2Tests */, + 5907C55B1F111921112DF2BF /* Stripe3DS2DemoUI */, + 7DA168BC86CE957505FA091B /* Stripe3DS2DemoUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 4FDEAB55B960B360B76B3F41 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5B16918BF5F67B0F5FB1AFD5 /* ARes.json in Resources */, + F4E8353A470750D9BCEA3CD8 /* CRes.json in Resources */, + E7A72BB7CC8DE8DA7FA0DEC5 /* ErrorMessage.json in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 563147EAF54FC4A425A06D46 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A97E1DF84D381952A0FB4C1C /* amex.der in Resources */, + EA89021709AFB5F04961EB78 /* cartes-bancaires.der in Resources */, + 3D674B4EE5ABACD65EB21A1E /* discover.der in Resources */, + FD6456BD86F07C446B9FB6C8 /* ec_test.der in Resources */, + 081347606D48F64161D95B45 /* mastercard.der in Resources */, + 81B1C12449852CEB3C59793F /* ul-test.der in Resources */, + 125427F1322501AA12EAEBE6 /* visa.der in Resources */, + 2D42EA44FC01C834209CFED4 /* Stripe3DS2.xcassets in Resources */, + 6BA3F565B6C0B78F6DED8826 /* Localizable.strings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C4175027D57AF53025CB9719 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7C20B044ACFD3A50FD4AC9A8 /* acs_challenge.html in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F3AE035E64AB50AB8B90A58E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 0D8CCC419762E2FD4F945EB0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B426123CC7FCA337E13304F9 /* NSDictionary+DecodingHelpersTest.m in Sources */, + E73028FD4537F6E48F927127 /* NSString+EmptyCheckingTests.m in Sources */, + D1427392DA8AD8F5B4118781 /* STDSACSNetworkingManagerTest.m in Sources */, + A33A549CF12209280E3B1DBA /* STDSAuthenticationRequestParametersTest.m in Sources */, + 5DAC25EBAB19555229654E44 /* STDSAuthenticationResponseTests.m in Sources */, + A56AE2CD941D7ADAC7FC8BCC /* STDSBase64URLEncodingTests.m in Sources */, + D63E3DDC92DD46062A265D86 /* STDSChallengeParametersTests.m in Sources */, + 4A9423CC79312BC3A0DEC38B /* STDSChallengeRequestParametersTest.m in Sources */, + ECF8755D2602E0E06DCB3210 /* STDSChallengeResponseObjectTest.m in Sources */, + 32BE38D04DD76CA4490389C6 /* STDSConfigParametersTests.m in Sources */, + B1C2AA6259CE34B0042A1FB1 /* STDSDeviceInformationManagerTests.m in Sources */, + 2B09B7FDF8C4AA551F7EE18E /* STDSDeviceInformationParameterTests.m in Sources */, + E4DA2CE8DC8C60DE40222185 /* STDSDirectoryServerCertificateTests.m in Sources */, + 9F52F67A1A3A2199AEA598FC /* STDSEllipticCurvePointTests.m in Sources */, + BFC5852E14724750E70DA2A6 /* STDSEphemeralKeyPairTests.m in Sources */, + 1767509FDC216602A5E43F0E /* STDSErrorMessageTest.m in Sources */, + D189220981C7705913D93687 /* STDSJSONEncoderTest.m in Sources */, + 98075195759ABB91B0D19847 /* STDSJSONWebEncryptionTests.m in Sources */, + 4C0E13298F08D391FE8600CF /* STDSJSONWebSignatureTests.m in Sources */, + 480CD62EC91F4796D1D8D8DC /* STDSSecTypeUtilitiesTests.m in Sources */, + BA65731CDB75C3124834586C /* STDSSynchronousLocationManagerTests.m in Sources */, + EA19A7AEC298F3EC010D1199 /* STDSTestJSONUtils.m in Sources */, + D927789F89B413C08DE4D388 /* STDSThreeDS2ServiceTests.m in Sources */, + 9B7081D378D1441B98B81207 /* STDSTransactionTest.m in Sources */, + 83878337274D8C43D492EC45 /* STDSUICustomizationTests.m in Sources */, + 30632F7C730BF8D23B607CF7 /* STDSWarningTests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 559A957A4AA29D178C3E3D8F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A8CAD8276ED8057A760147CB /* NSData+JWEHelpers.m in Sources */, + 0D4B42EC5019D213BDBC84E7 /* NSDictionary+DecodingHelpers.m in Sources */, + 089DAFECA444861762781974 /* NSError+Stripe3DS2.m in Sources */, + 9ED9C602C10682CD5DD91C12 /* NSLayoutConstraint+LayoutSupport.m in Sources */, + DEEF260365A4D9B0D83E557D /* NSString+EmptyChecking.m in Sources */, + 88197445522F39DB1E9F6AE7 /* NSString+JWEHelpers.m in Sources */, + B31168F5FEC3464AC212DB85 /* STDSACSNetworkingManager.m in Sources */, + 5FE5FB933E2651C8D84FA477 /* STDSAuthenticationResponseObject.m in Sources */, + 75150C4678B8207AA6E7D969 /* STDSBrandingView.m in Sources */, + 8233B9F131602DD7143FF96F /* STDSBundleLocator.m in Sources */, + 14BDBD98F8E15576403255C9 /* STDSChallengeInformationView.m in Sources */, + 2970D4880EBFAEFABA2E8EBD /* STDSChallengeRequestParameters.m in Sources */, + 2AB06EC20B22306F1DDF9F4B /* STDSChallengeResponseImageObject.m in Sources */, + B9A456E52E62E9DAE9F424A7 /* STDSChallengeResponseMessageExtensionObject.m in Sources */, + 09DFAF7B38EAB76BC66AC9C8 /* STDSChallengeResponseObject.m in Sources */, + AA4D157F31A246890D446981 /* STDSChallengeResponseSelectionInfoObject.m in Sources */, + CCC376FE7424029342A8D15E /* STDSChallengeResponseViewController.m in Sources */, + 4FE8C076102C195D609977F5 /* STDSChallengeSelectionView.m in Sources */, + A6909D6C1A68963504733939 /* STDSDebuggerChecker.m in Sources */, + E630DCBBEFAAD0435BEF989C /* STDSDeviceInformation.m in Sources */, + 3F2624C33291FCE00D596768 /* STDSDeviceInformationManager.m in Sources */, + 0EEFDE04392FD017EA0A017A /* STDSDeviceInformationParameter.m in Sources */, + 9F9BB9E7FA18FEDA44F7042E /* STDSDirectoryServerCertificate.m in Sources */, + 6B3AA85EE71FC42EF9BD999F /* STDSEllipticCurvePoint.m in Sources */, + 05647ABCFBDBE1209D5044AC /* STDSEphemeralKeyPair.m in Sources */, + 2E2022723BC7A4B2DD5ECE76 /* STDSErrorMessage+Internal.m in Sources */, + 4142A3AB3E384F91A1E5550B /* STDSExpandableInformationView.m in Sources */, + 9146B2E3B89EF3F489D7E233 /* STDSIPAddress.m in Sources */, + 136255493F3F0E930FBA8606 /* STDSImageLoader.m in Sources */, + EB791827A943AA61976D8C29 /* STDSIntegrityChecker.m in Sources */, + 955CBE0C979291F89567D8A7 /* STDSJSONWebEncryption.m in Sources */, + EFC72E766F9FD4EE18D309BC /* STDSJSONWebSignature.m in Sources */, + BA00D11D792C99B7D5749F59 /* STDSJailbreakChecker.m in Sources */, + 602C526C0B52DF81846DA664 /* STDSOSVersionChecker.m in Sources */, + 574A7976213046F02F7F60C4 /* STDSProcessingView.m in Sources */, + 1156B25EBE0135B627E174D2 /* STDSProgressViewController.m in Sources */, + DBDB8B56FFF5C4234705E698 /* STDSSecTypeUtilities.m in Sources */, + D41F091BE1579B801D1BBC20 /* STDSSelectionButton.m in Sources */, + 4518AD83580FC222B883DAFB /* STDSSimulatorChecker.m in Sources */, + 3F30A8F418870D176A626F00 /* STDSSpacerView.m in Sources */, + 3909D8028AC485BCCF17B48B /* STDSStackView.m in Sources */, + E0644CE0260178023E643B23 /* STDSSynchronousLocationManager.m in Sources */, + D5706ACF84F0A993CE1885D4 /* STDSTextChallengeView.m in Sources */, + 1B20B454D0C309613A1FAF68 /* STDSThreeDSProtocolVersion.m in Sources */, + 884D59AF7FD70889276AFC02 /* STDSWebView.m in Sources */, + E70CFDC59731BA62DD89906C /* STDSWhitelistView.m in Sources */, + A4CFDBFC3099BD7E1A694E3E /* UIButton+CustomInitialization.m in Sources */, + 2086DFD1FC02783FB106AD35 /* UIColor+DefaultColors.m in Sources */, + E19C8104FC1A9BAEA0BE8A9D /* UIColor+ThirteenSupport.m in Sources */, + 987347CA74818CEB716B9DB3 /* UIFont+DefaultFonts.m in Sources */, + 390691CA0EAF81418B0C65DB /* UIView+LayoutSupport.m in Sources */, + 08ECC7E70E7478E76793ED1E /* UIViewController+Stripe3DS2.m in Sources */, + D83D2FF20387FACC383A2A0F /* STDSAlreadyInitializedException.m in Sources */, + 7ED98325E7E54A54BFE54D6C /* STDSAuthenticationRequestParameters.m in Sources */, + C430332104547BA508416B41 /* STDSButtonCustomization.m in Sources */, + 6DB54DBAEFD08967367B6BF7 /* STDSChallengeParameters.m in Sources */, + FD8A81873C3BC7579ED07460 /* STDSCompletionEvent.m in Sources */, + 8A0872706BF4CDE72EF0C480 /* STDSConfigParameters.m in Sources */, + BF2651A9F3A31CB1AFEC44BB /* STDSCustomization.m in Sources */, + 8C9BD4B150F384111CBD3BD3 /* STDSErrorMessage.m in Sources */, + 3C88AE37A760B91E93D423CF /* STDSException.m in Sources */, + F569B584D357DC5C55542015 /* STDSFooterCustomization.m in Sources */, + EE3854BC2A14F87E8D51B0D0 /* STDSInvalidInputException.m in Sources */, + 0D3D9C95CB14A610EAAAEF6E /* STDSJSONEncoder.m in Sources */, + EEE77034728D2CFE38CC5A85 /* STDSLabelCustomization.m in Sources */, + BB379E68231A7DA62F87E628 /* STDSNavigationBarCustomization.m in Sources */, + 8A64AFBB2AC15E0E0F57ED25 /* STDSNotInitializedException.m in Sources */, + B51D1C218F460F67B58A1F0D /* STDSProtocolErrorEvent.m in Sources */, + 64D05353B50FAF3BB76D6527 /* STDSRuntimeErrorEvent.m in Sources */, + 01706B4660728A5BFAC12840 /* STDSRuntimeException.m in Sources */, + D3AA14D1E059E90F5A965CC4 /* STDSSelectionCustomization.m in Sources */, + 2C5621AABECF6192F92A20CF /* STDSStripe3DS2Error.m in Sources */, + 4E4E4BC1E2D041FA530336EE /* STDSSwiftTryCatch.m in Sources */, + 284F72D3FD8C06C0E5974086 /* STDSTextFieldCustomization.m in Sources */, + 24F626CD0F93B7AF59B1D6AA /* STDSThreeDS2Service.m in Sources */, + 6E727D2B738D7A3527952D28 /* STDSTransaction.m in Sources */, + DF4D38DC0AF89EAAA8718AAE /* STDSUICustomization.m in Sources */, + E08AF96E690D75F6AB52021F /* STDSWarning.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5D618F152EF19018F5C7E579 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B84ED7FBD49865F23B774067 /* AppDelegate.m in Sources */, + A39CFB56BF33B21A89987F9C /* STDSChallengeResponseObject+TestObjects.m in Sources */, + 128B64380D6724F016EE8D56 /* STDSDemoViewController.m in Sources */, + B012C4A8ACA1D064C40A1B63 /* main.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7A43CA17E1E2D826DCBDD88D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F939E05536AE838DC489C3AA /* STDSChallengeResponseViewControllerSnapshotTests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 026B55E92602E24EE7139E1F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = Stripe3DS2DemoUI; + target = 5907C55B1F111921112DF2BF /* Stripe3DS2DemoUI */; + targetProxy = 35B6793A7F3FB130F40209F1 /* PBXContainerItemProxy */; + }; + 0AE2FB81071258D69C9FCAD4 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = Stripe3DS2; + target = 57AEC53510AE0DC0539730F3 /* Stripe3DS2 */; + targetProxy = 52E1EB51359DA6E75D851D71 /* PBXContainerItemProxy */; + }; + 798525809F1F67D0C6634927 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = Stripe3DS2; + target = 57AEC53510AE0DC0539730F3 /* Stripe3DS2 */; + targetProxy = 6C355B127D670833C76237D3 /* PBXContainerItemProxy */; + }; + C7515969CD69027C276886AB /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = Stripe3DS2; + target = 57AEC53510AE0DC0539730F3 /* Stripe3DS2 */; + targetProxy = 947ED275EAB25AD4CD701936 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + F31A6580385C6390910AFD93 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 8A6035A8211B3D50D10EB947 /* bg-BG */, + 40DC4913154C3D3A27E35207 /* ca-ES */, + 7859D635964B2854A07B5285 /* cs-CZ */, + 8B8D429F1C03603DB218700F /* da */, + 2C2666DF7D23EBC3FFE7CCF1 /* de */, + 4C158B552B0CBE635A2AF7BB /* el-GR */, + EF5219762616ACF204F08C19 /* en */, + F112967E9FE8EE02FFFDC313 /* en-GB */, + 1BBADD412A7C2D1FF35A7C86 /* es */, + 406D88ADED27D64DED2002A2 /* es-419 */, + 461FDC130846625171164A9E /* et-EE */, + F139E48FDEFD921FF410892F /* fi */, + 49EF79D69693F937FEC46906 /* fil */, + 4BC4D9FD1D3869D491CC2CF8 /* fr */, + E64C700DED163CB77DCDEA78 /* fr-CA */, + 0E38A775DB9F529427E900CF /* hr */, + 371F8076630A8839C1F6A508 /* hu */, + 62773D5C85C567F28BCE0DA5 /* id */, + 13ED1CC076C6B3FB49C9197A /* it */, + 157970FF5B4A817041E3D668 /* ja */, + F86F34D1339F7467D0FDAC75 /* ko */, + 71494F94F36F4B731A27D182 /* lt-LT */, + 38572D4E32BF3670F1C83409 /* lv-LV */, + B1A7D41498B79E35BD724681 /* ms-MY */, + 18D88DE3E34106F934015BF9 /* mt */, + 6B487D8DD39DAC17042A3F04 /* nb */, + 83BDD516620860DC08B36B96 /* nl */, + D3F5A6D5A680F008C00A2D69 /* nn-NO */, + FE1DE9D978E6E682CB094128 /* pl-PL */, + 6690476C1BEA59C37576FB36 /* pt-BR */, + BC3EE020DC9358FF56389BBE /* pt-PT */, + F4D6DB90E3D01495C1F9BFE9 /* ro-RO */, + 694DF6F47767A651907D55D4 /* ru */, + 58C38FF9205C9B7D79C51A37 /* sk-SK */, + 3B9A821F35B93898A7AC1ADF /* sl-SI */, + 38F3A7D625E4B0D3506A37F6 /* sv */, + 2192708AD201F80F6E1A39F7 /* tr */, + B97D91CB54F48F64CAC9E378 /* vi */, + 3F017BF6ECE0EBE4E0DFCF9C /* zh-Hans */, + AF389752CCEB6FA734AAE31E /* zh-Hant */, + CECC1E22D0F0336039B435DE /* zh-HK */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 03D567A9F86C4B204A11D604 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6940D75CA36F7DB9FB56196B /* Stripe3DS2DemoUI-Release.xcconfig */; + buildSettings = { + INFOPLIST_FILE = Stripe3DS2DemoUI/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.Stripe3DS2DemoUI; + PRODUCT_NAME = Stripe3DS2DemoUI; + SDKROOT = iphoneos; + }; + name = Release; + }; + 380615C693D7BF5A8884139A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5316D0759F7F2ED0BF1D3B2E /* Stripe3DS2DemoUI-Debug.xcconfig */; + buildSettings = { + INFOPLIST_FILE = Stripe3DS2DemoUI/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.Stripe3DS2DemoUI; + PRODUCT_NAME = Stripe3DS2DemoUI; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 437A50F3FA4CE0127490A271 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4DB91F58CF23E72636C69C41 /* Project-Release.xcconfig */; + buildSettings = { + }; + name = Release; + }; + 7C7BC68E9581B5EDB7602612 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D7A9D36FEF7E97CF735D82B8 /* Stripe3DS2DemoUITests-Release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = Stripe3DS2DemoUITests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.Stripe3DS2DemoUITests; + PRODUCT_NAME = Stripe3DS2DemoUITests; + SDKROOT = iphoneos; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Stripe3DS2DemoUI.app/Stripe3DS2DemoUI"; + TEST_TARGET_NAME = Stripe3DS2DemoUI; + }; + name = Release; + }; + 872D92C1EE444AB448EB744C /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F5686FB8EFA3AE3B39F85BBC /* Stripe3DS2Tests-Release.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = Stripe3DS2Tests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.Stripe3DS2Tests; + PRODUCT_NAME = Stripe3DS2Tests; + SDKROOT = iphoneos; + }; + name = Release; + }; + 8CD9CB7B7E57B44EE65EF421 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3A8CD68A006F970DDC54469A /* Project-Debug.xcconfig */; + buildSettings = { + }; + name = Debug; + }; + A084DA5D39CBD41FD4E5294E /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F2BC7014D26158AC9D74CC67 /* Stripe3DS2Tests-Debug.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = Stripe3DS2Tests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.Stripe3DS2Tests; + PRODUCT_NAME = Stripe3DS2Tests; + SDKROOT = iphoneos; + }; + name = Debug; + }; + C34CBE7C8AA7B8ADB823B098 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0E3C05BE55CF3086738AA182 /* Stripe3DS2DemoUITests-Debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = Stripe3DS2DemoUITests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.Stripe3DS2DemoUITests; + PRODUCT_NAME = Stripe3DS2DemoUITests; + SDKROOT = iphoneos; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Stripe3DS2DemoUI.app/Stripe3DS2DemoUI"; + TEST_TARGET_NAME = Stripe3DS2DemoUI; + }; + name = Debug; + }; + D4CD9EDC1DA055683F427D24 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F8F6FFED1A0966A49B78F252 /* Stripe3DS2-Release.xcconfig */; + buildSettings = { + INFOPLIST_FILE = Stripe3DS2/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "com.stripe.stripe-3ds2"; + PRODUCT_NAME = Stripe3DS2; + SDKROOT = iphoneos; + }; + name = Release; + }; + FCA19B8EBB1E9578613A65BE /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A9CD7EC589C5CC6CE5CF9310 /* Stripe3DS2-Debug.xcconfig */; + buildSettings = { + INFOPLIST_FILE = Stripe3DS2/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "com.stripe.stripe-3ds2"; + PRODUCT_NAME = Stripe3DS2; + SDKROOT = iphoneos; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 271DFDD5ABBB719F3BD1AB56 /* Build configuration list for PBXNativeTarget "Stripe3DS2Tests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A084DA5D39CBD41FD4E5294E /* Debug */, + 872D92C1EE444AB448EB744C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 37237B2A5629D2257C454AD1 /* Build configuration list for PBXNativeTarget "Stripe3DS2DemoUI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 380615C693D7BF5A8884139A /* Debug */, + 03D567A9F86C4B204A11D604 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 57B08C966701355644CF5CEB /* Build configuration list for PBXProject "Stripe3DS2" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8CD9CB7B7E57B44EE65EF421 /* Debug */, + 437A50F3FA4CE0127490A271 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5C9BBB6FD4175090CFCAA126 /* Build configuration list for PBXNativeTarget "Stripe3DS2" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FCA19B8EBB1E9578613A65BE /* Debug */, + D4CD9EDC1DA055683F427D24 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5F64A3CED17753DA2643AA63 /* Build configuration list for PBXNativeTarget "Stripe3DS2DemoUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C34CBE7C8AA7B8ADB823B098 /* Debug */, + 7C7BC68E9581B5EDB7602612 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 176A29DF97FAFE939C7F667B /* XCRemoteSwiftPackageReference "ios-snapshot-test-case" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/uber/ios-snapshot-test-case"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 0118969F6608A92583CC6C98 /* iOSSnapshotTestCase */ = { + isa = XCSwiftPackageProductDependency; + productName = iOSSnapshotTestCase; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 8D219187B704575AD3CD3EC5 /* Project object */; +} diff --git a/Stripe3DS2/Stripe3DS2.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Stripe3DS2/Stripe3DS2.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Stripe3DS2/Stripe3DS2.xcodeproj/xcshareddata/xcschemes/Stripe3DS2.xcscheme b/Stripe3DS2/Stripe3DS2.xcodeproj/xcshareddata/xcschemes/Stripe3DS2.xcscheme new file mode 100644 index 00000000..14b59e6f --- /dev/null +++ b/Stripe3DS2/Stripe3DS2.xcodeproj/xcshareddata/xcschemes/Stripe3DS2.xcscheme @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Stripe3DS2/Stripe3DS2.xcodeproj/xcshareddata/xcschemes/Stripe3DS2DemoUI.xcscheme b/Stripe3DS2/Stripe3DS2.xcodeproj/xcshareddata/xcschemes/Stripe3DS2DemoUI.xcscheme new file mode 100644 index 00000000..b45aa0a9 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2.xcodeproj/xcshareddata/xcschemes/Stripe3DS2DemoUI.xcscheme @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Stripe3DS2/Stripe3DS2/Info.plist b/Stripe3DS2/Stripe3DS2/Info.plist new file mode 100644 index 00000000..e1fe4cfb --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/Stripe3DS2/Stripe3DS2/NSData+JWEHelpers.h b/Stripe3DS2/Stripe3DS2/NSData+JWEHelpers.h new file mode 100644 index 00000000..71159625 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/NSData+JWEHelpers.h @@ -0,0 +1,20 @@ +// +// NSData+JWEHelpers.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/29/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface NSData (JWEHelpers) + +- (nullable NSString *)_stds_base64URLEncodedString; +- (nullable NSString *)_stds_base64URLDecodedString; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/NSData+JWEHelpers.m b/Stripe3DS2/Stripe3DS2/NSData+JWEHelpers.m new file mode 100644 index 00000000..c1bbab4e --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/NSData+JWEHelpers.m @@ -0,0 +1,35 @@ +// +// NSData+JWEHelpers.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/29/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "NSData+JWEHelpers.h" + +#import "NSString+JWEHelpers.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation NSData (STDSJSONWebEncryption) + +- (nullable NSString *)_stds_base64URLEncodedString { + // ref. https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-41#appendix-C + NSString *unpaddedBase64EncodedString = [[[[self base64EncodedStringWithOptions:0] + stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"="]] // remove extra padding + stringByReplacingOccurrencesOfString:@"+" withString:@"-"] // replace "+" character w/ "-" + stringByReplacingOccurrencesOfString:@"/" withString:@"_"]; // replace "/" character w/ "_" + + return unpaddedBase64EncodedString; +} + +- (nullable NSString *)_stds_base64URLDecodedString { + return [[[self base64EncodedStringWithOptions:0] + stringByReplacingOccurrencesOfString:@"-" withString:@"+"] // replace "-" character w/ "+" + stringByReplacingOccurrencesOfString:@"_" withString:@"/"]; // replace "_" character w/ "/" +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/NSDictionary+DecodingHelpers.h b/Stripe3DS2/Stripe3DS2/NSDictionary+DecodingHelpers.h new file mode 100644 index 00000000..70371f9f --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/NSDictionary+DecodingHelpers.h @@ -0,0 +1,47 @@ +// +// NSDictionary+DecodingHelpers.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSJSONDecodable.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + Errors are populated according to the following rules: + - If the field is required and... + - the value is nil or empty -> STDSErrorCodeJSONFieldMissing + - the value is the wrong type -> STDSErrorCodeJSONFieldInvalid + - validator returns NO -> STDSErrorCodeJSONFieldInvalid + + - If the field is not required and... + - the value is nil -> valid, no error + - the value is empty -> STDSErrorCodeJSONFieldInvalid + - the value is the wrong type -> STDSErrorCodeJSONFieldInvalid + - validator returns NO -> STDSErrorCodeJSONFieldInvalid + */ +@interface NSDictionary (DecodingHelpers) + +/// Convenience method to extract an NSArray and populate it with instances of arrayElementType. +/// If isRequired is YES, returns nil without error if the key is not present +- (nullable NSArray *)_stds_arrayForKey:(NSString *)key arrayElementType:(Class)arrayElementType required:(BOOL)isRequired error:(NSError **)error; + +- (nullable NSURL *)_stds_urlForKey:(NSString *)key required:(BOOL)isRequired error:(NSError **)error; + +- (nullable NSDictionary *)_stds_dictionaryForKey:(NSString *)key required:(BOOL)isRequired error:(NSError **)error; + +- (nullable NSNumber *)_stds_boolForKey:(NSString *)key required:(BOOL)isRequired error:(NSError **)error; + +/// Convenience method that calls `_stpStringForKey:validator:required:error:`, passing nil for the validator argument +- (nullable NSString *)_stds_stringForKey:(NSString *)key required:(BOOL)isRequired error:(NSError **)error; + +- (nullable NSString *)_stds_stringForKey:(NSString *)key validator:(nullable BOOL (^)(NSString *))validatorBlock required:(BOOL)isRequired error:(NSError **)error; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/NSDictionary+DecodingHelpers.m b/Stripe3DS2/Stripe3DS2/NSDictionary+DecodingHelpers.m new file mode 100644 index 00000000..4d2e2c09 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/NSDictionary+DecodingHelpers.m @@ -0,0 +1,147 @@ +// +// NSDictionary+DecodingHelpers.m +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "NSDictionary+DecodingHelpers.h" + +#import "NSError+Stripe3DS2.h" + +@implementation NSDictionary (DecodingHelpers) + +#pragma mark - NSArray + +- (nullable NSArray *)_stds_arrayForKey:(NSString *)key arrayElementType:(Class)arrayElementType required:(BOOL)isRequired error:(NSError * _Nullable __autoreleasing * _Nullable)error { + id value = self[key]; + + // Missing? + if (value == nil || ([value isKindOfClass:[NSArray class]] && ((NSArray *)value).count == 0)) { + if (isRequired && error) { + *error = [NSError _stds_missingJSONFieldError:key]; + } + return nil; + } + + // Invalid type or value? + if (![value isKindOfClass:[NSArray class]]) { + if (error) { + *error = [NSError _stds_invalidJSONFieldError:key]; + } + return nil; + } + + NSMutableArray *returnArray = [NSMutableArray new]; + for (id json in value) { + if (![json isKindOfClass:[NSDictionary class]]) { + if (error) { + *error = [NSError _stds_invalidJSONFieldError:key]; + } + return nil; + } + id element = [arrayElementType decodedObjectFromJSON:json error:error]; + if (element) { + [returnArray addObject:element]; + } + } + + return returnArray; +} + +#pragma mark - NSURL + +- (nullable NSURL *)_stds_urlForKey:(NSString *)key required:(BOOL)isRequired error:(NSError * _Nullable __autoreleasing *)error { + NSString *urlRawString = [self _stds_stringForKey:key validator:^BOOL (NSString *value) { + return [NSURL URLWithString:value] != nil; + } required:isRequired error:error]; + + if (urlRawString) { + return [NSURL URLWithString:urlRawString]; + } else { + return nil; + } +} + +#pragma mark - NSDictionary + +- (nullable NSDictionary *)_stds_dictionaryForKey:(NSString *)key required:(BOOL)isRequired error:(NSError * _Nullable __autoreleasing *)error { + id value = self[key]; + + // Missing? + if (value == nil) { + if (error && isRequired) { + *error = [NSError _stds_missingJSONFieldError:key]; + } + return nil; + } + + // Invalid type? + if (![value isKindOfClass:[NSDictionary class]]) { + if (error) { + *error = [NSError _stds_invalidJSONFieldError:key]; + } + return nil; + } + + return value; +} + +#pragma mark - NSString + +- (nullable NSString *)_stds_stringForKey:(NSString *)key required:(BOOL)isRequired error:(NSError * _Nullable __autoreleasing * _Nullable)error { + return [self _stds_stringForKey:key validator:nil required:isRequired error:error]; +} + +- (nullable NSString *)_stds_stringForKey:(NSString *)key validator:(nullable BOOL (^)(NSString * _Nonnull))validatorBlock required:(BOOL)isRequired error:(NSError * _Nullable __autoreleasing * _Nullable)error { + id value = self[key]; + + // Missing? + if (value == nil || ([value isKindOfClass:[NSString class]] && ((NSString *)value).length == 0)) { + if (error) { + if (isRequired) { + *error = [NSError _stds_missingJSONFieldError:key]; + } else if (value != nil) { + *error = [NSError _stds_invalidJSONFieldError:key]; + } + } + return nil; + } + + // Invalid type or value? + if (![value isKindOfClass:[NSString class]] || (validatorBlock && !validatorBlock(value))) { + if (error) { + *error = [NSError _stds_invalidJSONFieldError:key]; + } + return nil; + } + + return value; +} + +#pragma mark - NSURL + +- (NSNumber *)_stds_boolForKey:(NSString *)key required:(BOOL)isRequired error:(NSError * _Nullable __autoreleasing *)error { + id value = self[key]; + + // Missing? + if (value == nil) { + if (error && isRequired) { + *error = [NSError _stds_missingJSONFieldError:key]; + } + return nil; + } + + // Invalid type? + if (![value isKindOfClass:[NSNumber class]]) { + if (error) { + *error = [NSError _stds_invalidJSONFieldError:key]; + } + return nil; + } + + return value; +} + +@end diff --git a/Stripe3DS2/Stripe3DS2/NSError+Stripe3DS2.h b/Stripe3DS2/Stripe3DS2/NSError+Stripe3DS2.h new file mode 100644 index 00000000..6af09054 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/NSError+Stripe3DS2.h @@ -0,0 +1,32 @@ +// +// NSError+Stripe3DS2.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface NSError (Stripe3DS2) + + +/// Represents an error where a JSON field value is not valid (e.g. expected 'Y' or 'N' but received something else). ++ (instancetype)_stds_invalidJSONFieldError:(NSString *)fieldName; + +/// Represents an error where a JSON field was either required or conditionally required but missing, empty, or null. ++ (instancetype)_stds_missingJSONFieldError:(NSString *)fieldName; + +/// Represents an error where a network request timed out. ++ (instancetype)_stds_timedOutError; + +// We explicitly do not provide any more info here based on security recommendations +// "the recipient MUST NOT distinguish between format, padding, and length errors of encrypted keys" +// https://tools.ietf.org/html/rfc7516#section-11.5 ++ (instancetype)_stds_jweError; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/NSError+Stripe3DS2.m b/Stripe3DS2/Stripe3DS2/NSError+Stripe3DS2.m new file mode 100644 index 00000000..cd60c4cc --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/NSError+Stripe3DS2.m @@ -0,0 +1,38 @@ +// +// NSError+Stripe3DS2.m +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "NSError+Stripe3DS2.h" +#import "STDSLocalizedString.h" + +#import "STDSStripe3DS2Error.h" + +@implementation NSError (Stripe3DS2) + ++ (instancetype)_stds_invalidJSONFieldError:(NSString *)fieldName { + return [NSError errorWithDomain:STDSStripe3DS2ErrorDomain + code:STDSErrorCodeJSONFieldInvalid + userInfo:@{STDSStripe3DS2ErrorFieldKey: fieldName}]; +} + ++ (instancetype)_stds_missingJSONFieldError:(NSString *)fieldName { + return [NSError errorWithDomain:STDSStripe3DS2ErrorDomain + code:STDSErrorCodeJSONFieldMissing + userInfo:@{STDSStripe3DS2ErrorFieldKey: fieldName}]; +} + ++ (instancetype)_stds_timedOutError { + return [NSError errorWithDomain:STDSStripe3DS2ErrorDomain + code:STDSErrorCodeTimeout + userInfo:@{NSLocalizedDescriptionKey : STDSLocalizedString(@"Timeout", @"Error description for when a network request times out. English value is as required by UL certification.")}]; +} + ++ (instancetype)_stds_jweError { + return [[NSError alloc] initWithDomain:STDSStripe3DS2ErrorDomain code:STDSErrorCodeDecryptionVerification userInfo:nil]; +} + +@end diff --git a/Stripe3DS2/Stripe3DS2/NSLayoutConstraint+LayoutSupport.h b/Stripe3DS2/Stripe3DS2/NSLayoutConstraint+LayoutSupport.h new file mode 100644 index 00000000..66596c81 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/NSLayoutConstraint+LayoutSupport.h @@ -0,0 +1,53 @@ +// +// NSLayoutConstraint+LayoutSupport.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface NSLayoutConstraint (LayoutSupport) + +/** + Provides an NSLayoutConstraint where the `NSLayoutAttributeTop` is equal for both views, with a multiplier of 1, and a constant of 0. + + @param view1 The view to constrain. + @param view2 The view to constraint to. + @return An NSLayoutConstraint that is constraining the first view to the second at the top. + */ ++ (NSLayoutConstraint *)_stds_topConstraintWithItem:(id)view1 toItem:(id)view2; + +/** + Provides an NSLayoutConstraint where the `NSLayoutAttributeLeft` is equal for both views, with a multiplier of 1, and a constant of 0. + + @param view1 The view to constrain. + @param view2 The view to constraint to. + @return An NSLayoutConstraint that is constraining the first view to the second on the left. + */ ++ (NSLayoutConstraint *)_stds_leftConstraintWithItem:(id)view1 toItem:(id)view2; + +/** + Provides an NSLayoutConstraint where the `NSLayoutAttributeRight` is equal for both views, with a multiplier of 1, and a constant of 0. + + @param view1 The view to constrain. + @param view2 The view to constraint to. + @return An NSLayoutConstraint that is constraining the first view to the second on the right. + */ ++ (NSLayoutConstraint *)_stds_rightConstraintWithItem:(id)view1 toItem:(id)view2; + +/** + Provides an NSLayoutConstraint where the `NSLayoutAttributeBottom` is equal for both views, with a multiplier of 1, and a constant of 0. + + @param view1 The view to constrain. + @param view2 The view to constraint to. + @return An NSLayoutConstraint that is constraining the first view to the second at the bottom. + */ ++ (NSLayoutConstraint *)_stds_bottomConstraintWithItem:(id)view1 toItem:(id)view2; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/NSLayoutConstraint+LayoutSupport.m b/Stripe3DS2/Stripe3DS2/NSLayoutConstraint+LayoutSupport.m new file mode 100644 index 00000000..8b73b5fe --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/NSLayoutConstraint+LayoutSupport.m @@ -0,0 +1,30 @@ +// +// NSLayoutConstraint+LayoutSupport.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "NSLayoutConstraint+LayoutSupport.h" + +@implementation NSLayoutConstraint (LayoutSupport) + + ++ (NSLayoutConstraint *)_stds_topConstraintWithItem:(id)view1 toItem:(id)view2 { + return [NSLayoutConstraint constraintWithItem:view1 attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:view2 attribute:NSLayoutAttributeTop multiplier:1 constant:0]; +} + ++ (NSLayoutConstraint *)_stds_leftConstraintWithItem:(id)view1 toItem:(id)view2 { + return [NSLayoutConstraint constraintWithItem:view1 attribute:NSLayoutAttributeLeft relatedBy:NSLayoutRelationEqual toItem:view2 attribute:NSLayoutAttributeLeft multiplier:1 constant:0]; +} + ++ (NSLayoutConstraint *)_stds_rightConstraintWithItem:(id)view1 toItem:(id)view2 { + return [NSLayoutConstraint constraintWithItem:view1 attribute:NSLayoutAttributeRight relatedBy:NSLayoutRelationEqual toItem:view2 attribute:NSLayoutAttributeRight multiplier:1 constant:0]; +} + ++ (NSLayoutConstraint *)_stds_bottomConstraintWithItem:(id)view1 toItem:(id)view2 { + return [NSLayoutConstraint constraintWithItem:view1 attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:view2 attribute:NSLayoutAttributeBottom multiplier:1 constant:0]; +} + +@end diff --git a/Stripe3DS2/Stripe3DS2/NSString+EmptyChecking.h b/Stripe3DS2/Stripe3DS2/NSString+EmptyChecking.h new file mode 100644 index 00000000..aa019bf0 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/NSString+EmptyChecking.h @@ -0,0 +1,19 @@ +// +// NSString+EmptyChecking.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/4/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface NSString (EmptyChecking) + ++ (BOOL)_stds_isStringEmpty:(NSString *)string; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/NSString+EmptyChecking.m b/Stripe3DS2/Stripe3DS2/NSString+EmptyChecking.m new file mode 100644 index 00000000..bf749718 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/NSString+EmptyChecking.m @@ -0,0 +1,25 @@ +// +// NSString+EmptyChecking.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/4/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "NSString+EmptyChecking.h" + +@implementation NSString (EmptyChecking) + ++ (BOOL)_stds_isStringEmpty:(NSString *)string { + if (string.length == 0) { + return YES; + } + + if(![string stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]].length) { + return YES; + } + + return NO; +} + +@end diff --git a/Stripe3DS2/Stripe3DS2/NSString+JWEHelpers.h b/Stripe3DS2/Stripe3DS2/NSString+JWEHelpers.h new file mode 100644 index 00000000..acb9afde --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/NSString+JWEHelpers.h @@ -0,0 +1,21 @@ +// +// NSString+JWEHelpers.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/29/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface NSString (JWEHelpers) + +- (nullable NSString *)_stds_base64URLEncodedString; +- (nullable NSString *)_stds_base64URLDecodedString; +- (nullable NSData *)_stds_base64URLDecodedData; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/NSString+JWEHelpers.m b/Stripe3DS2/Stripe3DS2/NSString+JWEHelpers.m new file mode 100644 index 00000000..2c8d5ad8 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/NSString+JWEHelpers.m @@ -0,0 +1,54 @@ +// +// NSString+JWEHelpers.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/29/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "NSString+JWEHelpers.h" + +#import "NSData+JWEHelpers.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation NSString (JWEHelpers) + +- (nullable NSString *)_stds_base64URLEncodedString { + return [[self dataUsingEncoding:NSUTF8StringEncoding] _stds_base64URLEncodedString]; +} + +- (nullable NSString *)_stds_base64URLDecodedString { + NSData *data = [self _stds_base64URLDecodedData]; + return data != nil ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : nil; +} + +// ref. https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-41#appendix-C +- (nullable NSData *)_stds_base64URLDecodedData { + NSCharacterSet *illegalBase64Chars = [NSCharacterSet characterSetWithCharactersInString:@"+/ \n"]; // TC_SDK_10556_001 & TC_SDK_10557_001 & TC_SDK_10558_001 & TC_SDK_10559_001 + if ([self hasSuffix:@"="] || [self rangeOfCharacterFromSet:illegalBase64Chars].location != NSNotFound) { + return nil; // invalid base64url string TC_SDK_10554_001 & TC_SDK_10555_001 + } + NSMutableString *decodedString = [[[self stringByReplacingOccurrencesOfString:@"-" withString:@"+"] // replace "-" character w/ "+" + stringByReplacingOccurrencesOfString:@"_" withString:@"/"] mutableCopy]; // replace "_" character w/ "/"]; + + switch (decodedString.length % 4) { + case 0: + break; // no padding needed + case 2: + [decodedString appendString:@"=="]; // pad with 2 + break; + case 3: + [decodedString appendString:@"="]; // pad with 1 + break; + default: + return nil; // invalid base64url string + + } + + return [[NSData alloc] initWithBase64EncodedString:decodedString options:0]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/amex.der b/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/amex.der new file mode 100644 index 00000000..61a1cfd1 Binary files /dev/null and b/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/amex.der differ diff --git a/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/cartes-bancaires.der b/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/cartes-bancaires.der new file mode 100644 index 00000000..23d14ff0 Binary files /dev/null and b/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/cartes-bancaires.der differ diff --git a/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/discover.der b/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/discover.der new file mode 100644 index 00000000..5b17935a Binary files /dev/null and b/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/discover.der differ diff --git a/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/ec_test.der b/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/ec_test.der new file mode 100644 index 00000000..06587c44 Binary files /dev/null and b/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/ec_test.der differ diff --git a/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/mastercard.der b/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/mastercard.der new file mode 100644 index 00000000..6e136e13 Binary files /dev/null and b/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/mastercard.der differ diff --git a/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/ul-test.der b/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/ul-test.der new file mode 100644 index 00000000..11e5f29b Binary files /dev/null and b/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/ul-test.der differ diff --git a/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/visa.der b/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/visa.der new file mode 100644 index 00000000..a77735df Binary files /dev/null and b/Stripe3DS2/Stripe3DS2/Resources/CertificateFiles/visa.der differ diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/Chevron.imageset/Chevron@1x.png b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/Chevron.imageset/Chevron@1x.png new file mode 100644 index 00000000..e6aa5e46 Binary files /dev/null and b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/Chevron.imageset/Chevron@1x.png differ diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/Chevron.imageset/Chevron@2x.png b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/Chevron.imageset/Chevron@2x.png new file mode 100644 index 00000000..821c9bea Binary files /dev/null and b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/Chevron.imageset/Chevron@2x.png differ diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/Chevron.imageset/Chevron@3x.png b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/Chevron.imageset/Chevron@3x.png new file mode 100644 index 00000000..42f836e1 Binary files /dev/null and b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/Chevron.imageset/Chevron@3x.png differ diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/Chevron.imageset/Contents.json b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/Chevron.imageset/Contents.json new file mode 100644 index 00000000..3218fd17 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/Chevron.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "Chevron@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "Chevron@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "Chevron@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/Contents.json b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/amex-logo.imageset/Contents.json b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/amex-logo.imageset/Contents.json new file mode 100644 index 00000000..d7a5fb5e --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/amex-logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "american-express@1x.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/amex-logo.imageset/american-express@1x.pdf b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/amex-logo.imageset/american-express@1x.pdf new file mode 100644 index 00000000..9582b790 Binary files /dev/null and b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/amex-logo.imageset/american-express@1x.pdf differ diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/cartes-bancaires-logo.imageset/Contents.json b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/cartes-bancaires-logo.imageset/Contents.json new file mode 100644 index 00000000..ef5cd216 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/cartes-bancaires-logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "cartes-bancaires-logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/cartes-bancaires-logo.imageset/cartes-bancaires-logo.png b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/cartes-bancaires-logo.imageset/cartes-bancaires-logo.png new file mode 100644 index 00000000..e65785c3 Binary files /dev/null and b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/cartes-bancaires-logo.imageset/cartes-bancaires-logo.png differ diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/discover-logo.imageset/Contents.json b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/discover-logo.imageset/Contents.json new file mode 100644 index 00000000..428f8cf0 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/discover-logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "discover@1x.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/discover-logo.imageset/discover@1x.png b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/discover-logo.imageset/discover@1x.png new file mode 100644 index 00000000..09ca3812 Binary files /dev/null and b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/discover-logo.imageset/discover@1x.png differ diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/error.imageset/Contents.json b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/error.imageset/Contents.json new file mode 100644 index 00000000..4f4b21e6 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/error.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "error@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "error@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "error@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/error.imageset/error@1x.png b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/error.imageset/error@1x.png new file mode 100644 index 00000000..bf8442f7 Binary files /dev/null and b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/error.imageset/error@1x.png differ diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/error.imageset/error@2x.png b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/error.imageset/error@2x.png new file mode 100644 index 00000000..a4d3a314 Binary files /dev/null and b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/error.imageset/error@2x.png differ diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/error.imageset/error@3x.png b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/error.imageset/error@3x.png new file mode 100644 index 00000000..5b856c41 Binary files /dev/null and b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/error.imageset/error@3x.png differ diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/mastercard-logo.imageset/Contents.json b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/mastercard-logo.imageset/Contents.json new file mode 100644 index 00000000..102a36c8 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/mastercard-logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "mastercard@1x.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/mastercard-logo.imageset/mastercard@1x.pdf b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/mastercard-logo.imageset/mastercard@1x.pdf new file mode 100644 index 00000000..cc999c3f Binary files /dev/null and b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/mastercard-logo.imageset/mastercard@1x.pdf differ diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/visa-logo.imageset/Contents.json b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/visa-logo.imageset/Contents.json new file mode 100644 index 00000000..c72228ce --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/visa-logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "visa@1x.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/visa-logo.imageset/visa@1x.pdf b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/visa-logo.imageset/visa@1x.pdf new file mode 100644 index 00000000..7044f715 Binary files /dev/null and b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/visa-logo.imageset/visa@1x.pdf differ diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/visa-white-logo.imageset/Contents.json b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/visa-white-logo.imageset/Contents.json new file mode 100644 index 00000000..e91aeeb5 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/visa-white-logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "visa-white@1x.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/visa-white-logo.imageset/visa-white@1x.pdf b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/visa-white-logo.imageset/visa-white@1x.pdf new file mode 100644 index 00000000..437593c4 Binary files /dev/null and b/Stripe3DS2/Stripe3DS2/Resources/Stripe3DS2.xcassets/visa-white-logo.imageset/visa-white@1x.pdf differ diff --git a/Stripe3DS2/Stripe3DS2/Resources/bg-BG.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/bg-BG.lproj/Localizable.strings new file mode 100644 index 00000000..7e168827 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/bg-BG.lproj/Localizable.strings @@ -0,0 +1,45 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "A debugger is attached to the App."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "An emulator is being used to run the App."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancel"; + +/* Accessibility label for expandandable text control to indicate text is hidden. */ +"Collapsed" = "Collapsed"; + +/* Accessibility label for expandandable text control to indicate that the UI has been expanded and additional text is available. */ +"Expanded" = "Expanded"; + +/* Spoken by VoiceOver when the challenge is loading. */ +"Loading" = "Loading"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Secure checkout"; + +/* Indicates that a button is selected. */ +"Selected" = "Selected"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "The device is jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "The integrity of the SDK has been tampered."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "The OS or the OS Version is not supported."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Unselected"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Yes"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/ca-ES.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/ca-ES.lproj/Localizable.strings new file mode 100644 index 00000000..7e168827 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/ca-ES.lproj/Localizable.strings @@ -0,0 +1,45 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "A debugger is attached to the App."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "An emulator is being used to run the App."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancel"; + +/* Accessibility label for expandandable text control to indicate text is hidden. */ +"Collapsed" = "Collapsed"; + +/* Accessibility label for expandandable text control to indicate that the UI has been expanded and additional text is available. */ +"Expanded" = "Expanded"; + +/* Spoken by VoiceOver when the challenge is loading. */ +"Loading" = "Loading"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Secure checkout"; + +/* Indicates that a button is selected. */ +"Selected" = "Selected"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "The device is jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "The integrity of the SDK has been tampered."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "The OS or the OS Version is not supported."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Unselected"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Yes"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/cs-CZ.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/cs-CZ.lproj/Localizable.strings new file mode 100644 index 00000000..7e168827 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/cs-CZ.lproj/Localizable.strings @@ -0,0 +1,45 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "A debugger is attached to the App."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "An emulator is being used to run the App."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancel"; + +/* Accessibility label for expandandable text control to indicate text is hidden. */ +"Collapsed" = "Collapsed"; + +/* Accessibility label for expandandable text control to indicate that the UI has been expanded and additional text is available. */ +"Expanded" = "Expanded"; + +/* Spoken by VoiceOver when the challenge is loading. */ +"Loading" = "Loading"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Secure checkout"; + +/* Indicates that a button is selected. */ +"Selected" = "Selected"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "The device is jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "The integrity of the SDK has been tampered."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "The OS or the OS Version is not supported."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Unselected"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Yes"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/da.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/da.lproj/Localizable.strings new file mode 100644 index 00000000..2388efaa --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/da.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "Et fejlfindingsprogram er vedhæftet appen."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "En emulator bruges til at køre appen."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Annullér"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "Indlæser"; + +/* The no answer to a yes or no question. */ +"No" = "Nej"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Sikker betaling"; + +/* Indicates that a button is selected. */ +"Selected" = "Valgt"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "Enheden er jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "Integriteten af SDK'en er blevet ændret."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "Operativsystemet eller versionen af operativsystemet understøttes ikke."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Udløb"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Fravalgt"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Ja"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/de.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/de.lproj/Localizable.strings new file mode 100644 index 00000000..55a9ea44 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/de.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "Ein Debugger ist an die App angeschlossen."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "Ein Emulator wird zum Ausführen dieser App verwendet."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Abbrechen"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "Wird geladen"; + +/* The no answer to a yes or no question. */ +"No" = "Nein"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Sicherer Bezahlvogang"; + +/* Indicates that a button is selected. */ +"Selected" = "Ausgewählt"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "Das Gerät ist beschädigt."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "Die Integrität des SDK wurde manipuliert."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "Das Betriebssystem oder die Betriebssystemversion wird nicht unterstützt."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Zeitüberschreitung"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Nicht ausgewählt"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Ja"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/el-GR.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/el-GR.lproj/Localizable.strings new file mode 100644 index 00000000..7e168827 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/el-GR.lproj/Localizable.strings @@ -0,0 +1,45 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "A debugger is attached to the App."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "An emulator is being used to run the App."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancel"; + +/* Accessibility label for expandandable text control to indicate text is hidden. */ +"Collapsed" = "Collapsed"; + +/* Accessibility label for expandandable text control to indicate that the UI has been expanded and additional text is available. */ +"Expanded" = "Expanded"; + +/* Spoken by VoiceOver when the challenge is loading. */ +"Loading" = "Loading"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Secure checkout"; + +/* Indicates that a button is selected. */ +"Selected" = "Selected"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "The device is jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "The integrity of the SDK has been tampered."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "The OS or the OS Version is not supported."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Unselected"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Yes"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/en-GB.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/en-GB.lproj/Localizable.strings new file mode 100644 index 00000000..ddfe3b2a --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/en-GB.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "A debugger is attached to the App."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "An emulator is being used to run the App."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancel"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "Loading"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Secure checkout"; + +/* Indicates that a button is selected. */ +"Selected" = "Selected"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "The device has been jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "The integrity of the SDK has been tampered with."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "The OS or the OS version is not supported."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Unselected"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Yes"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/en.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/en.lproj/Localizable.strings new file mode 100644 index 00000000..7e168827 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/en.lproj/Localizable.strings @@ -0,0 +1,45 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "A debugger is attached to the App."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "An emulator is being used to run the App."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancel"; + +/* Accessibility label for expandandable text control to indicate text is hidden. */ +"Collapsed" = "Collapsed"; + +/* Accessibility label for expandandable text control to indicate that the UI has been expanded and additional text is available. */ +"Expanded" = "Expanded"; + +/* Spoken by VoiceOver when the challenge is loading. */ +"Loading" = "Loading"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Secure checkout"; + +/* Indicates that a button is selected. */ +"Selected" = "Selected"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "The device is jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "The integrity of the SDK has been tampered."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "The OS or the OS Version is not supported."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Unselected"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Yes"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/es-419.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/es-419.lproj/Localizable.strings new file mode 100644 index 00000000..759db059 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/es-419.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "Se ha asociado un depurador a la aplicación."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "Se está usando un emulador para ejecutar la aplicación."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancelar"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "Cargando"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Proceso de compra seguro"; + +/* Indicates that a button is selected. */ +"Selected" = "Seleccionado"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "El dispositivo ha sido liberado."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "Se ha alterado la integridad del SDK."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "El OS o la versión del OS no son compatibles."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Tiempo de espera agotado"; + +/* Indicates that a button is not selected. */ +"Unselected" = "No seleccionado"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Sí"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/es.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/es.lproj/Localizable.strings new file mode 100644 index 00000000..5a6f2732 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/es.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "Se ha asociado un depurador a la aplicación."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "Se está usando un emulador para ejecutar la aplicación."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancelar"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "Cargando"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Proceso de compra seguro"; + +/* Indicates that a button is selected. */ +"Selected" = "Seleccionado"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "El dispositivo ha sido liberado."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "Se ha alterado la integridad del SDK."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "El SO o la versión del SO no son compatibles."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Tiempo de espera agotado"; + +/* Indicates that a button is not selected. */ +"Unselected" = "No seleccionado"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Sí"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/et-EE.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/et-EE.lproj/Localizable.strings new file mode 100644 index 00000000..7e168827 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/et-EE.lproj/Localizable.strings @@ -0,0 +1,45 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "A debugger is attached to the App."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "An emulator is being used to run the App."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancel"; + +/* Accessibility label for expandandable text control to indicate text is hidden. */ +"Collapsed" = "Collapsed"; + +/* Accessibility label for expandandable text control to indicate that the UI has been expanded and additional text is available. */ +"Expanded" = "Expanded"; + +/* Spoken by VoiceOver when the challenge is loading. */ +"Loading" = "Loading"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Secure checkout"; + +/* Indicates that a button is selected. */ +"Selected" = "Selected"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "The device is jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "The integrity of the SDK has been tampered."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "The OS or the OS Version is not supported."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Unselected"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Yes"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/fi.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/fi.lproj/Localizable.strings new file mode 100644 index 00000000..faeaf2d6 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/fi.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "Sovellukseen on liitetty virheiden tarkastusohjelma."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "Sovelluksen suorittamiseen käytetään emulaattoria."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Peruuta"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "Ladataan"; + +/* The no answer to a yes or no question. */ +"No" = "Ei"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Turvallinen maksaminen"; + +/* Indicates that a button is selected. */ +"Selected" = "Valittu"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "Laitteen käyttöjärjestelmän rajoitukset on poistettu."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "SDK:n eheyttä on peukaloitu."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "Käyttöjärjestelmää tai käyttöjärjestelmäversiota ei tueta."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Aikakatkaisu"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Ei valittu"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Kyllä"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/fil.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/fil.lproj/Localizable.strings new file mode 100644 index 00000000..7e168827 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/fil.lproj/Localizable.strings @@ -0,0 +1,45 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "A debugger is attached to the App."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "An emulator is being used to run the App."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancel"; + +/* Accessibility label for expandandable text control to indicate text is hidden. */ +"Collapsed" = "Collapsed"; + +/* Accessibility label for expandandable text control to indicate that the UI has been expanded and additional text is available. */ +"Expanded" = "Expanded"; + +/* Spoken by VoiceOver when the challenge is loading. */ +"Loading" = "Loading"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Secure checkout"; + +/* Indicates that a button is selected. */ +"Selected" = "Selected"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "The device is jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "The integrity of the SDK has been tampered."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "The OS or the OS Version is not supported."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Unselected"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Yes"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/fr-CA.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/fr-CA.lproj/Localizable.strings new file mode 100644 index 00000000..19956a78 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/fr-CA.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "Un débogueur est attaché à l'application."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "Un émulateur est utilisé pour exécuter l'application."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Annuler"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "Chargement"; + +/* The no answer to a yes or no question. */ +"No" = "Non"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Paiement sécurisé"; + +/* Indicates that a button is selected. */ +"Selected" = "Sélectionné"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "L'appareil est débridé."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "L'intégrité de SDK a été modifiée."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "Le système d'exploitation ou la version du système d'exploitation n'est pas pris(e) en charge."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Expiration"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Non sélectionné"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Oui"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/fr.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/fr.lproj/Localizable.strings new file mode 100644 index 00000000..9d595679 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/fr.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "Un débogueur est attaché à l'application."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "Un émulateur est utilisé pour accéder à l'application."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Annuler"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "Chargement"; + +/* The no answer to a yes or no question. */ +"No" = "Non"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Paiement sécurisé"; + +/* Indicates that a button is selected. */ +"Selected" = "Sélectionné"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "L'appareil est débridé."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "L'intégrité du SDK a été altérée."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "Le système d'exploitation ou la version du système d'exploitation ne sont pas pris en charge."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Non sélectionné"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Oui"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/hr.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/hr.lproj/Localizable.strings new file mode 100644 index 00000000..7e168827 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/hr.lproj/Localizable.strings @@ -0,0 +1,45 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "A debugger is attached to the App."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "An emulator is being used to run the App."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancel"; + +/* Accessibility label for expandandable text control to indicate text is hidden. */ +"Collapsed" = "Collapsed"; + +/* Accessibility label for expandandable text control to indicate that the UI has been expanded and additional text is available. */ +"Expanded" = "Expanded"; + +/* Spoken by VoiceOver when the challenge is loading. */ +"Loading" = "Loading"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Secure checkout"; + +/* Indicates that a button is selected. */ +"Selected" = "Selected"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "The device is jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "The integrity of the SDK has been tampered."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "The OS or the OS Version is not supported."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Unselected"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Yes"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/hu.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/hu.lproj/Localizable.strings new file mode 100644 index 00000000..889cda1b --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/hu.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "Az alkalmazáshoz hibaelhárítót csatoltak."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "Az alkalmazás futtatása emulátorral történik."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Mégse"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "Betöltés folyamatban"; + +/* The no answer to a yes or no question. */ +"No" = "Nem"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Biztosnágos kifizetés"; + +/* Indicates that a button is selected. */ +"Selected" = "Kijelölve"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "Az eszközt feltörték."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "Az SDK integritása sérült."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "Az OS vagy OS-verzió nem támogatott."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "időtúllépés"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Nincs kijelölve"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Igen"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/id.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/id.lproj/Localizable.strings new file mode 100644 index 00000000..7e168827 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/id.lproj/Localizable.strings @@ -0,0 +1,45 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "A debugger is attached to the App."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "An emulator is being used to run the App."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancel"; + +/* Accessibility label for expandandable text control to indicate text is hidden. */ +"Collapsed" = "Collapsed"; + +/* Accessibility label for expandandable text control to indicate that the UI has been expanded and additional text is available. */ +"Expanded" = "Expanded"; + +/* Spoken by VoiceOver when the challenge is loading. */ +"Loading" = "Loading"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Secure checkout"; + +/* Indicates that a button is selected. */ +"Selected" = "Selected"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "The device is jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "The integrity of the SDK has been tampered."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "The OS or the OS Version is not supported."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Unselected"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Yes"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/it.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/it.lproj/Localizable.strings new file mode 100644 index 00000000..419bf6b1 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/it.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "All'app è connesso un debugger."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "Per eseguire l'app viene utilizzato un emulatore."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Annulla"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "Caricamento in corso"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Completamento del pagamento sicuro"; + +/* Indicates that a button is selected. */ +"Selected" = "Selezionato"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "Il dispositivo è jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "L'integrità dell'SDK è stata manomessa."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "Il sistema operativo o la versione del sistema operativo non sono supportati."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Non selezionato"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Sì"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/ja.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/ja.lproj/Localizable.strings new file mode 100644 index 00000000..9cec4421 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/ja.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "アプリにデバッガが添付されています。"; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "アプリの実行にエミュレータが使用されています。"; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "キャンセル"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "読み込み中"; + +/* The no answer to a yes or no question. */ +"No" = "いいえ"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "セキュアなチェックアウト"; + +/* Indicates that a button is selected. */ +"Selected" = "選択済み"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "このデバイスは脱獄状態です。"; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "SDK の完全性が改ざんされています。"; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "OS または OS のバージョンがサポートされていません。"; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "タイムアウト"; + +/* Indicates that a button is not selected. */ +"Unselected" = "選択解除済み"; + +/* The yes answer to a yes or no question. */ +"Yes" = "はい"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/ko.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/ko.lproj/Localizable.strings new file mode 100644 index 00000000..4ae98c37 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/ko.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "디버거가 앱에 연결되었습니다."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "앱을 실행하는 데 에뮬레이터가 사용되고 있습니다."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "취소"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "로드 중"; + +/* The no answer to a yes or no question. */ +"No" = "아니요"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "보안 체크 아웃"; + +/* Indicates that a button is selected. */ +"Selected" = "선택됨"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "장치가 무단 해제되었습니다."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "SDK의 무결성이 변경되었습니다."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = " OS 또는 OS 버전이 지원되지 않습니다."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "시간 초과"; + +/* Indicates that a button is not selected. */ +"Unselected" = "선택 해제됨"; + +/* The yes answer to a yes or no question. */ +"Yes" = "예"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/lt-LT.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/lt-LT.lproj/Localizable.strings new file mode 100644 index 00000000..7e168827 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/lt-LT.lproj/Localizable.strings @@ -0,0 +1,45 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "A debugger is attached to the App."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "An emulator is being used to run the App."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancel"; + +/* Accessibility label for expandandable text control to indicate text is hidden. */ +"Collapsed" = "Collapsed"; + +/* Accessibility label for expandandable text control to indicate that the UI has been expanded and additional text is available. */ +"Expanded" = "Expanded"; + +/* Spoken by VoiceOver when the challenge is loading. */ +"Loading" = "Loading"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Secure checkout"; + +/* Indicates that a button is selected. */ +"Selected" = "Selected"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "The device is jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "The integrity of the SDK has been tampered."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "The OS or the OS Version is not supported."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Unselected"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Yes"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/lv-LV.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/lv-LV.lproj/Localizable.strings new file mode 100644 index 00000000..7e168827 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/lv-LV.lproj/Localizable.strings @@ -0,0 +1,45 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "A debugger is attached to the App."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "An emulator is being used to run the App."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancel"; + +/* Accessibility label for expandandable text control to indicate text is hidden. */ +"Collapsed" = "Collapsed"; + +/* Accessibility label for expandandable text control to indicate that the UI has been expanded and additional text is available. */ +"Expanded" = "Expanded"; + +/* Spoken by VoiceOver when the challenge is loading. */ +"Loading" = "Loading"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Secure checkout"; + +/* Indicates that a button is selected. */ +"Selected" = "Selected"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "The device is jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "The integrity of the SDK has been tampered."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "The OS or the OS Version is not supported."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Unselected"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Yes"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/ms-MY.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/ms-MY.lproj/Localizable.strings new file mode 100644 index 00000000..7e168827 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/ms-MY.lproj/Localizable.strings @@ -0,0 +1,45 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "A debugger is attached to the App."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "An emulator is being used to run the App."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancel"; + +/* Accessibility label for expandandable text control to indicate text is hidden. */ +"Collapsed" = "Collapsed"; + +/* Accessibility label for expandandable text control to indicate that the UI has been expanded and additional text is available. */ +"Expanded" = "Expanded"; + +/* Spoken by VoiceOver when the challenge is loading. */ +"Loading" = "Loading"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Secure checkout"; + +/* Indicates that a button is selected. */ +"Selected" = "Selected"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "The device is jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "The integrity of the SDK has been tampered."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "The OS or the OS Version is not supported."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Unselected"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Yes"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/mt.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/mt.lproj/Localizable.strings new file mode 100644 index 00000000..55b091cf --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/mt.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "Debugger huwa mqabbad mal-Applikazzjoni."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "Qed jintuża emulatur biex iħaddem l-Applikazzjoni."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Ikkanċella"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "Qed jillowdja"; + +/* The no answer to a yes or no question. */ +"No" = "Le"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Checkout sigur"; + +/* Indicates that a button is selected. */ +"Selected" = "Attivata"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "L-apparat huwa mmodifikat (jailbroken)."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "L-integrità tal-SDK ġiet imbagħbsa."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "Is-Sistema Operattiva (OS) jew il-Verżjoni tas-Sistema Operattiva (OS) mhijiex appoġġjata."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Waqfa qasira"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Mhijiex attivata"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Iva"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/nb.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/nb.lproj/Localizable.strings new file mode 100644 index 00000000..78613b3d --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/nb.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "En debugger er knyttet til App."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "En emulator brukes til å kjøre App."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Avbryt"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "Laster"; + +/* The no answer to a yes or no question. */ +"No" = "Nei"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Sikker utsjekk"; + +/* Indicates that a button is selected. */ +"Selected" = "Valgt"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "Enheten er jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "Integriteten til SDK har blitt manipulert."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "OS- eller OS-versjonen støttes ikke."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Avbrudd"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Ikke valgt"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Ja"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/nl.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/nl.lproj/Localizable.strings new file mode 100644 index 00000000..335a340a --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/nl.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "Er is een foutopsporingsprogramma gekoppeld aan de app."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "Er wordt een emulator gebruikt om de app uit te voeren."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Annuleren"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "Bezig met laden"; + +/* The no answer to a yes or no question. */ +"No" = "Nee"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Beveiligde Checkout"; + +/* Indicates that a button is selected. */ +"Selected" = "Geselecteerd"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "Het apparaat is opengebroken via een jailbreak."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "De integriteit van de SDK is niet meer intact."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "Het besturingssysteem of de versie wordt niet ondersteund."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Time-out"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Niet geselecteerd"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Ja"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/nn-NO.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/nn-NO.lproj/Localizable.strings new file mode 100644 index 00000000..65443693 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/nn-NO.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "Ein avlusar er knytta til appen."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "Ein emulator blir nytta til å køyre appen."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Avbryt"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "Lastar inn"; + +/* The no answer to a yes or no question. */ +"No" = "Nei"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Trygg utsjekk"; + +/* Indicates that a button is selected. */ +"Selected" = "Vald"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "Eininga er jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "Integriteten til SDK er manipulert."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "OS- eller OS-versjonen blir ikkje stydd."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Tidsavbrot"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Uvald"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Ja"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/pl-PL.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/pl-PL.lproj/Localizable.strings new file mode 100644 index 00000000..7e168827 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/pl-PL.lproj/Localizable.strings @@ -0,0 +1,45 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "A debugger is attached to the App."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "An emulator is being used to run the App."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancel"; + +/* Accessibility label for expandandable text control to indicate text is hidden. */ +"Collapsed" = "Collapsed"; + +/* Accessibility label for expandandable text control to indicate that the UI has been expanded and additional text is available. */ +"Expanded" = "Expanded"; + +/* Spoken by VoiceOver when the challenge is loading. */ +"Loading" = "Loading"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Secure checkout"; + +/* Indicates that a button is selected. */ +"Selected" = "Selected"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "The device is jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "The integrity of the SDK has been tampered."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "The OS or the OS Version is not supported."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Unselected"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Yes"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/pt-BR.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/pt-BR.lproj/Localizable.strings new file mode 100644 index 00000000..2e3e7b5b --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/pt-BR.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "Um depurador está anexado ao aplicativo."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "Está sendo usado um emulador para executar o aplicativo."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancelar"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "Carregando"; + +/* The no answer to a yes or no question. */ +"No" = "Não"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Finalização de compra segura"; + +/* Indicates that a button is selected. */ +"Selected" = "Selecionado"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "O dispositivo está desbloqueado."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "A integridade do SDK foi adulterada."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "O SO ou a versão do SO não é compatível."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Tempo esgotado"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Não selecionado"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Sim"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/pt-PT.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/pt-PT.lproj/Localizable.strings new file mode 100644 index 00000000..3be97a51 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/pt-PT.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "Um depurador está anexado à aplicação."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "Um emulador está a ser utilizado para executar a aplicação."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancelar"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "A carregar"; + +/* The no answer to a yes or no question. */ +"No" = "Não"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Finalização de compra segura"; + +/* Indicates that a button is selected. */ +"Selected" = "Selecionado"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "O dispositivo está desbloqueado."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "A integridade do SDK foi adulterada."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "Sistema operativo ou versão do sistema operativo não suportado(a)."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Tempo limite"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Não selecionado"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Sim"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/ro-RO.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/ro-RO.lproj/Localizable.strings new file mode 100644 index 00000000..7e168827 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/ro-RO.lproj/Localizable.strings @@ -0,0 +1,45 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "A debugger is attached to the App."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "An emulator is being used to run the App."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancel"; + +/* Accessibility label for expandandable text control to indicate text is hidden. */ +"Collapsed" = "Collapsed"; + +/* Accessibility label for expandandable text control to indicate that the UI has been expanded and additional text is available. */ +"Expanded" = "Expanded"; + +/* Spoken by VoiceOver when the challenge is loading. */ +"Loading" = "Loading"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Secure checkout"; + +/* Indicates that a button is selected. */ +"Selected" = "Selected"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "The device is jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "The integrity of the SDK has been tampered."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "The OS or the OS Version is not supported."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Unselected"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Yes"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/ru.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/ru.lproj/Localizable.strings new file mode 100644 index 00000000..2c7cd69b --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/ru.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "К приложению подключен отладчик."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "Приложение запускается и работает в симуляторе."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Отмена"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "Идет загрузка"; + +/* The no answer to a yes or no question. */ +"No" = "Нет"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Безопасное оформление и оплата заказа"; + +/* Indicates that a button is selected. */ +"Selected" = "Выбранные"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "Устройство разблокировано."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "Целостность пакета средств разработки программного обеспечения нарушена."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "Данная операционная система или данная версия операционной системы не поддерживается."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Истечение лимита времени"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Не выбранные"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Да"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/sk-SK.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/sk-SK.lproj/Localizable.strings new file mode 100644 index 00000000..7e168827 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/sk-SK.lproj/Localizable.strings @@ -0,0 +1,45 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "A debugger is attached to the App."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "An emulator is being used to run the App."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancel"; + +/* Accessibility label for expandandable text control to indicate text is hidden. */ +"Collapsed" = "Collapsed"; + +/* Accessibility label for expandandable text control to indicate that the UI has been expanded and additional text is available. */ +"Expanded" = "Expanded"; + +/* Spoken by VoiceOver when the challenge is loading. */ +"Loading" = "Loading"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Secure checkout"; + +/* Indicates that a button is selected. */ +"Selected" = "Selected"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "The device is jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "The integrity of the SDK has been tampered."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "The OS or the OS Version is not supported."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Unselected"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Yes"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/sl-SI.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/sl-SI.lproj/Localizable.strings new file mode 100644 index 00000000..7e168827 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/sl-SI.lproj/Localizable.strings @@ -0,0 +1,45 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "A debugger is attached to the App."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "An emulator is being used to run the App."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancel"; + +/* Accessibility label for expandandable text control to indicate text is hidden. */ +"Collapsed" = "Collapsed"; + +/* Accessibility label for expandandable text control to indicate that the UI has been expanded and additional text is available. */ +"Expanded" = "Expanded"; + +/* Spoken by VoiceOver when the challenge is loading. */ +"Loading" = "Loading"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Secure checkout"; + +/* Indicates that a button is selected. */ +"Selected" = "Selected"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "The device is jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "The integrity of the SDK has been tampered."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "The OS or the OS Version is not supported."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Unselected"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Yes"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/sv.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/sv.lproj/Localizable.strings new file mode 100644 index 00000000..e92a466a --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/sv.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "En felsökare är ansluten till appen."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "En emulator används för att köra appen."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Avbryt"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "Laddar"; + +/* The no answer to a yes or no question. */ +"No" = "Nej"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Säker utcheckning"; + +/* Indicates that a button is selected. */ +"Selected" = "Markerad"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "Enheten är jailbreakad."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "SDK:ns integritet har manipulerats."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "OS eller OS-versionen stöds inte."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Avbrott"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Avmarkerad"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Ja"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/tr.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/tr.lproj/Localizable.strings new file mode 100644 index 00000000..bf9064f1 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/tr.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "Uygulamaya hata ayıklayıcı eklendi."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "Uygulamayı çalıştırmak için bir emülatör kullanılıyor."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "İptal"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "Yüklüyor"; + +/* The no answer to a yes or no question. */ +"No" = "Hayır"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Güvenli ödeme"; + +/* Indicates that a button is selected. */ +"Selected" = "Seçildi"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "Cihazın üretici yazılım kilidi kaldırılmış."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "SDK bütünlüğü ihlal edilmiş."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "İS veya İS Sürümü desteklenmiyor."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Zaman aşımı"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Seçimi kaldırıldı"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Evet"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/vi.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/vi.lproj/Localizable.strings new file mode 100644 index 00000000..7e168827 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/vi.lproj/Localizable.strings @@ -0,0 +1,45 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "A debugger is attached to the App."; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "An emulator is being used to run the App."; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "Cancel"; + +/* Accessibility label for expandandable text control to indicate text is hidden. */ +"Collapsed" = "Collapsed"; + +/* Accessibility label for expandandable text control to indicate that the UI has been expanded and additional text is available. */ +"Expanded" = "Expanded"; + +/* Spoken by VoiceOver when the challenge is loading. */ +"Loading" = "Loading"; + +/* The no answer to a yes or no question. */ +"No" = "No"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "Secure checkout"; + +/* Indicates that a button is selected. */ +"Selected" = "Selected"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "The device is jailbroken."; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "The integrity of the SDK has been tampered."; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "The OS or the OS Version is not supported."; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "Timeout"; + +/* Indicates that a button is not selected. */ +"Unselected" = "Unselected"; + +/* The yes answer to a yes or no question. */ +"Yes" = "Yes"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/zh-HK.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/zh-HK.lproj/Localizable.strings new file mode 100644 index 00000000..16328109 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/zh-HK.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "調試器已附在應用程式內。"; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "正在用模擬器執行應用程式。"; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "取消"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "正在載入"; + +/* The no answer to a yes or no question. */ +"No" = "否"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "安全結帳"; + +/* Indicates that a button is selected. */ +"Selected" = "已選"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "該設備已越獄。"; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "SDK 的完整性已受損。"; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "不支援此 OS 或 OS 版本。"; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "超時"; + +/* Indicates that a button is not selected. */ +"Unselected" = "已去選"; + +/* The yes answer to a yes or no question. */ +"Yes" = "是"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/zh-Hans.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/zh-Hans.lproj/Localizable.strings new file mode 100644 index 00000000..b7729c45 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "该应用附带有调试程序。"; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "正在使用模拟器运行此应用。"; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "取消"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "正在加载"; + +/* The no answer to a yes or no question. */ +"No" = "否"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "安全的结账流程"; + +/* Indicates that a button is selected. */ +"Selected" = "已选择"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "该设备已越狱。"; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "该 SDK 的完整性已被破坏。"; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "不支持 OS 或 OS 版本。"; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "超时"; + +/* Indicates that a button is not selected. */ +"Unselected" = "未选择"; + +/* The yes answer to a yes or no question. */ +"Yes" = "是"; + diff --git a/Stripe3DS2/Stripe3DS2/Resources/zh-Hant.lproj/Localizable.strings b/Stripe3DS2/Stripe3DS2/Resources/zh-Hant.lproj/Localizable.strings new file mode 100644 index 00000000..4ea12100 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Resources/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,39 @@ +/* The text for warning when a debugger is currently attached to the process. */ +"A debugger is attached to the App." = "除錯工具附加在應用程式內。"; + +/* The text for warning when an emulator is being used to run the application. */ +"An emulator is being used to run the App." = "正在用模擬器執行應用程式。"; + +/* The text for the button that cancels the current challenge process. */ +"Cancel" = "取消"; + +/* Spoken by VoiceOver when the screen is loading. */ +"Loading" = "正在載入"; + +/* The no answer to a yes or no question. */ +"No" = "否"; + +/* The title for the challenge response step of an authenticated checkout. */ +"Secure checkout" = "安全結帳"; + +/* Indicates that a button is selected. */ +"Selected" = "已選"; + +/* The text for warning when a device is jailbroken */ +"The device is jailbroken." = "該裝置已越獄。"; + +/* The text for warning when the integrity of the SDK has been tampered with */ +"The integrity of the SDK has been tampered." = "SDK 的完整性已受損。"; + +/* The text for warning when the SDK is running on an unsupported OS or OS version. */ +"The OS or the OS Version is not supported." = "不支援此 OS 或 OS 版本。"; + +/* Error description for when a network request times out. English value is as required by UL certification. */ +"Timeout" = "逾時"; + +/* Indicates that a button is not selected. */ +"Unselected" = "已去選"; + +/* The yes answer to a yes or no question. */ +"Yes" = "是"; + diff --git a/Stripe3DS2/Stripe3DS2/STDSACSNetworkingManager.h b/Stripe3DS2/Stripe3DS2/STDSACSNetworkingManager.h new file mode 100644 index 00000000..cc907e88 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSACSNetworkingManager.h @@ -0,0 +1,30 @@ +// +// STDSACSNetworkingManager.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 4/3/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +@class STDSChallengeRequestParameters; +@class STDSErrorMessage; +@protocol STDSChallengeResponse; + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSACSNetworkingManager : NSObject + +- (instancetype)initWithURL:(NSURL *)acsURL + sdkContentEncryptionKey:(NSData *)sdkCEK + acsContentEncryptionKey:(NSData *)acsCEK + acsTransactionIdentifier:(NSString *)acsTransactionID; + +- (void)submitChallengeRequest:(STDSChallengeRequestParameters *)request withCompletion:(void (^)(id _Nullable, NSError * _Nullable))completion; + +- (void)sendErrorMessage:(STDSErrorMessage *)errorMessage; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSACSNetworkingManager.m b/Stripe3DS2/Stripe3DS2/STDSACSNetworkingManager.m new file mode 100644 index 00000000..f7c8de55 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSACSNetworkingManager.m @@ -0,0 +1,169 @@ +// +// STDSACSNetworkingManager..m +// Stripe3DS2 +// +// Created by Cameron Sabol on 4/3/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSACSNetworkingManager.h" + +#import "STDSChallengeRequestParameters.h" +#import "STDSChallengeResponseObject.h" +#import "STDSJSONEncoder.h" +#import "STDSJSONWebEncryption.h" +#import "STDSStripe3DS2Error.h" +#import "STDSErrorMessage+Internal.h" +#import "NSError+Stripe3DS2.h" + +NS_ASSUME_NONNULL_BEGIN + +/// [Req 239] requires us to abort if the ACS does not respond with the CRes message within 10 seconds. +static const NSTimeInterval kTimeoutInterval = 10; + +@implementation STDSACSNetworkingManager { + NSURL *_acsURL; + NSData *_sdkContentEncryptionKey; + NSData *_acsContentEncryptionKey; + NSString *_acsTransactionIdentifier; + + NSURLSession *_urlSession; + NSURLSessionTask * _Nullable _currentTask; +} + +- (instancetype)initWithURL:(NSURL *)acsURL + sdkContentEncryptionKey:(NSData *)sdkCEK + acsContentEncryptionKey:(NSData *)acsCEK + acsTransactionIdentifier:(nonnull NSString *)acsTransactionID { + self = [super init]; + if (self) { + _acsURL = acsURL; + _sdkContentEncryptionKey = sdkCEK; + _acsContentEncryptionKey = acsCEK; + _acsTransactionIdentifier = [acsTransactionID copy]; + _urlSession = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration ephemeralSessionConfiguration] + delegate:nil + delegateQueue:[NSOperationQueue mainQueue]]; + } + + return self; +} + +- (void)dealloc { + [_urlSession finishTasksAndInvalidate]; +} + +- (void)submitChallengeRequest:(STDSChallengeRequestParameters *)request withCompletion:(void (^)(id _Nullable, NSError * _Nullable))completion { + if (_currentTask != nil) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(nil, [NSError errorWithDomain:STDSStripe3DS2ErrorDomain + code:STDSErrorCodeAssertionFailed + userInfo:@{@"assertion": [NSString stringWithFormat:@"%@ is not intended to handle multiple concurrent tasks.", NSStringFromClass([self class])]}]); + }); + return; + } + + NSDictionary *requestJSON = [STDSJSONEncoder dictionaryForObject:request]; + NSError *encryptionError = nil; + NSString *encryptedRequest = [STDSJSONWebEncryption directEncryptJSON:requestJSON + withContentEncryptionKey:_sdkContentEncryptionKey + forACSTransactionID:_acsTransactionIdentifier + error:&encryptionError]; + + if (encryptedRequest != nil) { + NSMutableURLRequest *urlRequest = [[NSMutableURLRequest alloc] initWithURL:_acsURL]; + urlRequest.HTTPMethod = @"POST"; + urlRequest.timeoutInterval = kTimeoutInterval; + [urlRequest setValue:@"application/jose;charset=UTF-8" forHTTPHeaderField:@"Content-Type"]; + __weak __typeof(self) weakSelf = self; + NSURLSessionUploadTask *requestTask = [_urlSession uploadTaskWithRequest:[urlRequest copy] fromData:[encryptedRequest dataUsingEncoding:NSUTF8StringEncoding] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) { + + __typeof(self) strongSelf = weakSelf; + if (strongSelf == nil) { + return; + } + + strongSelf->_currentTask = nil; + + if (data != nil) { + NSError *decryptionError = nil; + NSDictionary *decrypted = [STDSJSONWebEncryption decryptData:data + withContentEncryptionKey:strongSelf->_acsContentEncryptionKey + error:&decryptionError]; + if (decrypted != nil) { + NSError *challengeResponseError = nil; + id response = [strongSelf decodeJSON:decrypted error:&challengeResponseError]; + completion(response, challengeResponseError); + } else { + completion(nil, decryptionError); + } + } else { + if (error.code == NSURLErrorTimedOut) { + // We convert timeout errors for convenience, since the SDK must treat them differently from generic network errors. + error = [NSError _stds_timedOutError]; + } + completion(nil, error); + } + + }]; + _currentTask = requestTask; + [requestTask resume]; + } else { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(nil, encryptionError); + }); + } +} + +- (void)sendErrorMessage:(STDSErrorMessage *)errorMessage { + NSDictionary *requestJSON = [STDSJSONEncoder dictionaryForObject:errorMessage]; + NSMutableURLRequest *urlRequest = [[NSMutableURLRequest alloc] initWithURL:_acsURL]; + urlRequest.HTTPMethod = @"POST"; + [urlRequest setValue:@"application/JSON; charset = UTF-8" forHTTPHeaderField:@"Content-Type"]; + NSURLSessionUploadTask *requestTask = [_urlSession uploadTaskWithRequest:[urlRequest copy] + fromData:[NSJSONSerialization dataWithJSONObject:requestJSON options:0 error:nil] + completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) { + // no-op + }]; + [requestTask resume]; +} + +#pragma mark - Helpers + +/** + Returns an STDSChallengeResponseObject instance decoded from the given dict, or populates the error argument. + */ +- (nullable id)decodeJSON:(NSDictionary *)dict error:(NSError * _Nullable *)outError { + NSString *kErrorMessageType = @"Erro"; + NSString *kChallengeResponseType = @"CRes"; + NSString *messageType = dict[@"messageType"]; + NSError *error; + id decodedObject; + + if ([messageType isEqualToString:kErrorMessageType]) { + // Error message type + STDSErrorMessage *errorMessage = [STDSErrorMessage decodedObjectFromJSON:dict error:&error]; + if (errorMessage) { + error = [NSError errorWithDomain:STDSStripe3DS2ErrorDomain + code:STDSErrorCodeReceivedErrorMessage + userInfo:@{STDSStripe3DS2ErrorMessageErrorKey: errorMessage}]; + } + } else if ([messageType isEqualToString:kChallengeResponseType]) { + // CRes message type + decodedObject = [STDSChallengeResponseObject decodedObjectFromJSON:dict error:&error]; + } else { + // Unknown message type + error = [NSError errorWithDomain:STDSStripe3DS2ErrorDomain + code:STDSErrorCodeUnknownMessageType + userInfo:nil]; + } + + if (error && outError) { + *outError = error; + } + return decodedObject; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSAuthenticationResponseObject.h b/Stripe3DS2/Stripe3DS2/STDSAuthenticationResponseObject.h new file mode 100644 index 00000000..78b2c491 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSAuthenticationResponseObject.h @@ -0,0 +1,20 @@ +// +// STDSAuthenticationResponseObject.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 5/20/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSAuthenticationResponse.h" +#import "STDSJSONDecodable.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSAuthenticationResponseObject : NSObject + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSAuthenticationResponseObject.m b/Stripe3DS2/Stripe3DS2/STDSAuthenticationResponseObject.m new file mode 100644 index 00000000..4a9bd175 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSAuthenticationResponseObject.m @@ -0,0 +1,100 @@ +// +// STDSAuthenticationResponseObject.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 5/20/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSAuthenticationResponseObject.h" + +#import "NSDictionary+DecodingHelpers.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSAuthenticationResponseObject + +@synthesize acsOperatorID = _acsOperatorID; +@synthesize acsReferenceNumber = _acsReferenceNumber; +@synthesize acsSignedContent = _acsSignedContent; +@synthesize acsTransactionID = _acsTransactionID; +@synthesize acsURL = _acsURL; +@synthesize cardholderInfo = _cardholderInfo; +@synthesize status = _status; +@synthesize challengeRequired = _challengeRequired; +@synthesize directoryServerReferenceNumber = _directoryServerReferenceNumber; +@synthesize directoryServerTransactionID = _directoryServerTransactionID; +@synthesize protocolVersion = _protocolVersion; +@synthesize sdkTransactionID = _sdkTransactionID; +@synthesize threeDSServerTransactionID = _threeDSServerTransactionID; +@synthesize willUseDecoupledAuthentication = _willUseDecoupledAuthentication; + ++ (nullable instancetype)decodedObjectFromJSON:(nullable NSDictionary *)json error:(NSError **)outError { + if (json == nil) { + return nil; + } + STDSAuthenticationResponseObject *response = [[self alloc] init]; + NSError *error = nil; + + response->_threeDSServerTransactionID = [[json _stds_stringForKey:@"threeDSServerTransID" required:YES error:&error] copy]; + NSString *transStatusString = [json _stds_stringForKey:@"transStatus" required:NO error:&error]; + response->_status = [self statusTypeForString:transStatusString]; + response->_challengeRequired = (response->_status == STDSACSStatusTypeChallengeRequired); + response->_willUseDecoupledAuthentication = [[json _stds_boolForKey:@"acsDecConInd" required:NO error:&error] boolValue]; + response->_acsOperatorID = [[json _stds_stringForKey:@"acsOperatorID" required:NO error:&error] copy]; + response->_acsReferenceNumber = [[json _stds_stringForKey:@"acsReferenceNumber" required:NO error:&error] copy]; + response->_acsSignedContent = [[json _stds_stringForKey:@"acsSignedContent" required:NO error:&error] copy]; + response->_acsTransactionID = [[json _stds_stringForKey:@"acsTransID" required:YES error:&error] copy]; + response->_acsURL = [json _stds_urlForKey:@"acsURL" required:NO error:&error]; + response->_cardholderInfo = [[json _stds_stringForKey:@"cardholderInfo" required:NO error:&error] copy]; + response->_directoryServerReferenceNumber = [[json _stds_stringForKey:@"dsReferenceNumber" required:NO error:&error] copy]; + response->_directoryServerTransactionID = [[json _stds_stringForKey:@"dsTransID" required:NO error:&error] copy]; + response->_protocolVersion = [[json _stds_stringForKey:@"messageVersion" required:YES error:&error] copy]; + response->_sdkTransactionID = [[json _stds_stringForKey:@"sdkTransID" required:YES error:&error] copy]; + + if (error != nil) { + if (outError != nil) { + *outError = error; + } + + return nil; + } + + return response; +} + ++ (STDSACSStatusType)statusTypeForString:(NSString *)statusString { + if ([statusString isEqualToString:@"Y"]) { + return STDSACSStatusTypeAuthenticated; + } + if ([statusString isEqualToString:@"C"]) { + return STDSACSStatusTypeChallengeRequired; + } + if ([statusString isEqualToString:@"D"]) { + return STDSACSStatusTypeDecoupledAuthentication; + } + if ([statusString isEqualToString:@"N"]) { + return STDSACSStatusTypeNotAuthenticated; + } + if ([statusString isEqualToString:@"A"]) { + return STDSACSStatusTypeProofGenerated; + } + if ([statusString isEqualToString:@"U"]) { + return STDSACSStatusTypeError; + } + if ([statusString isEqualToString:@"R"]) { + return STDSACSStatusTypeRejected; + } + if ([statusString isEqualToString:@"I"]) { + return STDSACSStatusTypeInformationalOnly; + } + return STDSACSStatusTypeUnknown; +} + +@end + +id _Nullable STDSAuthenticationResponseFromJSON(NSDictionary *json) { + return [STDSAuthenticationResponseObject decodedObjectFromJSON:json error:NULL]; +} + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSBrandingView.h b/Stripe3DS2/Stripe3DS2/STDSBrandingView.h new file mode 100644 index 00000000..310e18a6 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSBrandingView.h @@ -0,0 +1,23 @@ +// +// STDSBrandingView.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSBrandingView: UIView + +/// The issuer image to present in the branding view. +@property (nonatomic, strong) UIImage *issuerImage; + +/// The payment system image to present in the branding view. +@property (nonatomic, strong) UIImage *paymentSystemImage; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSBrandingView.m b/Stripe3DS2/Stripe3DS2/STDSBrandingView.m new file mode 100644 index 00000000..25dde351 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSBrandingView.m @@ -0,0 +1,126 @@ +// +// STDSBrandingView.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSBrandingView.h" +#import "STDSStackView.h" +#import "UIView+LayoutSupport.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSBrandingView() + +@property (nonatomic, strong) STDSStackView *stackView; + +@property (nonatomic, strong) UIImageView *issuerImageView; +@property (nonatomic, strong) UIImageView *paymentSystemImageView; + +@property (nonatomic, strong) UIView *issuerView; +@property (nonatomic, strong) UIView *paymentSystemView; + +@end + +@implementation STDSBrandingView + +static const CGFloat kBrandingViewBottomPadding = 24; +static const CGFloat kBrandingViewSpacing = 16; +static const CGFloat kImageViewBorderWidth = 1; +static const CGFloat kImageViewHorizontalInset = 7; +static const CGFloat kImageViewVerticalInset = 19; +static const CGFloat kImageViewCornerRadius = 6; + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + + if (self) { + [self _setupViewHierarchy]; + } + + return self; +} + +- (void)setPaymentSystemImage:(UIImage *)paymentSystemImage { + _paymentSystemImage = paymentSystemImage; + + self.paymentSystemImageView.image = paymentSystemImage; +} + +- (void)setIssuerImage:(UIImage *)issuerImage { + _issuerImage = issuerImage; + + self.issuerImageView.image = issuerImage; +} + +- (void)didMoveToWindow { + [super didMoveToWindow]; + + if (self.window.screen.nativeScale > 0) { + self.issuerView.layer.borderWidth = kImageViewBorderWidth / self.window.screen.nativeScale; + self.paymentSystemView.layer.borderWidth = kImageViewBorderWidth / self.window.screen.nativeScale; + } +} + +- (void)_setupViewHierarchy { + self.layoutMargins = UIEdgeInsetsMake(0, 0, kBrandingViewBottomPadding, 0); + + self.stackView = [[STDSStackView alloc] initWithAlignment:STDSStackViewLayoutAxisHorizontal]; + [self addSubview:self.stackView]; + + [self.stackView _stds_pinToSuperviewBounds]; + + self.issuerImageView = [self _newBrandingImageView]; + self.issuerView = [self _newInsetViewWithImageView:self.issuerImageView]; + [self.stackView addArrangedSubview:self.issuerView]; + + [self.stackView addSpacer:kBrandingViewSpacing]; + + self.paymentSystemImageView = [self _newBrandingImageView]; + self.paymentSystemView = [self _newInsetViewWithImageView:self.paymentSystemImageView]; + [self.stackView addArrangedSubview:self.paymentSystemView]; + + NSLayoutConstraint *imageViewWidthConstraint = [NSLayoutConstraint constraintWithItem:self.issuerView attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:self attribute:NSLayoutAttributeWidth multiplier:0.5 constant:0]; + // Setting the priority of the width constraint, so that the priority of the equal widths constraint below takes precedence, allowing both image views to take half of the remaining space equally. + imageViewWidthConstraint.priority = UILayoutPriorityDefaultHigh; + NSLayoutConstraint *width = [NSLayoutConstraint constraintWithItem:self.paymentSystemView attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:self.issuerView attribute:NSLayoutAttributeWidth multiplier:1 constant:0]; + + [NSLayoutConstraint activateConstraints:@[imageViewWidthConstraint, width]]; +} + +- (UIView *)_newInsetViewWithImageView:(UIImageView *)imageView { + UIView *insetView = [UIView new]; + insetView.layoutMargins = UIEdgeInsetsMake(kImageViewHorizontalInset, kImageViewVerticalInset, kImageViewHorizontalInset, kImageViewVerticalInset); + insetView.layer.cornerRadius = kImageViewCornerRadius; + insetView.backgroundColor = [UIColor whiteColor]; // Issuer images always expect a white background. + insetView.layer.masksToBounds = YES; + insetView.layer.borderColor = (self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleLight) ? + [UIColor colorWithRed:(CGFloat)0.0 green:(CGFloat)57.0/(CGFloat)255.0 blue:(CGFloat)69.0/(CGFloat)255.0 alpha:(CGFloat)0.25].CGColor : + [UIColor colorWithRed:(CGFloat)195.0/(CGFloat)255.0 green:(CGFloat)214.0/(CGFloat)255.0 blue:(CGFloat)218.0/(CGFloat)255.0 alpha:(CGFloat)0.25].CGColor; + + [insetView addSubview:imageView]; + [imageView _stds_pinToSuperviewBounds]; + + return insetView; +} + +- (UIImageView *)_newBrandingImageView { + UIImageView *imageView = [[UIImageView alloc] init]; + imageView.contentMode = UIViewContentModeScaleAspectFit; + + return imageView; +} + +- (void)traitCollectionDidChange:(UITraitCollection * _Nullable)previousTraitCollection { + CGColorRef borderColor = (self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleLight) ? + [UIColor colorWithRed:(CGFloat)0.0 green:(CGFloat)57.0/(CGFloat)255.0 blue:(CGFloat)69.0/(CGFloat)255.0 alpha:(CGFloat)0.25].CGColor : + [UIColor colorWithRed:(CGFloat)195.0/(CGFloat)255.0 green:(CGFloat)214.0/(CGFloat)255.0 blue:(CGFloat)218.0/(CGFloat)255.0 alpha:(CGFloat)0.25].CGColor; + self.issuerView.layer.borderColor = borderColor; + self.paymentSystemView.layer.borderColor = borderColor; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSBundleLocator.h b/Stripe3DS2/Stripe3DS2/STDSBundleLocator.h new file mode 100644 index 00000000..ed919fa5 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSBundleLocator.h @@ -0,0 +1,15 @@ +// +// STDSBundleLocator.h +// Stripe3DS2 +// +// Created by David Estes on 7/23/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +@interface STDSBundleLocator : NSObject + ++ (NSBundle *)stdsResourcesBundle; + +@end diff --git a/Stripe3DS2/Stripe3DS2/STDSBundleLocator.m b/Stripe3DS2/Stripe3DS2/STDSBundleLocator.m new file mode 100644 index 00000000..55384068 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSBundleLocator.m @@ -0,0 +1,109 @@ +// +// STDSBundleLocator.m +// Stripe3DS2 +// +// Created by David Estes on 7/23/19. +// Copyright © 2019 Stripe. All rights reserved. +// Based on STPBundleLocator.m in Stripe.framework +// + +#import "STDSBundleLocator.h" + +/** + Using a private class to ensure that it can't be subclassed, which may + change the result of `bundleForClass` + */ +@interface STDSBundleLocatorInternal : NSObject +@end +@implementation STDSBundleLocatorInternal +@end + +@implementation STDSBundleLocator + +// This is copied from SPM's resource_bundle_accessor.m ++ (NSBundle *)stdsSPMBundle { + NSString *bundleName = @"Stripe_Stripe"; + + NSArray *candidates = @[ + NSBundle.mainBundle.resourceURL, + [NSBundle bundleForClass:[self class]].resourceURL, + NSBundle.mainBundle.bundleURL + ]; + + for (NSURL* candiate in candidates) { + NSURL *bundlePath = [candiate URLByAppendingPathComponent:[NSString stringWithFormat:@"%@.bundle", bundleName]]; + + NSBundle *bundle = [NSBundle bundleWithURL:bundlePath]; + if (bundle != nil) { + return bundle; + } + } + + return nil; +} + ++ (NSBundle *)stdsResourcesBundle { + /** + First, find Stripe.framework. + Places to check: + 1. Stripe_Stripe3DS2.bundle (for SwiftPM) + 1. Stripe_Stripe.bundle (for SwiftPM) + 2. Stripe.bundle (for manual static installations, Fabric, and framework-less Cocoapods) + 3. Stripe.framework/Stripe.bundle (for framework-based Cocoapods) + 4. Stripe.framework (for Carthage, manual dynamic installations) + 5. main bundle (for people dragging all our files into their project) + **/ + + static NSBundle *ourBundle; + + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ +#ifdef SWIFTPM_MODULE_BUNDLE + ourBundle = SWIFTPM_MODULE_BUNDLE; +#endif + + if (ourBundle == nil) { + ourBundle = [STDSBundleLocator stdsSPMBundle]; + } + + if (ourBundle == nil) { + ourBundle = [NSBundle bundleWithPath:@"Stripe.bundle"]; + } + + if (ourBundle == nil) { + // This might be the same as the previous check if not using a dynamic framework + NSString *path = [[NSBundle bundleForClass:[STDSBundleLocatorInternal class]] pathForResource:@"Stripe" ofType:@"bundle"]; + ourBundle = [NSBundle bundleWithPath:path]; + } + + if (ourBundle == nil) { + // This will be the same as mainBundle if not using a dynamic framework + ourBundle = [NSBundle bundleForClass:[STDSBundleLocatorInternal class]]; + } + + if (ourBundle == nil) { + ourBundle = [NSBundle mainBundle]; + } + + // Once we've found Stripe.framework, seek around to find Stripe3DS2.bundle. + // Try to find Stripe3DS2 bundle within our current bundle + NSString *stdsBundlePath = [[ourBundle bundlePath] stringByAppendingPathComponent:@"Stripe3DS2.bundle"]; + NSBundle *stdsBundle = [NSBundle bundleWithPath:stdsBundlePath]; + if (stdsBundle != nil) { + ourBundle = stdsBundle; + } + // If it's not there, it might be a level up from us? + // (CocoaPods arranges us this way, as an example.) + if (stdsBundle == nil) { + NSString *stdsBundlePath = [[[ourBundle bundlePath] stringByDeletingLastPathComponent] stringByAppendingPathComponent:@"Stripe3DS2.bundle"]; + stdsBundle = [NSBundle bundleWithPath:stdsBundlePath]; + if (stdsBundle != nil) { + ourBundle = stdsBundle; + } + } + }); + + return ourBundle; +} + +@end diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeInformationView.h b/Stripe3DS2/Stripe3DS2/STDSChallengeInformationView.h new file mode 100644 index 00000000..2111ea3f --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeInformationView.h @@ -0,0 +1,25 @@ +// +// STDSChallengeInformationView.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/4/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import "STDSLabelCustomization.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSChallengeInformationView: UIView + +@property (nonatomic, strong, nullable) NSString *headerText; +@property (nonatomic, strong, nullable) UIImage *textIndicatorImage; +@property (nonatomic, strong, nullable) NSString *challengeInformationText; +@property (nonatomic, strong, nullable) NSString *challengeInformationLabel; + +@property (nonatomic, strong, nullable) STDSLabelCustomization *labelCustomization; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeInformationView.m b/Stripe3DS2/Stripe3DS2/STDSChallengeInformationView.m new file mode 100644 index 00000000..6796dfe3 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeInformationView.m @@ -0,0 +1,137 @@ +// +// STDSChallengeInformationView.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/4/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSChallengeInformationView.h" +#import "STDSStackView.h" +#import "STDSSpacerView.h" +#import "UIView+LayoutSupport.h" +#import "NSString+EmptyChecking.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSChallengeInformationView () + +@property (nonatomic, strong) STDSStackView *informationStackView; +@property (nonatomic, strong) STDSStackView *indicatorStackView; + +@property (nonatomic, strong) UILabel *headerLabel; +@property (nonatomic, strong) UIImageView *textIndicatorImageView; +@property (nonatomic, strong) UILabel *textLabel; +@property (nonatomic, strong) UILabel *informationLabel; +@property (nonatomic, strong) UIView *indicatorStackViewSpacerView; +@property (nonatomic, strong) UIView *indicatorImageTextSpacerView; + +@end + +@implementation STDSChallengeInformationView + +static const CGFloat kHeaderTextBottomPadding = 8; +static const CGFloat kInformationTextBottomPadding = 20; +static const CGFloat kChallengeInformationViewBottomPadding = 6; +static const CGFloat kTextIndicatorHorizontalPadding = 8; + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + + if (self) { + [self _setupViewHierarchy]; + } + + return self; +} + +- (void)setHeaderText:(NSString * _Nullable)headerText { + _headerText = headerText; + + self.headerLabel.text = headerText; + self.headerLabel.hidden = [NSString _stds_isStringEmpty:headerText]; +} + +- (void)setTextIndicatorImage:(UIImage * _Nullable)textIndicatorImage { + _textIndicatorImage = textIndicatorImage; + + self.textIndicatorImageView.image = textIndicatorImage; + self.textIndicatorImageView.hidden = textIndicatorImage == nil; + self.indicatorImageTextSpacerView.hidden = textIndicatorImage == nil; +} + +- (void)setChallengeInformationText:(NSString * _Nullable)challengeInformationText { + _challengeInformationText = challengeInformationText; + + self.textLabel.text = challengeInformationText; + self.textLabel.hidden = [NSString _stds_isStringEmpty:challengeInformationText]; +} + +- (void)setChallengeInformationLabel:(NSString * _Nullable)challengeInformationLabel { + _challengeInformationLabel = challengeInformationLabel; + + self.informationLabel.text = challengeInformationLabel; + self.informationLabel.hidden = [NSString _stds_isStringEmpty:challengeInformationLabel]; + self.indicatorStackViewSpacerView.hidden = self.informationLabel.hidden; +} + +- (void)_setupViewHierarchy { + self.layoutMargins = UIEdgeInsetsMake(0, 0, kChallengeInformationViewBottomPadding, 0); + + self.headerLabel = [self _newInformationLabel]; + + self.textIndicatorImageView = [[UIImageView alloc] init]; + self.textIndicatorImageView.contentMode = UIViewContentModeTop; + self.textIndicatorImageView.hidden = YES; + + self.textLabel = [self _newInformationLabel]; + self.informationLabel = [self _newInformationLabel]; + + self.indicatorStackView = [[STDSStackView alloc] initWithAlignment:STDSStackViewLayoutAxisHorizontal]; + + [self.indicatorStackView addArrangedSubview:self.textIndicatorImageView]; + self.indicatorImageTextSpacerView = [[STDSSpacerView alloc] initWithLayoutAxis:STDSStackViewLayoutAxisHorizontal dimension:kTextIndicatorHorizontalPadding]; + self.indicatorImageTextSpacerView.hidden = YES; + [self.indicatorStackView addArrangedSubview:self.indicatorImageTextSpacerView]; + [self.indicatorStackView addArrangedSubview:self.textLabel]; + + self.informationStackView = [[STDSStackView alloc] initWithAlignment:STDSStackViewLayoutAxisVertical]; + [self.informationStackView addArrangedSubview:self.headerLabel]; + [self.informationStackView addSpacer:kHeaderTextBottomPadding]; + [self.informationStackView addArrangedSubview:self.indicatorStackView]; + self.indicatorStackViewSpacerView = [[STDSSpacerView alloc] initWithLayoutAxis:STDSStackViewLayoutAxisVertical dimension:kInformationTextBottomPadding]; + [self.informationStackView addArrangedSubview:self.indicatorStackViewSpacerView]; + [self.informationStackView addArrangedSubview:self.informationLabel]; + + [self addSubview:self.informationStackView]; + + [self.informationStackView _stds_pinToSuperviewBounds]; + + NSLayoutConstraint *imageViewWidthConstraint = [NSLayoutConstraint constraintWithItem:self.textIndicatorImageView attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0 constant:35]; + [NSLayoutConstraint activateConstraints:@[imageViewWidthConstraint]]; +} + +- (void)setLabelCustomization:(STDSLabelCustomization * _Nullable)labelCustomization { + _labelCustomization = labelCustomization; + + self.headerLabel.font = labelCustomization.headingFont; + self.headerLabel.textColor = labelCustomization.headingTextColor; + + self.textLabel.font = labelCustomization.font; + self.textLabel.textColor = labelCustomization.textColor; + + self.informationLabel.font = labelCustomization.font; + self.informationLabel.textColor = labelCustomization.textColor; +} + +- (UILabel *)_newInformationLabel { + UILabel *label = [[UILabel alloc] init]; + label.numberOfLines = 0; + label.hidden = YES; + + return label; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeRequestParameters.h b/Stripe3DS2/Stripe3DS2/STDSChallengeRequestParameters.h new file mode 100644 index 00000000..d0809963 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeRequestParameters.h @@ -0,0 +1,138 @@ +// +// STDSChallengeRequestParameters.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 4/1/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSJSONEncodable.h" + +@class STDSChallengeParameters; + +typedef NS_ENUM(NSInteger, STDSChallengeCancelType) { + /// The cardholder selected "Cancel" from the UI + STDSChallengeCancelTypeCardholderSelectedCancel, + + /// The transaction timed out + STDSChallengeCancelTypeTransactionTimedOut, +}; + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSChallengeRequestParameters : NSObject + +/** + Convenience initializer to create parameters for the first Challenge Request for a transaction. + */ +- (instancetype)initWithChallengeParameters:(STDSChallengeParameters *)challengeParams + transactionIdentifier:(NSString *)transactionIdentifier + messageVersion:(NSString *)messageVersion; + +/** + Designated initializer for `STDSChallengeRequestParameters` + */ +- (instancetype)initWithThreeDSServerTransactionIdentifier:(NSString *)threeDSServerTransactionIdentifier + acsTransactionIdentifier:(NSString *)acsTransactionIdentifier + messageVersion:(NSString *)messageVersion + sdkTransactionIdentifier:(NSString *)sdkTransactionIdentifier + requestorAppUrl:(NSString *)requestorAppUrl + sdkCounterStoA:(NSInteger)sdkCounterStoA NS_DESIGNATED_INITIALIZER; + +/** + Returns a new instance of STDSChallengeRequestParameters using the receiver, copying over the properties that are invariant across all CReqs for a given transaction and incrementing sdkCounterStoA. + */ +- (instancetype)nextChallengeRequestParametersByIncrementCounter; + +- (instancetype)init NS_UNAVAILABLE; + +#pragma mark - Required Properties + +/** + Universally unique transaction identifier assigned by the 3DS SDK to identify a single transaction. + */ +@property (nonatomic, readonly) NSString *sdkTransactionIdentifier; + +/** + Transaction identifier assigned by the 3DS Server to uniquely identify + a transaction. + */ +@property (nonatomic, readonly) NSString *threeDSServerTransactionIdentifier; + +/** + Transaction identifier assigned by the Access Control Server (ACS) + to uniquely identify a transaction. + */ +@property (nonatomic, readonly) NSString *acsTransactionIdentifier; + +/** + Identifies the type of message - always "CReq" + */ +@property (nonatomic, readonly) NSString *messageType; + +/** + The protocol version that is supported by the SDK and used for the transaction. + */ +@property (nonatomic, readonly) NSString *messageVersion; + +/** + Counter used as a security measure in the 3DS SDK to ACS secure channel. + */ +@property (nonatomic, readonly) NSString *sdkCounterStoA; + +#pragma mark - Optional/Conditional Properties + +/** + The URL for the application that is requesting 3DS2 verification. + This property can be optionally set and will be included with the + messages sent to the Directory Server during the challenge flow. + */ +@property (nonatomic, copy, nullable) NSString *threeDSRequestorAppURL; + +/** + A STDSChallengeCancelType wrapped in NSNumber, indicating that the authentication has been canceled. + */ +@property (nonatomic, copy, nullable) NSNumber *challengeCancel; + +/** + Contains the data that the Cardholder entered into the Native UI text field. + + @note The setter converts empty strings to nil. + */ +@property (nonatomic, copy, nullable) NSString *challengeDataEntry; + +/** + Data that the Cardholder entered into the HTML UI. + */ +@property (nonatomic, copy, nullable) NSString *challengeHTMLDataEntry; + +/** + Data necessary to support requirements not otherwise defined in the 3- D Secure message. + */ +@property (nonatomic, copy, nullable) NSArray *messageExtension; + +/** + A BOOL indiciating that Cardholder has completed the authentication as requested by selecting the Continue button in an Out- of-Band (OOB) authentication method. + */ +@property (nonatomic, nullable) NSNumber *oobContinue; + +/** + Indicator to resend the challenge information code to the Cardholder. + */ +@property (nonatomic, copy, nullable) NSString *resendChallenge; + +/** + Indicator confirming whether whitelisting was opted by the cardholder. + */ +@property (nonatomic, copy, nullable) NSString *whitelistingDataEntry; + +/** + Indicator informing that the Cardholder submits an empty response (no data entered in the UI). + */ +@property (nonatomic, copy, nullable) NSString *challengeNoEntry; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeRequestParameters.m b/Stripe3DS2/Stripe3DS2/STDSChallengeRequestParameters.m new file mode 100644 index 00000000..18fcb8db --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeRequestParameters.m @@ -0,0 +1,105 @@ +// +// STDSChallengeRequestParameters.m +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 4/1/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSChallengeRequestParameters.h" + +#import "STDSChallengeParameters.h" + +@implementation STDSChallengeRequestParameters + +- (instancetype)initWithChallengeParameters:(STDSChallengeParameters *)challengeParams + transactionIdentifier:(NSString *)transactionIdentifier + messageVersion:(NSString *)messageVersion { + return [self initWithThreeDSServerTransactionIdentifier:challengeParams.threeDSServerTransactionID + acsTransactionIdentifier:challengeParams.acsTransactionID + messageVersion:messageVersion + sdkTransactionIdentifier:transactionIdentifier + requestorAppUrl:challengeParams.threeDSRequestorAppURL + sdkCounterStoA:0]; +} + +- (instancetype)initWithThreeDSServerTransactionIdentifier:(NSString *)threeDSServerTransactionIdentifier + acsTransactionIdentifier:(NSString *)acsTransactionIdentifier + messageVersion:(NSString *)messageVersion + sdkTransactionIdentifier:(NSString *)sdkTransactionIdentifier + requestorAppUrl:(NSString *)requestorAppUrl + sdkCounterStoA:(NSInteger)sdkCounterStoA { + self = [super init]; + if (self) { + _messageType = @"CReq"; + _threeDSServerTransactionIdentifier = [threeDSServerTransactionIdentifier copy]; + _acsTransactionIdentifier = [acsTransactionIdentifier copy]; + _messageVersion = [messageVersion copy]; + _sdkTransactionIdentifier = [sdkTransactionIdentifier copy]; + _threeDSRequestorAppURL = [requestorAppUrl copy]; + _sdkCounterStoA = [NSString stringWithFormat:@"%03ld", (long)sdkCounterStoA]; + } + return self; +} + +- (instancetype)nextChallengeRequestParametersByIncrementCounter { + NSInteger incrementedCounter = [self.sdkCounterStoA intValue] + 1; + return [[STDSChallengeRequestParameters alloc] initWithThreeDSServerTransactionIdentifier:self.threeDSServerTransactionIdentifier + acsTransactionIdentifier:self.acsTransactionIdentifier + messageVersion:self.messageVersion + sdkTransactionIdentifier:self.sdkTransactionIdentifier + requestorAppUrl:self.threeDSRequestorAppURL // TC_SDK_10209_001 + sdkCounterStoA:incrementedCounter]; +} + +- (void)setChallengeDataEntry:(NSString *)challengeDataEntry { + // [Req 40] ...if the cardholder has submitted the response without entering any data in the UI, the Challenge Data Entry field shall not be present in the CReq message. + if (challengeDataEntry.length == 0) { + _challengeDataEntry = nil; + _challengeNoEntry = @"Y"; + } else { + _challengeDataEntry = [challengeDataEntry copy]; + _challengeNoEntry = nil; + } +} + +#pragma mark - Helpers + +- (nullable NSString *)challengeCancelString { + if (self.challengeCancel == nil) { + return nil; + } + + STDSChallengeCancelType challengeCancelType = (STDSChallengeCancelType)[self.challengeCancel integerValue]; + switch (challengeCancelType) { + case STDSChallengeCancelTypeCardholderSelectedCancel: + return @"01"; + case STDSChallengeCancelTypeTransactionTimedOut: + return @"08"; + } + return @"07"; // Unknown +} + +#pragma mark - STDSJSONEncodable + ++ (NSDictionary *)propertyNamesToJSONKeysMapping { + return @{ + NSStringFromSelector(@selector(threeDSServerTransactionIdentifier)): @"threeDSServerTransID", + NSStringFromSelector(@selector(acsTransactionIdentifier)): @"acsTransID", + NSStringFromSelector(@selector(threeDSRequestorAppURL)): @"threeDSRequestorAppURL", + NSStringFromSelector(@selector(challengeCancelString)): @"challengeCancel", + NSStringFromSelector(@selector(challengeDataEntry)): @"challengeDataEntry", + NSStringFromSelector(@selector(challengeHTMLDataEntry)): @"challengeHTMLDataEntry", + NSStringFromSelector(@selector(challengeNoEntry)): @"challengeNoEntry", + NSStringFromSelector(@selector(messageExtension)): @"messageExtension", + NSStringFromSelector(@selector(messageVersion)): @"messageVersion", + NSStringFromSelector(@selector(messageType)): @"messageType", + NSStringFromSelector(@selector(oobContinue)): @"oobContinue", + NSStringFromSelector(@selector(resendChallenge)): @"resendChallenge", + NSStringFromSelector(@selector(sdkTransactionIdentifier)): @"sdkTransID", + NSStringFromSelector(@selector(sdkCounterStoA)): @"sdkCounterStoA", + NSStringFromSelector(@selector(whitelistingDataEntry)): @"whitelistingDataEntry", + }; +} + +@end diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeResponse.h b/Stripe3DS2/Stripe3DS2/STDSChallengeResponse.h new file mode 100644 index 00000000..58606d25 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeResponse.h @@ -0,0 +1,145 @@ +// +// STDSChallengeResponse.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import "STDSChallengeResponseSelectionInfo.h" +#import "STDSChallengeResponseMessageExtension.h" +#import "STDSChallengeResponseImage.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + The `STDSACSUIType` enum defines the type of UI to be presented. + */ +typedef NS_ENUM(NSInteger, STDSACSUIType) { + + /// No UI associated with the response. + STDSACSUITypeNone = 0, + + /// Text challenge response UI. + STDSACSUITypeText = 1, + + /// Single-select challenge response UI. + STDSACSUITypeSingleSelect = 2, + + /// Multi-select challenge response UI. + STDSACSUITypeMultiSelect = 3, + + /// Out Of Band challenge response UI. + STDSACSUITypeOOB = 4, + + /// HTML challenge response UI. + STDSACSUITypeHTML = 5, +}; + +/// A protocol that represents the information contained within a challenge response. +@protocol STDSChallengeResponse + +/// Universally unique transaction identifier assigned by the 3DS Server to identify a single transaction. +@property (nonatomic, readonly) NSString *threeDSServerTransactionID; + +/// Counter used as a security measure in the ACS to 3DS SDK secure channel. +@property (nonatomic, readonly) NSString *acsCounterACStoSDK; + +/// Universally unique transaction identifier assigned by the ACS to identify a single transaction. +@property (nonatomic, readonly) NSString *acsTransactionID; + +/// HTML provided by the ACS in the Challenge Response message. Utilised when HTML is specified in the ACS UI Type during the Cardholder challenge. +@property (nonatomic, readonly, nullable) NSString *acsHTML; + +/// Optional HTML provided by the ACS in the CRes message to be utilised in the Out of Band flow when the HTML is specified in the ACS UI Type during the Cardholder challenge, displayed when the app is moved to the foreground. +@property (nonatomic, readonly, nullable) NSString *acsHTMLRefresh; + +/// User interface type that the 3DS SDK will render, which includes the specific data mapping and requirements. +@property (nonatomic, readonly) STDSACSUIType acsUIType; + +/** + Indicator of the state of the ACS challenge cycle and whether the challenge has completed or will require additional messages. Shall be populated in all Challenge Response messages to convey the current state of the transaction. + + - Note: + If set to YES, the ACS will populate the Transaction Status in the Challenge Response message. + */ +@property (nonatomic, readonly) BOOL challengeCompletionIndicator; + +/// Header text that for the challenge information screen that is being presented. +@property (nonatomic, readonly, nullable) NSString *challengeInfoHeader; + +/// Label to modify the Challenge Data Entry field provided by the Issuer. +@property (nonatomic, readonly, nullable) NSString *challengeInfoLabel; + +/// Text provided by the ACS/Issuer to Cardholder during the Challenge Message exchange. +@property (nonatomic, readonly, nullable) NSString *challengeInfoText; + +/// Text provided by the ACS/Issuer to Cardholder during OOB authentication to replace Challenge Information Text and Challenge Information Text Indicator +@property (nonatomic, readonly, nullable) NSString *challengeAdditionalInfoText; + +/// Indicates when the Issuer/ACS would like a warning icon or similar visual indicator to draw attention to the “Challenge Information Text” that is being displayed. +@property (nonatomic, readonly) BOOL showChallengeInfoTextIndicator; + +/// Selection information that will be presented to the Cardholder if the option is single or multi-select. The variables will be sent in a JSON Array and parsed by the SDK for display in the user interface. +@property (nonatomic, readonly, nullable) NSArray> *challengeSelectInfo; + +/// Label displayed to the Cardholder for the content in Expandable Information Text. +@property (nonatomic, readonly, nullable) NSString *expandInfoLabel; + +/// Text provided by the Issuer from the ACS to be displayed to the Cardholder for additional information and the format will be an expandable text field. +@property (nonatomic, readonly, nullable) NSString *expandInfoText; + +/// Sent in the initial Challenge Response message from the ACS to the 3DS SDK to provide the URL(s) of the Issuer logo or image to be used in the Native UI. +@property (nonatomic, readonly, nullable) id issuerImage; + +/// Data necessary to support requirements not otherwise defined in the 3-D Secure message are carried in a Message Extension. +@property (nonatomic, readonly, nullable) NSArray> *messageExtensions; + +/// Identifies the type of message that is passed. +@property (nonatomic, readonly) NSString *messageType; + +/// Protocol version identifier. This shall be the Protocol Version Number of the specification utilised by the system creating this message. The Message Version Number is set by the 3DS Server which originates the protocol with the AReq message. The Message Version Number does not change during a 3DS transaction. +@property (nonatomic, readonly) NSString *messageVersion; + +/// Mobile Deep link to an authentication app used in the out-of-band authentication. The App URL will open the appropriate location within the authentication app. +@property (nonatomic, readonly, nullable) NSURL *oobAppURL; + +/// Label to be displayed for the link to the OOB App URL. For example: “oobAppLabel”: “Click here to open Your Bank App” +@property (nonatomic, readonly, nullable) NSString *oobAppLabel; + +/// Label to be used in the UI for the button that the user selects when they have completed the OOB authentication. +@property (nonatomic, readonly, nullable) NSString *oobContinueLabel; + +/// Sent in the initial Challenge Response message from the ACS to the 3DS SDK to provide the URL(s) of the DS or Payment System logo or image to be used in the Native UI. +@property (nonatomic, readonly, nullable) id paymentSystemImage; + +/// Label to be used in the UI for the button that the user selects when they would like to have the authentication information present. +@property (nonatomic, readonly, nullable) NSString *resendInformationLabel; + +/// Universally unique transaction identifier assigned by the 3DS SDK to identify a single transaction. +@property (nonatomic, readonly) NSString *sdkTransactionID; + +/** + Label to be used in the UI for the button that the user selects when they have completed the authentication. + + - Note: + This is not used for OOB authentication. + */ +@property (nonatomic, readonly, nullable) NSString *submitAuthenticationLabel; + +/// Text provided by the ACS/Issuer to Cardholder during a Whitelisting transaction. For example, “Would you like to add this Merchant to your whitelist?” +@property (nonatomic, readonly, nullable) NSString *whitelistingInfoText; + +/// Label to be displayed to the Cardholder for the "why" information section. +@property (nonatomic, readonly, nullable) NSString *whyInfoLabel; + +/// Text provided by the Issuer to be displayed to the Cardholder to explain why the Cardholder is being asked to perform the authentication task. +@property (nonatomic, readonly, nullable) NSString *whyInfoText; + +/// Indicates the state of the associated Transaction. +@property (nonatomic, readonly, nullable) NSString *transactionStatus; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeResponseImage.h b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseImage.h new file mode 100644 index 00000000..9128d77d --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseImage.h @@ -0,0 +1,27 @@ +// +// STDSChallengeResponseImage.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// A protocol used to represent information about an individual image resource inside of a challenge response. +@protocol STDSChallengeResponseImage + +/// A medium density image to display as the issuer image. +@property (nonatomic, readonly, nullable) NSURL *mediumDensityURL; + +/// A high density image to display as the issuer image. +@property (nonatomic, readonly, nullable) NSURL *highDensityURL; + +/// An extra-high density image to display as the issuer image. +@property (nonatomic, readonly, nullable) NSURL *extraHighDensityURL; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeResponseImageObject.h b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseImageObject.h new file mode 100644 index 00000000..59e21208 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseImageObject.h @@ -0,0 +1,23 @@ +// +// STDSChallengeResponseImageObject.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import "STDSChallengeResponseImage.h" + +#import "STDSJSONDecodable.h" + +NS_ASSUME_NONNULL_BEGIN + +/// An object used to represent information about an individual image resource inside of a challenge response. +@interface STDSChallengeResponseImageObject: NSObject + +- (instancetype)initWithMediumDensityURL:(NSURL * _Nullable)mediumDensityURL highDensityURL:(NSURL * _Nullable)highDensityURL extraHighDensityURL:(NSURL * _Nullable)extraHighDensityURL; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeResponseImageObject.m b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseImageObject.m new file mode 100644 index 00000000..a3ea6840 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseImageObject.m @@ -0,0 +1,53 @@ +// +// STDSChallengeResponseImageObject.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSChallengeResponseImageObject.h" + +#import "NSDictionary+DecodingHelpers.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSChallengeResponseImageObject() + +@property (nonatomic, nullable) NSURL *mediumDensityURL; +@property (nonatomic, nullable) NSURL *highDensityURL; +@property (nonatomic, nullable) NSURL *extraHighDensityURL; + +@end + +@implementation STDSChallengeResponseImageObject + +- (instancetype)initWithMediumDensityURL:(NSURL * _Nullable)mediumDensityURL highDensityURL:(NSURL * _Nullable)highDensityURL extraHighDensityURL:(NSURL * _Nullable)extraHighDensityURL { + self = [super init]; + + if (self) { + _mediumDensityURL = mediumDensityURL; + _highDensityURL = highDensityURL; + _extraHighDensityURL = extraHighDensityURL; + } + + return self; +} + ++ (nullable instancetype)decodedObjectFromJSON:(nullable NSDictionary *)json error:(NSError * _Nullable __autoreleasing * _Nullable)outError { + if (json == nil) { + return nil; + } + + NSURL *mediumDensityURL = [json _stds_urlForKey:@"medium" required:NO error:nil]; + NSURL *highDensityURL = [json _stds_urlForKey:@"high" required:NO error:nil]; + NSURL *extraHighDensityURL = [json _stds_urlForKey:@"extraHigh" required:NO error:nil]; + + return [[STDSChallengeResponseImageObject alloc] initWithMediumDensityURL:mediumDensityURL + highDensityURL:highDensityURL + extraHighDensityURL:extraHighDensityURL]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeResponseMessageExtension.h b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseMessageExtension.h new file mode 100644 index 00000000..c26425f9 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseMessageExtension.h @@ -0,0 +1,30 @@ +// +// STDSChallengeResponseMessageExtension.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// A protocol that encapsulates an individual message extension inside of a challenge response. +@protocol STDSChallengeResponseMessageExtension + +/// The name of the extension data set as defined by the extension owner. +@property (nonatomic, readonly) NSString *name; + +/// A unique identifier for the extension. +@property (nonatomic, readonly) NSString *identifier; + +/// A Boolean value indicating whether the recipient must understand the contents of the extension to interpret the entire message. +@property (nonatomic, readonly, getter = isCriticalityIndicator) BOOL criticalityIndicator; + +/// The data carried in the extension. +@property (nonatomic, readonly) NSDictionary *data; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeResponseMessageExtensionObject.h b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseMessageExtensionObject.h new file mode 100644 index 00000000..5ece844b --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseMessageExtensionObject.h @@ -0,0 +1,21 @@ +// +// STDSChallengeResponseMessageExtensionObject.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import "STDSChallengeResponseMessageExtension.h" + +#import "STDSJSONDecodable.h" + +NS_ASSUME_NONNULL_BEGIN + +/// An object used to represent an individual message extension inside of a challenge response. +@interface STDSChallengeResponseMessageExtensionObject: NSObject + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeResponseMessageExtensionObject.m b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseMessageExtensionObject.m new file mode 100644 index 00000000..54e58d42 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseMessageExtensionObject.m @@ -0,0 +1,72 @@ +// +// STDSChallengeResponseMessageExtensionObject.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSChallengeResponseMessageExtensionObject.h" + +#import "NSDictionary+DecodingHelpers.h" +#import "NSError+Stripe3DS2.h" + +NS_ASSUME_NONNULL_BEGIN + +static const NSInteger kMaximumStringFieldLength = 64; +static const NSInteger kMaximumDataFieldLength = 8059; + +@implementation STDSChallengeResponseMessageExtensionObject + +@synthesize name = _name; +@synthesize identifier = _identifier; +@synthesize criticalityIndicator = _criticalityIndicator; +@synthesize data = _data; + +- (instancetype)initWithName:(NSString *)name identifier:(NSString *)identifier criticalityIndicator:(BOOL)criticalityIndicator data:(NSDictionary *)data { + self = [super init]; + if (self) { + _name = [name copy]; + _identifier = [identifier copy]; + _criticalityIndicator = criticalityIndicator; + _data = data; + } + return self; +} + ++ (nullable instancetype)decodedObjectFromJSON:(nullable NSDictionary *)json error:(NSError * _Nullable __autoreleasing * _Nullable)outError { + if (json == nil) { + return nil; + } + NSError *error; + + NSString *name = [json _stds_stringForKey:@"name" validator:^BOOL (NSString *value) { + return value.length <= kMaximumStringFieldLength; + }required:YES error:&error]; + NSString *identifier = [json _stds_stringForKey:@"id" validator:^BOOL (NSString *value) { + return value.length <= kMaximumStringFieldLength; + } required:YES error:&error]; + BOOL criticalityIndicator= [json _stds_boolForKey:@"criticalityIndicator" required:YES error:&error].boolValue; + NSDictionary *data = [json _stds_dictionaryForKey:@"data" required:YES error:&error]; + // The spec requires data to be "Maximum 8059 characters" + if (data && [NSJSONSerialization dataWithJSONObject:data options:0 error:nil].length > kMaximumDataFieldLength) { + error = [NSError _stds_invalidJSONFieldError:@"data"]; + } + + if (error) { + if (outError) { + *outError = error; + } + return nil; + } + + if (data != nil) { + return [[STDSChallengeResponseMessageExtensionObject alloc] initWithName:name identifier:identifier criticalityIndicator:criticalityIndicator data:data]; + } else { + return nil; + } +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeResponseObject.h b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseObject.h new file mode 100644 index 00000000..1f245286 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseObject.h @@ -0,0 +1,49 @@ +// +// STDSChallengeResponseObject.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import "STDSChallengeResponse.h" +#import "STDSJSONDecodable.h" + +NS_ASSUME_NONNULL_BEGIN + +/// An object used to represent a challenge response from the ACS. +@interface STDSChallengeResponseObject: NSObject + +- (instancetype)initWithThreeDSServerTransactionID:(NSString *)threeDSServerTransactionID + acsCounterACStoSDK:(NSString *)acsCounterACStoSDK + acsTransactionID:(NSString *)acsTransactionID + acsHTML:(NSString * _Nullable)acsHTML + acsHTMLRefresh:(NSString * _Nullable)acsHTMLRefresh + acsUIType:(STDSACSUIType)acsUIType + challengeCompletionIndicator:(BOOL)challengeCompletionIndicator + challengeInfoHeader:(NSString * _Nullable)challengeInfoHeader + challengeInfoLabel:(NSString * _Nullable)challengeInfoLabel + challengeInfoText:(NSString * _Nullable)challengeInfoText + challengeAdditionalInfoText:(NSString * _Nullable)challengeAdditionalInfoText + showChallengeInfoTextIndicator:(BOOL)showChallengeInfoTextIndicator + challengeSelectInfo:(NSArray> * _Nullable)challengeSelectInfo + expandInfoLabel:(NSString * _Nullable)expandInfoLabel + expandInfoText:(NSString * _Nullable)expandInfoText + issuerImage:(id _Nullable)issuerImage + messageExtensions:(NSArray> * _Nullable)messageExtensions + messageVersion:(NSString *)messageVersion + oobAppURL:(NSURL * _Nullable)oobAppURL + oobAppLabel:(NSString * _Nullable)oobAppLabel + oobContinueLabel:(NSString * _Nullable)oobContinueLabel + paymentSystemImage:(id _Nullable)paymentSystemImage + resendInformationLabel:(NSString * _Nullable)resendInformationLabel + sdkTransactionID:(NSString *)sdkTransactionID + submitAuthenticationLabel:(NSString * _Nullable)submitAuthenticationLabel + whitelistingInfoText:(NSString * _Nullable)whitelistingInfoText + whyInfoLabel:(NSString * _Nullable)whyInfoLabel + whyInfoText:(NSString * _Nullable)whyInfoText + transactionStatus:(NSString * _Nullable)transactionStatus; +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeResponseObject.m b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseObject.m new file mode 100644 index 00000000..7d952bb9 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseObject.m @@ -0,0 +1,321 @@ +// +// STDSChallengeResponseObject.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSChallengeResponseObject.h" + +#import "NSDictionary+DecodingHelpers.h" +#import "NSError+Stripe3DS2.h" +#import "STDSChallengeResponseSelectionInfoObject.h" +#import "STDSChallengeResponseImageObject.h" +#import "STDSChallengeResponseMessageExtensionObject.h" +#import "NSString+JWEHelpers.h" +#import "STDSStripe3DS2Error.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSChallengeResponseObject + +@synthesize threeDSServerTransactionID = _threeDSServerTransactionID; +@synthesize acsCounterACStoSDK = _acsCounterACStoSDK; +@synthesize acsTransactionID = _acsTransactionID; +@synthesize acsHTML = _acsHTML; +@synthesize acsHTMLRefresh = _acsHTMLRefresh; +@synthesize acsUIType = _acsUIType; +@synthesize challengeCompletionIndicator = _challengeCompletionIndicator; +@synthesize challengeInfoHeader = _challengeInfoHeader; +@synthesize challengeInfoLabel = _challengeInfoLabel; +@synthesize challengeInfoText = _challengeInfoText; +@synthesize challengeAdditionalInfoText = _challengeAdditionalInfoText; +@synthesize showChallengeInfoTextIndicator = _showChallengeInfoTextIndicator; +@synthesize challengeSelectInfo = _challengeSelectInfo; +@synthesize expandInfoLabel = _expandInfoLabel; +@synthesize expandInfoText = _expandInfoText; +@synthesize issuerImage = _issuerImage; +@synthesize messageExtensions = _messageExtensions; +@synthesize messageType = _messageType; +@synthesize messageVersion = _messageVersion; +@synthesize oobAppURL = _oobAppURL; +@synthesize oobAppLabel = _oobAppLabel; +@synthesize oobContinueLabel = _oobContinueLabel; +@synthesize paymentSystemImage = _paymentSystemImage; +@synthesize resendInformationLabel = _resendInformationLabel; +@synthesize sdkTransactionID = _sdkTransactionID; +@synthesize submitAuthenticationLabel = _submitAuthenticationLabel; +@synthesize whitelistingInfoText = _whitelistingInfoText; +@synthesize whyInfoLabel = _whyInfoLabel; +@synthesize whyInfoText = _whyInfoText; +@synthesize transactionStatus = _transactionStatus; + +- (NSString *)description { + return [NSString stringWithFormat:@"%@ -- completion: %@, count: %@", [super description], @(self.challengeCompletionIndicator), self.acsCounterACStoSDK]; +} + +- (instancetype)initWithThreeDSServerTransactionID:(NSString *)threeDSServerTransactionID + acsCounterACStoSDK:(NSString *)acsCounterACStoSDK + acsTransactionID:(NSString *)acsTransactionID + acsHTML:(NSString * _Nullable)acsHTML + acsHTMLRefresh:(NSString * _Nullable)acsHTMLRefresh + acsUIType:(STDSACSUIType)acsUIType + challengeCompletionIndicator:(BOOL)challengeCompletionIndicator + challengeInfoHeader:(NSString * _Nullable)challengeInfoHeader + challengeInfoLabel:(NSString * _Nullable)challengeInfoLabel + challengeInfoText:(NSString * _Nullable)challengeInfoText + challengeAdditionalInfoText:(NSString * _Nullable)challengeAdditionalInfoText + showChallengeInfoTextIndicator:(BOOL)showChallengeInfoTextIndicator + challengeSelectInfo:(NSArray> * _Nullable)challengeSelectInfo + expandInfoLabel:(NSString * _Nullable)expandInfoLabel + expandInfoText:(NSString * _Nullable)expandInfoText + issuerImage:(id _Nullable)issuerImage + messageExtensions:(NSArray> * _Nullable)messageExtensions + messageVersion:(NSString *)messageVersion + oobAppURL:(NSURL * _Nullable)oobAppURL + oobAppLabel:(NSString * _Nullable)oobAppLabel + oobContinueLabel:(NSString * _Nullable)oobContinueLabel + paymentSystemImage:(id _Nullable)paymentSystemImage + resendInformationLabel:(NSString * _Nullable)resendInformationLabel + sdkTransactionID:(NSString *)sdkTransactionID + submitAuthenticationLabel:(NSString * _Nullable)submitAuthenticationLabel + whitelistingInfoText:(NSString * _Nullable)whitelistingInfoText + whyInfoLabel:(NSString * _Nullable)whyInfoLabel + whyInfoText:(NSString * _Nullable)whyInfoText + transactionStatus:(NSString * _Nullable)transactionStatus { + self = [super init]; + + if (self) { + _threeDSServerTransactionID = [threeDSServerTransactionID copy]; + _acsCounterACStoSDK = [acsCounterACStoSDK copy]; + _acsTransactionID = [acsTransactionID copy]; + _acsHTML = [acsHTML copy]; + _acsHTMLRefresh = [acsHTMLRefresh copy]; + _acsUIType = acsUIType; + _challengeCompletionIndicator = challengeCompletionIndicator; + _challengeInfoHeader = [challengeInfoHeader copy]; + _challengeInfoLabel = [challengeInfoLabel copy]; + _challengeInfoText = [challengeInfoText copy]; + _challengeAdditionalInfoText = [challengeAdditionalInfoText copy]; + _showChallengeInfoTextIndicator = showChallengeInfoTextIndicator; + _challengeSelectInfo = [challengeSelectInfo copy]; + _expandInfoLabel = [expandInfoLabel copy]; + _expandInfoText = [expandInfoText copy]; + _issuerImage = issuerImage; + _messageExtensions = [messageExtensions copy]; + _messageType = @"CRes"; + _messageVersion = [messageVersion copy]; + _oobAppURL = oobAppURL; + _oobAppLabel = [oobAppLabel copy]; + _oobContinueLabel = [oobContinueLabel copy]; + _paymentSystemImage = paymentSystemImage; + _resendInformationLabel = [resendInformationLabel copy]; + _sdkTransactionID = [sdkTransactionID copy]; + _submitAuthenticationLabel = [submitAuthenticationLabel copy]; + _whitelistingInfoText = [whitelistingInfoText copy]; + _whyInfoLabel = [whyInfoLabel copy]; + _whyInfoText = [whyInfoText copy]; + _transactionStatus = [transactionStatus copy]; + } + + return self; +} + +#pragma mark Private Helpers + ++ (NSDictionary *)acsUITypeStringMapping { + return @{ + @"01": @(STDSACSUITypeText), + @"02": @(STDSACSUITypeSingleSelect), + @"03": @(STDSACSUITypeMultiSelect), + @"04": @(STDSACSUITypeOOB), + @"05": @(STDSACSUITypeHTML), + }; +} + +/// The message extension identifiers that we support. ++ (NSSet *)supportedMessageExtensions { + return [NSSet new]; +} + +#pragma mark STDSJSONDecodable + ++ (nullable instancetype)decodedObjectFromJSON:(nullable NSDictionary *)json error:(NSError **)outError { + if (json == nil) { + return nil; + } + NSError *error; + +#pragma mark Required + NSString *threeDSServerTransactionID = [json _stds_stringForKey:@"threeDSServerTransID" validator:^BOOL (NSString *value) { + return [[NSUUID alloc] initWithUUIDString:value] != nil; + } required:YES error:&error]; + NSString *acsCounterACStoSDK = [json _stds_stringForKey:@"acsCounterAtoS" required:YES error:&error]; + NSString *acsTransactionID = [json _stds_stringForKey:@"acsTransID" required:YES error:&error]; + NSString *challengeCompletionIndicatorRawString = [json _stds_stringForKey:@"challengeCompletionInd" validator:^BOOL (NSString *value) { + return [value isEqualToString:@"N"] || [value isEqualToString:@"Y"]; + } required:YES error:&error]; + // There is only one valid messageType value for this object (@"CRes"), so we don't store it. + [json _stds_stringForKey:@"messageType" validator:^BOOL (NSString *value) { + return [value isEqualToString:@"CRes"]; + } required:YES error:&error]; + NSString *messageVersion = [json _stds_stringForKey:@"messageVersion" required:YES error:&error]; + NSString *sdkTransactionID = [json _stds_stringForKey:@"sdkTransID" required:YES error:&error]; + + BOOL challengeCompletionIndicator = challengeCompletionIndicatorRawString.boolValue; + + STDSACSUIType acsUIType = STDSACSUITypeNone; + if (!challengeCompletionIndicator) { + NSString *acsUITypeRawString = [json _stds_stringForKey:@"acsUiType" validator:^BOOL (NSString *value) { + return [self acsUITypeStringMapping][value] != nil; + } required:YES error:&error]; + + acsUIType = [self acsUITypeStringMapping][acsUITypeRawString].integerValue; + } + + if (error) { + // We failed to populate a required field + if (outError) { + *outError = error; + } + return nil; + } + + // At this point all the above values are valid: e.g. raw string representations of a BOOL or enum will map to a valid value. + +#pragma mark Conditional + NSString *encodedAcsHTML = [json _stds_stringForKey:@"acsHTML" required:(acsUIType == STDSACSUITypeHTML) error: &error]; + NSString *acsHTML = [encodedAcsHTML _stds_base64URLDecodedString]; + if (encodedAcsHTML && !acsHTML) { + // html was not valid base64url + error = [NSError _stds_invalidJSONFieldError:@"acsHTML"]; + } + + NSArray> *challengeSelectInfo = [json _stds_arrayForKey:@"challengeSelectInfo" + arrayElementType:[STDSChallengeResponseSelectionInfoObject class] + required:(acsUIType == STDSACSUITypeSingleSelect || acsUIType == STDSACSUITypeMultiSelect) + error:&error]; + NSString *oobContinueLabel = [json _stds_stringForKey:@"oobContinueLabel" required:(acsUIType == STDSACSUITypeOOB) error:&error]; + NSString *submitAuthenticationLabel = [json _stds_stringForKey:@"submitAuthenticationLabel" required:(acsUIType == STDSACSUITypeText || acsUIType == STDSACSUITypeSingleSelect || acsUIType == STDSACSUITypeMultiSelect || acsUIType == STDSACSUITypeText) error:&error]; + +#pragma mark Optional + NSArray> *messageExtensions = [json _stds_arrayForKey:@"messageExtension" + arrayElementType:[STDSChallengeResponseMessageExtensionObject class] + required:NO + error:&error]; + NSMutableArray *unrecognizedMessageExtensionIdentifiers = [NSMutableArray new]; + for (id messageExtension in messageExtensions) { + if (messageExtension.criticalityIndicator && ![[self supportedMessageExtensions] containsObject:messageExtension.identifier]) { + [unrecognizedMessageExtensionIdentifiers addObject:messageExtension.identifier]; + } + } + if (unrecognizedMessageExtensionIdentifiers.count > 0) { + error = [NSError errorWithDomain:STDSStripe3DS2ErrorDomain code:STDSErrorCodeUnrecognizedCriticalMessageExtension userInfo:@{STDSStripe3DS2UnrecognizedCriticalMessageExtensionsKey: unrecognizedMessageExtensionIdentifiers}]; + } + if (messageExtensions.count > 10) { + error = [NSError _stds_invalidJSONFieldError:@"messageExtension"]; + } + + NSString *encodedAcsHTMLRefresh = [json _stds_stringForKey:@"acsHTMLRefresh" required:NO error: &error]; + NSString *acsHTMLRefresh = [encodedAcsHTMLRefresh _stds_base64URLDecodedString]; + if (encodedAcsHTMLRefresh && !acsHTMLRefresh) { + // html was not valid base64url + error = [NSError _stds_invalidJSONFieldError:@"acsHTMLRefresh"]; + } + + BOOL infoLabelRequired = NO; + BOOL headerRequired = NO; + BOOL infoTextRequired = NO; + switch (acsUIType) { + case STDSACSUITypeNone: + break; // no-op + case STDSACSUITypeText: + case STDSACSUITypeSingleSelect: + case STDSACSUITypeMultiSelect: + infoLabelRequired = YES; // TC_SDK_10270_001 & TC_SDK_10276_001 & TC_SDK_10284_001 + headerRequired = YES; // TC_SDK_10268_001 & TC_SDK_10273_001 & TC_SDK_10282_001 + infoTextRequired = YES; // TC_SDK_10272_001 & TC_SDK_10278_001 & TC_SDK_10286_001 + break; + case STDSACSUITypeOOB: + + break; + case STDSACSUITypeHTML: + break; // no-op + } + + + NSString *challengeInfoLabel = [json _stds_stringForKey:@"challengeInfoLabel" validator:nil required:infoLabelRequired error:&error]; + NSString *challengeInfoHeader = [json _stds_stringForKey:@"challengeInfoHeader" required: (oobContinueLabel != nil) || headerRequired error:&error]; // TC_SDK_10292_001 + NSString *challengeInfoText = [json _stds_stringForKey:@"challengeInfoText" required:(oobContinueLabel != nil) || infoTextRequired error:&error]; // TC_SDK_10292_001 + NSString *challengeAdditionalInfoText = [json _stds_stringForKey:@"challengeAddInfo" required:NO error:&error]; + if (!error && submitAuthenticationLabel && (!challengeInfoLabel || !challengeInfoHeader || !challengeInfoText)) { + error = [NSError _stds_missingJSONFieldError:@"challengeInfoLabel or challengeInfoText"]; + } + + NSString *showChallengeInfoTextIndicatorRawString; + if (json[@"challengeInfoTextIndicator"]) { + showChallengeInfoTextIndicatorRawString = [json _stds_stringForKey:@"challengeInfoTextIndicator" validator:^BOOL (NSString *value) { + return [value isEqualToString:@"N"] || [value isEqualToString:@"Y"]; + } required:NO error:&error]; + } + BOOL showChallengeInfoTextIndicator = showChallengeInfoTextIndicatorRawString ? showChallengeInfoTextIndicatorRawString.boolValue : NO; // If the field is missing, we shouldn't show the indicator + NSString *expandInfoLabel = [json _stds_stringForKey:@"expandInfoLabel" required:NO error:&error]; + NSString *expandInfoText = [json _stds_stringForKey:@"expandInfoText" required:NO error:&error]; + NSURL *oobAppURL = [json _stds_urlForKey:@"oobAppURL" required:NO error:&error]; + NSString *oobAppLabel = [json _stds_stringForKey:@"oobAppURL" required:NO error:&error]; + NSDictionary *issuerImageJSON = [json _stds_dictionaryForKey:@"issuerImage" required:NO error:&error]; + STDSChallengeResponseImageObject *issuerImage = [STDSChallengeResponseImageObject decodedObjectFromJSON:issuerImageJSON error:&error]; + NSDictionary *paymentSystemImageJSON = [json _stds_dictionaryForKey:@"psImage" required:NO error:&error]; + STDSChallengeResponseImageObject *paymentSystemImage = [STDSChallengeResponseImageObject decodedObjectFromJSON:paymentSystemImageJSON error:&error]; + NSString *resendInformationLabel = [json _stds_stringForKey:@"resendInformationLabel" required:NO error:&error]; + NSString *whitelistingInfoText = [json _stds_stringForKey:@"whitelistingInfoText" required:NO error:&error]; + if (whitelistingInfoText.length > 64) { + // TC_SDK_10199_001 + error = [NSError _stds_invalidJSONFieldError:@"whitelisting text is greater than 64 characters"]; + } + NSString *whyInfoLabel = [json _stds_stringForKey:@"whyInfoLabel" required:NO error:&error]; + NSString *whyInfoText = [json _stds_stringForKey:@"whyInfoText" required:NO error:&error]; + NSString *transactionStatus = [json _stds_stringForKey:@"transStatus" required:challengeCompletionIndicator error:&error]; + + if (error) { + if (outError) { + *outError = error; + } + return nil; + } + + return [[self alloc] initWithThreeDSServerTransactionID:threeDSServerTransactionID + acsCounterACStoSDK:acsCounterACStoSDK + acsTransactionID:acsTransactionID + acsHTML:acsHTML + acsHTMLRefresh:acsHTMLRefresh + acsUIType:acsUIType + challengeCompletionIndicator:challengeCompletionIndicator + challengeInfoHeader:challengeInfoHeader + challengeInfoLabel:challengeInfoLabel + challengeInfoText:challengeInfoText + challengeAdditionalInfoText:challengeAdditionalInfoText + showChallengeInfoTextIndicator:showChallengeInfoTextIndicator + challengeSelectInfo:challengeSelectInfo + expandInfoLabel:expandInfoLabel + expandInfoText:expandInfoText + issuerImage:issuerImage + messageExtensions:messageExtensions + messageVersion:messageVersion + oobAppURL:oobAppURL + oobAppLabel:oobAppLabel + oobContinueLabel:oobContinueLabel + paymentSystemImage:paymentSystemImage + resendInformationLabel:resendInformationLabel + sdkTransactionID:sdkTransactionID + submitAuthenticationLabel:submitAuthenticationLabel + whitelistingInfoText:whitelistingInfoText + whyInfoLabel:whyInfoLabel + whyInfoText:whyInfoText + transactionStatus:transactionStatus]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeResponseSelectionInfo.h b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseSelectionInfo.h new file mode 100644 index 00000000..a482c8c1 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseSelectionInfo.h @@ -0,0 +1,24 @@ +// +// STDSChallengeResponseSelectionInfo.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// A protocol that encapsulates information about an individual selection inside of a challenge response. +@protocol STDSChallengeResponseSelectionInfo + +/// The name of the selection option. +@property (nonatomic, readonly) NSString *name; + +/// The value of the selection option. +@property (nonatomic, readonly) NSString *value; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeResponseSelectionInfoObject.h b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseSelectionInfoObject.h new file mode 100644 index 00000000..6e34b6c6 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseSelectionInfoObject.h @@ -0,0 +1,21 @@ +// +// STDSChallengeResponseSelectionInfoObject.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import "STDSChallengeResponseSelectionInfo.h" + +NS_ASSUME_NONNULL_BEGIN + +/// An object used to represent information about an individual selection inside of a challenge response. +@interface STDSChallengeResponseSelectionInfoObject: NSObject + +- (instancetype)initWithName:(NSString *)name value:(NSString *)value; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeResponseSelectionInfoObject.m b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseSelectionInfoObject.m new file mode 100644 index 00000000..8aa22ee0 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseSelectionInfoObject.m @@ -0,0 +1,46 @@ +// +// STDSChallengeResponseSelectionInfoObject.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSChallengeResponseSelectionInfoObject.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSChallengeResponseSelectionInfoObject() + +@property (nonatomic, strong) NSString *name; +@property (nonatomic, strong) NSString *value; + +@end + +@implementation STDSChallengeResponseSelectionInfoObject + +- (instancetype)initWithName:(NSString *)name value:(NSString *)value { + self = [super init]; + + if (self) { + _name = name; + _value = value; + } + + return self; +} + ++ (nullable instancetype)decodedObjectFromJSON:(nullable NSDictionary *)json error:(NSError * _Nullable __autoreleasing * _Nullable)outError { + if (json == nil) { + return nil; + } + + NSString *name = [json allKeys].firstObject; + NSString *value = [json objectForKey:name]; + + return [[STDSChallengeResponseSelectionInfoObject alloc] initWithName:name value:value]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeResponseViewController.h b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseViewController.h new file mode 100644 index 00000000..e1ace0cc --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseViewController.h @@ -0,0 +1,80 @@ +// +// STDSChallengeResponseViewController.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/4/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import "STDSChallengeResponse.h" +#import "STDSUICustomization.h" +#import "STDSImageLoader.h" +#import "STDSDirectoryServer.h" + +@class STDSChallengeResponseViewController; + +NS_ASSUME_NONNULL_BEGIN + +@protocol STDSChallengeResponseViewControllerDelegate + +/** + Called when the user taps the Submit button after entering text in the Text flow (STDSACSUITypeText) + */ +- (void)challengeResponseViewController:(STDSChallengeResponseViewController *)viewController didSubmitInput:(NSString *)userInput + whitelistSelection: (id) whitelistSelection; + +/** + Called when the user taps the Submit button after selecting one or more options in the Single-Select (STDSACSUITypeSingleSelect) or Multi-Select (STDSACSUITypeMultiSelect) flow. + */ +- (void)challengeResponseViewController:(STDSChallengeResponseViewController *)viewController didSubmitSelection:(NSArray> *)selection whitelistSelection: (id) whitelistSelection; + +/** + Called when the user submits an HTML form. + */ +- (void)challengeResponseViewController:(STDSChallengeResponseViewController *)viewController didSubmitHTMLForm:(NSString *)form; + +/** + Called when the user taps the Continue button from an Out-of-Band flow (STDSACSUITypeOOB). + */ +- (void)challengeResponseViewControllerDidOOBContinue:(STDSChallengeResponseViewController *)viewController whitelistSelection: (id) whitelistSelection; + +/** + Called when the user taps the Cancel button. + */ +- (void)challengeResponseViewControllerDidCancel:(STDSChallengeResponseViewController *)viewController; + +/** + Called when the user taps the Resend button. + */ +- (void)challengeResponseViewControllerDidRequestResend:(STDSChallengeResponseViewController *)viewController; + +@end + +@protocol STDSChallengeResponseViewControllerPresentationDelegate + +- (void)dismissChallengeResponseViewController:(STDSChallengeResponseViewController *)viewController; + +@end + +@interface STDSChallengeResponseViewController : UIViewController + +@property (nonatomic, weak) id delegate; + +@property (nonatomic, nullable, weak) id presentationDelegate; + +/// Use setChallengeResponser:animated: to update this value +@property (nonatomic, strong, readonly) id response; + +- (instancetype)initWithUICustomization:(STDSUICustomization * _Nullable)uiCustomization imageLoader:(STDSImageLoader *)imageLoader directoryServer:(STDSDirectoryServer)directoryServer; + +/// If `setLoading` was called beforehand, this waits until the loading spinner has been shown for at least 1 second before displaying the challenge responseself.processingView.isHidden. +- (void)setChallengeResponse:(id)response animated:(BOOL)animated; + +- (void)setLoading; + +- (void)dismiss; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeResponseViewController.m b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseViewController.m new file mode 100644 index 00000000..7f313c8a --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeResponseViewController.m @@ -0,0 +1,571 @@ +// +// STDSChallengeResponseViewController.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/4/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +@import WebKit; + +#import "STDSBundleLocator.h" +#import "STDSLocalizedString.h" +#import "STDSChallengeResponseViewController.h" +#import "STDSImageLoader.h" +#import "STDSStackView.h" +#import "STDSBrandingView.h" +#import "STDSChallengeInformationView.h" +#import "STDSChallengeSelectionView.h" +#import "STDSTextChallengeView.h" +#import "STDSWhitelistView.h" +#import "STDSExpandableInformationView.h" +#import "STDSWebView.h" +#import "STDSProcessingView.h" +#import "UIView+LayoutSupport.h" +#import "NSString+EmptyChecking.h" +#import "UIColor+DefaultColors.h" +#import "UIButton+CustomInitialization.h" +#import "UIFont+DefaultFonts.h" +#import "UIViewController+Stripe3DS2.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSChallengeResponseViewController() + +@property (nonatomic, strong, nullable) id response; +@property (nonatomic) STDSDirectoryServer directoryServer; +/// Used to track how long we've been showing a loading spinner. Nil if we are not showing a spinner. +@property (nonatomic, strong, nullable) NSDate *loadingStartDate; +@property (nonatomic, strong, nullable) STDSUICustomization *uiCustomization; +@property (nonatomic, strong) STDSImageLoader *imageLoader; +@property (nonatomic, strong) NSTimer *processingTimer; +@property (nonatomic, getter=isLoading) BOOL loading; +@property (nonatomic, strong) STDSProcessingView *processingView; +@property (nonatomic, strong, nullable) UIScrollView *scrollView; +@property (nonatomic, strong, nullable) STDSWebView *webView; +@property (nonatomic, strong, nullable) STDSChallengeInformationView *challengeInformationView; +@property (nonatomic, strong) UITapGestureRecognizer *tapOutsideKeyboardGestureRecognizer; + +// User input views +@property (nonatomic, strong) STDSChallengeSelectionView *challengeSelectionView; +@property (nonatomic, strong) STDSTextChallengeView *textChallengeView; +@property (nonatomic, strong) STDSWhitelistView *whitelistView; +@property (nonatomic, strong) UIStackView *buttonStackView; +@end + +@implementation STDSChallengeResponseViewController + +static const NSTimeInterval kInterstepProcessingTime = 1.0; +static const NSTimeInterval kDefaultTransitionAnimationDuration = 0.3; +static const CGFloat kBrandingViewHeight = 107; +static const CGFloat kContentHorizontalInset = 16; +static const CGFloat kExpandableContentHorizontalInset = 27; +static const CGFloat kContentViewTopPadding = 16; +static const CGFloat kContentViewBottomPadding = 26; +static const CGFloat kExpandableContentViewTopPadding = 28; + +static NSString * const kHTMLStringLoadingURL = @"about:blank"; + +- (instancetype)initWithUICustomization:(STDSUICustomization * _Nullable)uiCustomization imageLoader:(STDSImageLoader *)imageLoader directoryServer:(STDSDirectoryServer)directoryServer { + self = [super initWithNibName:nil bundle:nil]; + + if (self) { + _uiCustomization = uiCustomization; + _imageLoader = imageLoader; + _tapOutsideKeyboardGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(_didTapOutsideKeyboard:)]; + _directoryServer = directoryServer; + } + + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + [self _stds_setupNavigationBarElementsWithCustomization:_uiCustomization cancelButtonSelector:@selector(_cancelButtonTapped:)]; + self.view.backgroundColor = self.uiCustomization.backgroundColor; + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_keyboardDidShow:) name:UIKeyboardDidShowNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_keyboardWillHide:) name:UIKeyboardWillHideNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_applicationWillEnterForeground:) name:UIApplicationWillEnterForegroundNotification object:nil]; + + NSString *imageName = STDSDirectoryServerImageName(self.directoryServer); + UIImage *dsImage = imageName ? [UIImage imageNamed:imageName inBundle:[STDSBundleLocator stdsResourcesBundle] compatibleWithTraitCollection:nil] : nil; + self.processingView = [[STDSProcessingView alloc] initWithCustomization:self.uiCustomization directoryServerLogo:dsImage]; + self.processingView.hidden = !self.isLoading; + + [self.view addSubview:self.processingView]; + [self.processingView _stds_pinToSuperviewBoundsWithoutMargin]; + + [self.view addGestureRecognizer:self.tapOutsideKeyboardGestureRecognizer]; +} + +- (UIStatusBarStyle)preferredStatusBarStyle { + return self.uiCustomization.preferredStatusBarStyle; +} + +#pragma mark - Public APIs + +- (void)setLoading { + [self _setLoading:YES]; +} + +- (void)setChallengeResponse:(id)response animated:(BOOL)animated { + BOOL isFirstChallengeResponse = _response == nil; + _response = response; + + [self.processingTimer invalidate]; + + if (isFirstChallengeResponse || !self.isLoading || !self.loadingStartDate) { + [self _displayChallengeResponseAnimated:animated]; + } else { + // Show the loading spinner for at least kDefaultProcessingTime seconds before displaying + NSTimeInterval timeSpentLoading = [[NSDate date] timeIntervalSinceDate:self.loadingStartDate]; + if (timeSpentLoading >= kInterstepProcessingTime) { + // loadingStartDate is nil if we called this method in between viewDidLoad and viewDidAppear. + // There is no time requirement for the initial CRes. + [self _displayChallengeResponseAnimated:animated]; + } else { + self.processingTimer = [NSTimer timerWithTimeInterval:(kInterstepProcessingTime - timeSpentLoading) target:self selector:@selector(_timerDidFire:) userInfo:@(animated) repeats:NO]; + [[NSRunLoop currentRunLoop] addTimer:self.processingTimer forMode:NSDefaultRunLoopMode]; + } + } +} + +- (void)dismiss { + if (self.presentationDelegate) { + [self.presentationDelegate dismissChallengeResponseViewController:self]; + } else { + [self dismissViewControllerAnimated:YES completion:nil]; + } +} + +#pragma mark - Private Helpers + +- (void)_setLoading:(BOOL)isLoading { + self.loading = isLoading; + if (!self.viewLoaded || isLoading == !self.processingView.isHidden) { + return; + } + + /* According to the specs [0], this should be set to NO during AReq/Ares and YES during CReq/CRes. + However, according to UL test feedback [1], the AReq/ARes and initial CReq/CRes processing views should be identical. + + [0]: EMV 3-D Secure Protocol and Core Functions Specification v2.1.0 4.2.1.1 + - "The 3DS SDK shall for the CReq/CRes message exchange...[Req 148] Not include the DS logo or any other design element in the Processing screen." + - "The 3DS SDK shall for the AReq/ARes message exchange...[Req 143] If requested, integrate the DS logo into the Processing screen." + + [1]: UL_PreCompTestReport_ID846_201906_1.0 + - "Visual test case TC_SDK_10022_001 - The test case is FAILED because the processing screen for step 1 and step 2 are not identical. Step 1 displays a 'DS logo' while step 2 does not. + + To pass certification, we'll show the DS logo during the initial CReq/CRes (when self.response == nil). + */ + self.processingView.shouldDisplayDSLogo = self.response == nil; + // If there's no response, the blur view has nothing to blur and looks better visually if it's just the background color + // EDIT Jan 2021: The challenge contents is hidden so this never looks good https://jira.corp.stripe.com/browse/MOBILESDK-153 + self.processingView.shouldDisplayBlurView = NO; // self.response != nil; + + if (isLoading) { + [self.view bringSubviewToFront:self.processingView]; + self.processingView.hidden = NO; + + self.loadingStartDate = [NSDate date]; + UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, STDSLocalizedString(@"Loading", @"Spoken by VoiceOver when the challenge is loading.")); + } else { + self.processingView.hidden = YES; + self.loadingStartDate = nil; + } +} + +- (void)_timerDidFire:(NSTimer *)timer { + BOOL animated = ((NSNumber *)timer.userInfo).boolValue; + [self.processingTimer invalidate]; + [self _displayChallengeResponseAnimated:animated]; +} + +- (void)_setupViewHierarchy { + self.scrollView = [[UIScrollView alloc] init]; + self.scrollView.backgroundColor = self.uiCustomization.footerCustomization.backgroundColor; + self.scrollView.alwaysBounceVertical = YES; + [self.view addSubview:self.scrollView]; + [self.scrollView _stds_pinToSuperviewBoundsWithoutMargin]; + + STDSStackView *containerStackView = [[STDSStackView alloc] initWithAlignment:STDSStackViewLayoutAxisVertical]; + [self.scrollView addSubview:containerStackView]; + [containerStackView _stds_pinToSuperviewBoundsWithoutMargin]; + + UIView *contentView = [UIView new]; + contentView.layoutMargins = UIEdgeInsetsMake(kContentViewTopPadding, kContentHorizontalInset, kContentViewBottomPadding, kContentHorizontalInset); + contentView.backgroundColor = self.uiCustomization.backgroundColor; + [containerStackView addArrangedSubview:contentView]; + + STDSStackView *contentStackView = [[STDSStackView alloc] initWithAlignment:STDSStackViewLayoutAxisVertical]; + [contentView addSubview:contentStackView]; + [contentStackView _stds_pinToSuperviewBounds]; + + STDSBrandingView *brandingView = [self _newConfiguredBrandingView]; + STDSChallengeInformationView *challengeInformationView = [self _newConfiguredChallengeInformationView]; + self.challengeInformationView = challengeInformationView; + UIButton *actionButton = [self _newConfiguredActionButton]; + UIButton *resendButton = [self _newConfiguredResendButton]; + STDSTextChallengeView *textChallengeView = [self _newConfiguredTextChallengeView]; + self.textChallengeView = textChallengeView; + STDSChallengeSelectionView *challengeSelectionView = [self _newConfiguredChallengeSelectionView]; + self.challengeSelectionView = challengeSelectionView; + self.whitelistView = [self _newConfiguredWhitelistView]; + + UIView *expandableContentView = [UIView new]; + expandableContentView.layoutMargins = UIEdgeInsetsMake(kExpandableContentViewTopPadding, kExpandableContentHorizontalInset, 0, kExpandableContentHorizontalInset); + [containerStackView addArrangedSubview:expandableContentView]; + + STDSStackView *expandableContentStackView = [[STDSStackView alloc] initWithAlignment:STDSStackViewLayoutAxisVertical]; + [expandableContentView addSubview:expandableContentStackView]; + [expandableContentStackView _stds_pinToSuperviewBounds]; + + STDSExpandableInformationView *whyInformationView = [self _newConfiguredWhyInformationView]; + STDSExpandableInformationView *expandableInformationView = [self _newConfiguredExpandableInformationView]; + + [contentStackView addArrangedSubview:brandingView]; + [contentStackView addArrangedSubview:challengeInformationView]; + [contentStackView addArrangedSubview:textChallengeView]; + [contentStackView addArrangedSubview:challengeSelectionView]; + + self.buttonStackView = [self _newSubmitButtonStackView]; + + [self.buttonStackView addArrangedSubview:actionButton]; + + [contentStackView addArrangedSubview:self.buttonStackView]; + + if (_response.acsUIType != STDSACSUITypeOOB && _response.acsUIType != STDSACSUITypeMultiSelect && _response.acsUIType != STDSACSUITypeSingleSelect) { + [self.buttonStackView addArrangedSubview:resendButton]; + } + if (!self.whitelistView.isHidden) { + [contentStackView addSpacer:10]; + } + [contentStackView addArrangedSubview:self.whitelistView]; + [expandableContentStackView addArrangedSubview:whyInformationView]; + [expandableContentStackView addArrangedSubview:expandableInformationView]; + + NSLayoutConstraint *contentViewWidth = [NSLayoutConstraint constraintWithItem:containerStackView attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:self.scrollView attribute:NSLayoutAttributeWidth multiplier:1 constant:0]; + NSLayoutConstraint *brandingViewHeightConstraint = [NSLayoutConstraint constraintWithItem:brandingView attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1 constant:kBrandingViewHeight]; + [NSLayoutConstraint activateConstraints:@[brandingViewHeightConstraint, contentViewWidth]]; + + [self _loadBrandingViewImages:brandingView]; +} + +- (void)_setupWebView { + self.webView = [[STDSWebView alloc] init]; + self.webView.navigationDelegate = self; + [self.view addSubview:self.webView]; + [self.webView _stds_pinToSuperviewBounds]; + [self.webView loadExternalResourceBlockingHTMLString:self.response.acsHTML]; +} + +- (void)_loadBrandingViewImages:(STDSBrandingView *)brandingView { + NSURL *issuerImageURL = [self _highestFideltyURLFromChallengeResponseImage:self.response.issuerImage]; + + if (issuerImageURL != nil) { + [self.imageLoader loadImageFromURL:issuerImageURL completion:^(UIImage * _Nullable image) { + brandingView.issuerImage = image; + }]; + } + + NSURL *paymentSystemImageURL = [self _highestFideltyURLFromChallengeResponseImage:self.response.paymentSystemImage]; + + if (paymentSystemImageURL != nil) { + [self.imageLoader loadImageFromURL:paymentSystemImageURL completion:^(UIImage * _Nullable image) { + brandingView.paymentSystemImage = image; + }]; + } +} + +- (NSURL * _Nullable)_highestFideltyURLFromChallengeResponseImage:(id )image { + return image.extraHighDensityURL ?: image.highDensityURL ?: image.mediumDensityURL; +} + +- (void)_displayChallengeResponseAnimated:(BOOL)animated { + if (self.response != nil) { + [self _setLoading:NO]; + + UIScrollView *existingScrollView = self.scrollView; + STDSWebView *existingWebView = self.webView; + + void (^transitionBlock)(UIView *, BOOL) = ^void(UIView *viewToTransition, BOOL animated) { + NSTimeInterval transitionTime = animated ? kDefaultTransitionAnimationDuration : 0; + viewToTransition.alpha = 0; + [UIView animateWithDuration:transitionTime animations:^{ + viewToTransition.alpha = 1; + } completion:^(BOOL finished) { + [existingScrollView removeFromSuperview]; + [existingWebView removeFromSuperview]; + [[NSNotificationCenter defaultCenter] postNotificationName:@"STDSChallengeResponseViewController.didDisplayChallengeResponse" object:self]; + UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification, self.navigationItem.titleView); + }]; + }; + + switch (self.response.acsUIType) { + case STDSACSUITypeNone: + break; + case STDSACSUITypeText: + case STDSACSUITypeSingleSelect: + case STDSACSUITypeMultiSelect: + case STDSACSUITypeOOB: + [self _setupViewHierarchy]; + + transitionBlock(self.scrollView, animated); + break; + case STDSACSUITypeHTML: + [self _setupWebView]; + + transitionBlock(self.webView, animated); + break; + } + } +} + +- (STDSBrandingView *)_newConfiguredBrandingView { + STDSBrandingView *brandingView = [[STDSBrandingView alloc] init]; + brandingView.hidden = self.response.issuerImage == nil && self.response.paymentSystemImage == nil; + + return brandingView; +} + +- (STDSChallengeInformationView *)_newConfiguredChallengeInformationView { + STDSChallengeInformationView *challengeInformationView = [[STDSChallengeInformationView alloc] init]; + challengeInformationView.headerText = self.response.challengeInfoHeader; + challengeInformationView.challengeInformationText = self.response.challengeInfoText; + challengeInformationView.challengeInformationLabel = self.response.challengeInfoLabel; + challengeInformationView.labelCustomization = self.uiCustomization.labelCustomization; + + if (self.response.showChallengeInfoTextIndicator) { + challengeInformationView.textIndicatorImage = [UIImage imageNamed:@"error" inBundle:[STDSBundleLocator stdsResourcesBundle] compatibleWithTraitCollection:nil]; + } + + return challengeInformationView; +} + +- (STDSTextChallengeView *)_newConfiguredTextChallengeView { + STDSTextChallengeView *textChallengeView = [[STDSTextChallengeView alloc] init]; + textChallengeView.hidden = self.response.acsUIType != STDSACSUITypeText; + textChallengeView.textFieldCustomization = self.uiCustomization.textFieldCustomization; + textChallengeView.textField.accessibilityLabel = self.response.challengeInfoLabel; + textChallengeView.backgroundColor = self.uiCustomization.backgroundColor; + + return textChallengeView; +} + +- (STDSChallengeSelectionView *)_newConfiguredChallengeSelectionView { + STDSChallengeSelectionStyle selectionStyle = self.response.acsUIType == STDSACSUITypeMultiSelect ? STDSChallengeSelectionStyleMulti : STDSChallengeSelectionStyleSingle; + STDSChallengeSelectionView *challengeSelectionView = [[STDSChallengeSelectionView alloc] initWithChallengeSelectInfo:self.response.challengeSelectInfo selectionStyle:selectionStyle]; + challengeSelectionView.hidden = self.response.acsUIType != STDSACSUITypeSingleSelect && self.response.acsUIType != STDSACSUITypeMultiSelect; + challengeSelectionView.labelCustomization = self.uiCustomization.labelCustomization; + challengeSelectionView.selectionCustomization = self.uiCustomization.selectionCustomization; + challengeSelectionView.backgroundColor = self.uiCustomization.backgroundColor; + + return challengeSelectionView; +} + +- (UIButton *)_newConfiguredActionButton { + STDSUICustomizationButtonType buttonType = STDSUICustomizationButtonTypeSubmit; + NSString *buttonTitle; + + switch (self.response.acsUIType) { + case STDSACSUITypeNone: + break; + case STDSACSUITypeText: + case STDSACSUITypeSingleSelect: + case STDSACSUITypeMultiSelect: { + buttonTitle = self.response.submitAuthenticationLabel; + + break; + } + case STDSACSUITypeOOB: { + buttonType = STDSUICustomizationButtonTypeContinue; + buttonTitle = self.response.oobContinueLabel; + + break; + } + case STDSACSUITypeHTML: + break; + } + + STDSButtonCustomization *buttonCustomization = [self.uiCustomization buttonCustomizationForButtonType:buttonType]; + UIButton *actionButton = [UIButton _stds_buttonWithTitle:buttonTitle customization:buttonCustomization]; + [actionButton addTarget:self action:@selector(_actionButtonTapped:) forControlEvents:UIControlEventTouchUpInside]; + actionButton.hidden = buttonTitle == nil || [NSString _stds_isStringEmpty:buttonTitle]; + actionButton.accessibilityIdentifier = @"Continue"; + + return actionButton; +} + +- (UIButton *)_newConfiguredResendButton { + STDSButtonCustomization *buttonCustomization = [self.uiCustomization buttonCustomizationForButtonType:STDSUICustomizationButtonTypeResend]; + + NSString *resendButtonTitle = self.response.resendInformationLabel; + UIButton *resendButton = [UIButton _stds_buttonWithTitle:resendButtonTitle customization:buttonCustomization]; + + resendButton.hidden = resendButtonTitle == nil || [NSString _stds_isStringEmpty:resendButtonTitle]; + [resendButton addTarget:self action:@selector(_resendButtonTapped:) forControlEvents:UIControlEventTouchUpInside]; + + return resendButton; +} + +- (STDSWhitelistView *)_newConfiguredWhitelistView { + STDSWhitelistView *whitelistView = [[STDSWhitelistView alloc] init]; + whitelistView.whitelistText = self.response.whitelistingInfoText; + whitelistView.labelCustomization = self.uiCustomization.labelCustomization; + whitelistView.selectionCustomization = self.uiCustomization.selectionCustomization; + whitelistView.hidden = whitelistView.whitelistText == nil; + whitelistView.accessibilityIdentifier = @"STDSWhitelistView"; + + return whitelistView; +} + +- (STDSExpandableInformationView *)_newConfiguredWhyInformationView { + STDSExpandableInformationView *whyInformationView = [[STDSExpandableInformationView alloc] init]; + whyInformationView.title = self.response.whyInfoLabel; + whyInformationView.text = self.response.whyInfoText; + whyInformationView.customization = self.uiCustomization.footerCustomization; + whyInformationView.hidden = whyInformationView.title == nil; + whyInformationView.backgroundColor = self.uiCustomization.footerCustomization.backgroundColor; + __weak typeof(self) weakSelf = self; + whyInformationView.didTap = ^{ + [weakSelf.textChallengeView endEditing:NO]; + }; + + return whyInformationView; +} + +- (STDSExpandableInformationView *)_newConfiguredExpandableInformationView { + + STDSExpandableInformationView *expandableInformationView = [[STDSExpandableInformationView alloc] init]; + expandableInformationView.title = self.response.expandInfoLabel; + expandableInformationView.text = self.response.expandInfoText; + expandableInformationView.customization = self.uiCustomization.footerCustomization; + expandableInformationView.hidden = expandableInformationView.title == nil; + expandableInformationView.backgroundColor = self.uiCustomization.footerCustomization.backgroundColor; + __weak typeof(self) weakSelf = self; + expandableInformationView.didTap = ^{ + [weakSelf.textChallengeView endEditing:NO]; + }; + + return expandableInformationView; +} + +- (UIStackView *)_newSubmitButtonStackView { + UIStackView *stackView = [[UIStackView alloc] init]; + stackView.axis = UILayoutConstraintAxisVertical; + stackView.distribution = UIStackViewDistributionFillEqually; + stackView.alignment = UIStackViewAlignmentFill; + stackView.spacing = 5; + stackView.translatesAutoresizingMaskIntoConstraints = NO; + + CGSize size = [UIScreen mainScreen].bounds.size; + if (size.width > size.height) { + // hack to detect landscape + stackView.axis = UILayoutConstraintAxisHorizontal; + stackView.alignment = UIStackViewAlignmentCenter; + } + return stackView; +} + +- (void)_keyboardDidShow:(NSNotification *)notification { + CGSize keyboardSize = [[[notification userInfo] objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue].size; + UIEdgeInsets contentInsets = UIEdgeInsetsMake(self.scrollView.contentInset.top, 0.0, keyboardSize.height, 0.0); + self.scrollView.contentInset = contentInsets; + self.scrollView.scrollIndicatorInsets = contentInsets; +} + +- (void)_keyboardWillHide:(NSNotification *)notification { + UIEdgeInsets contentInsets = UIEdgeInsetsMake(self.scrollView.contentInset.top, 0.0, 0.0, 0.0); + self.scrollView.contentInset = contentInsets; + self.scrollView.scrollIndicatorInsets = contentInsets; +} + +- (void)_applicationWillEnterForeground:(NSNotification *)notification { + if (self.response.acsUIType == STDSACSUITypeOOB && self.response.challengeAdditionalInfoText) { + // [Req 316] When Challenge Additional Information Text is present, the SDK would replace the Challenge Information Text and Challenge Information Text Indicator with the Challenge Additional Information Text when the 3DS Requestor App is moved to the foreground. + self.challengeInformationView.challengeInformationText = self.response.challengeAdditionalInfoText; + self.challengeInformationView.textIndicatorImage = nil; + } else if (self.response.acsUIType == STDSACSUITypeHTML && self.response.acsHTMLRefresh) { + // [Req 317] When the ACS HTML Refresh element is present, the SDK replaces the ACS HTML with the contents of ACS HTML Refresh when the 3DS Requestor App is moved to the foreground. + [self.webView loadExternalResourceBlockingHTMLString:self.response.acsHTMLRefresh]; + } +} + +- (void)_didTapOutsideKeyboard:(UIGestureRecognizer *)gestureRecognizer { + // Note this doesn't fire if a subview handles the touch (e.g. UIControls, STDSExpandableInformationView) + [self.textChallengeView endEditing:NO]; +} + +#pragma mark - Button callbacks + +- (void)_cancelButtonTapped:(UIButton *)sender { + [self.textChallengeView endEditing:NO]; + [self.delegate challengeResponseViewControllerDidCancel:self]; +} + +- (void)_resendButtonTapped:(UIButton *)sender { + [self.textChallengeView endEditing:NO]; + [self.delegate challengeResponseViewControllerDidRequestResend:self]; +} + +- (void)_actionButtonTapped:(UIButton *)sender { + [self.textChallengeView endEditing:NO]; + switch (self.response.acsUIType) { + case STDSACSUITypeNone: + break; + case STDSACSUITypeText: { + [self.delegate challengeResponseViewController:self + didSubmitInput:self.textChallengeView.inputText + whitelistSelection:self.whitelistView.selectedResponse]; + break; + } + case STDSACSUITypeSingleSelect: + case STDSACSUITypeMultiSelect: { + [self.delegate challengeResponseViewController:self + didSubmitSelection:self.challengeSelectionView.currentlySelectedChallengeInfo + whitelistSelection:self.whitelistView.selectedResponse]; + break; + } + case STDSACSUITypeOOB: + [self.delegate challengeResponseViewControllerDidOOBContinue:self + whitelistSelection:self.whitelistView.selectedResponse]; + break; + case STDSACSUITypeHTML: + // No action button in this case, see WKNavigationDelegate. + break; + } +} + +#pragma mark - WKNavigationDelegate + +- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { + + NSURLRequest *request = navigationAction.request; + + if ([request.URL.absoluteString isEqualToString:kHTMLStringLoadingURL]) { + return decisionHandler(WKNavigationActionPolicyAllow); + } else { + if (navigationAction.navigationType == WKNavigationTypeFormSubmitted || navigationAction.navigationType == WKNavigationTypeOther) { + // When the Cardholder’s response is returned as a parameter string, the form data is passed to the web view instance by triggering a location change to a specified (HTTPS://EMV3DS/challenge) URL with the challenge responses appended to the location URL as query parameters (for example, HTTPS://EMV3DS/challenge?city=Pittsburgh). The web view instance, because it monitors URL changes, receives the Cardholder’s responses as query parameters. + [self.delegate challengeResponseViewController:self didSubmitHTMLForm:request.URL.query]; + } + + return decisionHandler(WKNavigationActionPolicyCancel); + } +} + +- (void) viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coordinator { + if (size.width > size.height) { + // hack to detect landscape + self.buttonStackView.axis = UILayoutConstraintAxisHorizontal; + self.buttonStackView.alignment = UIStackViewAlignmentCenter; + } else { + self.buttonStackView.axis = UILayoutConstraintAxisVertical; + self.buttonStackView.alignment = UIStackViewAlignmentFill; + } +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeSelectionView.h b/Stripe3DS2/Stripe3DS2/STDSChallengeSelectionView.h new file mode 100644 index 00000000..5ac5be0d --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeSelectionView.h @@ -0,0 +1,35 @@ +// +// STDSChallengeSelectionView.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/6/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import "STDSChallengeResponseSelectionInfo.h" +#import "STDSLabelCustomization.h" +#import "STDSSelectionCustomization.h" + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, STDSChallengeSelectionStyle) { + + /// A display style for selecting a single option. + STDSChallengeSelectionStyleSingle = 0, + + /// A display style for selection multiple options. + STDSChallengeSelectionStyleMulti = 1, +}; + +@interface STDSChallengeSelectionView : UIView + +@property (nonatomic, strong, readonly) NSArray> *currentlySelectedChallengeInfo; +@property (nonatomic, strong) STDSLabelCustomization *labelCustomization; +@property (nonatomic, strong) STDSSelectionCustomization *selectionCustomization; + +- (instancetype)initWithChallengeSelectInfo:(NSArray> *)challengeSelectInfo selectionStyle:(STDSChallengeSelectionStyle)selectionStyle; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSChallengeSelectionView.m b/Stripe3DS2/Stripe3DS2/STDSChallengeSelectionView.m new file mode 100644 index 00000000..0b4d15b3 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSChallengeSelectionView.m @@ -0,0 +1,255 @@ +// +// STDSChallengeSelectionView.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/6/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSLocalizedString.h" +#import "STDSBundleLocator.h" +#import "STDSChallengeSelectionView.h" +#import "STDSStackView.h" +#import "UIView+LayoutSupport.h" +#import "STDSSelectionButton.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSChallengeResponseSelectionRow: STDSStackView + +typedef NS_ENUM(NSInteger, STDSChallengeResponseSelectionRowStyle) { + + /// A display style for showing a radio button. + STDSChallengeResponseSelectionRowStyleRadio = 0, + + /// A display style for shows a checkbox. + STDSChallengeResponseSelectionRowStyleCheckbox = 1, +}; + +typedef void (^STDSChallengeResponseRowSelectedBlock)(STDSChallengeResponseSelectionRow *); + +@property (nonatomic, strong, readonly) id challengeSelectInfo; +@property (nonatomic, getter=isSelected) BOOL selected; +@property (nonatomic, strong) STDSLabelCustomization *labelCustomization; +@property (nonatomic, strong) STDSSelectionCustomization *selectionCustomization; + +- (instancetype)initWithChallengeSelectInfo:(id)challengeSelectInfo rowStyle:(STDSChallengeResponseSelectionRowStyle)rowStyle rowSelectedBlock:(STDSChallengeResponseRowSelectedBlock)rowSelectedBlock; + +@end + +@interface STDSChallengeResponseSelectionRow() + +@property (nonatomic, strong) id challengeSelectInfo; +@property (nonatomic, strong) STDSChallengeResponseRowSelectedBlock rowSelectedBlock; +@property (nonatomic) STDSChallengeResponseSelectionRowStyle rowStyle; +@property (nonatomic, strong) STDSSelectionButton *selectionButton; +@property (nonatomic, strong) UILabel *valueLabel; +@property (nonatomic, strong) UITapGestureRecognizer *valueLabelTapRecognizer; + +@end + +@implementation STDSChallengeResponseSelectionRow + +- (instancetype)initWithChallengeSelectInfo:(id)challengeSelectInfo rowStyle:(STDSChallengeResponseSelectionRowStyle)rowStyle rowSelectedBlock:(STDSChallengeResponseRowSelectedBlock)rowSelectedBlock { + self = [super initWithAlignment:STDSStackViewLayoutAxisHorizontal]; + + if (self) { + _challengeSelectInfo = challengeSelectInfo; + _rowStyle = rowStyle; + _rowSelectedBlock = rowSelectedBlock; + self.isAccessibilityElement = YES; + self.accessibilityIdentifier = @"STDSChallengeResponseSelectionRow"; + + [self _setupViewHierarchy]; + } + + return self; +} + +- (void)_setupViewHierarchy { + self.selectionButton = [[STDSSelectionButton alloc] initWithCustomization:self.selectionCustomization]; + self.selectionButton.customization = self.selectionCustomization; + [self.selectionButton addTarget:self action:@selector(_rowWasSelected) forControlEvents:UIControlEventTouchUpInside]; + + if (self.rowStyle == STDSChallengeResponseSelectionRowStyleCheckbox) { + self.selectionButton.isCheckbox = YES; + } + + self.valueLabel = [[UILabel alloc] init]; + self.valueLabel.text = self.challengeSelectInfo.value; + self.valueLabel.userInteractionEnabled = YES; + self.valueLabel.numberOfLines = 0; + self.valueLabelTapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(_rowWasSelected)]; + [self.valueLabel addGestureRecognizer:self.valueLabelTapRecognizer]; + + [self addArrangedSubview:self.selectionButton]; + [self addSpacer:15.0]; + [self addArrangedSubview:self.valueLabel]; + [self addArrangedSubview:[UIView new]]; +} + +- (void)_rowWasSelected { + self.rowSelectedBlock(self); +} + +- (BOOL)isSelected { + /// Placeholder until visual and interaction design is complete. + return self.selectionButton.isSelected; +} + +- (void)setSelected:(BOOL)selected { + /// Placeholder until visual and interaction design is complete. + self.selectionButton.selected = selected; +} + +- (void)setLabelCustomization:(STDSLabelCustomization *)labelCustomization { + _labelCustomization = labelCustomization; + + self.valueLabel.font = labelCustomization.font; + self.valueLabel.textColor = labelCustomization.textColor; +} + +- (void)setSelectionCustomization:(STDSSelectionCustomization *)selectionCustomization { + _selectionCustomization = selectionCustomization; + + self.selectionButton.customization = selectionCustomization; +} + +#pragma mark - UIAccessibility + +- (BOOL)accessibilityActivate { + self.rowSelectedBlock(self); + return YES; +} + +- (nullable NSString *)accessibilityLabel { + return self.valueLabel.text; +} + +- (nullable NSString *)accessibilityValue { + return self.selected ? STDSLocalizedString(@"Selected", @"Indicates that a button is selected.") : STDSLocalizedString(@"Unselected", @"Indicates that a button is not selected."); +} + +- (UIAccessibilityTraits)accessibilityTraits { + // remove the selected trait since we manually add that as an accessibilityValue above + return (self.selectionButton.accessibilityTraits & ~UIAccessibilityTraitSelected); +} + +@end + +@interface STDSChallengeSelectionView() + +@property (nonatomic, strong) STDSStackView *containerView; +@property (nonatomic, strong) NSArray *challengeSelectionRows; + +@property (nonatomic) STDSChallengeSelectionStyle selectionStyle; + +@end + +@implementation STDSChallengeSelectionView + +static const CGFloat kChallengeSelectionViewTopPadding = 5; +static const CGFloat kChallengeSelectionViewBottomPadding = 20; +static const CGFloat kChallengeSelectionViewInterRowVerticalPadding = 16; + +- (instancetype)initWithChallengeSelectInfo:(NSArray> *)challengeSelectInfo selectionStyle:(STDSChallengeSelectionStyle)selectionStyle { + self = [super init]; + + if (self) { + _selectionStyle = selectionStyle; + _challengeSelectionRows = [self _rowsForChallengeSelectInfo:challengeSelectInfo]; + + [self _setupViewHierarchy]; + } + + return self; +} + +- (void)_setupViewHierarchy { + self.containerView = [[STDSStackView alloc] initWithAlignment:STDSStackViewLayoutAxisVertical]; + + for (STDSChallengeResponseSelectionRow *selectionRow in self.challengeSelectionRows) { + [self.containerView addArrangedSubview:selectionRow]; + + if (selectionRow != self.challengeSelectionRows.lastObject) { + [self.containerView addSpacer:kChallengeSelectionViewInterRowVerticalPadding]; + } + } + + if (self.challengeSelectionRows.count > 0) { + self.layoutMargins = UIEdgeInsetsMake(kChallengeSelectionViewTopPadding, 0, kChallengeSelectionViewBottomPadding, 0); + } else { + self.layoutMargins = UIEdgeInsetsZero; + } + + [self addSubview:self.containerView]; + [self.containerView _stds_pinToSuperviewBounds]; +} + +- (NSArray *)_rowsForChallengeSelectInfo:(NSArray> *)challengeSelectInfo { + NSMutableArray *challengeRows = [NSMutableArray array]; + STDSChallengeResponseSelectionRowStyle rowStyle = self.selectionStyle == STDSChallengeSelectionStyleSingle ? STDSChallengeResponseSelectionRowStyleRadio : STDSChallengeResponseSelectionRowStyleCheckbox; + + for (id selectionInfo in challengeSelectInfo) { + __weak typeof(self) weakSelf = self; + STDSChallengeResponseSelectionRow *challengeRow = [[STDSChallengeResponseSelectionRow alloc] initWithChallengeSelectInfo:selectionInfo rowStyle:rowStyle rowSelectedBlock:^(STDSChallengeResponseSelectionRow * _Nonnull selectedRow) { + __strong typeof(self) strongSelf = weakSelf; + + [strongSelf _rowWasSelected:selectedRow]; + }]; + + if (selectionInfo == challengeSelectInfo.firstObject && self.selectionStyle == STDSChallengeSelectionStyleSingle) { + challengeRow.selected = YES; + } + + [challengeRows addObject:challengeRow]; + } + + return [challengeRows copy]; +} + +- (void)_rowWasSelected:(STDSChallengeResponseSelectionRow *)selectedRow { + switch (self.selectionStyle) { + case STDSChallengeSelectionStyleSingle: + for (STDSChallengeResponseSelectionRow *row in self.challengeSelectionRows) { + row.selected = row == selectedRow; + } + + break; + case STDSChallengeSelectionStyleMulti: + selectedRow.selected = !selectedRow.isSelected; + break; + } +} + +- (NSArray> *)currentlySelectedChallengeInfo { + NSMutableArray *selectedChallengeInfo = [NSMutableArray array]; + + for (STDSChallengeResponseSelectionRow *selectionRow in self.challengeSelectionRows) { + if (selectionRow.isSelected) { + [selectedChallengeInfo addObject:selectionRow.challengeSelectInfo]; + } + } + + return [selectedChallengeInfo copy]; +} + +- (void)setLabelCustomization:(STDSLabelCustomization *)labelCustomization { + _labelCustomization = labelCustomization; + + for (STDSChallengeResponseSelectionRow *row in self.challengeSelectionRows) { + row.labelCustomization = labelCustomization; + } +} + +- (void)setSelectionCustomization:(STDSSelectionCustomization *)selectionCustomization { + _selectionCustomization = selectionCustomization; + + for (STDSChallengeResponseSelectionRow *row in self.challengeSelectionRows) { + row.selectionCustomization = selectionCustomization; + } +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSDebuggerChecker.h b/Stripe3DS2/Stripe3DS2/STDSDebuggerChecker.h new file mode 100644 index 00000000..a50689f9 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSDebuggerChecker.h @@ -0,0 +1,19 @@ +// +// STDSDebuggerChecker.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 4/8/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSDebuggerChecker : NSObject + ++ (BOOL)processIsCurrentlyAttachedToDebugger; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSDebuggerChecker.m b/Stripe3DS2/Stripe3DS2/STDSDebuggerChecker.m new file mode 100644 index 00000000..cbe3a059 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSDebuggerChecker.m @@ -0,0 +1,54 @@ +// +// STDSDebuggerChecker.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 4/8/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSDebuggerChecker.h" + +#include +#include +#include +#include +#include + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSDebuggerChecker + +// This checking code has been lifted from the apple documentation on how to determine if you're attached to a debugger: https://developer.apple.com/library/archive/qa/qa1361/_index.html ++ (BOOL)processIsCurrentlyAttachedToDebugger { + int junk; + int mib[4]; + struct kinfo_proc info; + size_t size; + + // Initialize the flags so that, if sysctl fails for some bizarre + // reason, we get a predictable result. + + info.kp_proc.p_flag = 0; + + // Initialize mib, which tells sysctl the info we want, in this case + // we're looking for information about a specific process ID. + + mib[0] = CTL_KERN; + mib[1] = KERN_PROC; + mib[2] = KERN_PROC_PID; + mib[3] = getpid(); + + // Call sysctl. + + size = sizeof(info); + junk = sysctl(mib, sizeof(mib) / sizeof(*mib), &info, &size, NULL, 0); + assert(junk == 0); + + // We're being debugged if the P_TRACED flag is set. + + return ( (info.kp_proc.p_flag & P_TRACED) != 0 ); +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSDeviceInformation.h b/Stripe3DS2/Stripe3DS2/STDSDeviceInformation.h new file mode 100644 index 00000000..d73e1872 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSDeviceInformation.h @@ -0,0 +1,21 @@ +// +// STDSDeviceInformation.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 3/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSDeviceInformation : NSObject + +- (instancetype)initWithDictionary:(NSDictionary *)deviceInformationDict; + +@property (nonatomic, copy, readonly) NSDictionary *dictionaryValue; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSDeviceInformation.m b/Stripe3DS2/Stripe3DS2/STDSDeviceInformation.m new file mode 100644 index 00000000..92425435 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSDeviceInformation.m @@ -0,0 +1,30 @@ +// +// STDSDeviceInformation.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 3/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSDeviceInformation.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSDeviceInformation + +- (instancetype)initWithDictionary:(NSDictionary *)deviceInformationDict { + self = [super init]; + if (self) { + _dictionaryValue = [deviceInformationDict copy]; + } + + return self; +} + +- (NSString *)description { + return [NSString stringWithFormat:@"%@ : %@", [super description], _dictionaryValue]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSDeviceInformationManager.h b/Stripe3DS2/Stripe3DS2/STDSDeviceInformationManager.h new file mode 100644 index 00000000..b23abfed --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSDeviceInformationManager.h @@ -0,0 +1,23 @@ +// +// STDSDeviceInformationManager.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/23/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +@class STDSDeviceInformation; +@class STDSWarning; + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSDeviceInformationManager : NSObject + ++ (STDSDeviceInformation *)deviceInformationWithWarnings:(NSArray *)warnings + ignoringRestrictions:(BOOL)ignoreRestrictions; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSDeviceInformationManager.m b/Stripe3DS2/Stripe3DS2/STDSDeviceInformationManager.m new file mode 100644 index 00000000..5bd65e90 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSDeviceInformationManager.m @@ -0,0 +1,65 @@ +// +// STDSDeviceInformationManager.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/23/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSDeviceInformationManager.h" + +#import "STDSDeviceInformation.h" +#import "STDSDeviceInformationParameter.h" +#import "STDSWarning.h" + +NS_ASSUME_NONNULL_BEGIN + +// TC_SDK_10089_001, Req 2 & 5 +static const NSString * const k3DSDataVersion = @"1.4"; + +static const NSString * const kDataVersionKey = @"DV"; +static const NSString * const kDeviceDataKey = @"DD"; +static const NSString * const kDeviceParameterNotAvailableKey = @"DPNA"; +static const NSString * const kDeviceWarningsKey = @"SW"; + +@implementation STDSDeviceInformationManager + ++ (STDSDeviceInformation *)deviceInformationWithWarnings:(NSArray *)warnings + ignoringRestrictions:(BOOL)ignoreRestrictions { + NSMutableDictionary *deviceInformation = [NSMutableDictionary dictionaryWithObject:k3DSDataVersion forKey:kDataVersionKey]; + + for (STDSDeviceInformationParameter *parameter in [STDSDeviceInformationParameter allParameters]) { + + [parameter collectIgnoringRestrictions:ignoreRestrictions withHandler:^(BOOL collected, NSString * _Nonnull identifier, id _Nonnull value) { + if (collected) { + NSMutableDictionary *deviceData = deviceInformation[kDeviceDataKey]; + if (deviceData == nil) { + deviceData = [NSMutableDictionary dictionary]; + deviceInformation[kDeviceDataKey] = deviceData; + } + deviceData[identifier] = value; + } else { + NSMutableDictionary *notAvailableData = deviceInformation[kDeviceParameterNotAvailableKey]; + if (notAvailableData == nil) { + notAvailableData = [NSMutableDictionary dictionary]; + deviceInformation[kDeviceParameterNotAvailableKey] = notAvailableData; + } + notAvailableData[identifier] = value; + } + }]; + } + + NSMutableArray *warningIDs = [NSMutableArray arrayWithCapacity:warnings.count]; + for (STDSWarning *warning in warnings) { + [warningIDs addObject:warning.identifier]; + } + if (warningIDs.count > 0) { + deviceInformation[kDeviceWarningsKey] = [warningIDs copy]; + } + + return [[STDSDeviceInformation alloc] initWithDictionary:deviceInformation]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSDeviceInformationParameter+Private.h b/Stripe3DS2/Stripe3DS2/STDSDeviceInformationParameter+Private.h new file mode 100644 index 00000000..22d9f1a1 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSDeviceInformationParameter+Private.h @@ -0,0 +1,76 @@ +// +// STDSDeviceInformationParameter+Private.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/23/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSDeviceInformationParameter.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSDeviceInformationParameter (Private) + +- (instancetype)initWithIdentifier:(NSString *)identifier + permissionCheck:(nullable BOOL (^)(void))permissionCheck + valueCheck:(id _Nullable (^)(void))valueCheck; + +/// Platform: Platform that the device is using ++ (instancetype)platform; +/// Device Model: Mobile device manufacturer and model ++ (instancetype)deviceModel; +/// OS Name: Operating system name ++ (instancetype)OSName; +/// OS Version: Operating system version ++ (instancetype)OSVersion; +/// Locale: Device locale set by the user ++ (instancetype)locale; +/// Time zone: Device time zone ++ (instancetype)timeZone; +/// Advertising ID: Unique ID available for adertising and fraud detection purposes ++ (instancetype)advertisingID; +/// Screen Resolution: Pixel width and pixel height ++ (instancetype)screenResolution; +/// Device Name: User-assigned device name ++ (instancetype)deviceName; +/// IP Address: IP address of device ++ (instancetype)IPAddress; +/// Latitude: Device physical location latitude ++ (instancetype)latitude; +/// Longitude: Device physical location longitude ++ (instancetype)longitude; + +/// Identifier for Vendor: Alphanumeric string that uniquely ideitifies a device to the app's vendor ++ (instancetype)identiferForVendor; +/// UserInterfaceIdiom: Style of interface to use on the current device ++ (instancetype)userInterfaceIdiom; + +/// familyNames: an array of font family names available on the system ++ (instancetype)familyNames; +/// fontNamesForFamilyName: an array of font names available in a particular font family using the system font family ++ (instancetype)fontNamesForFamilyName; +/// systemFont: System font ++ (instancetype)systemFont; +/// labelFontSize: standard font size used for labels ++ (instancetype)labelFontSize; +/// buttonFontSize: standard font size used for buttons ++ (instancetype)buttonFontSize; +/// smallSystemFontSize: size of the standard small system font ++ (instancetype)smallSystemFontSize; +/// systemFontSize: size of the standard system font ++ (instancetype)systemFontSize; + +/// systemLocale: the ID of the generic locale that contains fixed "backstop" settings that provide values for otherwise undefined keys ++ (instancetype)systemLocale; +/// availableLocaleIdentifiers: an array of NSString objecgts, each of which identifies a locale available on the system ++ (instancetype)availableLocaleIdentifiers; +/// preferredLanguages: the user's language preference order as an array of strings ++ (instancetype)preferredLanguages; + +/// defaultTimeZone: the default time zone for the current application ++ (instancetype)defaultTimeZone; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSDeviceInformationParameter.h b/Stripe3DS2/Stripe3DS2/STDSDeviceInformationParameter.h new file mode 100644 index 00000000..46a3f66b --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSDeviceInformationParameter.h @@ -0,0 +1,24 @@ +// +// STDSDeviceInformationParameter.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/23/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSDeviceInformationParameter : NSObject + ++ (NSArray *)allParameters; + +/// Returns a UUID unique to the app version ++ (NSString *)sdkAppIdentifier; + +- (void)collectIgnoringRestrictions:(BOOL)ignoreRestrictions withHandler:(void (^)(BOOL, NSString *, id))handler; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSDeviceInformationParameter.m b/Stripe3DS2/Stripe3DS2/STDSDeviceInformationParameter.m new file mode 100644 index 00000000..4633a111 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSDeviceInformationParameter.m @@ -0,0 +1,449 @@ +// +// STDSDeviceInformationParameter.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/23/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSDeviceInformationParameter.h" + +#import +#import + +#import "STDSIPAddress.h" +#import "STDSSynchronousLocationManager.h" + +NS_ASSUME_NONNULL_BEGIN + +// Code value to use if the parameter is restricted by the region or market +static const NSString * const kParameterRestrictedCode = @"RE01"; +// Code value to use if the platform version does not support the parameter or the parameter has been deprecated +static const NSString * const kParameterUnavailableCode = @"RE02"; +// Code value to use if parameter collection not possible without prompting the user for permission +static const NSString * const kParameterMissingPermissionsCode = @"RE03"; +// Code value to use if parameter value returned is null or blank +static const NSString * const kParameterNilCode = @"RE04"; + +@implementation STDSDeviceInformationParameter +{ + NSString *_identifier; + BOOL (^ _Nullable _permissionCheck)(void); + id (^_valueCheck)(void); +} + +- (instancetype)initWithIdentifier:(NSString *)identifier + permissionCheck:(nullable BOOL (^)(void))permissionCheck + valueCheck:(id (^)(void))valueCheck { + self = [super init]; + if (self) { + _identifier = [identifier copy]; + _permissionCheck = [permissionCheck copy]; + _valueCheck = [valueCheck copy]; + } + + return self; +} + +- (BOOL)_hasPermissions { + if (_permissionCheck == nil) { + return YES; + } + return _permissionCheck(); +} + +- (BOOL)_isRestricted { + static NSSet *sApprovedParameters = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sApprovedParameters = [NSSet setWithObjects: + // platform + @"C001", + // device model + @"C002", + // OS name + @"C003", + // OS version + @"C004", + // locale + @"C005", + // time zone + @"C006", + // advertising id (i.e. hardware id) + @"C007", + // screen solution + @"C008", + nil + ]; + }); + + return ![sApprovedParameters containsObject:_identifier]; +} + +- (void)collectIgnoringRestrictions:(BOOL)ignoreRestrictions withHandler:(void (^)(BOOL, NSString *, id))handler { + if (!ignoreRestrictions && [self _isRestricted]) { + handler(NO, _identifier, kParameterRestrictedCode); + return; + } else if (![self _hasPermissions]) { + handler(NO, _identifier, kParameterMissingPermissionsCode); + return; + } + + NSAssert(_valueCheck != nil, @"STDSDeviceInformationParameter should not have nil _valueCheck."); + id value = _valueCheck != nil ? _valueCheck() : nil; + + handler(value != nil, _identifier, value ?: kParameterUnavailableCode); +} + ++ (NSArray *)allParameters { + static NSArray *allParameters = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + allParameters = @[ + +#pragma mark - Common Parameters + + [STDSDeviceInformationParameter platform], + [STDSDeviceInformationParameter deviceModel], + [STDSDeviceInformationParameter OSName], + [STDSDeviceInformationParameter OSVersion], + [STDSDeviceInformationParameter locale], + [STDSDeviceInformationParameter timeZone], + [STDSDeviceInformationParameter advertisingID], + [STDSDeviceInformationParameter screenResolution], + [STDSDeviceInformationParameter deviceName], + [STDSDeviceInformationParameter IPAddress], + [STDSDeviceInformationParameter latitude], + [STDSDeviceInformationParameter longitude], + [STDSDeviceInformationParameter applicationPackageName], + [STDSDeviceInformationParameter sdkAppId], + [STDSDeviceInformationParameter sdkVersion], + + +#pragma mark - iOS-Specific Parameters + + [STDSDeviceInformationParameter identiferForVendor], + [STDSDeviceInformationParameter userInterfaceIdiom], + [STDSDeviceInformationParameter familyNames], + [STDSDeviceInformationParameter fontNamesForFamilyName], + [STDSDeviceInformationParameter systemFont], + [STDSDeviceInformationParameter labelFontSize], + [STDSDeviceInformationParameter buttonFontSize], + [STDSDeviceInformationParameter smallSystemFontSize], + [STDSDeviceInformationParameter systemFontSize], + [STDSDeviceInformationParameter systemLocale], + [STDSDeviceInformationParameter availableLocaleIdentifiers], + [STDSDeviceInformationParameter preferredLanguages], + [STDSDeviceInformationParameter defaultTimeZone], + [STDSDeviceInformationParameter appStoreReciptURL], + ]; + + + }); + + return allParameters; +} + ++ (instancetype)platform { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"C001" + permissionCheck:nil + valueCheck:^id _Nullable{ + return @"iOS"; + }]; +} + ++ (instancetype)deviceModel { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"C002" + permissionCheck:nil + valueCheck:^id _Nullable{ + return [[UIDevice currentDevice] model]; + }]; +} + ++ (instancetype)OSName { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"C003" + permissionCheck:nil + valueCheck:^id _Nullable{ + return [[UIDevice currentDevice] systemName]; + }]; +} + ++ (instancetype)OSVersion { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"C004" + permissionCheck:nil + valueCheck:^id _Nullable{ + return [[UIDevice currentDevice] systemVersion]; + }]; +} + ++ (instancetype)locale { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"C005" + permissionCheck:nil + valueCheck:^id _Nullable{ + NSLocale *locale = [NSLocale currentLocale]; + NSString *language = locale.languageCode; + NSString *country = locale.countryCode; + if (language != nil && country != nil) { + return [@[language, country] componentsJoinedByString:@"-"]; + } else { + return nil; + } + }]; +} + ++ (instancetype)timeZone { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"C006" + permissionCheck:nil + valueCheck:^id _Nullable{ + return [NSTimeZone localTimeZone].name; + }]; +} + ++ (instancetype)advertisingID { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"C007" + permissionCheck:nil + valueCheck:^id _Nullable{ + // Actually collecting advertisingIdentifier would require our users to tell Apple they're using it during app submission. + // advertisingIdentifier returns all zeros when the user has limited ad tracking. + return @"00000000-0000-0000-0000-000000000000"; + }]; +} + ++ (instancetype)screenResolution { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"C008" + permissionCheck:nil + valueCheck:^id _Nullable{ + CGRect boundsInPixels = [UIScreen mainScreen].nativeBounds; + return [NSString stringWithFormat:@"%ldx%ld", (long)boundsInPixels.size.width, (long)boundsInPixels.size.height]; + + }]; +} + ++ (instancetype)deviceName +{ + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"C009" + permissionCheck:nil + valueCheck:^id _Nullable{ + return [UIDevice currentDevice].localizedModel; + }]; +} + ++ (instancetype)IPAddress { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"C010" + permissionCheck:nil + valueCheck:^id _Nullable{ + return STDSCurrentDeviceIPAddress(); + }]; +} + ++ (instancetype)latitude { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"C011" + permissionCheck:^BOOL{ + return [STDSSynchronousLocationManager hasPermissions]; + } + valueCheck:^id _Nullable{ + CLLocation *location = [[STDSSynchronousLocationManager sharedManager] deviceLocation]; + return location != nil ? @(location.coordinate.latitude).stringValue : nil; + }]; +} + ++ (instancetype)longitude { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"C012" + permissionCheck:^BOOL{ + return [STDSSynchronousLocationManager hasPermissions]; + } + valueCheck:^id _Nullable{ + CLLocation *location = [[STDSSynchronousLocationManager sharedManager] deviceLocation]; + return location != nil ? @(location.coordinate.longitude).stringValue : nil; + }]; +} + ++ (instancetype)applicationPackageName { + /* + The unique package name/bundle identifier of the application in which the + 3DS SDK is embedded. + • iOS: obtained from the [NSBundle mainBundle] bundleIdentifier + property. + */ + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"C013" + permissionCheck:nil + valueCheck:^id _Nullable{ + return [[NSBundle mainBundle] bundleIdentifier]; + }]; +} + + ++ (instancetype)sdkAppId { + /* + Universally unique ID that is created for each installation of the 3DS + Requestor App on a Consumer Device. + Note: This should be the same ID that is passed to the Requestor App in + the AuthenticationRequestParameters object (Refer to Section + 4.12.1 in the EMV 3DS SDK Specification). + */ + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"C014" + permissionCheck:nil + valueCheck:^id _Nullable{ + return [STDSDeviceInformationParameter sdkAppIdentifier]; + }]; +} + + ++ (instancetype)sdkVersion { + /* + 3DS SDK version as applied by the implementer and stored securely in the + SDK (refer to Req 58 in the EMV 3DS SDK Specification). + */ + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"C015" + permissionCheck:nil + valueCheck:^id _Nullable{ + return @"2.2.0"; + }]; +} + ++ (instancetype)identiferForVendor { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"I001" + permissionCheck:nil + valueCheck:^id _Nullable{ + // N.B. This can return nil if the device is locked + // We've decided to mark this case and similar as parameter unavailable, + // even though we have permission and the device _can_ provide it when + // it's in a different state + return [UIDevice currentDevice].identifierForVendor.UUIDString; + }]; +} + ++ (instancetype)userInterfaceIdiom { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"I002" + permissionCheck:nil + valueCheck:^id _Nullable{ + return @([UIDevice currentDevice].userInterfaceIdiom).stringValue; + }]; +} + ++ (instancetype)familyNames { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"I003" + permissionCheck:nil + valueCheck:^id _Nullable{ + return UIFont.familyNames; + }]; +} + ++ (instancetype)fontNamesForFamilyName { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"I004" + permissionCheck:nil + valueCheck:^id _Nullable{ + NSArray *fontNames = [UIFont fontNamesForFamilyName:[UIFont systemFontOfSize:[UIFont systemFontSize]].familyName]; + if (fontNames.count == 0) { + return @[@""]; // Workaround for TC_SDK_10176_001 + } + return fontNames; + }]; +} + ++ (instancetype)systemFont { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"I005" + permissionCheck:nil + valueCheck:^id _Nullable{ + return [UIFont systemFontOfSize:[UIFont systemFontSize]].fontName; + }]; +} + ++ (instancetype)labelFontSize { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"I006" + permissionCheck:nil + valueCheck:^id _Nullable{ + return @([UIFont labelFontSize]).stringValue; + }]; +} + ++ (instancetype)buttonFontSize { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"I007" + permissionCheck:nil + valueCheck:^id _Nullable{ + return @([UIFont buttonFontSize]).stringValue; + }]; +} + ++ (instancetype)smallSystemFontSize { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"I008" + permissionCheck:nil + valueCheck:^id _Nullable{ + return @([UIFont smallSystemFontSize]).stringValue; + }]; +} + ++ (instancetype)systemFontSize { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"I009" + permissionCheck:nil + valueCheck:^id _Nullable{ + return @([UIFont systemFontSize]).stringValue; + }]; +} + ++ (instancetype)systemLocale { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"I010" + permissionCheck:nil + valueCheck:^id _Nullable{ + return [NSLocale currentLocale].localeIdentifier; + }]; +} + ++ (instancetype)availableLocaleIdentifiers { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"I011" + permissionCheck:nil + valueCheck:^id _Nullable{ + return [NSLocale availableLocaleIdentifiers]; + }]; +} + ++ (instancetype)preferredLanguages { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"I012" + permissionCheck:nil + valueCheck:^id _Nullable{ + return [NSLocale preferredLanguages]; + }]; +} + ++ (instancetype)defaultTimeZone { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"I013" + permissionCheck:nil + valueCheck:^id _Nullable{ + return [NSTimeZone defaultTimeZone].name; + }]; +} + ++ (instancetype)appStoreReciptURL { + return [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"I014" + permissionCheck:nil + valueCheck:^id _Nullable { + NSString *appStoreReceiptURL = [[NSBundle mainBundle] appStoreReceiptURL].absoluteString; + if (appStoreReceiptURL) { + return appStoreReceiptURL; + } + return kParameterNilCode; + }]; +} + ++ (NSString *)sdkAppIdentifier { + static NSString * const appIdentifierKeyPrefix = @"STDSStripe3DS2AppIdentifierKey"; + NSString *appVersion = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"] ?: @""; + NSString *appIdentifierUserDefaultsKey = [appIdentifierKeyPrefix stringByAppendingString:appVersion]; + NSString *appIdentifier = [[NSUserDefaults standardUserDefaults] stringForKey:appIdentifierUserDefaultsKey]; + if (appIdentifier == nil) { + appIdentifier = [[NSUUID UUID] UUIDString].lowercaseString; + // Clean up any previous app identifiers + NSSet *previousKeys = [[[NSUserDefaults standardUserDefaults] dictionaryRepresentation] keysOfEntriesPassingTest:^BOOL (NSString *key, id obj, BOOL *stop) { + return [key hasPrefix:appIdentifierKeyPrefix] && ![key isEqualToString:appIdentifierUserDefaultsKey]; + }]; + for (NSString *key in previousKeys) { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:key]; + } + } + [[NSUserDefaults standardUserDefaults] setObject:appIdentifier forKey:appIdentifierUserDefaultsKey]; + return appIdentifier; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSDirectoryServer.h b/Stripe3DS2/Stripe3DS2/STDSDirectoryServer.h new file mode 100644 index 00000000..ce903303 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSDirectoryServer.h @@ -0,0 +1,132 @@ +// +// Header.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 3/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import + +NS_ASSUME_NONNULL_BEGIN +typedef NS_ENUM(NSInteger, STDSDirectoryServer) { + STDSDirectoryServerULTestRSA, + STDSDirectoryServerULTestEC, + STDSDirectoryServerSTPTestRSA, + STDSDirectoryServerSTPTestEC, + STDSDirectoryServerAmex, + STDSDirectoryServerCartesBancaires, + STDSDirectoryServerDiscover, + STDSDirectoryServerMastercard, + STDSDirectoryServerVisa, + STDSDirectoryServerCustom, + STDSDirectoryServerUnknown, +}; + +static NSString * const kULTestRSADirectoryServerID = @"F055545342"; +static NSString * const kULTestECDirectoryServerID = @"F155545342"; + +static NSString * const kSTDSTestRSADirectoryServerID = @"ul_test"; +static NSString * const kSTDSTestECDirectoryServerID = @"ec_test"; + +static NSString * const kSTDSAmexDirectoryServerID = @"A000000025"; +static NSString * const kSTDSCartesBancairesServerID = @"A000000042"; +static NSString * const kSTDSDiscoverDirectoryServerID = @"A000000324"; +static NSString * const kSTDSDiscoverDirectoryServerID_2 = @"A000000152"; +static NSString * const kSTDSMastercardDirectoryServerID = @"A000000004"; +static NSString * const kSTDSVisaDirectoryServerID = @"A000000003"; + + +/// Returns the typed directory server enum or STDSDirectoryServerUnknown if the directoryServerID is not recognized +NS_INLINE STDSDirectoryServer STDSDirectoryServerForID(NSString *directoryServerID) { + if ([directoryServerID isEqualToString:kULTestRSADirectoryServerID]) { + return STDSDirectoryServerULTestRSA; + } else if ([directoryServerID isEqualToString:kULTestECDirectoryServerID]) { + return STDSDirectoryServerULTestEC; + } else if ([directoryServerID isEqualToString:kSTDSTestRSADirectoryServerID]) { + return STDSDirectoryServerSTPTestRSA; + } else if ([directoryServerID isEqualToString:kSTDSTestECDirectoryServerID]) { + return STDSDirectoryServerSTPTestEC; + } else if ([directoryServerID isEqualToString:kSTDSAmexDirectoryServerID]) { + return STDSDirectoryServerAmex; + } else if ([directoryServerID isEqualToString:kSTDSDiscoverDirectoryServerID] || [directoryServerID isEqualToString:kSTDSDiscoverDirectoryServerID_2]) { + return STDSDirectoryServerDiscover; + } else if ([directoryServerID isEqualToString:kSTDSMastercardDirectoryServerID]) { + return STDSDirectoryServerMastercard; + } else if ([directoryServerID isEqualToString:kSTDSVisaDirectoryServerID]) { + return STDSDirectoryServerVisa; + } else if ([directoryServerID isEqualToString:kSTDSCartesBancairesServerID]) { + return STDSDirectoryServerCartesBancaires; + } + + return STDSDirectoryServerUnknown; +} + +/// Returns the directory server ID or nil for STDSDirectoryServerUnknown +NS_INLINE NSString * _Nullable STDSDirectoryServerIdentifier(STDSDirectoryServer directoryServer) { + switch (directoryServer) { + case STDSDirectoryServerULTestRSA: + return kULTestRSADirectoryServerID; + + case STDSDirectoryServerULTestEC: + return kULTestECDirectoryServerID; + + case STDSDirectoryServerSTPTestRSA: + return kSTDSTestRSADirectoryServerID; + + case STDSDirectoryServerSTPTestEC: + return kSTDSTestECDirectoryServerID; + + case STDSDirectoryServerAmex: + return kSTDSAmexDirectoryServerID; + + case STDSDirectoryServerDiscover: + return kSTDSDiscoverDirectoryServerID; + + case STDSDirectoryServerMastercard: + return kSTDSMastercardDirectoryServerID; + + case STDSDirectoryServerVisa: + return kSTDSVisaDirectoryServerID; + + case STDSDirectoryServerCartesBancaires: + return kSTDSCartesBancairesServerID; + + case STDSDirectoryServerCustom: + return nil; + + case STDSDirectoryServerUnknown: + return nil; + } +} + +/// Returns the directory server image name if one exists +NS_INLINE NSString * _Nullable STDSDirectoryServerImageName(STDSDirectoryServer directoryServer) { + switch (directoryServer) { + case STDSDirectoryServerAmex: + return @"amex-logo"; + case STDSDirectoryServerDiscover: + return @"discover-logo"; + case STDSDirectoryServerMastercard: + return @"mastercard-logo"; + case STDSDirectoryServerCartesBancaires: + return @"cartes-bancaires-logo"; + // just default to an arbitrary logo for the test servers + case STDSDirectoryServerULTestEC: + case STDSDirectoryServerULTestRSA: + case STDSDirectoryServerSTPTestRSA: + case STDSDirectoryServerSTPTestEC: + case STDSDirectoryServerVisa: + if ([[UITraitCollection currentTraitCollection] userInterfaceStyle] == UIUserInterfaceStyleDark) { + return @"visa-white-logo"; + } + return @"visa-logo"; + case STDSDirectoryServerCustom: + case STDSDirectoryServerUnknown: + return nil; + + } +} + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSDirectoryServerCertificate+Internal.h b/Stripe3DS2/Stripe3DS2/STDSDirectoryServerCertificate+Internal.h new file mode 100644 index 00000000..8f45d98a --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSDirectoryServerCertificate+Internal.h @@ -0,0 +1,22 @@ +// +// STDSDirectoryServerCertificate+Internal.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 4/2/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSDirectoryServerCertificate.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSDirectoryServerCertificate (Internal) + +/// Verifies the certificate chain represented by certificates where each element is a base64 encoded (NOT base64url) certificate ++ (BOOL)_verifyCertificateChain:(NSArray *)certificates withRootCertificates:(NSArray *)rootCertificates; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSDirectoryServerCertificate.h b/Stripe3DS2/Stripe3DS2/STDSDirectoryServerCertificate.h new file mode 100644 index 00000000..4ac91b09 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSDirectoryServerCertificate.h @@ -0,0 +1,43 @@ +// +// STDSDirectoryServerCertificate.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 3/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +@class STDSJSONWebSignature; + +#import "STDSDirectoryServer.h" + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, STDSDirectoryServerKeyType) { + STDSDirectoryServerKeyTypeRSA, + STDSDirectoryServerKeyTypeEC, + STDSDirectoryServerKeyTypeUnknown, +}; + +@interface STDSDirectoryServerCertificate : NSObject + ++ (nullable instancetype)certificateForDirectoryServer:(STDSDirectoryServer)directoryServer; + ++ (nullable instancetype)customCertificateWithData:(NSData *)certificateData; + ++ (nullable instancetype)customCertificateWithString:(NSString *)certificateString; + +@property (nonatomic, readonly) STDSDirectoryServerKeyType keyType; + +@property (nonatomic, readonly) SecKeyRef publicKey; + +@property (nonatomic, readonly, copy) NSString *certificateString; + +- (nullable NSData *)encryptDataUsingRSA_OAEP_SHA256:(NSData *)plaintext; + ++ (BOOL)verifyJSONWebSignature:(STDSJSONWebSignature *)jws withRootCertificates:(NSArray *)rootCertificates; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSDirectoryServerCertificate.m b/Stripe3DS2/Stripe3DS2/STDSDirectoryServerCertificate.m new file mode 100644 index 00000000..b5d180dd --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSDirectoryServerCertificate.m @@ -0,0 +1,326 @@ +// +// STDSDirectoryServerCertificate.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 3/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSDirectoryServerCertificate.h" +#import "STDSDirectoryServerCertificate+Internal.h" + +#import "NSData+JWEHelpers.h" +#import "NSString+JWEHelpers.h" +#import "STDSEllipticCurvePoint.h" +#import "STDSJSONWebSignature.h" +#import "STDSSecTypeUtilities.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSDirectoryServerCertificate () +{ + SecCertificateRef _certificate; + STDSDirectoryServer _directoryServer; +} + +- (instancetype)_initForDirectoryServer:(STDSDirectoryServer)directoryServer; + +@end + +@implementation STDSDirectoryServerCertificate + +- (instancetype)_initWithCertificate:(SecCertificateRef _Nullable)certificate forDirectorySever:(STDSDirectoryServer)directoryServer { + self = [super init]; + if (self) { + _certificate = certificate; + switch (directoryServer) { + + case STDSDirectoryServerULTestRSA: { + /** + UL provides the following, which is PKCS#8, but Security framework wants PKCS#1. Luckily all we have to do is remove the first 32 characters which are just a header to convert + @"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr/O0BfXWngO9OJDBsqdR\n5U2h28jrX6Y+LlblTBaYeT2tW7+ca3YzTFXA8duVUwdlWxl3JZCOOeL1feVP6g0TNOHVCkCnirVDLkcozod4aSkNvx+929aDr1ithqhruf0skBc2sMZGBBCNpso6XGzyAf2uZ2+9DvXoKIUYgcr7PQmL2Y0awyQN7KCRcusaotYNz2mOPrL/hAv6hTexkNrQ\nKzFcPwCuc6kN6aNjD+p2CJ51/5p02SNS70nPOmwmg63j6f3n7xVykQ56kNc1l5B5xOpeHJmqk3+hyF1dF/47rQmMFicN41QSvZ5AZJKgWlIn2VQROMkEHkF9ZBRLx1nF\nTwIDAQAB\n-----END PUBLIC KEY-----\n" + */ + static NSString * const kULTestRSAPublicKey = @"MIIBCgKCAQEAr/O0BfXWngO9OJDBsqdR\n5U2h28jrX6Y+LlblTBaYeT2tW7+ca3YzTFXA8duVUwdlWxl3JZCOOeL1feVP6g0TNOHVCkCnirVDLkcozod4aSkNvx+929aDr1ithqhruf0skBc2sMZGBBCNpso6XGzyAf2uZ2+9DvXoKIUYgcr7PQmL2Y0awyQN7KCRcusaotYNz2mOPrL/hAv6hTexkNrQ\nKzFcPwCuc6kN6aNjD+p2CJ51/5p02SNS70nPOmwmg63j6f3n7xVykQ56kNc1l5B5xOpeHJmqk3+hyF1dF/47rQmMFicN41QSvZ5AZJKgWlIn2VQROMkEHkF9ZBRLx1nF\nTwIDAQAB"; + + NSString *cleanedString = [[[kULTestRSAPublicKey stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]] componentsJoinedByString:@""]; + + NSData *base64Decoded = [[NSData alloc] initWithBase64EncodedString:cleanedString options:0]; + NSDictionary *attributes = @{ + (__bridge NSString *)kSecAttrKeyType: (__bridge NSString *)kSecAttrKeyTypeRSA, + (__bridge NSString *)kSecAttrKeyClass: (__bridge NSString *)kSecAttrKeyClassPublic, + }; + CFErrorRef error = NULL; + SecKeyRef key = SecKeyCreateWithData((__bridge CFDataRef)base64Decoded, (__bridge CFDictionaryRef)attributes, &error); + if (key == NULL) { + return nil; + } + _publicKey = key; + } + break; + + case STDSDirectoryServerULTestEC: { + static NSString * const kULTestECPublicKey = @"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYktbLuAv0v52erE5LPscomKaOmQs\nvevxzOyn9k4sF1hqpBc5kUygzxA9Jl0R/2dTuk8ka7UCujk36xeUsLVpWA=="; + NSString *cleanedString = [[[kULTestECPublicKey stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]] componentsJoinedByString:@""]; + NSData *base64Decoded = [[NSData alloc] initWithBase64EncodedString:cleanedString options:0]; + // This data is PEM encoded, to get to ec standard we take the last 65 bytes + if (base64Decoded.length >= 65) { + base64Decoded = [base64Decoded subdataWithRange:NSMakeRange(base64Decoded.length - 65, 65)]; + } + NSDictionary *attributes = @{ + (__bridge NSString *)kSecAttrKeyType: (__bridge NSString *)kSecAttrKeyTypeECSECPrimeRandom, + (__bridge NSString *)kSecAttrKeyClass: (__bridge NSString *)kSecAttrKeyClassPublic, + }; + CFErrorRef error = NULL; + SecKeyRef key = SecKeyCreateWithData((__bridge CFDataRef)base64Decoded, (__bridge CFDictionaryRef)attributes, &error); + if (key == NULL) { + return nil; + } + _publicKey = key; + } + break; + + case STDSDirectoryServerSTPTestRSA: + // fall-through + case STDSDirectoryServerSTPTestEC: + // fall-through + case STDSDirectoryServerAmex: + // fall-through + case STDSDirectoryServerCartesBancaires: + // fall-through + case STDSDirectoryServerDiscover: + // fall-through + case STDSDirectoryServerMastercard: + // fall-through + case STDSDirectoryServerVisa: + // fall-through + case STDSDirectoryServerCustom: + // fall-through + case STDSDirectoryServerUnknown: + NSAssert(certificate != NULL, @"Must provide a certificate"); + _publicKey = SecCertificateCopyKey(certificate); + } + _directoryServer = directoryServer; + + if (_publicKey == NULL) { + return nil; + } + } + + return self; +} + +- (instancetype)_initForDirectoryServer:(STDSDirectoryServer)directoryServer { + SecCertificateRef certificate = NULL; + + switch (directoryServer) { + case STDSDirectoryServerULTestRSA: + // fall-through + case STDSDirectoryServerULTestEC: + // The UL test servers don't actually have certificates, just hard-coded key values + break; + + case STDSDirectoryServerSTPTestRSA: + // fall-through + case STDSDirectoryServerSTPTestEC: + // fall-through + case STDSDirectoryServerAmex: + // fall-through + case STDSDirectoryServerCartesBancaires: + // fall-through + case STDSDirectoryServerDiscover: + // fall-through; + case STDSDirectoryServerMastercard: + // fall-through + case STDSDirectoryServerVisa: { + certificate = STDSCertificateForServer(directoryServer); + if (certificate == NULL) { + return nil; + } + } + break; + + case STDSDirectoryServerCustom: + return nil; + + case STDSDirectoryServerUnknown: + return nil; + } + return [self _initWithCertificate:certificate forDirectorySever:directoryServer]; +} + ++ (nullable instancetype)certificateForDirectoryServer:(STDSDirectoryServer)directoryServer { + return [[self alloc] _initForDirectoryServer:directoryServer]; +} + ++ (nullable instancetype)customCertificateWithData:(NSData *)certificateData { + SecCertificateRef certificate = STDSSecCertificateFromData(certificateData); + if (certificate == NULL) { + return nil; + } + return [[self alloc] _initWithCertificate:certificate forDirectorySever:STDSDirectoryServerCustom]; +} + ++ (nullable instancetype)customCertificateWithString:(NSString *)certificateString { + SecCertificateRef certificate = STDSSecCertificateFromString(certificateString); + if (certificate == NULL) { + return nil; + } + + return [[self alloc] _initWithCertificate:certificate forDirectorySever:STDSDirectoryServerCustom]; +} + +- (void)dealloc { + if (_certificate != NULL) { + CFRelease(_certificate); + } + if (_publicKey != NULL) { + CFRelease(_publicKey); + } +} + +- (NSString *)certificateString { + NSData *data = (NSData *)CFBridgingRelease(SecCertificateCopyData(_certificate)); + return [data base64EncodedStringWithOptions:0]; +} + +- (STDSDirectoryServerKeyType)keyType { + switch (_directoryServer) { + case STDSDirectoryServerULTestRSA: + return STDSDirectoryServerKeyTypeRSA; + + case STDSDirectoryServerULTestEC: + return STDSDirectoryServerKeyTypeEC; + + + case STDSDirectoryServerSTPTestRSA: + // fall-through + case STDSDirectoryServerSTPTestEC: + // fall-through + case STDSDirectoryServerAmex: + // fall-through + case STDSDirectoryServerCartesBancaires: + // fall-through + case STDSDirectoryServerDiscover: + // fall-through; + case STDSDirectoryServerMastercard: + // fall-through + case STDSDirectoryServerVisa: + // fall-through + case STDSDirectoryServerCustom: { + NSAssert(_certificate != NULL, @"Must have a valid certificate file"); + if (_certificate == NULL) { + return STDSDirectoryServerKeyTypeUnknown; + } + CFStringRef keyType = STDSSecCertificateCopyPublicKeyType(_certificate); + STDSDirectoryServerKeyType ret = STDSDirectoryServerKeyTypeUnknown; + if (keyType != NULL) { + if (CFStringCompare(keyType, kSecAttrKeyTypeRSA, 0) == kCFCompareEqualTo) { + ret = STDSDirectoryServerKeyTypeRSA; + } else if (CFStringCompare(keyType, kSecAttrKeyTypeECSECPrimeRandom, 0) == kCFCompareEqualTo) { + ret = STDSDirectoryServerKeyTypeEC; + } + + CFRelease(keyType); + } + return ret; + } + + case STDSDirectoryServerUnknown: + NSAssert(0, @"Should not have an STDSDirectoryServerCertificate instance withSTPDirectoryServerUnknown"); + return STDSDirectoryServerKeyTypeUnknown; + } +} + +- (nullable NSData *)encryptDataUsingRSA_OAEP_SHA256:(NSData *)plaintext { + NSAssert(_publicKey != NULL, @"STDSDirectoryServerCertificate should always have _publicKey"); + if (_publicKey == NULL) { + return nil; + } + + CFDataRef encryptedData = SecKeyCreateEncryptedData(_publicKey, + kSecKeyAlgorithmRSAEncryptionOAEPSHA256, + (CFDataRef)plaintext, + NULL); + return (NSData *)CFBridgingRelease(encryptedData); +} + ++ (BOOL)_verifyCertificateChain:(NSArray *)certificatesStrings withRootCertificates:(NSArray *)rootCertificateStrings { + if (certificatesStrings.count == 0 || rootCertificateStrings.count == 0) { + return NO; + } + + NSMutableArray *certificates = [[NSMutableArray alloc] initWithCapacity:certificatesStrings.count]; + for (NSString *certificateString in certificatesStrings) { + SecCertificateRef certificate = STDSSecCertificateFromString(certificateString); + if (certificate == NULL) { + return NO; + } + [certificates addObject:(id)CFBridgingRelease(certificate)]; + } + + NSMutableArray *rootCertificates = [[NSMutableArray alloc] initWithCapacity:rootCertificateStrings.count]; + for (NSString *certificateString in rootCertificateStrings) { + SecCertificateRef certificate = STDSSecCertificateFromString(certificateString); + if (certificate == NULL) { + return NO; + } + [rootCertificates addObject:(id)CFBridgingRelease(certificate)]; + } + + SecPolicyRef policy = SecPolicyCreateBasicX509(); + SecTrustRef trust; + OSStatus status = SecTrustCreateWithCertificates((__bridge CFTypeRef)certificates, + policy, + &trust); + if (policy) { + CFRelease(policy); + } + if (status != errSecSuccess) { + return NO; + } + if (rootCertificates.count > 0) { + status = SecTrustSetAnchorCertificates(trust, (__bridge CFTypeRef)rootCertificates); + if (status != errSecSuccess) { + return NO; + } + } + + CFErrorRef error = NULL; + + bool verified = SecTrustEvaluateWithError(trust, &error); + return (BOOL)verified; +} + ++ (BOOL)verifyJSONWebSignature:(STDSJSONWebSignature *)jws withRootCertificates:(NSArray *)rootCertificates { + if (jws.certificateChain.count == 0 || ![self _verifyCertificateChain:jws.certificateChain withRootCertificates:rootCertificates]) { + return NO; + } + + switch (jws.algorithm) { + case STDSJSONWebSignatureAlgorithmES256: + return STDSVerifyEllipticCurveP256Signature(jws.ellipticCurvePoint.x, jws.ellipticCurvePoint.y, jws.digest, jws.signature); + + case STDSJSONWebSignatureAlgorithmPS256: { + if (jws.certificateChain.count == 0) { + return NO; + } + NSString *certificateString = [jws.certificateChain firstObject]; + SecCertificateRef certificate = STDSSecCertificateFromString(certificateString); + if (certificate == NULL) { + return NO; + } + + BOOL verified = STDSVerifyRSASignature(certificate, jws.digest, jws.signature); + CFRelease(certificate); + return verified; + } + + + case STDSJSONWebSignatureAlgorithmUnknown: + return NO; + } +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSEllipticCurvePoint.h b/Stripe3DS2/Stripe3DS2/STDSEllipticCurvePoint.h new file mode 100644 index 00000000..c856951d --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSEllipticCurvePoint.h @@ -0,0 +1,26 @@ +// +// STDSEllipticCurvePoint.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 3/20/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSEllipticCurvePoint : NSObject + +- (nullable instancetype)initWithX:(NSData *)x y:(NSData *)y; +- (nullable instancetype)initWithCertificateData:(NSData *)certificateData; +- (nullable instancetype)initWithKey:(SecKeyRef)key; +- (nullable instancetype)initWithJWK:(NSDictionary *)jwk; + +@property (nonatomic, readonly) NSData *x; +@property (nonatomic, readonly) NSData *y; + +@property (nonatomic, readonly) SecKeyRef publicKey; + +@end +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSEllipticCurvePoint.m b/Stripe3DS2/Stripe3DS2/STDSEllipticCurvePoint.m new file mode 100644 index 00000000..49fdf3e5 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSEllipticCurvePoint.m @@ -0,0 +1,90 @@ +// +// STDSEllipticCurvePoint.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 3/20/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSEllipticCurvePoint.h" + +#import "NSDictionary+DecodingHelpers.h" +#import "NSString+JWEHelpers.h" +#import "STDSSecTypeUtilities.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSEllipticCurvePoint + +- (nullable instancetype)initWithX:(NSData *)x y:(NSData *)y { + self = [super init]; + if (self) { + _x = x; + _y = y; + _publicKey = STDSSecKeyRefFromCoordinates(x, y); + if (_publicKey == NULL) { + return nil; + } + } + + return self; +} + +- (nullable instancetype)initWithKey:(SecKeyRef)key { + self = [super init]; + if (self) { + _publicKey = key; + CFErrorRef error = NULL; + NSData *keyData = (NSData *)CFBridgingRelease(SecKeyCopyExternalRepresentation(key, &error)); + if (keyData == nil) { + return nil; + } + + NSUInteger coordinateLength = (keyData.length - 1) / 2; // -1 because the first byte is formatting 0x04 + NSData *xData = [keyData subdataWithRange:NSMakeRange(1, coordinateLength)]; + NSData *yData = [keyData subdataWithRange:NSMakeRange(1 + coordinateLength, coordinateLength)]; + _x = xData; + _y = yData; + } + + return self; +} + +- (nullable instancetype)initWithCertificateData:(NSData *)certificateData { + SecCertificateRef certificate = STDSSecCertificateFromData(certificateData); + if (certificateData != NULL) { + SecKeyRef key = SecCertificateCopyKey(certificate); + CFRelease(certificate); + if (key != NULL) { + STDSEllipticCurvePoint *point = [self initWithKey:key]; + CFRelease(key); + return point; + } + } + return nil; +} + +- (nullable instancetype)initWithJWK:(NSDictionary *)jwk { + NSString *kty = [jwk _stds_stringForKey:@"kty" validator:^BOOL(NSString * _Nonnull val) { + return [val isEqualToString:@"EC"]; + } required:YES error:NULL]; + NSString *crv = [jwk _stds_stringForKey:@"crv" validator:^BOOL(NSString * _Nonnull val) { + return [val isEqualToString:@"P-256"]; + } required:YES error:NULL]; + + NSData *coordinateX = [[jwk _stds_stringForKey:@"x" required:YES error:NULL] _stds_base64URLDecodedData]; + NSData *coordinateY = [[jwk _stds_stringForKey:@"y" required:YES error:NULL] _stds_base64URLDecodedData]; + + if (kty == nil || + crv == nil || + coordinateX == nil || + coordinateY == nil + ) { + return nil; + } + return [self initWithX:coordinateX y:coordinateY]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSEphemeralKeyPair+Testing.h b/Stripe3DS2/Stripe3DS2/STDSEphemeralKeyPair+Testing.h new file mode 100644 index 00000000..d8efb11d --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSEphemeralKeyPair+Testing.h @@ -0,0 +1,19 @@ +// +// STDSEphemeralKeyPair+Testing.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 4/18/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSEphemeralKeyPair.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSEphemeralKeyPair (Testing) + ++ (nullable instancetype)testKeyPair; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSEphemeralKeyPair.h b/Stripe3DS2/Stripe3DS2/STDSEphemeralKeyPair.h new file mode 100644 index 00000000..515fbb58 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSEphemeralKeyPair.h @@ -0,0 +1,39 @@ +// +// STDSEphemeralKeyPair.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 3/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +@class STDSDirectoryServerCertificate; +@class STDSEllipticCurvePoint; + +#import "STDSDirectoryServer.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSEphemeralKeyPair : NSObject + +/// Creates a returns a new elliptic curve key pair using curve P-256 ++ (nullable instancetype)ephemeralKeyPair; + +- (instancetype)init NS_UNAVAILABLE; + +@property (nonatomic, readonly) NSString *publicKeyJWK; +@property (nonatomic, readonly) STDSEllipticCurvePoint *publicKeyCurvePoint; + +/** + Creates and returns a new secret key derived using Elliptic Curve Diffie-Hellman + and the certificate's public key (return nil on failure). + Per OpenSSL documentation: Never use a derived secret directly. Typically it is passed through some + hash function to produce a key (e.g. pass the secret as the first argument to STDSCreateConcatKDFWithSHA256) + */ +- (nullable NSData *)createSharedSecretWithEllipticCurveKey:(STDSEllipticCurvePoint *)ecKey; +- (nullable NSData *)createSharedSecretWithCertificate:(STDSDirectoryServerCertificate *)certificate; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSEphemeralKeyPair.m b/Stripe3DS2/Stripe3DS2/STDSEphemeralKeyPair.m new file mode 100644 index 00000000..426ebc5a --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSEphemeralKeyPair.m @@ -0,0 +1,108 @@ +// +// STDSEphemeralKeyPair.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 3/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSEphemeralKeyPair.h" + +#import "NSData+JWEHelpers.h" +#import "NSDictionary+DecodingHelpers.h" +#import "NSString+JWEHelpers.h" +#import "STDSDirectoryServerCertificate.h" +#import "STDSEllipticCurvePoint.h" +#import "STDSSecTypeUtilities.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSEphemeralKeyPair () +{ + SecKeyRef _privateKey; + SecKeyRef _publicKey; +} + +@end + + +@implementation STDSEphemeralKeyPair + +- (instancetype)_initWithPrivateKey:(SecKeyRef)privateKey publicKey:(SecKeyRef)publicKey { + self = [super init]; + if (self) { + _privateKey = privateKey; + _publicKey = publicKey; + } + + return self; +} + ++ (nullable instancetype)ephemeralKeyPair { + NSDictionary *parameters = @{ + (__bridge NSString *)kSecAttrKeyType: (__bridge NSString *)kSecAttrKeyTypeECSECPrimeRandom, + (__bridge NSString *)kSecAttrKeySizeInBits: @(256), + }; + CFErrorRef error = NULL; + SecKeyRef privateKey = SecKeyCreateRandomKey((__bridge CFDictionaryRef)parameters, &error); + + if (privateKey != NULL) { + SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey); + return [[self alloc] _initWithPrivateKey:privateKey publicKey:publicKey]; + } + + return nil; +} + ++ (nullable instancetype)testKeyPair { + + // values from EMVCo_3DS_-AppBased_CryptoExamples_082018.pdf + NSData *d = [@"iyn--IbkBeNoPu8cN245L6pOQWt2lTH8V0Ds92jQmWA" _stds_base64URLDecodedData]; + NSData *x = [@"C1PL42i6kmNkM61aupEAgLJ4gF1ZRzcV7lqo1TG0mL4" _stds_base64URLDecodedData]; + NSData *y = [@"cNToWLSdcFQKG--PGVEUQrIHP8w6TcRyj0pyFx4-ZMc" _stds_base64URLDecodedData]; + + SecKeyRef privateKey = STDSPrivateSecKeyRefFromCoordinates(x, y, d); + if (privateKey == NULL) { + return nil; + } + SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey); + if (publicKey == NULL) { + return nil; + } + + return [[STDSEphemeralKeyPair alloc] _initWithPrivateKey:privateKey publicKey:publicKey]; +} + +- (void)dealloc { + if (_privateKey != NULL) { + CFRelease(_privateKey); + } + if (_publicKey != NULL) { + CFRelease(_publicKey); + } +} + +- (NSString *)publicKeyJWK { + STDSEllipticCurvePoint *publicKeyCurvePoint = [[STDSEllipticCurvePoint alloc] initWithKey:_publicKey]; + return [NSString stringWithFormat:@"{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"%@\",\"y\":\"%@\"}", [publicKeyCurvePoint.x _stds_base64URLEncodedString], [publicKeyCurvePoint.y _stds_base64URLEncodedString]]; +} + +- (nullable NSData *)createSharedSecretWithEllipticCurveKey:(STDSEllipticCurvePoint *)ecKey { + return [self _createSharedSecretWithPrivateKey:_privateKey publicKey:ecKey.publicKey]; +} + +- (nullable NSData *)createSharedSecretWithCertificate:(STDSDirectoryServerCertificate *)certificate { + return [self _createSharedSecretWithPrivateKey:_privateKey publicKey:certificate.publicKey]; +} + +- (nullable NSData *)_createSharedSecretWithPrivateKey:(SecKeyRef)privateKey publicKey:(SecKeyRef)publicKey { + NSDictionary *params = @{(__bridge NSString *)kSecKeyKeyExchangeParameterRequestedSize: @(32)}; + CFErrorRef error = NULL; + CFDataRef secret = SecKeyCopyKeyExchangeResult(privateKey, kSecKeyAlgorithmECDHKeyExchangeStandard, publicKey, (__bridge CFDictionaryRef)params, &error); + + return (NSData *)CFBridgingRelease(secret); +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSErrorMessage+Internal.h b/Stripe3DS2/Stripe3DS2/STDSErrorMessage+Internal.h new file mode 100644 index 00000000..3a0730c3 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSErrorMessage+Internal.h @@ -0,0 +1,40 @@ +// +// STDSErrorMessage+Internal.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 4/9/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import "STDSErrorMessage.h" + +NS_ASSUME_NONNULL_BEGIN + +// Constructors for the circumstances in which we are required to send an ErrorMessage to the ACS +@interface STDSErrorMessage (Internal) + +/// Received an invalid message type ++ (instancetype)errorForInvalidMessageWithACSTransactionID:(NSString *)acsTransactionID + messageVersion:(NSString *)messageVersion; + +/// Encountered an invalid field parsing a JSON response ++ (nullable instancetype)errorForJSONFieldInvalidWithACSTransactionID:(NSString *)acsTransactionID messageVersion:(NSString *)messageVersion error:(NSError *)error; + +/// Encountered a missing field parsing a JSON response ++ (nullable instancetype)errorForJSONFieldMissingWithACSTransactionID:(NSString *)acsTransactionID messageVersion:(NSString *)messageVersion error:(NSError *)error; + +/// Encountered an error decrypting a networking response ++ (instancetype)errorForDecryptionErrorWithACSTransactionID:(NSString *)acsTransactionID messageVersion:(NSString *)messageVersion; + +/// Timed out ++ (instancetype)errorForTimeoutWithACSTransactionID:(NSString *)acsTransactionID messageVersion:(NSString *)messageVersion; + ++ (instancetype)errorForUnrecognizedIDWithACSTransactionID:(NSString *)transactionID messageVersion:(NSString *)messageVersion; + +/// Encountered unrecognized critical message extension(s) ++ (instancetype)errorForUnrecognizedCriticalMessageExtensionsWithACSTransactionID:(NSString *)acsTransactionID messageVersion:(NSString *)messageVersion error:(NSError *)error; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSErrorMessage+Internal.m b/Stripe3DS2/Stripe3DS2/STDSErrorMessage+Internal.m new file mode 100644 index 00000000..02eed1d0 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSErrorMessage+Internal.m @@ -0,0 +1,91 @@ +// +// STDSErrorMessage+Internal.m +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 4/9/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSErrorMessage+Internal.h" +#import "STDSStripe3DS2Error.h" + +@implementation STDSErrorMessage (Internal) + ++ (NSString *)_stringForErrorCode:(STDSErrorMessageCode)errorCode { + return [NSString stringWithFormat:@"%ld", (long)errorCode]; +} + ++ (instancetype)errorForInvalidMessageWithACSTransactionID:(nonnull NSString *)acsTransactionID messageVersion:(nonnull NSString *)messageVersion { + return [[[self class] alloc] initWithErrorCode:[self _stringForErrorCode:STDSErrorMessageCodeInvalidMessage] + errorComponent:@"C" + errorDescription:@"Message not recognized" + errorDetails:@"Unknown message type" + messageVersion:messageVersion + acsTransactionIdentifier:acsTransactionID + errorMessageType:@"CRes"]; + +} + ++ (nullable instancetype)errorForJSONFieldMissingWithACSTransactionID:(NSString *)acsTransactionID messageVersion:(NSString *)messageVersion error:(NSError *)error { + return [[[self class] alloc] initWithErrorCode:[self _stringForErrorCode:STDSErrorMessageCodeRequiredDataElementMissing] + errorComponent:@"C" + errorDescription:@"Missing Field" + errorDetails:error.userInfo[STDSStripe3DS2ErrorFieldKey] + messageVersion:messageVersion + acsTransactionIdentifier:acsTransactionID + errorMessageType:@"CRes"]; +} + ++ (nullable instancetype)errorForJSONFieldInvalidWithACSTransactionID:(NSString *)acsTransactionID messageVersion:(NSString *)messageVersion error:(NSError *)error { + return [[[self class] alloc] initWithErrorCode:[self _stringForErrorCode:STDSErrorMessageErrorInvalidDataElement] + errorComponent:@"C" + errorDescription:@"Invalid Field" + errorDetails:error.userInfo[STDSStripe3DS2ErrorFieldKey] + messageVersion:messageVersion + acsTransactionIdentifier:acsTransactionID + errorMessageType:@"CRes"]; +} + ++ (instancetype)errorForDecryptionErrorWithACSTransactionID:(NSString *)acsTransactionID messageVersion:(NSString *)messageVersion { + return [[[self class] alloc] initWithErrorCode:[self _stringForErrorCode:STDSErrorMessageErrorDataDecryptionFailure] + errorComponent:@"C" + errorDescription:@"Response could not be decrypted." + errorDetails:@"Response could not be decrypted.s" + messageVersion:messageVersion + acsTransactionIdentifier:acsTransactionID + errorMessageType:@"CRes"]; +} + ++ (instancetype)errorForTimeoutWithACSTransactionID:(NSString *)acsTransactionID messageVersion:(NSString *)messageVersion { + return [[[self class] alloc] initWithErrorCode:[self _stringForErrorCode:STDSErrorMessageErrorTimeout] + errorComponent:@"C" + errorDescription:@"Transaction timed out." + errorDetails:@"Transaction timed out." + messageVersion:messageVersion + acsTransactionIdentifier:acsTransactionID + errorMessageType:@"CRes"]; +} + ++ (instancetype)errorForUnrecognizedIDWithACSTransactionID:(NSString *)acsTransactionID messageVersion:(NSString *)messageVersion { + return [[[self class] alloc] initWithErrorCode:[self _stringForErrorCode:STDSErrorMessageErrorTransactionIDNotRecognized] + errorComponent:@"C" + errorDescription:@"Unrecognized transaction ID" + errorDetails:@"Unrecognized transaction ID" + messageVersion:messageVersion + acsTransactionIdentifier:acsTransactionID + errorMessageType:@"CRes"]; +} + ++ (instancetype)errorForUnrecognizedCriticalMessageExtensionsWithACSTransactionID:(NSString *)acsTransactionID messageVersion:(NSString *)messageVersion error:(NSError *)error { + NSArray *unrecognizedIDs = error.userInfo[STDSStripe3DS2UnrecognizedCriticalMessageExtensionsKey]; + + return [[[self class] alloc] initWithErrorCode:[self _stringForErrorCode:STDSErrorMessageCodeUnrecognizedCriticalMessageExtension] + errorComponent:@"C" + errorDescription:@"Critical message extension not recognised." + errorDetails:[unrecognizedIDs componentsJoinedByString:@","] + messageVersion:messageVersion + acsTransactionIdentifier:acsTransactionID + errorMessageType:@"CRes"]; +} + +@end diff --git a/Stripe3DS2/Stripe3DS2/STDSException+Internal.h b/Stripe3DS2/Stripe3DS2/STDSException+Internal.h new file mode 100644 index 00000000..3429aba9 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSException+Internal.h @@ -0,0 +1,19 @@ +// +// STDSException+Internal.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/22/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSException.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSException (Internal) + ++ (instancetype)exceptionWithMessage:(NSString *)format, ...; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSExpandableInformationView.h b/Stripe3DS2/Stripe3DS2/STDSExpandableInformationView.h new file mode 100644 index 00000000..f6f3ee0e --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSExpandableInformationView.h @@ -0,0 +1,23 @@ +// +// STDSExpandableInformationView.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/11/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import "STDSFooterCustomization.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSExpandableInformationView : UIView + +@property (nonatomic, strong, nullable) NSString *title; +@property (nonatomic, strong, nullable) NSString *text; +@property (nonatomic, strong, nullable) STDSFooterCustomization *customization; +@property (nonatomic, strong, nullable) void (^didTap)(void); + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSExpandableInformationView.m b/Stripe3DS2/Stripe3DS2/STDSExpandableInformationView.m new file mode 100644 index 00000000..9b1bbdfb --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSExpandableInformationView.m @@ -0,0 +1,150 @@ +// +// STDSExpandableInformationView.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/11/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSLocalizedString.h" +#import "STDSBundleLocator.h" +#import "STDSExpandableInformationView.h" +#import "STDSStackView.h" +#import "UIView+LayoutSupport.h" +#import "NSString+EmptyChecking.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSExpandableInformationView() + +@property (nonatomic, strong) UIView *tappableView; +@property (nonatomic, strong) STDSStackView *textContainerView; +@property (nonatomic, strong) STDSStackView *imageViewStackView; +@property (nonatomic, strong) UIView *imageViewSpacerView; +@property (nonatomic, strong) UILabel *titleLabel; +@property (nonatomic, strong) UILabel *textLabel; +@property (nonatomic, strong) UIImageView *titleImageView; + +@end + +@implementation STDSExpandableInformationView + +static const CGFloat kTitleContainerSpacing = 20; +static const CGFloat kTextContainerSpacing = 13; +static const CGFloat kExpandableInformationViewBottomMargin = 30; +static const CGFloat kTitleImageViewRotationAnimationDuration = (CGFloat)0.2; + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + + if (self) { + [self _setupViewHierarchy]; + self.accessibilityIdentifier = @"STDSExpandableInformationView"; + } + + return self; +} + +- (void)_setupViewHierarchy { + self.layoutMargins = UIEdgeInsetsMake(0, 0, kExpandableInformationViewBottomMargin, 0); + + self.titleLabel = [[UILabel alloc] init]; + // Set titleLabel as not an accessibility element because we make the + // container, which is the actual control, have the same accessibility label + // and accurately reflects that interactivity and state of the control + self.titleLabel.isAccessibilityElement = NO; + self.titleLabel.numberOfLines = 0; + + self.textLabel = [[UILabel alloc] init]; + self.textLabel.numberOfLines = 0; + + UIImage *chevronImage = [[UIImage imageNamed:@"Chevron" inBundle:[STDSBundleLocator stdsResourcesBundle] compatibleWithTraitCollection:nil] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + self.titleImageView = [[UIImageView alloc] initWithImage:chevronImage]; + self.titleImageView.contentMode = UIViewContentModeScaleAspectFit; + + STDSStackView *containerView = [[STDSStackView alloc] initWithAlignment:STDSStackViewLayoutAxisHorizontal]; + [self addSubview:containerView]; + [containerView _stds_pinToSuperviewBounds]; + + STDSStackView *titleContainerView = [[STDSStackView alloc] initWithAlignment:STDSStackViewLayoutAxisVertical]; + [titleContainerView addArrangedSubview:self.titleLabel]; + + self.imageViewStackView = [[STDSStackView alloc] initWithAlignment:STDSStackViewLayoutAxisVertical]; + self.imageViewSpacerView = [UIView new]; + [self.imageViewStackView addArrangedSubview:self.titleImageView]; + + [containerView addArrangedSubview:self.imageViewStackView]; + [containerView addSpacer:kTitleContainerSpacing]; + [containerView addArrangedSubview:titleContainerView]; + [containerView addArrangedSubview:[UIView new]]; + + self.textContainerView = [[STDSStackView alloc] initWithAlignment:STDSStackViewLayoutAxisVertical]; + self.textContainerView.hidden = YES; + [self.textContainerView addSpacer:kTextContainerSpacing]; + [self.textContainerView addArrangedSubview:self.textLabel]; + [titleContainerView addArrangedSubview:self.textContainerView]; + + UITapGestureRecognizer *expandTextTapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(_toggleTextExpansion)]; + [containerView addGestureRecognizer:expandTextTapRecognizer]; + containerView.accessibilityTraits |= UIAccessibilityTraitButton; + containerView.isAccessibilityElement = YES; + self.tappableView = containerView; + [self _updateTappableViewAccessibilityValue]; +} + +- (void)setTitle:(NSString * _Nullable)title { + _title = title; + + self.titleLabel.text = title; + self.tappableView.accessibilityLabel = title; +} + +- (void)setText:(NSString * _Nullable)text { + _text = text; + + self.textLabel.text = text; +} + +- (void)_updateTappableViewAccessibilityValue { + if (self.textContainerView.isHidden) { + self.tappableView.accessibilityValue = STDSLocalizedString(@"Collapsed", @"Accessibility label for expandandable text control to indicate text is hidden."); + } else { + self.tappableView.accessibilityValue = STDSLocalizedString(@"Expanded", @"Accessibility label for expandandable text control to indicate that the UI has been expanded and additional text is available."); + } +} + +- (void)_toggleTextExpansion { + if (self.didTap) { + self.didTap(); + } + self.textContainerView.hidden = !self.textContainerView.hidden; + + CGFloat rotationValue = (CGFloat)M_PI_2; + if (self.textContainerView.isHidden) { + rotationValue = (CGFloat)0; + [self.imageViewStackView removeArrangedSubview:self.imageViewSpacerView]; + UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, self.titleLabel); + } else { + [self.imageViewStackView addArrangedSubview:self.imageViewSpacerView]; + UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, self.textLabel); + } + [self _updateTappableViewAccessibilityValue]; + + [UIView animateWithDuration:kTitleImageViewRotationAnimationDuration animations:^{ + self.titleImageView.transform = CGAffineTransformMakeRotation(rotationValue); + }]; +} + +- (void)setCustomization:(STDSFooterCustomization * _Nullable)customization { + self.titleLabel.font = customization.headingFont; + self.titleLabel.textColor = customization.headingTextColor; + + self.textLabel.font = customization.font; + self.textLabel.textColor = customization.textColor; + + self.titleImageView.tintColor = customization.chevronColor; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSIPAddress.h b/Stripe3DS2/Stripe3DS2/STDSIPAddress.h new file mode 100644 index 00000000..a6d5911f --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSIPAddress.h @@ -0,0 +1,15 @@ +// +// STDSIPAddress.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/23/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +NSString * _Nullable STDSCurrentDeviceIPAddress(void); + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSIPAddress.m b/Stripe3DS2/Stripe3DS2/STDSIPAddress.m new file mode 100644 index 00000000..863eddb8 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSIPAddress.m @@ -0,0 +1,43 @@ +// +// STDSIPAddress.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/23/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSIPAddress.h" + +#include +#include + +// source: https://zachwaugh.com/posts/programmatically-retrieving-ip-address-of-iphone +NSString * STDSCurrentDeviceIPAddress(void) { + NSString *address = nil; + struct ifaddrs *interfaces = NULL; + struct ifaddrs *temp_addr = NULL; + int success = 0; + + // retrieve the current interfaces - returns 0 on success + success = getifaddrs(&interfaces); + if (success == 0) { + // Loop through linked list of interfaces + temp_addr = interfaces; + while (temp_addr != NULL) { + if( temp_addr->ifa_addr->sa_family == AF_INET) { + // Check if interface is en0 which is the wifi connection on the iPhone + if ([[NSString stringWithUTF8String:temp_addr->ifa_name] isEqualToString:@"en0"]) { + // Get NSString from C String + address = [NSString stringWithUTF8String:inet_ntoa(((struct sockaddr_in *)temp_addr->ifa_addr)->sin_addr)]; + } + } + + temp_addr = temp_addr->ifa_next; + } + } + + // Free memory + freeifaddrs(interfaces); + + return address; +} diff --git a/Stripe3DS2/Stripe3DS2/STDSImageLoader.h b/Stripe3DS2/Stripe3DS2/STDSImageLoader.h new file mode 100644 index 00000000..15f1396a --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSImageLoader.h @@ -0,0 +1,36 @@ +// +// STDSImageLoader.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/5/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef void (^STDSImageDownloadCompletionBlock)(UIImage * _Nullable); + +@interface STDSImageLoader: NSObject + +/** + Initializes an `STDSImageLoader` with the given parameters. + + @param session The session to initialize the loader with. + @return Returns an initialized `STDSImageLoader` object. + */ +- (instancetype)initWithURLSession:(NSURLSession *)session; + +/** + Attempts to load an image from the specified URL. + + @param URL The URL to load an image for. + @param completion A completion block that is called when the image loading has finished. This will be called on the main queue. + */ +- (void)loadImageFromURL:(NSURL *)URL completion:(STDSImageDownloadCompletionBlock)completion; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSImageLoader.m b/Stripe3DS2/Stripe3DS2/STDSImageLoader.m new file mode 100644 index 00000000..1d87e44f --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSImageLoader.m @@ -0,0 +1,50 @@ +// +// STDSImageLoader.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/5/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import "STDSImageLoader.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSImageLoader() + +@property (nonatomic, strong) NSURLSession *session; + +@end + +@implementation STDSImageLoader + +- (instancetype)initWithURLSession:(NSURLSession *)session { + self = [super init]; + + if (self) { + _session = session; + } + + return self; +} + +- (void)loadImageFromURL:(NSURL *)URL completion:(STDSImageDownloadCompletionBlock)completion { + NSURLSessionDataTask *dataTask = [self.session dataTaskWithURL:URL completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) { + UIImage *image; + + if (data != nil) { + image = [UIImage imageWithData:data]; + } + + [NSOperationQueue.mainQueue addOperationWithBlock:^{ + completion(image); + }]; + }]; + + [dataTask resume]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSIntegrityChecker.h b/Stripe3DS2/Stripe3DS2/STDSIntegrityChecker.h new file mode 100644 index 00000000..ed584dec --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSIntegrityChecker.h @@ -0,0 +1,19 @@ +// +// STDSIntegrityChecker.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 4/8/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSIntegrityChecker : NSObject + ++ (BOOL)SDKIntegrityIsValid; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSIntegrityChecker.m b/Stripe3DS2/Stripe3DS2/STDSIntegrityChecker.m new file mode 100644 index 00000000..73ed1ea8 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSIntegrityChecker.m @@ -0,0 +1,41 @@ +// +// STDSIntegrityChecker.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 4/8/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSIntegrityChecker.h" +#import "Stripe3DS2.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSIntegrityChecker + ++ (BOOL)SDKIntegrityIsValid { + + if (NSClassFromString(@"STDSIntegrityChecker") == [STDSIntegrityChecker class] && + NSClassFromString(@"STDSConfigParameters") == [STDSConfigParameters class] && + NSClassFromString(@"STDSThreeDS2Service") == [STDSThreeDS2Service class] && + NSClassFromString(@"STDSUICustomization") == [STDSUICustomization class] && + NSClassFromString(@"STDSWarning") == [STDSWarning class] && + NSClassFromString(@"STDSAlreadyInitializedException") == [STDSAlreadyInitializedException class] && + NSClassFromString(@"STDSNotInitializedException") == [STDSNotInitializedException class] && + NSClassFromString(@"STDSRuntimeException") == [STDSRuntimeException class] && + NSClassFromString(@"STDSErrorMessage") == [STDSErrorMessage class] && + NSClassFromString(@"STDSRuntimeErrorEvent") == [STDSRuntimeErrorEvent class] && + NSClassFromString(@"STDSProtocolErrorEvent") == [STDSProtocolErrorEvent class] && + NSClassFromString(@"STDSAuthenticationRequestParameters") == [STDSAuthenticationRequestParameters class] && + NSClassFromString(@"STDSChallengeParameters") == [STDSChallengeParameters class] && + NSClassFromString(@"STDSCompletionEvent") == [STDSCompletionEvent class] && + NSClassFromString(@"STDSTransaction") == [STDSTransaction class]) { + return YES; + } + + return NO; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSJSONWebEncryption.h b/Stripe3DS2/Stripe3DS2/STDSJSONWebEncryption.h new file mode 100644 index 00000000..2b2bc306 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSJSONWebEncryption.h @@ -0,0 +1,45 @@ +// +// STDSJSONWebEncryption.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/24/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSDirectoryServer.h" + +@class STDSDirectoryServerCertificate; +@class STDSJSONWebSignature; + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSJSONWebEncryption : NSObject + ++ (nullable NSString *)encryptJSON:(NSDictionary *)json + forDirectoryServer:(STDSDirectoryServer)directoryServer + error:(out NSError * _Nullable *)error; + ++ (nullable NSString *)encryptJSON:(NSDictionary *)json + withCertificate:(STDSDirectoryServerCertificate *)certificate + directoryServerID:(NSString *)directoryServerID + serverKeyID:(nullable NSString *)serverKeyID + error:(out NSError * _Nullable *)error; + ++ (nullable NSString *)directEncryptJSON:(NSDictionary *)json + withContentEncryptionKey:(NSData *)contentEncryptionKey + forACSTransactionID:(NSString *)acsTransactionID + error:(out NSError * _Nullable *)error; + ++ (nullable NSDictionary *)decryptData:(NSData *)data + withContentEncryptionKey:(NSData *)contentEncryptionKey + error:(out NSError * _Nullable *)error; + ++ (BOOL)verifyJSONWebSignature:(STDSJSONWebSignature *)jws forDirectoryServer:(STDSDirectoryServer)directoryServer; + ++ (BOOL)verifyJSONWebSignature:(STDSJSONWebSignature *)jws withCertificate:(STDSDirectoryServerCertificate *)certificate rootCertificates:(NSArray *)rootCertificates; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSJSONWebEncryption.m b/Stripe3DS2/Stripe3DS2/STDSJSONWebEncryption.m new file mode 100644 index 00000000..57023a5e --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSJSONWebEncryption.m @@ -0,0 +1,407 @@ +// +// STDSJSONWebEncryption.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/24/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSJSONWebEncryption.h" + +#import +#import + +#import "NSData+JWEHelpers.h" +#import "NSError+Stripe3DS2.h" +#import "NSString+JWEHelpers.h" +#import "STDSDirectoryServerCertificate.h" +#import "STDSEphemeralKeyPair.h" +#import "STDSJSONWebSignature.h" +#import "STDSSecTypeUtilities.h" + +NS_ASSUME_NONNULL_BEGIN + +#pragma mark - _STPJSONWebEncryptionResult +@interface _STPJSONWebEncryptionResult : NSObject + +- (instancetype)initWithCiphertext:(NSData *)ciphertextData + initializationVector:(NSData *)initializationVector + hmacTag:(NSData *)hmacTag; + +@property (nonatomic, copy, readonly) NSData *ciphertextData; +@property (nonatomic, copy, readonly) NSData *initializationVector; +@property (nonatomic, copy, readonly) NSData *hmacTag; + +@end + +@implementation _STPJSONWebEncryptionResult + +- (instancetype)initWithCiphertext:(NSData *)ciphertextData + initializationVector:(NSData *)initializationVector + hmacTag:(NSData *)hmacTag { + self = [super init]; + if (self) { + _ciphertextData = [ciphertextData copy]; + _initializationVector = [initializationVector copy]; + _hmacTag = [hmacTag copy]; + } + + return self; +} + +@end + +#pragma mark - STDSJSONWebEncryption + +static const size_t kContentEncryptionKeyByteCount = 32; +static const size_t kHMACSubKeyByteCount = 16; +static const size_t kAESSubKeyByteCount = 16; + +@implementation STDSJSONWebEncryption + ++ (nullable NSString *)encryptJSON:(NSDictionary *)json + forDirectoryServer:(STDSDirectoryServer)directoryServer + error:(out NSError *__autoreleasing _Nullable * _Nullable)error { + + NSString *ciphertext = nil; + + STDSDirectoryServerCertificate *certificate = [STDSDirectoryServerCertificate certificateForDirectoryServer:directoryServer]; + NSString *directoryServerID = STDSDirectoryServerIdentifier(directoryServer); + + if (certificate != nil && directoryServerID != nil) { + + ciphertext = [self encryptJSON:json + withCertificate:certificate + directoryServerID:directoryServerID + serverKeyID:nil + error:error]; + } + + if (error != nil && ciphertext == nil) { + *error = *error ?: [NSError _stds_jweError]; + } + + return ciphertext; +} + ++ (nullable NSString *)encryptJSON:(NSDictionary *)json + withCertificate:(STDSDirectoryServerCertificate *)certificate + directoryServerID:(NSString *)directoryServerID + serverKeyID:(nullable NSString *)serverKeyID + error:(out NSError * _Nullable *)error { + + NSString *ciphertext = nil; + NSError *jsonError = nil; + NSData * jsonData = [NSJSONSerialization dataWithJSONObject:json options:0 error:&jsonError]; + + if (jsonData != nil) { + + switch (certificate.keyType) { + case STDSDirectoryServerKeyTypeRSA: + ciphertext = [self _RSAEncryptPlaintext:jsonData + withCertificate:certificate + serverKeyID:serverKeyID]; + break; + + case STDSDirectoryServerKeyTypeEC: + ciphertext = [self _directEncryptPlaintext:jsonData + withCertificate:certificate + forDirectoryServer:directoryServerID]; + break; + + case STDSDirectoryServerKeyTypeUnknown: + break; + } + } + + if (error != nil && ciphertext == nil) { + *error = jsonError ?: [NSError _stds_jweError]; + } + + return ciphertext; +} + ++ (nullable NSString *)directEncryptJSON:(NSDictionary *)json + withContentEncryptionKey:(NSData *)contentEncryptionKey + forACSTransactionID:(NSString *)acsTransactionID + error:(out NSError * _Nullable *)error { + NSString *ciphertext = nil; + + NSError *jsonError = nil; + + + NSData * jsonData = [NSJSONSerialization dataWithJSONObject:json options:0 error:&jsonError]; + + if (jsonData != nil) { + NSString *headerString = [NSString stringWithFormat:@"{\"kid\":\"%@\",\"enc\":\"A128CBC-HS256\",\"alg\":\"dir\"}", acsTransactionID]; + ciphertext = [self _directEncryptPlaintext:jsonData + withContentEncryptionKey:contentEncryptionKey + headerString:headerString]; + } + + if (error != nil && ciphertext == nil) { + *error = jsonError ?: [NSError _stds_jweError]; + } + + return ciphertext; +} + ++ (nullable NSData *)_hmacTagWithKey:(NSData *)hmacSubKey + headerString:(NSString *)headerString + initializationVector:(NSData *)initializationVector + cipherText:(NSData *)cipherText { + NSString *encodedHeaderString = [headerString _stds_base64URLEncodedString]; + + // Per JWE spec, the encoded header data is included as additional authenticated data but with ASCII encoding https://tools.ietf.org/html/rfc7516#section-5.1 + NSData *additionalAuthenticatedData = [encodedHeaderString dataUsingEncoding:NSASCIIStringEncoding]; + uint64_t AL = CFSwapInt64HostToBig(additionalAuthenticatedData.length*8); + + NSMutableData *d = [NSMutableData data]; + [d appendBytes:additionalAuthenticatedData.bytes length:additionalAuthenticatedData.length]; + [d appendBytes:initializationVector.bytes length:initializationVector.length]; + [d appendBytes:cipherText.bytes length:cipherText.length]; + [d appendBytes:&AL length:sizeof(AL)]; + + NSMutableData *M = [[NSMutableData alloc] initWithLength:CC_SHA256_DIGEST_LENGTH]; + + CCHmac(kCCHmacAlgSHA256, + hmacSubKey.bytes, + kHMACSubKeyByteCount, + d.bytes, + d.length, + M.mutableBytes); + + + NSData *hmacTag = [M subdataWithRange:NSMakeRange(0, 16)]; + + return hmacTag; +} + ++ (nullable _STPJSONWebEncryptionResult *)_encryptPlaintext:(NSData *)plaintextData + withKey:(NSData *)contentEncryptionKeyData + headerString:(NSString *)headerString { + // ecrypt JSON according to JWE (RFC 7516) using JWE Compact Serialization + // enc: A128CBC-HS256 + + // Ref: rfc7518 sec 5.2.3 https://www.rfc-editor.org/rfc/rfc7518.txt + + _STPJSONWebEncryptionResult *result = nil; + + static const size_t kInitializationVectorByteCount = 16; + + NSAssert(contentEncryptionKeyData.length == kContentEncryptionKeyByteCount, @"Must use a valid 256 content encryption key"); + if (contentEncryptionKeyData.length == kContentEncryptionKeyByteCount) { + + NSData *hmacSubKeyData = [contentEncryptionKeyData subdataWithRange:NSMakeRange(0, kHMACSubKeyByteCount)]; + NSData *aesSubKeyData = [contentEncryptionKeyData subdataWithRange:NSMakeRange(kHMACSubKeyByteCount, kAESSubKeyByteCount)]; + NSData *initializationVectorData = STDSCryptoRandomData(kInitializationVectorByteCount); + + + // pad with block size for AES + NSMutableData *ciphertextData = [NSMutableData dataWithLength:plaintextData.length + kCCBlockSizeAES128]; + size_t outLength; + CCCryptorStatus aesEncryptionResult = CCCrypt(kCCEncrypt, + kCCAlgorithmAES, + kCCOptionPKCS7Padding, + aesSubKeyData.bytes, + kAESSubKeyByteCount, + initializationVectorData.bytes, + plaintextData.bytes, + (size_t)plaintextData.length, + ciphertextData.mutableBytes, + ciphertextData.length, + &outLength); + if (aesEncryptionResult == kCCSuccess) { + ciphertextData.length = outLength; + + NSData *hmacTag = [self _hmacTagWithKey:hmacSubKeyData + headerString:headerString + initializationVector:initializationVectorData + cipherText:ciphertextData]; + + result = [[_STPJSONWebEncryptionResult alloc] initWithCiphertext:ciphertextData + initializationVector:initializationVectorData + hmacTag:hmacTag]; + } + } + + return result; +} + ++ (nullable NSString *)_RSAEncryptPlaintext:(NSData *)plaintextData + withCertificate:(STDSDirectoryServerCertificate *)certificate + serverKeyID:(nullable NSString *)serverKeyID { + NSData *contentEncryptionKey = STDSCryptoRandomData(kContentEncryptionKeyByteCount); + if (contentEncryptionKey != nil) { + NSString *headerString = nil; + + if (serverKeyID != nil) { + headerString = [NSString stringWithFormat:@"{\"enc\":\"A128CBC-HS256\",\"alg\":\"RSA-OAEP-256\",\"kid\":\"%@\"}", serverKeyID]; + } else { + headerString = @"{\"enc\":\"A128CBC-HS256\",\"alg\":\"RSA-OAEP-256\"}"; + + } + _STPJSONWebEncryptionResult *encryptedData = [self _encryptPlaintext:plaintextData + withKey:contentEncryptionKey + headerString:headerString]; + if (encryptedData != nil) { + NSData *encryptedCEK = [certificate encryptDataUsingRSA_OAEP_SHA256:contentEncryptionKey]; + if (encryptedCEK != nil) { + return [NSString stringWithFormat:@"%@.%@.%@.%@.%@", [headerString _stds_base64URLEncodedString], [encryptedCEK _stds_base64URLEncodedString], [encryptedData.initializationVector _stds_base64URLEncodedString], [encryptedData.ciphertextData _stds_base64URLEncodedString], [encryptedData.hmacTag _stds_base64URLEncodedString]]; + } + } + } + + return nil; +} + ++ (nullable NSString *)_directEncryptPlaintext:(NSData *)plaintextData + withCertificate:(STDSDirectoryServerCertificate *)certificate + forDirectoryServer:(NSString *)directoryServerID { + + STDSEphemeralKeyPair *ephemeralKeyPair = [STDSEphemeralKeyPair ephemeralKeyPair]; + NSData *rawSharedSecret = [ephemeralKeyPair createSharedSecretWithCertificate:certificate]; + NSData *contentEncryptionKey = STDSCreateConcatKDFWithSHA256(rawSharedSecret, kContentEncryptionKeyByteCount, directoryServerID); + + NSString *headerString = [NSString stringWithFormat:@"{\"enc\":\"A128CBC-HS256\",\"alg\":\"ECDH-ES\",\"epk\":%@}", ephemeralKeyPair.publicKeyJWK]; + + return [self _directEncryptPlaintext:plaintextData + withContentEncryptionKey:contentEncryptionKey + headerString:headerString]; +} + ++ (nullable NSString *)_directEncryptPlaintext:(NSData *)plaintextData + withContentEncryptionKey:(NSData *)contentEncryptionKey + headerString:(NSString *)headerString { + + _STPJSONWebEncryptionResult *encryptedData = [self _encryptPlaintext:plaintextData + withKey:contentEncryptionKey + headerString:headerString]; + if (encryptedData != nil) { + return [NSString stringWithFormat:@"%@..%@.%@.%@", [headerString _stds_base64URLEncodedString], [encryptedData.initializationVector _stds_base64URLEncodedString], [encryptedData.ciphertextData _stds_base64URLEncodedString], [encryptedData.hmacTag _stds_base64URLEncodedString]]; + } + + return nil; +} + +#pragma mark - Decryption + ++ (nullable NSDictionary *)decryptData:(NSData *)data + withContentEncryptionKey:(NSData *)contentEncryptionKey + error:(out NSError * _Nullable *)error { + NSString *jweString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + NSArray *jweComponents = [jweString componentsSeparatedByString:@"."]; + if (jweComponents.count != 5) { + + // Data may be JSON describing error + NSError *jsonError = nil; + NSDictionary *errorJSON = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonError]; + if (errorJSON != nil) { + return errorJSON; + } + if (error != NULL) { + *error = jsonError ?: [NSError _stds_jweError]; + } + return nil; + } + + NSString *headerString = [jweComponents[0] _stds_base64URLDecodedString]; + NSData *initializationVector = [jweComponents[2] _stds_base64URLDecodedData]; + NSData *ciphertextData = [jweComponents[3] _stds_base64URLDecodedData]; + NSData *hmacTag = [jweComponents[4] _stds_base64URLDecodedData]; + + if (headerString == nil || + initializationVector == nil || + ciphertextData == nil || + hmacTag == nil + ) { + if (error != NULL) { + *error = [NSError _stds_jweError]; + } + return nil; + } + NSAssert(contentEncryptionKey.length == kContentEncryptionKeyByteCount, @"Must use a valid 256 content encryption key"); + if (contentEncryptionKey.length != kContentEncryptionKeyByteCount) { + if (error != NULL) { + *error = [NSError _stds_jweError]; + } + return nil; + } + + NSData *hmacSubKeyData = [contentEncryptionKey subdataWithRange:NSMakeRange(0, kHMACSubKeyByteCount)]; + NSData *aesSubKeyData = [contentEncryptionKey subdataWithRange:NSMakeRange(kHMACSubKeyByteCount, kAESSubKeyByteCount)]; + + // pad with block size for AES + NSMutableData *plaintextData = [NSMutableData dataWithLength:ciphertextData.length + kCCBlockSizeAES128]; + size_t outLength; + CCCryptorStatus aesDecryptionResult = CCCrypt(kCCDecrypt, + kCCAlgorithmAES, + kCCOptionPKCS7Padding, + aesSubKeyData.bytes, + kAESSubKeyByteCount, + initializationVector.bytes, + ciphertextData.bytes, + (size_t)ciphertextData.length, + plaintextData.mutableBytes, + plaintextData.length, + &outLength); + + if (aesDecryptionResult != kCCSuccess) { + if (error != NULL) { + *error = [NSError _stds_jweError]; + } + return nil; + } + + plaintextData.length = outLength; + NSData *calculatedHMACTag = [self _hmacTagWithKey:hmacSubKeyData + headerString:headerString + initializationVector:initializationVector + cipherText:ciphertextData]; + + if (![calculatedHMACTag isEqualToData:hmacTag]) { + if (error != NULL) { + *error = [NSError _stds_jweError]; + } + return nil; + } + + NSDictionary *decryptedJSON = [NSJSONSerialization JSONObjectWithData:plaintextData + options:0 + error:error]; + if (*error != NULL) { + *error = [NSError _stds_jweError]; + return nil; + } + + return decryptedJSON; +} + +#pragma mark - JSON Web Signature Verification + ++ (BOOL)verifyJSONWebSignature:(STDSJSONWebSignature *)jws forDirectoryServer:(STDSDirectoryServer)directoryServer { + STDSDirectoryServerCertificate *certificate = [STDSDirectoryServerCertificate certificateForDirectoryServer:directoryServer]; + NSString *certificateString = nil; + + if (directoryServer == STDSDirectoryServerULTestRSA || directoryServer == STDSDirectoryServerULTestEC) { + // for UL tests, the last certificate in the chain is the anchor/root + certificateString = jws.certificateChain.lastObject; + } else { + NSAssert(0, @"We shouldn't be using this path outside of UL testing"); + certificateString = certificate.certificateString; + } + + if (certificateString == nil) { + return NO; + } + + return [self verifyJSONWebSignature:jws withCertificate:certificate rootCertificates:@[certificateString]]; +} + ++ (BOOL)verifyJSONWebSignature:(STDSJSONWebSignature *)jws withCertificate:(__unused STDSDirectoryServerCertificate *)certificate rootCertificates:(NSArray *)rootCertificates { + return [STDSDirectoryServerCertificate verifyJSONWebSignature:jws withRootCertificates:rootCertificates]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSJSONWebSignature.h b/Stripe3DS2/Stripe3DS2/STDSJSONWebSignature.h new file mode 100644 index 00000000..d6f17a19 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSJSONWebSignature.h @@ -0,0 +1,41 @@ +// +// STDSJSONWebSignature.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 4/2/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +@class STDSEllipticCurvePoint; + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, STDSJSONWebSignatureAlgorithm) { + STDSJSONWebSignatureAlgorithmES256, + STDSJSONWebSignatureAlgorithmPS256, + STDSJSONWebSignatureAlgorithmUnknown, +}; + +@interface STDSJSONWebSignature : NSObject + +- (nullable instancetype)initWithString:(NSString *)jwsString; +- (nullable instancetype)initWithString:(NSString *)jwsString allowNilKey:(BOOL)allowNilKey; + +@property (nonatomic, readonly) STDSJSONWebSignatureAlgorithm algorithm; + +@property (nonatomic, readonly) NSData *digest; +@property (nonatomic, readonly) NSData *signature; + +@property (nonatomic, readonly) NSData *payload; + +/// non-nil if algorithm == STDSJSONWebSignatureAlgorithmES256 +@property (nonatomic, nullable, readonly) STDSEllipticCurvePoint *ellipticCurvePoint; + +/// non-nil if algorithm == STDSJSONWebSignatureAlgorithmPS256, can be non-nil for algorithm == STDSJSONWebSignatureAlgorithmES256 +@property (nonatomic, nullable, readonly) NSArray *certificateChain; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSJSONWebSignature.m b/Stripe3DS2/Stripe3DS2/STDSJSONWebSignature.m new file mode 100644 index 00000000..2e294937 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSJSONWebSignature.m @@ -0,0 +1,88 @@ +// +// STDSJSONWebSignature.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 4/2/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSJSONWebSignature.h" + +#import "NSString+JWEHelpers.h" +#import "STDSEllipticCurvePoint.h" +#import "STDSSecTypeUtilities.h" + +NS_ASSUME_NONNULL_BEGIN + +static NSString * const kJWSAlgorithmKey = @"alg"; +static NSString * const kJWSAlgorithmES256 = @"ES256"; +static NSString * const kJWSAlgorithmPS256 = @"PS256"; + +static NSString * const kJWSCertificateChainKey = @"x5c"; + +@implementation STDSJSONWebSignature + +- (nullable instancetype)initWithString:(NSString *)jwsString { + return [self initWithString:jwsString allowNilKey:NO]; +} + +- (nullable instancetype)initWithString:(NSString *)jwsString allowNilKey:(BOOL)allowNilKey { + self = [super init]; + if (self) { + NSArray *components = [jwsString componentsSeparatedByString:@"."]; + if (components.count != 3) { + return nil; + } + NSData *headerData = [components[0] _stds_base64URLDecodedData]; + if (headerData == nil) { + return nil; + } + NSError *jsonError = nil; + NSDictionary * headerJSON = [NSJSONSerialization JSONObjectWithData:headerData options:0 error:&jsonError]; + if (headerJSON == nil) { + return nil; + } + + if (headerJSON[kJWSCertificateChainKey] != nil && ![headerJSON[kJWSCertificateChainKey] isKindOfClass:[NSArray class]]) { + return nil; + } + + _certificateChain = headerJSON[kJWSCertificateChainKey]; + + NSString *algorithm = headerJSON[kJWSAlgorithmKey]; + if ([algorithm compare:kJWSAlgorithmES256 options: NSCaseInsensitiveSearch] == NSOrderedSame) { + _algorithm = STDSJSONWebSignatureAlgorithmES256; + if (_certificateChain.count > 0) { + SecCertificateRef certificate = STDSSecCertificateFromString(_certificateChain.firstObject); + if (certificate != NULL) { + SecKeyRef key = SecCertificateCopyKey(certificate); + CFRelease(certificate); + if (key != NULL) { + _ellipticCurvePoint = [[STDSEllipticCurvePoint alloc] initWithKey:key]; + CFRelease(key); + } + } + if (_ellipticCurvePoint == nil && !allowNilKey) { + return nil; + } + } else if (!allowNilKey) { + return nil; + } + + } else if ([algorithm compare:kJWSAlgorithmPS256 options: NSCaseInsensitiveSearch] == NSOrderedSame) { + _algorithm = STDSJSONWebSignatureAlgorithmPS256; + } else { + return nil; + } + + _digest = [[@[components[0], components[1]] componentsJoinedByString:@"."] dataUsingEncoding:NSUTF8StringEncoding]; + _signature = [components[2] _stds_base64URLDecodedData]; + _payload = [components[1] _stds_base64URLDecodedData]; + } + + return self; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSJailbreakChecker.h b/Stripe3DS2/Stripe3DS2/STDSJailbreakChecker.h new file mode 100644 index 00000000..303575c5 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSJailbreakChecker.h @@ -0,0 +1,19 @@ +// +// STDSJailbreakChecker.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 4/8/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSJailbreakChecker : NSObject + ++ (BOOL)isJailbroken; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSJailbreakChecker.m b/Stripe3DS2/Stripe3DS2/STDSJailbreakChecker.m new file mode 100644 index 00000000..6c0dcbf2 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSJailbreakChecker.m @@ -0,0 +1,31 @@ +// +// STDSJailbreakChecker.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 4/8/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSJailbreakChecker.h" + +@import UIKit; + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSJailbreakChecker + +// This was implemented under the following guidance: https://medium.com/@pinmadhon/how-to-check-your-app-is-installed-on-a-jailbroken-device-67fa0170cf56 ++ (BOOL)isJailbroken { + + // Check for existence of files that are common for jailbroken devices + if ([[NSFileManager defaultManager] fileExistsAtPath:@"/Applications/Cydia.app"] || + [[NSFileManager defaultManager] fileExistsAtPath:@"/Library/MobileSubstrate/MobileSubstrate.dylib"]) { + return YES; + } + + return NO; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSLocalizedString.h b/Stripe3DS2/Stripe3DS2/STDSLocalizedString.h new file mode 100644 index 00000000..7a3a76c2 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSLocalizedString.h @@ -0,0 +1,18 @@ +// +// STDSLocalizedString.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 7/9/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSBundleLocator.h" + +#ifndef STDSLocalizedString_h +#define STDSLocalizedString_h + +#define STDSLocalizedString(key, comment) \ +[[STDSBundleLocator stdsResourcesBundle] localizedStringForKey:(key) value:@"" table:nil] + + +#endif /* STDSLocalizedString_h */ diff --git a/Stripe3DS2/Stripe3DS2/STDSOSVersionChecker.h b/Stripe3DS2/Stripe3DS2/STDSOSVersionChecker.h new file mode 100644 index 00000000..72b79cf1 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSOSVersionChecker.h @@ -0,0 +1,19 @@ +// +// STDSOSVersionChecker.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 4/8/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSOSVersionChecker : NSObject + ++ (BOOL)isSupportedOSVersion; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSOSVersionChecker.m b/Stripe3DS2/Stripe3DS2/STDSOSVersionChecker.m new file mode 100644 index 00000000..fea650cd --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSOSVersionChecker.m @@ -0,0 +1,21 @@ +// +// STDSOSVersionChecker.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 4/8/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSOSVersionChecker.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSOSVersionChecker + ++ (BOOL)isSupportedOSVersion { + return YES; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSProcessingView.h b/Stripe3DS2/Stripe3DS2/STDSProcessingView.h new file mode 100644 index 00000000..95364fc5 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSProcessingView.h @@ -0,0 +1,26 @@ +// +// STDSProcessingView.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/19/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@class STDSUICustomization; + +@interface STDSProcessingView : UIView + +/// Defaults to NO +@property (nonatomic) BOOL shouldDisplayBlurView; +/// Defaults to YES +@property (nonatomic) BOOL shouldDisplayDSLogo; + +- (instancetype)initWithCustomization:(STDSUICustomization *)customization directoryServerLogo:(nullable UIImage *)directoryServerLogo; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSProcessingView.m b/Stripe3DS2/Stripe3DS2/STDSProcessingView.m new file mode 100644 index 00000000..3126f974 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSProcessingView.m @@ -0,0 +1,98 @@ +// +// STDSProcessingView.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/19/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSProcessingView.h" +#import "STDSStackView.h" +#import "UIView+LayoutSupport.h" +#import "UIFont+DefaultFonts.h" +#import "STDSUICustomization.h" +#import "STDSBundleLocator.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSProcessingView() + +@property (nonatomic, strong) STDSStackView *imageStackView; +@property (nonatomic, strong) UIView *blurViewPlaceholder; +@property (nonatomic, strong) UIImageView *imageView; + +@end + +@implementation STDSProcessingView + +static const CGFloat kProcessingViewHorizontalMargin = 8; +static const CGFloat kProcessingViewTopPadding = 22; +static const CGFloat kProcessingViewBottomPadding = 36; + +- (instancetype)initWithCustomization:(STDSUICustomization *)customization directoryServerLogo:(nullable UIImage *)directoryServerLogo { + self = [super initWithFrame:CGRectZero]; + + if (self) { + _blurViewPlaceholder = [UIView new]; + _imageView = [[UIImageView alloc] initWithImage:directoryServerLogo]; + _imageView.contentMode = UIViewContentModeScaleAspectFit; + _shouldDisplayDSLogo = YES; + [self _setupViewHierarchyWithCustomization:customization]; + } + + return self; +} + +- (void)setShouldDisplayBlurView:(BOOL)shouldDisplayBlurView { + _shouldDisplayBlurView = shouldDisplayBlurView; + self.blurViewPlaceholder.hidden = shouldDisplayBlurView; +} + +- (void)setShouldDisplayDSLogo:(BOOL)shouldDisplayDSLogo { + _shouldDisplayDSLogo = shouldDisplayDSLogo; + self.imageView.hidden = !shouldDisplayDSLogo; +} + +- (void)_setupViewHierarchyWithCustomization:(STDSUICustomization *)customization { + self.blurViewPlaceholder.backgroundColor = customization.backgroundColor; + UIVisualEffectView *blurView = [[UIVisualEffectView alloc] initWithEffect:[UIBlurEffect effectWithStyle:customization.blurStyle]]; + blurView.translatesAutoresizingMaskIntoConstraints = NO; + + STDSStackView *containerView = [[STDSStackView alloc] initWithAlignment:STDSStackViewLayoutAxisVertical]; + containerView.backgroundColor = customization.backgroundColor; + containerView.layer.cornerRadius = 13; + containerView.translatesAutoresizingMaskIntoConstraints = NO; + + UIActivityIndicatorView *indicatorView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:customization.activityIndicatorViewStyle]; + + [self addSubview:blurView]; + [blurView.contentView addSubview:self.blurViewPlaceholder]; + [self addSubview:containerView]; + + [self.blurViewPlaceholder _stds_pinToSuperviewBoundsWithoutMargin]; + [blurView _stds_pinToSuperviewBoundsWithoutMargin]; + + NSLayoutConstraint *centerXConstraint = [NSLayoutConstraint constraintWithItem:containerView attribute:NSLayoutAttributeCenterX relatedBy:NSLayoutRelationEqual toItem:self attribute:NSLayoutAttributeCenterX multiplier:1.0 constant:0]; + NSLayoutConstraint *centerYConstraint = [NSLayoutConstraint constraintWithItem:containerView attribute:NSLayoutAttributeCenterY relatedBy:NSLayoutRelationEqual toItem:self attribute:NSLayoutAttributeCenterY multiplier:1.0 constant:0]; + NSLayoutConstraint *widthConstraint = [NSLayoutConstraint constraintWithItem:containerView attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:self attribute:NSLayoutAttributeWidth multiplier:1.0 constant:-kProcessingViewHorizontalMargin * 2]; + + [NSLayoutConstraint activateConstraints:@[ + centerXConstraint, + centerYConstraint, + widthConstraint, + [containerView.topAnchor constraintGreaterThanOrEqualToAnchor:self.topAnchor], + [self.bottomAnchor constraintGreaterThanOrEqualToAnchor:containerView.bottomAnchor], + ]]; + + [containerView addSpacer:kProcessingViewTopPadding]; + [containerView addArrangedSubview:self.imageView]; + [containerView addSpacer:20]; + [containerView addArrangedSubview:indicatorView]; + [containerView addSpacer:kProcessingViewBottomPadding]; + + [indicatorView startAnimating]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSProgressViewController.h b/Stripe3DS2/Stripe3DS2/STDSProgressViewController.h new file mode 100644 index 00000000..147fa4e4 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSProgressViewController.h @@ -0,0 +1,23 @@ +// +// STDSProgressViewController.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 5/6/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSDirectoryServer.h" + +@class STDSImageLoader, STDSUICustomization; + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSProgressViewController : UIViewController + +- (instancetype)initWithDirectoryServer:(STDSDirectoryServer)directoryServer uiCustomization:(STDSUICustomization * _Nullable)uiCustomization didCancel:(void (^)(void))didCancel; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSProgressViewController.m b/Stripe3DS2/Stripe3DS2/STDSProgressViewController.m new file mode 100644 index 00000000..d53fe7f1 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSProgressViewController.m @@ -0,0 +1,55 @@ +// +// STDSProgressViewController.m +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 5/6/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSProgressViewController.h" + +#import "STDSBundleLocator.h" +#import "STDSUICustomization.h" +#import "UIViewController+Stripe3DS2.h" +#import "STDSProcessingView.h" + +@interface STDSProgressViewController() +@property (nonatomic, strong, nullable) STDSUICustomization *uiCustomization; +@property (nonatomic, strong) void (^didCancel)(void); +@property (nonatomic) STDSDirectoryServer directoryServer; +@end + +@implementation STDSProgressViewController + +- (instancetype)initWithDirectoryServer:(STDSDirectoryServer)directoryServer uiCustomization:(STDSUICustomization * _Nullable)uiCustomization didCancel:(void (^)(void))didCancel { + self = [super initWithNibName:nil bundle:nil]; + + if (self) { + _directoryServer = directoryServer; + _uiCustomization = uiCustomization; + _didCancel = didCancel; + } + + return self; +} + +- (void)loadView { + NSString *imageName = STDSDirectoryServerImageName(self.directoryServer); + UIImage *dsImage = imageName ? [UIImage imageNamed:imageName inBundle:[STDSBundleLocator stdsResourcesBundle] compatibleWithTraitCollection:nil] : nil; + self.view = [[STDSProcessingView alloc] initWithCustomization:self.uiCustomization directoryServerLogo:dsImage]; +} + +- (UIStatusBarStyle)preferredStatusBarStyle { + return self.uiCustomization.preferredStatusBarStyle; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + [self _stds_setupNavigationBarElementsWithCustomization:self.uiCustomization cancelButtonSelector:@selector(_cancelButtonTapped:)]; +} + +- (void)_cancelButtonTapped:(UIButton *)sender { + self.didCancel(); +} + +@end diff --git a/Stripe3DS2/Stripe3DS2/STDSSecTypeUtilities.h b/Stripe3DS2/Stripe3DS2/STDSSecTypeUtilities.h new file mode 100644 index 00000000..d0d416e6 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSSecTypeUtilities.h @@ -0,0 +1,49 @@ +// +// STDSSecTypeUtilities.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/28/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSDirectoryServer.h" + +NS_ASSUME_NONNULL_BEGIN + +/// Returns the SecCertificateRef for the specified server or NULL if there's an error +SecCertificateRef _Nullable STDSCertificateForServer(STDSDirectoryServer server); + +/// Returns the public key in the certificate or NULL if there's an error +SecKeyRef _Nullable SecCertificateCopyKey(SecCertificateRef certificate); + +/// Returns one of the values defined for kSecAttrKeyType in or NULL +CFStringRef _Nullable STDSSecCertificateCopyPublicKeyType(SecCertificateRef certificate); + +/// Returns the hashed secret or nil +NSData * _Nullable STDSCreateConcatKDFWithSHA256(NSData *sharedSecret, NSUInteger keyLength, NSString *apv); + +/// Verifies the signature and payload using the Elliptic Curve P-256 with coordinateX and coordinateY +BOOL STDSVerifyEllipticCurveP256Signature(NSData *coordinateX, NSData *coordinateY, NSData *payload, NSData *signature); + +/// Verifies the signature and payload using RSA-PSS +BOOL STDSVerifyRSASignature(SecCertificateRef certificate, NSData *payload, NSData *signature); + +/// Returns data of length numBytes generated using CommonCrypto's CCRandomGenerateBytes or nil on failure +NSData * _Nullable STDSCryptoRandomData(size_t numBytes); + +/// Creates a certificate from base64encoded data +SecCertificateRef _Nullable STDSSecCertificateFromData(NSData *data); + +/// Creates a certificate from a PEM or DER encoded certificate string +SecCertificateRef _Nullable STDSSecCertificateFromString(NSString *certificateString); + +// Creates a public key using Elliptic Curve P-256 with coordinateX and coordinateY +SecKeyRef _Nullable STDSSecKeyRefFromCoordinates(NSData *coordinateX, NSData *coordinateY); + +// Creates a private key using Elliptic Curve P-256 with x, y, and d +SecKeyRef _Nullable STDSPrivateSecKeyRefFromCoordinates(NSData *x, NSData *y, NSData *d); + + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSSecTypeUtilities.m b/Stripe3DS2/Stripe3DS2/STDSSecTypeUtilities.m new file mode 100644 index 00000000..2a2331fb --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSSecTypeUtilities.m @@ -0,0 +1,366 @@ +// +// STDSSecTypeUtilities.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/28/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSSecTypeUtilities.h" + +#import +#import +#import + +#import "STDSBundleLocator.h" +#import "STDSEllipticCurvePoint.h" + +NS_ASSUME_NONNULL_BEGIN + +SecCertificateRef _Nullable STDSCertificateForServer(STDSDirectoryServer server) { + static NSMutableDictionary *sCertificateData = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sCertificateData = [[NSMutableDictionary alloc] init]; + }); + + NSString *serverKey = nil; + switch (server) { + case STDSDirectoryServerULTestRSA: + serverKey = @"STDSDirectoryServerULTestRSA"; + break; + + case STDSDirectoryServerULTestEC: + serverKey = @"STDSDirectoryServerULTestEC"; + break; + + case STDSDirectoryServerSTPTestRSA: + serverKey = @"STDSDirectoryServerSTPTestRSA"; + break; + + case STDSDirectoryServerSTPTestEC: + serverKey = @"STDSDirectoryServerSTPTestEC"; + break; + + case STDSDirectoryServerAmex: + serverKey = @"STDSDirectoryServerAmex"; + break; + + case STDSDirectoryServerDiscover: + serverKey = @"STDSDirectoryServerDiscover"; + break; + + case STDSDirectoryServerMastercard: + serverKey = @"STDSDirectoryServerMastercard"; + break; + + case STDSDirectoryServerVisa: + serverKey = @"STDSDirectoryServerVisa"; + break; + + case STDSDirectoryServerCartesBancaires: + serverKey = @"STDSDirectoryServerCartesBancaires"; + break; + + case STDSDirectoryServerCustom: + break; + + case STDSDirectoryServerUnknown: + break; + } + + if (serverKey == nil) { + return NULL; + } + + NSData *certificateData = sCertificateData[serverKey]; + if (certificateData == nil) { + NSString *certificatePath = nil; + switch (server) { + case STDSDirectoryServerULTestRSA: + break; + + case STDSDirectoryServerULTestEC: + break; + + case STDSDirectoryServerSTPTestRSA: + certificatePath = [[STDSBundleLocator stdsResourcesBundle] pathForResource:@"ul-test" ofType:@"der"]; + break; + + case STDSDirectoryServerSTPTestEC: + certificatePath = [[STDSBundleLocator stdsResourcesBundle] pathForResource:@"ec_test" ofType:@"der"]; + break; + + case STDSDirectoryServerAmex: + certificatePath = [[STDSBundleLocator stdsResourcesBundle] pathForResource:@"amex" ofType:@"der"]; + break; + + case STDSDirectoryServerDiscover: + certificatePath = [[STDSBundleLocator stdsResourcesBundle] pathForResource:@"discover" ofType:@"der"]; + break; + + case STDSDirectoryServerMastercard: + certificatePath = [[STDSBundleLocator stdsResourcesBundle] pathForResource:@"mastercard" ofType:@"der"]; + break; + + case STDSDirectoryServerVisa: + certificatePath = [[STDSBundleLocator stdsResourcesBundle] pathForResource:@"visa" ofType:@"der"]; + break; + + case STDSDirectoryServerCartesBancaires: + certificatePath = [[STDSBundleLocator stdsResourcesBundle] pathForResource:@"cartes_bancaires" ofType:@"der"]; + break; + + case STDSDirectoryServerCustom: + break; + + case STDSDirectoryServerUnknown: + break; + } + + if (certificatePath != nil) { + certificateData = [NSData dataWithContentsOfFile:certificatePath]; + // cache the file data to limit file IO + sCertificateData[serverKey] = certificateData; + } + } + + // Note to Future: SecCertificateCreateWithData only works with DER formatted data. The other popular + // format for certificate files is PEM. These can be converted before adding to the SDK by invoking + // `openssl x509 -in certificate_PEM.crt -outform der -out certificate_DER.der` + return certificateData != nil ? SecCertificateCreateWithData(NULL, (CFDataRef)certificateData): NULL; +}; + +CFStringRef _Nullable STDSSecCertificateCopyPublicKeyType(SecCertificateRef certificate) { + CFStringRef ret = NULL; + + SecKeyRef key = SecCertificateCopyKey(certificate); + + if (key != NULL) { + CFDictionaryRef attributes = SecKeyCopyAttributes(key); + if (attributes == NULL) { + CFRelease(key); + return NULL; + } + + if (attributes != NULL) { + const void *keyType = CFDictionaryGetValue(attributes, kSecAttrKeyType); + if (keyType != NULL && CFGetTypeID(keyType) == CFStringGetTypeID()) { + ret = CFStringCreateCopy(kCFAllocatorDefault, (CFStringRef)keyType); + } + CFRelease(attributes); + } + CFRelease(key); + } + + return ret; +} + +SecCertificateRef _Nullable STDSSecCertificateFromString(NSString *certificateString) { + static NSString * const kCertificateAnchorPrefix = @"-----BEGIN CERTIFICATE-----"; + static NSString * const kCertificateAnchorSuffix = @"-----END CERTIFICATE-----"; + + // first remove newlines + NSString *certificateStringNoAnchors = [[[certificateString stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]] componentsJoinedByString:@""]; + + // remove the begin/end certificate markers + NSUInteger fromIndex = [certificateStringNoAnchors hasPrefix:kCertificateAnchorPrefix] ? kCertificateAnchorPrefix.length : 0; + NSUInteger toIndex = [certificateStringNoAnchors hasSuffix:kCertificateAnchorSuffix] ? certificateStringNoAnchors.length - kCertificateAnchorSuffix.length : certificateStringNoAnchors.length; + certificateStringNoAnchors = [[certificateStringNoAnchors substringWithRange:NSMakeRange(fromIndex, toIndex - fromIndex)] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + + if (certificateStringNoAnchors.length == 0) { + return NULL; + } + NSData *certificateData = [[NSData alloc] initWithBase64EncodedString:certificateStringNoAnchors options:0]; + if (certificateData == nil) { + return NULL; + } + + return STDSSecCertificateFromData(certificateData); +} + +SecCertificateRef _Nullable STDSSecCertificateFromData(NSData *data) { + SecCertificateRef certificate = SecCertificateCreateWithData(NULL, (__bridge CFDataRef)data); + return certificate; +} + +SecKeyRef _Nullable STDSPrivateSecKeyRefFromCoordinates(NSData *x, NSData *y, NSData *d) { + static unsigned char prefixBytes[] = {0x04}; + NSMutableData *bytes = [[NSMutableData alloc] initWithBytes:(void *)prefixBytes length:1]; + [bytes appendData:x]; + [bytes appendData:y]; + [bytes appendData:d]; + NSDictionary *attributes = @{ + (__bridge NSString *)kSecAttrKeyType: (__bridge NSString *)kSecAttrKeyTypeECSECPrimeRandom, + (__bridge NSString *)kSecAttrKeyClass: (__bridge NSString *)kSecAttrKeyClassPrivate, + (__bridge NSString *)kSecAttrKeySizeInBits: @(256), + }; + CFErrorRef error = NULL; + SecKeyRef key = SecKeyCreateWithData((__bridge CFDataRef)bytes, (__bridge CFDictionaryRef)attributes, &error); + return key; +} + +SecKeyRef _Nullable STDSSecKeyRefFromCoordinates(NSData *coordinateX, NSData *coordinateY) { + static unsigned char prefixBytes[] = {0x04}; + NSMutableData *bytes = [[NSMutableData alloc] initWithBytes:(void *)prefixBytes length:1]; + [bytes appendData:coordinateX]; + [bytes appendData:coordinateY]; + NSDictionary *attributes = @{ + (__bridge NSString *)kSecAttrKeyType: (__bridge NSString *)kSecAttrKeyTypeECSECPrimeRandom, + (__bridge NSString *)kSecAttrKeyClass: (__bridge NSString *)kSecAttrKeyClassPublic, + (__bridge NSString *)kSecAttrKeySizeInBits: @(256), + }; + CFErrorRef error = NULL; + SecKeyRef key = SecKeyCreateWithData((__bridge CFDataRef)bytes, (__bridge CFDictionaryRef)attributes, &error); + return key; +} + +// ref. https://crypto.stackexchange.com/questions/1795/how-can-i-convert-a-der-ecdsa-signature-to-asn-1/1797#1797 +NSData * _Nullable STDSDEREncodedSignature(NSData * signature) { + // make sure input signature is of correct R || S format + NSUInteger signatureLength = signature.length; + if (signatureLength == 0 || signatureLength % 2 != 0) { + return nil; + } + + static const uint8_t bytePrefix = 0x00; + + NSMutableData *rBytes = [[NSMutableData alloc] init]; + uint8_t firstRByte; + [signature getBytes:&firstRByte length:1]; + + if (firstRByte >= 0x80) { + // "Signed big-endian encoding of minimal length", we can't have the first bit be 1 because these are postive values + [rBytes appendBytes:&bytePrefix length:1]; + } + [rBytes appendBytes:signature.bytes length:signatureLength / 2]; + + NSMutableData *sBytes = [[NSMutableData alloc] init]; + uint8_t firstSByte; + [signature getBytes:&firstSByte range:NSMakeRange(signatureLength / 2, 1)]; + + if (firstSByte >= 0x80) { + // "Signed big-endian encoding of minimal length", we can't have the first bit be 1 because these are postive values + [sBytes appendBytes:&bytePrefix length:1]; + } + [sBytes appendBytes:(signature.bytes + (signatureLength / 2)) length:signatureLength / 2]; + + uint8_t rLength = (uint8_t)rBytes.length; + uint8_t sLength = (uint8_t)sBytes.length; + + static const uint8_t derBytePrefix = 0x30; + NSMutableData *derEncoded = [[NSMutableData alloc] initWithBytes:&derBytePrefix length:1]; + + static const uint8_t derSeparatorByte = 0x02; + // numBytes does not include the 0x30 byte + uint8_t numBytes = rLength + sLength + 2 + 2; // + 2 for separators, + 2 for r and s size bytes + [derEncoded appendBytes:&numBytes length:1]; + [derEncoded appendBytes:&derSeparatorByte length:1]; + + [derEncoded appendBytes:&rLength length:1]; + [derEncoded appendBytes:rBytes.bytes length:rBytes.length]; + + [derEncoded appendBytes:&derSeparatorByte length:1]; + + [derEncoded appendBytes:&sLength length:1]; + [derEncoded appendBytes:sBytes.bytes length:sBytes.length]; + + return [derEncoded copy]; +} + +BOOL STDSVerifyEllipticCurveP256Signature(NSData *coordinateX, NSData *coordinateY, NSData *payload, NSData *signature) { + BOOL ret = NO; + + // make P-256 curve key from coordinates + SecKeyRef key = STDSSecKeyRefFromCoordinates(coordinateX, coordinateY); + + if (key != NULL) { + size_t hashBytesSize = CC_SHA256_DIGEST_LENGTH; + unsigned char hashBytes[hashBytesSize]; + CC_SHA256(payload.bytes, (CC_LONG)payload.length, hashBytes); + CFErrorRef error = NULL; + NSData *derEncodedSignature = STDSDEREncodedSignature(signature); + if (derEncodedSignature == nil) { + CFRelease(key); + return NO; + } + ret = (BOOL)SecKeyVerifySignature(key, kSecKeyAlgorithmECDSASignatureDigestX962SHA256, (__bridge CFDataRef)[NSData dataWithBytes:hashBytes length:hashBytesSize], (__bridge CFDataRef)derEncodedSignature, &error); + CFRelease(key); + } + return ret; +} + +BOOL STDSVerifyRSASignature(SecCertificateRef certificate, NSData *payload, NSData *signature) { + BOOL ret = NO; + + SecKeyRef key = SecCertificateCopyKey(certificate); + if (key != NULL && signature) { + CFErrorRef error = NULL; + size_t hashBytesSize = CC_SHA256_DIGEST_LENGTH; + unsigned char hashBytes[hashBytesSize]; + CC_SHA256(payload.bytes, (CC_LONG)payload.length, hashBytes); + + ret = (BOOL)SecKeyVerifySignature(key, kSecKeyAlgorithmRSASignatureDigestPSSSHA256, (__bridge CFDataRef)[NSData dataWithBytes:hashBytes length:hashBytesSize], (__bridge CFDataRef)signature, &error); + CFRelease(key); + } + return ret; +} + +NSData * _Nullable STDSCryptoRandomData(size_t numBytes) { + void *randomBytes[numBytes]; + memset(randomBytes, 0, numBytes); + if (CCRandomGenerateBytes(randomBytes, numBytes) == kCCSuccess) { + NSData *data = [NSData dataWithBytes:randomBytes length:numBytes]; + return data; + } + return NULL; +} + +NSData * _Nullable _STPCreateKDFFormattedData(NSData *data) { + uint32_t bigEndianLength = CFSwapInt32HostToBig((uint32_t)data.length); + NSMutableData *encodedLength = [NSMutableData dataWithBytes:&bigEndianLength length:4]; + [encodedLength appendData:data]; + return [encodedLength copy]; +} + +NSData * _Nullable STDSCreateConcatKDFWithSHA256(NSData *sharedSecret, NSUInteger keyLength, NSString *apv) { + NSData *concatKDFData = nil; + + uint32_t bigEndianKeyLength = CFSwapInt32HostToBig((uint32_t)keyLength*8); + + // algorithmID and partyUInfo are intentionally empty strings based on the Core Spec + // section 6.2.3.3 which states that they should be null. The KDF standard, NIST.800-56A, + // requires that null values still have the length bytes set to 0. + NSData *algorithmID = _STPCreateKDFFormattedData([@"" dataUsingEncoding:NSASCIIStringEncoding]); + NSData *partyUInfo = _STPCreateKDFFormattedData([@"" dataUsingEncoding:NSUTF8StringEncoding]); + NSData *partyVInfo = _STPCreateKDFFormattedData([apv dataUsingEncoding:NSUTF8StringEncoding]); + NSData *suppPubInfo = [NSData dataWithBytes:&bigEndianKeyLength length:4]; + + if (algorithmID == nil || + partyUInfo == nil || + partyVInfo == nil || + suppPubInfo == nil + ) { + return nil; + } + + NSMutableData *otherInfo = [algorithmID mutableCopy]; + [otherInfo appendData:partyUInfo]; + [otherInfo appendData:partyVInfo]; + [otherInfo appendData:suppPubInfo]; + + const unsigned char roundOneBytes[4] = {0, 0, 0, 1}; + NSMutableData *roundOneHashInput = [[NSMutableData alloc] initWithBytes:roundOneBytes length:4]; + [roundOneHashInput appendData:sharedSecret]; + [roundOneHashInput appendData:otherInfo]; + + NSMutableData *roundOneHashOutput = [NSMutableData dataWithLength:CC_SHA256_DIGEST_LENGTH]; + + if (CC_SHA256(roundOneHashInput.bytes, (CC_LONG)roundOneHashInput.length, roundOneHashOutput.mutableBytes) != nil) { + concatKDFData = [NSData dataWithBytes:roundOneHashOutput.bytes length:MAX(keyLength, roundOneHashOutput.length)]; + } + + return concatKDFData; +} + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSSelectionButton.h b/Stripe3DS2/Stripe3DS2/STDSSelectionButton.h new file mode 100644 index 00000000..5cb78b7c --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSSelectionButton.h @@ -0,0 +1,26 @@ +// +// STDSSelectionButton.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 6/11/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +@class STDSSelectionCustomization; + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSSelectionButton : UIButton + +@property (nonatomic) STDSSelectionCustomization *customization; + +/// This control can either be styled as a radio button or a checkbox +@property (nonatomic) BOOL isCheckbox; + +- (instancetype)initWithCustomization:(STDSSelectionCustomization *)customization; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSSelectionButton.m b/Stripe3DS2/Stripe3DS2/STDSSelectionButton.m new file mode 100644 index 00000000..97bf19e4 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSSelectionButton.m @@ -0,0 +1,167 @@ +// +// STDSSelectionButton.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 6/11/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSSelectionButton.h" + +#import "STDSSelectionCustomization.h" + +@interface _STDSSelectionButtonView: UIView +@property (nonatomic) BOOL isCheckbox; +@property (nonatomic) STDSSelectionCustomization *customization; +@property (nonatomic, getter = isSelected) BOOL selected; +@end + +@implementation _STDSSelectionButtonView + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (self) { + self.opaque = NO; + } + + return self; +} + +- (void)setSelected:(BOOL)selected { + _selected = selected; + [self setNeedsDisplay]; +} + + +- (void)setCustomization:(STDSSelectionCustomization *)customization { + _customization = customization; + [self setNeedsDisplay]; +} + + +- (void)drawRect:(CGRect)rect { + if (self.isCheckbox) { + [self _drawCheckboxWithRect:rect]; + } else { + [self _drawRadioButtonWithRect:rect]; + } +} + +- (void)_drawRadioButtonWithRect:(CGRect)rect { + // Draw background + UIBezierPath *background = [UIBezierPath bezierPathWithOvalInRect:rect]; + if (self.isSelected) { + [self.customization.primarySelectedColor setFill]; + } else { + [self.customization.unselectedBackgroundColor setFill]; + } + [background fill]; + + // Draw unselected border + if (!self.isSelected) { + UIBezierPath *border = [UIBezierPath bezierPathWithOvalInRect:CGRectInset(rect, 0.5, 0.5)]; + [self.customization.unselectedBorderColor setStroke]; + [border stroke]; + } + + // Draw inner circle if selected + if (self.isSelected) { + CGRect selectedRect = CGRectInset(rect, 9, 9); + UIBezierPath *selected = [UIBezierPath bezierPathWithOvalInRect:selectedRect]; + [self.customization.secondarySelectedColor setFill]; + [selected fill]; + } +} + +- (void)_drawCheckboxWithRect:(CGRect)rect { + // Draw background + UIBezierPath *background = [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:8]; + if (self.isSelected) { + [self.customization.primarySelectedColor setFill]; + } else { + [self.customization.unselectedBackgroundColor setFill]; + } + [background fill]; + + // Draw unselected border + if (!self.isSelected) { + UIBezierPath *border = [UIBezierPath bezierPathWithRoundedRect:CGRectInset(rect, 0.5, 0.5) cornerRadius:8]; + border.lineWidth = 0.5; + [self.customization.unselectedBorderColor setStroke]; + [border stroke]; + } + + // Draw check mark if selected + if (self.isSelected) { + UIBezierPath *checkmark = [UIBezierPath bezierPath]; + [checkmark moveToPoint: CGPointMake(10, 15)]; + [checkmark addLineToPoint:CGPointMake(13.5, 18.5)]; + [checkmark addLineToPoint:CGPointMake(22, 10)]; + [self.customization.secondarySelectedColor setStroke]; + checkmark.lineWidth = 2; + [checkmark stroke]; + } +} + +@end + +static const CGFloat kMinimumTouchAreaDimension = 42.f; +static const CGFloat kContentSizeDimension = 30.f; + +@implementation STDSSelectionButton { + _STDSSelectionButtonView *_contentView; +} + +- (instancetype)initWithCustomization:(STDSSelectionCustomization *)customization { + self = [super init]; + if (self) { + _contentView = [[_STDSSelectionButtonView alloc] initWithFrame:CGRectMake(0, 0, kContentSizeDimension, kContentSizeDimension)]; + _contentView.userInteractionEnabled = NO; + [self addSubview:_contentView]; + self.customization = customization; + } + return self; +} + +- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event { + BOOL pointInside = [super pointInside:point withEvent:event]; + if (!pointInside && + self.enabled && + !self.isHidden && + (CGRectGetWidth(self.bounds) < kMinimumTouchAreaDimension || CGRectGetHeight(self.bounds) < kMinimumTouchAreaDimension) + ) { + // Make sure that we intercept touch events even outside our bounds if they are within the + // minimum touch area. Otherwise this button is too hard to tap + CGRect expandedBounds = CGRectInset(self.bounds, MIN(CGRectGetWidth(self.bounds) - kMinimumTouchAreaDimension, 0), MIN(CGRectGetHeight(self.bounds) < kMinimumTouchAreaDimension, 0)); + pointInside = CGRectContainsPoint(expandedBounds, point); + } + + return pointInside; +} + +- (CGSize)intrinsicContentSize { + return CGSizeMake(kContentSizeDimension, kContentSizeDimension); +} + +- (void)setSelected:(BOOL)selected { + [super setSelected:selected]; + _contentView.selected = selected; +} + +- (void)setCustomization:(STDSSelectionCustomization *)customization { + _contentView.customization = customization; +} + +- (STDSSelectionCustomization *)customization { + return _contentView.customization; +} + +- (void)setIsCheckbox:(BOOL)isCheckbox { + _contentView.isCheckbox = isCheckbox; +} + +- (BOOL)isCheckbox { + return _contentView.isCheckbox; +} + +@end diff --git a/Stripe3DS2/Stripe3DS2/STDSSimulatorChecker.h b/Stripe3DS2/Stripe3DS2/STDSSimulatorChecker.h new file mode 100644 index 00000000..7b6e77d7 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSSimulatorChecker.h @@ -0,0 +1,19 @@ +// +// STDSSimulatorChecker.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 4/8/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSSimulatorChecker : NSObject + ++ (BOOL)isRunningOnSimulator; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSSimulatorChecker.m b/Stripe3DS2/Stripe3DS2/STDSSimulatorChecker.m new file mode 100644 index 00000000..47ea89e1 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSSimulatorChecker.m @@ -0,0 +1,25 @@ +// +// STDSSimulatorChecker.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 4/8/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSSimulatorChecker.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSSimulatorChecker + ++ (BOOL)isRunningOnSimulator { +#if TARGET_IPHONE_SIMULATOR + return YES; +#else + return NO; +#endif +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSSpacerView.h b/Stripe3DS2/Stripe3DS2/STDSSpacerView.h new file mode 100644 index 00000000..072ad1ed --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSSpacerView.h @@ -0,0 +1,20 @@ +// +// STDSSpacerView.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/4/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import "STDSStackView.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSSpacerView : UIView + +- (instancetype)initWithLayoutAxis:(STDSStackViewLayoutAxis)layoutAxis dimension:(CGFloat)dimension; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSSpacerView.m b/Stripe3DS2/Stripe3DS2/STDSSpacerView.m new file mode 100644 index 00000000..a674e68a --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSSpacerView.m @@ -0,0 +1,34 @@ +// +// STDSSpacerView.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/4/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSSpacerView.h" + +@implementation STDSSpacerView + +- (instancetype)initWithLayoutAxis:(STDSStackViewLayoutAxis)layoutAxis dimension:(CGFloat)dimension { + self = [super initWithFrame:CGRectZero]; + + if (self) { + NSLayoutConstraint *constraint; + + switch (layoutAxis) { + case STDSStackViewLayoutAxisHorizontal: + constraint = [NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0 constant:dimension]; + break; + case STDSStackViewLayoutAxisVertical: + constraint = [NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0 constant:dimension]; + break; + } + + [NSLayoutConstraint activateConstraints:@[constraint]]; + } + + return self; +} + +@end diff --git a/Stripe3DS2/Stripe3DS2/STDSStackView.h b/Stripe3DS2/Stripe3DS2/STDSStackView.h new file mode 100644 index 00000000..cfedb01a --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSStackView.h @@ -0,0 +1,56 @@ +// +// STDSStackView.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, STDSStackViewLayoutAxis) { + + /// A horizontal layout for the stack view to use. + STDSStackViewLayoutAxisHorizontal = 0, + + /// A vertical layout for the stack view to use. + STDSStackViewLayoutAxisVertical = 1, +}; + +@interface STDSStackView: UIView + +/** + Initializes an `STDSStackView`. + + @param alignment The alignment for the stack view to use. + @return An initialized `STDSStackView`. + */ +- (instancetype)initWithAlignment:(STDSStackViewLayoutAxis)alignment; + +/** + Adds a subview to the list of arranged subviews. Views will be displayed in the order they are added. + + @param view The view to add to the stack view. + */ +- (void)addArrangedSubview:(UIView *)view; + +/** + Removes a subview from the list of arranged subviews. + + @param view The view to remove. + */ +- (void)removeArrangedSubview:(UIView *)view; + +/** + Adds a spacer that fits the layout axis of the `STDSStackView`. + + @param dimension How wide or tall the spacer should be, depending on the axis of the `STDSStackView`. + @note Spacers added through this function will not be removed or hidden automatically when they no longer fall between two views. For more precise interactions, add an `STDSSpacerView` manually through `addArrangedSubview:`. + */ +- (void)addSpacer:(CGFloat)dimension; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSStackView.m b/Stripe3DS2/Stripe3DS2/STDSStackView.m new file mode 100644 index 00000000..7ec01d1e --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSStackView.m @@ -0,0 +1,164 @@ +// +// STDSStackView.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSStackView.h" +#import "STDSSpacerView.h" +#import "NSLayoutConstraint+LayoutSupport.h" + +@interface STDSStackView() + +@property (nonatomic) STDSStackViewLayoutAxis layoutAxis; +@property (nonatomic, strong) NSMutableArray *arrangedSubviews; +@property (nonatomic, strong, readonly) NSArray *visibleArrangedSubviews; + +@end + +@implementation STDSStackView + +static NSString *UIViewHiddenKeyPath = @"hidden"; + +- (instancetype)initWithAlignment:(STDSStackViewLayoutAxis)layoutAxis { + self = [super initWithFrame:CGRectZero]; + + if (self) { + _layoutAxis = layoutAxis; + _arrangedSubviews = [NSMutableArray array]; + } + + return self; +} + +- (NSArray *)visibleArrangedSubviews { + return [self.arrangedSubviews filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(UIView *object, NSDictionary *bindings) { + return !object.isHidden; + }]]; +} + +- (void)addArrangedSubview:(UIView *)view { + view.translatesAutoresizingMaskIntoConstraints = false; + + [self _deactivateExistingConstraints]; + + [self.arrangedSubviews addObject:view]; + [self addSubview:view]; + + [self _applyConstraints]; + + [view addObserver:self forKeyPath:UIViewHiddenKeyPath options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL]; +} + +- (void)removeArrangedSubview:(UIView *)view { + if (![self.arrangedSubviews containsObject:view]) { + return; + } + + [self _deactivateExistingConstraints]; + + [view removeObserver:self forKeyPath:UIViewHiddenKeyPath]; + + [self.arrangedSubviews removeObject:view]; + [view removeFromSuperview]; + + [self _applyConstraints]; +} + +- (void)addSpacer:(CGFloat)dimension { + STDSSpacerView *spacerView = [[STDSSpacerView alloc] initWithLayoutAxis:self.layoutAxis dimension:dimension]; + + [self addArrangedSubview:spacerView]; +} + +- (void)dealloc { + for (UIView *view in self.arrangedSubviews) { + [view removeObserver:self forKeyPath:UIViewHiddenKeyPath]; + } +} + +- (void)_applyConstraints { + if (self.layoutAxis == STDSStackViewLayoutAxisHorizontal) { + [self _applyHorizontalConstraints]; + } else { + [self _applyVerticalConstraints]; + } +} + +- (void)_deactivateExistingConstraints { + [NSLayoutConstraint deactivateConstraints:self.constraints]; +} + +- (void)_applyVerticalConstraints { + UIView *previousView; + + for (UIView *view in self.visibleArrangedSubviews) { + NSLayoutConstraint *leftConstraint = [NSLayoutConstraint _stds_leftConstraintWithItem:view toItem:self]; + NSLayoutConstraint *rightConstraint = [NSLayoutConstraint _stds_rightConstraintWithItem:view toItem:self]; + NSLayoutConstraint *topConstraint; + + if (previousView == nil) { + topConstraint = [NSLayoutConstraint _stds_topConstraintWithItem:view toItem:self]; + } else { + topConstraint = [NSLayoutConstraint constraintWithItem:view attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:previousView attribute:NSLayoutAttributeBottom multiplier:1 constant:0]; + } + + [NSLayoutConstraint activateConstraints:@[topConstraint, leftConstraint, rightConstraint]]; + + if (view == self.visibleArrangedSubviews.lastObject) { + NSLayoutConstraint *bottomConstraint = [NSLayoutConstraint _stds_bottomConstraintWithItem:view toItem:self]; + + [NSLayoutConstraint activateConstraints:@[bottomConstraint]]; + } + + previousView = view; + } +} + +- (void)_applyHorizontalConstraints { + UIView *previousView; + NSLayoutConstraint *previousRightConstraint; + + for (UIView *view in self.visibleArrangedSubviews) { + NSLayoutConstraint *topConstraint = [NSLayoutConstraint _stds_topConstraintWithItem:view toItem:self]; + NSLayoutConstraint *bottomConstraint = [NSLayoutConstraint _stds_bottomConstraintWithItem:view toItem:self]; + NSLayoutConstraint *rightConstraint = [NSLayoutConstraint _stds_rightConstraintWithItem:view toItem:self]; + + if (previousView == nil) { + NSLayoutConstraint *leftConstraint = [NSLayoutConstraint _stds_leftConstraintWithItem:view toItem:self]; + + [NSLayoutConstraint activateConstraints:@[topConstraint, leftConstraint, rightConstraint, bottomConstraint]]; + } else { + NSLayoutConstraint *leftConstraint = [NSLayoutConstraint constraintWithItem:view attribute:NSLayoutAttributeLeft relatedBy:NSLayoutRelationEqual toItem:previousView attribute:NSLayoutAttributeRight multiplier:1 constant:0]; + + if (previousRightConstraint != nil) { + [NSLayoutConstraint deactivateConstraints:@[previousRightConstraint]]; + } + + NSLayoutConstraint *previousConstraint = [NSLayoutConstraint constraintWithItem:previousView attribute:NSLayoutAttributeRight relatedBy:NSLayoutRelationEqual toItem:view attribute:NSLayoutAttributeLeft multiplier:1 constant:0]; + + [NSLayoutConstraint activateConstraints:@[topConstraint, leftConstraint, rightConstraint, previousConstraint, bottomConstraint]]; + } + + previousView = view; + previousRightConstraint = rightConstraint; + } +} + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { + if ([object isKindOfClass:[UIView class]] && [keyPath isEqualToString:UIViewHiddenKeyPath]) { + BOOL hiddenStatusChanged = [change[NSKeyValueChangeNewKey] boolValue] != [change[NSKeyValueChangeOldKey] boolValue]; + + if (hiddenStatusChanged) { + [self _deactivateExistingConstraints]; + + [self _applyConstraints]; + } + } else { + [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; + } +} + +@end diff --git a/Stripe3DS2/Stripe3DS2/STDSSynchronousLocationManager.h b/Stripe3DS2/Stripe3DS2/STDSSynchronousLocationManager.h new file mode 100644 index 00000000..883c6a35 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSSynchronousLocationManager.h @@ -0,0 +1,26 @@ +// +// STDSSynchronousLocationManager.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/23/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +@class CLLocation; + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSSynchronousLocationManager : NSObject + ++ (instancetype)sharedManager; + ++ (BOOL)hasPermissions; + +// May be long running. Will return nil on failure or if app lacks permissions +- (nullable CLLocation *)deviceLocation; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSSynchronousLocationManager.m b/Stripe3DS2/Stripe3DS2/STDSSynchronousLocationManager.m new file mode 100644 index 00000000..c492c6fa --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSSynchronousLocationManager.m @@ -0,0 +1,110 @@ +// +// STDSSynchronousLocationManager.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/23/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSSynchronousLocationManager.h" + +#import + +NS_ASSUME_NONNULL_BEGIN + +static const int64_t kLocationFetchTimeoutSeconds = 15; + +typedef void (^LocationUpdateCompletionBlock)(CLLocation * _Nullable); + +@interface STDSSynchronousLocationManager () + +@end + +@implementation STDSSynchronousLocationManager +{ + CLLocationManager * _Nullable _locationManager; + dispatch_queue_t _Nullable _locationFetchQueue; + NSMutableArray *_pendingLocationUpdateCompletions; +} + ++ (instancetype)sharedManager { + static STDSSynchronousLocationManager *sharedManager = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedManager = [[STDSSynchronousLocationManager alloc] init]; + }); + return sharedManager; +} + ++ (BOOL)hasPermissions { + CLAuthorizationStatus authorizationStatus = [CLLocationManager authorizationStatus]; + return [CLLocationManager locationServicesEnabled] && + (authorizationStatus == kCLAuthorizationStatusAuthorizedAlways || authorizationStatus == kCLAuthorizationStatusAuthorizedWhenInUse); +} + +- (instancetype)init { + self = [super init]; + if (self) { + if ([STDSSynchronousLocationManager hasPermissions]) { + _locationManager = [[CLLocationManager alloc] init]; + _locationManager.delegate = self; + _locationFetchQueue = dispatch_queue_create("com.stripe.3ds2locationqueue", DISPATCH_QUEUE_SERIAL); + } + _pendingLocationUpdateCompletions = [NSMutableArray array]; + } + + return self; +} + +- (nullable CLLocation *)deviceLocation { + + __block CLLocation *location = nil; + dispatch_group_t group = dispatch_group_create(); + dispatch_group_enter(group); + [self _fetchDeviceLocation:^(CLLocation * _Nullable latestLocation) { + location = latestLocation; + dispatch_group_leave(group); + }]; + + dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC * kLocationFetchTimeoutSeconds)); + return location; +} + +- (void)_fetchDeviceLocation:(void (^)(CLLocation * _Nullable))completion { + + if (![STDSSynchronousLocationManager hasPermissions] || _locationFetchQueue == nil) { + return completion(nil); + } + + dispatch_async(_locationFetchQueue, ^{ + [self->_pendingLocationUpdateCompletions addObject:completion]; + + if (self->_pendingLocationUpdateCompletions.count == 1) { + [self->_locationManager requestLocation]; + } + }); +} + +- (void)_stopUpdatingLocationAndReportResult:(nullable CLLocation *)location { + [_locationManager stopUpdatingLocation]; + + dispatch_async(_locationFetchQueue, ^{ + for (LocationUpdateCompletionBlock completion in self->_pendingLocationUpdateCompletions) { + completion(location); + } + [self->_pendingLocationUpdateCompletions removeAllObjects]; + }); +} + +#pragma mark - CLLocationManagerDelegate +- (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray *)locations { + [self _stopUpdatingLocationAndReportResult:locations.firstObject]; +} + +- (void)locationManager:(CLLocationManager *)manager didFailWithError:(NSError *)error { + [self _stopUpdatingLocationAndReportResult:nil]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSTextChallengeView.h b/Stripe3DS2/Stripe3DS2/STDSTextChallengeView.h new file mode 100644 index 00000000..d44ea54a --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSTextChallengeView.h @@ -0,0 +1,26 @@ +// +// STDSTextChallengeView.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/5/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import "STDSTextFieldCustomization.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSTextField: UITextField + +@end + +@interface STDSTextChallengeView : UIView + +@property (nonatomic, strong, nullable) STDSTextFieldCustomization *textFieldCustomization; +@property (nonatomic, copy, readonly, nullable) NSString *inputText; +@property (nonatomic, strong) STDSTextField *textField; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSTextChallengeView.m b/Stripe3DS2/Stripe3DS2/STDSTextChallengeView.m new file mode 100644 index 00000000..e3d87d9a --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSTextChallengeView.m @@ -0,0 +1,125 @@ +// +// STDSTextChallengeView.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/5/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSTextChallengeView.h" +#import "STDSStackView.h" +#import "UIView+LayoutSupport.h" +#import "NSString+EmptyChecking.h" +#import "UIColor+ThirteenSupport.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSTextField + +static const CGFloat kTextFieldMargin = (CGFloat)8.0; + +- (CGRect)textRectForBounds:(CGRect)bounds { + return CGRectInset(bounds, kTextFieldMargin, 0); +} + +- (CGRect)editingRectForBounds:(CGRect)bounds { + return CGRectInset(bounds, kTextFieldMargin, 0); +} + +- (nullable NSString *)accessibilityIdentifier { + return @"STDSTextField"; +} + +@end + +@interface STDSTextChallengeView() + +@property (nonatomic, strong) STDSStackView *containerView; +@property (nonatomic, strong) NSLayoutConstraint *borderViewHeightConstraint; + +@end + +@implementation STDSTextChallengeView + +static const CGFloat kBorderViewHeight = 1; +static const CGFloat kTextFieldKernSpacing = 3; +static const CGFloat kTextFieldPlaceholderKernSpacing = 14; +static const CGFloat kTextChallengeViewBottomPadding = 11; + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + + if (self) { + [self _setupViewHierarchy]; + } + + return self; +} + +- (void)_setupViewHierarchy { + self.layoutMargins = UIEdgeInsetsMake(0, 0, kTextChallengeViewBottomPadding, 0); + + self.containerView = [[STDSStackView alloc] initWithAlignment:STDSStackViewLayoutAxisVertical]; + + self.textField = [[STDSTextField alloc] init]; + self.textField.autocorrectionType = UITextAutocorrectionTypeNo; + self.textField.autocapitalizationType = UITextAutocapitalizationTypeNone; + self.textField.delegate = self; + self.textField.clearButtonMode = UITextFieldViewModeWhileEditing; + self.textField.textContentType = UITextContentTypeOneTimeCode; + [self.textField.defaultTextAttributes setValue:@(kTextFieldKernSpacing) forKey:NSKernAttributeName]; + + UIView *borderView = [UIView new]; + borderView.backgroundColor = [UIColor _stds_colorWithDynamicProvider:^UIColor * _Nonnull(UITraitCollection * _Nonnull traitCollection) { + return [[UIColor _stds_systemGray2Color] colorWithAlphaComponent:(CGFloat)0.6]; + }]; + + [self.containerView addArrangedSubview:self.textField]; + [self.containerView addArrangedSubview:borderView]; + [self addSubview:self.containerView]; + [self.containerView _stds_pinToSuperviewBounds]; + + self.borderViewHeightConstraint = [NSLayoutConstraint constraintWithItem:borderView attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0 constant:kBorderViewHeight]; + [NSLayoutConstraint activateConstraints:@[self.borderViewHeightConstraint]]; +} + +- (void)setTextFieldCustomization:(STDSTextFieldCustomization * _Nullable)textFieldCustomization { + _textFieldCustomization = textFieldCustomization; + + self.textField.font = textFieldCustomization.font; + self.textField.textColor = textFieldCustomization.textColor; + self.textField.layer.borderColor = textFieldCustomization.borderColor.CGColor; + self.textField.layer.borderWidth = textFieldCustomization.borderWidth; + self.textField.layer.cornerRadius = textFieldCustomization.cornerRadius; + self.textField.keyboardAppearance = textFieldCustomization.keyboardAppearance; + NSDictionary *placeholderTextAttributes = @{ + NSKernAttributeName: @(kTextFieldPlaceholderKernSpacing), + NSBaselineOffsetAttributeName: @(3.0f), + NSForegroundColorAttributeName: textFieldCustomization.placeholderTextColor, + }; + self.textField.attributedPlaceholder = [[NSAttributedString alloc] initWithString:@"••••••" attributes:placeholderTextAttributes]; +} + +- (NSString * _Nullable)inputText { + return self.textField.text; +} + +- (void)didMoveToWindow { + [super didMoveToWindow]; + + if (self.window.screen.nativeScale > 0) { + self.borderViewHeightConstraint.constant = kBorderViewHeight / self.window.screen.nativeScale; + } +} + +#pragma mark - UITextFieldDelegate + +- (BOOL)textFieldShouldReturn:(UITextField *)textField { + [textField resignFirstResponder]; + + return NO; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSThreeDSProtocolVersion+Private.h b/Stripe3DS2/Stripe3DS2/STDSThreeDSProtocolVersion+Private.h new file mode 100644 index 00000000..43257e78 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSThreeDSProtocolVersion+Private.h @@ -0,0 +1,53 @@ +// +// STDSThreeDSProtocolVersion+Private.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 3/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSThreeDSProtocolVersion.h" + +NS_ASSUME_NONNULL_BEGIN + + +typedef NS_ENUM(NSInteger, STDSThreeDSProtocolVersion) { + STDSThreeDSProtocolVersion2_1_0, + STDSThreeDSProtocolVersion2_2_0, + STDSThreeDSProtocolVersionUnknown, + STDSThreeDSProtocolVersionFallbackTest, +}; + +static NSString * const kThreeDS2ProtocolVersion2_1_0 = @"2.1.0"; +static NSString * const kThreeDS2ProtocolVersion2_2_0 = @"2.2.0"; +static NSString * const kThreeDSProtocolVersionFallbackTest = @"2.0.0"; + +NS_INLINE STDSThreeDSProtocolVersion STDSThreeDSProtocolVersionForString(NSString *stringValue) { + if ([stringValue isEqualToString:kThreeDS2ProtocolVersion2_1_0]) { + return STDSThreeDSProtocolVersion2_1_0; + } else if ([stringValue isEqualToString:kThreeDS2ProtocolVersion2_2_0]) { + return STDSThreeDSProtocolVersion2_2_0; + } else if ([stringValue isEqualToString:kThreeDSProtocolVersionFallbackTest]) { + return STDSThreeDSProtocolVersionFallbackTest; + } else { + return STDSThreeDSProtocolVersionUnknown; + } +} + +NS_INLINE NSString * _Nullable STDSThreeDSProtocolVersionStringValue(STDSThreeDSProtocolVersion protocolVersion) { + switch (protocolVersion) { + case STDSThreeDSProtocolVersion2_1_0: + return kThreeDS2ProtocolVersion2_1_0; + + case STDSThreeDSProtocolVersion2_2_0: + return kThreeDS2ProtocolVersion2_2_0; + + case STDSThreeDSProtocolVersionFallbackTest: + return kThreeDSProtocolVersionFallbackTest; + + case STDSThreeDSProtocolVersionUnknown: + return nil; + } +} + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSThreeDSProtocolVersion.m b/Stripe3DS2/Stripe3DS2/STDSThreeDSProtocolVersion.m new file mode 100644 index 00000000..3d275b14 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSThreeDSProtocolVersion.m @@ -0,0 +1,15 @@ +// +// STDSThreeDSProtocolVersion.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 6/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSThreeDSProtocolVersion+Private.h" + +NS_ASSUME_NONNULL_BEGIN + +NSString * const Stripe3DS2ProtocolVersion = @"2.1.0"; + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSTransaction+Private.h b/Stripe3DS2/Stripe3DS2/STDSTransaction+Private.h new file mode 100644 index 00000000..2c35e956 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSTransaction+Private.h @@ -0,0 +1,41 @@ +// +// STDSTransaction+Private.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/22/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSTransaction.h" + +@class STDSDeviceInformation; +@class STDSDirectoryServerCertificate; + +#import "STDSDirectoryServer.h" +#import "STDSThreeDSProtocolVersion+Private.h" +#import "STDSUICustomization.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSTransaction () + +- (instancetype)initWithDeviceInformation:(STDSDeviceInformation *)deviceInformation + directoryServer:(STDSDirectoryServer)directoryServer + protocolVersion:(STDSThreeDSProtocolVersion)protocolVersion + uiCustomization:(STDSUICustomization *)uiCustomization; + +- (instancetype)initWithDeviceInformation:(STDSDeviceInformation *)deviceInformation + directoryServerID:(NSString *)directoryServerID + serverKeyID:(nullable NSString *)serverKeyID + directoryServerCertificate:(STDSDirectoryServerCertificate *)directoryServerCertificate + rootCertificateStrings:(NSArray *)rootCertificateStrings + protocolVersion:(STDSThreeDSProtocolVersion)protocolVersion + uiCustomization:(STDSUICustomization *)uiCustomization; + +@property (nonatomic, strong) NSTimer *timeoutTimer; +@property (nonatomic) BOOL bypassTestModeVerification; // Should be used during internal testing ONLY +@property (nonatomic) BOOL useULTestLOA; // Should only be used when running tests with the UL reference app + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSWebView.h b/Stripe3DS2/Stripe3DS2/STDSWebView.h new file mode 100644 index 00000000..7e6673fc --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSWebView.h @@ -0,0 +1,22 @@ +// +// STDSWebView.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/13/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSWebView : WKWebView + +/** + Convenience method that prepends the given HTML string with a CSP meta tag that disables external resource loading, and passes it to `loadHTMLString:baseURL:`. + */ +- (WKNavigation *)loadExternalResourceBlockingHTMLString:(NSString *)html; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSWebView.m b/Stripe3DS2/Stripe3DS2/STDSWebView.m new file mode 100644 index 00000000..d30da6bc --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSWebView.m @@ -0,0 +1,37 @@ +// +// STDSWebView.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/13/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSWebView.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSWebView + +- (instancetype)init { + WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init]; + configuration.preferences.javaScriptEnabled = NO; + return [super initWithFrame:CGRectZero configuration:configuration]; +} + +/// Overriden to do nothing per 3DS2 security guidelines. +- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)(_Nullable id, NSError * _Nullable error))completionHandler { + +} + +- (WKNavigation *)loadExternalResourceBlockingHTMLString:(NSString *)html { + NSString *cspMetaTag = @""; + return [self loadHTMLString:[cspMetaTag stringByAppendingString:html] baseURL:nil]; +} + +- (nullable NSString *)accessibilityIdentifier { + return @"STDSWebView"; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSWhitelistView.h b/Stripe3DS2/Stripe3DS2/STDSWhitelistView.h new file mode 100644 index 00000000..a744f2b8 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSWhitelistView.h @@ -0,0 +1,25 @@ +// +// STDSWhitelistView.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/11/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import "STDSChallengeResponseSelectionInfo.h" +#import "STDSLabelCustomization.h" +#import "STDSSelectionCustomization.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSWhitelistView : UIView + +@property (nonatomic, strong, nullable) NSString *whitelistText; +@property (nonatomic, readonly, nullable) id selectedResponse; +@property (nonatomic, strong, nullable) STDSLabelCustomization *labelCustomization; +@property (nonatomic, strong, nullable) STDSSelectionCustomization *selectionCustomization; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/STDSWhitelistView.m b/Stripe3DS2/Stripe3DS2/STDSWhitelistView.m new file mode 100644 index 00000000..a5181fa0 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/STDSWhitelistView.m @@ -0,0 +1,103 @@ +// +// STDSWhitelistView.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/11/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSLocalizedString.h" +#import "STDSWhitelistView.h" +#import "STDSStackView.h" +#import "STDSChallengeResponseSelectionInfoObject.h" +#import "NSString+EmptyChecking.h" +#import "UIView+LayoutSupport.h" +#import "STDSSelectionButton.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSWhitelistView() + +@property (nonatomic, strong) UILabel *whitelistLabel; +@property (nonatomic, strong) STDSSelectionButton *selectionButton; + +@end + +@implementation STDSWhitelistView + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + + if (self) { + [self _setupViewHierarchy]; + } + + return self; +} + +- (void)_setupViewHierarchy { + self.layoutMargins = UIEdgeInsetsZero; + + STDSStackView *containerView = [[STDSStackView alloc] initWithAlignment:STDSStackViewLayoutAxisVertical]; + [self addSubview:containerView]; + [containerView _stds_pinToSuperviewBounds]; + + self.whitelistLabel = [[UILabel alloc] init]; + self.whitelistLabel.numberOfLines = 0; + + self.selectionButton = [[STDSSelectionButton alloc] initWithCustomization:self.selectionCustomization]; + self.selectionButton.isCheckbox = YES; + [self.selectionButton addTarget:self action:@selector(_selectionButtonWasTapped) forControlEvents:UIControlEventTouchUpInside]; + + UIStackView *stackView = [self _buildStackView]; + [stackView addArrangedSubview:self.selectionButton]; + [stackView addArrangedSubview:self.whitelistLabel]; + + [containerView addArrangedSubview:stackView]; +} + +- (void)setWhitelistText:(NSString * _Nullable)whitelistText { + _whitelistText = whitelistText; + + self.whitelistLabel.text = whitelistText; + self.whitelistLabel.hidden = [NSString _stds_isStringEmpty:whitelistText]; + self.selectionButton.hidden = self.whitelistLabel.hidden; +} + +- (id _Nullable)selectedResponse { + if (self.selectionButton.selected) { + return [[STDSChallengeResponseSelectionInfoObject alloc] initWithName:@"Y" value:STDSLocalizedString(@"Yes", @"The yes answer to a yes or no question.")];; + } + + return [[STDSChallengeResponseSelectionInfoObject alloc] initWithName:@"N" value:STDSLocalizedString(@"No", @"The no answer to a yes or no question.")]; +} + +- (void)setLabelCustomization:(STDSLabelCustomization * _Nullable)labelCustomization { + _labelCustomization = labelCustomization; + + self.whitelistLabel.font = labelCustomization.font; + self.whitelistLabel.textColor = labelCustomization.textColor; +} + +- (void)setSelectionCustomization:(STDSSelectionCustomization * _Nullable)selectionCustomization { + _selectionCustomization = selectionCustomization; + self.selectionButton.customization = selectionCustomization; +} + +- (UIStackView *)_buildStackView { + UIStackView *stackView = [[UIStackView alloc] init]; + stackView.axis = UILayoutConstraintAxisHorizontal; + stackView.distribution = UIStackViewDistributionFillProportionally; + stackView.alignment = UIStackViewAlignmentCenter; + stackView.spacing = 20; + stackView.translatesAutoresizingMaskIntoConstraints = NO; + return stackView; +} + +- (void)_selectionButtonWasTapped { + self.selectionButton.selected = !self.selectionButton.selected; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/Stripe3DS2-Bridging-Header.h b/Stripe3DS2/Stripe3DS2/Stripe3DS2-Bridging-Header.h new file mode 100644 index 00000000..cfc1861e --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/Stripe3DS2-Bridging-Header.h @@ -0,0 +1,12 @@ +// +// Stripe3DS2-Bridging-Header.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 4/10/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#ifndef Stripe3DS2_Bridging_Header_h +#define Stripe3DS2_Bridging_Header_h + +#endif /* Stripe3DS2_Bridging_Header_h */ diff --git a/Stripe3DS2/Stripe3DS2/UIButton+CustomInitialization.h b/Stripe3DS2/Stripe3DS2/UIButton+CustomInitialization.h new file mode 100644 index 00000000..badb4d6d --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/UIButton+CustomInitialization.h @@ -0,0 +1,21 @@ +// +// UIButton+CustomInitialization.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/18/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSUICustomization.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface UIButton (CustomInitialization) + ++ (UIButton *)_stds_buttonWithTitle:(NSString * _Nullable)title customization:(STDSButtonCustomization * _Nullable)customization; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/UIButton+CustomInitialization.m b/Stripe3DS2/Stripe3DS2/UIButton+CustomInitialization.m new file mode 100644 index 00000000..cd289e81 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/UIButton+CustomInitialization.m @@ -0,0 +1,64 @@ +// +// UIButton+CustomInitialization.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/18/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "UIButton+CustomInitialization.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation UIButton (CustomInitialization) + +static const CGFloat kDefaultButtonContentInset = (CGFloat)12.0; + ++ (UIButton *)_stds_buttonWithTitle:(NSString * _Nullable)title customization:(STDSButtonCustomization * _Nullable)customization { + UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem]; + button.clipsToBounds = YES; + button.contentEdgeInsets = UIEdgeInsetsMake(kDefaultButtonContentInset, 0, kDefaultButtonContentInset, 0); + [[self class] _stds_configureButton:button withTitle:title customization:customization]; + + return button; +} + ++ (void)_stds_configureButton:(UIButton *)button withTitle:(NSString * _Nullable)buttonTitle customization:(STDSButtonCustomization * _Nullable)buttonCustomization { + button.backgroundColor = buttonCustomization.backgroundColor; + button.layer.cornerRadius = buttonCustomization.cornerRadius; + + UIFont *font = buttonCustomization.font; + UIColor *textColor = buttonCustomization.textColor; + + if (buttonTitle != nil) { + NSMutableDictionary *attributesDictionary = [NSMutableDictionary dictionary]; + + if (font != nil) { + attributesDictionary[NSFontAttributeName] = font; + } + + if (textColor != nil) { + attributesDictionary[NSForegroundColorAttributeName] = textColor; + } + switch (buttonCustomization.titleStyle) { + case STDSButtonTitleStyleDefault: + break; + case STDSButtonTitleStyleSentenceCapitalized: + buttonTitle = [buttonTitle localizedCapitalizedString]; + break; + case STDSButtonTitleStyleLowercase: + buttonTitle = [buttonTitle localizedLowercaseString]; + break; + case STDSButtonTitleStyleUppercase: + buttonTitle = [buttonTitle localizedUppercaseString]; + break; + } + + NSAttributedString *title = [[NSAttributedString alloc] initWithString:buttonTitle attributes:attributesDictionary]; + [button setAttributedTitle:title forState:UIControlStateNormal]; + } +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/UIColor+DefaultColors.h b/Stripe3DS2/Stripe3DS2/UIColor+DefaultColors.h new file mode 100644 index 00000000..3c45eb9c --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/UIColor+DefaultColors.h @@ -0,0 +1,21 @@ +// +// UIColor+DefaultColors.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/18/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface UIColor (DefaultColors) + +/// The challenge view footer background color ++ (UIColor *)_stds_defaultFooterBackgroundColor; ++ (UIColor *)_stds_blueColor; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/UIColor+DefaultColors.m b/Stripe3DS2/Stripe3DS2/UIColor+DefaultColors.m new file mode 100644 index 00000000..8c33d519 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/UIColor+DefaultColors.m @@ -0,0 +1,24 @@ +// +// UIColor+DefaultColors.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/18/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "UIColor+DefaultColors.h" +#import "UIColor+ThirteenSupport.h" + +@implementation UIColor (DefaultColors) + ++ (UIColor *)_stds_defaultFooterBackgroundColor { + return [UIColor _stds_systemGray5Color]; +} + ++ (UIColor *)_stds_blueColor { + return [UIColor _stds_colorWithDynamicProvider:^UIColor * _Nonnull(UITraitCollection * _Nonnull traitCollection) { + return (traitCollection.userInterfaceStyle == UIUserInterfaceStyleLight) ? [UIColor colorWithRed:(CGFloat)(29.0 / 255.0) green:(CGFloat)(115.0 / 255.0) blue:(CGFloat)(250.0 / 255.0) alpha:1.0] : [UIColor colorWithRed:(CGFloat)(39.0 / 255.0) green:(CGFloat)(125.0 / 255.0) blue:(CGFloat)(255.0 / 255.0) alpha:1.0]; + }]; +} + +@end diff --git a/Stripe3DS2/Stripe3DS2/UIColor+ThirteenSupport.h b/Stripe3DS2/Stripe3DS2/UIColor+ThirteenSupport.h new file mode 100644 index 00000000..fdccd761 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/UIColor+ThirteenSupport.h @@ -0,0 +1,24 @@ +// +// UIColor+ThirteenSupport.h +// Stripe3DS2 +// +// Created by David Estes on 8/21/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface UIColor (STDSThirteenSupport) + ++ (UIColor *)_stds_colorWithDynamicProvider:(UIColor * _Nonnull (^)(UITraitCollection *traitCollection))dynamicProvider; ++ (UIColor *)_stds_systemGray5Color; ++ (UIColor *)_stds_systemGray2Color; ++ (UIColor *)_stds_systemBackgroundColor; ++ (UIColor *)_stds_labelColor; + + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/UIColor+ThirteenSupport.m b/Stripe3DS2/Stripe3DS2/UIColor+ThirteenSupport.m new file mode 100644 index 00000000..ffff47be --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/UIColor+ThirteenSupport.m @@ -0,0 +1,33 @@ +// +// UIColor+ThirteenSupport.m +// Stripe3DS2 +// +// Created by David Estes on 8/21/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "UIColor+ThirteenSupport.h" + +@implementation UIColor (STDSThirteenSupport) + ++ (UIColor *)_stds_colorWithDynamicProvider:(UIColor * _Nonnull (^)(UITraitCollection *traitCollection))dynamicProvider { + return [UIColor colorWithDynamicProvider:dynamicProvider]; +} + ++ (UIColor *)_stds_systemGray5Color { + return [UIColor systemGray5Color]; +} + ++ (UIColor *)_stds_systemGray2Color { + return [UIColor systemGray2Color]; +} + ++ (UIColor *)_stds_systemBackgroundColor { + return [UIColor systemBackgroundColor]; +} + ++ (UIColor *)_stds_labelColor { + return [UIColor labelColor]; +} + +@end diff --git a/Stripe3DS2/Stripe3DS2/UIFont+DefaultFonts.h b/Stripe3DS2/Stripe3DS2/UIFont+DefaultFonts.h new file mode 100644 index 00000000..ceb5bfa9 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/UIFont+DefaultFonts.h @@ -0,0 +1,23 @@ +// +// UIFont+DefaultFonts.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/18/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface UIFont (DefaultFonts) + ++ (UIFont *)_stds_defaultHeadingTextFont; + ++ (UIFont *)_stds_defaultLabelTextFontWithScale:(CGFloat)scale; ++ (UIFont *)_stds_defaultButtonTextFontWithScale:(CGFloat)scale; ++ (UIFont *)_stds_defaultBoldLabelTextFontWithScale:(CGFloat)scale; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/UIFont+DefaultFonts.m b/Stripe3DS2/Stripe3DS2/UIFont+DefaultFonts.m new file mode 100644 index 00000000..26e10ef7 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/UIFont+DefaultFonts.m @@ -0,0 +1,41 @@ +// +// UIFont+DefaultFonts.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/18/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "UIFont+DefaultFonts.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation UIFont (DefaultFonts) + ++ (UIFont *)_stds_defaultHeadingTextFont { + UIFontDescriptor *fontDescriptor = [[UIFontDescriptor preferredFontDescriptorWithTextStyle:UIFontTextStyleHeadline] fontDescriptorWithSymbolicTraits:UIFontDescriptorTraitBold]; + + return [UIFont fontWithDescriptor:fontDescriptor size:fontDescriptor.pointSize * (CGFloat)1.1]; +} + ++ (UIFont *)_stds_defaultLabelTextFontWithScale:(CGFloat)scale { + UIFontDescriptor *fontDescriptor = [UIFontDescriptor preferredFontDescriptorWithTextStyle:UIFontTextStyleBody]; + + return [UIFont fontWithDescriptor:fontDescriptor size:fontDescriptor.pointSize * scale]; +} + ++ (UIFont *)_stds_defaultBoldLabelTextFontWithScale:(CGFloat)scale { + UIFontDescriptor *fontDescriptor = [[UIFontDescriptor preferredFontDescriptorWithTextStyle:UIFontTextStyleBody] fontDescriptorWithSymbolicTraits:UIFontDescriptorTraitBold]; + + return [UIFont fontWithDescriptor:fontDescriptor size:fontDescriptor.pointSize * scale]; +} + ++ (UIFont *)_stds_defaultButtonTextFontWithScale:(CGFloat)scale { + UIFontDescriptor *fontDescriptor = [UIFontDescriptor preferredFontDescriptorWithTextStyle:UIFontTextStyleHeadline]; + + return [UIFont fontWithDescriptor:fontDescriptor size:fontDescriptor.pointSize * scale]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/UIView+LayoutSupport.h b/Stripe3DS2/Stripe3DS2/UIView+LayoutSupport.h new file mode 100644 index 00000000..2c849dee --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/UIView+LayoutSupport.h @@ -0,0 +1,24 @@ +// +// UIView+LayoutSupport.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface UIView (LayoutSupport) + +/** + Pins the view to its superview's bounds. + */ +- (void)_stds_pinToSuperviewBounds; + +- (void)_stds_pinToSuperviewBoundsWithoutMargin; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/UIView+LayoutSupport.m b/Stripe3DS2/Stripe3DS2/UIView+LayoutSupport.m new file mode 100644 index 00000000..374088b0 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/UIView+LayoutSupport.m @@ -0,0 +1,35 @@ +// +// UIView+LayoutSupport.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 2/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "UIView+LayoutSupport.h" + +@implementation UIView (LayoutSupport) + +- (void)_stds_pinToSuperviewBounds { + self.translatesAutoresizingMaskIntoConstraints = false; + + NSLayoutConstraint *topConstraint = [NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self.superview attribute:NSLayoutAttributeTopMargin multiplier:1 constant:0]; + NSLayoutConstraint *bottomConstraint = [NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:self.superview attribute:NSLayoutAttributeBottomMargin multiplier:1 constant:0]; + NSLayoutConstraint *leftConstraint = [NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeLeft relatedBy:NSLayoutRelationEqual toItem:self.superview attribute:NSLayoutAttributeLeftMargin multiplier:1 constant:0]; + NSLayoutConstraint *rightConstraint = [NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeRight relatedBy:NSLayoutRelationEqual toItem:self.superview attribute:NSLayoutAttributeRightMargin multiplier:1 constant:0]; + + [NSLayoutConstraint activateConstraints:@[topConstraint, bottomConstraint, leftConstraint, rightConstraint]]; +} + +- (void)_stds_pinToSuperviewBoundsWithoutMargin { + self.translatesAutoresizingMaskIntoConstraints = false; + + NSLayoutConstraint *topConstraint = [NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self.superview attribute:NSLayoutAttributeTop multiplier:1 constant:0]; + NSLayoutConstraint *bottomConstraint = [NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:self.superview attribute:NSLayoutAttributeBottom multiplier:1 constant:0]; + NSLayoutConstraint *leftConstraint = [NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeLeft relatedBy:NSLayoutRelationEqual toItem:self.superview attribute:NSLayoutAttributeLeft multiplier:1 constant:0]; + NSLayoutConstraint *rightConstraint = [NSLayoutConstraint constraintWithItem:self attribute:NSLayoutAttributeRight relatedBy:NSLayoutRelationEqual toItem:self.superview attribute:NSLayoutAttributeRight multiplier:1 constant:0]; + + [NSLayoutConstraint activateConstraints:@[topConstraint, bottomConstraint, leftConstraint, rightConstraint]]; +} + +@end diff --git a/Stripe3DS2/Stripe3DS2/UIViewController+Stripe3DS2.h b/Stripe3DS2/Stripe3DS2/UIViewController+Stripe3DS2.h new file mode 100644 index 00000000..130a490f --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/UIViewController+Stripe3DS2.h @@ -0,0 +1,21 @@ +// +// UIViewController+Stripe3DS2.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 5/6/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@class STDSUICustomization; + +@interface UIViewController (Stripe3DS2) + +- (void)_stds_setupNavigationBarElementsWithCustomization:(STDSUICustomization *)customization cancelButtonSelector:(SEL)cancelButtonSelector; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/UIViewController+Stripe3DS2.m b/Stripe3DS2/Stripe3DS2/UIViewController+Stripe3DS2.m new file mode 100644 index 00000000..e681ba95 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/UIViewController+Stripe3DS2.m @@ -0,0 +1,49 @@ +// +// UIViewController+Stripe3DS2.m +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 5/6/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "UIViewController+Stripe3DS2.h" + +#import "UIButton+CustomInitialization.h" +#import "STDSUICustomization.h" + +@implementation UIViewController (Stripe3DS2) + +- (void)_stds_setupNavigationBarElementsWithCustomization:(STDSUICustomization *)customization cancelButtonSelector:(SEL)cancelButtonSelector { + STDSNavigationBarCustomization *navigationBarCustomization = customization.navigationBarCustomization; + + self.navigationController.navigationBar.barStyle = customization.navigationBarCustomization.barStyle; + + // Cancel button + STDSButtonCustomization *cancelButtonCustomization = [customization buttonCustomizationForButtonType:STDSUICustomizationButtonTypeCancel]; + UIButton *cancelButton = [UIButton _stds_buttonWithTitle:navigationBarCustomization.buttonText customization:cancelButtonCustomization]; + // The cancel button's frame has a size of 0 in iOS 8 + cancelButton.frame = CGRectMake(0, 0, cancelButton.intrinsicContentSize.width, cancelButton.intrinsicContentSize.height); + cancelButton.accessibilityIdentifier = @"Cancel"; + [cancelButton addTarget:self action:cancelButtonSelector forControlEvents:UIControlEventTouchUpInside]; + UIBarButtonItem *cancelBarButtonItem = [[UIBarButtonItem alloc] initWithCustomView:cancelButton]; + self.navigationItem.rightBarButtonItem = cancelBarButtonItem; + + // Title + self.title = navigationBarCustomization.headerText; + NSMutableDictionary *titleTextAttributes = [NSMutableDictionary dictionary]; + UIFont *headerFont = navigationBarCustomization.font; + if (headerFont) { + titleTextAttributes[NSFontAttributeName] = headerFont; + } + UIColor *headerColor = navigationBarCustomization.textColor; + if (headerColor) { + titleTextAttributes[NSForegroundColorAttributeName] = headerColor; + } + self.navigationController.navigationBar.titleTextAttributes = titleTextAttributes; + + // Color + self.navigationController.navigationBar.barTintColor = navigationBarCustomization.barTintColor; + self.navigationController.navigationBar.translucent = navigationBarCustomization.translucent; +} + +@end diff --git a/Stripe3DS2/Stripe3DS2/include/STDSAlreadyInitializedException.h b/Stripe3DS2/Stripe3DS2/include/STDSAlreadyInitializedException.h new file mode 100644 index 00000000..c39a85d8 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSAlreadyInitializedException.h @@ -0,0 +1,22 @@ +// +// STDSAlreadyInitializedException.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/22/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSException.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + `STDSAlreadyInitializedException` represents an exception that will be thrown in the `STDSThreeDS2Service` instance has already been initialized. + + @see STDSThreeDS2Service + */ +@interface STDSAlreadyInitializedException : STDSException + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSAlreadyInitializedException.m b/Stripe3DS2/Stripe3DS2/include/STDSAlreadyInitializedException.m new file mode 100644 index 00000000..af161983 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSAlreadyInitializedException.m @@ -0,0 +1,17 @@ +// +// STDSAlreadyInitializedException.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/22/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSAlreadyInitializedException.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSAlreadyInitializedException + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSAuthenticationRequestParameters.h b/Stripe3DS2/Stripe3DS2/include/STDSAuthenticationRequestParameters.h new file mode 100644 index 00000000..766e4857 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSAuthenticationRequestParameters.h @@ -0,0 +1,68 @@ +// +// STDSAuthenticationRequestParameters.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/21/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSJSONEncodable.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSAuthenticationRequestParameters : NSObject + +/** + Designated initializer for `STDSAuthenticationRequestParameters`. + + @param sdkTransactionIdentifier The SDK Transaction Identifier, as created by `[STDSTransaction createTransaction]` + @param deviceData Optional device data collected by the SDK. + @param sdkEphemeralPublicKey The SDK ephemeral public key. + @param sdkAppIdentifier The SDK app identifier. + @param sdkReferenceNumber The SDK reference number. + @param messageVersion The protocol version that is supported by the SDK and used for the transaction. + + @exception InvalidInputException Thrown if an input parameter is invalid. @see InvalidInputException + */ +- (instancetype)initWithSDKTransactionIdentifier:(NSString *)sdkTransactionIdentifier + deviceData:(nullable NSString *)deviceData + sdkEphemeralPublicKey:(NSString *)sdkEphemeralPublicKey + sdkAppIdentifier:(NSString *)sdkAppIdentifier + sdkReferenceNumber:(NSString *)sdkReferenceNumber + messageVersion:(NSString *)messageVersion; + +/** + The encrypted device data as a JWE string. + */ +@property (nonatomic, readonly, nullable) NSString *deviceData; + +/** + The SDK Transaction Identifier. + */ +@property (nonatomic, readonly) NSString *sdkTransactionIdentifier; + +/** + The SDK App Identifier. + */ +@property (nonatomic, readonly) NSString *sdkAppIdentifier; + +/** + The SDK reference number. + */ +@property (nonatomic, readonly) NSString *sdkReferenceNumber; + +/** + The SDK ephemeral public key. + */ +@property (nonatomic, readonly) NSString *sdkEphemeralPublicKey; + +/** + The protocol version that is used for the transaction. + */ +@property (nonatomic, readonly) NSString *messageVersion; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSAuthenticationRequestParameters.m b/Stripe3DS2/Stripe3DS2/include/STDSAuthenticationRequestParameters.m new file mode 100644 index 00000000..dc6286b7 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSAuthenticationRequestParameters.m @@ -0,0 +1,63 @@ +// +// STDSAuthenticationRequestParameters.m +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/21/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSAuthenticationRequestParameters.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSAuthenticationRequestParameters () + +@property (nonatomic, nullable, readonly) NSDictionary *sdkEphemeralPublicKeyJSON; + +@end + +@implementation STDSAuthenticationRequestParameters + +- (instancetype)initWithSDKTransactionIdentifier:(NSString *)sdkTransactionIdentifier + deviceData:(nullable NSString *)deviceData + sdkEphemeralPublicKey:(NSString *)sdkEphemeralPublicKey + sdkAppIdentifier:(NSString *)sdkAppIdentifier + sdkReferenceNumber:(NSString *)sdkReferenceNumber + messageVersion:(NSString *)messageVersion { + self = [super init]; + if (self) { + _sdkTransactionIdentifier = [sdkTransactionIdentifier copy]; + _deviceData = [deviceData copy]; + _sdkEphemeralPublicKey = [sdkEphemeralPublicKey copy]; + _sdkAppIdentifier = [sdkAppIdentifier copy]; + _sdkReferenceNumber = [sdkReferenceNumber copy]; + _messageVersion = [messageVersion copy]; + } + return self; +} + +- (nullable NSDictionary *)sdkEphemeralPublicKeyJSON { + NSData *data = [self.sdkEphemeralPublicKey dataUsingEncoding:NSUTF8StringEncoding]; + if (data == nil) { + return nil; + } + + return [NSJSONSerialization JSONObjectWithData:data options:0 error:NULL]; +} + +#pragma mark - STDSJSONEncodable + ++ (NSDictionary *)propertyNamesToJSONKeysMapping { + return @{ + NSStringFromSelector(@selector(sdkTransactionIdentifier)): @"sdkTransID", + NSStringFromSelector(@selector(deviceData)): @"sdkEncData", + NSStringFromSelector(@selector(sdkEphemeralPublicKeyJSON)): @"sdkEphemPubKey", + NSStringFromSelector(@selector(sdkAppIdentifier)): @"sdkAppID", + NSStringFromSelector(@selector(sdkReferenceNumber)): @"sdkReferenceNumber", + NSStringFromSelector(@selector(messageVersion)): @"messageVersion", + }; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSAuthenticationResponse.h b/Stripe3DS2/Stripe3DS2/include/STDSAuthenticationResponse.h new file mode 100644 index 00000000..d527f38d --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSAuthenticationResponse.h @@ -0,0 +1,115 @@ +// +// STDSAuthenticationResponse.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 2/13/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSJSONDecodable.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + The `STDSACSStatusType` enum defines the status of a transaction, as detailed in + 3DS2 Spec Seq 3.33: + */ +typedef NS_ENUM(NSInteger, STDSACSStatusType) { + /// The status is unknown or invalid + STDSACSStatusTypeUnknown = 0, + + /// Authenticated + STDSACSStatusTypeAuthenticated = 1, + + /// Requires a Cardholder challenge to complete authentication + STDSACSStatusTypeChallengeRequired = 2, + + /// Requires a Cardholder challenge using Decoupled Authentication + STDSACSStatusTypeDecoupledAuthentication = 3, + + /// Not authenticated + STDSACSStatusTypeNotAuthenticated = 4, + + /// Not authenticated, but a proof of authentication attempt (Authentication Value) + /// was generated + STDSACSStatusTypeProofGenerated = 5, + + /// Not authenticated, as authentication could not be performed due to technical or + /// other issue + STDSACSStatusTypeError = 6, + + /// Not authenticated because the Issuer is rejecting authentication and requesting + /// that authorisation not be attempted + STDSACSStatusTypeRejected = 7, + + /// Authentication not requested by the 3DS Server for data sent for informational + /// purposes only + STDSACSStatusTypeInformationalOnly = 8, +}; + +/** + A native protocol representing the response sent by the 3DS Server. + Only parameters relevant to performing 3DS2 authentication in the mobile SDK are exposed. + */ +@protocol STDSAuthenticationResponse + +/// Universally unique transaction identifier assigned by the 3DS Server to identify a single transaction. +@property (nonatomic, readonly) NSString *threeDSServerTransactionID; + +/// Transaction status +@property (nonatomic, readonly) STDSACSStatusType status; + +/// Indication of whether a challenge is required. +@property (nonatomic, readonly, getter=isChallengeRequired) BOOL challengeRequired; + +/// Indicates whether the ACS confirms utilisation of Decoupled Authentication and agrees to utilise Decoupled Authentication to authenticate the Cardholder. +@property (nonatomic, readonly) BOOL willUseDecoupledAuthentication; + +/** + DS assigned ACS identifier. + Each DS can provide a unique ID to each ACS on an individual basis. + */ +@property (nonatomic, readonly, nullable) NSString *acsOperatorID; + +/// Unique identifier assigned by the EMVCo Secretariat upon Testing and Approval. +@property (nonatomic, readonly, nullable) NSString *acsReferenceNumber; + +/// Contains the JWS object (represented as a string) created by the ACS for the ARes message. +@property (nonatomic, readonly, nullable) NSString *acsSignedContent; + +/// Universally Unique transaction identifier assigned by the ACS to identify a single transaction. +@property (nonatomic, readonly) NSString *acsTransactionID; + +/// Fully qualified URL of the ACS to be used for the challenge. +@property (nonatomic, readonly, nullable) NSURL *acsURL; + +/** + Text provided by the ACS/Issuer to Cardholder during a Frictionless or Decoupled transaction. The Issuer can provide information to Cardholder. + For example, “Additional authentication is needed for this transaction, please contact (Issuer Name) at xxx-xxx-xxxx.” + */ +@property (nonatomic, readonly, nullable) NSString *cardholderInfo; + +/// EMVCo-assigned unique identifier to track approved DS. +@property (nonatomic, readonly, nullable) NSString *directoryServerReferenceNumber; + +/// Universally unique transaction identifier assigned by the DS to identify a single transaction. +@property (nonatomic, readonly, nullable) NSString *directoryServerTransactionID; + +/** + Protocol version identifier This shall be the Protocol Version Number of the specification utilised by the system creating this message. + The Message Version Number is set by the 3DS Server which originates the protocol with the AReq message. + The Message Version Number does not change during a 3DS transaction. + */ +@property (nonatomic, readonly) NSString *protocolVersion; + +/// Universally unique transaction identifier assigned by the 3DS SDK to identify a single transaction. +@property (nonatomic, readonly) NSString *sdkTransactionID; + +@end + +/// A utility to parse an STDSAuthenticationResponse from JSON +id _Nullable STDSAuthenticationResponseFromJSON(NSDictionary *json); + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSButtonCustomization.h b/Stripe3DS2/Stripe3DS2/include/STDSButtonCustomization.h new file mode 100644 index 00000000..dd63f982 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSButtonCustomization.h @@ -0,0 +1,83 @@ +// +// STDSButtonCustomization.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/14/19. +// Copyright © 2019 Stripe. All rights reserved. +// +#import +#import + +#import "STDSCustomization.h" + +/// An enum that defines the different types of buttons that are able to be customized. +typedef NS_ENUM(NSInteger, STDSUICustomizationButtonType) { + + /// The submit button type. + STDSUICustomizationButtonTypeSubmit = 0, + + /// The continue button type. + STDSUICustomizationButtonTypeContinue = 1, + + /// The next button type. + STDSUICustomizationButtonTypeNext = 2, + + /// The cancel button type. + STDSUICustomizationButtonTypeCancel = 3, + + /// The resend button type. + STDSUICustomizationButtonTypeResend = 4, +}; + +/// An enumeration of the case transformations that can be applied to the button's title +typedef NS_ENUM(NSInteger, STDSButtonTitleStyle) { + /// Default style, doesn't modify the title + STDSButtonTitleStyleDefault, + + /// Applies localizedUppercaseString to the title + STDSButtonTitleStyleUppercase, + + /// Applies localizedLowercaseString to the title + STDSButtonTitleStyleLowercase, + + /// Applies localizedCapitalizedString to the title + STDSButtonTitleStyleSentenceCapitalized, +}; + +NS_ASSUME_NONNULL_BEGIN + +/// A customization object to use to configure the UI of a button. +@interface STDSButtonCustomization: STDSCustomization + +/// The default settings for the provided button type. ++ (instancetype)defaultSettingsForButtonType:(STDSUICustomizationButtonType)type; + +/** + Initializes an instance of STDSButtonCustomization with the given backgroundColor and colorRadius. + */ +- (instancetype)initWithBackgroundColor:(UIColor *)backgroundColor cornerRadius:(CGFloat)cornerRadius; + +/** + This is unavailable because there are no sensible default property values without a button type. + Use `defaultSettingsForButtonType:` or `initWithBackgroundColor:cornerRadius:` instead. + */ +- (instancetype)init NS_UNAVAILABLE; + +/** + The background color of the button. + The default for .resend and .cancel is clear. + The default for .submit, .continue, and .next is blue. + */ +@property (nonatomic) UIColor *backgroundColor; + +/// The corner radius of the button. Defaults to 8. +@property (nonatomic) CGFloat cornerRadius; + +/** + The capitalization style of the button title + */ +@property (nonatomic) STDSButtonTitleStyle titleStyle; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSButtonCustomization.m b/Stripe3DS2/Stripe3DS2/include/STDSButtonCustomization.m new file mode 100644 index 00000000..a82a6cbe --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSButtonCustomization.m @@ -0,0 +1,69 @@ +// +// STDSButtonCustomization.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/14/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSButtonCustomization.h" + +#import "STDSUICustomization.h" +#import "UIColor+DefaultColors.h" +#import "UIFont+DefaultFonts.h" + +static const CGFloat kDefaultButtonCornerRadius = 8.0; +static const CGFloat kDefaultButtonFontScale = (CGFloat)0.9; + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSButtonCustomization + ++ (instancetype)defaultSettingsForButtonType:(STDSUICustomizationButtonType)type { + UIColor *backgroundColor = [UIColor _stds_blueColor]; + CGFloat cornerRadius = kDefaultButtonCornerRadius; + UIFont *font = [UIFont _stds_defaultBoldLabelTextFontWithScale:kDefaultButtonFontScale]; + UIColor *textColor = UIColor.whiteColor; + switch (type) { + case STDSUICustomizationButtonTypeContinue: + case STDSUICustomizationButtonTypeSubmit: + case STDSUICustomizationButtonTypeNext: + break; + case STDSUICustomizationButtonTypeResend: + backgroundColor = UIColor.clearColor; + textColor = [UIColor _stds_blueColor]; + font = nil; + break; + case STDSUICustomizationButtonTypeCancel: + backgroundColor = UIColor.clearColor; + textColor = nil; + font = nil; + break; + } + STDSButtonCustomization *buttonCustomization = [[self alloc] initWithBackgroundColor:backgroundColor cornerRadius:cornerRadius]; + buttonCustomization.font = font; + buttonCustomization.textColor = textColor; + return buttonCustomization; +} + +- (instancetype)initWithBackgroundColor:(UIColor *)backgroundColor cornerRadius:(CGFloat)cornerRadius { + self = [super init]; + if (self) { + _backgroundColor = backgroundColor; + _cornerRadius = cornerRadius; + } + return self; +} + +- (id)copyWithZone:(nullable NSZone *)zone { + STDSButtonCustomization *copy = [super copyWithZone:zone]; + copy.backgroundColor = self.backgroundColor; + copy.cornerRadius = self.cornerRadius; + copy.titleStyle = self.titleStyle; + + return copy; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSChallengeParameters.h b/Stripe3DS2/Stripe3DS2/include/STDSChallengeParameters.h new file mode 100644 index 00000000..e1aa151c --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSChallengeParameters.h @@ -0,0 +1,61 @@ +// +// STDSChallengeParameters.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 2/13/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +@protocol STDSAuthenticationResponse; + +NS_ASSUME_NONNULL_BEGIN + +/** + `STDSChallengeParameters` contains information from the 3DS Server's + authentication response that are used by the 3DS2 SDK to initiate + the challenge flow. + */ +@interface STDSChallengeParameters : NSObject + +/** + Convenience intiializer to create an instace of `STDSChallengeParameters` from an + `STDSAuthenticationResponse` + */ +- (instancetype)initWithAuthenticationResponse:(id)authResponse; + +/** + Transaction identifier assigned by the 3DS Server to uniquely identify + a transaction. + */ +@property (nonatomic, copy) NSString *threeDSServerTransactionID; + +/** + Transaction identifier assigned by the Access Control Server (ACS) + to uniquely identify a transaction. + */ +@property (nonatomic, copy) NSString *acsTransactionID; + +/** + The reference number of the relevant Access Control Server. + */ +@property (nonatomic, copy) NSString *acsReferenceNumber; + +/** + The encrypted message sent by the Access Control Server + containing the ACS URL, epthemeral public key, and the + 3DS2 SDK ephemeral public key. + */ +@property (nonatomic, copy) NSString *acsSignedContent; + +/** + The URL for the application that is requesting 3DS2 verification. + This property can be optionally set and will be included with the + messages sent to the Directory Server during the challenge flow. + */ +@property (nonatomic, copy, nullable) NSString *threeDSRequestorAppURL; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSChallengeParameters.m b/Stripe3DS2/Stripe3DS2/include/STDSChallengeParameters.m new file mode 100644 index 00000000..ac166cd7 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSChallengeParameters.m @@ -0,0 +1,31 @@ +// +// STDSChallengeParameters.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 2/13/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSChallengeParameters.h" + +#import "STDSAuthenticationResponse.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSChallengeParameters + +- (instancetype)initWithAuthenticationResponse:(id)authResponse { + self = [self init]; + if (self) { + _threeDSServerTransactionID = [authResponse.threeDSServerTransactionID copy]; + _acsTransactionID = [authResponse.acsTransactionID copy]; + _acsReferenceNumber = [authResponse.acsReferenceNumber copy]; + _acsSignedContent = [authResponse.acsSignedContent copy]; + } + + return self; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSChallengeStatusReceiver.h b/Stripe3DS2/Stripe3DS2/include/STDSChallengeStatusReceiver.h new file mode 100644 index 00000000..a352e0c4 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSChallengeStatusReceiver.h @@ -0,0 +1,67 @@ +// +// STDSChallengeStatusReceiver.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/20/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import + +@class STDSTransaction, STDSCompletionEvent, STDSRuntimeErrorEvent, STDSProtocolErrorEvent; + +NS_ASSUME_NONNULL_BEGIN + +/** + Implement the `STDSChallengeStatusReceiver` protocol to receive challenge status notifications at the end of the challenge process. + @see `STDSTransaction.doChallenge` + */ +@protocol STDSChallengeStatusReceiver + +/** + Called when the challenge process is completed. + + @param completionEvent Information about the completion of the challenge process. @see `STDSCompletionEvent` + */ +- (void)transaction:(STDSTransaction *)transaction didCompleteChallengeWithCompletionEvent:(STDSCompletionEvent *)completionEvent; + +/** + Called when the user selects the option to cancel the transaction on the challenge screen. + */ +- (void)transactionDidCancel:(STDSTransaction *)transaction; + +/** + Called when the challenge process reaches or exceeds the timeout interval that was passed to `STDSTransaction.doChallenge` + */ +- (void)transactionDidTimeOut:(STDSTransaction *)transaction; + +/** + Called when the 3DS SDK receives an EMV 3-D Secure protocol-defined error message from the ACS. + + @param protocolErrorEvent The error code and details. @see `STDSProtocolErrorEvent` + */ +- (void)transaction:(STDSTransaction *)transaction didErrorWithProtocolErrorEvent:(STDSProtocolErrorEvent *)protocolErrorEvent; + +/** + Called when the 3DS SDK encounters errors during the challenge process. These errors include all errors except those covered by `didErrorWithProtocolErrorEvent`. + + @param runtimeErrorEvent The error code and details. @see `STDSRuntimeErrorEvent` + */ +- (void)transaction:(STDSTransaction *)transaction didErrorWithRuntimeErrorEvent:(STDSRuntimeErrorEvent *)runtimeErrorEvent; + +@optional + +/** + Optional method that will be called when the transaction displays a new challenge screen. + */ +- (void)transactionDidPresentChallengeScreen:(STDSTransaction *)transaction; + +/** + Optional method for custom dismissal of the challenge view controller. Meant only for internal use by Stripe SDK. + */ +- (void)dismissChallengeViewController:(UIViewController *)challengeViewController forTransaction:(STDSTransaction *)transaction; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSCompletionEvent.h b/Stripe3DS2/Stripe3DS2/include/STDSCompletionEvent.h new file mode 100644 index 00000000..851c71db --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSCompletionEvent.h @@ -0,0 +1,40 @@ +// +// STDSCompletionEvent.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/20/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + `STDSCompletionEvent` contains information about completion of the challenge process. + */ +@interface STDSCompletionEvent : NSObject + +/** + Designated initializer for `STDSCompletionEvent`. + */ +- (instancetype)initWithSDKTransactionIdentifier:(NSString *)identifier transactionStatus:(NSString *)transactionStatus NS_DESIGNATED_INITIALIZER; + +/** + `STDSCompletionEvent` should not be directly initialized. + */ +- (instancetype)init NS_UNAVAILABLE; + +/** + The SDK Transaction ID. + */ +@property (nonatomic, readonly) NSString *sdkTransactionIdentifier; + +/** + The transaction status that was received in the final challenge response. + */ +@property (nonatomic, readonly) NSString *transactionStatus; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSCompletionEvent.m b/Stripe3DS2/Stripe3DS2/include/STDSCompletionEvent.m new file mode 100644 index 00000000..6ee3c43d --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSCompletionEvent.m @@ -0,0 +1,26 @@ +// +// STDSCompletionEvent.m +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/20/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSCompletionEvent.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSCompletionEvent + +- (instancetype)initWithSDKTransactionIdentifier:(NSString *)identifier transactionStatus:(NSString *)transactionStatus { + self = [super init]; + if (self) { + _sdkTransactionIdentifier = [identifier copy]; + _transactionStatus = [transactionStatus copy]; + } + return self; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSConfigParameters.h b/Stripe3DS2/Stripe3DS2/include/STDSConfigParameters.h new file mode 100644 index 00000000..4d77ba5d --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSConfigParameters.h @@ -0,0 +1,96 @@ +// +// STDSConfigParameters.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/22/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + The default group name that will be used to group additional + configuration parameters. + */ +extern NSString * const kSTDSConfigDefaultGroupName; + +/** + `STDSConfigParameters` represents additional configuration parameters + that can be passed to the Stripe3DS2 SDK during initialization. + + There are currently no supported additional parameters and apps can + just pass `[STDSConfigParameters alloc] initWithStandardParameters` + to the `STDSThreeDS2Service` instance. + */ +@interface STDSConfigParameters : NSObject + +/** + Convenience initializer to get an `STDSConfigParameters` instance + with the default expected configuration parameters. + */ +- (instancetype)initWithStandardParameters; + +/** + Adds the parameter to this instance. + + @param paramName The name of the parameter to add + @param paramValue The value of the parameter to add + @param paramGroup The group to which this parameter will be added. If `nil` the parameter will be added to `kSTDSConfigDefaultGroupName` + + @exception STDSInvalidInputException Will throw an `STDSInvalidInputException` if `paramName` or `paramValue` are `nil`. @see STDSInvalidInputException + */ +- (void)addParameterNamed:(NSString *)paramName withValue:(NSString *)paramValue toGroup:(nullable NSString *)paramGroup; + +/** + Adds the parameter to the default group in this instance. + + @param paramName The name of the parameter to add + @param paramValue The value of the parameter to add + + @exception STDSInvalidInputException Will throw an `STDSInvalidInputException` if `paramName` or `paramValue` are `nil`. @see STDSInvalidInputException + */ +- (void)addParameterNamed:(NSString *)paramName withValue:(NSString *)paramValue; + +/** + Returns the value for `paramName` in `paramGroup` or `nil` if the parameter value is not set. + + @param paramName The name of the parameter to return + @param paramGroup The group from which to fetch the parameter value. If `nil` will default to `kSTDSConfigDefaultGroupName` + + @exception STDSInvalidInputException Will throw an `STDSInvalidInputException` if `paramName` is `nil`. @see STDSInvalidInputException + */ +- (nullable NSString *)parameterValue:(NSString *)paramName inGroup:(nullable NSString *)paramGroup; + +/** + Returns the value for `paramName` in the default group or `nil` if the parameter value is not set. + + @param paramName The name of the parameter to return + + @exception STDSInvalidInputException Will throw an `STDSInvalidInputException` if `paramName` is `nil`. @see STDSInvalidInputException + */ +- (nullable NSString *)parameterValue:(NSString *)paramName; + +/** + Removes the specified parameter from the group and returns the value or `nil` if the parameter was not found. + + @param paramName The name of the parameter to remove + @param paramGroup The group from which to remove this parameter. If `nil` will default to `kSTDSConfigDefaultGroupName` + + @exception STDSInvalidInputException Will throw an `STDSInvalidInputException` if `paramName` is `nil`. @see STDSInvalidInputException + */ +- (nullable NSString *)removeParameterNamed:(NSString *)paramName fromGroup:(nullable NSString *)paramGroup; + +/** + Removes the specified parameter from the default group and returns the value or `nil` if the parameter was not found. + + @param paramName The name of the parameter to remove + + @exception STDSInvalidInputException Will throw an `STDSInvalidInputException` if `paramName` is `nil`. @see STDSInvalidInputException + */ +- (nullable NSString *)removeParameterNamed:(NSString *)paramName; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSConfigParameters.m b/Stripe3DS2/Stripe3DS2/include/STDSConfigParameters.m new file mode 100644 index 00000000..e334a5bb --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSConfigParameters.m @@ -0,0 +1,113 @@ +// +// STDSConfigParameters.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/22/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSConfigParameters.h" + +#import "STDSException+Internal.h" +#import "STDSInvalidInputException.h" + +NS_ASSUME_NONNULL_BEGIN + +NSString * const kSTDSConfigDefaultGroupName = @"STDSConfigParameters.group.default"; + +@implementation STDSConfigParameters +{ + NSMutableDictionary *_parameters; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _parameters = [[NSMutableDictionary alloc] init]; + } + + return self; +} + +- (instancetype)initWithStandardParameters { + self = [self init]; + if (self) { + // Nothing for now because we don't have any standard parameters + } + + return self; +} + +- (void)addParameterNamed:(NSString *)paramName withValue:(NSString *)paramValue { + [self _addParameterNamed:paramName withValue:paramValue toGroup:kSTDSConfigDefaultGroupName]; +} + +- (void)addParameterNamed:(NSString *)paramName withValue:(NSString *)paramValue toGroup:(nullable NSString *)paramGroup { + [self _addParameterNamed:paramName withValue:paramValue toGroup:paramGroup ?: kSTDSConfigDefaultGroupName]; +} + +- (void)_addParameterNamed:(NSString *)paramName withValue:(NSString *)paramValue toGroup:(NSString *)paramGroup { + if (paramName == nil) { + @throw [STDSInvalidInputException exceptionWithMessage:@"nil paramName passed to instance %@", self]; + } else if (paramValue == nil) { + @throw [STDSInvalidInputException exceptionWithMessage:@"nil paramValue passed to instance %@", self]; + } else if (paramGroup == nil) { + @throw [STDSInvalidInputException exceptionWithMessage:@"nil paramGroup passed to instance %@", self]; + } + + NSMutableDictionary *groupParameters = _parameters[paramGroup]; + if (groupParameters == nil) { + groupParameters = [[NSMutableDictionary alloc] init]; + _parameters[paramGroup] = groupParameters; + } + + if (groupParameters[paramName] != nil) { + @throw [STDSInvalidInputException exceptionWithMessage:@"Cannot override value of %@ for parameter %@ in group %@ with value %@", groupParameters[paramName], paramName, paramGroup, paramValue]; + } + + groupParameters[paramName] = paramValue; +} + +- (nullable NSString *)parameterValue:(NSString *)paramName { + return [self _parameterValue:paramName inGroup:kSTDSConfigDefaultGroupName]; +} + +- (nullable NSString *)parameterValue:(NSString *)paramName inGroup:(nullable NSString *)paramGroup { + return [self _parameterValue:paramName inGroup:paramGroup ?: kSTDSConfigDefaultGroupName]; +} + +- (nullable NSString *)_parameterValue:(NSString *)paramName inGroup:(NSString *)paramGroup { + if (paramName == nil) { + @throw [STDSInvalidInputException exceptionWithMessage:@"nil paramName passed to instance %@", self]; + } else if (paramGroup == nil) { + @throw [STDSInvalidInputException exceptionWithMessage:@"nil paramGroup passed to instance %@", self]; + } + + NSMutableDictionary *groupParameters = _parameters[paramGroup]; + return groupParameters[paramName]; +} + +- (nullable NSString *)removeParameterNamed:(NSString *)paramName { + return [self _removeParameterNamed:paramName fromGroup:kSTDSConfigDefaultGroupName]; +} + +- (nullable NSString *)removeParameterNamed:(NSString *)paramName fromGroup:(nullable NSString *)paramGroup { + return [self _removeParameterNamed:paramName fromGroup:paramGroup ?: kSTDSConfigDefaultGroupName]; +} + +- (nullable NSString *)_removeParameterNamed:(NSString *)paramName fromGroup:(NSString *)paramGroup { + if (paramName == nil) { + @throw [STDSInvalidInputException exceptionWithMessage:@"nil paramName passed to instance %@", self]; + } else if (paramGroup == nil) { + @throw [STDSInvalidInputException exceptionWithMessage:@"nil paramGroup passed to instance %@", self]; + } + + NSMutableDictionary *groupParameters = _parameters[paramGroup]; + NSString *paramValue = groupParameters[paramName]; + [groupParameters removeObjectForKey:paramName]; + return paramValue; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSCustomization.h b/Stripe3DS2/Stripe3DS2/include/STDSCustomization.h new file mode 100644 index 00000000..516eff9e --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSCustomization.h @@ -0,0 +1,25 @@ +// +// STDSCustomization.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/14/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +/// This class provides a common set of customization parameters, used to customize elements of the UI. +@interface STDSCustomization : NSObject + +/// The font to use for text. +@property (nonatomic, nullable) UIFont *font; + +/// The color to use for the text. +@property (nonatomic, nullable) UIColor *textColor; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSCustomization.m b/Stripe3DS2/Stripe3DS2/include/STDSCustomization.m new file mode 100644 index 00000000..4544024c --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSCustomization.m @@ -0,0 +1,25 @@ +// +// STDSCustomization.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/14/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSCustomization.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSCustomization + +- (id)copyWithZone:(nullable NSZone *)zone { + STDSCustomization *copy = [[[self class] allocWithZone:zone] init]; + copy.font = self.font; + copy.textColor = self.textColor; + + return copy; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSErrorMessage.h b/Stripe3DS2/Stripe3DS2/include/STDSErrorMessage.h new file mode 100644 index 00000000..29c6c5a6 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSErrorMessage.h @@ -0,0 +1,103 @@ +// +// STDSErrorMessage.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/21/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSJSONEncodable.h" +#import "STDSJSONDecodable.h" + +NS_ASSUME_NONNULL_BEGIN + +/// Error codes as defined by the 3DS2 spec. +typedef NS_ENUM(NSInteger, STDSErrorMessageCode) { + /// The SDK received a message that is not an ARes, CRes, or ErrorMessage. + STDSErrorMessageCodeInvalidMessage = 101, + + /// A required data element is missing from the network response. + STDSErrorMessageCodeRequiredDataElementMissing = 201, + + // Critical message extension not recognised + STDSErrorMessageCodeUnrecognizedCriticalMessageExtension = 202, + + /// A data element is not in the required format or the value is invalid. + STDSErrorMessageErrorInvalidDataElement = 203, + + // Transaction ID not recognized + STDSErrorMessageErrorTransactionIDNotRecognized = 301, + + /// A network response could not be decrypted or verified. + STDSErrorMessageErrorDataDecryptionFailure = 302, + + /// The SDK timed out + STDSErrorMessageErrorTimeout = 402, +}; + +/** + `STDSErrorMessage` represents an error message that is returned by the ACS or to be sent to the ACS. + */ +@interface STDSErrorMessage : NSObject + +/** + Designated initializer for `STDSErrorMessage`. + + @param errorCode The error code. + @param errorComponent The component that identified the error. + @param errorDescription Text describing the error. + @param errorDetails Additional error details. Optional. + */ +- (instancetype)initWithErrorCode:(NSString *)errorCode + errorComponent:(NSString *)errorComponent + errorDescription:(NSString *)errorDescription + errorDetails:(nullable NSString *)errorDetails + messageVersion:(NSString *)messageVersion + acsTransactionIdentifier:(nullable NSString *)acsTransactionIdentifier + errorMessageType:(NSString *)errorMessageType; + +/** + The error code. + */ +@property (nonatomic, readonly) NSString *errorCode; + +/** + The 3-D Secure component that identified the error. + */ +@property (nonatomic, readonly) NSString *errorComponent; + +/** + Text describing the error. + */ +@property (nonatomic, readonly) NSString *errorDescription; + +/** + Additional error details. + */ +@property (nonatomic, nullable, readonly) NSString *errorDetails; + +/** + The protocol version identifier. + */ +@property (nonatomic, readonly) NSString *messageVersion; + +/** + The ACS transaction identifier. + */ +@property (nonatomic, readonly, nullable) NSString *acsTransactionIdentifier; + +/** + The message type that was identified as erroneous. + */ +@property (nonatomic, readonly, nullable) NSString *errorMessageType; + +/** + A representation of the `STDSErrorMessage` as an `NSError` + */ +- (NSError *)NSErrorValue; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSErrorMessage.m b/Stripe3DS2/Stripe3DS2/include/STDSErrorMessage.m new file mode 100644 index 00000000..1e80b645 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSErrorMessage.m @@ -0,0 +1,94 @@ +// +// STDSErrorMessage.m +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/21/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSErrorMessage.h" + +#import "NSDictionary+DecodingHelpers.h" +#import "STDSJSONEncoder.h" +#import "STDSStripe3DS2Error.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSErrorMessage + +- (instancetype)initWithErrorCode:(NSString *)errorCode + errorComponent:(NSString *)errorComponent + errorDescription:(NSString *)errorDescription + errorDetails:(nullable NSString *)errorDetails + messageVersion:(NSString *)messageVersion + acsTransactionIdentifier:(nullable NSString *)acsTransactionIdentifier + errorMessageType:(NSString *)errorMessageType { + self = [super init]; + if (self) { + _errorCode = [errorCode copy]; + _errorComponent = [errorComponent copy]; + _errorDescription = [errorDescription copy]; + _errorDetails = [errorDetails copy]; + _messageVersion = [messageVersion copy]; + _acsTransactionIdentifier = [acsTransactionIdentifier copy]; + _errorMessageType = [errorMessageType copy]; + } + return self; +} + +- (NSString *)messageType { + return @"Erro"; +} + +- (NSError *)NSErrorValue { + return [NSError errorWithDomain:STDSStripe3DS2ErrorDomain + code:[self.errorCode integerValue] + userInfo: [STDSJSONEncoder dictionaryForObject:self]]; +} + +#pragma mark - STDSJSONEncodable + ++ (NSDictionary *)propertyNamesToJSONKeysMapping { + return @{ + NSStringFromSelector(@selector(errorCode)): @"errorCode", + NSStringFromSelector(@selector(errorComponent)): @"errorComponent", + NSStringFromSelector(@selector(errorDescription)): @"errorDescription", + NSStringFromSelector(@selector(errorDetails)): @"errorDetail", + NSStringFromSelector(@selector(messageType)): @"messageType", + NSStringFromSelector(@selector(messageVersion)): @"messageVersion", + NSStringFromSelector(@selector(acsTransactionIdentifier)): @"acsTransID", + NSStringFromSelector(@selector(errorMessageType)): @"errorMessageType", + }; +} + +#pragma mark - STDSJSONDecodable + ++ (nullable instancetype)decodedObjectFromJSON:(nullable NSDictionary *)json error:(NSError * _Nullable __autoreleasing * _Nullable)outError { + if (json == nil) { + return nil; + } + NSError *error; + + // Required + NSString *errorCode = [json _stds_stringForKey:@"errorCode" required:YES error:&error]; + NSString *errorComponent = [json _stds_stringForKey:@"errorComponent" required:YES error:&error]; + NSString *errorDescription = [json _stds_stringForKey:@"errorDescription" required:YES error:&error]; + NSString *errorDetail = [json _stds_stringForKey:@"errorDetail" required:YES error:&error]; + NSString *messageVersion = [json _stds_stringForKey:@"messageVersion" required:YES error:&error]; + + // Optional + NSString *errorMessageType = [json _stds_stringForKey:@"errorMessageType" required:NO error:&error]; + NSString *acsTransactionIdentifier = [json _stds_stringForKey:@"acsTransID" required:NO error:nil]; + + if (error) { + if (outError) { + *outError = error; + } + return nil; + } + return [[self alloc] initWithErrorCode:errorCode errorComponent:errorComponent errorDescription:errorDescription errorDetails:errorDetail messageVersion:messageVersion acsTransactionIdentifier:acsTransactionIdentifier errorMessageType:errorMessageType]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSException.h b/Stripe3DS2/Stripe3DS2/include/STDSException.h new file mode 100644 index 00000000..1f2c5ecd --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSException.h @@ -0,0 +1,25 @@ +// +// STDSException.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/22/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + An abstract class to represent 3DS2 SDK custom exceptions + */ +@interface STDSException : NSException + +/** + A description of the exception. + */ +@property (nonatomic, readonly) NSString *message; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSException.m b/Stripe3DS2/Stripe3DS2/include/STDSException.m new file mode 100644 index 00000000..673d5bbd --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSException.m @@ -0,0 +1,27 @@ +// +// STDSException.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/22/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSException.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSException + ++ (instancetype)exceptionWithMessage:(NSString *)format, ... { + va_list args; + va_start(args, format); + NSString *message = [[NSString alloc] initWithFormat:format arguments:args]; + va_end(args); + STDSException *exception = [[[self class] alloc] initWithName:NSStringFromClass([self class]) reason:message userInfo:nil]; + exception->_message = [message copy]; + return exception; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSFooterCustomization.h b/Stripe3DS2/Stripe3DS2/include/STDSFooterCustomization.h new file mode 100644 index 00000000..990ffd9b --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSFooterCustomization.h @@ -0,0 +1,41 @@ +// +// STDSFooterCustomization.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 6/10/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSCustomization.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + The Challenge view displays a footer with additional details that + expand when tapped. This object configures the appearance of that view. +*/ +@interface STDSFooterCustomization : STDSCustomization + +/// The default settings. ++ (instancetype)defaultSettings; + +/** + The background color of the footer. + Defaults to gray. + */ +@property (nonatomic) UIColor *backgroundColor; + +/// The color of the chevron. Defaults to a dark gray. +@property (nonatomic) UIColor *chevronColor; + +/// The color of the heading text. Defaults to black. +@property (nonatomic) UIColor *headingTextColor; + +/// The font to use for the heading text. +@property (nonatomic) UIFont *headingFont; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSFooterCustomization.m b/Stripe3DS2/Stripe3DS2/include/STDSFooterCustomization.m new file mode 100644 index 00000000..6c770fda --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSFooterCustomization.m @@ -0,0 +1,45 @@ +// +// STDSFooterCustomization.m +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 6/10/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSFooterCustomization.h" + +#import "UIFont+DefaultFonts.h" +#import "UIColor+DefaultColors.h" +#import "UIColor+ThirteenSupport.h" + +@implementation STDSFooterCustomization + ++ (instancetype)defaultSettings { + return [self new]; +} + +- (instancetype)init { + self = [super init]; + if (self) { + self.textColor = UIColor._stds_labelColor; + _headingTextColor = UIColor._stds_labelColor; + _backgroundColor = [UIColor _stds_defaultFooterBackgroundColor]; + _chevronColor = [UIColor _stds_systemGray2Color]; + self.font = [UIFont _stds_defaultLabelTextFontWithScale:(CGFloat)0.9]; + _headingFont = [UIFont _stds_defaultLabelTextFontWithScale:(CGFloat)0.9]; + } + return self; +} + +- (instancetype)copyWithZone:(nullable NSZone *)zone { + STDSFooterCustomization *copy = [super copyWithZone:zone]; + copy.headingTextColor = self.headingTextColor; + copy.headingFont = self.headingFont; + copy.backgroundColor = self.backgroundColor; + copy.chevronColor = self.chevronColor; + + return copy; +} + + +@end diff --git a/Stripe3DS2/Stripe3DS2/include/STDSInvalidInputException.h b/Stripe3DS2/Stripe3DS2/include/STDSInvalidInputException.h new file mode 100644 index 00000000..b30d4e10 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSInvalidInputException.h @@ -0,0 +1,21 @@ +// +// STDSInvalidInputException.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/22/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSException.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + `STDSInvalidInputException` represents an exception that will be thrown by + Stripe3DS2 SDK methods that are called with invalid input arguments. + */ +@interface STDSInvalidInputException : STDSException + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSInvalidInputException.m b/Stripe3DS2/Stripe3DS2/include/STDSInvalidInputException.m new file mode 100644 index 00000000..7ff4cd3e --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSInvalidInputException.m @@ -0,0 +1,17 @@ +// +// STDSInvalidInputException.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/22/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSInvalidInputException.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSInvalidInputException + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSJSONDecodable.h b/Stripe3DS2/Stripe3DS2/include/STDSJSONDecodable.h new file mode 100644 index 00000000..f93428ac --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSJSONDecodable.h @@ -0,0 +1,33 @@ +// +// STDSJSONDecodable.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@protocol STDSJSONDecodable + +/** + Initializes an instance of the class from its JSON representation. + + This method recognizes two categories of errors: + - a required field is missing. + - a required field value is in valid (e.g. expected 'Y' or 'N' but received 'X'). + + Errors populating optional fields are ignored. + + @param json The JSON dictionary that represents an object of this type + @param outError If there was a missing required field or invalid field value, contains an instance of NSError. + + @return The object represented by the JSON dictionary. If the object could not be decoded, returns nil and populates the outError argument. + */ ++ (nullable instancetype)decodedObjectFromJSON:(nullable NSDictionary *)json error:(NSError **)outError; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSJSONEncodable.h b/Stripe3DS2/Stripe3DS2/include/STDSJSONEncodable.h new file mode 100644 index 00000000..9861a23c --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSJSONEncodable.h @@ -0,0 +1,22 @@ +// +// STDSJSONEncodable.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@protocol STDSJSONEncodable + +/** + Returns a map of property names to their JSON representation's key value. For example, `STDSChallengeParameters` has a property called `acsTransactionID`, but the 3DS2 JSON spec expects a field called `acsTransID`. This dictionary represents a mapping from the former to the latter (in other words, [STDSChallengeParameters propertyNamesToJSONKeysMapping][@"acsTransactionID"] == @"acsTransID".) + */ ++ (NSDictionary *)propertyNamesToJSONKeysMapping; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSJSONEncoder.h b/Stripe3DS2/Stripe3DS2/include/STDSJSONEncoder.h new file mode 100644 index 00000000..7ccc07e6 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSJSONEncoder.h @@ -0,0 +1,27 @@ +// +// STDSJSONEncoder.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSJSONEncodable.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + `STDSJSONEncoder` is a utility class to help with converting API objects into JSON + */ +@interface STDSJSONEncoder : NSObject + +/** + Method to convert an STDSJSONEncodable object into a JSON dictionary. + */ ++ (NSDictionary *)dictionaryForObject:(NSObject *)object; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSJSONEncoder.m b/Stripe3DS2/Stripe3DS2/include/STDSJSONEncoder.m new file mode 100644 index 00000000..4c55b02c --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSJSONEncoder.m @@ -0,0 +1,51 @@ +// +// STDSJSONEncoder.m +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSJSONEncoder.h" + +@implementation STDSJSONEncoder + ++ (NSDictionary *)dictionaryForObject:(nonnull NSObject *)object { + NSMutableDictionary *keyPairs = [NSMutableDictionary dictionary]; + [[object.class propertyNamesToJSONKeysMapping] enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull propertyName, NSString * _Nonnull keyName, __unused BOOL * _Nonnull stop) { + id value = [self jsonEncodableValueForObject:[object valueForKey:propertyName]]; + if (value != [NSNull null]) { + keyPairs[keyName] = value; + } + }]; + return [keyPairs copy]; +} + ++ (id)jsonEncodableValueForObject:(NSObject *)object { + if ([object conformsToProtocol:@protocol(STDSJSONEncodable)]) { + return [self dictionaryForObject:(NSObject*)object]; + } else if ([object isKindOfClass:[NSDictionary class]]) { + NSDictionary *dict = (NSDictionary *)object; + NSMutableDictionary *result = [NSMutableDictionary dictionaryWithCapacity:dict.count]; + + [dict enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull value, __unused BOOL * _Nonnull stop) { + result[key] = [self jsonEncodableValueForObject:value]; + }]; + + return result; + } else if ([object isKindOfClass:[NSArray class]]) { + NSArray *array = (NSArray *)object; + NSMutableArray *result = [NSMutableArray arrayWithCapacity:array.count]; + + for (NSObject *element in array) { + [result addObject:[self jsonEncodableValueForObject:element]]; + } + return result; + } else if ([object isKindOfClass:[NSString class]] || [object isKindOfClass:[NSNumber class]]) { + return object; + } else { + return [NSNull null]; + } +} + +@end diff --git a/Stripe3DS2/Stripe3DS2/include/STDSLabelCustomization.h b/Stripe3DS2/Stripe3DS2/include/STDSLabelCustomization.h new file mode 100644 index 00000000..af75cf39 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSLabelCustomization.h @@ -0,0 +1,31 @@ +// +// STDSLabelCustomization.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/14/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSCustomization.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + A customization object to use to configure the UI of a text label. + + The font and textColor inherited from `STDSCustomization` configure non-heading labels. + */ +@interface STDSLabelCustomization : STDSCustomization + +/// The default settings. ++ (instancetype)defaultSettings; + +/// The color of the heading text. Defaults to black. +@property (nonatomic) UIColor *headingTextColor; + +/// The font to use for the heading text. +@property (nonatomic) UIFont *headingFont; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSLabelCustomization.m b/Stripe3DS2/Stripe3DS2/include/STDSLabelCustomization.m new file mode 100644 index 00000000..bb57c5f8 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSLabelCustomization.m @@ -0,0 +1,43 @@ +// +// STDSLabelCustomization.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/14/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSLabelCustomization.h" + +#import "UIFont+DefaultFonts.h" +#import "UIColor+ThirteenSupport.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSLabelCustomization + ++ (instancetype)defaultSettings { + return [self new]; +} + +- (instancetype)init { + self = [super init]; + if (self) { + self.textColor = UIColor._stds_labelColor; + _headingTextColor = UIColor._stds_labelColor; + self.font = [UIFont _stds_defaultLabelTextFontWithScale:(CGFloat)0.9]; + _headingFont = [UIFont _stds_defaultHeadingTextFont]; + } + return self; +} + +- (id)copyWithZone:(nullable NSZone *)zone { + STDSLabelCustomization *copy = [super copyWithZone:zone]; + copy.headingTextColor = self.headingTextColor; + copy.headingFont = self.headingFont; + + return copy; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSNavigationBarCustomization.h b/Stripe3DS2/Stripe3DS2/include/STDSNavigationBarCustomization.h new file mode 100644 index 00000000..18affaaf --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSNavigationBarCustomization.h @@ -0,0 +1,59 @@ +// +// STDSNavigationBarCustomization.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/14/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import + +#import "STDSCustomization.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + A customization object to use to configure a UINavigationBar. + + The font and textColor inherited from `STDSCustomization` configure the + title of the navigation bar, and default to nil. + */ +@interface STDSNavigationBarCustomization : STDSCustomization + +/// The default settings. ++ (instancetype)defaultSettings; + +/** + The tint color of the navigation bar background. + Defaults to nil. + */ +@property (nonatomic, nullable) UIColor *barTintColor; + +/** + The navigation bar style. + Defaults to UIBarStyleDefault. + */ +@property (nonatomic) UIBarStyle barStyle; + +/** + A Boolean value indicating whether the navigation bar is translucent or not. + Defaults to YES. + */ +@property (nonatomic) BOOL translucent; + +/** + The text to display in the title of the navigation bar. + Defaults to "Secure checkout". + */ +@property (nonatomic, copy) NSString *headerText; + +/** + The text to display for the button in the navigation bar. + Defaults to "Cancel". + */ +@property (nonatomic, copy) NSString *buttonText; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSNavigationBarCustomization.m b/Stripe3DS2/Stripe3DS2/include/STDSNavigationBarCustomization.m new file mode 100644 index 00000000..5e062356 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSNavigationBarCustomization.m @@ -0,0 +1,44 @@ +// +// STDSNavigationBarCustomization.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/14/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSLocalizedString.h" +#import "STDSNavigationBarCustomization.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSNavigationBarCustomization + ++ (instancetype)defaultSettings { + return [self new]; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _barTintColor = nil; + _headerText = STDSLocalizedString(@"Secure checkout", @"The title for the challenge response step of an authenticated checkout."); + _buttonText = STDSLocalizedString(@"Cancel", "The text for the button that cancels the current challenge process."); + _translucent = YES; + } + return self; +} + +- (id)copyWithZone:(nullable NSZone *)zone { + STDSNavigationBarCustomization *copy = [super copyWithZone:zone]; + copy.barTintColor = self.barTintColor; + copy.headerText = self.headerText; + copy.buttonText = self.buttonText; + copy.barStyle = self.barStyle; + copy.translucent = self.translucent; + + return copy; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSNotInitializedException.h b/Stripe3DS2/Stripe3DS2/include/STDSNotInitializedException.h new file mode 100644 index 00000000..cb836d5e --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSNotInitializedException.h @@ -0,0 +1,23 @@ +// +// STDSNotInitializedException.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 2/13/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSException.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + `STDSNotInitializedException` represents an exception that will be thrown by + the the Stripe3DS2 SDK if methods are called without initializing `STDSThreeDS2Service`. + + @see STDSThreeDS2Service + */ +@interface STDSNotInitializedException : STDSException + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSNotInitializedException.m b/Stripe3DS2/Stripe3DS2/include/STDSNotInitializedException.m new file mode 100644 index 00000000..58544cc2 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSNotInitializedException.m @@ -0,0 +1,17 @@ +// +// STDSNotInitializedException.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 2/13/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSNotInitializedException.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSNotInitializedException + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSProtocolErrorEvent.h b/Stripe3DS2/Stripe3DS2/include/STDSProtocolErrorEvent.h new file mode 100644 index 00000000..1e3142a7 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSProtocolErrorEvent.h @@ -0,0 +1,42 @@ +// +// STDSProtocolErrorEvent.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/20/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +@class STDSErrorMessage; + +NS_ASSUME_NONNULL_BEGIN + +/** + `STDSProtocolErrorEvent` contains details about erorrs received from or sent to the ACS. + */ +@interface STDSProtocolErrorEvent : NSObject + +/** + Designated initializer for `STDSProtocolErrorEvent`. + */ +- (instancetype)initWithSDKTransactionIdentifier:(NSString *)identifier errorMessage:(STDSErrorMessage *)errorMessage; + +/** + `STDSProtocolErrorEvent` should not be directly initialized. + */ +- (instancetype)init NS_UNAVAILABLE; + +/** + Details about the error. + */ +@property (nonatomic, readonly) STDSErrorMessage *errorMessage; + +/** + The SDK Transaction Identifier. + */ +@property (nonatomic, readonly) NSString *sdkTransactionIdentifier; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSProtocolErrorEvent.m b/Stripe3DS2/Stripe3DS2/include/STDSProtocolErrorEvent.m new file mode 100644 index 00000000..0dbd75b5 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSProtocolErrorEvent.m @@ -0,0 +1,28 @@ +// +// STDSProtocolErrorEvent.m +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/20/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSProtocolErrorEvent.h" + +#import "STDSErrorMessage.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSProtocolErrorEvent + +- (instancetype)initWithSDKTransactionIdentifier:(NSString *)identifier errorMessage:(STDSErrorMessage *)errorMessage { + self = [super init]; + if (self) { + _sdkTransactionIdentifier = identifier; + _errorMessage = errorMessage; + } + return self; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSRuntimeErrorEvent.h b/Stripe3DS2/Stripe3DS2/include/STDSRuntimeErrorEvent.h new file mode 100644 index 00000000..658cdb90 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSRuntimeErrorEvent.h @@ -0,0 +1,53 @@ +// +// STDSRuntimeErrorEvent.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/20/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +FOUNDATION_EXTERN NSString * const kSTDSRuntimeErrorCodeParsingError; +FOUNDATION_EXTERN NSString * const kSTDSRuntimeErrorCodeEncryptionError; + +/** + `STDSRuntimeErrorEvent` contains details about run-time errors encountered during authentication. + + The following are examples of run-time errors: + - ACS is unreachable + - Unparseable message + - Network issues + */ +@interface STDSRuntimeErrorEvent : NSObject + +/** + A code corresponding to the type of error this represents. + */ +@property (nonatomic, readonly) NSString *errorCode; + +/** + Details about the error. + */ +@property (nonatomic, readonly) NSString *errorMessage; + +/** + Designated initializer for `STDSRuntimeErrorEvent`. + */ +- (instancetype)initWithErrorCode:(NSString *)errorCode errorMessage:(NSString *)errorMessage NS_DESIGNATED_INITIALIZER; + +/** + A representation of the `STDSRuntimeErrorEvent` as an `NSError` + */ +- (NSError *)NSErrorValue; + +/** + `STDSRuntimeErrorEvent` should not be directly initialized. + */ +- (instancetype)init NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSRuntimeErrorEvent.m b/Stripe3DS2/Stripe3DS2/include/STDSRuntimeErrorEvent.m new file mode 100644 index 00000000..8155e06e --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSRuntimeErrorEvent.m @@ -0,0 +1,37 @@ +// +// STDSRuntimeErrorEvent.m +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/20/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSRuntimeErrorEvent.h" + +#import "STDSStripe3DS2Error.h" + +NS_ASSUME_NONNULL_BEGIN + +NSString * const kSTDSRuntimeErrorCodeParsingError = @"STDSRuntimeErrorCodeParsingError"; +NSString * const kSTDSRuntimeErrorCodeEncryptionError = @"STDSRuntimeErrorCodeEncryptionError"; + +@implementation STDSRuntimeErrorEvent + +- (instancetype)initWithErrorCode:(NSString *)errorCode errorMessage:(NSString *)errorMessage { + self = [super init]; + if (self) { + _errorCode = [errorCode copy]; + _errorMessage = [errorMessage copy]; + } + return self; +} + +- (NSError *)NSErrorValue { + return [NSError errorWithDomain:STDSStripe3DS2ErrorDomain + code:[self.errorCode isEqualToString:kSTDSRuntimeErrorCodeParsingError] ? STDSErrorCodeRuntimeParsing : STDSErrorCodeRuntimeEncryption + userInfo:@{@"errorMessage": self.errorMessage}]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSRuntimeException.h b/Stripe3DS2/Stripe3DS2/include/STDSRuntimeException.h new file mode 100644 index 00000000..5b63a2d3 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSRuntimeException.h @@ -0,0 +1,21 @@ +// +// STDSRuntimeException.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/22/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSException.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + `STDSRuntimeException` represents an exception that will be thrown by the + Stripe3DS2 SDK if it encounters an internal error. + */ +@interface STDSRuntimeException : STDSException + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSRuntimeException.m b/Stripe3DS2/Stripe3DS2/include/STDSRuntimeException.m new file mode 100644 index 00000000..3a70d376 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSRuntimeException.m @@ -0,0 +1,17 @@ +// +// STDSRuntimeException.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/22/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSRuntimeException.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSRuntimeException + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSSelectionCustomization.h b/Stripe3DS2/Stripe3DS2/include/STDSSelectionCustomization.h new file mode 100644 index 00000000..a9818bb9 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSSelectionCustomization.h @@ -0,0 +1,48 @@ +// +// STDSSelectionCustomization.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 6/11/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + A customization object that configures the appearance of + radio buttons and checkboxes. + */ +@interface STDSSelectionCustomization: NSObject + +/// The default settings. ++ (instancetype)defaultSettings; + +/** + The primary color of the selected state. + Defaults to blue. + */ +@property (nonatomic) UIColor *primarySelectedColor; + +/** + The secondary color of the selected state (e.g. the checkmark color). + Defaults to white. + */ +@property (nonatomic) UIColor *secondarySelectedColor; + +/** + The background color displayed in the unselected state. + Defaults to light blue. + */ +@property (nonatomic) UIColor *unselectedBackgroundColor; + +/** + The color of the border drawn around the view in the unselected state. + Defaults to blue. + */ +@property (nonatomic) UIColor *unselectedBorderColor; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSSelectionCustomization.m b/Stripe3DS2/Stripe3DS2/include/STDSSelectionCustomization.m new file mode 100644 index 00000000..d5fdcf35 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSSelectionCustomization.m @@ -0,0 +1,64 @@ +// +// STDSSelectionCustomization.m +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 6/11/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSSelectionCustomization.h" + +#import "UIColor+DefaultColors.h" +#import "UIColor+ThirteenSupport.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSSelectionCustomization + ++ (instancetype)defaultSettings { + return [self new]; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _primarySelectedColor = [UIColor _stds_blueColor]; + _secondarySelectedColor = UIColor.whiteColor; + _unselectedBackgroundColor = [UIColor _stds_colorWithDynamicProvider:^UIColor * _Nonnull(UITraitCollection * _Nonnull traitCollection) { + return (traitCollection.userInterfaceStyle == UIUserInterfaceStyleLight) ? + [UIColor colorWithRed:(CGFloat)231.0 / (CGFloat)255.0 + green:(CGFloat)241.0 / (CGFloat)255.0 + blue:(CGFloat)254.0 / (CGFloat)255.0 + alpha:1.0] : + [UIColor colorWithRed:(CGFloat)30.0 / (CGFloat)255.0 + green:(CGFloat)63.0 / (CGFloat)255.0 + blue:(CGFloat)84.0 / (CGFloat)255.0 + alpha:1.0]; + }]; + _unselectedBorderColor = [UIColor _stds_colorWithDynamicProvider:^UIColor * _Nonnull(UITraitCollection * _Nonnull traitCollection) { + return (traitCollection.userInterfaceStyle == UIUserInterfaceStyleLight) ? + [UIColor colorWithRed:(CGFloat)131.0 / (CGFloat)255.0 + green:(CGFloat)191.0 / (CGFloat)255.0 + blue:(CGFloat)250.0 / (CGFloat)255.0 + alpha:1] : + [UIColor colorWithRed:(CGFloat)65.0 / (CGFloat)255.0 + green:(CGFloat)94.0 / (CGFloat)255.0 + blue:(CGFloat)123.0 / (CGFloat)255.0 + alpha:1]; + }]; + } + return self; +} + +- (nonnull id)copyWithZone:(nullable NSZone *)zone { + STDSSelectionCustomization *copy = [STDSSelectionCustomization new]; + copy.primarySelectedColor = self.primarySelectedColor; + copy.secondarySelectedColor = self.secondarySelectedColor; + copy.unselectedBackgroundColor = self.unselectedBackgroundColor; + copy.unselectedBorderColor = self.unselectedBorderColor; + return copy; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSStripe3DS2Error.h b/Stripe3DS2/Stripe3DS2/include/STDSStripe3DS2Error.h new file mode 100644 index 00000000..2e506fe2 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSStripe3DS2Error.h @@ -0,0 +1,68 @@ +// +// STDSStripe3DS2Error.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +FOUNDATION_EXPORT NSString * const STDSStripe3DS2ErrorDomain; + +/** + NSError.userInfo contains this key if we received an ErrorMessage instead of the expected response object. + The value of this key is the ErrorMessage. + */ +FOUNDATION_EXPORT NSString * const STDSStripe3DS2ErrorMessageErrorKey; + +/** + NSError.userInfo contains this key if we errored parsing JSON. + The value of this key is the invalid or missing field. + */ +FOUNDATION_EXPORT NSString * const STDSStripe3DS2ErrorFieldKey; + +/** + NSError.userInfo contains this key if we couldn't recognize critical message extension(s) + The value of this key is an array of identifiers. + */ +FOUNDATION_EXPORT NSString * const STDSStripe3DS2UnrecognizedCriticalMessageExtensionsKey; + + +typedef NS_ENUM(NSInteger, STDSErrorCode) { + + /// Code triggered an assertion + STDSErrorCodeAssertionFailed = 204, + + // JSON Parsing + /// Received invalid or malformed data + STDSErrorCodeJSONFieldInvalid = 203, + /// Expected field missing + STDSErrorCodeJSONFieldMissing = 201, + + /// Critical message extension not recognised + STDSErrorCodeUnrecognizedCriticalMessageExtension = 202, + + /// Decryption or verification error + STDSErrorCodeDecryptionVerification = 302, + + /// Error code corresponding to a `STDSRuntimeErrorEvent` for an unparseable network response + STDSErrorCodeRuntimeParsing = 400, + /// Error code corresponding to a `STDSRuntimeErrorEvent` for an error with decrypting or verifying a network response + STDSErrorCodeRuntimeEncryption = 401, + + // Networking + /// We received an ErrorMessage instead of the expected response object. `userInfo[STDSStripe3DS2ErrorMessageErrorKey]` will contain the ErrorMessage object. + STDSErrorCodeReceivedErrorMessage = 1000, + /// We received an unknown message type. + STDSErrorCodeUnknownMessageType = 1001, + /// Request timed out + STDSErrorCodeTimeout = 1002, + + /// Unknown + STDSErrorCodeUnknownError = 2000, +}; + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSStripe3DS2Error.m b/Stripe3DS2/Stripe3DS2/include/STDSStripe3DS2Error.m new file mode 100644 index 00000000..6d2330ed --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSStripe3DS2Error.m @@ -0,0 +1,15 @@ +// +// STDSStripe3DS2Error.m +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSStripe3DS2Error.h" + +NSString * const STDSStripe3DS2ErrorDomain = @"com.stripe3ds2"; +NSString * const STDSStripe3DS2ErrorMessageErrorKey = @"STDSStripe3DS2ErrorMessageErrorKey"; +NSString * const STDSStripe3DS2ErrorFieldKey = @"STDSStripe3DS2ErrorFieldKey"; +NSString * const STDSStripe3DS2UnrecognizedCriticalMessageExtensionsKey = @"STDSStripe3DS2UnrecognizedCriticalMessageExtensionsKey"; + diff --git a/Stripe3DS2/Stripe3DS2/include/STDSSwiftTryCatch.h b/Stripe3DS2/Stripe3DS2/include/STDSSwiftTryCatch.h new file mode 100644 index 00000000..47443368 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSSwiftTryCatch.h @@ -0,0 +1,50 @@ +// +// STDSSwiftTryCatch.h +// +// Created by William Falcon on 10/10/14. +// Copyright (c) 2014 William Falcon. All rights reserved. +// +/* + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + Provides try catch functionality for swift by wrapping around Objective-C + */ +@interface STDSSwiftTryCatch : NSObject + +/** + Provides try catch functionality for swift by wrapping around Objective-C + */ ++ (void)tryBlock:(void(^)(void))tryBlock catchBlock:(void(^)(NSException*exception))catchBlock finallyBlock:(void(^)(void))finallyBlock; +/** + Throws Objective-C exception with name and reason set to `s` + */ ++ (void)throwString:(NSString*)s; + +/** + Throws exception `e` + */ ++ (void)throwException:(NSException*)e; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSSwiftTryCatch.m b/Stripe3DS2/Stripe3DS2/include/STDSSwiftTryCatch.m new file mode 100644 index 00000000..a9ac8601 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSSwiftTryCatch.m @@ -0,0 +1,59 @@ +// +// STDSSwiftTryCatch.h +// +// Created by William Falcon on 10/10/14. +// Copyright (c) 2014 William Falcon. All rights reserved. +// +/* + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + */ + +#import "STDSSwiftTryCatch.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSSwiftTryCatch + ++ (void)tryBlock:(void(^)(void))tryBlock catchBlock:(void(^)(NSException*exception))catchBlock finallyBlock:(void(^)(void))finallyBlock { + @try { + tryBlock ? tryBlock() : nil; + } + + @catch (NSException *exception) { + catchBlock ? catchBlock(exception) : nil; + } + @finally { + finallyBlock ? finallyBlock() : nil; + } +} + ++ (void)throwString:(NSString*)s +{ + @throw [NSException exceptionWithName:s reason:s userInfo:nil]; +} + ++ (void)throwException:(NSException*)e +{ + @throw e; +} + + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSTextFieldCustomization.h b/Stripe3DS2/Stripe3DS2/include/STDSTextFieldCustomization.h new file mode 100644 index 00000000..6f2bfede --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSTextFieldCustomization.h @@ -0,0 +1,47 @@ +// +// STDSTextFieldCustomization.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/14/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSCustomization.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + A customization object to use to configure the UI of a text field. + + The font and textColor inherited from `STDSCustomization` configure + the user input text. + */ +@interface STDSTextFieldCustomization : STDSCustomization + +/** + The default settings. + + The default textColor is black. + */ ++ (instancetype)defaultSettings; + +/// The border width of the text field. Defaults to 2. +@property (nonatomic) CGFloat borderWidth; + +/// The color of the border of the text field. Defaults to clear. +@property (nonatomic) UIColor *borderColor; + +/// The corner radius of the edges of the text field. Defaults to 8. +@property (nonatomic) CGFloat cornerRadius; + +/// The appearance of the keyboard. Defaults to UIKeyboardAppearanceDefault. +@property (nonatomic) UIKeyboardAppearance keyboardAppearance; + +/// The color of the placeholder text. Defaults to light gray. +@property (nonatomic) UIColor *placeholderTextColor; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSTextFieldCustomization.m b/Stripe3DS2/Stripe3DS2/include/STDSTextFieldCustomization.m new file mode 100644 index 00000000..18a85c8d --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSTextFieldCustomization.m @@ -0,0 +1,50 @@ +// +// STDSTextFieldCustomization.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/14/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSTextFieldCustomization.h" + +#import "UIFont+DefaultFonts.h" +#import "UIColor+ThirteenSupport.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSTextFieldCustomization + ++ (instancetype)defaultSettings { + return [self new]; +} + +- (instancetype)init { + self = [super init]; + if (self) { + self.font = [UIFont _stds_defaultLabelTextFontWithScale:(CGFloat)1.9]; + _borderWidth = 2; + _cornerRadius = 8; + _keyboardAppearance = UIKeyboardAppearanceDefault; + + self.textColor = UIColor._stds_labelColor; + _borderColor = UIColor.clearColor; + _placeholderTextColor = UIColor._stds_systemGray2Color; + } + return self; +} + +- (id)copyWithZone:(nullable NSZone *)zone { + STDSTextFieldCustomization *copy = [super copyWithZone:zone]; + copy.borderWidth = self.borderWidth; + copy.borderColor = self.borderColor; + copy.cornerRadius = self.cornerRadius; + copy.keyboardAppearance = self.keyboardAppearance; + copy.placeholderTextColor = self.placeholderTextColor; + + return copy; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSThreeDS2Service.h b/Stripe3DS2/Stripe3DS2/include/STDSThreeDS2Service.h new file mode 100644 index 00000000..c0baa55f --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSThreeDS2Service.h @@ -0,0 +1,84 @@ +// +// STDSThreeDS2Service.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/22/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +@class STDSConfigParameters; +@class STDSTransaction; +@class STDSUICustomization; +@class STDSWarning; + +NS_ASSUME_NONNULL_BEGIN + +/** + `STDSThreeDS2Service` is the main 3DS SDK interface and provides methods to process transactions. + */ +@interface STDSThreeDS2Service : NSObject + +/** + A list of warnings that may be populated once the SDK has been initialized. + */ +@property (nonatomic, readonly, nullable) NSArray *warnings; + +/** + Initializes the 3DS SDK instance. + + This method should be called at the start of the payment stage of a transaction. + **Note: Until the `STDSThreeDS2Service instance is initialized, it will be unusable.** + + - Performs security checks + - Collects device information + + @param config Configuration information that will be used during initialization. @see STDSConfigParameters + @param locale Optional override for the locale to use in UI. If `nil`, will default to the current system locale. + @param uiSettings Optional custom UI settings. If `nil`, will default to `[STDSUICustomization defaultSettings]`. + This argument is copied; any further changes to the customization object have no effect. @see STDSUICustomization + + @exception STDSInvalidInputException Will throw an `STDSInvalidInputException` if `config` is `nil` or any of `config`, `locale`, or `uiSettings` are invalid. @see STDSInvalidInputException + @exception STDSAlreadyInitializedException Will throw an `STDSAlreadyInitializedException` if the 3DS SDK instance has already been initialized. @see STDSSDKAlreadyInitializedException + @exception STDSRuntimeException Will throw an `STDSRuntimeException` if there is an internal error in the SDK. @see STDSRuntimeException + */ +- (void)initializeWithConfig:(STDSConfigParameters *)config + locale:(nullable NSLocale *)locale + uiSettings:(nullable STDSUICustomization *)uiSettings; + +/** + Creates and returns an instance of `STDSTransaction`. + + @param directoryServerID The Directory Server identifier returned in the authentication response + @param protocolVersion 3DS protocol version according to which the transaction will be created. Uses the default value of 2.1.0 if nil + + @exception STDSNotInitializedException Will throw an `STDSNotInitializedException` if the the `STDSThreeDS2Service` instance hasn't been initialized with a call to `initializeWithConfig:locale:uiSettings:`. @see STDSNotInitializedException + @exception STDSInvalidInputException Will throw an `STDSInvalidInputException` if `directoryServerID` is not recognized or if the `protocolVersion` is not supported by this version of the SDK. @see STDSInvalidInputException + @exception STDSRuntimeException Will throw an `STDSRuntimeException` if there is an internal error in the SDK. @see STDSRuntimeException + */ +- (STDSTransaction *)createTransactionForDirectoryServer:(NSString *)directoryServerID + withProtocolVersion:(nullable NSString *)protocolVersion; + +/** + Creates and returns an instance of `STDSTransaction` using a custom directory server certificate. + Will return nil if unable to create a certificate from the provided params. + + @param directoryServerID The Directory Server identifier returned in the authentication response + @param serverKeyID An additional authentication key used by some Directory Servers + @param certificateString A Base64-encoded PEM or DER formatted certificate string containing the directory server's public key + @param rootCertificateStrings An arry of base64-encoded PEM or DER formatted certificate strings containing the DS root certificate used for signature checks + @param protocolVersion 3DS protocol version according to which the transaction will be created. Uses the default value of 2.1.0 if nil + + @exception STDSNotInitializedException Will throw an `STDSNotInitializedException` if the the `STDSThreeDS2Service` instance hasn't been initialized with a call to `initializeWithConfig:locale:uiSettings:`. @see STDSNotInitializedException + @exception STDSInvalidInputException Will throw an `STDSInvalidInputException` if the `protocolVersion` is not supported by this version of the SDK. @see STDSInvalidInputException + */ +- (nullable STDSTransaction *)createTransactionForDirectoryServer:(NSString *)directoryServerID + serverKeyID:(nullable NSString *)serverKeyID + certificateString:(NSString *)certificateString + rootCertificateStrings:(NSArray *)rootCertificateStrings + withProtocolVersion:(nullable NSString *)protocolVersion; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSThreeDS2Service.m b/Stripe3DS2/Stripe3DS2/include/STDSThreeDS2Service.m new file mode 100644 index 00000000..2c45de66 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSThreeDS2Service.m @@ -0,0 +1,191 @@ +// +// STDSThreeDS2Service.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/22/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSThreeDS2Service.h" + +#include + +#import "STDSAlreadyInitializedException.h" +#import "STDSConfigParameters.h" +#import "STDSDebuggerChecker.h" +#import "STDSDeviceInformationManager.h" +#import "STDSDirectoryServerCertificate.h" +#import "STDSException+Internal.h" +#import "STDSInvalidInputException.h" +#import "STDSLocalizedString.h" +#import "STDSJailbreakChecker.h" +#import "STDSIntegrityChecker.h" +#import "STDSNotInitializedException.h" +#import "STDSOSVersionChecker.h" +#import "STDSSecTypeUtilities.h" +#import "STDSSimulatorChecker.h" +#import "STDSThreeDSProtocolVersion.h" +#import "STDSTransaction+Private.h" +#import "STDSWarning.h" + +static const int kServiceNotInitialized = 0; +static const int kServiceInitialized = 1; + +static NSString * const kInternalStripeTestingConfigParam = @"kInternalStripeTestingConfigParam"; +static NSString * const kIgnoreDeviceInformationRestrictionsParam = @"kIgnoreDeviceInformationRestrictionsParam"; +static NSString * const kUseULTestLOAParam = @"kUseULTestLOAParam"; + +@implementation STDSThreeDS2Service +{ + atomic_int _initialized; + + STDSDeviceInformation *_deviceInformation; + STDSUICustomization *_uiSettings; + STDSConfigParameters *_configuration; +} + +@synthesize warnings = _warnings; + +- (void)initializeWithConfig:(STDSConfigParameters *)config + locale:(nullable NSLocale *)locale + uiSettings:(nullable STDSUICustomization *)uiSettings { + if (config == nil) { + @throw [STDSInvalidInputException exceptionWithMessage:[NSString stringWithFormat:@"%@ config parameter must be non-nil.", NSStringFromSelector(_cmd)]]; + } + + int notInitialized = kServiceNotInitialized; // Can't pass a const to atomic_compare_exchange_strong_explicit so copy here + if (!atomic_compare_exchange_strong_explicit(&_initialized, ¬Initialized, kServiceInitialized, memory_order_release, memory_order_relaxed)) { + @throw [STDSAlreadyInitializedException exceptionWithMessage:[NSString stringWithFormat:@"STDSThreeDS2Service instance %p has already been initialized.", self]]; + } + + _configuration = config; + _uiSettings = uiSettings ? [uiSettings copy] : [STDSUICustomization defaultSettings]; + + NSMutableArray *warnings = [NSMutableArray array]; + if ([STDSJailbreakChecker isJailbroken]) { + STDSWarning *jailbrokenWarning = [[STDSWarning alloc] initWithIdentifier:@"SW01" message:STDSLocalizedString(@"The device is jailbroken.", @"The text for warning when a device is jailbroken") severity:STDSWarningSeverityHigh]; + [warnings addObject:jailbrokenWarning]; + } + + if (![STDSIntegrityChecker SDKIntegrityIsValid]) { + STDSWarning *integrityWarning = [[STDSWarning alloc] initWithIdentifier:@"SW02" message:STDSLocalizedString(@"The integrity of the SDK has been tampered.", @"The text for warning when the integrity of the SDK has been tampered with") severity:STDSWarningSeverityHigh]; + [warnings addObject:integrityWarning]; + } + + if ([STDSSimulatorChecker isRunningOnSimulator]) { + STDSWarning *simulatorWarning = [[STDSWarning alloc] initWithIdentifier:@"SW03" message:STDSLocalizedString(@"An emulator is being used to run the App.", @"The text for warning when an emulator is being used to run the application.") severity:STDSWarningSeverityHigh]; + [warnings addObject:simulatorWarning]; + } + + if ([STDSDebuggerChecker processIsCurrentlyAttachedToDebugger]) { + STDSWarning *debuggerWarning = [[STDSWarning alloc] initWithIdentifier:@"SW04" message:STDSLocalizedString(@"A debugger is attached to the App.", @"The text for warning when a debugger is currently attached to the process.") severity:STDSWarningSeverityMedium]; + [warnings addObject:debuggerWarning]; + } + + if (![STDSOSVersionChecker isSupportedOSVersion]) { + STDSWarning *versionWarning = [[STDSWarning alloc] initWithIdentifier:@"SW05" message:STDSLocalizedString(@"The OS or the OS Version is not supported.", "The text for warning when the SDK is running on an unsupported OS or OS version.") severity:STDSWarningSeverityHigh]; + [warnings addObject:versionWarning]; + } + + _warnings = [warnings copy]; + + _deviceInformation = [STDSDeviceInformationManager deviceInformationWithWarnings:_warnings + ignoringRestrictions:[[_configuration parameterValue:kIgnoreDeviceInformationRestrictionsParam] isEqualToString:@"Y"]]; + +} + +- (STDSTransaction *)createTransactionForDirectoryServer:(NSString *)directoryServerID + withProtocolVersion:(nullable NSString *)protocolVersion { + if (_initialized != kServiceInitialized) { + @throw [STDSNotInitializedException exceptionWithMessage:@"STDSThreeDS2Service instance %p has not been initialized before call to %@", self, NSStringFromSelector(_cmd)]; + } + + if (directoryServerID == nil) { + @throw [STDSInvalidInputException exceptionWithMessage:@"%@ directoryServerID parameter must be non-nil.", NSStringFromSelector(_cmd)]; + } + + STDSDirectoryServer directoryServer = STDSDirectoryServerForID(directoryServerID); + if (directoryServer == STDSDirectoryServerUnknown) { + if ([[_configuration parameterValue:kInternalStripeTestingConfigParam] isEqualToString:@"Y"]) { + directoryServer = STDSDirectoryServerULTestRSA; + } else { + @throw [STDSInvalidInputException exceptionWithMessage:@"%@ is an invalid directoryServerID value", directoryServerID]; + } + } + + if (protocolVersion != nil && ![self _supportsProtocolVersion:protocolVersion]) { + @throw [STDSInvalidInputException exceptionWithMessage:@"3DS2 Protocol Version %@ is not supported by this SDK", protocolVersion]; + } + + + + STDSTransaction *transaction = [[STDSTransaction alloc] initWithDeviceInformation:_deviceInformation + directoryServer:directoryServer + protocolVersion:(protocolVersion != nil) ? STDSThreeDSProtocolVersionForString(protocolVersion) : STDSThreeDSProtocolVersion2_1_0 + uiCustomization:_uiSettings]; + transaction.bypassTestModeVerification = [[_configuration parameterValue:kInternalStripeTestingConfigParam] isEqualToString:@"Y"]; + transaction.useULTestLOA = [[_configuration parameterValue:kUseULTestLOAParam] isEqualToString:@"Y"]; + return transaction; + +} + +- (nullable STDSTransaction *)createTransactionForDirectoryServer:(NSString *)directoryServerID + serverKeyID:(nullable NSString *)serverKeyID + certificateString:(NSString *)certificateString + rootCertificateStrings:(NSArray *)rootCertificateStrings + withProtocolVersion:(nullable NSString *)protocolVersion { + if (_initialized != kServiceInitialized) { + @throw [STDSNotInitializedException exceptionWithMessage:@"STDSThreeDS2Service instance %p has not been initialized before call to %@", self, NSStringFromSelector(_cmd)]; + } + + if (protocolVersion != nil && ![self _supportsProtocolVersion:protocolVersion]) { + @throw [STDSInvalidInputException exceptionWithMessage:@"3DS2 Protocol Version %@ is not supported by this SDK", protocolVersion]; + } + + STDSTransaction *transaction = nil; + + STDSDirectoryServerCertificate *certificate = [STDSDirectoryServerCertificate customCertificateWithString:certificateString]; + + if (certificate != nil) { + transaction = [[STDSTransaction alloc] initWithDeviceInformation:_deviceInformation + directoryServerID:directoryServerID + serverKeyID:serverKeyID + directoryServerCertificate:certificate + rootCertificateStrings:rootCertificateStrings + protocolVersion:(protocolVersion != nil) ? STDSThreeDSProtocolVersionForString(protocolVersion) : STDSThreeDSProtocolVersion2_1_0 + uiCustomization:_uiSettings]; + transaction.bypassTestModeVerification = [_configuration parameterValue:kInternalStripeTestingConfigParam] != nil; + } + + return transaction; +} + +- (nullable NSArray *)warnings { + if (_initialized != kServiceInitialized) { + @throw [STDSNotInitializedException exceptionWithMessage:@"STDSThreeDS2Service instance %p has not been initialized before call to %@", self, NSStringFromSelector(_cmd)]; + } + + return _warnings; +} + +#pragma mark - Internal + +- (BOOL)_supportsProtocolVersion:(NSString *)protocolVersion { + STDSThreeDSProtocolVersion version = STDSThreeDSProtocolVersionForString(protocolVersion); + switch (version) { + case STDSThreeDSProtocolVersion2_1_0: + return YES; + + case STDSThreeDSProtocolVersion2_2_0: + return YES; + + case STDSThreeDSProtocolVersionFallbackTest: + // only support fallback test if we have the internal testing config param + return [[_configuration parameterValue:kInternalStripeTestingConfigParam] isEqualToString:@"Y"]; + + case STDSThreeDSProtocolVersionUnknown: + return NO; + } +} + +@end diff --git a/Stripe3DS2/Stripe3DS2/include/STDSThreeDSProtocolVersion.h b/Stripe3DS2/Stripe3DS2/include/STDSThreeDSProtocolVersion.h new file mode 100644 index 00000000..b4161a6e --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSThreeDSProtocolVersion.h @@ -0,0 +1,15 @@ +// +// STDSThreeDSProtocolVersion.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 6/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +FOUNDATION_EXPORT NSString * const Stripe3DS2ProtocolVersion; + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSTransaction.h b/Stripe3DS2/Stripe3DS2/include/STDSTransaction.h new file mode 100644 index 00000000..02a1f1af --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSTransaction.h @@ -0,0 +1,94 @@ +// +// STDSTransaction.h +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/21/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import + +typedef void (^STDSTransactionVoidBlock)(void); + +@class STDSAuthenticationRequestParameters, STDSChallengeParameters; +@protocol STDSChallengeStatusReceiver; + +NS_ASSUME_NONNULL_BEGIN + +/** + `STDSTransaction` holds parameters that the 3DS Server requires to create AReq messages and to perform the Challenge Flow. + */ +@interface STDSTransaction : NSObject + +/** + The UI type of the presented challenge for this transaction if applicable. Will be one of + "none" + "text" + "single_select" + "multi_select" + "oob" + "html" + */ +@property (nonatomic, readonly, copy) NSString *presentedChallengeUIType; + +/** + Encrypts device information collected during initialization and returns it along with SDK details. + + @return Encrypted device information and details about this SDK. @see STDSAuthenticationRequestParameters + + @exception SDKRuntimeException Thrown if an internal error is encountered. + */ +- (STDSAuthenticationRequestParameters *)createAuthenticationRequestParameters; + +/** + Returns a UIViewController instance displaying the Directory Server logo and a spinner. Present this during the Authentication Request/Response. + */ +- (UIViewController *)createProgressViewControllerWithDidCancel:(STDSTransactionVoidBlock)didCancel; + +/** + Initiates the challenge process, displaying challenge UI as needed. + + @param presentingViewController The UIViewController used to present the challenge response UIViewController + @param challengeParameters Details required to conduct the challenge process. @see STDSChallengeParameters + @param challengeStatusReceiver A callback object to receive the status of the challenge. See @STDSChallengeStatusReceiver + @param timeout An interval in seconds within which the challenge process will finish. Must be at least 5 minutes. + + @exception STDSInvalidInputException Thrown if an argument is invalid (e.g. timeout less than 5 minutes). @see STDSInvalidInputException + @exception STDSSDKRuntimeException Thrown if an internal error is encountered, and if you call this method after calling `close`. @see SDKRuntimeException + + @note challengeStatusReceiver must conform to . This is a workaround: When the static Stripe3DS2 is compiled into Stripe.framework, the resulting swiftinterface and generated .h files reference this protocol. To allow users to build without including Stripe3DS2 directly, we'll take an `id` here instead. + */ +- (void)doChallengeWithViewController:(UIViewController *)presentingViewController + challengeParameters:(STDSChallengeParameters *)challengeParameters + challengeStatusReceiver:(id)challengeStatusReceiver + timeout:(NSTimeInterval)timeout; + +/** + Returns the version of the Stripe3DS2 SDK, e.g. @"1.0" + */ +- (NSString *)sdkVersion; + +/** +Cleans up resources held by `STDSTransaction`. Call this when the transaction is completed, if `doChallengeWithChallengeParameters:challengeStatusReceiver:timeout` is not called. + + @note Don't use this object after calling this method. Calling `doChallengeWithViewController:challengeParameters:challengeStatusReceiver:timeout` after calling this method will throw an `STDSSDKRuntimeException` + */ +- (void)close; + +/** + Alternate challenge initiation method meant only for internal use by Stripe SDK. + */ +- (void)doChallengeWithChallengeParameters:(STDSChallengeParameters *)challengeParameters + challengeStatusReceiver:(id)challengeStatusReceiver + timeout:(NSTimeInterval)timeout + presentationBlock:(void (^)(UIViewController *, void(^)(void)))presentationBlock; + +/** + Function to manually cancel the challenge flow. + */ +- (void)cancelChallengeFlow; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSTransaction.m b/Stripe3DS2/Stripe3DS2/include/STDSTransaction.m new file mode 100644 index 00000000..654efa64 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSTransaction.m @@ -0,0 +1,531 @@ +// +// STDSTransaction.m +// Stripe3DS2 +// +// Created by Yuki Tokuhiro on 3/21/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSTransaction.h" +#import "STDSTransaction+Private.h" + +#import "STDSBundleLocator.h" +#import "NSDictionary+DecodingHelpers.h" +#import "NSError+Stripe3DS2.h" +#import "NSString+JWEHelpers.h" +#import "STDSACSNetworkingManager.h" +#import "STDSAuthenticationRequestParameters.h" +#import "STDSChallengeRequestParameters.h" +#import "STDSCompletionEvent.h" +#import "STDSChallengeParameters.h" +#import "STDSChallengeResponseObject.h" +#import "STDSChallengeResponseViewController.h" +#import "STDSChallengeStatusReceiver.h" +#import "STDSDeviceInformation.h" +#import "STDSEllipticCurvePoint.h" +#import "STDSEphemeralKeyPair.h" +#import "STDSErrorMessage+Internal.h" +#import "STDSException+Internal.h" +#import "STDSInvalidInputException.h" +#import "STDSJSONWebEncryption.h" +#import "STDSJSONWebSignature.h" +#import "STDSProgressViewController.h" +#import "STDSProtocolErrorEvent.h" +#import "STDSRuntimeErrorEvent.h" +#import "STDSRuntimeException.h" +#import "STDSSecTypeUtilities.h" +#import "STDSStripe3DS2Error.h" +#import "STDSDeviceInformationParameter.h" + +static const NSTimeInterval kMinimumTimeout = 5 * 60; +static NSString * const kStripeLOA = @"3DS_LOA_SDK_STIN_020100_00162"; +static NSString * const kULTestLOA = @"3DS_LOA_SDK_PPFU_020100_00007"; + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSTransaction() + +@property (nonatomic, weak) id challengeStatusReceiver; +@property (nonatomic, strong, nullable) STDSChallengeResponseViewController *challengeResponseViewController; +/// Stores the most recent parameters used to make a CReq +@property (nonatomic, nullable) STDSChallengeRequestParameters *challengeRequestParameters; +/// YES if `close` was called or the challenge flow finished. +@property (nonatomic, getter=isCompleted) BOOL completed; +@end + +@implementation STDSTransaction +{ + STDSDeviceInformation *_deviceInformation; + STDSDirectoryServer _directoryServer; + STDSEphemeralKeyPair *_ephemeralKeyPair; + STDSThreeDSProtocolVersion _protocolVersion; + NSString *_identifier; + + STDSDirectoryServerCertificate *_customDirectoryServerCertificate; + NSArray *_rootCertificateStrings; + NSString *_customDirectoryServerID; + NSString *_serverKeyID; + + STDSACSNetworkingManager *_networkingManager; + + STDSUICustomization *_uiCustomization; +} + +- (instancetype)initWithDeviceInformation:(STDSDeviceInformation *)deviceInformation + directoryServer:(STDSDirectoryServer)directoryServer + protocolVersion:(STDSThreeDSProtocolVersion)protocolVersion + uiCustomization:(nonnull STDSUICustomization *)uiCustomization { + self = [super init]; + if (self) { + _deviceInformation = deviceInformation; + _directoryServer = directoryServer; + _protocolVersion = protocolVersion; + _completed = NO; + _identifier = [NSUUID UUID].UUIDString.lowercaseString; + _ephemeralKeyPair = [STDSEphemeralKeyPair ephemeralKeyPair]; + _uiCustomization = uiCustomization; + if (_ephemeralKeyPair == nil) { + return nil; + } + } + + return self; +} + +- (instancetype)initWithDeviceInformation:(STDSDeviceInformation *)deviceInformation + directoryServerID:(NSString *)directoryServerID + serverKeyID:(nullable NSString *)serverKeyID + directoryServerCertificate:(STDSDirectoryServerCertificate *)directoryServerCertificate + rootCertificateStrings:(NSArray *)rootCertificateStrings + protocolVersion:(STDSThreeDSProtocolVersion)protocolVersion + uiCustomization:(STDSUICustomization *)uiCustomization { + self = [super init]; + if (self) { + _deviceInformation = deviceInformation; + _directoryServer = STDSDirectoryServerCustom; + _customDirectoryServerCertificate = directoryServerCertificate; + _rootCertificateStrings = rootCertificateStrings; + _customDirectoryServerID = [directoryServerID copy]; + _serverKeyID = [serverKeyID copy]; + _protocolVersion = protocolVersion; + _completed = NO; + _identifier = [NSUUID UUID].UUIDString.lowercaseString; + _ephemeralKeyPair = [STDSEphemeralKeyPair ephemeralKeyPair]; + _uiCustomization = uiCustomization; + if (_ephemeralKeyPair == nil) { + return nil; + } + } + + return self; +} + +- (NSString *)sdkVersion { + return [[STDSBundleLocator stdsResourcesBundle] objectForInfoDictionaryKey:@"CFBundleVersion"]; +} + +- (NSString *)presentedChallengeUIType { + switch (self.challengeResponseViewController.response.acsUIType) { + + case STDSACSUITypeNone: + return @"none"; + + case STDSACSUITypeText: + return @"text"; + + case STDSACSUITypeSingleSelect: + return @"single_select"; + + case STDSACSUITypeMultiSelect: + return @"multi_select"; + + case STDSACSUITypeOOB: + return @"oob"; + + case STDSACSUITypeHTML: + return @"html"; + } +} + +- (STDSAuthenticationRequestParameters *)createAuthenticationRequestParameters { + NSError *error = nil; + NSString *encryptedDeviceData = nil; + + if (_directoryServer == STDSDirectoryServerCustom) { + encryptedDeviceData = [STDSJSONWebEncryption encryptJSON:_deviceInformation.dictionaryValue + withCertificate:_customDirectoryServerCertificate + directoryServerID:_customDirectoryServerID + serverKeyID:_serverKeyID + error:&error]; + } else { + encryptedDeviceData = [STDSJSONWebEncryption encryptJSON:_deviceInformation.dictionaryValue + forDirectoryServer:_directoryServer + error:&error]; + } + if (encryptedDeviceData == nil) { + @throw [STDSRuntimeException exceptionWithMessage:@"Error encrypting device information %@", error]; + } + + return [[STDSAuthenticationRequestParameters alloc] initWithSDKTransactionIdentifier:_identifier + deviceData:encryptedDeviceData + sdkEphemeralPublicKey:_ephemeralKeyPair.publicKeyJWK + sdkAppIdentifier:[STDSDeviceInformationParameter sdkAppIdentifier] + sdkReferenceNumber:self.useULTestLOA ? kULTestLOA : kStripeLOA + messageVersion:[self _messageVersion]]; +} + +- (UIViewController *)createProgressViewControllerWithDidCancel:(void (^)(void))didCancel { + return [[STDSProgressViewController alloc] initWithDirectoryServer:[self _directoryServerForUI] uiCustomization:_uiCustomization didCancel:didCancel]; +} + +- (void)doChallengeWithViewController:(UIViewController *)presentingViewController + challengeParameters:(STDSChallengeParameters *)challengeParameters + challengeStatusReceiver:(id)challengeStatusReceiver + timeout:(NSTimeInterval)timeout { + + [self doChallengeWithChallengeParameters:challengeParameters + challengeStatusReceiver:challengeStatusReceiver + timeout:timeout + presentationBlock:^(UIViewController * _Nonnull challengeVC, void (^completion)(void)) { + UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:challengeVC]; + + // Disable "swipe to dismiss" behavior in iOS 13 + if ([navigationController respondsToSelector:NSSelectorFromString(@"isModalInPresentation")]) { + [navigationController setValue:@YES forKey:@"modalInPresentation"]; + } + + [presentingViewController presentViewController:navigationController animated:YES completion:^{ + completion(); + }]; + }]; +} + +- (void)doChallengeWithChallengeParameters:(STDSChallengeParameters *)challengeParameters + challengeStatusReceiver:(id)challengeStatusReceiver + timeout:(NSTimeInterval)timeout + presentationBlock:(void (^)(UIViewController *, void(^)(void)))presentationBlock { + if (self.isCompleted) { + @throw [STDSRuntimeException exceptionWithMessage:@"The transaction has already completed."]; + } else if (timeout < kMinimumTimeout) { + @throw [STDSInvalidInputException exceptionWithMessage:@"Timeout value of %lf seconds is less than 5 minutes", timeout]; + } + self.challengeStatusReceiver = challengeStatusReceiver; + self.timeoutTimer = [NSTimer scheduledTimerWithTimeInterval:(timeout) target:self selector:@selector(_didTimeout) userInfo:nil repeats:NO]; + + self.challengeRequestParameters = [[STDSChallengeRequestParameters alloc] initWithChallengeParameters:challengeParameters + transactionIdentifier:_identifier + messageVersion:[self _messageVersion]]; + + STDSJSONWebSignature *jws = [[STDSJSONWebSignature alloc] initWithString:challengeParameters.acsSignedContent allowNilKey:self.bypassTestModeVerification]; + BOOL validJWS = jws != nil; + if (validJWS && !self.bypassTestModeVerification) { + if (_customDirectoryServerCertificate != nil) { + if (_rootCertificateStrings.count == 0) { + validJWS = NO; + } else { + validJWS = [STDSJSONWebEncryption verifyJSONWebSignature:jws withCertificate:_customDirectoryServerCertificate rootCertificates:_rootCertificateStrings]; + } + } else { + validJWS = [STDSJSONWebEncryption verifyJSONWebSignature:jws forDirectoryServer:_directoryServer]; + } + } + if (!validJWS) { + dispatch_async(dispatch_get_main_queue(), ^{ + [challengeStatusReceiver transaction:self + didErrorWithRuntimeErrorEvent:[[STDSRuntimeErrorEvent alloc] initWithErrorCode:kSTDSRuntimeErrorCodeEncryptionError errorMessage:@"Error verifying JWS response."]]; + [self _cleanUp]; + }); + return; + } + + NSError *jsonError = nil; + NSDictionary *json = [NSJSONSerialization JSONObjectWithData:jws.payload options:0 error:&jsonError]; + NSDictionary *acsEphmeralKeyJWK = [json _stds_dictionaryForKey:@"acsEphemPubKey" required:NO error:NULL]; + STDSEllipticCurvePoint *acsEphemeralKey = [[STDSEllipticCurvePoint alloc] initWithJWK:acsEphmeralKeyJWK]; + NSString *acsURLString = [json _stds_stringForKey:@"acsURL" required:NO error:NULL]; + NSURL *acsURL = [NSURL URLWithString:acsURLString ?: @""]; + if (acsEphemeralKey == nil || acsURL == nil) { + dispatch_async(dispatch_get_main_queue(), ^{ + [challengeStatusReceiver transaction:self + didErrorWithRuntimeErrorEvent:[[STDSRuntimeErrorEvent alloc] initWithErrorCode:kSTDSRuntimeErrorCodeParsingError errorMessage:[NSString stringWithFormat:@"Unable to create key or url from ACS json: %@\n\n jsonError: %@", json, jsonError]]]; + [self _cleanUp]; + }); + return; + } + + NSData *ecdhSecret = [_ephemeralKeyPair createSharedSecretWithEllipticCurveKey:acsEphemeralKey]; + if (ecdhSecret == nil) { + dispatch_async(dispatch_get_main_queue(), ^{ + [challengeStatusReceiver transaction:self + didErrorWithRuntimeErrorEvent:[[STDSRuntimeErrorEvent alloc] initWithErrorCode:kSTDSRuntimeErrorCodeEncryptionError errorMessage:@"Error during Diffie-Helman key exchange"]]; + [self _cleanUp]; + }); + return; + } + + NSData *contentEncryptionKeySDKtoACS = STDSCreateConcatKDFWithSHA256(ecdhSecret, 32, self.useULTestLOA ? kULTestLOA : kStripeLOA); + // These two keys are intentionally identical + // ref. Protocol and Core Functions Specification Version 2.2.0 Section 6.2.3.2 & 6.2.3.3 + // "In this version of the specification [contentEncryptionKeyACStoSDK] and [contentEncryptionKeySDKtoACS] + // are extracted with the same value." + NSData *contentEncryptionKeyACStoSDK = [contentEncryptionKeySDKtoACS copy]; + + _networkingManager = [[STDSACSNetworkingManager alloc] initWithURL:acsURL + sdkContentEncryptionKey:contentEncryptionKeySDKtoACS + acsContentEncryptionKey:contentEncryptionKeyACStoSDK + acsTransactionIdentifier:self.challengeRequestParameters.acsTransactionIdentifier]; + // Start the Challenge flow + STDSImageLoader *imageLoader = [[STDSImageLoader alloc] initWithURLSession:NSURLSession.sharedSession]; + self.challengeResponseViewController = [[STDSChallengeResponseViewController alloc] initWithUICustomization:_uiCustomization imageLoader:imageLoader directoryServer:[self _directoryServerForUI]]; + self.challengeResponseViewController.delegate = self; + + presentationBlock(self.challengeResponseViewController, ^{ [self _makeChallengeRequest:self.challengeRequestParameters didCancel:NO]; }); +} + +- (void)close { + [self _cleanUp]; +} + +- (void)cancelChallengeFlow { + [self challengeResponseViewControllerDidCancel:self.challengeResponseViewController]; +} + +- (void)dealloc { + [self _cleanUp]; +} + +#pragma mark - Private + +// When we get a directory certificate and ID from the server, we mark it as Custom for encryption, +// but may have a correct mapping to a DS logo for the UI +- (STDSDirectoryServer)_directoryServerForUI { + return (_customDirectoryServerID != nil) ? STDSDirectoryServerForID(_customDirectoryServerID) : _directoryServer; +} + +- (void)_makeChallengeRequest:(STDSChallengeRequestParameters *)challengeRequestParameters didCancel:(BOOL)didCancel { + [self.challengeResponseViewController setLoading]; + __weak STDSTransaction *weakSelf = self; + [_networkingManager submitChallengeRequest:self.challengeRequestParameters + withCompletion:^(id _Nullable response, NSError * _Nullable error) { + STDSTransaction *strongSelf = weakSelf; + if (strongSelf == nil || strongSelf.isCompleted) { + return; + } + // Parsing or network errors + if (response == nil || error) { + if (!error) { + error = [NSError errorWithDomain:STDSStripe3DS2ErrorDomain code:STDSErrorCodeUnknownError userInfo:nil]; + } + [strongSelf _handleError:error]; + return; + } + // Consistency errors (e.g. acsTransID changes) + NSError *validationError; + if (![strongSelf _validateChallengeResponse:response error:&validationError]) { + [strongSelf _handleError:validationError]; + return; + } + [strongSelf _handleChallengeResponse:response didCancel:didCancel]; + }]; +} + +- (BOOL)_validateChallengeResponse:(id)challengeResponse error:(NSError **)outError { + NSError *error; + if (![challengeResponse.acsTransactionID isEqualToString:self.challengeRequestParameters.acsTransactionIdentifier] || + ![challengeResponse.threeDSServerTransactionID isEqualToString:self.challengeRequestParameters.threeDSServerTransactionIdentifier] || + ![challengeResponse.sdkTransactionID isEqualToString:self.challengeRequestParameters.sdkTransactionIdentifier]) { + error = [NSError errorWithDomain:STDSStripe3DS2ErrorDomain code:STDSErrorMessageErrorTransactionIDNotRecognized userInfo:nil]; + } else if (![challengeResponse.messageVersion isEqualToString:self.challengeRequestParameters.messageVersion]) { + error = [NSError _stds_invalidJSONFieldError:@"messageVersion"]; + } else if (!self.bypassTestModeVerification && ![challengeResponse.acsCounterACStoSDK isEqualToString:self.challengeRequestParameters.sdkCounterStoA]) { + error = [NSError errorWithDomain:STDSStripe3DS2ErrorDomain code:STDSErrorCodeDecryptionVerification userInfo:nil]; + } else if (challengeResponse.acsUIType == STDSACSUITypeHTML && !challengeResponse.acsHTML) { + error = [NSError errorWithDomain:STDSStripe3DS2ErrorDomain code:STDSErrorCodeDecryptionVerification userInfo:nil]; + } + + if (error && outError) { + *outError = error; + } + return error == nil; +} + +- (void) _handleError:(NSError *)error { + // All the codes corresponding to errors that we treat as protocol errors (ie send to the ACS and report as an STDSProtocolErrorEvent) + NSSet *protocolErrorCodes = [NSSet setWithArray:@[@(STDSErrorCodeUnknownMessageType), + @(STDSErrorCodeJSONFieldInvalid), + @(STDSErrorCodeJSONFieldMissing), + @(STDSErrorCodeReceivedErrorMessage), + @(STDSErrorMessageErrorTransactionIDNotRecognized), + @(STDSErrorCodeUnrecognizedCriticalMessageExtension), + @(STDSErrorCodeDecryptionVerification)]]; + if (error.domain == STDSStripe3DS2ErrorDomain) { + NSString *sdkTransactionIdentifier = _identifier; + NSString *acsTransactionIdentifier = self.challengeRequestParameters.acsTransactionIdentifier; + NSString *messageVersion = [self _messageVersion]; + STDSErrorMessage *errorMessage; + switch (error.code) { + case STDSErrorCodeReceivedErrorMessage: + errorMessage = error.userInfo[STDSStripe3DS2ErrorMessageErrorKey]; + break; + case STDSErrorCodeUnknownMessageType: + errorMessage = [STDSErrorMessage errorForInvalidMessageWithACSTransactionID:acsTransactionIdentifier messageVersion:messageVersion]; + break; + case STDSErrorCodeJSONFieldInvalid: + errorMessage = [STDSErrorMessage errorForJSONFieldInvalidWithACSTransactionID:acsTransactionIdentifier messageVersion:messageVersion error:error]; + break; + case STDSErrorCodeJSONFieldMissing: + errorMessage = [STDSErrorMessage errorForJSONFieldMissingWithACSTransactionID:acsTransactionIdentifier messageVersion:messageVersion error:error]; + break; + case STDSErrorCodeTimeout: + errorMessage = [STDSErrorMessage errorForTimeoutWithACSTransactionID:acsTransactionIdentifier messageVersion:messageVersion]; + break; + case STDSErrorMessageErrorTransactionIDNotRecognized: + errorMessage = [STDSErrorMessage errorForUnrecognizedIDWithACSTransactionID:acsTransactionIdentifier messageVersion:messageVersion]; + break; + case STDSErrorCodeUnrecognizedCriticalMessageExtension: + errorMessage = [STDSErrorMessage errorForUnrecognizedCriticalMessageExtensionsWithACSTransactionID:acsTransactionIdentifier messageVersion:messageVersion error:error]; + break; + case STDSErrorCodeDecryptionVerification: + errorMessage = [STDSErrorMessage errorForDecryptionErrorWithACSTransactionID:acsTransactionIdentifier messageVersion:messageVersion]; + break; + default: + break; + } + + // Send the ErrorMessage (unless we received one) + if (error.code != STDSErrorCodeReceivedErrorMessage && errorMessage != nil) { + [_networkingManager sendErrorMessage:errorMessage]; + } + + // If it's a protocol error, call back to the challengeStatusReceiver + if ([protocolErrorCodes containsObject:@(error.code)] && errorMessage != nil) { + STDSProtocolErrorEvent *protocolErrorEvent = [[STDSProtocolErrorEvent alloc] initWithSDKTransactionIdentifier:sdkTransactionIdentifier + errorMessage:errorMessage]; + [self.challengeStatusReceiver transaction:self didErrorWithProtocolErrorEvent:protocolErrorEvent]; + } + + } + + if (error.domain != STDSStripe3DS2ErrorDomain || ![protocolErrorCodes containsObject:@(error.code)]) { + // This error is not a protocol error, and therefore a runtime error. + NSString *errorCode = [NSString stringWithFormat:@"%ld", (long)error.code]; + STDSRuntimeErrorEvent *runtimeErrorEvent = [[STDSRuntimeErrorEvent alloc] initWithErrorCode:errorCode errorMessage:error.localizedDescription]; + [self.challengeStatusReceiver transaction:self didErrorWithRuntimeErrorEvent:runtimeErrorEvent]; + } + + [self _dismissChallengeResponseViewController]; + [self _cleanUp]; +} + +- (void)_handleChallengeResponse:(id)challengeResponse didCancel:(BOOL)didCancel { + if (challengeResponse.challengeCompletionIndicator) { + // Final CRes + // We need to pass didCancel to here because we can't distinguish between cancellation and auth failure from the CRes + // (they both result in a transactionStatus of "N") + if (didCancel) { + // We already dismissed the view controller + [self.challengeStatusReceiver transactionDidCancel:self]; + [self _cleanUp]; + } else { + [self _dismissChallengeResponseViewController]; + STDSCompletionEvent *completionEvent = [[STDSCompletionEvent alloc] initWithSDKTransactionIdentifier:_identifier + transactionStatus:challengeResponse.transactionStatus]; + [self.challengeStatusReceiver transaction:self didCompleteChallengeWithCompletionEvent:completionEvent]; + [self _cleanUp]; + } + } else { + [self.challengeResponseViewController setChallengeResponse:challengeResponse animated:YES]; + + if ([self.challengeStatusReceiver respondsToSelector:@selector(transactionDidPresentChallengeScreen:)]) { + [self.challengeStatusReceiver transactionDidPresentChallengeScreen:self]; + } + } +} + +- (void)_cleanUp { + [self.timeoutTimer invalidate]; + self.completed = YES; + self.challengeResponseViewController = nil; + self.challengeStatusReceiver = nil; + _networkingManager = nil; +} + +- (void)_didTimeout { + [self _dismissChallengeResponseViewController]; + [_networkingManager sendErrorMessage:[STDSErrorMessage errorForTimeoutWithACSTransactionID:self.challengeRequestParameters.acsTransactionIdentifier messageVersion:[self _messageVersion]]]; + [self.challengeStatusReceiver transactionDidTimeOut:self]; + [self _cleanUp]; +} + +- (void)_dismissChallengeResponseViewController { + if ([self.challengeStatusReceiver respondsToSelector:@selector(dismissChallengeViewController:forTransaction:)]) { + [self.challengeStatusReceiver dismissChallengeViewController:self.challengeResponseViewController forTransaction:self]; + } else { + [self.challengeResponseViewController dismissViewControllerAnimated:YES completion:nil]; + } +} + +#pragma mark Helpers + +- (nullable NSString *)_messageVersion { + NSString *messageVersion = STDSThreeDSProtocolVersionStringValue(_protocolVersion); + if (messageVersion == nil) { + @throw [STDSRuntimeException exceptionWithMessage:@"Error determining message version."]; + } + return messageVersion; +} + +/// Convenience method to construct a CSV from the names of each STDSChallengeResponseSelectionInfo in the given array +- (NSString *)_csvForChallengeResponseSelectionInfo:(NSArray> *)selectionInfoArray { + NSMutableArray *selectionInfoNames = [NSMutableArray new]; + for (id selectionInfo in selectionInfoArray) { + [selectionInfoNames addObject:selectionInfo.name]; + } + return [selectionInfoNames componentsJoinedByString:@","]; +} + +#pragma mark - STDSChallengeResponseViewController + +- (void)challengeResponseViewController:(nonnull STDSChallengeResponseViewController *)viewController didSubmitInput:(nonnull NSString *)userInput whitelistSelection:(nonnull id)whitelistSelection { + self.challengeRequestParameters = [self.challengeRequestParameters nextChallengeRequestParametersByIncrementCounter]; + self.challengeRequestParameters.challengeDataEntry = userInput; + self.challengeRequestParameters.whitelistingDataEntry = whitelistSelection.name; + [self _makeChallengeRequest:self.challengeRequestParameters didCancel:NO]; +} + +- (void)challengeResponseViewController:(nonnull STDSChallengeResponseViewController *)viewController didSubmitSelection:(nonnull NSArray> *)selection whitelistSelection:(nonnull id)whitelistSelection { + self.challengeRequestParameters = [self.challengeRequestParameters nextChallengeRequestParametersByIncrementCounter]; + self.challengeRequestParameters.challengeDataEntry = [self _csvForChallengeResponseSelectionInfo:selection]; + self.challengeRequestParameters.whitelistingDataEntry = whitelistSelection.name; + [self _makeChallengeRequest:self.challengeRequestParameters didCancel:NO]; +} + +- (void)challengeResponseViewControllerDidOOBContinue:(nonnull STDSChallengeResponseViewController *)viewController whitelistSelection:(nonnull id)whitelistSelection { + self.challengeRequestParameters = [self.challengeRequestParameters nextChallengeRequestParametersByIncrementCounter]; + self.challengeRequestParameters.oobContinue = @(YES); + self.challengeRequestParameters.whitelistingDataEntry = whitelistSelection.name; + [self _makeChallengeRequest:self.challengeRequestParameters didCancel:NO]; +} + +- (void)challengeResponseViewControllerDidCancel:(STDSChallengeResponseViewController *)viewController { + self.challengeRequestParameters = [self.challengeRequestParameters nextChallengeRequestParametersByIncrementCounter]; + self.challengeRequestParameters.challengeCancel = @(STDSChallengeCancelTypeCardholderSelectedCancel); + [self _dismissChallengeResponseViewController]; + [self _makeChallengeRequest:self.challengeRequestParameters didCancel:YES]; +} + +- (void)challengeResponseViewControllerDidRequestResend:(STDSChallengeResponseViewController *)viewController { + self.challengeRequestParameters = [self.challengeRequestParameters nextChallengeRequestParametersByIncrementCounter]; + self.challengeRequestParameters.resendChallenge = @"Y"; + [self _makeChallengeRequest:self.challengeRequestParameters didCancel:NO]; +} + +- (void)challengeResponseViewController:(nonnull STDSChallengeResponseViewController *)viewController didSubmitHTMLForm:(nonnull NSString *)form { + self.challengeRequestParameters = [self.challengeRequestParameters nextChallengeRequestParametersByIncrementCounter]; + self.challengeRequestParameters.challengeHTMLDataEntry = form; + [self _makeChallengeRequest:self.challengeRequestParameters didCancel:NO]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSUICustomization.h b/Stripe3DS2/Stripe3DS2/include/STDSUICustomization.h new file mode 100644 index 00000000..651b22f1 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSUICustomization.h @@ -0,0 +1,109 @@ +// +// STDSUICustomization.h +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/14/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import "STDSCustomization.h" +#import "STDSButtonCustomization.h" +#import "STDSNavigationBarCustomization.h" +#import "STDSLabelCustomization.h" +#import "STDSTextFieldCustomization.h" +#import "STDSFooterCustomization.h" +#import "STDSSelectionCustomization.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + The `STDSUICustomization` provides configuration for UI elements. + + It's important to configure this object appropriately before using it to initialize a + `STDSThreeDS2Service` object. `STDSThreeDS2Service` makes a copy of the customization + settings you provide; it ignores any subsequent changes you make to your `STDSUICustomization` instance. +*/ +@interface STDSUICustomization: NSObject + +/// The default settings. See individual properties for their default values. ++ (instancetype)defaultSettings; + +/** + Provides custom settings for the UINavigationBar of all UIViewControllers the SDK display. + The default is `[STDSNavigationBarCustomization defaultSettings]`. + */ +@property (nonatomic) STDSNavigationBarCustomization *navigationBarCustomization; + +/** + Provides custom settings for labels. + The default is `[STDSLabelCustomization defaultSettings]`. + */ +@property (nonatomic) STDSLabelCustomization *labelCustomization; + +/** + Provides custom settings for text fields. + The default is `[STDSTextFieldCustomization defaultSettings]`. + */ +@property (nonatomic) STDSTextFieldCustomization *textFieldCustomization; + +/** + The primary background color of all UIViewControllers the SDK display. + Defaults to white. + */ +@property (nonatomic) UIColor *backgroundColor; + +/** + The Challenge view displays a footer with additional details. This controls the background color of that view. + Defaults to gray. + */ +@property (nonatomic) STDSFooterCustomization *footerCustomization; + +/** + Sets a given button customization for the specified type. + + @param buttonCustomization The buttom customization to use. + @param buttonType The type of button to use the customization for. + */ +- (void)setButtonCustomization:(STDSButtonCustomization *)buttonCustomization forType:(STDSUICustomizationButtonType)buttonType; + +/** + Retrieves a button customization object for the given button type. + + @param buttonType The button type to retrieve a customization object for. + @return A button customization object, or the default if none was set. + @see STDSButtonCustomization + */ +- (STDSButtonCustomization *)buttonCustomizationForButtonType:(STDSUICustomizationButtonType)buttonType; + +/** + Provides custom settings for radio buttons and checkboxes. + The default is `[STDSSelectionCustomization defaultSettings]`. + */ +@property (nonatomic) STDSSelectionCustomization *selectionCustomization; + + +/** + The preferred status bar style for all UIViewControllers the SDK display. + Defaults to UIStatusBarStyleDefault. + */ +@property (nonatomic) UIStatusBarStyle preferredStatusBarStyle; + +#pragma mark - Progress View + +/** + The style of UIActivityIndicatorViews displayed. + This should contrast with `backgroundColor`. Defaults to regular on iOS 13+, + gray on iOS 10-12. + */ +@property (nonatomic) UIActivityIndicatorViewStyle activityIndicatorViewStyle; + +/** + The style of the UIBlurEffect displayed underneath the UIActivityIndicatorView. + Defaults to UIBlurEffectStyleDefault. + */ +@property (nonatomic) UIBlurEffectStyle blurStyle; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSUICustomization.m b/Stripe3DS2/Stripe3DS2/include/STDSUICustomization.m new file mode 100644 index 00000000..8d1d6812 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSUICustomization.m @@ -0,0 +1,83 @@ +// +// STDSUICustomization.m +// Stripe3DS2 +// +// Created by Andrew Harrison on 3/14/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSUICustomization.h" +#import "UIColor+ThirteenSupport.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSUICustomization() + +@property (nonatomic, strong) NSMutableDictionary *buttonCustomizationDictionary; + +@end + +@implementation STDSUICustomization + ++ (instancetype)defaultSettings { + return [[STDSUICustomization alloc] init]; +} + +- (instancetype)init { + self = [super init]; + + if (self) { + _buttonCustomizationDictionary = [@{ + @(STDSUICustomizationButtonTypeNext): [STDSButtonCustomization defaultSettingsForButtonType:STDSUICustomizationButtonTypeNext + ], + @(STDSUICustomizationButtonTypeCancel): [STDSButtonCustomization defaultSettingsForButtonType:STDSUICustomizationButtonTypeCancel], + @(STDSUICustomizationButtonTypeResend): [STDSButtonCustomization defaultSettingsForButtonType:STDSUICustomizationButtonTypeResend], + @(STDSUICustomizationButtonTypeSubmit): [STDSButtonCustomization defaultSettingsForButtonType:STDSUICustomizationButtonTypeSubmit], + @(STDSUICustomizationButtonTypeContinue): [STDSButtonCustomization defaultSettingsForButtonType:STDSUICustomizationButtonTypeContinue], + } mutableCopy]; + _navigationBarCustomization = [STDSNavigationBarCustomization defaultSettings]; + _labelCustomization = [STDSLabelCustomization defaultSettings]; + _textFieldCustomization = [STDSTextFieldCustomization defaultSettings]; + _footerCustomization = [STDSFooterCustomization defaultSettings]; + _selectionCustomization = [STDSSelectionCustomization defaultSettings]; + _backgroundColor = UIColor._stds_systemBackgroundColor; + _activityIndicatorViewStyle = UIActivityIndicatorViewStyleMedium; + _blurStyle = UIBlurEffectStyleRegular; + _preferredStatusBarStyle = UIStatusBarStyleDefault; + } + + return self; +} + +- (void)setButtonCustomization:(STDSButtonCustomization *)buttonCustomization forType:(STDSUICustomizationButtonType)buttonType { + self.buttonCustomizationDictionary[@(buttonType)] = buttonCustomization; +} + +- (STDSButtonCustomization *)buttonCustomizationForButtonType:(STDSUICustomizationButtonType)buttonType { + return self.buttonCustomizationDictionary[@(buttonType)]; +} + +#pragma mark - NSCopying + +- (instancetype)copyWithZone:(nullable NSZone *)zone { + STDSUICustomization *copy = [[[self class] allocWithZone:zone] init]; + copy.navigationBarCustomization = [self.navigationBarCustomization copy]; + copy.labelCustomization = [self.labelCustomization copy]; + copy.textFieldCustomization = [self.textFieldCustomization copy]; + NSMutableDictionary *buttonCustomizationDictionary = [NSMutableDictionary new]; + for (NSNumber *buttonCustomization in self.buttonCustomizationDictionary) { + buttonCustomizationDictionary[buttonCustomization] = [self.buttonCustomizationDictionary[buttonCustomization] copy]; + } + copy.buttonCustomizationDictionary = buttonCustomizationDictionary; + copy.footerCustomization = [self.footerCustomization copy]; + copy.selectionCustomization = [self.selectionCustomization copy]; + copy.backgroundColor = self.backgroundColor; + copy.activityIndicatorViewStyle = self.activityIndicatorViewStyle; + copy.blurStyle = self.blurStyle; + copy.preferredStatusBarStyle = self.preferredStatusBarStyle; + return copy; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSWarning.h b/Stripe3DS2/Stripe3DS2/include/STDSWarning.h new file mode 100644 index 00000000..5f09fe70 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSWarning.h @@ -0,0 +1,69 @@ +// +// STDSWarning.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 2/12/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + The `STDSWarningSeverity` enum defines the severity levels of warnings generated + during SDK initialization. @see STDSThreeDS2Service + */ +typedef NS_ENUM(NSInteger, STDSWarningSeverity) { + /** + Low severity + */ + STDSWarningSeverityLow = 0, + + /** + Medium severity + */ + STDSWarningSeverityMedium, + + /** + High severity + */ + STDSWarningSeverityHigh, +}; + +/** + The `STDSWarning` class represents warnings generated by `STDSThreeDS2Service` during + security checks run during initialization. @see STDSThreeDS2Service + */ +@interface STDSWarning : NSObject + +/** + Designated initializer for `STDSWarning`. + */ +- (instancetype)initWithIdentifier:(NSString *)identifier + message:(NSString *)message + severity:(STDSWarningSeverity)severity NS_DESIGNATED_INITIALIZER; + +/** + `STDSWarning` should not be directly initialized. + */ +- (instancetype)init NS_UNAVAILABLE; + +/** + The identifier for this warning instance. + */ +@property (nonatomic, readonly) NSString *identifier; + +/** + The descriptive message for this warning. + */ +@property (nonatomic, readonly) NSString *message; + +/** + The severity of this warning. + */ +@property (nonatomic, readonly) STDSWarningSeverity severity; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/STDSWarning.m b/Stripe3DS2/Stripe3DS2/include/STDSWarning.m new file mode 100644 index 00000000..ad0d6301 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/STDSWarning.m @@ -0,0 +1,30 @@ +// +// STDSWarning.m +// Stripe3DS2 +// +// Created by Cameron Sabol on 2/12/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSWarning.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation STDSWarning + +- (instancetype)initWithIdentifier:(NSString *)identifier + message:(NSString *)message + severity:(STDSWarningSeverity)severity { + self = [super init]; + if (self) { + _identifier = [identifier copy]; + _message = [message copy]; + _severity = severity; + } + + return self; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2/include/Stripe3DS2-Prefix.pch b/Stripe3DS2/Stripe3DS2/include/Stripe3DS2-Prefix.pch new file mode 100644 index 00000000..888096ff --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/Stripe3DS2-Prefix.pch @@ -0,0 +1,14 @@ +// +// Stripe3DS2-Prefix.pch +// Stripe3DS2 +// +// Created by Cameron Sabol on 4/16/20. +// Copyright © 2020 Stripe. All rights reserved. +// + +#ifndef Stripe3DS2_pch +#define Stripe3DS2_pch + +#import "STDSLocalizedString.h" + +#endif /* Stripe3DS2_pch */ diff --git a/Stripe3DS2/Stripe3DS2/include/Stripe3DS2.h b/Stripe3DS2/Stripe3DS2/include/Stripe3DS2.h new file mode 100644 index 00000000..9e9f3eba --- /dev/null +++ b/Stripe3DS2/Stripe3DS2/include/Stripe3DS2.h @@ -0,0 +1,52 @@ +// +// Stripe3DS2.h +// Stripe3DS2 +// +// Created by Cameron Sabol on 1/16/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +//! Project version number for Stripe3DS2. +FOUNDATION_EXPORT double Stripe3DS2VersionNumber; + +//! Project version string for Stripe3DS2. +FOUNDATION_EXPORT const unsigned char Stripe3DS2VersionString[]; + +#import "STDSConfigParameters.h" +#import "STDSThreeDS2Service.h" +#import "STDSUICustomization.h" +#import "STDSWarning.h" + +#import "STDSAlreadyInitializedException.h" +#import "STDSInvalidInputException.h" +#import "STDSNotInitializedException.h" +#import "STDSRuntimeException.h" + +#import "STDSErrorMessage.h" +#import "STDSProtocolErrorEvent.h" +#import "STDSRuntimeErrorEvent.h" +#import "STDSStripe3DS2Error.h" +#import "STDSThreeDSProtocolVersion.h" + +#import "STDSAuthenticationRequestParameters.h" +#import "STDSAuthenticationResponse.h" +#import "STDSChallengeParameters.h" +#import "STDSChallengeStatusReceiver.h" +#import "STDSCompletionEvent.h" +#import "STDSJSONDecodable.h" +#import "STDSJSONEncoder.h" +#import "STDSTransaction.h" + +#import "STDSButtonCustomization.h" +#import "STDSCustomization.h" +#import "STDSException.h" +#import "STDSFooterCustomization.h" +#import "STDSJSONEncodable.h" +#import "STDSLabelCustomization.h" +#import "STDSNavigationBarCustomization.h" +#import "STDSSelectionCustomization.h" +#import "STDSTextFieldCustomization.h" + +#import "STDSSwiftTryCatch.h" diff --git a/Stripe3DS2/Stripe3DS2DemoUI/Info.plist b/Stripe3DS2/Stripe3DS2DemoUI/Info.plist new file mode 100644 index 00000000..80cd6052 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2DemoUI/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/Stripe3DS2/Stripe3DS2DemoUI/Resources/acs_challenge.html b/Stripe3DS2/Stripe3DS2DemoUI/Resources/acs_challenge.html new file mode 100644 index 00000000..4b576e2b --- /dev/null +++ b/Stripe3DS2/Stripe3DS2DemoUI/Resources/acs_challenge.html @@ -0,0 +1,178 @@ + + + + + 3DS - One-Time Passcode - PA + + + + + + +
+ + + + +
+
+

Purchase Authentication

+

We have send you a text message with a code to your registered mobile number ending in ***.

+

You are paying Merchant ABC the amount of $xxx.xx on mm/dd/yy.

+
+ +
+

Enter your code below:

+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + Need some help? +

Help content will be displayed here.

+ +
+
+
+
+ + Learn more about authentication +

Authentication information will be displayed here.

+ +
+
+
+ + +
+ +
+
+ diff --git a/Stripe3DS2/Stripe3DS2DemoUI/Sources/AppDelegate.h b/Stripe3DS2/Stripe3DS2DemoUI/Sources/AppDelegate.h new file mode 100644 index 00000000..0483838d --- /dev/null +++ b/Stripe3DS2/Stripe3DS2DemoUI/Sources/AppDelegate.h @@ -0,0 +1,15 @@ +// +// AppDelegate.h +// Stripe3DS2DemoUI +// +// Created by Andrew Harrison on 2/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +@interface AppDelegate : UIResponder + +@property (strong, nonatomic) UIWindow *window; + +@end diff --git a/Stripe3DS2/Stripe3DS2DemoUI/Sources/AppDelegate.m b/Stripe3DS2/Stripe3DS2DemoUI/Sources/AppDelegate.m new file mode 100644 index 00000000..0cf5fc19 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2DemoUI/Sources/AppDelegate.m @@ -0,0 +1,36 @@ +// +// AppDelegate.m +// Stripe3DS2DemoUI +// +// Created by Andrew Harrison on 2/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +@import Stripe3DS2; + +#import "AppDelegate.h" +#import "STDSDemoViewController.h" +#import "STDSImageLoader.h" + +@interface AppDelegate () + +@end + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + + self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + + STDSImageLoader *imageLoader = [[STDSImageLoader alloc] initWithURLSession:NSURLSession.sharedSession]; + STDSDemoViewController *demoViewController = [[STDSDemoViewController alloc] initWithImageLoader:imageLoader]; + UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:demoViewController]; + + self.window.rootViewController = navigationController; + + [self.window makeKeyAndVisible]; + + return YES; +} + +@end diff --git a/Stripe3DS2/Stripe3DS2DemoUI/Sources/STDSChallengeResponseObject+TestObjects.h b/Stripe3DS2/Stripe3DS2DemoUI/Sources/STDSChallengeResponseObject+TestObjects.h new file mode 100644 index 00000000..3a1ab053 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2DemoUI/Sources/STDSChallengeResponseObject+TestObjects.h @@ -0,0 +1,23 @@ +// +// STDSChallengeResponseObject+TestObjects.h +// Stripe3DS2DemoUI +// +// Created by Andrew Harrison on 3/7/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSChallengeResponseObject.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSChallengeResponseObject (TestObjects) + ++ (id)textChallengeResponseWithWhitelist:(BOOL)whitelist resendCode:(BOOL)resendCode; ++ (id)singleSelectChallengeResponse; ++ (id)multiSelectChallengeResponse; ++ (id)OOBChallengeResponse; ++ (id)HTMLChallengeResponse; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2DemoUI/Sources/STDSChallengeResponseObject+TestObjects.m b/Stripe3DS2/Stripe3DS2DemoUI/Sources/STDSChallengeResponseObject+TestObjects.m new file mode 100644 index 00000000..934c313d --- /dev/null +++ b/Stripe3DS2/Stripe3DS2DemoUI/Sources/STDSChallengeResponseObject+TestObjects.m @@ -0,0 +1,197 @@ +// +// STDSChallengeResponseObject+TestObjects.m +// Stripe3DS2DemoUI +// +// Created by Andrew Harrison on 3/7/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSChallengeResponseObject+TestObjects.h" +#import "STDSChallengeResponseSelectionInfoObject.h" +#import "STDSChallengeResponseImageObject.h" + +@implementation STDSChallengeResponseObject (TestObjects) + ++ (id)textChallengeResponseWithWhitelist:(BOOL)whitelist resendCode:(BOOL)resendCode { + return [[STDSChallengeResponseObject alloc] initWithThreeDSServerTransactionID:@"" + acsCounterACStoSDK:@"" + acsTransactionID:@"" + acsHTML:nil + acsHTMLRefresh:nil + acsUIType:STDSACSUITypeText + challengeCompletionIndicator:NO + challengeInfoHeader:@"Verify by phone" + challengeInfoLabel:@"Enter your 6 digit code:" + challengeInfoText:@"Great! We have sent you a text message with secure code to your registered mobile phone number.\n\nSent to a number ending in •••• •••• 4729." + challengeAdditionalInfoText:nil + showChallengeInfoTextIndicator:NO + challengeSelectInfo:nil + expandInfoLabel:@"Expand Info Label" + expandInfoText:@"This field displays expandable information text provided by the ACS." + issuerImage:[self issuerImage] + messageExtensions:nil + messageVersion:@"" + oobAppURL:nil + oobAppLabel:nil + oobContinueLabel:nil + paymentSystemImage:[self paymentImage] + resendInformationLabel:resendCode ? @"Resend code" : nil + sdkTransactionID:@"" + submitAuthenticationLabel:@"Submit" + whitelistingInfoText:whitelist ? @"Would you like to add this Merchant to your whitelist?" : nil + whyInfoLabel:@"Learn more about authentication" + whyInfoText:@"This is additional information about authentication. You are being provided extra information you wouldn't normally see, because you've tapped on the above label." + transactionStatus:nil]; +} + ++ (id)singleSelectChallengeResponse { + STDSChallengeResponseSelectionInfoObject *infoObject1 = [[STDSChallengeResponseSelectionInfoObject alloc] initWithName:@"Mobile" value:@"***-***-*321"]; + STDSChallengeResponseSelectionInfoObject *infoObject2 = [[STDSChallengeResponseSelectionInfoObject alloc] initWithName:@"Email" value:@"a******3@g****.com"]; + + return [[STDSChallengeResponseObject alloc] initWithThreeDSServerTransactionID:@"" + acsCounterACStoSDK:@"" + acsTransactionID:@"" + acsHTML:nil + acsHTMLRefresh:nil + acsUIType:STDSACSUITypeSingleSelect + challengeCompletionIndicator:NO + challengeInfoHeader:@"Payment Security" + challengeInfoLabel:nil + challengeInfoText:@"Hi Steve, your online payment is being secured using Card Network. Please select the location you would like to receive the code from YourBank." + challengeAdditionalInfoText:nil + showChallengeInfoTextIndicator:NO + challengeSelectInfo:@[infoObject1, infoObject2] + expandInfoLabel:@"Need some help?" + expandInfoText:@"You've indicated that you need help! We'd be happy to assist with that, by providing helpful text here that makes sense in context." + issuerImage:nil + messageExtensions:nil + messageVersion:@"" + oobAppURL:nil + oobAppLabel:nil + oobContinueLabel:nil + paymentSystemImage:nil + resendInformationLabel:nil + sdkTransactionID:@"" + submitAuthenticationLabel:@"Next" + whitelistingInfoText:nil + whyInfoLabel:@"Learn more about authentication" + whyInfoText:@"This is additional information about authentication. You are being provided extra information you wouldn't normally see, because you've tapped on the above label." + transactionStatus:nil]; +} + ++ (id)multiSelectChallengeResponse { + STDSChallengeResponseSelectionInfoObject *infoObject1 = [[STDSChallengeResponseSelectionInfoObject alloc] initWithName:@"Option1" value:@"Chicago, Illinois"]; + STDSChallengeResponseSelectionInfoObject *infoObject2 = [[STDSChallengeResponseSelectionInfoObject alloc] initWithName:@"Option2" value:@"Portland, Oregon"]; + STDSChallengeResponseSelectionInfoObject *infoObject3 = [[STDSChallengeResponseSelectionInfoObject alloc] initWithName:@"Option3" value:@"Dallas, Texas"]; + STDSChallengeResponseSelectionInfoObject *infoObject4 = [[STDSChallengeResponseSelectionInfoObject alloc] initWithName:@"Option4" value:@"St Louis, Missouri"]; + + return [[STDSChallengeResponseObject alloc] initWithThreeDSServerTransactionID:@"" + acsCounterACStoSDK:@"" + acsTransactionID:@"" + acsHTML:nil + acsHTMLRefresh:nil + acsUIType:STDSACSUITypeMultiSelect + challengeCompletionIndicator:NO + challengeInfoHeader:@"Payment Security" + challengeInfoLabel:@"Question 2: What cities have you lived in?" + challengeInfoText:@"Please answer 3 security questions from YourBank to complete your payment.\n\nSelect all that apply." + challengeAdditionalInfoText:nil + showChallengeInfoTextIndicator:NO + challengeSelectInfo:@[infoObject1, infoObject2, infoObject3, infoObject4] + expandInfoLabel:nil + expandInfoText:nil + issuerImage:nil + messageExtensions:nil + messageVersion:@"" + oobAppURL:nil + oobAppLabel:nil + oobContinueLabel:nil + paymentSystemImage:nil + resendInformationLabel:nil + sdkTransactionID:@"" + submitAuthenticationLabel:@"Next" + whitelistingInfoText:nil + whyInfoLabel:@"Learn more about authentication" + whyInfoText:@"This is additional information about authentication. You are being provided extra information you wouldn't normally see, because you've tapped on the above label." + transactionStatus:nil]; +} + ++ (id)OOBChallengeResponse { + return [[STDSChallengeResponseObject alloc] initWithThreeDSServerTransactionID:@"" + acsCounterACStoSDK:@"" + acsTransactionID:@"" + acsHTML:nil + acsHTMLRefresh:nil + acsUIType:STDSACSUITypeOOB + challengeCompletionIndicator:NO + challengeInfoHeader:@"Payment Security" + challengeInfoLabel:nil + challengeInfoText:@"For added security, you will be authenticated with YourBank application.\n\nStep 1 - Open your YourBank application directly from your phone and verify this payment.\n\nStep 2 - Tap continue after you have completed authentication with your YourBank application." + challengeAdditionalInfoText:nil + showChallengeInfoTextIndicator:YES + challengeSelectInfo:nil + expandInfoLabel:@"Need some help?" + expandInfoText:@"You've indicated that you need help! We'd be happy to assist with that, by providing helpful text here that makes sense in context." + issuerImage:[self issuerImage] + messageExtensions:nil + messageVersion:@"" + oobAppURL:nil + oobAppLabel:nil + oobContinueLabel:@"Continue" + paymentSystemImage:[self paymentImage] + resendInformationLabel:nil + sdkTransactionID:@"" + submitAuthenticationLabel:nil + whitelistingInfoText:nil + whyInfoLabel:@"Learn more about authentication" + whyInfoText:@"This is additional information about authentication. You are being provided extra information you wouldn't normally see, because you've tapped on the above label." + transactionStatus:nil]; +} + ++ (id)HTMLChallengeResponse { + NSString *htmlFilePath = [[NSBundle mainBundle] pathForResource:@"acs_challenge" ofType:@"html"]; + NSString *html = [NSString stringWithContentsOfFile:htmlFilePath encoding:NSUTF8StringEncoding error:nil]; + return [[STDSChallengeResponseObject alloc] initWithThreeDSServerTransactionID:@"" + acsCounterACStoSDK:@"" + acsTransactionID:@"" + acsHTML:html + acsHTMLRefresh:nil + acsUIType:STDSACSUITypeHTML + challengeCompletionIndicator:NO + challengeInfoHeader:nil + challengeInfoLabel:nil + challengeInfoText:nil + challengeAdditionalInfoText:nil + showChallengeInfoTextIndicator:NO + challengeSelectInfo:nil + expandInfoLabel:nil + expandInfoText:nil + issuerImage:nil + messageExtensions:nil + messageVersion:@"" + oobAppURL:nil + oobAppLabel:nil + oobContinueLabel:nil + paymentSystemImage:nil + resendInformationLabel:nil + sdkTransactionID:@"" + submitAuthenticationLabel:nil + whitelistingInfoText:nil + whyInfoLabel:nil + whyInfoText:nil + transactionStatus:nil]; +} + ++ (id)issuerImage { + return [[STDSChallengeResponseImageObject alloc] initWithMediumDensityURL:[NSURL URLWithString:@"https://via.placeholder.com/150.png?text=150+ISSUER"] + highDensityURL:[NSURL URLWithString:@"https://via.placeholder.com/300.png?text=300+ISSUER"] + extraHighDensityURL:[NSURL URLWithString:@"https://via.placeholder.com/450.png?text=450+ISSUER"]]; +} + ++ (id)paymentImage { + return [[STDSChallengeResponseImageObject alloc] initWithMediumDensityURL:[NSURL URLWithString:@"https://via.placeholder.com/150.png?text=150+PAYMENT"] + highDensityURL:[NSURL URLWithString:@"https://via.placeholder.com/300.png?text=300+PAYMENT"] + extraHighDensityURL:[NSURL URLWithString:@"https://via.placeholder.com/450.png?text=450+PAYMENT"]]; +} + +@end diff --git a/Stripe3DS2/Stripe3DS2DemoUI/Sources/STDSDemoViewController.h b/Stripe3DS2/Stripe3DS2DemoUI/Sources/STDSDemoViewController.h new file mode 100644 index 00000000..db53f304 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2DemoUI/Sources/STDSDemoViewController.h @@ -0,0 +1,20 @@ +// +// STDSDemoViewController.h +// Stripe3DS2DemoUI +// +// Created by Andrew Harrison on 3/11/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import "STDSImageLoader.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSDemoViewController: UIViewController + +- (instancetype)initWithImageLoader:(STDSImageLoader *)imageLoader; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2DemoUI/Sources/STDSDemoViewController.m b/Stripe3DS2/Stripe3DS2DemoUI/Sources/STDSDemoViewController.m new file mode 100644 index 00000000..fa4ddeee --- /dev/null +++ b/Stripe3DS2/Stripe3DS2DemoUI/Sources/STDSDemoViewController.m @@ -0,0 +1,225 @@ +// +// STDSDemoViewController.m +// Stripe3DS2DemoUI +// +// Created by Andrew Harrison on 3/11/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSDemoViewController.h" +#import "STDSChallengeResponseViewController.h" +#import "STDSChallengeResponseObject+TestObjects.h" +#import "STDSProgressViewController.h" +#import "STDSStackView.h" +#import "UIView+LayoutSupport.h" +#import "UIColor+ThirteenSupport.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSDemoViewController () + +@property (nonatomic, strong) STDSImageLoader *imageLoader; +@property (nonatomic) BOOL shouldLoadSlowly; +@property (nonatomic) STDSUICustomization *customization; +@property (nonatomic) BOOL isDarkMode; + +@end + +@implementation STDSDemoViewController + +- (instancetype)initWithImageLoader:(STDSImageLoader *)imageLoader { + self = [super initWithNibName:nil bundle:nil]; + + if (self) { + _imageLoader = imageLoader; + _customization = [STDSUICustomization defaultSettings]; + } + + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + + self.view.backgroundColor = [UIColor _stds_systemBackgroundColor]; + + STDSStackView *containerView = [[STDSStackView alloc] initWithAlignment:STDSStackViewLayoutAxisVertical]; + [self.view addSubview:containerView]; + [containerView _stds_pinToSuperviewBounds]; + + NSDictionary *buttonTitleToSelectorMapping = @{ + @"Toggle Dark Mode": NSStringFromSelector(@selector(toggleDarkMode)), + @"Present Text Challenge": NSStringFromSelector(@selector(presentTextChallenge)), + @"Present Text Challenge With Whitelist": NSStringFromSelector(@selector(presentTextChallengeWithWhitelist)), + @"Present Text Challenge With Resend": NSStringFromSelector(@selector(presentTextChallengeWithResendCode)), + @"Present Text Challenge With Whitelist and Resend": NSStringFromSelector(@selector(presentTextChallengeWithResendCodeAndWhitelist)), + @"Present Text Challenge (loads slowly w/ initial progressView)": NSStringFromSelector(@selector(presentTextChallengeLoadsSlowly)), + @"Present Single Select Challenge": NSStringFromSelector(@selector(presentSingleSelectChallenge)), + @"Present Multi Select Challenge": NSStringFromSelector(@selector(presentMultiSelectChallenge)), + @"Present OOB Challenge": NSStringFromSelector(@selector(presentOOBChallenge)), + @"Present HTML Challenge": NSStringFromSelector(@selector(presentHTMLChallenge)), + @"Present Progress View": NSStringFromSelector(@selector(presentProgressView)), + }; + for (NSString *key in [buttonTitleToSelectorMapping keysSortedByValueUsingComparator:^(NSString *a, NSString *b) { + return [a compare:b]; + }]) { + UIButton *button = [UIButton buttonWithType:UIButtonTypeSystem]; + [button addTarget:self action:NSSelectorFromString(buttonTitleToSelectorMapping[key]) forControlEvents:UIControlEventTouchUpInside]; + button.titleLabel.numberOfLines = 0; + button.titleLabel.textAlignment = NSTextAlignmentCenter; + [button setTitle:key forState:UIControlStateNormal]; + [containerView addArrangedSubview:button]; + } + [containerView addArrangedSubview:[UIView new]]; +} + +- (void)toggleDarkMode { + if (self.isDarkMode) { + self.customization = [STDSUICustomization defaultSettings]; + self.isDarkMode = false; + } else { + self.customization = [STDSUICustomization defaultSettings]; + // Navigation bar + self.customization.navigationBarCustomization = [STDSNavigationBarCustomization new]; + self.customization.navigationBarCustomization.headerText = @"Authentication"; + self.customization.navigationBarCustomization.buttonText = @"Nope"; + self.customization.navigationBarCustomization.textColor = UIColor.whiteColor; + self.customization.navigationBarCustomization.barStyle = UIBarStyleBlack; + + // General + self.customization.backgroundColor = UIColor.blackColor; + self.customization.activityIndicatorViewStyle = UIActivityIndicatorViewStyleMedium; + self.customization.preferredStatusBarStyle = UIStatusBarStyleLightContent; + self.customization.footerCustomization = [STDSFooterCustomization new]; + self.customization.footerCustomization.backgroundColor = [UIColor colorWithRed:.08 green:.08 blue:.08 alpha:1]; + self.customization.footerCustomization.headingFont = [UIFont boldSystemFontOfSize:15]; + self.customization.footerCustomization.headingTextColor = [UIColor colorWithRed:.95 green:.95 blue:.95 alpha:1]; + self.customization.footerCustomization.textColor = [UIColor colorWithRed:.90 green:.90 blue:.90 alpha:1]; + + // Cancel button + STDSButtonCustomization *cancelButtonCustomization = [STDSButtonCustomization defaultSettingsForButtonType:STDSUICustomizationButtonTypeCancel]; + cancelButtonCustomization.textColor = UIColor.grayColor; + cancelButtonCustomization.titleStyle = STDSButtonTitleStyleUppercase; + [self.customization setButtonCustomization:cancelButtonCustomization forType:STDSUICustomizationButtonTypeCancel]; + + // Text + self.customization.labelCustomization.headingTextColor = UIColor.whiteColor; + self.customization.labelCustomization.textColor = UIColor.whiteColor; + + // Text field + self.customization.textFieldCustomization.keyboardAppearance = UIKeyboardAppearanceDark; + self.customization.textFieldCustomization.textColor = UIColor.whiteColor; + self.customization.textFieldCustomization.borderColor = UIColor.whiteColor; + + // Radio/Checkbox + self.customization.selectionCustomization.secondarySelectedColor = UIColor.lightGrayColor; + self.customization.selectionCustomization.unselectedBorderColor = UIColor.blackColor; + self.customization.selectionCustomization.unselectedBackgroundColor = UIColor.darkGrayColor; + + self.isDarkMode = true; + } +} + +- (void)presentTextChallenge { + [self presentChallengeForChallengeResponse:[STDSChallengeResponseObject textChallengeResponseWithWhitelist:NO resendCode:NO]]; +} + +- (void)presentTextChallengeWithWhitelist { + [self presentChallengeForChallengeResponse:[STDSChallengeResponseObject textChallengeResponseWithWhitelist:YES resendCode:NO]]; +} + +- (void)presentTextChallengeWithResendCode { + [self presentChallengeForChallengeResponse:[STDSChallengeResponseObject textChallengeResponseWithWhitelist:NO resendCode:YES]]; +} + +- (void)presentTextChallengeWithResendCodeAndWhitelist { + [self presentChallengeForChallengeResponse:[STDSChallengeResponseObject textChallengeResponseWithWhitelist:YES resendCode:YES]]; +} + +- (void)presentTextChallengeLoadsSlowly { + self.shouldLoadSlowly = YES; + STDSProgressViewController *progressVC = [[STDSProgressViewController alloc] initWithDirectoryServer:STDSDirectoryServerULTestEC uiCustomization:self.customization didCancel:^{}]; + UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:progressVC]; + [self.navigationController presentViewController:navigationController animated:YES completion:nil]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self.navigationController dismissViewControllerAnimated:YES completion:nil]; + [self presentChallengeForChallengeResponse:[STDSChallengeResponseObject textChallengeResponseWithWhitelist:NO resendCode:NO]]; + }); + +} + +- (void)presentSingleSelectChallenge { + [self presentChallengeForChallengeResponse:[STDSChallengeResponseObject singleSelectChallengeResponse]]; +} + +- (void)presentMultiSelectChallenge { + [self presentChallengeForChallengeResponse:[STDSChallengeResponseObject multiSelectChallengeResponse]]; +} + +- (void)presentOOBChallenge { + [self presentChallengeForChallengeResponse:[STDSChallengeResponseObject OOBChallengeResponse]]; +} + +- (void)presentHTMLChallenge { + [self presentChallengeForChallengeResponse:[STDSChallengeResponseObject HTMLChallengeResponse]]; +} + +- (void)presentProgressView { + __weak typeof(self) weakSelf = self; + UIViewController *vc = [[STDSProgressViewController alloc] initWithDirectoryServer:STDSDirectoryServerULTestEC uiCustomization:self.customization didCancel:^{ + [weakSelf dismissViewControllerAnimated:YES completion:nil]; + }]; + UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:vc]; + [self.navigationController presentViewController:navigationController animated:YES completion:nil]; +} + +- (void)presentChallengeForChallengeResponse:(id)challengeResponse { + STDSChallengeResponseViewController *challengeResponseViewController = [[STDSChallengeResponseViewController alloc] initWithUICustomization:self.customization imageLoader:self.imageLoader directoryServer:STDSDirectoryServerULTestEC]; + challengeResponseViewController.delegate = self; + + UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:challengeResponseViewController]; + [self.navigationController presentViewController:navigationController animated:YES completion:nil]; + // Simulate what `STDSTransaction` does + [challengeResponseViewController setLoading]; + NSUInteger delay = self.shouldLoadSlowly ? 5 : 0; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [challengeResponseViewController setChallengeResponse:challengeResponse animated:YES]; + }); +} + +#pragma mark - STDSChallengeResponseViewControllerDelegate + +- (void)challengeResponseViewController:(nonnull STDSChallengeResponseViewController *)viewController didSubmitHTMLForm:(nonnull NSString *)form { + [self.navigationController dismissViewControllerAnimated:YES completion:nil]; +} + +- (void)challengeResponseViewController:(nonnull STDSChallengeResponseViewController *)viewController didSubmitInput:(nonnull NSString *)userInput whitelistSelection:(nonnull id)whitelistSelection { + [viewController setLoading]; + NSUInteger delay = self.shouldLoadSlowly ? 5 : 0; + self.shouldLoadSlowly = NO; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [viewController setChallengeResponse:[STDSChallengeResponseObject OOBChallengeResponse] animated:YES]; + }); +} + +- (void)challengeResponseViewController:(nonnull STDSChallengeResponseViewController *)viewController didSubmitSelection:(nonnull NSArray> *)selection whitelistSelection:(nonnull id)whitelistSelection { + [viewController setLoading]; + [viewController setChallengeResponse:[STDSChallengeResponseObject textChallengeResponseWithWhitelist:YES resendCode:YES] animated:YES]; +} + +- (void)challengeResponseViewControllerDidCancel:(nonnull STDSChallengeResponseViewController *)viewController { + self.shouldLoadSlowly = NO; + [self.navigationController dismissViewControllerAnimated:YES completion:nil]; +} + +- (void)challengeResponseViewControllerDidOOBContinue:(nonnull STDSChallengeResponseViewController *)viewController whitelistSelection:(nonnull id)whitelistSelection { + [viewController setLoading]; + [viewController setChallengeResponse:[STDSChallengeResponseObject singleSelectChallengeResponse] animated:YES]; +} + +- (void)challengeResponseViewControllerDidRequestResend:(nonnull STDSChallengeResponseViewController *)viewController { +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2DemoUI/Sources/main.m b/Stripe3DS2/Stripe3DS2DemoUI/Sources/main.m new file mode 100644 index 00000000..ca445d07 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2DemoUI/Sources/main.m @@ -0,0 +1,16 @@ +// +// main.m +// Stripe3DS2DemoUI +// +// Created by Andrew Harrison on 2/27/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import "AppDelegate.h" + +int main(int argc, char * argv[]) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); + } +} diff --git a/Stripe3DS2/Stripe3DS2DemoUITests/Info.plist b/Stripe3DS2/Stripe3DS2DemoUITests/Info.plist new file mode 100644 index 00000000..afb93bf7 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2DemoUITests/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + UILaunchStoryboardName + LaunchScreen + + diff --git a/Stripe3DS2/Stripe3DS2DemoUITests/STDSChallengeResponseViewControllerSnapshotTests.m b/Stripe3DS2/Stripe3DS2DemoUITests/STDSChallengeResponseViewControllerSnapshotTests.m new file mode 100644 index 00000000..94904843 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2DemoUITests/STDSChallengeResponseViewControllerSnapshotTests.m @@ -0,0 +1,117 @@ +// +// STDSChallengeResponseViewControllerSnapshotTests.m +// Stripe3DS2DemoUITests +// +// Created by Andrew Harrison on 3/28/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +@import iOSSnapshotTestCaseCore; + +#import + +#import "STDSChallengeResponseViewController.h" +#import "STDSChallengeResponseObject+TestObjects.h" + +/** + Calls FBSnapshotVerifyView with a default 2% per-pixel color differentiation, as M1 and Intel machines render shadows differently. + @param view The view to snapshot. + @param identifier An optional identifier, used if there are multiple snapshot tests in a given -test method. + */ +#define STPSnapshotVerifyView(view__, identifier__) \ +FBSnapshotVerifyViewWithPixelOptions(view__, identifier__, FBSnapshotTestCaseDefaultSuffixes(), 0.02, 0) + +@interface STDSChallengeResponseViewControllerSnapshotTests: FBSnapshotTestCase + +@end + +@implementation STDSChallengeResponseViewControllerSnapshotTests + +- (void)setUp { + [super setUp]; + + /// Recorded on an iPhone 12 Mini running iOS 15.4 +// self.recordMode = YES; +} + +- (void)testVerifyTextChallengeDesign { + STDSChallengeResponseViewController *challengeResponseViewController = [self challengeResponseViewControllerForResponse:[STDSChallengeResponseObject textChallengeResponseWithWhitelist:NO resendCode:NO] directoryServer:STDSDirectoryServerCustom]; + [challengeResponseViewController view]; + + [self waitForChallengeResponseTimer]; + + STPSnapshotVerifyView(challengeResponseViewController.view, @"TextChallengeResponse"); +} + +- (void)testVerifySingleSelectDesign { + STDSChallengeResponseViewController *challengeResponseViewController = [self challengeResponseViewControllerForResponse:[STDSChallengeResponseObject singleSelectChallengeResponse] directoryServer:STDSDirectoryServerCustom]; + [challengeResponseViewController view]; + + [self waitForChallengeResponseTimer]; + + STPSnapshotVerifyView(challengeResponseViewController.view, @"SingleSelectResponse"); +} + +- (void)testVerifyMultiSelectDesign { + STDSChallengeResponseViewController *challengeResponseViewController = [self challengeResponseViewControllerForResponse:[STDSChallengeResponseObject multiSelectChallengeResponse] directoryServer:STDSDirectoryServerCustom]; + [challengeResponseViewController view]; + + [self waitForChallengeResponseTimer]; + + STPSnapshotVerifyView(challengeResponseViewController.view, @"MultiSelectResponse"); +} + +- (void)testVerifyOOBDesign { + STDSChallengeResponseViewController *challengeResponseViewController = [self challengeResponseViewControllerForResponse:[STDSChallengeResponseObject OOBChallengeResponse] directoryServer:STDSDirectoryServerCustom]; + [challengeResponseViewController view]; + + [self waitForChallengeResponseTimer]; + + STPSnapshotVerifyView(challengeResponseViewController.view, @"OOBResponse"); +} + +- (void)testLoadingAmex { + STDSChallengeResponseViewController *challengeResponseViewController = [self challengeResponseViewControllerForResponse:nil directoryServer:STDSDirectoryServerAmex]; + [challengeResponseViewController view]; + [challengeResponseViewController setLoading]; + + STPSnapshotVerifyView(challengeResponseViewController.view, @"LoadingAmex"); +} + +- (void)testLoadingDiscover { + STDSChallengeResponseViewController *challengeResponseViewController = [self challengeResponseViewControllerForResponse:nil directoryServer:STDSDirectoryServerDiscover]; + [challengeResponseViewController view]; + [challengeResponseViewController setLoading]; + + STPSnapshotVerifyView(challengeResponseViewController.view, @"LoadingDiscover"); +} + +- (void)testLoadingMastercard { + STDSChallengeResponseViewController *challengeResponseViewController = [self challengeResponseViewControllerForResponse:nil directoryServer:STDSDirectoryServerMastercard]; + [challengeResponseViewController view]; + [challengeResponseViewController setLoading]; + + STPSnapshotVerifyView(challengeResponseViewController.view, @"LoadingMastercard"); +} + +- (void)testLoadingVisa { + STDSChallengeResponseViewController *challengeResponseViewController = [self challengeResponseViewControllerForResponse:nil directoryServer:STDSDirectoryServerVisa]; + [challengeResponseViewController view]; + [challengeResponseViewController setLoading]; + + STPSnapshotVerifyView(challengeResponseViewController.view, @"LoadingVisa"); +} + +- (STDSChallengeResponseViewController *)challengeResponseViewControllerForResponse:(id)response directoryServer:(STDSDirectoryServer)directoryServer { + STDSImageLoader *imageLoader = [[STDSImageLoader alloc] initWithURLSession:NSURLSession.sharedSession]; + + STDSChallengeResponseViewController *vc = [[STDSChallengeResponseViewController alloc] initWithUICustomization:[STDSUICustomization defaultSettings] imageLoader:imageLoader directoryServer:directoryServer]; + [vc setChallengeResponse:response animated:NO]; + return vc; +} + +- (void)waitForChallengeResponseTimer { + (void)[XCTWaiter waitForExpectations:@[[self expectationWithDescription:@""]] timeout:2.5]; +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Resources/Info.plist b/Stripe3DS2/Stripe3DS2Resources/Info.plist new file mode 100644 index 00000000..7607d027 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Resources/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSHumanReadableCopyright + Copyright © 2020 Stripe. All rights reserved. + NSPrincipalClass + + + diff --git a/Stripe3DS2/Stripe3DS2Tests/Info.plist b/Stripe3DS2/Stripe3DS2Tests/Info.plist new file mode 100644 index 00000000..6c40a6cd --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Stripe3DS2/Stripe3DS2Tests/JSON/ARes.json b/Stripe3DS2/Stripe3DS2Tests/JSON/ARes.json new file mode 100644 index 00000000..a1634721 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/JSON/ARes.json @@ -0,0 +1,16 @@ +{ + "dsTransID": "4e4750e7-6ab5-45a4-accf-9c668ed3b5a7", + "acsTransID": "fa695a82-a48c-455d-9566-a652058dda27", + "p_messageVersion": "1.0.5", + "acsOperatorID": "acsOperatorUL", + "sdkTransID": "D77EB83F-F317-4E29-9852-EBAAB55515B7", + "eci": "00", + "dsReferenceNumber": "3DS_LOA_DIS_PPFU_020100_00010", + "acsReferenceNumber": "3DS_LOA_ACS_PPFU_020100_00009", + "threeDSServerTransID": "fc7a39de-dc41-4b65-ba76-a322769b2efc", + "messageVersion": "2.1.0", + "authenticationValue": "AABBCCDDEEFFAABBCCDDEEFFAAA=", + "messageType": "pArs", + "transStatus": "C", + "acsChallengeMandated": "NO" +} diff --git a/Stripe3DS2/Stripe3DS2Tests/JSON/CRes.json b/Stripe3DS2/Stripe3DS2Tests/JSON/CRes.json new file mode 100644 index 00000000..98895661 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/JSON/CRes.json @@ -0,0 +1,31 @@ +{ + "threeDSServerTransID": "8a880dc0-d2d2-4067-bcb1-b08d1690b26e", + "acsTransID": "d7c1ee99-9478-44a6-b1f2-391e29c6b340", + "acsUiType": "01", + "challengeAddInfo": "Additional information to be shown.", + "challengeCompletionInd": "N", + "challengeInfoHeader": "Header information", + "challengeInfoLabel": "One-time-password", + "challengeInfoText": "Please enter the received one-time-password", + "challengeInfoTextIndicator": "N", + "expandInfoLabel": "Additional instructions", + "expandInfoText": "The issuer will send you via SMS a one-time password. Please enter the value in the designated input field above and press continue to complete the 3-D Secure authentication process.", + "issuerImage": { + "medium": "https://acs.com/medium_image.svg", + "high": "https://acs.com/high_image.svg", + "extraHigh": "https://acs.com/extraHigh_image.svg" + }, + "messageType": "CRes", + "messageVersion": "2.1.0", + "psImage": { + "medium": "https://ds.com/medium_image.svg", + "high": "https://ds.com/high_image.svg", + "extraHigh": "https://ds.com/extraHigh_image.svg" + }, + "resendInformationLabel": "Send new One-time-password", + "sdkTransID": "b2385523-a66c-4907-ac3c-91848e8c0067", + "submitAuthenticationLabel": "Continue", + "whyInfoLabel": "Why using 3-D Secure?", + "whyInfoText": "Some explanation about why using 3-D Secure is an excellent idea as part of an online payment transaction", + "acsCounterAtoS": "001" +} diff --git a/Stripe3DS2/Stripe3DS2Tests/JSON/ErrorMessage.json b/Stripe3DS2/Stripe3DS2Tests/JSON/ErrorMessage.json new file mode 100644 index 00000000..6acd63f1 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/JSON/ErrorMessage.json @@ -0,0 +1,13 @@ +{ + "threeDSServerTransID": "6afa6072-9412-446b-9673-2f98b3ee98a2", + "acsTransID": "375d90ad-3873-498b-9133-380cbbc8d99d", + "dsTransID": "0b470d4f-fdf8-429f-9147-505b1a589883", + "errorCode": "203", + "errorComponent": "A", + "errorDescription": "Data element not in the required format. Not numeric or wrong length.", + "errorDetail": "billAddrCountry,billAddrPostCode,dsURL", + "errorMessageType": "AReq", + "messageType": "Erro", + "messageVersion": "2.1.0", + "sdkTransID": "b2385523-a66c-4907-ac3c-91848e8c0067" +} diff --git a/Stripe3DS2/Stripe3DS2Tests/NSDictionary+DecodingHelpersTest.m b/Stripe3DS2/Stripe3DS2Tests/NSDictionary+DecodingHelpersTest.m new file mode 100644 index 00000000..846c5268 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/NSDictionary+DecodingHelpersTest.m @@ -0,0 +1,312 @@ +// +// NSDictionary+DecodingHelpersTest.m +// Stripe3DS2Tests +// +// Created by Yuki Tokuhiro on 3/28/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "NSDictionary+DecodingHelpers.h" +#import "STDSStripe3DS2Error.h" +#import "NSError+Stripe3DS2.h" + +@interface JSONDecodableTestObject : NSObject +@property (nonatomic, copy) NSString *value; +@end + +@implementation JSONDecodableTestObject + ++ (instancetype)decodedObjectFromJSON:(NSDictionary *)json error:(NSError * _Nullable __autoreleasing *)outError { + NSString *value = [json _stds_stringForKey:@"key" required:YES error:outError]; + if (outError && *outError) { + return nil; + } + JSONDecodableTestObject *obj = [[self alloc] init]; + obj.value = value; + return obj; +} + +@end + +@interface NSDictionary_DecodingHelpersTest : XCTestCase + +@end + +@implementation NSDictionary_DecodingHelpersTest + +- (void)testMissingRequiredKey { + // Every getter should fail the same way if the key is not present + NSDictionary *json = @{}; + NSError *expectedError = [NSError _stds_missingJSONFieldError:@"key"]; + NSError *error; + id value; + + value = [json _stds_stringForKey:@"key" required:YES error:&error]; + XCTAssertNotNil(error); + if (error) { + XCTAssertEqual(error.code, expectedError.code); + XCTAssertEqualObjects(error.userInfo, expectedError.userInfo); + } else { + XCTFail(@"Error should have a value"); + } + XCTAssertNil(value); + + value = [json _stds_stringForKey:@"key" validator:^BOOL (NSString *value) { + return NO; + } required:YES error:&error]; + XCTAssertNotNil(error); + if (error) { + XCTAssertEqual(error.code, expectedError.code); + XCTAssertEqualObjects(error.userInfo, expectedError.userInfo); + } else { + XCTFail(@"Error should have a value"); + } + XCTAssertNil(value); + + value = [json _stds_boolForKey:@"key" required:YES error:&error]; + XCTAssertNotNil(error); + if (error) { + XCTAssertEqual(error.code, expectedError.code); + XCTAssertEqualObjects(error.userInfo, expectedError.userInfo); + } else { + XCTFail(@"Error should have a value"); + } + XCTAssertNil(value); + + value = [json _stds_arrayForKey:@"key" arrayElementType:[JSONDecodableTestObject class] required:YES error:&error]; + XCTAssertNotNil(error); + if (error) { + XCTAssertEqual(error.code, expectedError.code); + XCTAssertEqualObjects(error.userInfo, expectedError.userInfo); + } else { + XCTFail(@"Error should have a value"); + } + XCTAssertNil(value); + + value = [json _stds_urlForKey:@"key" required:YES error:&error]; + XCTAssertNotNil(error); + if (error) { + XCTAssertEqual(error.code, expectedError.code); + XCTAssertEqualObjects(error.userInfo, expectedError.userInfo); + } else { + XCTFail(@"Error should have a value"); + } + XCTAssertNil(value); + + value = [json _stds_dictionaryForKey:@"key" required:YES error:&error]; + XCTAssertNotNil(error); + if (error) { + XCTAssertEqual(error.code, expectedError.code); + XCTAssertEqualObjects(error.userInfo, expectedError.userInfo); + } else { + XCTFail(@"Error should have a value"); + } + XCTAssertNil(value); +} + +- (void)testInvalidType { + // Every getter should fail the same way if the value is not the expected type + NSDictionary *json = @{@"key": [NSObject new]}; + NSError *expectedError = [NSError _stds_invalidJSONFieldError:@"key"]; + NSError *error; + id value; + + value = [json _stds_stringForKey:@"key" required:YES error:&error]; + XCTAssertNotNil(error); + if (error) { + XCTAssertEqual(error.code, expectedError.code); + XCTAssertEqualObjects(error.userInfo, expectedError.userInfo); + } else { + XCTFail(@"Error should have a value"); + } + XCTAssertNil(value); + + value = [json _stds_stringForKey:@"key" validator:^BOOL (NSString *value) { + return NO; + } required:YES error:&error]; + XCTAssertNotNil(error); + if (error) { + XCTAssertEqual(error.code, expectedError.code); + XCTAssertEqualObjects(error.userInfo, expectedError.userInfo); + } else { + XCTFail(@"Error should have a value"); + } + XCTAssertNil(value); + + value = [json _stds_boolForKey:@"key" required:YES error:&error]; + XCTAssertNotNil(error); + if (error) { + XCTAssertEqual(error.code, expectedError.code); + XCTAssertEqualObjects(error.userInfo, expectedError.userInfo); + } else { + XCTFail(@"Error should have a value"); + } + XCTAssertNil(value); + + value = [json _stds_arrayForKey:@"key" arrayElementType:[JSONDecodableTestObject class] required:YES error:&error]; + XCTAssertNotNil(error); + if (error) { + XCTAssertEqual(error.code, expectedError.code); + XCTAssertEqualObjects(error.userInfo, expectedError.userInfo); + } else { + XCTFail(@"Error should have a value"); + } + XCTAssertNil(value); + + value = [json _stds_urlForKey:@"key" required:YES error:&error]; + XCTAssertNotNil(error); + if (error) { + XCTAssertEqual(error.code, expectedError.code); + XCTAssertEqualObjects(error.userInfo, expectedError.userInfo); + } else { + XCTFail(@"Error should have a value"); + } + XCTAssertNil(value); + + value = [json _stds_dictionaryForKey:@"key" required:YES error:&error]; + XCTAssertNotNil(error); + if (error) { + XCTAssertEqual(error.code, expectedError.code); + XCTAssertEqualObjects(error.userInfo, expectedError.userInfo); + } else { + XCTFail(@"Error should have a value"); + } + XCTAssertNil(value); +} + +#pragma mark NSString + +- (void)testString { + NSDictionary *json = [self _basicJSONDictionary]; + NSError *error; + NSString *value = [json _stds_stringForKey:@"key" required:YES error:&error]; + XCTAssertNil(error); + XCTAssertNotNil(value); + XCTAssertEqualObjects(value, @"value"); +} + +- (void)testEmptyString { + NSDictionary *json = @{@"key": @""}; + NSError *expectedError = [NSError _stds_missingJSONFieldError:@"key"]; + NSError *error; + id value; + + // Required empty string should produce a missing error + value = [json _stds_stringForKey:@"key" required:YES error:&error]; + if (error) { + XCTAssertEqual(error.code, expectedError.code); + XCTAssertEqualObjects(error.userInfo, expectedError.userInfo); + } else { + XCTFail(@"Error should have a value"); + } + XCTAssertNil(value); + + // Not required empty string should produce an invalid error + expectedError = [NSError _stds_invalidJSONFieldError:@"key"]; + value = [json _stds_stringForKey:@"key" required:NO error:&error]; + if (error) { + XCTAssertEqual(error.code, expectedError.code); + XCTAssertEqualObjects(error.userInfo, expectedError.userInfo); + } else { + XCTFail(@"Error should have a value"); + } + XCTAssertNil(value); +} + +- (void)testInvalidValueString { + NSDictionary *json = [self _basicJSONDictionary]; + NSError *expectedError = [NSError _stds_invalidJSONFieldError:@"key"]; + NSError *error; + NSString *value = [json _stds_stringForKey:@"key" validator:^BOOL (NSString *value) { + return NO; + } required:YES error:&error]; + XCTAssertNotNil(error); + if (error) { + XCTAssertEqual(error.code, expectedError.code); + XCTAssertEqualObjects(error.userInfo, expectedError.userInfo); + } else { + XCTFail(@"Error should have a value"); + } + XCTAssertNil(value); +} + +#pragma mark NSArray + +- (void)testArray { + NSDictionary *json = @{ + @"key": @[@{@"key": @"value"}] + }; + NSError *error; + NSArray *array = [json _stds_arrayForKey:@"key" arrayElementType:[JSONDecodableTestObject class] required:YES error:&error]; + XCTAssertNil(error); + XCTAssertNotNil(array); + if (array.count == 1) { + XCTAssertEqualObjects([array[0] class], [JSONDecodableTestObject class]); + XCTAssertEqualObjects(array[0].value, @"value"); + } else { + XCTFail(@"Array was not populated"); + } +} + +- (void)testInvalidElementTypeArray { + NSDictionary *json = @{ + @"key": @[@"value1", @"value2"] + }; + NSError *expectedError = [NSError _stds_invalidJSONFieldError:@"key"]; + NSError *error; + NSArray *value = [json _stds_arrayForKey:@"key" arrayElementType:[JSONDecodableTestObject class] required:YES error:&error]; + XCTAssertNotNil(error); + if (error) { + XCTAssertEqual(error.code, expectedError.code); + XCTAssertEqualObjects(error.userInfo, expectedError.userInfo); + } else { + XCTFail(@"Error should have a value"); + } + XCTAssertNil(value); +} + +#pragma mark NSDictionary + +- (void)testDictionary { + NSDictionary *nestedJSON = [self _basicJSONDictionary]; + NSDictionary *json = @{ + @"key": nestedJSON + }; + NSError *error; + NSDictionary *value = [json _stds_dictionaryForKey:@"key" required:YES error:&error]; + XCTAssertNil(error); + XCTAssertNotNil(value); + XCTAssertEqualObjects(value, nestedJSON); +} + +#pragma mark NSURL + +- (void)testURL { + NSDictionary *json = @{@"key": @"www.stripe.com"}; + NSError *error; + NSURL *value = [json _stds_urlForKey:@"key" required:YES error:&error]; + XCTAssertNil(error); + XCTAssertNotNil(value); + XCTAssertEqualObjects(value, [NSURL URLWithString:@"www.stripe.com"]); +} + +#pragma mark BOOL + +- (void)testBOOL { + NSDictionary *json = @{@"key": @(YES)}; + NSError *error; + BOOL value = [json _stds_boolForKey:@"key" required:YES error:&error]; + XCTAssertNil(error); + XCTAssertEqual(value, YES); +} + +#pragma mark Helpers + +- (NSDictionary *)_basicJSONDictionary { + return @{@"key": @"value"}; +} + + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/NSString+EmptyCheckingTests.m b/Stripe3DS2/Stripe3DS2Tests/NSString+EmptyCheckingTests.m new file mode 100644 index 00000000..49024547 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/NSString+EmptyCheckingTests.m @@ -0,0 +1,32 @@ +// +// NSString+EmptyCheckingTests.m +// Stripe3DS2Tests +// +// Created by Andrew Harrison on 3/4/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "NSString+EmptyChecking.h" + +@interface NSString_EmptyCheckingTests : XCTestCase + +@end + +@implementation NSString_EmptyCheckingTests + +- (void)testStringIsEmpty { + XCTAssertTrue([NSString _stds_isStringEmpty:@""]); + XCTAssertTrue([NSString _stds_isStringEmpty:@" "]); + XCTAssertTrue([NSString _stds_isStringEmpty:@"\n"]); + XCTAssertTrue([NSString _stds_isStringEmpty:@"\t"]); +} + +- (void)testStringIsNotEmpty { + XCTAssertFalse([NSString _stds_isStringEmpty:@"Hello"]); + XCTAssertFalse([NSString _stds_isStringEmpty:@","]); + XCTAssertFalse([NSString _stds_isStringEmpty:@"\\n"]); +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSACSNetworkingManagerTest.m b/Stripe3DS2/Stripe3DS2Tests/STDSACSNetworkingManagerTest.m new file mode 100644 index 00000000..33fca13f --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSACSNetworkingManagerTest.m @@ -0,0 +1,54 @@ +// +// STDSACSNetworkingManagerTest.m +// Stripe3DS2Tests +// +// Created by Yuki Tokuhiro on 4/18/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSStripe3DS2Error.h" +#import "STDSACSNetworkingManager.h" +#import "STDSTestJSONUtils.h" +#import "STDSErrorMessage.h" +#import "STDSChallengeResponseObject.h" + +@interface STDSACSNetworkingManager (Private) +- (nullable id)decodeJSON:(NSDictionary *)dict error:(NSError * _Nullable *)outError; +@end + +@interface STDSACSNetworkingManagerTest : XCTestCase + +@end + +@implementation STDSACSNetworkingManagerTest + +- (void)testDecodeJSON { + STDSACSNetworkingManager *manager = [[STDSACSNetworkingManager alloc] init]; + NSError *error; + id decoded; + + // Unknown message type + NSDictionary *unknownMessageDict = @{@"messageType": @"foo"}; + decoded = [manager decodeJSON:unknownMessageDict error:&error]; + XCTAssertEqual(error.code, STDSErrorCodeUnknownMessageType); + XCTAssertNil(decoded); + error = nil; + + // Error Message type + NSDictionary *errorMessageDict = [STDSTestJSONUtils jsonNamed:@"ErrorMessage"]; + decoded = [manager decodeJSON:errorMessageDict error:&error]; + XCTAssertEqual(error.code, STDSErrorCodeReceivedErrorMessage); + XCTAssertTrue([error.userInfo[STDSStripe3DS2ErrorMessageErrorKey] isKindOfClass:[STDSErrorMessage class]]); + XCTAssertNil(decoded); + error = nil; + + // ChallengeResponse message type + NSDictionary *challengeResponseDict = [STDSTestJSONUtils jsonNamed:@"CRes"]; + decoded = [manager decodeJSON:challengeResponseDict error:&error]; + XCTAssertNil(error); + XCTAssertTrue([decoded isKindOfClass:[STDSChallengeResponseObject class]]); +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSAuthenticationRequestParametersTest.m b/Stripe3DS2/Stripe3DS2Tests/STDSAuthenticationRequestParametersTest.m new file mode 100644 index 00000000..7e701da8 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSAuthenticationRequestParametersTest.m @@ -0,0 +1,41 @@ +// +// STDSAuthenticationRequestParametersTest.m +// Stripe3DS2Tests +// +// Created by Yuki Tokuhiro on 3/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSAuthenticationRequestParameters.h" +#import "STDSJSONEncoder.h" + +@interface STDSAuthenticationRequestParametersTest : XCTestCase + +@end + +@implementation STDSAuthenticationRequestParametersTest + +#pragma mark - STDSJSONEncodable + +- (void)testPropertyNamesToJSONKeysMapping { + STDSAuthenticationRequestParameters *params = [STDSAuthenticationRequestParameters new]; + + NSDictionary *mapping = [STDSAuthenticationRequestParameters propertyNamesToJSONKeysMapping]; + + for (NSString *propertyName in [mapping allKeys]) { + XCTAssertFalse([propertyName containsString:@":"]); + XCTAssert([params respondsToSelector:NSSelectorFromString(propertyName)]); + } + + for (NSString *formFieldName in [mapping allValues]) { + XCTAssert([formFieldName isKindOfClass:[NSString class]]); + XCTAssert([formFieldName length] > 0); + } + + XCTAssertEqual([[mapping allValues] count], [[NSSet setWithArray:[mapping allValues]] count]); + XCTAssertTrue([NSJSONSerialization isValidJSONObject:[STDSJSONEncoder dictionaryForObject:params]]); +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSAuthenticationResponseTests.m b/Stripe3DS2/Stripe3DS2Tests/STDSAuthenticationResponseTests.m new file mode 100644 index 00000000..7dac3fd1 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSAuthenticationResponseTests.m @@ -0,0 +1,32 @@ +// +// STDSAuthenticationResponseTests.m +// Stripe3DS2Tests +// +// Created by Cameron Sabol on 5/20/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSAuthenticationResponseObject.h" +#import "STDSTestJSONUtils.h" + +@interface STDSAuthenticationResponseTests : XCTestCase + +@end + +@implementation STDSAuthenticationResponseTests + +- (void)testInitWithJSON { + NSError *error = nil; + STDSAuthenticationResponseObject *ares = [STDSAuthenticationResponseObject decodedObjectFromJSON:[STDSTestJSONUtils jsonNamed:@"ARes"] error:&error]; + + XCTAssertNil(error); + XCTAssertNotNil(ares, @"Failed to create an ares parsed from JSON"); + + id authResponse = STDSAuthenticationResponseFromJSON([STDSTestJSONUtils jsonNamed:@"ARes"]); + XCTAssertNotNil(authResponse, @"Failed to create an ares parsed from JSON"); + XCTAssert(authResponse.isChallengeRequired, @"ares did not indicate that a challenge was required"); +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSBase64URLEncodingTests.m b/Stripe3DS2/Stripe3DS2Tests/STDSBase64URLEncodingTests.m new file mode 100644 index 00000000..25642a5d --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSBase64URLEncodingTests.m @@ -0,0 +1,59 @@ +// +// STDSBase64URLEncodingTests.m +// Stripe3DS2Tests +// +// Created by Cameron Sabol on 3/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "NSData+JWEHelpers.h" +#import "NSString+JWEHelpers.h" + +@interface STDSBase64URLEncodingTests : XCTestCase + +@end + +@implementation STDSBase64URLEncodingTests + +// test cases from https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-41 + +- (void)testEncodingDataToString { + { + Byte bytes[5] = {3, 236, 255, 224, 193}; + NSData *data = [NSData dataWithBytes:bytes length:5]; + XCTAssertEqualObjects([data _stds_base64URLEncodedString], @"A-z_4ME"); + } + + { + Byte bytes[30] = {123, 34, 116, 121, 112, 34, 58, 34, 74, 87, 84, 34, 44, 13, 10, 32, 34, 97, 108, 103, 34, 58, 34, 72, 83, 50, 53, 54, 34, 125}; + NSData *data = [NSData dataWithBytes:bytes length:30]; + XCTAssertEqualObjects([data _stds_base64URLEncodedString], @"eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9"); + } + + { + Byte bytes[70] = {123, 34, 105, 115, 115, 34, 58, 34, 106, 111, 101, 34, 44, 13, 10, + 32, 34, 101, 120, 112, 34, 58, 49, 51, 48, 48, 56, 49, 57, 51, 56, + 48, 44, 13, 10, 32, 34, 104, 116, 116, 112, 58, 47, 47, 101, 120, 97, + 109, 112, 108, 101, 46, 99, 111, 109, 47, 105, 115, 95, 114, 111, + 111, 116, 34, 58, 116, 114, 117, 101, 125}; + NSData *data = [NSData dataWithBytes:bytes length:70]; + XCTAssertEqualObjects([data _stds_base64URLEncodedString], @"eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ"); + } + +} + +- (void)testEncodingString { + XCTAssertEqualObjects([@"{\"typ\":\"JWT\",\r\n \"alg\":\"HS256\"}" _stds_base64URLEncodedString], @"eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9"); + + XCTAssertEqualObjects([@"{\"iss\":\"joe\",\r\n \"exp\":1300819380,\r\n \"http://example.com/is_root\":true}" _stds_base64URLEncodedString], @"eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ"); +} + +- (void)testDecodingString { + XCTAssertEqualObjects([@"eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9" _stds_base64URLDecodedString], @"{\"typ\":\"JWT\",\r\n \"alg\":\"HS256\"}"); + + XCTAssertEqualObjects([ @"eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ" _stds_base64URLDecodedString], @"{\"iss\":\"joe\",\r\n \"exp\":1300819380,\r\n \"http://example.com/is_root\":true}"); +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSChallengeParametersTests.m b/Stripe3DS2/Stripe3DS2Tests/STDSChallengeParametersTests.m new file mode 100644 index 00000000..8b430d10 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSChallengeParametersTests.m @@ -0,0 +1,56 @@ +// +// STDSChallengeParametersTests.m +// Stripe3DS2Tests +// +// Created by Cameron Sabol on 2/22/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSAuthenticationResponseObject.h" +#import "STDSChallengeParameters.h" + +@interface TestAuthResponse: STDSAuthenticationResponseObject + +@end + +@interface STDSChallengeParametersTests : XCTestCase + +@end + +@implementation STDSChallengeParametersTests + +- (void)testInitWithAuthResponse { + STDSChallengeParameters *params = [[STDSChallengeParameters alloc] initWithAuthenticationResponse:[[TestAuthResponse alloc] init]]; + + XCTAssertEqual(params.threeDSServerTransactionID, @"test_threeDSServerTransactionID", @"Failed to set test_threeDSServerTransactionID"); + XCTAssertEqual(params.acsTransactionID, @"test_acsTransactionID", @"Failed to set test_acsTransactionID"); + XCTAssertEqual(params.acsReferenceNumber, @"test_acsReferenceNumber", @"Failed to set test_acsReferenceNumber"); + XCTAssertEqual(params.acsSignedContent, @"test_acsSignedContent", @"Failed to set test_acsSignedContent"); + XCTAssertNil(params.threeDSRequestorAppURL, @"Should not have set threeDSRequestorAppURL"); +} + +@end + +#pragma mark - TestAuthResponse + +@implementation TestAuthResponse + +- (NSString *)threeDSServerTransactionID { + return @"test_threeDSServerTransactionID"; +} + +- (NSString *)acsTransactionID { + return @"test_acsTransactionID"; +} + +- (NSString *)acsReferenceNumber { + return @"test_acsReferenceNumber"; +} + +- (NSString *)acsSignedContent { + return @"test_acsSignedContent"; +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSChallengeRequestParametersTest.m b/Stripe3DS2/Stripe3DS2Tests/STDSChallengeRequestParametersTest.m new file mode 100644 index 00000000..e97b030e --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSChallengeRequestParametersTest.m @@ -0,0 +1,71 @@ +// +// STDSChallengeRequestParametersTest.m +// Stripe3DS2Tests +// +// Created by Yuki Tokuhiro on 4/1/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSChallengeRequestParameters.h" +#import "STDSJSONEncoder.h" + +@interface STDSChallengeRequestParametersTest : XCTestCase + +@end + +@implementation STDSChallengeRequestParametersTest + +#pragma mark - STDSJSONEncodable + +- (void)testPropertyNamesToJSONKeysMapping { + STDSChallengeRequestParameters *params = [[STDSChallengeRequestParameters alloc] initWithThreeDSServerTransactionIdentifier:@"server id" + acsTransactionIdentifier:@"acs id" + messageVersion:@"message version" + sdkTransactionIdentifier:@"sdk id" + requestorAppUrl:@"requestor app url" + sdkCounterStoA:0]; + + NSDictionary *mapping = [STDSChallengeRequestParameters propertyNamesToJSONKeysMapping]; + + for (NSString *propertyName in [mapping allKeys]) { + XCTAssertFalse([propertyName containsString:@":"]); + XCTAssert([params respondsToSelector:NSSelectorFromString(propertyName)]); + } + + for (NSString *formFieldName in [mapping allValues]) { + XCTAssert([formFieldName isKindOfClass:[NSString class]]); + XCTAssert([formFieldName length] > 0); + } + + XCTAssertEqual([[mapping allValues] count], [[NSSet setWithArray:[mapping allValues]] count]); + XCTAssertTrue([NSJSONSerialization isValidJSONObject:[STDSJSONEncoder dictionaryForObject:params]]); +} + +- (void)testNextChallengeRequestParametersIncrementsCounter { + STDSChallengeRequestParameters *params = [[STDSChallengeRequestParameters alloc] initWithThreeDSServerTransactionIdentifier:@"server id" + acsTransactionIdentifier:@"acs id" + messageVersion:@"message version" + sdkTransactionIdentifier:@"sdk id" + requestorAppUrl:@"requestor app url" + sdkCounterStoA:0]; + for (NSInteger i = 0; i < 1000; i++) { + XCTAssertEqual(params.sdkCounterStoA.length, 3); + XCTAssertEqual(params.sdkCounterStoA.integerValue, i); + params = [params nextChallengeRequestParametersByIncrementCounter]; + } +} + +- (void)testEmptyChallengeDataEntryField { + STDSChallengeRequestParameters *params = [[STDSChallengeRequestParameters alloc] initWithThreeDSServerTransactionIdentifier:@"server id" + acsTransactionIdentifier:@"acs id" + messageVersion:@"message version" + sdkTransactionIdentifier:@"sdk id" + requestorAppUrl:@"requestor app url" + sdkCounterStoA:0]; + params.challengeDataEntry = @""; + XCTAssertNil(params.challengeDataEntry); +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSChallengeResponseObjectTest.m b/Stripe3DS2/Stripe3DS2Tests/STDSChallengeResponseObjectTest.m new file mode 100644 index 00000000..7ffdd95a --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSChallengeResponseObjectTest.m @@ -0,0 +1,79 @@ +// +// STDSChallengeResponseObjectTest.m +// Stripe3DS2Tests +// +// Created by Yuki Tokuhiro on 3/29/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import "STDSChallengeResponseObject.h" +#import "STDSTestJSONUtils.h" +#import "NSError+Stripe3DS2.h" + +@interface STDSChallengeResponseObjectTest : XCTestCase + +@end + +@implementation STDSChallengeResponseObjectTest + +- (void)testSuccessfulDecode { + NSDictionary *json = [STDSTestJSONUtils jsonNamed:@"CRes"]; + NSError *error; + STDSChallengeResponseObject *cr = [STDSChallengeResponseObject decodedObjectFromJSON:json error:&error]; + + XCTAssertNil(error); + XCTAssertNotNil(cr); + + XCTAssertEqualObjects(cr.threeDSServerTransactionID, @"8a880dc0-d2d2-4067-bcb1-b08d1690b26e"); + XCTAssertEqualObjects(cr.acsTransactionID, @"d7c1ee99-9478-44a6-b1f2-391e29c6b340"); + XCTAssertEqual(cr.acsUIType, STDSACSUITypeText); + XCTAssertEqual(cr.challengeCompletionIndicator, NO); + XCTAssertEqualObjects(cr.challengeInfoHeader, @"Header information"); + XCTAssertEqualObjects(cr.challengeInfoLabel, @"One-time-password"); + XCTAssertEqualObjects(cr.challengeInfoText, @"Please enter the received one-time-password"); + XCTAssertEqual(cr.showChallengeInfoTextIndicator, NO); + XCTAssertEqualObjects(cr.expandInfoLabel, @"Additional instructions"); + XCTAssertEqualObjects(cr.expandInfoText, @"The issuer will send you via SMS a one-time password. Please enter the value in the designated input field above and press continue to complete the 3-D Secure authentication process."); + XCTAssertEqualObjects(cr.issuerImage.mediumDensityURL, [NSURL URLWithString:@"https://acs.com/medium_image.svg"]); + XCTAssertEqualObjects(cr.issuerImage.highDensityURL, [NSURL URLWithString:@"https://acs.com/high_image.svg"]); + XCTAssertEqualObjects(cr.issuerImage.extraHighDensityURL, [NSURL URLWithString:@"https://acs.com/extraHigh_image.svg"]); + XCTAssertEqualObjects(cr.messageType, @"CRes"); + XCTAssertEqualObjects(cr.messageVersion, @"2.1.0"); + XCTAssertEqualObjects(cr.paymentSystemImage.mediumDensityURL, [NSURL URLWithString:@"https://ds.com/medium_image.svg"]); + XCTAssertEqualObjects(cr.paymentSystemImage.highDensityURL, [NSURL URLWithString:@"https://ds.com/high_image.svg"]); + XCTAssertEqualObjects(cr.paymentSystemImage.extraHighDensityURL, [NSURL URLWithString:@"https://ds.com/extraHigh_image.svg"]); + XCTAssertEqualObjects(cr.resendInformationLabel, @"Send new One-time-password"); + XCTAssertEqualObjects(cr.sdkTransactionID, @"b2385523-a66c-4907-ac3c-91848e8c0067"); + XCTAssertEqualObjects(cr.submitAuthenticationLabel, @"Continue"); + XCTAssertEqualObjects(cr.whyInfoLabel, @"Why using 3-D Secure?"); + XCTAssertEqualObjects(cr.whyInfoText, @"Some explanation about why using 3-D Secure is an excellent idea as part of an online payment transaction"); + XCTAssertEqualObjects(cr.acsCounterACStoSDK, @"001"); +} + +- (void)testMissingFields { + NSArray *requiredFields = @[ + @"threeDSServerTransID", + @"acsCounterAtoS", + @"acsTransID", + @"acsUiType", + @"challengeCompletionInd", + @"messageType", + @"messageVersion", + @"sdkTransID", + ]; + + for (NSString *field in requiredFields) { + NSMutableDictionary *response = [[STDSTestJSONUtils jsonNamed:@"CRes"] mutableCopy]; + [response removeObjectForKey:field]; + + NSError *error; + NSError *expectedError = [NSError _stds_missingJSONFieldError:field]; + XCTAssertNil([STDSChallengeResponseObject decodedObjectFromJSON:response error:&error]); + XCTAssertNotNil(error); + XCTAssertEqual(error.code, expectedError.code); + XCTAssertEqualObjects(error.userInfo, expectedError.userInfo); + } +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSConfigParametersTests.m b/Stripe3DS2/Stripe3DS2Tests/STDSConfigParametersTests.m new file mode 100644 index 00000000..28114985 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSConfigParametersTests.m @@ -0,0 +1,67 @@ +// +// STDSConfigParametersTests.m +// Stripe3DS2Tests +// +// Created by Cameron Sabol on 2/13/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSConfigParameters.h" +#import "STDSInvalidInputException.h" + +@interface STDSConfigParametersTests : XCTestCase + +@end + +@implementation STDSConfigParametersTests + +- (void)testStandardParameters { + STDSConfigParameters *defaultParameters = [[STDSConfigParameters alloc] initWithStandardParameters]; + XCTAssertNotNil(defaultParameters, @"Should return a non-nil instance"); +} + +- (void)testAddRead { + STDSConfigParameters *parameters = [[STDSConfigParameters alloc] init]; + XCTAssertNoThrow([parameters addParameterNamed:@"testName" withValue:@"testValue"], @"Should not throw with non-nil name and value."); + NSString *paramValue = nil; + XCTAssertNoThrow(paramValue = [parameters parameterValue:@"testName"], @"Should not throw with non-nil name."); + XCTAssertEqual(paramValue, @"testValue", @"Returned value does not match expectation."); +} + +- (void)testDefaultGroup { + STDSConfigParameters *parameters = [[STDSConfigParameters alloc] init]; + XCTAssertNoThrow([parameters addParameterNamed:@"testName" withValue:@"testValue"], @"Should not throw with non-nil name and value."); + XCTAssertNoThrow([parameters addParameterNamed:@"testName" withValue:@"testValue2" toGroup:@"otherGroup"], @"Should not throw with non-nil name, value, and group."); + NSString *paramValue = nil; + XCTAssertNoThrow(paramValue = [parameters parameterValue:@"testName"], @"Should not throw with non-nil name."); + XCTAssertEqual(paramValue, @"testValue", @"Returned value does not match expectation. Should default to default group's value."); + XCTAssertNoThrow(paramValue = [parameters parameterValue:@"testName" inGroup:@"otherGroup"], @"Should not throw with non-nil name and group name."); + XCTAssertEqual(paramValue, @"testValue2", @"Returned value does not match expectation. Should read from custom group."); +} + +- (void)testExceptions { + STDSConfigParameters *parameters = [[STDSConfigParameters alloc] init]; + XCTAssertNoThrow([parameters addParameterNamed:@"testParam" withValue:@"testValue" toGroup:nil], @"Should not throw with nil group."); + + XCTAssertThrowsSpecific([parameters addParameterNamed:@"testParam" withValue:@"value2"], STDSInvalidInputException, @"Should throw STDSInvalidInputException if trying to override testParam value."); + [parameters addParameterNamed:@"testParam" withValue:@"testValue" toGroup:@"otherGroup"]; + XCTAssertThrowsSpecific([parameters addParameterNamed:@"testParam" withValue:@"value2" toGroup:@"otherGroup"], STDSInvalidInputException, @"Should throw STDSInvalidInputException if trying to override testParam value in non-default group."); +} + +- (void)testRemove { + STDSConfigParameters *parameters = [[STDSConfigParameters alloc] init]; + + [parameters addParameterNamed:@"testParam" withValue:@"testValue"]; + [parameters addParameterNamed:@"testParam" withValue:@"testValue2" toGroup:@"otherGroup"]; + XCTAssertEqual([parameters removeParameterNamed:@"testParam"], @"testValue", @"Should return testValue when removing."); + XCTAssertNil([parameters parameterValue:@"testParam"]); + XCTAssertNotNil([parameters parameterValue:@"testParam" inGroup:@"otherGroup"], @"Should only remove param in specified group."); + + XCTAssertNil([parameters removeParameterNamed:@"testParam"], @"Should return nil if removing a non-existant parameter."); + + XCTAssertEqual([parameters removeParameterNamed:@"testParam" fromGroup:@"otherGroup"], @"testValue2", @"Should return group-specific value when removing."); +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSDeviceInformationManagerTests.m b/Stripe3DS2/Stripe3DS2Tests/STDSDeviceInformationManagerTests.m new file mode 100644 index 00000000..d86c2b9e --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSDeviceInformationManagerTests.m @@ -0,0 +1,33 @@ +// +// STDSDeviceInformationManagerTests.m +// Stripe3DS2Tests +// +// Created by Cameron Sabol on 1/24/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSDeviceInformation.h" +#import "STDSDeviceInformationManager.h" +#import "STDSWarning.h" + +@interface STDSDeviceInformationManagerTests : XCTestCase + +@end + +@implementation STDSDeviceInformationManagerTests + +- (void)testDeviceInformation { + STDSDeviceInformation *deviceInformation = [STDSDeviceInformationManager deviceInformationWithWarnings:@[] ignoringRestrictions:NO]; + XCTAssertEqualObjects(deviceInformation.dictionaryValue[@"DV"], @"1.4", @"Device data version check."); + XCTAssertNotNil(deviceInformation.dictionaryValue[@"DD"], @"Device data should be non-nil"); + XCTAssertNotNil(deviceInformation.dictionaryValue[@"DPNA"], @"Param not available should be non-nil in simulator"); + XCTAssertNil(deviceInformation.dictionaryValue[@"SW"]); + + deviceInformation = [STDSDeviceInformationManager deviceInformationWithWarnings:@[[[STDSWarning alloc] initWithIdentifier:@"WARNING_1" message:@"" severity:STDSWarningSeverityMedium], [[STDSWarning alloc] initWithIdentifier:@"WARNING_2" message:@"" severity:STDSWarningSeverityMedium], ] ignoringRestrictions:NO]; + NSArray *warningIDs = @[@"WARNING_1", @"WARNING_2"]; + XCTAssertEqualObjects(deviceInformation.dictionaryValue[@"SW"], warningIDs, @"Failed to set warning identifiers correctly"); +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSDeviceInformationParameterTests.m b/Stripe3DS2/Stripe3DS2Tests/STDSDeviceInformationParameterTests.m new file mode 100644 index 00000000..c550a7eb --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSDeviceInformationParameterTests.m @@ -0,0 +1,214 @@ +// +// STDSDeviceInformationParameterTests.m +// Stripe3DS2Tests +// +// Created by Cameron Sabol on 1/24/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSDeviceInformationParameter+Private.h" + +@interface STDSDeviceInformationParameterTests : XCTestCase + +@end + +@implementation STDSDeviceInformationParameterTests + +- (void)testNoPermissions { + STDSDeviceInformationParameter *noPermissionParam = [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"NoPermissionID" + permissionCheck:^BOOL{ + return NO; + } + valueCheck:^id _Nullable{ + XCTFail(@"Should not try to collect value if we don't have permission for it"); + return @"fail"; + }]; + [noPermissionParam collectIgnoringRestrictions:YES withHandler:^(BOOL collected, NSString * _Nonnull identifier, id _Nonnull value) { + XCTAssertFalse(collected, @"Should not have collected a param we don't have permission for."); + XCTAssertTrue([value isKindOfClass:[NSString class]], @"No permission value should be a string."); + XCTAssertEqualObjects(value, @"RE03", @"Returned value should be 'RE03' for param with missing permissions."); + }]; +} + +- (void)testNoValue { + STDSDeviceInformationParameter *noValueParam = [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"NoValueID" + permissionCheck:^BOOL{ + return YES; + } + valueCheck:^id _Nullable{ + return nil; + }]; + [noValueParam collectIgnoringRestrictions:YES withHandler:^(BOOL collected, NSString * _Nonnull identifier, id _Nonnull value) { + XCTAssertFalse(collected, @"Should not have collected a param we don't have a value for."); + XCTAssertTrue([value isKindOfClass:[NSString class]], @"No value value should be a string."); + XCTAssertEqualObjects(value, @"RE02", @"Returned value should be 'RE02' for param with unavailable value."); + }]; +} + +- (void)testCollect { + __block BOOL permissionCheckCalled = NO; + __block BOOL valueCheckCalled = NO; + __block BOOL collectedHandlerCalled = NO; + + STDSDeviceInformationParameter *param = [[STDSDeviceInformationParameter alloc] initWithIdentifier:@"ParamID" + permissionCheck:^BOOL{ + XCTAssertFalse(valueCheckCalled); + permissionCheckCalled = YES; + return YES; + } + valueCheck:^id _Nullable{ + XCTAssertTrue(permissionCheckCalled); + valueCheckCalled = YES; + return @"param_val"; + }]; + [param collectIgnoringRestrictions:YES withHandler:^(BOOL collected, NSString * _Nonnull identifier, id _Nonnull value) { + XCTAssertTrue(collected, @"Should have marked value as collected."); + XCTAssertEqualObjects(value, @"param_val", @"Inaccurate returned value."); + XCTAssertTrue(permissionCheckCalled); + XCTAssertTrue(valueCheckCalled); + collectedHandlerCalled = YES; + }]; + + // This check tests that collect is synchronous for now + XCTAssertTrue(collectedHandlerCalled); + + // reset so the permission before value check doesn't fail on the second call + permissionCheckCalled = NO; + valueCheckCalled = NO; + + // make sure the ignoreRestrictions param is respected + [param collectIgnoringRestrictions:NO withHandler:^(BOOL collected, NSString * _Nonnull identifier, id _Nonnull value) { + XCTAssertFalse(collected, @"Should not have marked value as collected."); + XCTAssertFalse(permissionCheckCalled, @"Restrictions shouldn't even check the runtime permission."); + XCTAssertFalse(valueCheckCalled, @"Should not have tried to get the value."); + XCTAssertEqualObjects(value, @"RE01", @"Should return market restricted code as the value."); + }]; +} + +- (void)testAllParameters { + NSArray *allParams = [STDSDeviceInformationParameter allParameters]; + XCTAssertEqual(allParams.count, 29, @"iOS should collect 29 separate parameters."); + NSMutableSet *allParamIdentifiers = [[NSMutableSet alloc] init]; + for (STDSDeviceInformationParameter *param in allParams) { + [param collectIgnoringRestrictions:YES withHandler:^(BOOL collected, NSString * _Nonnull identifier, id _Nonnull value) { + [allParamIdentifiers addObject:identifier]; + }]; + } + XCTAssertEqual(allParamIdentifiers.count, allParams.count, @"Sanity check that there are not duplicate identifiers."); + NSArray *expectedIdentifiers = @[ + @"C001", + @"C002", + @"C003", + @"C004", + @"C005", + @"C006", + @"C007", + @"C008", + @"C009", + @"C010", + @"C011", + @"C012", + @"C013", + @"C014", + @"C015", + @"I001", + @"I002", + @"I003", + @"I004", + @"I005", + @"I006", + @"I007", + @"I008", + @"I009", + @"I010", + @"I011", + @"I012", + @"I013", + @"I014", + ]; + for (NSString *identifier in expectedIdentifiers) { + XCTAssertTrue([allParamIdentifiers containsObject:identifier], @"Missing identifier %@", identifier); + } +} + +- (void)testOnlyApprovedIdentifiers { + NSArray *allParams = [STDSDeviceInformationParameter allParameters]; + NSMutableSet *collectedParameterIdentifiers = [[NSMutableSet alloc] init]; + for (STDSDeviceInformationParameter *param in allParams) { + [param collectIgnoringRestrictions:NO withHandler:^(BOOL collected, NSString * _Nonnull identifier, id _Nonnull value) { + + if (collected) { + [collectedParameterIdentifiers addObject:identifier]; + } + }]; + } + NSArray *expectedIdentifiers = @[ + @"C001", + @"C002", + @"C003", + @"C004", + @"C005", + @"C006", + @"C007", + @"C008", + ]; + XCTAssertEqual(collectedParameterIdentifiers.count, expectedIdentifiers.count, @"Should only have collected the expected amount."); + + for (NSString *identifier in expectedIdentifiers) { + XCTAssertTrue([collectedParameterIdentifiers containsObject:identifier], @"Missing identifier %@", identifier); + } +} + +- (void)testIdentifiersAccurate { + NSDictionary *expectedIdentifiers = @{ + @"C001": [STDSDeviceInformationParameter platform], + @"C002": [STDSDeviceInformationParameter deviceModel], + @"C003": [STDSDeviceInformationParameter OSName], + @"C004": [STDSDeviceInformationParameter OSVersion], + @"C005": [STDSDeviceInformationParameter locale], + @"C006": [STDSDeviceInformationParameter timeZone], + @"C007": [STDSDeviceInformationParameter advertisingID], + @"C008": [STDSDeviceInformationParameter screenResolution], + @"C009": [STDSDeviceInformationParameter deviceName], + @"C010": [STDSDeviceInformationParameter IPAddress], + @"C011": [STDSDeviceInformationParameter latitude], + @"C012": [STDSDeviceInformationParameter longitude], + @"I001": [STDSDeviceInformationParameter identiferForVendor], + @"I002": [STDSDeviceInformationParameter userInterfaceIdiom], + @"I003": [STDSDeviceInformationParameter familyNames], + @"I004": [STDSDeviceInformationParameter fontNamesForFamilyName], + @"I005": [STDSDeviceInformationParameter systemFont], + @"I006": [STDSDeviceInformationParameter labelFontSize], + @"I007": [STDSDeviceInformationParameter buttonFontSize], + @"I008": [STDSDeviceInformationParameter smallSystemFontSize], + @"I009": [STDSDeviceInformationParameter systemFontSize], + @"I010": [STDSDeviceInformationParameter systemLocale], + @"I011": [STDSDeviceInformationParameter availableLocaleIdentifiers], + @"I012": [STDSDeviceInformationParameter preferredLanguages], + @"I013": [STDSDeviceInformationParameter defaultTimeZone], + }; + + [expectedIdentifiers enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, STDSDeviceInformationParameter * _Nonnull obj, BOOL * _Nonnull stop) { + [obj collectIgnoringRestrictions:YES withHandler:^(BOOL collected, NSString * _Nonnull identifier, id _Nonnull value) { + XCTAssertEqualObjects(key, identifier); + }]; + }]; +} + +#pragma mark - App ID + +- (void)testSDKAppIdentifier { + // xctest in Xcode 13+ uses the Xcode version for the current app id string, previous versions are empty + NSString *appIdentifierKeyPrefix = @"STDSStripe3DS2AppIdentifierKey"; + NSString *appVersion = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"] ?: @""; + NSString *appIdentifierUserDefaultsKey = [appIdentifierKeyPrefix stringByAppendingString:appVersion]; + + [[NSUserDefaults standardUserDefaults] removeObjectForKey:appIdentifierUserDefaultsKey]; + NSString *appId = [STDSDeviceInformationParameter sdkAppIdentifier]; + XCTAssertNotNil(appId); + XCTAssertEqualObjects(appId, [[NSUserDefaults standardUserDefaults] stringForKey:appIdentifierUserDefaultsKey]); +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSDirectoryServerCertificateTests.m b/Stripe3DS2/Stripe3DS2Tests/STDSDirectoryServerCertificateTests.m new file mode 100644 index 00000000..43e7bab5 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSDirectoryServerCertificateTests.m @@ -0,0 +1,811 @@ +// +// STDSDirectoryServerCertificateTests.m +// Stripe3DS2Tests +// +// Created by Cameron Sabol on 3/28/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSDirectoryServerCertificate+Internal.h" +#import "STDSJSONWebSignature.h" +#import "NSString+JWEHelpers.h" + +@interface STDSDirectoryServerCertificateTests : XCTestCase + +@end + +@implementation STDSDirectoryServerCertificateTests + +- (void)testCertificateForDirectoryServer { + NSArray *directoryServers = @[ + @(STDSDirectoryServerULTestRSA), + @(STDSDirectoryServerULTestEC), + @(STDSDirectoryServerSTPTestRSA), + @(STDSDirectoryServerSTPTestEC), + @(STDSDirectoryServerAmex), + @(STDSDirectoryServerDiscover), + @(STDSDirectoryServerMastercard), + @(STDSDirectoryServerVisa), + @(STDSDirectoryServerCustom), + @(STDSDirectoryServerUnknown), + ]; + + for (NSNumber *directoryServerNum in directoryServers) { + STDSDirectoryServer directoryServer = (STDSDirectoryServer)[directoryServerNum integerValue]; + switch (directoryServer) { + case STDSDirectoryServerULTestRSA: + case STDSDirectoryServerULTestEC: + case STDSDirectoryServerSTPTestRSA: + case STDSDirectoryServerSTPTestEC: + case STDSDirectoryServerAmex: + case STDSDirectoryServerCartesBancaires: + case STDSDirectoryServerDiscover: + case STDSDirectoryServerMastercard: + case STDSDirectoryServerVisa: + XCTAssertNotNil([STDSDirectoryServerCertificate certificateForDirectoryServer:directoryServer], @"Failed creating certificate for type %@", directoryServerNum); + break; + + case STDSDirectoryServerCustom: + case STDSDirectoryServerUnknown: + XCTAssertNil([STDSDirectoryServerCertificate certificateForDirectoryServer:directoryServer], @"Should return nil for STDSDirectoryServerUnknown"); + break; + } + } +} + +- (void)testKeyType { + NSArray *directoryServers = @[ + @(STDSDirectoryServerULTestRSA), + @(STDSDirectoryServerULTestEC), + @(STDSDirectoryServerSTPTestRSA), + @(STDSDirectoryServerSTPTestEC), + @(STDSDirectoryServerAmex), + @(STDSDirectoryServerCartesBancaires), + @(STDSDirectoryServerDiscover), + @(STDSDirectoryServerMastercard), + @(STDSDirectoryServerVisa), + @(STDSDirectoryServerCustom), + @(STDSDirectoryServerUnknown), + ]; + + for (NSNumber *directoryServerNum in directoryServers) { + STDSDirectoryServer directoryServer = (STDSDirectoryServer)[directoryServerNum integerValue]; + STDSDirectoryServerCertificate *certificate = [STDSDirectoryServerCertificate certificateForDirectoryServer:directoryServer]; + switch (directoryServer) { + case STDSDirectoryServerULTestRSA: + XCTAssertEqual(certificate.keyType, STDSDirectoryServerKeyTypeRSA, @"Incorrect key type for STDSDirectoryServerULTestRSA"); + break; + + case STDSDirectoryServerULTestEC: + XCTAssertEqual(certificate.keyType, STDSDirectoryServerKeyTypeEC, @"Incorrect key type for STDSDirectoryServerULTestEC"); + break; + + case STDSDirectoryServerSTPTestRSA: + XCTAssertEqual(certificate.keyType, STDSDirectoryServerKeyTypeRSA, @"Incorrect key type for STDSDirectoryServerSTPTestRSA"); + break; + + case STDSDirectoryServerSTPTestEC: + XCTAssertEqual(certificate.keyType, STDSDirectoryServerKeyTypeEC, @"Incorrect key type for STDSDirectoryServerSTPTestEC"); + break; + + case STDSDirectoryServerAmex: + XCTAssertEqual(certificate.keyType, STDSDirectoryServerKeyTypeRSA, @"Incorrect key type for STDSDirectoryServerAmex"); + break; + + case STDSDirectoryServerCartesBancaires: + XCTAssertEqual(certificate.keyType, STDSDirectoryServerKeyTypeRSA, @"Incorrect key type for STDSDirectoryServerCartesBancaires"); + break; + + case STDSDirectoryServerDiscover: + XCTAssertEqual(certificate.keyType, STDSDirectoryServerKeyTypeRSA, @"Incorrect key type for STDSDirectoryServerDiscover"); + break; + + case STDSDirectoryServerMastercard: + XCTAssertEqual(certificate.keyType, STDSDirectoryServerKeyTypeRSA, @"Incorrect key type for STDSDirectoryServerMastercard"); + break; + + case STDSDirectoryServerVisa: + XCTAssertEqual(certificate.keyType, STDSDirectoryServerKeyTypeRSA, @"Incorrect key type for STDSDirectoryServerVisa"); + break; + + case STDSDirectoryServerCustom: + case STDSDirectoryServerUnknown: + // asserts + break; + } + } +} + +- (void)testRSA_OAEP_SHA256Encryption { + STDSDirectoryServerCertificate *certificate = [STDSDirectoryServerCertificate certificateForDirectoryServer:STDSDirectoryServerSTPTestRSA]; + if (certificate != nil) { + + XCTAssertNotNil([certificate encryptDataUsingRSA_OAEP_SHA256:[@"In nature's infinite book of secrecy a little I can read." dataUsingEncoding:NSUTF8StringEncoding]]); + } else { + XCTFail(@"Failed loading certificate for %@", NSStringFromSelector(_cmd)); + } +} + +- (void)testCustomCertificate { + NSString *certificateString = @"MIIDXTCCAkWgAwIBAgIQbS4C4BSig7uuJ5uDpeT4VjANBgkqhkiG9w0BAQsFADBH" + "MRMwEQYKCZImiZPyLGQBGRYDY29tMRcwFQYKCZImiZPyLGQBGRYHZXhhbXBsZTEX" + "MBUGA1UEAwwOUlNBIEV4YW1wbGUgRFMwHhcNMTcxMTIxMTE0ODQ5WhcNMjcxMjMx" + "MTQwMDAwWjBHMRMwEQYKCZImiZPyLGQBGRYDY29tMRcwFQYKCZImiZPyLGQBGRYH" + "ZXhhbXBsZTEXMBUGA1UEAwwOUlNBIEV4YW1wbGUgRFMwggEiMA0GCSqGSIb3DQEB" + "AQUAA4IBDwAwggEKAoIBAQCfgQ+0A4Jz0CWR5Ac/MdK2ABuCzttNkvBQFl1Hz8q4" + "o8Qct3isdVN5P475dXaNGiN02HElZMO813uepDRUSJlAfP8AmZIKkxokxEFIUqsp" + "vbCpXAZT82xg5gv5C2JY3aVvNwR7pcLR0CmvnJ1AuseqQceKDdEGit1pnoCP6gEe" + "oUQdik97tOl7459V8d3UTpxLozUVlwPU00tgPmUUek8j1tPAmWx17e6EaoLRkK4Q" + "eDyWHPA4eu0hBtLQVVtv2Tf61VNTh+D/cv++eJQUArC4IuoqdLYFjB2r+bNKdstj" + "uH+qLGhHuOKDf/+RGG5rHBSRHPmJqJCSqBzmAd2s0/nPAgMBAAGjRTBDMBIGA1Ud" + "EwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTDgwKdvAPq" + "bbCmehDaw0PwavI83jANBgkqhkiG9w0BAQsFAAOCAQEAOUcKqpzNQ6lr0PbDSsns" + "D6onfi+8j3TD0xG0zBSf+8G4zs8Zb6vzzQ5qHKgfr4aeen8Pw0cw2KKUJ2dFaBqj" + "n3/6/MIZbgaBvXKUbmY8xCxKQ+tOFc3KWIu4pSaO50tMPJjU/lP35bv19AA9vs9M" + "TKY2qLf88bmoNYT3W8VSDcB58KBHa7HVIPx7BUUtSyb2N2Jqx5AOiYy4NarhB3hV" + "ftkZBmCzi2Qw50KWIgTFYcIVeRTx3Js/F0IuEdgZHBK2gmO7fdM7+QKYm83401vl" + "YRNCXfIZ0H9E1V3NddqJuqIutdUajckSzMhXdNCJqfI4FAQAymTWGL3/lZyr/30x" + "Fg=="; + + STDSDirectoryServerCertificate *certificate = [STDSDirectoryServerCertificate customCertificateWithData:[[NSData alloc] initWithBase64EncodedString:certificateString options:0]]; + XCTAssertNotNil(certificate, @"Failed to create certificate from string."); + XCTAssertEqual(certificate.keyType, STDSDirectoryServerKeyTypeRSA, @"Parsed incorrect key type from custom certificate"); +} + +- (void)testCustomCertificateWithString { + // Using ds-amex.pm.PEM + NSString *certificateString = @"MIIE0TCCA7mgAwIBAgIUXbeqM1duFcHk4dDBwT8o7Ln5wX8wDQYJKoZIhvcNAQEL" + "BQAwXjELMAkGA1UEBhMCVVMxITAfBgNVBAoTGEFtZXJpY2FuIEV4cHJlc3MgQ29t" + "cGFueTEsMCoGA1UEAxMjQW1lcmljYW4gRXhwcmVzcyBTYWZla2V5IElzc3Vpbmcg" + "Q0EwHhcNMTgwMjIxMjM0OTMxWhcNMjAwMjIxMjM0OTMwWjCB0DELMAkGA1UEBhMC" + "VVMxETAPBgNVBAgTCE5ldyBZb3JrMREwDwYDVQQHEwhOZXcgWW9yazE/MD0GA1UE" + "ChM2QW1lcmljYW4gRXhwcmVzcyBUcmF2ZWwgUmVsYXRlZCBTZXJ2aWNlcyBDb21w" + "YW55LCBJbmMuMTkwNwYDVQQLEzBHbG9iYWwgTmV0d29yayBUZWNobm9sb2d5IC0g" + "TmV0d29yayBBUEkgUGxhdGZvcm0xHzAdBgNVBAMTFlNESy5TYWZlS2V5LkVuY3J5" + "cHRLZXkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDSFF9kTYbwRrxX" + "C6WcJJYio5TZDM62+CnjQRfggV3GMI+xIDtMIN8LL/jbWBTycu97vrNjNNv+UPhI" + "WzhFDdUqyRfrY337A39uE8k1xhdDI3dNeZz6xgq8r9hn2NBou78YPBKidpN5oiHn" + "TxcFq1zudut2fmaldaa9a4ZKgIQo+02heiJfJ8XNWkoWJ17GcjJ59UU8C1KF/y1G" + "ymYO5ha2QRsVZYI17+ZFsqnpcXwK4Mr6RQKV6UimmO0nr5++CgvXfekcWAlLV6Xq" + "juACWi3kw0haepaX/9qHRu1OSyjzWNcSVZ0On6plB5Lq6Y9ylgmxDrv+zltz3MrT" + "K7txIAFFAgMBAAGjggESMIIBDjAMBgNVHRMBAf8EAjAAMCEGA1UdEQQaMBiCFlNE" + "Sy5TYWZlS2V5LkVuY3J5cHRLZXkwRQYJKwYBBAGCNxQCBDgeNgBBAE0ARQBYAF8A" + "UwBBAEYARQBLAEUAWQAyAF8ARABTAF8ARQBOAEMAUgBZAFAAVABJAE8ATjAOBgNV" + "HQ8BAf8EBAMCBJAwHwYDVR0jBBgwFoAU7k/rXuVMhTBxB1zSftPgmLFuDIgwRAYD" + "VR0fBD0wOzA5oDegNYYzaHR0cDovL2FtZXhzay5jcmwuY29tLXN0cm9uZy1pZC5u" + "ZXQvYW1leHNhZmVrZXkuY3JsMB0GA1UdDgQWBBQHclVTo5nwZGH8labJ2F2P45xi" + "fDANBgkqhkiG9w0BAQsFAAOCAQEAWY6b77VBoGLs3k5vOqSU7QRqT+4v6y77T8LA" + "BKrSZ58DiVZWVyDSxyftQUiRRgFHt2gTN0yfJTP50Fyp84nCEWC0tugZ4iIhgPss" + "HzL+4/u4eG/MTzK2ESxvPgr6YHajyuU+GXA89u8+bsFrFmojOjhTgFKli7YUeV/0" + "xoiYZf2utlns800ofJrcrfiFoqE6PvK4Od0jpeMgfSKv71nK5ihA1+wTk76ge1fs" + "PxL23hEdRpWW11ofaLfJGkLFXMM3/LHSXWy7HhsBgDELdzLSHU4VkSv8yTOZxsRO" + "ByxdC5v3tXGcK56iQdtKVPhFGOOEBugw7AcuRzv3f1GhvzAQZg=="; + STDSDirectoryServerCertificate *certificate = certificate = [STDSDirectoryServerCertificate customCertificateWithString:certificateString]; + XCTAssertNotNil(certificate, @"Failed to create certificate from string."); + XCTAssertEqual(certificate.keyType, STDSDirectoryServerKeyTypeRSA, @"Parsed incorrect key type from custom certificate"); + + // 3ds2.rsa.encryption.crt + certificateString = @"-----BEGIN CERTIFICATE-----" + "MIIFrjCCBJagAwIBAgIQB2rJmsHVwbONd36WP9QPrTANBgkqhkiG9w0BAQsFADBx" + "MQswCQYDVQQGEwJVUzENMAsGA1UEChMEVklTQTEvMC0GA1UECxMmVmlzYSBJbnRl" + "cm5hdGlvbmFsIFNlcnZpY2UgQXNzb2NpYXRpb24xIjAgBgNVBAMTGVZpc2EgZUNv" + "bW1lcmNlIElzc3VpbmcgQ0EwHhcNMTcxMTAyMjIyMzEwWhcNMjAxMTAzMDAyMzEw" + "WjCBoTEYMBYGA1UEBxMPSGlnaGxhbmRzIFJhbmNoMREwDwYDVQQIEwhDb2xvcmFk" + "bzELMAkGA1UEBhMCVVMxDTALBgNVBAoTBFZJU0ExLzAtBgNVBAsTJlZpc2EgSW50" + "ZXJuYXRpb25hbCBTZXJ2aWNlIEFzc29jaWF0aW9uMSUwIwYDVQQDExwzZHMyLnJz" + "YS5lbmNyeXB0aW9uLnZpc2EuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB" + "CgKCAQEAst+HGfPPsX3p6HHEQ9YzourlQj16Nscmm13Cp7cZe4dZB2oWnJqZ7oh/" + "pEoEoOAxBw1x4NFgXKTKdHAeu3VBNVw8SwMTdIC+X16VV+3VIyPbUvJXFp3QoR8W" + "UwPB3F1Lb9SMFNS95boYDZKIOdPW0cP1dRi7pFugsBUZDCP/H3nFfBFHMCBoga+P" + "3AHGj5y8RVpv0hS9jaIsYjX+i58B61OGCB7D0AiADNZJuFzw2+xpNkt6NJJF66FP" + "O8qIh8xR2xGVDf7TtCbss/CugLRgSqKab9YRB8/TBTcy5bxj6O8HD6aL2zGLcMY9" + "dCobXxCodLEtMjJdVL8N+iZrsI2gtwIDAQABo4ICDzCCAgswEwYDVR0lBAwwCgYI" + "KwYBBQUHAwEwZQYIKwYBBQUHAQEEWTBXMCUGCCsGAQUFBzABhhlodHRwOi8vb2Nz" + "cC52aXNhLmNvbS9vY3NwMC4GCCsGAQUFBzAChiJodHRwOi8vZW5yb2xsLnZpc2Fj" + "YS5jb20vZWNvbW0uY2VyMB8GA1UdIwQYMBaAFN/DKlUuL0I6ekCdkqD3R3nXj4eK" + "MAwGA1UdEwEB/wQCMAAwgcoGA1UdHwSBwjCBvzAooCagJIYiaHR0cDovL0Vucm9s" + "bC52aXNhY2EuY29tL2VDb21tLmNybDCBkqCBj6CBjIaBiWxkYXA6Ly9FbnJvbGwu" + "dmlzYWNhLmNvbTozODkvY249VmlzYSBlQ29tbWVyY2UgSXNzdWluZyBDQSxjPVVT" + "LG91PVZpc2EgSW50ZXJuYXRpb25hbCBTZXJ2aWNlIEFzc29jaWF0aW9uLG89VklT" + "QT9jZXJ0aWZpY2F0ZVJldm9jYXRpb25MaXN0MA4GA1UdDwEB/wQEAwIFoDAnBgNV" + "HREEIDAeghwzZHMyLnJzYS5lbmNyeXB0aW9uLnZpc2EuY29tMB0GA1UdDgQWBBT8" + "m2pDtUtY13f/3NCOmexHavP5zDA5BgNVHSAEMjAwMC4GBWeBAwEBMCUwIwYIKwYB" + "BQUHAgEWF2h0dHA6Ly93d3cudmlzYS5jb20vcGtpMA0GCSqGSIb3DQEBCwUAA4IB" + "AQCcCUhU7KnHUDuLXqwSuzC8lWWCcEqPRgPPzY3mgBUg9ya0p+v7QF2BG77tpygK" + "E2yDPkOE8trzYeMi7TCuvKgZvUXDSOka8SId9QleMBlo2pzNi0vKKBG8+E7qmGaf" + "etQHVaoFvhg24/e7y8q89VYNKfLXn8TWMUOJdTQoNP+4bHcCnBvWWUcI2LlyEog1" + "2FDSG8hgP3cpw+0B2Hace9BQGR7ZgTIJAANEHZ54QGOYdxZEcDS5IEpKZlN8INs/" + "NKJyCkqP09VA4NO/WHaGFAXtgoLmjlA9Kal+4ieJPKijVDxcHVv/uPSfVQJ0/vCa" + "udJGOXV9q4VteupwLxfOGW8w" + "-----END CERTIFICATE-----"; + + certificateString = @"-----BEGIN CERTIFICATE-----\n" + "MIIFrjCCBJagAwIBAgIQB2rJmsHVwbONd36WP9QPrTANBgkqhkiG9w0BAQsFADBx\n" + "MQswCQYDVQQGEwJVUzENMAsGA1UEChMEVklTQTEvMC0GA1UECxMmVmlzYSBJbnRl\n" + "cm5hdGlvbmFsIFNlcnZpY2UgQXNzb2NpYXRpb24xIjAgBgNVBAMTGVZpc2EgZUNv\n" + "bW1lcmNlIElzc3VpbmcgQ0EwHhcNMTcxMTAyMjIyMzEwWhcNMjAxMTAzMDAyMzEw\n" + "WjCBoTEYMBYGA1UEBxMPSGlnaGxhbmRzIFJhbmNoMREwDwYDVQQIEwhDb2xvcmFk\n" + "bzELMAkGA1UEBhMCVVMxDTALBgNVBAoTBFZJU0ExLzAtBgNVBAsTJlZpc2EgSW50\n" + "ZXJuYXRpb25hbCBTZXJ2aWNlIEFzc29jaWF0aW9uMSUwIwYDVQQDExwzZHMyLnJz\n" + "YS5lbmNyeXB0aW9uLnZpc2EuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\n" + "CgKCAQEAst+HGfPPsX3p6HHEQ9YzourlQj16Nscmm13Cp7cZe4dZB2oWnJqZ7oh/\n" + "pEoEoOAxBw1x4NFgXKTKdHAeu3VBNVw8SwMTdIC+X16VV+3VIyPbUvJXFp3QoR8W\n" + "UwPB3F1Lb9SMFNS95boYDZKIOdPW0cP1dRi7pFugsBUZDCP/H3nFfBFHMCBoga+P\n" + "3AHGj5y8RVpv0hS9jaIsYjX+i58B61OGCB7D0AiADNZJuFzw2+xpNkt6NJJF66FP\n" + "O8qIh8xR2xGVDf7TtCbss/CugLRgSqKab9YRB8/TBTcy5bxj6O8HD6aL2zGLcMY9\n" + "dCobXxCodLEtMjJdVL8N+iZrsI2gtwIDAQABo4ICDzCCAgswEwYDVR0lBAwwCgYI\n" + "KwYBBQUHAwEwZQYIKwYBBQUHAQEEWTBXMCUGCCsGAQUFBzABhhlodHRwOi8vb2Nz\n" + "cC52aXNhLmNvbS9vY3NwMC4GCCsGAQUFBzAChiJodHRwOi8vZW5yb2xsLnZpc2Fj\n" + "YS5jb20vZWNvbW0uY2VyMB8GA1UdIwQYMBaAFN/DKlUuL0I6ekCdkqD3R3nXj4eK\n" + "MAwGA1UdEwEB/wQCMAAwgcoGA1UdHwSBwjCBvzAooCagJIYiaHR0cDovL0Vucm9s\n" + "bC52aXNhY2EuY29tL2VDb21tLmNybDCBkqCBj6CBjIaBiWxkYXA6Ly9FbnJvbGwu\n" + "dmlzYWNhLmNvbTozODkvY249VmlzYSBlQ29tbWVyY2UgSXNzdWluZyBDQSxjPVVT\n" + "LG91PVZpc2EgSW50ZXJuYXRpb25hbCBTZXJ2aWNlIEFzc29jaWF0aW9uLG89VklT\n" + "QT9jZXJ0aWZpY2F0ZVJldm9jYXRpb25MaXN0MA4GA1UdDwEB/wQEAwIFoDAnBgNV\n" + "HREEIDAeghwzZHMyLnJzYS5lbmNyeXB0aW9uLnZpc2EuY29tMB0GA1UdDgQWBBT8\n" + "m2pDtUtY13f/3NCOmexHavP5zDA5BgNVHSAEMjAwMC4GBWeBAwEBMCUwIwYIKwYB\n" + "BQUHAgEWF2h0dHA6Ly93d3cudmlzYS5jb20vcGtpMA0GCSqGSIb3DQEBCwUAA4IB\n" + "AQCcCUhU7KnHUDuLXqwSuzC8lWWCcEqPRgPPzY3mgBUg9ya0p+v7QF2BG77tpygK\n" + "E2yDPkOE8trzYeMi7TCuvKgZvUXDSOka8SId9QleMBlo2pzNi0vKKBG8+E7qmGaf\n" + "etQHVaoFvhg24/e7y8q89VYNKfLXn8TWMUOJdTQoNP+4bHcCnBvWWUcI2LlyEog1\n" + "2FDSG8hgP3cpw+0B2Hace9BQGR7ZgTIJAANEHZ54QGOYdxZEcDS5IEpKZlN8INs/\n" + "NKJyCkqP09VA4NO/WHaGFAXtgoLmjlA9Kal+4ieJPKijVDxcHVv/uPSfVQJ0/vCa\n" + "udJGOXV9q4VteupwLxfOGW8w\n" + "-----END CERTIFICATE-----\n"; + certificate = [STDSDirectoryServerCertificate customCertificateWithString:certificateString]; + XCTAssertNotNil(certificate, @"Failed to create certificate from string."); + XCTAssertEqual(certificate.keyType, STDSDirectoryServerKeyTypeRSA, @"Parsed incorrect key type from custom certificate"); + + certificateString = @"-----BEGIN CERTIFICATE-----" + "-----END CERTIFICATE-----"; + certificate = [STDSDirectoryServerCertificate customCertificateWithString:certificateString]; + XCTAssertNil(certificate, @"Should not return a valid value with only anchors"); + XCTAssertEqual(certificate.keyType, STDSDirectoryServerKeyTypeRSA, @"Parsed incorrect key type from custom certificate"); + certificateString = @"-----END CERTIFICATE-----"; + + certificate = [STDSDirectoryServerCertificate customCertificateWithString:certificateString]; + XCTAssertNil(certificate, @"Should not return a valid value with only suffix anchor"); + XCTAssertEqual(certificate.keyType, STDSDirectoryServerKeyTypeRSA, @"Parsed incorrect key type from custom certificate"); +} + +- (void)testCertificateChainValidation { + // ref. EMVCo_3DS_-AppBased_CryptoExamples_082018.pdf + NSString *certificateString = @"MIIDXTCCAkWgAwIBAgIQbS4C4BSig7uuJ5uDpeT4VjANBgkqhkiG9w0BAQsFADBH" + "MRMwEQYKCZImiZPyLGQBGRYDY29tMRcwFQYKCZImiZPyLGQBGRYHZXhhbXBsZTEX" + "MBUGA1UEAwwOUlNBIEV4YW1wbGUgRFMwHhcNMTcxMTIxMTE0ODQ5WhcNMjcxMjMx" + "MTQwMDAwWjBHMRMwEQYKCZImiZPyLGQBGRYDY29tMRcwFQYKCZImiZPyLGQBGRYH" + "ZXhhbXBsZTEXMBUGA1UEAwwOUlNBIEV4YW1wbGUgRFMwggEiMA0GCSqGSIb3DQEB" + "AQUAA4IBDwAwggEKAoIBAQCfgQ+0A4Jz0CWR5Ac/MdK2ABuCzttNkvBQFl1Hz8q4" + "o8Qct3isdVN5P475dXaNGiN02HElZMO813uepDRUSJlAfP8AmZIKkxokxEFIUqsp" + "vbCpXAZT82xg5gv5C2JY3aVvNwR7pcLR0CmvnJ1AuseqQceKDdEGit1pnoCP6gEe" + "oUQdik97tOl7459V8d3UTpxLozUVlwPU00tgPmUUek8j1tPAmWx17e6EaoLRkK4Q" + "eDyWHPA4eu0hBtLQVVtv2Tf61VNTh+D/cv++eJQUArC4IuoqdLYFjB2r+bNKdstj" + "uH+qLGhHuOKDf/+RGG5rHBSRHPmJqJCSqBzmAd2s0/nPAgMBAAGjRTBDMBIGA1Ud" + "EwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTDgwKdvAPq" + "bbCmehDaw0PwavI83jANBgkqhkiG9w0BAQsFAAOCAQEAOUcKqpzNQ6lr0PbDSsns" + "D6onfi+8j3TD0xG0zBSf+8G4zs8Zb6vzzQ5qHKgfr4aeen8Pw0cw2KKUJ2dFaBqj" + "n3/6/MIZbgaBvXKUbmY8xCxKQ+tOFc3KWIu4pSaO50tMPJjU/lP35bv19AA9vs9M" + "TKY2qLf88bmoNYT3W8VSDcB58KBHa7HVIPx7BUUtSyb2N2Jqx5AOiYy4NarhB3hV" + "ftkZBmCzi2Qw50KWIgTFYcIVeRTx3Js/F0IuEdgZHBK2gmO7fdM7+QKYm83401vl" + "YRNCXfIZ0H9E1V3NddqJuqIutdUajckSzMhXdNCJqfI4FAQAymTWGL3/lZyr/30x" + "Fg=="; + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wobjc-string-concatenation" + // ref. EMVCo_3DS_-AppBased_CryptoExamples_082018.pdf + NSArray *certChain = @[@"MIIDeTCCAmGgAwIBAgIQbS4C4BSig7uuJ5uDpeT4WDANB" + "gkqhkiG9w0BAQsFADBHMRMwEQYKCZImiZPyLGQBGRYDY29tMRcwFQYKCZImiZPyLGQBG" + "RYHZXhhbXBsZTEXMBUGA1UEAwwOUlNBIEV4YW1wbGUgRFMwHhcNMTcxMTIxMTE1NDAyW" + "hcNMjcxMjMxMTMzMDAwWjBIMRMwEQYKCZImiZPyLGQBGRYDY29tMRcwFQYKCZImiZPyL" + "GQBGRYHZXhhbXBsZTEYMBYGA1UEAwwPUlNBIEV4YW1wbGUgQUNTMIIBIjANBgkqhkiG9" + "w0BAQEFAAOCAQ8AMIIBCgKCAQEAkNrPIBDXMU6fcyv5i+QHQAQ+K8gsC3HJb7FYhYaw8" + "hXbNJa+t8q0lDKwLZgQXYV+ffWxXJv5GGrlZE4GU52lfMEegTDzYTrRQ3tepgKFjMGg6" + "Iy6fkl1ZNsx2gEonsnlShfzA9GJwRTmtKPbk1s+hwx1IU5AT+AIelNqBgcF2vE5W25/S" + "GGBoaROVdUYxqETDggM1z5cKV4ZjDZ8+lh4oVB07bkac6LQdHpJUUySH/Er20DXx30Ky" + "i97PciXKTS+QKXnmm8ivyRCmux22ZoPUind2BKC5OiG4MwALhaL2Z2k8CsRdfy+7dg7z" + "41Rp6D0ZeEvtaUp4bX4aKraL4rTfwIDAQABo2AwXjAMBgNVHRMBAf8EAjAAMA4GA1UdD" + "wEB/wQEAwIHgDAdBgNVHQ4EFgQUktwf6ZpTCxjYKw/BLW6PeiNX4swwHwYDVR0jBBgwF" + "oAUw4MCnbwD6m2wpnoQ2sND8GryPN4wDQYJKoZIhvcNAQELBQADggEBAGuNHxv/BR6j7" + "lCPysm1uhrbjBOqdrhJMR/Id4dB2GtdEScl3irGPmXyQ2SncTWhNfsgsKDZWp5Bk7+Ot" + "nty0eNUMk3hZEqgYjxhzau048XHbsfGvoJaMGZZNTwUvTUz2hkkhgpx9yQAKIA2LzFKc" + "gYhelPu4GW5rtEuxu3IS6WYy3D1GtF3naEWkjUra8hQOhOl2S+CYHmRd6lGkXykVDajM" + "gd2AJFzXdKLxTt0OYrWDGlUSzGACRBCd5xbRmATIldtccaGqDN1cNWv0I/bPN8EpKS6B" + "0WaZcPasItKWpDC85Jw1GrDxdhwoKHoxtSG+odiTwB5zLbrn2OsRE5bV7E="]; +#pragma clang diagnostic pop + + XCTAssertTrue([STDSDirectoryServerCertificate _verifyCertificateChain:certChain withRootCertificates:@[certificateString]], @"Failed to verify certificate chain."); + certChain = @[@"junk_data", @"more_junk"]; + XCTAssertFalse([STDSDirectoryServerCertificate _verifyCertificateChain:certChain withRootCertificates:@[certificateString]], @"Verified certificate chain with invalid certificates."); +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wobjc-string-concatenation" + // Test with valid certificates in the chain, but that are not related to the root certificate + // ref. https://tools.ietf.org/html/rfc7515 + certChain = @[ + @"MIIE3jCCA8agAwIBAgICAwEwDQYJKoZIhvcNAQEFBQAwYzELMAkGA1UEBhMCVVM" + "xITAfBgNVBAoTGFRoZSBHbyBEYWRkeSBHcm91cCwgSW5jLjExMC8GA1UECxMoR2" + "8gRGFkZHkgQ2xhc3MgMiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0wNjExM" + "TYwMTU0MzdaFw0yNjExMTYwMTU0MzdaMIHKMQswCQYDVQQGEwJVUzEQMA4GA1UE" + "CBMHQXJpem9uYTETMBEGA1UEBxMKU2NvdHRzZGFsZTEaMBgGA1UEChMRR29EYWR" + "keS5jb20sIEluYy4xMzAxBgNVBAsTKmh0dHA6Ly9jZXJ0aWZpY2F0ZXMuZ29kYW" + "RkeS5jb20vcmVwb3NpdG9yeTEwMC4GA1UEAxMnR28gRGFkZHkgU2VjdXJlIENlc" + "nRpZmljYXRpb24gQXV0aG9yaXR5MREwDwYDVQQFEwgwNzk2OTI4NzCCASIwDQYJ" + "KoZIhvcNAQEBBQADggEPADCCAQoCggEBAMQt1RWMnCZM7DI161+4WQFapmGBWTt" + "wY6vj3D3HKrjJM9N55DrtPDAjhI6zMBS2sofDPZVUBJ7fmd0LJR4h3mUpfjWoqV" + "Tr9vcyOdQmVZWt7/v+WIbXnvQAjYwqDL1CBM6nPwT27oDyqu9SoWlm2r4arV3aL" + "GbqGmu75RpRSgAvSMeYddi5Kcju+GZtCpyz8/x4fKL4o/K1w/O5epHBp+YlLpyo" + "7RJlbmr2EkRTcDCVw5wrWCs9CHRK8r5RsL+H0EwnWGu1NcWdrxcx+AuP7q2BNgW" + "JCJjPOq8lh8BJ6qf9Z/dFjpfMFDniNoW1fho3/Rb2cRGadDAW/hOUoz+EDU8CAw" + "EAAaOCATIwggEuMB0GA1UdDgQWBBT9rGEyk2xF1uLuhV+auud2mWjM5zAfBgNVH" + "SMEGDAWgBTSxLDSkdRMEXGzYcs9of7dqGrU4zASBgNVHRMBAf8ECDAGAQH/AgEA" + "MDMGCCsGAQUFBwEBBCcwJTAjBggrBgEFBQcwAYYXaHR0cDovL29jc3AuZ29kYWR" + "keS5jb20wRgYDVR0fBD8wPTA7oDmgN4Y1aHR0cDovL2NlcnRpZmljYXRlcy5nb2" + "RhZGR5LmNvbS9yZXBvc2l0b3J5L2dkcm9vdC5jcmwwSwYDVR0gBEQwQjBABgRVH" + "SAAMDgwNgYIKwYBBQUHAgEWKmh0dHA6Ly9jZXJ0aWZpY2F0ZXMuZ29kYWRkeS5j" + "b20vcmVwb3NpdG9yeTAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQEFBQADggE" + "BANKGwOy9+aG2Z+5mC6IGOgRQjhVyrEp0lVPLN8tESe8HkGsz2ZbwlFalEzAFPI" + "UyIXvJxwqoJKSQ3kbTJSMUA2fCENZvD117esyfxVgqwcSeIaha86ykRvOe5GPLL" + "5CkKSkB2XIsKd83ASe8T+5o0yGPwLPk9Qnt0hCqU7S+8MxZC9Y7lhyVJEnfzuz9" + "p0iRFEUOOjZv2kWzRaJBydTXRE4+uXR21aITVSzGh6O1mawGhId/dQb8vxRMDsx" + "uxN89txJx9OjxUUAiKEngHUuHqDTMBqLdElrRhjZkAzVvb3du6/KFUJheqwNTrZ" + "EjYx8WnM25sgVjOuH0aBsXBTWVU+4=", + @"MIIE+zCCBGSgAwIBAgICAQ0wDQYJKoZIhvcNAQEFBQAwgbsxJDAiBgNVBAcTG1Z" + "hbGlDZXJ0IFZhbGlkYXRpb24gTmV0d29yazEXMBUGA1UEChMOVmFsaUNlcnQsIE" + "luYy4xNTAzBgNVBAsTLFZhbGlDZXJ0IENsYXNzIDIgUG9saWN5IFZhbGlkYXRpb" + "24gQXV0aG9yaXR5MSEwHwYDVQQDExhodHRwOi8vd3d3LnZhbGljZXJ0LmNvbS8x" + "IDAeBgkqhkiG9w0BCQEWEWluZm9AdmFsaWNlcnQuY29tMB4XDTA0MDYyOTE3MDY" + "yMFoXDTI0MDYyOTE3MDYyMFowYzELMAkGA1UEBhMCVVMxITAfBgNVBAoTGFRoZS" + "BHbyBEYWRkeSBHcm91cCwgSW5jLjExMC8GA1UECxMoR28gRGFkZHkgQ2xhc3MgM" + "iBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASAwDQYJKoZIhvcNAQEBBQADggEN" + "ADCCAQgCggEBAN6d1+pXGEmhW+vXX0iG6r7d/+TvZxz0ZWizV3GgXne77ZtJ6XC" + "APVYYYwhv2vLM0D9/AlQiVBDYsoHUwHU9S3/Hd8M+eKsaA7Ugay9qK7HFiH7Eux" + "6wwdhFJ2+qN1j3hybX2C32qRe3H3I2TqYXP2WYktsqbl2i/ojgC95/5Y0V4evLO" + "tXiEqITLdiOr18SPaAIBQi2XKVlOARFmR6jYGB0xUGlcmIbYsUfb18aQr4CUWWo" + "riMYavx4A6lNf4DD+qta/KFApMoZFv6yyO9ecw3ud72a9nmYvLEHZ6IVDd2gWMZ" + "Eewo+YihfukEHU1jPEX44dMX4/7VpkI+EdOqXG68CAQOjggHhMIIB3TAdBgNVHQ" + "4EFgQU0sSw0pHUTBFxs2HLPaH+3ahq1OMwgdIGA1UdIwSByjCBx6GBwaSBvjCBu" + "zEkMCIGA1UEBxMbVmFsaUNlcnQgVmFsaWRhdGlvbiBOZXR3b3JrMRcwFQYDVQQK" + "Ew5WYWxpQ2VydCwgSW5jLjE1MDMGA1UECxMsVmFsaUNlcnQgQ2xhc3MgMiBQb2x" + "pY3kgVmFsaWRhdGlvbiBBdXRob3JpdHkxITAfBgNVBAMTGGh0dHA6Ly93d3cudm" + "FsaWNlcnQuY29tLzEgMB4GCSqGSIb3DQEJARYRaW5mb0B2YWxpY2VydC5jb22CA" + "QEwDwYDVR0TAQH/BAUwAwEB/zAzBggrBgEFBQcBAQQnMCUwIwYIKwYBBQUHMAGG" + "F2h0dHA6Ly9vY3NwLmdvZGFkZHkuY29tMEQGA1UdHwQ9MDswOaA3oDWGM2h0dHA" + "6Ly9jZXJ0aWZpY2F0ZXMuZ29kYWRkeS5jb20vcmVwb3NpdG9yeS9yb290LmNybD" + "BLBgNVHSAERDBCMEAGBFUdIAAwODA2BggrBgEFBQcCARYqaHR0cDovL2NlcnRpZ" + "mljYXRlcy5nb2RhZGR5LmNvbS9yZXBvc2l0b3J5MA4GA1UdDwEB/wQEAwIBBjAN" + "BgkqhkiG9w0BAQUFAAOBgQC1QPmnHfbq/qQaQlpE9xXUhUaJwL6e4+PrxeNYiY+" + "Sn1eocSxI0YGyeR+sBjUZsE4OWBsUs5iB0QQeyAfJg594RAoYC5jcdnplDQ1tgM" + "QLARzLrUc+cb53S8wGd9D0VmsfSxOaFIqII6hR8INMqzW/Rn453HWkrugp++85j" + "09VZw==", + @"MIIC5zCCAlACAQEwDQYJKoZIhvcNAQEFBQAwgbsxJDAiBgNVBAcTG1ZhbGlDZXJ" + "0IFZhbGlkYXRpb24gTmV0d29yazEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNT" + "AzBgNVBAsTLFZhbGlDZXJ0IENsYXNzIDIgUG9saWN5IFZhbGlkYXRpb24gQXV0a" + "G9yaXR5MSEwHwYDVQQDExhodHRwOi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkq" + "hkiG9w0BCQEWEWluZm9AdmFsaWNlcnQuY29tMB4XDTk5MDYyNjAwMTk1NFoXDTE" + "5MDYyNjAwMTk1NFowgbsxJDAiBgNVBAcTG1ZhbGlDZXJ0IFZhbGlkYXRpb24gTm" + "V0d29yazEXMBUGA1UEChMOVmFsaUNlcnQsIEluYy4xNTAzBgNVBAsTLFZhbGlDZ" + "XJ0IENsYXNzIDIgUG9saWN5IFZhbGlkYXRpb24gQXV0aG9yaXR5MSEwHwYDVQQD" + "ExhodHRwOi8vd3d3LnZhbGljZXJ0LmNvbS8xIDAeBgkqhkiG9w0BCQEWEWluZm9" + "AdmFsaWNlcnQuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDOOnHK5a" + "vIWZJV16vYdA757tn2VUdZZUcOBVXc65g2PFxTXdMwzzjsvUGJ7SVCCSRrCl6zf" + "N1SLUzm1NZ9WlmpZdRJEy0kTRxQb7XBhVQ7/nHk01xC+YDgkRoKWzk2Z/M/VXwb" + "P7RfZHM047QSv4dk+NoS/zcnwbNDu+97bi5p9wIDAQABMA0GCSqGSIb3DQEBBQU" + "AA4GBADt/UG9vUJSZSWI4OB9L+KXIPqeCgfYrx+jFzug6EILLGACOTb2oWH+heQ" + "C1u+mNr0HZDzTuIYEZoDJJKPTEjlbVUjP9UNV+mWwD5MlM/Mtsq2azSiGM5bUMM" + "j4QssxsodyamEwCW/POuZ6lcg5Ktz885hZo+L7tdEy8W9ViH0Pd", + @"MIIDeTCCAmGgAwIBAgIQbS4C4BSig7uuJ5uDpeT4WDANB" + "gkqhkiG9w0BAQsFADBHMRMwEQYKCZImiZPyLGQBGRYDY29tMRcwFQYKCZImiZPyLGQBG" + "RYHZXhhbXBsZTEXMBUGA1UEAwwOUlNBIEV4YW1wbGUgRFMwHhcNMTcxMTIxMTE1NDAyW" + "hcNMjcxMjMxMTMzMDAwWjBIMRMwEQYKCZImiZPyLGQBGRYDY29tMRcwFQYKCZImiZPyL" + "GQBGRYHZXhhbXBsZTEYMBYGA1UEAwwPUlNBIEV4YW1wbGUgQUNTMIIBIjANBgkqhkiG9" + "w0BAQEFAAOCAQ8AMIIBCgKCAQEAkNrPIBDXMU6fcyv5i+QHQAQ+K8gsC3HJb7FYhYaw8" + "hXbNJa+t8q0lDKwLZgQXYV+ffWxXJv5GGrlZE4GU52lfMEegTDzYTrRQ3tepgKFjMGg6" + "Iy6fkl1ZNsx2gEonsnlShfzA9GJwRTmtKPbk1s+hwx1IU5AT+AIelNqBgcF2vE5W25/S" + "GGBoaROVdUYxqETDggM1z5cKV4ZjDZ8+lh4oVB07bkac6LQdHpJUUySH/Er20DXx30Ky" + "i97PciXKTS+QKXnmm8ivyRCmux22ZoPUind2BKC5OiG4MwALhaL2Z2k8CsRdfy+7dg7z" + "41Rp6D0ZeEvtaUp4bX4aKraL4rTfwIDAQABo2AwXjAMBgNVHRMBAf8EAjAAMA4GA1UdD" + "wEB/wQEAwIHgDAdBgNVHQ4EFgQUktwf6ZpTCxjYKw/BLW6PeiNX4swwHwYDVR0jBBgwF" + "oAUw4MCnbwD6m2wpnoQ2sND8GryPN4wDQYJKoZIhvcNAQELBQADggEBAGuNHxv/BR6j7" + "lCPysm1uhrbjBOqdrhJMR/Id4dB2GtdEScl3irGPmXyQ2SncTWhNfsgsKDZWp5Bk7+Ot" + "nty0eNUMk3hZEqgYjxhzau048XHbsfGvoJaMGZZNTwUvTUz2hkkhgpx9yQAKIA2LzFKc" + "gYhelPu4GW5rtEuxu3IS6WYy3D1GtF3naEWkjUra8hQOhOl2S+CYHmRd6lGkXykVDajM" + "gd2AJFzXdKLxTt0OYrWDGlUSzGACRBCd5xbRmATIldtccaGqDN1cNWv0I/bPN8EpKS6B" + "0WaZcPasItKWpDC85Jw1GrDxdhwoKHoxtSG+odiTwB5zLbrn2OsRE5bV7E=",]; + XCTAssertFalse([STDSDirectoryServerCertificate _verifyCertificateChain:certChain withRootCertificates:@[certificateString]], @"Verified invalid certificate chain."); + + // stripe.com's cert chain retrieved by https://whatsmychaincert.com/ + // If below test cases start failing, the certs may have expired and should be replaced with newer ones + // from whatsmychaincert.com, select Generate Cert Chain, include root for stripe.com + // print the entire certificate chain with `openssl crl2pkcs7 -nocrl -certfile | openssl pkcs7 -print_certs -text` + // The root is the last printed certificate and the cert chain should be ordered with the top most at the end of certChain + + certChain = @[ + @"MIIEtjCCA56gAwIBAgIQDHmpRLCMEZUgkmFf4msdgzANBgkqhkiG9w0BAQsFADBs" + "MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3" + "d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5j" + "ZSBFViBSb290IENBMB4XDTEzMTAyMjEyMDAwMFoXDTI4MTAyMjEyMDAwMFowdTEL" + "MAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3" + "LmRpZ2ljZXJ0LmNvbTE0MDIGA1UEAxMrRGlnaUNlcnQgU0hBMiBFeHRlbmRlZCBW" + "YWxpZGF0aW9uIFNlcnZlciBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC" + "ggEBANdTpARR+JmmFkhLZyeqk0nQOe0MsLAAh/FnKIaFjI5j2ryxQDji0/XspQUY" + "uD0+xZkXMuwYjPrxDKZkIYXLBxA0sFKIKx9om9KxjxKws9LniB8f7zh3VFNfgHk/" + "LhqqqB5LKw2rt2O5Nbd9FLxZS99RStKh4gzikIKHaq7q12TWmFXo/a8aUGxUvBHy" + "/Urynbt/DvTVvo4WiRJV2MBxNO723C3sxIclho3YIeSwTQyJ3DkmF93215SF2AQh" + "cJ1vb/9cuhnhRctWVyh+HA1BV6q3uCe7seT6Ku8hI3UarS2bhjWMnHe1c63YlC3k" + "8wyd7sFOYn4XwHGeLN7x+RAoGTMCAwEAAaOCAUkwggFFMBIGA1UdEwEB/wQIMAYB" + "Af8CAQAwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEF" + "BQcDAjA0BggrBgEFBQcBAQQoMCYwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRp" + "Z2ljZXJ0LmNvbTBLBgNVHR8ERDBCMECgPqA8hjpodHRwOi8vY3JsNC5kaWdpY2Vy" + "dC5jb20vRGlnaUNlcnRIaWdoQXNzdXJhbmNlRVZSb290Q0EuY3JsMD0GA1UdIAQ2" + "MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIBFhxodHRwczovL3d3dy5kaWdpY2VydC5j" + "b20vQ1BTMB0GA1UdDgQWBBQ901Cl1qCt7vNKYApl0yHU+PjWDzAfBgNVHSMEGDAW" + "gBSxPsNpA/i/RwHUmCYaCALvY2QrwzANBgkqhkiG9w0BAQsFAAOCAQEAnbbQkIbh" + "hgLtxaDwNBx0wY12zIYKqPBKikLWP8ipTa18CK3mtlC4ohpNiAexKSHc59rGPCHg" + "4xFJcKx6HQGkyhE6V6t9VypAdP3THYUYUN9XR3WhfVUgLkc3UHKMf4Ib0mKPLQNa" + "2sPIoc4sUqIAY+tzunHISScjl2SFnjgOrWNoPLpSgVh5oywM395t6zHyuqB8bPEs" + "1OG9d4Q3A84ytciagRpKkk47RpqF/oOi+Z6Mo8wNXrM9zwR4jxQUezKcxwCmXMS1" + "oVWNWlZopCJwqjyBcdmdqEU79OX2olHdx3ti6G8MdOu42vi/hw15UJGQmxg7kVkn" + "8TUoE6smftX3eg==", + + @"MIIHQDCCBiigAwIBAgIQD2ygPziYarLZojJGDizjjzANBgkqhkiG9w0BAQsFADB1" + "MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3" + "d3cuZGlnaWNlcnQuY29tMTQwMgYDVQQDEytEaWdpQ2VydCBTSEEyIEV4dGVuZGVk" + "IFZhbGlkYXRpb24gU2VydmVyIENBMB4XDTIxMTAyMDAwMDAwMFoXDTIyMDIwMjIz" + "NTk1OVowgcYxHTAbBgNVBA8MFFByaXZhdGUgT3JnYW5pemF0aW9uMRMwEQYLKwYB" + "BAGCNzwCAQMTAlVTMRkwFwYLKwYBBAGCNzwCAQITCERlbGF3YXJlMRAwDgYDVQQF" + "Ewc0Njc1NTA2MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQG" + "A1UEBxMNU2FuIEZyYW5jaXNjbzEUMBIGA1UEChMLU3RyaXBlLCBJbmMxEzARBgNV" + "BAMTCnN0cmlwZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC1" + "ZyRTERjtA28e16WX4AUBBbXE8YTA6F5RNp5NEy2px6GF2LbjR8TobVfJoj63+CXR" + "W4uox0TQV526ZnPKbvYpAilYxEc9/fJTPS3lDJ4EgKRZlZ97UrGY8dzOR2aebpw4" + "xYnN8gYXFHeISG1ieQnnyZUck5HUF1FX3rcWh8y7doNLL9cvIt5+R6yLqeTZkCX3" + "eIAvANuhdN++nqTM7JoFSViZa8VNQ18wviB5Hw+VWdpZXF9SQmWpP4M7pgDUYTVa" + "xIsywBiisQBExh5NXEhSAboTtqxmt5IADSQx9E2tp5u3oY2Ql3YRGmYnfe1y7QKD" + "PdEVMRa4aFod+w9qe2OfAgMBAAGjggN4MIIDdDAfBgNVHSMEGDAWgBQ901Cl1qCt" + "7vNKYApl0yHU+PjWDzAdBgNVHQ4EFgQUzjnGrO9r9NbeN6cRkzoAtaKNZLowJQYD" + "VR0RBB4wHIIKc3RyaXBlLmNvbYIOd3d3LnN0cmlwZS5jb20wDgYDVR0PAQH/BAQD" + "AgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjB1BgNVHR8EbjBsMDSg" + "MqAwhi5odHRwOi8vY3JsMy5kaWdpY2VydC5jb20vc2hhMi1ldi1zZXJ2ZXItZzMu" + "Y3JsMDSgMqAwhi5odHRwOi8vY3JsNC5kaWdpY2VydC5jb20vc2hhMi1ldi1zZXJ2" + "ZXItZzMuY3JsMEoGA1UdIARDMEEwCwYJYIZIAYb9bAIBMDIGBWeBDAEBMCkwJwYI" + "KwYBBQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzCBiAYIKwYBBQUH" + "AQEEfDB6MCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wUgYI" + "KwYBBQUHMAKGRmh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFNI" + "QTJFeHRlbmRlZFZhbGlkYXRpb25TZXJ2ZXJDQS5jcnQwDAYDVR0TAQH/BAIwADCC" + "AX4GCisGAQQB1nkCBAIEggFuBIIBagFoAHYAKXm+8J45OSHwVnOfY6V35b5XfZxg" + "Cvj5TV0mXCVdx4QAAAF8n1z3KwAABAMARzBFAiAWda1cPqRI3YujJxhh3ahOlUoN" + "L+bpZFA30lL+L3UB1AIhAIAX2FUC4uyshmrtgnnq7OcmIzsOEVW7LDlFUlfokAbW" + "AHUAUaOw9f0BeZxWbbg3eI8MpHrMGyfL956IQpoN/tSLBeUAAAF8n1z3JAAABAMA" + "RjBEAiAPAv+vPLXXB1hB5S+MIrWXIIIsa0u+cPIARVuMEQPz5gIgLbrsj2bNmxYF" + "mfvwsORC61rCPouzzFkvQGy81lz0L38AdwBByMqx3yJGShDGoToJQodeTjGLGwPr" + "60vHaPCQYpYG9gAAAXyfXPahAAAEAwBIMEYCIQCdieTsguvk9YFGdpMPsSp4CJYY" + "1cn1lTiYQQBwzKSqogIhAOyyQKx4fhNqtWOiXUYhPaQtYLS7Ey4TeOlmm7U3OktO" + "MA0GCSqGSIb3DQEBCwUAA4IBAQBmZNhqh9q/u4hNPX7fPhIXTkHJD+B30kefLTOK" + "OA/ZlMzGdvrGaJAJNzQzZL0gTEf3x+D9yjOv8shBIfblugTTa/+ulDbSzjM7qrn6" + "nfojxtLdJ90UvkDCeJ30+Mn+FRahAeLN1jVAgXAUKTgU8QoAM0URZuLh/yWSoMzh" + "kx9PiDtEJR6DMoVVOPTf9Yk3iUBr//KDzVMbz5aJFDO+2fUjGqVmW+3rapYfealY" + "LNz/SmaO8S5U4z9A3xkwXSWxlEKkboKvngSdhNgYyPeHTmNKhcmFnnA9q2LvAl9u" + "UUhp5kZA9EKInRZiVl6i7z1RNhxe2RISbq1MlZi/raZYACt8", + ]; +#pragma clang diagnostic pop + + // root + NSString *rootCertificateString = @"MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBs" + "MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3" + "d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5j" + "ZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAwMFoXDTMxMTExMDAwMDAwMFowbDEL" + "MAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3" + "LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFuY2Ug" + "RVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm" + "+9S75S0tMqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTW" + "PNt0OKRKzE0lgvdKpVMSOO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEM" + "xChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFB" + "Ik5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQNAQTXKFx01p8VdteZOE3" + "hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUeh10aUAsg" + "EsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQF" + "MAMBAf8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaA" + "FLE+w2kD+L9HAdSYJhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3Nec" + "nzyIZgYIVyHbIUf4KmeqvxgydkAQV8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6z" + "eM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFpmyPInngiK3BD41VHMWEZ71jF" + "hS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkKmNEVX58Svnw2" + "Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe" + "vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep" + "+OkuE6N36B9K"; + + // mastercard DS (from mastercard.der) + NSString *dsCertificateString = @"MIIFtTCCA52gAwIBAgIQJqSRaPua/6cpablmVDHWUDANBgkqhkiG9w0BAQsFADB6" + "MQswCQYDVQQGEwJVUzETMBEGA1UEChMKTWFzdGVyQ2FyZDEoMCYGA1UECxMfTWFz" + "dGVyQ2FyZCBJZGVudGl0eSBDaGVjayBHZW4gMzEsMCoGA1UEAxMjUFJEIE1hc3Rl" + "ckNhcmQgM0RTMiBBY3F1aXJlciBTdWIgQ0EwHhcNMTgxMTIwMTQ1MzIzWhcNMjEx" + "MTIwMTQ1MzIzWjBxMQswCQYDVQQGEwJVUzEdMBsGA1UEChMUTWFzdGVyQ2FyZCBX" + "b3JsZHdpZGUxGzAZBgNVBAsTEmdhdGV3YXktZW5jcnlwdGlvbjEmMCQGA1UEAxMd" + "M2RzMi5kaXJlY3RvcnkubWFzdGVyY2FyZC5jb20wggEiMA0GCSqGSIb3DQEBAQUA" + "A4IBDwAwggEKAoIBAQCFlZjqbbL9bDKOzZFawdbyfQcezVEUSDCWWsYKw/V6co9A" + "GaPBUsGgzxF6+EDgVj3vYytgSl8xFvVPsb4ZJ6BJGvimda8QiIyrX7WUxQMB3hyS" + "BOPf4OB72CP+UkaFNR6hdlO5ofzTmB2oj1FdLGZmTN/sj6ZoHkn2Zzums8QAHFjv" + "FjspKUYCmms91gpNpJPUUztn0N1YMWVFpFMytahHIlpiGqTDt4314F7sFABLxzFr" + "Dmcqhf623SPV3kwQiLVWOvewO62ItYUFgHwle2dq76YiKrUv1C7vADSk2Am4gqwv" + "7dcCnFeM2AHbBFBa1ZBRQXosuXVw8ZcQqfY8m4iNAgMBAAGjggE+MIIBOjAOBgNV" + "HQ8BAf8EBAMCAygwCQYDVR0TBAIwADAfBgNVHSMEGDAWgBSakqJUx4CN/s5W4wMU" + "/17uSLhFuzBIBggrBgEFBQcBAQQ8MDowOAYIKwYBBQUHMAGGLGh0dHA6Ly9vY3Nw" + "LnBraS5pZGVudGl0eWNoZWNrLm1hc3RlcmNhcmQuY29tMCgGA1UdEQQhMB+CHTNk" + "czIuZGlyZWN0b3J5Lm1hc3RlcmNhcmQuY29tMGkGA1UdHwRiMGAwXqBcoFqGWGh0" + "dHA6Ly9jcmwucGtpLmlkZW50aXR5Y2hlY2subWFzdGVyY2FyZC5jb20vOWE5MmEy" + "NTRjNzgwOGRmZWNlNTZlMzAzMTRmZjVlZWU0OGI4NDViYi5jcmwwHQYDVR0OBBYE" + "FHxN6+P0r3+dFWmi/+pDQ8JWaCbuMA0GCSqGSIb3DQEBCwUAA4ICAQAtwW8siyCi" + "mhon1WUAUmufZ7bbegf3cTOafQh77NvA0xgVeloELUNCwsSSZgcOIa4Zgpsa0xi5" + "fYxXsPLgVPLM0mBhTOD1DnPu1AAm32QVelHe6oB98XxbkQlHGXeOLs62PLtDZd94" + "7pm08QMVb+MoCnHLaBLV6eKhKK+SNrfcxr33m0h3v2EMoiJ6zCvp8HgIHEhVpleU" + "8H2Uo5YObatb/KUHgtp2z0vEfyGhZR7hrr48vUQpfVGBABsCV0aqUkPxtAXWfQo9" + "1N9B7H3EIcSjbiUz5vkj9YeDSyJIi0Y/IZbzuNMsz2cRi1CWLl37w2fe128qWxYq" + "Y/k+Y4HX7uYchB8xPaZR4JczCvg1FV2JrkOcFvElVXWSMpBbe2PS6OMr3XxrHjzp" + "DyM9qvzge0Ai9+rq8AyGoG1dP2Ay83Ndlgi42X3yl1uEUW2feGojCQQCFFArazEj" + "LUkSlrB2kA12SWAhsqqQwnBLGSTp7PqPZeWkluQVXS0sbj0878kTra6TjG3U+KqO" + "JCj8v6G380qIkAXe1xMHHNQ6GS59HZMeBPYkK2y5hmh/JVo4bRfK7Ya3blBSBfB8" + "AVWQ5GqVWklvXZsQLN7FH/fMIT3y8iE1W19Ua4whlhvn7o/aYWOkHr1G2xyh8BHj" + "7H63A2hjcPlW/ZAJSTuBZUClAhsNohH2Jg=="; + + XCTAssertTrue([STDSDirectoryServerCertificate _verifyCertificateChain:certChain withRootCertificates:@[rootCertificateString]], @"Failed to verify w/ root certificate."); + XCTAssertFalse([STDSDirectoryServerCertificate _verifyCertificateChain:certChain withRootCertificates:@[dsCertificateString]], @"Should not verify w/ DS certificate."); + NSArray *bothCertificateStrings = @[dsCertificateString, rootCertificateString]; + XCTAssertTrue([STDSDirectoryServerCertificate _verifyCertificateChain:certChain withRootCertificates:bothCertificateStrings], @"Failed to verify w/ root certificate and ds certificate."); +} + +- (void)testVerifyPS256Signature { + // ref. EMVCo_3DS_-AppBased_CryptoExamples_082018.pdf + NSString *jwsString = @"eyJhbGciOiJQUzI1NiIsIng1YyI6WyJNSUlEZVRDQ0FtR2dBd0lCQWdJUWJTNEM0QlNp" + "Zzd1dUo1dURwZVQ0V0RBTkJna3Foa2lHOXcwQkFRc0ZBREJITVJNd0VRWUtDWkltaVpQ" + "eUxHUUJHUllEWTI5dE1SY3dGUVlLQ1pJbWlaUHlMR1FCR1JZSFpYaGhiWEJzWlRFWE1C" + "VUdBMVVFQXd3T1VsTkJJRVY0WVcxd2JHVWdSRk13SGhjTk1UY3hNVEl4TVRFMU5EQXlX" + "aGNOTWpjeE1qTXhNVE16TURBd1dqQklNUk13RVFZS0NaSW1pWlB5TEdRQkdSWURZMjl0" + "TVJjd0ZRWUtDWkltaVpQeUxHUUJHUllIWlhoaGJYQnNaVEVZTUJZR0ExVUVBd3dQVWxO" + "QklFVjRZVzF3YkdVZ1FVTlRNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1J" + "SUJDZ0tDQVFFQWtOclBJQkRYTVU2ZmN5djVpK1FIUUFRK0s4Z3NDM0hKYjdGWWhZYXc4" + "aFhiTkphK3Q4cTBsREt3TFpnUVhZVitmZld4WEp2NUdHcmxaRTRHVTUybGZNRWVnVER6" + "WVRyUlEzdGVwZ0tGak1HZzZJeTZma2wxWk5zeDJnRW9uc25sU2hmekE5R0p3UlRtdEtQ" + "Ymsxcytod3gxSVU1QVQrQUllbE5xQmdjRjJ2RTVXMjUvU0dHQm9hUk9WZFVZeHFFVERn" + "Z00xejVjS1Y0WmpEWjgrbGg0b1ZCMDdia2FjNkxRZEhwSlVVeVNIL0VyMjBEWHgzMEt5" + "aTk3UGNpWEtUUytRS1hubW04aXZ5UkNtdXgyMlpvUFVpbmQyQktDNU9pRzRNd0FMaGFM" + "MloyazhDc1JkZnkrN2RnN3o0MVJwNkQwWmVFdnRhVXA0Ylg0YUtyYUw0clRmd0lEQVFB" + "Qm8yQXdYakFNQmdOVkhSTUJBZjhFQWpBQU1BNEdBMVVkRHdFQi93UUVBd0lIZ0RBZEJn" + "TlZIUTRFRmdRVWt0d2Y2WnBUQ3hqWUt3L0JMVzZQZWlOWDRzd3dId1lEVlIwakJCZ3dG" + "b0FVdzRNQ25id0Q2bTJ3cG5vUTJzTkQ4R3J5UE40d0RRWUpLb1pJaHZjTkFRRUxCUUFE" + "Z2dFQkFHdU5IeHYvQlI2ajdsQ1B5c20xdWhyYmpCT3FkcmhKTVIvSWQ0ZEIyR3RkRVNj" + "bDNpckdQbVh5UTJTbmNUV2hOZnNnc0tEWldwNUJrNytPdG50eTBlTlVNazNoWkVxZ1lq" + "eGh6YXUwNDhYSGJzZkd2b0phTUdaWk5Ud1V2VFV6Mmhra2hncHg5eVFBS0lBMkx6Rktj" + "Z1loZWxQdTRHVzVydEV1eHUzSVM2V1l5M0QxR3RGM25hRVdralVyYThoUU9oT2wyUytD" + "WUhtUmQ2bEdrWHlrVkRhak1nZDJBSkZ6WGRLTHhUdDBPWXJXREdsVVN6R0FDUkJDZDV4" + "YlJtQVRJbGR0Y2NhR3FETjFjTld2MEkvYlBOOEVwS1M2QjBXYVpjUGFzSXRLV3BEQzg1" + "SncxR3JEeGRod29LSG94dFNHK29kaVR3QjV6TGJybjJPc1JFNWJWN0U9Il19" + "." + "eyJBQ1MgRXBoZW1lcmFsIFB1YmxpYyBLZXkgKFFUKSI6eyJrdHkiOiJFQyIsImNydiI6" + "IlAtMjU2IiwieCI6Im1QVUtUX2JBV0dISWhnMFRwampxVnNQMXJYV1F1X3Z3Vk9ISHRO" + "a2RZb0EiLCJ5IjoiOEJRQXNJbUdlQVM0NmZ5V3c1TWhmR1RUMElqQnBGdzJTUzM0RHY0" + "SXJzIix9LCJTREsgRXBoZW1lcmFsIFB1YmxpYyBLZXkgKFFDKSI6eyJrdHkiOiJFQyIs" + "ImNydiI6IlAtMjU2IiwieCI6IlplMmxvU1Yzd3Jyb0tVTl80emh3R2hDcW8zWGh1MXRk" + "NFFqZVE1d0lWUjAiLCJ5IjoiSGxMdGRYQVJZX2Y1NUEzZm56UWJQY202aGdyMzRNcDhw" + "LW51elFDRTBadyIsfSwiQUNTIFVSTCI6Imh0dHA6Ly9hY3NzZXJ2ZXIuZG9tYWlubmFt" + "ZS5jb20ifQ" + "." + "OiiD5pbwe_0wrG_j61LmUxidBzTbUHyZXrKE9efRylEgMJy0axYJLOUshIloiKMexPdo" + "yDdpZV5pMQR588q-" + "uuUfssKpDXj3dCrIlUCEwgs4yfIBAUwd2NHoDg20w25ek0NPH9RV_T867DAXIjDWyd2I" + "y0ZFvJeHSrRskyPY75PA_mAUIpahaW20rxJHHMyGuWx2byKxnIx6G14vXCOPp1xEv49K" + "xKWrgFLS3_GtVYuNDqQC5pleHd4drLKSbq1bjwqEM1osYEZvw9y-" + "f1vQYxAQ8GJEti9F_309GVCZSWe1oyNDY51mo-" + "BwiyojoCDPxQDOTp6g4lp656tUQNW16g"; + STDSJSONWebSignature *jws = [[STDSJSONWebSignature alloc] initWithString:jwsString]; + + // ref. EMVCo_3DS_-AppBased_CryptoExamples_082018.pdf + NSString *certificateString = @"MIIDXTCCAkWgAwIBAgIQbS4C4BSig7uuJ5uDpeT4VjANBgkqhkiG9w0BAQsFADBH" + "MRMwEQYKCZImiZPyLGQBGRYDY29tMRcwFQYKCZImiZPyLGQBGRYHZXhhbXBsZTEX" + "MBUGA1UEAwwOUlNBIEV4YW1wbGUgRFMwHhcNMTcxMTIxMTE0ODQ5WhcNMjcxMjMx" + "MTQwMDAwWjBHMRMwEQYKCZImiZPyLGQBGRYDY29tMRcwFQYKCZImiZPyLGQBGRYH" + "ZXhhbXBsZTEXMBUGA1UEAwwOUlNBIEV4YW1wbGUgRFMwggEiMA0GCSqGSIb3DQEB" + "AQUAA4IBDwAwggEKAoIBAQCfgQ+0A4Jz0CWR5Ac/MdK2ABuCzttNkvBQFl1Hz8q4" + "o8Qct3isdVN5P475dXaNGiN02HElZMO813uepDRUSJlAfP8AmZIKkxokxEFIUqsp" + "vbCpXAZT82xg5gv5C2JY3aVvNwR7pcLR0CmvnJ1AuseqQceKDdEGit1pnoCP6gEe" + "oUQdik97tOl7459V8d3UTpxLozUVlwPU00tgPmUUek8j1tPAmWx17e6EaoLRkK4Q" + "eDyWHPA4eu0hBtLQVVtv2Tf61VNTh+D/cv++eJQUArC4IuoqdLYFjB2r+bNKdstj" + "uH+qLGhHuOKDf/+RGG5rHBSRHPmJqJCSqBzmAd2s0/nPAgMBAAGjRTBDMBIGA1Ud" + "EwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTDgwKdvAPq" + "bbCmehDaw0PwavI83jANBgkqhkiG9w0BAQsFAAOCAQEAOUcKqpzNQ6lr0PbDSsns" + "D6onfi+8j3TD0xG0zBSf+8G4zs8Zb6vzzQ5qHKgfr4aeen8Pw0cw2KKUJ2dFaBqj" + "n3/6/MIZbgaBvXKUbmY8xCxKQ+tOFc3KWIu4pSaO50tMPJjU/lP35bv19AA9vs9M" + "TKY2qLf88bmoNYT3W8VSDcB58KBHa7HVIPx7BUUtSyb2N2Jqx5AOiYy4NarhB3hV" + "ftkZBmCzi2Qw50KWIgTFYcIVeRTx3Js/F0IuEdgZHBK2gmO7fdM7+QKYm83401vl" + "YRNCXfIZ0H9E1V3NddqJuqIutdUajckSzMhXdNCJqfI4FAQAymTWGL3/lZyr/30x" + "Fg=="; + + XCTAssertTrue([STDSDirectoryServerCertificate verifyJSONWebSignature:jws withRootCertificates:@[certificateString]], @"Failed to validate correct PS256 signature."); +} + +- (void)testVerifyPS256SignatureFail { + // ref. EMVCo_3DS_-AppBased_CryptoExamples_082018.pdf + NSString *jwsString = @"eyJhbGciOiJQUzI1NiIsIng1YyI6WyJNSUlEZVRDQ0FtR2dBd0lCQWdJUWJTNEM0QlNp" + "Zzd1dUo1dURwZVQ0V0RBTkJna3Foa2lHOXcwQkFRc0ZBREJITVJNd0VRWUtDWkltaVpQ" + "eUxHUUJHUllEWTI5dE1SY3dGUVlLQ1pJbWlaUHlMR1FCR1JZSFpYaGhiWEJzWlRFWE1C" + "VUdBMVVFQXd3T1VsTkJJRVY0WVcxd2JHVWdSRk13SGhjTk1UY3hNVEl4TVRFMU5EQXlX" + "aGNOTWpjeE1qTXhNVE16TURBd1dqQklNUk13RVFZS0NaSW1pWlB5TEdRQkdSWURZMjl0" + "TVJjd0ZRWUtDWkltaVpQeUxHUUJHUllIWlhoaGJYQnNaVEVZTUJZR0ExVUVBd3dQVWxO" + "QklFVjRZVzF3YkdVZ1FVTlRNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1J" + "SUJDZ0tDQVFFQWtOclBJQkRYTVU2ZmN5djVpK1FIUUFRK0s4Z3NDM0hKYjdGWWhZYXc4" + "aFhiTkphK3Q4cTBsREt3TFpnUVhZVitmZld4WEp2NUdHcmxaRTRHVTUybGZNRWVnVER6" + "WVRyUlEzdGVwZ0tGak1HZzZJeTZma2wxWk5zeDJnRW9uc25sU2hmekE5R0p3UlRtdEtQ" + "Ymsxcytod3gxSVU1QVQrQUllbE5xQmdjRjJ2RTVXMjUvU0dHQm9hUk9WZFVZeHFFVERn" + "Z00xejVjS1Y0WmpEWjgrbGg0b1ZCMDdia2FjNkxRZEhwSlVVeVNIL0VyMjBEWHgzMEt5" + "aTk3UGNpWEtUUytRS1hubW04aXZ5UkNtdXgyMlpvUFVpbmQyQktDNU9pRzRNd0FMaGFM" + "MloyazhDc1JkZnkrN2RnN3o0MVJwNkQwWmVFdnRhVXA0Ylg0YUtyYUw0clRmd0lEQVFB" + "Qm8yQXdYakFNQmdOVkhSTUJBZjhFQWpBQU1BNEdBMVVkRHdFQi93UUVBd0lIZ0RBZEJn" + "TlZIUTRFRmdRVWt0d2Y2WnBUQ3hqWUt3L0JMVzZQZWlOWDRzd3dId1lEVlIwakJCZ3dG" + "b0FVdzRNQ25id0Q2bTJ3cG5vUTJzTkQ4R3J5UE40d0RRWUpLb1pJaHZjTkFRRUxCUUFE" + "Z2dFQkFHdU5IeHYvQlI2ajdsQ1B5c20xdWhyYmpCT3FkcmhKTVIvSWQ0ZEIyR3RkRVNj" + "bDNpckdQbVh5UTJTbmNUV2hOZnNnc0tEWldwNUJrNytPdG50eTBlTlVNazNoWkVxZ1lq" + "eGh6YXUwNDhYSGJzZkd2b0phTUdaWk5Ud1V2VFV6Mmhra2hncHg5eVFBS0lBMkx6Rktj" + "Z1loZWxQdTRHVzVydEV1eHUzSVM2V1l5M0QxR3RGM25hRVdralVyYThoUU9oT2wyUytD" + "WUhtUmQ2bEdrWHlrVkRhak1nZDJBSkZ6WGRLTHhUdDBPWXJXREdsVVN6R0FDUkJDZDV4" + "YlJtQVRJbGR0Y2NhR3FETjFjTld2MEkvYlBOOEVwS1M2QjBXYVpjUGFzSXRLV3BEQzg1" + "SncxR3JEeGRod29LSG94dFNHK29kaVR3QjV6TGJybjJPc1JFNWJWN0U9Il19" + "." + "eyJBQ1MgRXBoZW1lcmFsIFB1YmxpYyBLZXkgKFFUKSI6eyJrdHkiOiJFQyIsImNydiI6" + "IlAtMjU2IiwieCI6Im1QVUtUX2JBV0dISWhnMFRwampxVnNQMXJYV1F1X3Z3Vk9ISHRO" + "a2RZb0EiLCJ5IjoiOEJRQXNJbUdlQVM0NmZ5V3c1TWhmR1RUMElqQnBGdzJTUzM0RHY0" + "SXJzIix9LCJTREsgRXBoZW1lcmFsIFB1YmxpYyBLZXkgKFFDKSI6eyJrdHkiOiJFQyIs" + "ImNydiI6IlAtMjU2IiwieCI6IlplMmxvU1Yzd3Jyb0tVTl80emh3R2hDcW8zWGh1MXRk" + "NFFqZVE1d0lWUjAiLCJ5IjoiSGxMdGRYQVJZX2Y1NUEzZm56UWJQY202aGdyMzRNcDhw" + "LW51elFDRTBadyIsfSwiQUNTIFVSTCI6Imh0dHA6Ly9hY3NzZXJ2ZXIuZG9tYWlubmFt" + "ZS5jb20ifQ" + "." + "OiiD5pbwe_0wrG_j61LmUxidBzTbUHyZXrKE9efRylEgMJy0axYJLOUshIloiKMexPdo" + "yDdpZV5pMQR588q-" + "uuUfssKpDXj3dCrIlUCEwgs4yfIBAUwd2NHoDg20w25ek0NPH9RV_T867DAXIjDWyd2I" + "y0ZFvJeHSrRskyPY75PA_mAUIpahaW20rxJHHMyGuWx2byKxnIx6G14vXCOPp1xEv49K" + "xKWrgFLS3_GtVYuNDqQC5pleHd4drLKSbq1bjwqEM1osYEZvw9y-" + "f1vQYxAQ8GJEti9F_309GVCZSWe1oyNEY51mo-" // This is all the same as testVerifyPS256Signature, except this line where "NDY51mo-" became "NEY51mo-" + "BwiyojoCDPxQDOTp6g4lp656tUQNW16g"; + STDSJSONWebSignature *jws = [[STDSJSONWebSignature alloc] initWithString:jwsString]; + + // ref. EMVCo_3DS_-AppBased_CryptoExamples_082018.pdf + NSString *certificateString = @"MIIDXTCCAkWgAwIBAgIQbS4C4BSig7uuJ5uDpeT4VjANBgkqhkiG9w0BAQsFADBH" + "MRMwEQYKCZImiZPyLGQBGRYDY29tMRcwFQYKCZImiZPyLGQBGRYHZXhhbXBsZTEX" + "MBUGA1UEAwwOUlNBIEV4YW1wbGUgRFMwHhcNMTcxMTIxMTE0ODQ5WhcNMjcxMjMx" + "MTQwMDAwWjBHMRMwEQYKCZImiZPyLGQBGRYDY29tMRcwFQYKCZImiZPyLGQBGRYH" + "ZXhhbXBsZTEXMBUGA1UEAwwOUlNBIEV4YW1wbGUgRFMwggEiMA0GCSqGSIb3DQEB" + "AQUAA4IBDwAwggEKAoIBAQCfgQ+0A4Jz0CWR5Ac/MdK2ABuCzttNkvBQFl1Hz8q4" + "o8Qct3isdVN5P475dXaNGiN02HElZMO813uepDRUSJlAfP8AmZIKkxokxEFIUqsp" + "vbCpXAZT82xg5gv5C2JY3aVvNwR7pcLR0CmvnJ1AuseqQceKDdEGit1pnoCP6gEe" + "oUQdik97tOl7459V8d3UTpxLozUVlwPU00tgPmUUek8j1tPAmWx17e6EaoLRkK4Q" + "eDyWHPA4eu0hBtLQVVtv2Tf61VNTh+D/cv++eJQUArC4IuoqdLYFjB2r+bNKdstj" + "uH+qLGhHuOKDf/+RGG5rHBSRHPmJqJCSqBzmAd2s0/nPAgMBAAGjRTBDMBIGA1Ud" + "EwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTDgwKdvAPq" + "bbCmehDaw0PwavI83jANBgkqhkiG9w0BAQsFAAOCAQEAOUcKqpzNQ6lr0PbDSsns" + "D6onfi+8j3TD0xG0zBSf+8G4zs8Zb6vzzQ5qHKgfr4aeen8Pw0cw2KKUJ2dFaBqj" + "n3/6/MIZbgaBvXKUbmY8xCxKQ+tOFc3KWIu4pSaO50tMPJjU/lP35bv19AA9vs9M" + "TKY2qLf88bmoNYT3W8VSDcB58KBHa7HVIPx7BUUtSyb2N2Jqx5AOiYy4NarhB3hV" + "ftkZBmCzi2Qw50KWIgTFYcIVeRTx3Js/F0IuEdgZHBK2gmO7fdM7+QKYm83401vl" + "YRNCXfIZ0H9E1V3NddqJuqIutdUajckSzMhXdNCJqfI4FAQAymTWGL3/lZyr/30x" + "Fg=="; + + XCTAssertFalse([STDSDirectoryServerCertificate verifyJSONWebSignature:jws withRootCertificates:@[certificateString]], @"Incorrectly validated PS256 signature."); +} + +- (void)testVerifyES256Signature { + // ref. EMVCo_3DS_-AppBased_CryptoExamples_082018.pdf + NSString *jwsString = @"eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlDclRDQ0FaV2dBd0lCQWdJUWJTNEM0QlNp" + "Zzd1dUo1dURwZVQ0V1RBTkJna3Foa2lHOXcwQkFRc0ZBREJITVJNd0VRWUtDWkltaVpQ" + "eUxHUUJHUllEWTI5dE1SY3dGUVlLQ1pJbWlaUHlMR1FCR1JZSFpYaGhiWEJzWlRFWE1C" + "VUdBMVVFQXd3T1VsTkJJRVY0WVcxd2JHVWdSRk13SGhjTk1UY3hNVEl4TVRVME16STNX" + "aGNOTWpjeE1qTXhNVE16TURBd1dqQkhNUk13RVFZS0NaSW1pWlB5TEdRQkdSWURZMjl0" + "TVJjd0ZRWUtDWkltaVpQeUxHUUJHUllIWlhoaGJYQnNaVEVYTUJVR0ExVUVBd3dPUlVN" + "Z1JYaGhiWEJzWlNCQlExTXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak9QUU1CQndOQ0FB" + "VGZvZml3YzZBaXUxWWc1dkc5ZUtYSGVEQ1ZoOWgzVk1xTjIveUoxQ1dHVWlwOEJqOHEr" + "ZXJPbzc0dHQ2akVUTXpBYVRwekxaNW9HRFpjZVpmR21NN2RvMkF3WGpBTUJnTlZIUk1C" + "QWY4RUFqQUFNQTRHQTFVZER3RUIvd1FFQXdJSGdEQWRCZ05WSFE0RUZnUVUwQVd0REhS" + "L3ZsUXJSQXo0YUtnSkJsbkZqRXN3SHdZRFZSMGpCQmd3Rm9BVXc0TUNuYndENm0yd3Bu" + "b1Eyc05EOEdyeVBONHdEUVlKS29aSWh2Y05BUUVMQlFBRGdnRUJBRXFsRVJld1VDZUV0" + "dEFrQzBGMTZIamp4ZnYxV2E4bmFEbWFSTDk5UTAvcXFVTjh3MHF3cEFQRjd3bjJhZkxm" + "YUdkKzV1WkViMVROWXdWOUF3OUwvczNCY1NURVJJbDZPRVduK3g3Y3RPbUh5MnZ2N21p" + "dGFVcmlsZUdvZGVubS9mYURkeTVWZ0tZaitLc01WTTJzTlZhZWtYK1Qwc3dBQ1g5Qjkw" + "dW5aeGE2MjU2dDJPSjJRVjV6dTNzWU8xTjBqOXY3K3lGK0ZneDAxNE5ydzcvWHQ4SUxH" + "RjU4TnhiUWhraGtmV1NmSHRhRTVtb0JBYldSdUZURmJrQmY0NVNLZTBVTWlVNUxhYzl4" + "STBPN1hDRCt6TkI1bXdzNE5PMkFZdnl4SHE5WCthNjRJaFhjbFhuZ1BRTXJVcU1vTFdJ" + "MTY2Z1JKU3ZRRVdzSUxJVXR4MndzaVlzPSJdfQ" + "." + "eyJBQ1MgRXBoZW1lcmFsIFB1YmxpYyBLZXkgKFFUKSI6eyJrdHkiOiJFQyIsImNydiI6" + "IlAtMjU2IiwieCI6Im1QVUtUX2JBV0dISWhnMFRwampxVnNQMXJYV1F1X3Z3Vk9ISHRO" + "a2RZb0EiLCJ5IjoiOEJRQXNJbUdlQVM0NmZ5V3c1TWhmR1RUMElqQnBGdzJTUzM0RHY0" + "SXJzIix9LCJTREsgRXBoZW1lcmFsIFB1YmxpYyBLZXkgKFFDKSI6eyJrdHkiOiJFQyIs" + "ImNydiI6IlAtMjU2IiwieCI6IlplMmxvU1Yzd3Jyb0tVTl80emh3R2hDcW8zWGh1MXRk" + "NFFqZVE1d0lWUjAiLCJ5IjoiSGxMdGRYQVJZX2Y1NUEzZm56UWJQY202aGdyMzRNcDhw" + "LW51elFDRTBadyIsfSwiQUNTIFVSTCI6Imh0dHA6Ly9hY3NzZXJ2ZXIuZG9tYWlubmFt" + "ZS5jb20ifQ" + "." + "KMNtxy3eZMDnVRK_UZvFtRY8fDuAAHJXHCaKncoViB3dy56u5VOT0XnB8K-" + "0nWwFhwWmwU1xGVwimBcfxNplCA"; + STDSJSONWebSignature *jws = [[STDSJSONWebSignature alloc] initWithString:jwsString]; + + // ref. EMVCo_3DS_-AppBased_CryptoExamples_082018.pdf + NSString *certificateString = @"MIIDXTCCAkWgAwIBAgIQbS4C4BSig7uuJ5uDpeT4VjANBgkqhkiG9w0BAQsFADBH" + "MRMwEQYKCZImiZPyLGQBGRYDY29tMRcwFQYKCZImiZPyLGQBGRYHZXhhbXBsZTEX" + "MBUGA1UEAwwOUlNBIEV4YW1wbGUgRFMwHhcNMTcxMTIxMTE0ODQ5WhcNMjcxMjMx" + "MTQwMDAwWjBHMRMwEQYKCZImiZPyLGQBGRYDY29tMRcwFQYKCZImiZPyLGQBGRYH" + "ZXhhbXBsZTEXMBUGA1UEAwwOUlNBIEV4YW1wbGUgRFMwggEiMA0GCSqGSIb3DQEB" + "AQUAA4IBDwAwggEKAoIBAQCfgQ+0A4Jz0CWR5Ac/MdK2ABuCzttNkvBQFl1Hz8q4" + "o8Qct3isdVN5P475dXaNGiN02HElZMO813uepDRUSJlAfP8AmZIKkxokxEFIUqsp" + "vbCpXAZT82xg5gv5C2JY3aVvNwR7pcLR0CmvnJ1AuseqQceKDdEGit1pnoCP6gEe" + "oUQdik97tOl7459V8d3UTpxLozUVlwPU00tgPmUUek8j1tPAmWx17e6EaoLRkK4Q" + "eDyWHPA4eu0hBtLQVVtv2Tf61VNTh+D/cv++eJQUArC4IuoqdLYFjB2r+bNKdstj" + "uH+qLGhHuOKDf/+RGG5rHBSRHPmJqJCSqBzmAd2s0/nPAgMBAAGjRTBDMBIGA1Ud" + "EwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTDgwKdvAPq" + "bbCmehDaw0PwavI83jANBgkqhkiG9w0BAQsFAAOCAQEAOUcKqpzNQ6lr0PbDSsns" + "D6onfi+8j3TD0xG0zBSf+8G4zs8Zb6vzzQ5qHKgfr4aeen8Pw0cw2KKUJ2dFaBqj" + "n3/6/MIZbgaBvXKUbmY8xCxKQ+tOFc3KWIu4pSaO50tMPJjU/lP35bv19AA9vs9M" + "TKY2qLf88bmoNYT3W8VSDcB58KBHa7HVIPx7BUUtSyb2N2Jqx5AOiYy4NarhB3hV" + "ftkZBmCzi2Qw50KWIgTFYcIVeRTx3Js/F0IuEdgZHBK2gmO7fdM7+QKYm83401vl" + "YRNCXfIZ0H9E1V3NddqJuqIutdUajckSzMhXdNCJqfI4FAQAymTWGL3/lZyr/30x" + "Fg=="; + + XCTAssertTrue([STDSDirectoryServerCertificate verifyJSONWebSignature:jws withRootCertificates:@[certificateString]], @"Failed to verify valid ES256 signature."); +} + +- (void)testVerifyES256SignatureFail { + // ref. EMVCo_3DS_-AppBased_CryptoExamples_082018.pdf + NSString *jwsString = @"eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlDclRDQ0FaV2dBd0lCQWdJUWJTNEM0QlNp" + "Zzd1dUo1dURwZVQ0V1RBTkJna3Foa2lHOXcwQkFRc0ZBREJITVJNd0VRWUtDWkltaVpQ" + "eUxHUUJHUllEWTI5dE1SY3dGUVlLQ1pJbWlaUHlMR1FCR1JZSFpYaGhiWEJzWlRFWE1C" + "VUdBMVVFQXd3T1VsTkJJRVY0WVcxd2JHVWdSRk13SGhjTk1UY3hNVEl4TVRVME16STNX" + "aGNOTWpjeE1qTXhNVE16TURBd1dqQkhNUk13RVFZS0NaSW1pWlB5TEdRQkdSWURZMjl0" + "TVJjd0ZRWUtDWkltaVpQeUxHUUJHUllIWlhoaGJYQnNaVEVYTUJVR0ExVUVBd3dPUlVN" + "Z1JYaGhiWEJzWlNCQlExTXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak9QUU1CQndOQ0FB" + "VGZvZml3YzZBaXUxWWc1dkc5ZUtYSGVEQ1ZoOWgzVk1xTjIveUoxQ1dHVWlwOEJqOHEr" + "ZXJPbzc0dHQ2akVUTXpBYVRwekxaNW9HRFpjZVpmR21NN2RvMkF3WGpBTUJnTlZIUk1C" + "QWY4RUFqQUFNQTRHQTFVZER3RUIvd1FFQXdJSGdEQWRCZ05WSFE0RUZnUVUwQVd0REhS" + "L3ZsUXJSQXo0YUtnSkJsbkZqRXN3SHdZRFZSMGpCQmd3Rm9BVXc0TUNuYndENm0yd3Bu" + "b1Eyc05EOEdyeVBONHdEUVlKS29aSWh2Y05BUUVMQlFBRGdnRUJBRXFsRVJld1VDZUV0" + "dEFrQzBGMTZIamp4ZnYxV2E4bmFEbWFSTDk5UTAvcXFVTjh3MHF3cEFQRjd3bjJhZkxm" + "YUdkKzV1WkViMVROWXdWOUF3OUwvczNCY1NURVJJbDZPRVduK3g3Y3RPbUh5MnZ2N21p" + "dGFVcmlsZUdvZGVubS9mYURkeTVWZ0tZaitLc01WTTJzTlZhZWtYK1Qwc3dBQ1g5Qjkw" + "dW5aeGE2MjU2dDJPSjJRVjV6dTNzWU8xTjBqOXY3K3lGK0ZneDAxNE5ydzcvWHQ4SUxH" + "RjU4TnhiUWhraGtmV1NmSHRhRTVtb0JBYldSdUZURmJrQmY0NVNLZTBVTWlVNUxhYzl4" + "STBPN1hDRCt6TkI1bXdzNE5PMkFZdnl4SHE5WCthNjRJaFhjbFhuZ1BRTXJVcU1vTFdJ" + "MTY2Z1JKU3ZRRVdzSUxJVXR4MndzaVlzPSJdfQ" + "." + "eyJBQ1MgRXBoZW1lcmFsIFB1YmxpYyBLZXkgKFFUKSI6eyJrdHkiOiJFQyIsImNydiI6" + "IlAtMjU2IiwieCI6Im1QVUtUX2JBV0dISWhnMFRwampxVnNQMXJYV1F1X3Z3Vk9ISHRO" + "a2RZb0EiLCJ5IjoiOEJRQXNJbUdlQVM0NmZ5V3c1TWhmR1RUMElqQnBGdzJTUzM0RHY0" + "SXJzIix9LCJTREsgRXBoZW1lcmFsIFB1YmxpYyBLZXkgKFFDKSI6eyJrdHkiOiJFQyIs" + "ImNydiI6IlAtMjU2IiwieCI6IlplMmxvU1Yzd3Jyb0tVTl80emh3R2hDcW8zWGh1MXRk" + "NFFqZVE1d0lWUjAiLCJ5IjoiSGxMdGRYQVJZX2Y1NUEzZm56UWJQY202aGdyMzRNcDhw" + "LW51elFDRTBadyIsfSwiQUNTIFVSTCI6Imh0dHA6Ly9hY3NzZXJ2ZXIuZG9tYWlubMFt" // This is all the same as testVerifyPS256Signature, except this line where "bmFt" became "bMFt" + "ZS5jb20ifQ" + "." + "KMNtxy3eZMDnVRK_UZvFtRY8fDuAAHJXHCaKncoViB3dy56u5VOT0XnB8K-" + "0nWwFhwWmwU1xGVwimBcfxNplCA"; + STDSJSONWebSignature *jws = [[STDSJSONWebSignature alloc] initWithString:jwsString]; + + // ref. EMVCo_3DS_-AppBased_CryptoExamples_082018.pdf + NSString *certificateString = @"MIIDXTCCAkWgAwIBAgIQbS4C4BSig7uuJ5uDpeT4VjANBgkqhkiG9w0BAQsFADBH" + "MRMwEQYKCZImiZPyLGQBGRYDY29tMRcwFQYKCZImiZPyLGQBGRYHZXhhbXBsZTEX" + "MBUGA1UEAwwOUlNBIEV4YW1wbGUgRFMwHhcNMTcxMTIxMTE0ODQ5WhcNMjcxMjMx" + "MTQwMDAwWjBHMRMwEQYKCZImiZPyLGQBGRYDY29tMRcwFQYKCZImiZPyLGQBGRYH" + "ZXhhbXBsZTEXMBUGA1UEAwwOUlNBIEV4YW1wbGUgRFMwggEiMA0GCSqGSIb3DQEB" + "AQUAA4IBDwAwggEKAoIBAQCfgQ+0A4Jz0CWR5Ac/MdK2ABuCzttNkvBQFl1Hz8q4" + "o8Qct3isdVN5P475dXaNGiN02HElZMO813uepDRUSJlAfP8AmZIKkxokxEFIUqsp" + "vbCpXAZT82xg5gv5C2JY3aVvNwR7pcLR0CmvnJ1AuseqQceKDdEGit1pnoCP6gEe" + "oUQdik97tOl7459V8d3UTpxLozUVlwPU00tgPmUUek8j1tPAmWx17e6EaoLRkK4Q" + "eDyWHPA4eu0hBtLQVVtv2Tf61VNTh+D/cv++eJQUArC4IuoqdLYFjB2r+bNKdstj" + "uH+qLGhHuOKDf/+RGG5rHBSRHPmJqJCSqBzmAd2s0/nPAgMBAAGjRTBDMBIGA1Ud" + "EwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTDgwKdvAPq" + "bbCmehDaw0PwavI83jANBgkqhkiG9w0BAQsFAAOCAQEAOUcKqpzNQ6lr0PbDSsns" + "D6onfi+8j3TD0xG0zBSf+8G4zs8Zb6vzzQ5qHKgfr4aeen8Pw0cw2KKUJ2dFaBqj" + "n3/6/MIZbgaBvXKUbmY8xCxKQ+tOFc3KWIu4pSaO50tMPJjU/lP35bv19AA9vs9M" + "TKY2qLf88bmoNYT3W8VSDcB58KBHa7HVIPx7BUUtSyb2N2Jqx5AOiYy4NarhB3hV" + "ftkZBmCzi2Qw50KWIgTFYcIVeRTx3Js/F0IuEdgZHBK2gmO7fdM7+QKYm83401vl" + "YRNCXfIZ0H9E1V3NddqJuqIutdUajckSzMhXdNCJqfI4FAQAymTWGL3/lZyr/30x" + "Fg=="; + + XCTAssertFalse([STDSDirectoryServerCertificate verifyJSONWebSignature:jws withRootCertificates:@[certificateString]], @"Verified invalid ES256 signature."); +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSEllipticCurvePointTests.m b/Stripe3DS2/Stripe3DS2Tests/STDSEllipticCurvePointTests.m new file mode 100644 index 00000000..a315dcd8 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSEllipticCurvePointTests.m @@ -0,0 +1,42 @@ +// +// STDSEllipticCurvePointTests.m +// Stripe3DS2Tests +// +// Created by Cameron Sabol on 4/5/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "NSString+JWEHelpers.h" +#import "STDSEllipticCurvePoint.h" + +@interface STDSEllipticCurvePointTests : XCTestCase + +@end + +@implementation STDSEllipticCurvePointTests + +- (void)testInitWithJWK { + + STDSEllipticCurvePoint *ecPoint = [[STDSEllipticCurvePoint alloc] initWithJWK:@{ // ref. EMVCo_3DS_-AppBased_CryptoExamples_082018.pdf + @"kty":@"EC", + @"crv":@"P-256", + @"x":@"mPUKT_bAWGHIhg0TpjjqVsP1rXWQu_vwVOHHtNkdYoA", + @"y":@"8BQAsImGeAS46fyWw5MhYfGTT0IjBpFw2SS34Dv4Irs", + }]; + + XCTAssertNotNil(ecPoint, @"Failed to create point with valid jwk"); + XCTAssertEqualObjects(ecPoint.x, [@"mPUKT_bAWGHIhg0TpjjqVsP1rXWQu_vwVOHHtNkdYoA" _stds_base64URLDecodedData], @"Parsed incorrect x-coordinate"); + XCTAssertEqualObjects(ecPoint.y, [@"8BQAsImGeAS46fyWw5MhYfGTT0IjBpFw2SS34Dv4Irs" _stds_base64URLDecodedData], @"Parsed incorrect y-coordinate"); + + ecPoint = [[STDSEllipticCurvePoint alloc] initWithJWK:@{ + @"kty":@"EC", + @"crv":@"P-128", + @"x":@"mPUKT_bAWGHIhg0TpjjqVsP1rXWQu_vwVOHHtNkdYoA", + @"y":@"8BQAsImGeAS46fyWw5MhYfGTT0IjBpFw2SS34Dv4Irs", + }]; + XCTAssertNil(ecPoint, @"Shoud return nil for non P-256 curve."); +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSEphemeralKeyPairTests.m b/Stripe3DS2/Stripe3DS2Tests/STDSEphemeralKeyPairTests.m new file mode 100644 index 00000000..5d8cb194 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSEphemeralKeyPairTests.m @@ -0,0 +1,41 @@ +// +// STDSEphemeralKeyPairTests.m +// Stripe3DS2Tests +// +// Created by Cameron Sabol on 3/26/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSDirectoryServerCertificate.h" +#import "STDSEllipticCurvePoint.h" +#import "STDSEphemeralKeyPair+Testing.h" +#import "STDSSecTypeUtilities.h" + +@interface STDSEphemeralKeyPairTests : XCTestCase + +@end + +@implementation STDSEphemeralKeyPairTests + +- (void)testCreateEpehemeralKeyPair { + STDSEphemeralKeyPair *keyPair = [STDSEphemeralKeyPair ephemeralKeyPair]; + XCTAssertNotNil(keyPair.publicKeyJWK, @"Failed to create a valid public key JWK"); + STDSEphemeralKeyPair *keyPair2 = [STDSEphemeralKeyPair ephemeralKeyPair]; + XCTAssertNotEqual(keyPair.publicKeyJWK, keyPair2.publicKeyJWK, @"Failed sanity check that two different ephemeral key pairs don't have the same public key JWK."); +} + +- (void)testDiffieHellmanSharedSecret { + // values from EMVCo_3DS_-AppBased_CryptoExamples_082018.pdf + NSDictionary *jwk = [NSJSONSerialization JSONObjectWithData:[@"{\"kty\":\"EC\",\"crv\":\"P-256\",\"kid\":\"UUIDkeyidentifierforDS-EC\", \"x\":\"2_v-MuNZccqwM7PXlakW9oHLP5XyrjMG1UVS8OxYrgA\", \"y\":\"rm1ktLmFIsP2R0YyJGXtsCbaTUesUK31Xc04tHJRolc\"}" dataUsingEncoding:NSUTF8StringEncoding] options:0 error:NULL]; + + STDSEllipticCurvePoint *publicKey = [[STDSEllipticCurvePoint alloc] initWithJWK:jwk]; + STDSEphemeralKeyPair *keyPair = [STDSEphemeralKeyPair testKeyPair]; + NSData *secret = [keyPair createSharedSecretWithEllipticCurveKey:publicKey]; + const unsigned char expectedSecretBytes[] = {0x5C, 0x32, 0xBC, 0x13, 0xF8, 0xEC, 0xEB, 0x14, 0x8A, 0xBA, 0xF2, 0xA6, 0xB9, 0xDD, 0x1F, 0x68, 0x91, 0xBB, 0x2A, 0x80, 0xAB, 0x09, 0x34, 0x7C, 0x64, 0x06, 0x82, 0x31, 0xA5, 0x9E, 0x8C, 0xA2}; + NSData *expectedSecret = [[NSData alloc] initWithBytes:expectedSecretBytes length:32]; + XCTAssertEqualObjects(secret, expectedSecret, @"Generated incorrect shared secret value"); +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSErrorMessageTest.m b/Stripe3DS2/Stripe3DS2Tests/STDSErrorMessageTest.m new file mode 100644 index 00000000..b55af754 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSErrorMessageTest.m @@ -0,0 +1,61 @@ +// +// STDSErrorMessageTest.m +// Stripe3DS2Tests +// +// Created by Yuki Tokuhiro on 3/29/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSErrorMessage.h" +#import "STDSJSONEncoder.h" +#import "STDSTestJSONUtils.h" + +@interface STDSErrorMessageTest : XCTestCase + +@end + +@implementation STDSErrorMessageTest + +#pragma mark - STDSJSONDecodable + +- (void)testSuccessfulDecode { + NSDictionary *json = [STDSTestJSONUtils jsonNamed:@"ErrorMessage"]; + NSError *error; + STDSErrorMessage *errorMessage = [STDSErrorMessage decodedObjectFromJSON:json error:&error]; + + XCTAssertNil(error); + XCTAssertNotNil(errorMessage); + XCTAssertEqualObjects(errorMessage.errorCode, @"203"); + XCTAssertEqualObjects(errorMessage.errorComponent, @"A"); + XCTAssertEqualObjects(errorMessage.errorDescription, @"Data element not in the required format. Not numeric or wrong length."); + XCTAssertEqualObjects(errorMessage.errorDetails, @"billAddrCountry,billAddrPostCode,dsURL"); + XCTAssertEqualObjects(errorMessage.errorMessageType, @"AReq"); + XCTAssertEqualObjects(errorMessage.messageVersion, @"2.1.0"); + +} + +#pragma mark - STDSJSONEncodable + +- (void)testPropertyNamesToJSONKeysMapping { + STDSErrorMessage *params = [STDSErrorMessage new]; + + NSDictionary *mapping = [STDSErrorMessage propertyNamesToJSONKeysMapping]; + + for (NSString *propertyName in [mapping allKeys]) { + XCTAssertFalse([propertyName containsString:@":"]); + XCTAssert([params respondsToSelector:NSSelectorFromString(propertyName)]); + } + + for (NSString *formFieldName in [mapping allValues]) { + XCTAssert([formFieldName isKindOfClass:[NSString class]]); + XCTAssert([formFieldName length] > 0); + } + + XCTAssertEqual([[mapping allValues] count], [[NSSet setWithArray:[mapping allValues]] count]); + XCTAssertTrue([NSJSONSerialization isValidJSONObject:[STDSJSONEncoder dictionaryForObject:params]]); +} + + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSJSONEncoderTest.m b/Stripe3DS2/Stripe3DS2Tests/STDSJSONEncoderTest.m new file mode 100644 index 00000000..a42c70d9 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSJSONEncoderTest.m @@ -0,0 +1,201 @@ +// +// STDSJSONEncoderTest.m +// Stripe3DS2Tests +// +// Created by Yuki Tokuhiro on 3/25/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSJSONEncoder.h" + +#pragma mark - STDSJSONEncodableObject + +@interface STDSJSONEncodableObject : NSObject +@property (nonatomic, copy) NSString *testProperty; +@property (nonatomic, copy) NSArray *testArrayProperty; +@property (nonatomic, copy) NSDictionary *testDictionaryProperty; +@property (nonatomic) STDSJSONEncodableObject *testNestedObjectProperty; +@end + +@implementation STDSJSONEncodableObject + ++ (NSDictionary *)propertyNamesToJSONKeysMapping { + return @{ + NSStringFromSelector(@selector(testProperty)): @"test_property", + NSStringFromSelector(@selector(testArrayProperty)): @"test_array_property", + NSStringFromSelector(@selector(testDictionaryProperty)): @"test_dictionary_property", + NSStringFromSelector(@selector(testNestedObjectProperty)): @"test_nested_property", + }; +} + +@end + +#pragma mark - STDSJSONEncoderTest + +@interface STDSJSONEncoderTest : XCTestCase +@end + +@implementation STDSJSONEncoderTest + +- (void)testEmptyEncodableObject { + STDSJSONEncodableObject *object = [STDSJSONEncodableObject new]; + XCTAssertEqualObjects([STDSJSONEncoder dictionaryForObject:object], @{}); +} + +- (void)testNestedObject { + STDSJSONEncodableObject *object = [STDSJSONEncodableObject new]; + STDSJSONEncodableObject *nestedObject = [STDSJSONEncodableObject new]; + nestedObject.testProperty = @"nested_object_property"; + object.testProperty = @"object_property"; + object.testNestedObjectProperty = nestedObject; + NSDictionary *jsonDictionary = [STDSJSONEncoder dictionaryForObject:object]; + NSDictionary *expected = @{ + @"test_property": @"object_property", + @"test_nested_property": @{ + @"test_property": @"nested_object_property", + } + }; + XCTAssertEqualObjects(jsonDictionary, expected); + XCTAssertTrue([NSJSONSerialization isValidJSONObject:jsonDictionary]); +} + +- (void)testSerializeDeserialize { + STDSJSONEncodableObject *object = [STDSJSONEncodableObject new]; + object.testProperty = @"test"; + NSDictionary *expected = @{@"test_property": @"test"}; + NSData *data = [NSJSONSerialization dataWithJSONObject:[STDSJSONEncoder dictionaryForObject:object] options:0 error:nil]; + id jsonObject = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + XCTAssertEqualObjects(expected, jsonObject); +} + +- (void)testBoolAndNumbers { + STDSJSONEncodableObject *testObject = [STDSJSONEncodableObject new]; + testObject.testArrayProperty = @[@0, + @1, + [NSNumber numberWithBool:NO], + [[NSNumber alloc] initWithBool:YES], + @YES]; + NSDictionary *jsonDictionary = [STDSJSONEncoder dictionaryForObject:testObject]; + NSDictionary *expected = @{ + @"test_array_property": @[ + @0, + @1, + [NSNumber numberWithBool:NO], + [[NSNumber alloc] initWithBool:YES], + @YES], + }; + XCTAssertEqualObjects(jsonDictionary, expected); + XCTAssertTrue([NSJSONSerialization isValidJSONObject:jsonDictionary]); + +} + +#pragma mark NSArray + +- (void)testArrayValueEmpty { + STDSJSONEncodableObject *testObject = [STDSJSONEncodableObject new]; + testObject.testProperty = @"success"; + testObject.testArrayProperty = @[]; + NSDictionary *jsonDictionary = [STDSJSONEncoder dictionaryForObject:testObject]; + NSDictionary *expected = @{ + @"test_property": @"success", + @"test_array_property": @[] + }; + XCTAssertEqualObjects(jsonDictionary, expected); + XCTAssertTrue([NSJSONSerialization isValidJSONObject:jsonDictionary]); +} + +- (void)testArrayValue { + STDSJSONEncodableObject *testObject = [STDSJSONEncodableObject new]; + testObject.testProperty = @"success"; + testObject.testArrayProperty = @[@1, @2, @3]; + NSDictionary *jsonDictionary = [STDSJSONEncoder dictionaryForObject:testObject]; + NSDictionary *expected = @{ + @"test_property": @"success", + @"test_array_property": @[@1, @2, @3] + }; + XCTAssertEqualObjects(jsonDictionary, expected); + XCTAssertTrue([NSJSONSerialization isValidJSONObject:jsonDictionary]); + +} + +- (void)testArrayOfEncodable { + STDSJSONEncodableObject *testObject = [STDSJSONEncodableObject new]; + + STDSJSONEncodableObject *inner1 = [STDSJSONEncodableObject new]; + inner1.testProperty = @"inner1"; + STDSJSONEncodableObject *inner2 = [STDSJSONEncodableObject new]; + inner2.testArrayProperty = @[@"inner2"]; + + testObject.testArrayProperty = @[inner1, inner2]; + NSDictionary *jsonDictionary = [STDSJSONEncoder dictionaryForObject:testObject]; + NSDictionary *expected = @{ + @"test_array_property": @[ + @{ + @"test_property": @"inner1" + }, + @{ + @"test_array_property": @[@"inner2"] + } + ] + }; + XCTAssertEqualObjects(jsonDictionary, expected); + XCTAssertTrue([NSJSONSerialization isValidJSONObject:jsonDictionary]); +} + +#pragma mark NSDictionary + +- (void)testDictionaryValueEmpty { + STDSJSONEncodableObject *testObject = [STDSJSONEncodableObject new]; + testObject.testProperty = @"success"; + testObject.testDictionaryProperty = @{}; + NSDictionary *jsonDictionary = [STDSJSONEncoder dictionaryForObject:testObject]; + NSDictionary *expected = @{ + @"test_property": @"success", + @"test_dictionary_property": @{} + }; + XCTAssertEqualObjects(jsonDictionary, expected); + XCTAssertTrue([NSJSONSerialization isValidJSONObject:jsonDictionary]); +} + +- (void)testDictionaryValue { + STDSJSONEncodableObject *testObject = [STDSJSONEncodableObject new]; + testObject.testProperty = @"success"; + testObject.testDictionaryProperty = @{@"foo": @"bar"}; + NSDictionary *jsonDictionary = [STDSJSONEncoder dictionaryForObject:testObject]; + NSDictionary *expected = @{ + @"test_property": @"success", + @"test_dictionary_property": @{@"foo": @"bar"} + }; + XCTAssertEqualObjects(jsonDictionary, expected); + XCTAssertTrue([NSJSONSerialization isValidJSONObject:jsonDictionary]); + +} + +- (void)testDictionaryOfEncodable { + STDSJSONEncodableObject *testObject = [STDSJSONEncodableObject new]; + + STDSJSONEncodableObject *inner1 = [STDSJSONEncodableObject new]; + inner1.testProperty = @"inner1"; + STDSJSONEncodableObject *inner2 = [STDSJSONEncodableObject new]; + inner2.testArrayProperty = @[@"inner2"]; + + testObject.testDictionaryProperty = @{@"one": inner1, @"two": inner2}; + + NSDictionary *jsonDictionary = [STDSJSONEncoder dictionaryForObject:testObject]; + NSDictionary *expected = @{ + @"test_dictionary_property": @{ + @"one": @{ + @"test_property": @"inner1" + }, + @"two": @{ + @"test_array_property": @[@"inner2"] + } + } + }; + XCTAssertEqualObjects(jsonDictionary, expected); + XCTAssertTrue([NSJSONSerialization isValidJSONObject:jsonDictionary]); +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSJSONWebEncryptionTests.m b/Stripe3DS2/Stripe3DS2Tests/STDSJSONWebEncryptionTests.m new file mode 100644 index 00000000..9e7cf928 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSJSONWebEncryptionTests.m @@ -0,0 +1,119 @@ +// +// STDSJSONWebEncryptionTests.m +// Stripe3DS2Tests +// +// Created by Cameron Sabol on 1/29/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSStripe3DS2Error.h" +#import "NSString+JWEHelpers.h" +#import "STDSDirectoryServer.h" +#import "STDSDirectoryServerCertificate.h" +#import "STDSJSONWebEncryption.h" +#import "STDSSecTypeUtilities.h" + +@interface STDSJSONWebEncryptionTests : XCTestCase + +@end + +@implementation STDSJSONWebEncryptionTests + +- (void)testEncryption { + NSDictionary *json = @{@"a": @(0), @"b": @[@(0), @(1), @(2)], @"c": @{@"d": @"val"}}; + NSError *error = nil; + + NSString *encrypted = [STDSJSONWebEncryption encryptJSON:json forDirectoryServer:STDSDirectoryServerSTPTestRSA error:&error]; + XCTAssertNotNil(encrypted, @"Should successfully encrypt with ul_test cert."); + XCTAssertNil(error, @"Successful encryption should not populate error."); + + encrypted = [STDSJSONWebEncryption encryptJSON:json forDirectoryServer:STDSDirectoryServerSTPTestEC error:&error]; + XCTAssertNotNil(encrypted, @"Should successfully encrypt with ec_test cert."); + XCTAssertNil(error, @"Successful encryption should not populate error."); + + encrypted = [STDSJSONWebEncryption encryptJSON:json forDirectoryServer:STDSDirectoryServerULTestRSA error:&error]; + XCTAssertNotNil(encrypted, @"Should successfully encrypt with STDSDirectoryServerULTestRSA."); + XCTAssertNil(error, @"Successful encryption should not populate error."); + + encrypted = [STDSJSONWebEncryption encryptJSON:json forDirectoryServer:STDSDirectoryServerULTestEC error:&error]; + XCTAssertNotNil(encrypted, @"Should successfully encrypt with STDSDirectoryServerULTestEC."); + XCTAssertNil(error, @"Successful encryption should not populate error."); + + encrypted = [STDSJSONWebEncryption encryptJSON:json forDirectoryServer:STDSDirectoryServerUnknown error:&error]; + XCTAssertNil(encrypted, @"Invalid server ID should return nil."); + XCTAssertEqual(error.code, STDSErrorCodeDecryptionVerification, @"Failed encryption should provide a STDSErrorCodeDecryptionVerification error."); +} + +- (void)testEncryptionWithCustomCertificate { + NSDictionary *json = @{@"a": @(0), @"b": @[@(0), @(1), @(2)], @"c": @{@"d": @"val"}}; + NSError *error = nil; + // Using ds-amex.pm.PEM + NSString *certificateString = @"MIIE0TCCA7mgAwIBAgIUXbeqM1duFcHk4dDBwT8o7Ln5wX8wDQYJKoZIhvcNAQEL" + "BQAwXjELMAkGA1UEBhMCVVMxITAfBgNVBAoTGEFtZXJpY2FuIEV4cHJlc3MgQ29t" + "cGFueTEsMCoGA1UEAxMjQW1lcmljYW4gRXhwcmVzcyBTYWZla2V5IElzc3Vpbmcg" + "Q0EwHhcNMTgwMjIxMjM0OTMxWhcNMjAwMjIxMjM0OTMwWjCB0DELMAkGA1UEBhMC" + "VVMxETAPBgNVBAgTCE5ldyBZb3JrMREwDwYDVQQHEwhOZXcgWW9yazE/MD0GA1UE" + "ChM2QW1lcmljYW4gRXhwcmVzcyBUcmF2ZWwgUmVsYXRlZCBTZXJ2aWNlcyBDb21w" + "YW55LCBJbmMuMTkwNwYDVQQLEzBHbG9iYWwgTmV0d29yayBUZWNobm9sb2d5IC0g" + "TmV0d29yayBBUEkgUGxhdGZvcm0xHzAdBgNVBAMTFlNESy5TYWZlS2V5LkVuY3J5" + "cHRLZXkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDSFF9kTYbwRrxX" + "C6WcJJYio5TZDM62+CnjQRfggV3GMI+xIDtMIN8LL/jbWBTycu97vrNjNNv+UPhI" + "WzhFDdUqyRfrY337A39uE8k1xhdDI3dNeZz6xgq8r9hn2NBou78YPBKidpN5oiHn" + "TxcFq1zudut2fmaldaa9a4ZKgIQo+02heiJfJ8XNWkoWJ17GcjJ59UU8C1KF/y1G" + "ymYO5ha2QRsVZYI17+ZFsqnpcXwK4Mr6RQKV6UimmO0nr5++CgvXfekcWAlLV6Xq" + "juACWi3kw0haepaX/9qHRu1OSyjzWNcSVZ0On6plB5Lq6Y9ylgmxDrv+zltz3MrT" + "K7txIAFFAgMBAAGjggESMIIBDjAMBgNVHRMBAf8EAjAAMCEGA1UdEQQaMBiCFlNE" + "Sy5TYWZlS2V5LkVuY3J5cHRLZXkwRQYJKwYBBAGCNxQCBDgeNgBBAE0ARQBYAF8A" + "UwBBAEYARQBLAEUAWQAyAF8ARABTAF8ARQBOAEMAUgBZAFAAVABJAE8ATjAOBgNV" + "HQ8BAf8EBAMCBJAwHwYDVR0jBBgwFoAU7k/rXuVMhTBxB1zSftPgmLFuDIgwRAYD" + "VR0fBD0wOzA5oDegNYYzaHR0cDovL2FtZXhzay5jcmwuY29tLXN0cm9uZy1pZC5u" + "ZXQvYW1leHNhZmVrZXkuY3JsMB0GA1UdDgQWBBQHclVTo5nwZGH8labJ2F2P45xi" + "fDANBgkqhkiG9w0BAQsFAAOCAQEAWY6b77VBoGLs3k5vOqSU7QRqT+4v6y77T8LA" + "BKrSZ58DiVZWVyDSxyftQUiRRgFHt2gTN0yfJTP50Fyp84nCEWC0tugZ4iIhgPss" + "HzL+4/u4eG/MTzK2ESxvPgr6YHajyuU+GXA89u8+bsFrFmojOjhTgFKli7YUeV/0" + "xoiYZf2utlns800ofJrcrfiFoqE6PvK4Od0jpeMgfSKv71nK5ihA1+wTk76ge1fs" + "PxL23hEdRpWW11ofaLfJGkLFXMM3/LHSXWy7HhsBgDELdzLSHU4VkSv8yTOZxsRO" + "ByxdC5v3tXGcK56iQdtKVPhFGOOEBugw7AcuRzv3f1GhvzAQZg=="; + STDSDirectoryServerCertificate *certificate = [STDSDirectoryServerCertificate customCertificateWithData:[[NSData alloc] initWithBase64EncodedString:certificateString options:0]]; + + NSString *encrypted = [STDSJSONWebEncryption encryptJSON:json + withCertificate:certificate + directoryServerID:@"custom_test" + serverKeyID:nil + error:&error]; + XCTAssertNotNil(encrypted, @"Should successfully encrypt with custom cert."); + XCTAssertNil(error, @"Successful encryption should not populate error."); +} + +- (void)testDirectEncryptionWithKey { + NSDictionary *json = @{@"a": @(0), @"b": @[@(0), @(1), @(2)], @"c": @{@"d": @"val"}}; + NSError *error = nil; + NSData *contentEncryptionKey = STDSCryptoRandomData(32); + NSString *encrypted = [STDSJSONWebEncryption directEncryptJSON:json + withContentEncryptionKey:contentEncryptionKey + forACSTransactionID:@"acs_id" + error:&error]; + XCTAssertNotNil(encrypted, @"Should successfully encrypt with direct encryption key."); + XCTAssertNil(error, @"Successful encryption should not populate error."); +} + +- (void)testDecryption { + NSDictionary *json = @{@"a": @(0), @"b": @[@(0), @(1), @(2)], @"c": @{@"d": @"val"}}; + NSError *error = nil; + NSData *contentEncryptionKey = STDSCryptoRandomData(32); + NSString *encrypted = [STDSJSONWebEncryption directEncryptJSON:json + withContentEncryptionKey:contentEncryptionKey + forACSTransactionID:@"acs_id" + error:&error]; + XCTAssertNotNil(encrypted, @"Should successfully encrypt with direct encryption key."); + XCTAssertNil(error, @"Successful encryption should not populate error."); + + NSDictionary *decrypted = [STDSJSONWebEncryption decryptData:[encrypted dataUsingEncoding:NSUTF8StringEncoding] + withContentEncryptionKey:contentEncryptionKey + error:&error]; + XCTAssertEqualObjects(decrypted, json, @"Decrypted is not equal to the encrypted json"); +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSJSONWebSignatureTests.m b/Stripe3DS2/Stripe3DS2Tests/STDSJSONWebSignatureTests.m new file mode 100644 index 00000000..c4587102 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSJSONWebSignatureTests.m @@ -0,0 +1,78 @@ +// +// STDSJSONWebSignatureTests.m +// Stripe3DS2Tests +// +// Created by Cameron Sabol on 4/2/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "NSString+JWEHelpers.h" +#import "STDSEllipticCurvePoint.h" +#import "STDSJSONWebSignature.h" + +@interface STDSJSONWebSignatureTests : XCTestCase + +@end + +@implementation STDSJSONWebSignatureTests + +- (void)testInitES256 { + + // generated a private ec key and certificate, plugged into jwt.io with default sample payload. + // This certificate will expire in 2030 but as this test doesn't cover certificate validity + // it shouldn't start failing + STDSJSONWebSignature *jws = [[STDSJSONWebSignature alloc] initWithString:@"eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsIng1YyI6WyJNSUh3TUlHV0Fna0ErdEM1LzJxV1RqRXdDZ1lJS29aSXpqMEVBd0l3QURBZUZ3MHlNVEF4TURReU1UQTBNamxhRncwek1UQXhNREl5TVRBME1qbGFNQUF3V1RBVEJnY3Foa2pPUFFJQkJnZ3Foa2pPUFFNQkJ3TkNBQVFSV3oram42NUJ0T012ZHlIS2N2akJlQlNEWkgycjFSVHdqbVlTaTlSL3pwQm51UTRFaU1uQ3FmTVBXaVpxQjRRZGJBZDBFN29INTBWcHVaMVAwODdHTUFvR0NDcUdTTTQ5QkFNQ0Ewa0FNRVlDSVFETTVRbHRDTFhEeEpvTG1EVXRqREgxZEJQVHBUVG1jS2pjOHlodVp1VHU2UUloQVBEU0cvN3plV09NdkhxNUpaWk8zd3JQeVBhTFlVNHBCcGpWTS95YzQ5MDciXX0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.71MhQ7FJavv1nQ7Boujfp7K0iBEYFGSGLZ3osnL9KAY9scF95Hf7ZMQ8I1JSgnGl227UY96is80MlbTijOOxsg"]; + + XCTAssertNotNil(jws, @"Failed to create jws object"); + + XCTAssertEqual(jws.algorithm, STDSJSONWebSignatureAlgorithmES256, @"Parsed incorrect algorithm"); + + NSData *digest = [@"eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsIng1YyI6WyJNSUh3TUlHV0Fna0ErdEM1LzJxV1RqRXdDZ1lJS29aSXpqMEVBd0l3QURBZUZ3MHlNVEF4TURReU1UQTBNamxhRncwek1UQXhNREl5TVRBME1qbGFNQUF3V1RBVEJnY3Foa2pPUFFJQkJnZ3Foa2pPUFFNQkJ3TkNBQVFSV3oram42NUJ0T012ZHlIS2N2akJlQlNEWkgycjFSVHdqbVlTaTlSL3pwQm51UTRFaU1uQ3FmTVBXaVpxQjRRZGJBZDBFN29INTBWcHVaMVAwODdHTUFvR0NDcUdTTTQ5QkFNQ0Ewa0FNRVlDSVFETTVRbHRDTFhEeEpvTG1EVXRqREgxZEJQVHBUVG1jS2pjOHlodVp1VHU2UUloQVBEU0cvN3plV09NdkhxNUpaWk8zd3JQeVBhTFlVNHBCcGpWTS95YzQ5MDciXX0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0" dataUsingEncoding:NSUTF8StringEncoding]; + XCTAssertEqualObjects(jws.digest, digest, @"Parsed payload incorrectly."); + NSData *signature = [@"71MhQ7FJavv1nQ7Boujfp7K0iBEYFGSGLZ3osnL9KAY9scF95Hf7ZMQ8I1JSgnGl227UY96is80MlbTijOOxsg" _stds_base64URLDecodedData]; + XCTAssertEqualObjects(jws.signature, signature, @"Parsed signature incorrectly."); + NSData *payload = [@"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0" _stds_base64URLDecodedData]; + XCTAssertEqualObjects(jws.payload, payload, @"Parsed payload incorrectly."); + + XCTAssertNotNil(jws.ellipticCurvePoint, @"Failed to parse elliptic curve point."); + XCTAssertNotNil(jws.certificateChain, @"Must have certificate chain."); + + const unsigned char keyBytes[] = {0x11, 0x5b, 0x3f, 0xa3, 0x9f, 0xae, 0x41, 0xb4, 0xe3, 0x2f, 0x77, 0x21, 0xca, 0x72, + 0xf8, 0xc1, 0x78, 0x14, 0x83, 0x64, 0x7d, 0xab, 0xd5, 0x14, 0xf0, 0x8e, 0x66, 0x12, 0x8b, + 0xd4, 0x7f, 0xce, 0x90, 0x67, 0xb9, 0x0e, 0x04, 0x88, 0xc9, 0xc2, 0xa9, 0xf3, 0x0f, 0x5a, + 0x26, 0x6a, 0x07, 0x84, 0x1d, 0x6c, 0x07, 0x74, 0x13, 0xba, 0x07, 0xe7, 0x45, 0x69, 0xb9, + 0x9d, 0x4f, 0xd3, 0xce, 0xc6}; + size_t keyLength = sizeof(keyBytes)/2; + NSData *coordinateX = [NSData dataWithBytes:keyBytes length:keyLength]; + NSData *coordinateY = [NSData dataWithBytes:keyBytes + keyLength length:keyLength]; + + XCTAssertEqualObjects(jws.ellipticCurvePoint.x, coordinateX, @"Incorrect x-point."); + XCTAssertEqualObjects(jws.ellipticCurvePoint.y, coordinateY, @"Incorrect y-point."); +} + +- (void)testInitPS256 { + + // test jws strings generated from jwt.io + STDSJSONWebSignature *jws = [[STDSJSONWebSignature alloc] initWithString:@"eyJhbGciOiJQUzI1NiIsIng1YyI6WyJNSUkiLCJNSUkyIl19.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.CDB63_lCSBlmrIfZBvn6w4rDKJgkmKhFe4mfR-xUnfxf9N0g4vZa0R9lFG5pjVkThq9CX-p-vM_64wG4bAC53VlXOk6DhjzN0LTCo1nB81rd8DgqMH4SkLFy3wP-Xe0akRmXE8iHmv63ip7d2LGQVCD38xwXOnoBUVANCrcsC0Iur1TTEXaEfT6ACwg3V1YTu-vygNdbhYZOC_Q9ESbaoPxOQfumXnD44m1EN_FV3d-uQJx1Rn6w3AkDw34P3KunLrwOMJt1mbkWzb66VDVsIxegc4N8TjJTzvxmCk841wUae3kZ97_HPIEfil3ewv80hZstEE2hcEXJbdBfsxsSqg"]; + + XCTAssertNotNil(jws, @"Failed to create jws object"); + + XCTAssertEqual(jws.algorithm, STDSJSONWebSignatureAlgorithmPS256, @"Parsed incorrect algorithm"); + + NSData *digest = [@"eyJhbGciOiJQUzI1NiIsIng1YyI6WyJNSUkiLCJNSUkyIl19.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0" dataUsingEncoding:NSUTF8StringEncoding]; + XCTAssertEqualObjects(jws.digest, digest, @"Parsed payload incorrectly."); + NSData *signature = [@"CDB63_lCSBlmrIfZBvn6w4rDKJgkmKhFe4mfR-xUnfxf9N0g4vZa0R9lFG5pjVkThq9CX-p-vM_64wG4bAC53VlXOk6DhjzN0LTCo1nB81rd8DgqMH4SkLFy3wP-Xe0akRmXE8iHmv63ip7d2LGQVCD38xwXOnoBUVANCrcsC0Iur1TTEXaEfT6ACwg3V1YTu-vygNdbhYZOC_Q9ESbaoPxOQfumXnD44m1EN_FV3d-uQJx1Rn6w3AkDw34P3KunLrwOMJt1mbkWzb66VDVsIxegc4N8TjJTzvxmCk841wUae3kZ97_HPIEfil3ewv80hZstEE2hcEXJbdBfsxsSqg" _stds_base64URLDecodedData]; + XCTAssertEqualObjects(jws.signature, signature, @"Parsed signature incorrectly."); + NSData *payload = [@"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0" _stds_base64URLDecodedData]; + XCTAssertEqualObjects(jws.payload, payload, @"Parsed payload incorrectly."); + + XCTAssertNil(jws.ellipticCurvePoint, @"Should not create elliptic curve point."); + + NSArray *certChain = @[@"MII", @"MII2"]; + XCTAssertEqualObjects(jws.certificateChain, certChain, @"Failed to parse x5c correctly."); + + +} +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSSecTypeUtilitiesTests.m b/Stripe3DS2/Stripe3DS2Tests/STDSSecTypeUtilitiesTests.m new file mode 100644 index 00000000..9c4c3b2d --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSSecTypeUtilitiesTests.m @@ -0,0 +1,142 @@ +// +// STDSSecTypeUtilitiesTests.m +// Stripe3DS2Tests +// +// Created by Cameron Sabol on 1/28/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "NSString+JWEHelpers.h" +#import "STDSSecTypeUtilities.h" + +@interface STDSSecTypeUtilitiesTests : XCTestCase + +@end + +@implementation STDSSecTypeUtilitiesTests + +- (void)testDirectoryServerForID { + XCTAssertEqual(STDSDirectoryServerForID(@"ul_test"), STDSDirectoryServerSTPTestRSA, @"ul_test should map to STDSDirectoryServerSTPTestRSA."); + XCTAssertEqual(STDSDirectoryServerForID(@"ec_test"), STDSDirectoryServerSTPTestEC, @"ec_test should map to STDSDirectoryServerSTPTestEC."); + XCTAssertEqual(STDSDirectoryServerForID(@"F055545342"), STDSDirectoryServerULTestRSA, @"F055545342 should map to STDSDirectoryServerULTestRSA."); + XCTAssertEqual(STDSDirectoryServerForID(@"F155545342"), STDSDirectoryServerULTestEC, @"F155545342 should map to STDSDirectoryServerULTestEC."); + + XCTAssertEqual(STDSDirectoryServerForID(@"junk"), STDSDirectoryServerUnknown, @"junk server ID should map to STDSDirectoryServerUnknown."); +} + +- (void)testCertificateForServer { + SecCertificateRef certificate = NULL; + + certificate = STDSCertificateForServer(STDSDirectoryServerSTPTestRSA); + XCTAssertTrue(certificate != NULL, @"Unable to load STDSDirectoryServerSTPTestRSA certificate."); + if (certificate != NULL) { + CFRelease(certificate); + } + certificate = STDSCertificateForServer(STDSDirectoryServerSTPTestEC); + XCTAssertTrue(certificate != NULL, @"Unable to load STDSDirectoryServerSTPTestEC certificate."); + if (certificate != NULL) { + CFRelease(certificate); + } + certificate = STDSCertificateForServer(STDSDirectoryServerUnknown); + if (certificate != NULL) { + XCTFail(@"Should not have an unknown certificate."); + CFRelease(certificate); + } +} + +- (void)testCopyPublicRSAKey { + SecCertificateRef certificate = STDSCertificateForServer(STDSDirectoryServerSTPTestRSA); + if (certificate != NULL) { + SecKeyRef publicKey = SecCertificateCopyKey(certificate); + if (publicKey != NULL) { + CFRelease(publicKey); + } else { + XCTFail(@"Unable to load public key from certificate"); + } + + CFRelease(certificate); + } else { + XCTFail(@"Failed loading certificate for %@", NSStringFromSelector(_cmd)); + } +} + +- (void)testCopyPublicECKey { + SecCertificateRef certificate = STDSCertificateForServer(STDSDirectoryServerSTPTestEC); + if (certificate != NULL) { + SecKeyRef publicKey = SecCertificateCopyKey(certificate); + if (publicKey != NULL) { + CFRelease(publicKey); + } else { + XCTFail(@"Unable to load public key from certificate"); + } + + CFRelease(certificate); + } else { + XCTFail(@"Failed loading certificate for %@", NSStringFromSelector(_cmd)); + } +} + +- (void)testCopyKeyTypeRSA { + SecCertificateRef certificate = STDSCertificateForServer(STDSDirectoryServerSTPTestRSA); + if (certificate != NULL) { + CFStringRef keyType = STDSSecCertificateCopyPublicKeyType(certificate); + if (keyType != NULL) { + XCTAssertTrue(CFStringCompare(keyType, kSecAttrKeyTypeRSA, 0) == kCFCompareEqualTo, @"Key type is incorrect"); + CFRelease(keyType); + } else { + XCTFail(@"Failed to copy key type."); + } + CFRelease(certificate); + } else { + XCTFail(@"Failed loading certificate for %@", NSStringFromSelector(_cmd)); + } +} + +- (void)testCopyKeyTypeEC { + SecCertificateRef certificate = STDSCertificateForServer(STDSDirectoryServerSTPTestEC); + if (certificate != NULL) { + CFStringRef keyType = STDSSecCertificateCopyPublicKeyType(certificate); + if (keyType != NULL) { + XCTAssertTrue(CFStringCompare(keyType, kSecAttrKeyTypeECSECPrimeRandom, 0) == kCFCompareEqualTo, @"Key type is incorrect"); + CFRelease(keyType); + } else { + XCTFail(@"Failed to copy key type."); + } + CFRelease(certificate); + } else { + XCTFail(@"Failed loading certificate for %@", NSStringFromSelector(_cmd)); + } +} + +- (void)testRandomData { + // We're not actually going to implement randomness tests... just sanity + NSData *data1 = STDSCryptoRandomData(32); + NSData *data2 = STDSCryptoRandomData(32); + + XCTAssertNotNil(data1); + XCTAssertTrue(data1.length == 32, @"Random data is not correct length."); + XCTAssertNotEqualObjects(data1, data2, @"Sanity check: two random data's should not equate to equal (unless you get reeeeallly unlucky."); + XCTAssertTrue(STDSCryptoRandomData(12).length == 12, @"Random data is not correct length."); + XCTAssertNotNil(STDSCryptoRandomData(0), @"Empty random data should return empty data."); + XCTAssertTrue(STDSCryptoRandomData(0).length == 0, @"Empty random data should have length 0"); +} + +- (void)testConcatKDFWithSHA256 { + NSData *data = STDSCreateConcatKDFWithSHA256(STDSCryptoRandomData(32), 256, @"acs_identifier"); + XCTAssertNotNil(data, @"Failed to concat KDF and hash."); + XCTAssertEqual(data.length, 256, @"Concat returned data of incorrect length"); +} + + +- (void)testVerifyEllipticCurveP256 { + NSData *payload = [@"eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ" dataUsingEncoding:NSUTF8StringEncoding]; + NSData *signature = [@"DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSApmWQxfKTUJqPP3-Kg6NU1Q" _stds_base64URLDecodedData]; + + NSData *coordinateX = [@"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU" _stds_base64URLDecodedData]; + NSData *coordinateY = [@"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0" _stds_base64URLDecodedData]; + + XCTAssertTrue(STDSVerifyEllipticCurveP256Signature(coordinateX, coordinateY, payload, signature)); +} +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSSynchronousLocationManagerTests.m b/Stripe3DS2/Stripe3DS2Tests/STDSSynchronousLocationManagerTests.m new file mode 100644 index 00000000..65e2f9a2 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSSynchronousLocationManagerTests.m @@ -0,0 +1,28 @@ +// +// STDSSynchronousLocationManagerTests.m +// Stripe3DS2Tests +// +// Created by Cameron Sabol on 1/24/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSSynchronousLocationManager.h" + +@interface STDSSynchronousLocationManagerTests : XCTestCase + +@end + +@implementation STDSSynchronousLocationManagerTests + +- (void)testLocationFetchIsSynchronous { + id originalLocation = [[NSObject alloc] init]; + id location = originalLocation; + + location = [[STDSSynchronousLocationManager sharedManager] deviceLocation]; + // tests that location gets synchronously updated (even if it's to nil due to permissions while running tests) + XCTAssertNotEqual(originalLocation, location); +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSTestJSONUtils.h b/Stripe3DS2/Stripe3DS2Tests/STDSTestJSONUtils.h new file mode 100644 index 00000000..c05e729a --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSTestJSONUtils.h @@ -0,0 +1,19 @@ +// +// STDSTestJSONUtils.h +// Stripe3DS2Tests +// +// Created by Yuki Tokuhiro on 3/29/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface STDSTestJSONUtils : NSObject + ++ (NSDictionary *)jsonNamed:(NSString *)name; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSTestJSONUtils.m b/Stripe3DS2/Stripe3DS2Tests/STDSTestJSONUtils.m new file mode 100644 index 00000000..f742e1fc --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSTestJSONUtils.m @@ -0,0 +1,54 @@ +// +// STDSTestJSONUtils.m +// Stripe3DS2Tests +// +// Created by Yuki Tokuhiro on 3/29/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import "STDSTestJSONUtils.h" + +@implementation STDSTestJSONUtils + ++ (NSDictionary *)jsonNamed:(NSString *)name { + NSData *data = [self dataFromJSONFile:name]; + if (data != nil) { + return [NSJSONSerialization JSONObjectWithData:data options:(NSJSONReadingOptions)kNilOptions error:nil]; + } + return nil; +} + ++ (NSBundle *)testBundle { + return [NSBundle bundleForClass:[STDSTestJSONUtils class]]; +} + ++ (NSData *)dataFromJSONFile:(NSString *)name { + NSBundle *bundle = [self testBundle]; + NSString *path = [bundle pathForResource:name ofType:@"json"]; + + if (!path) { + // Missing JSON file + return nil; + } + + NSError *error = nil; + NSString *jsonString = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:&error]; + + if (!jsonString) { + // File read error + return nil; + } + + // Strip all lines that begin with `//` + NSMutableArray *jsonLines = [[NSMutableArray alloc] init]; + + for (NSString *line in [jsonString componentsSeparatedByString:@"\n"]) { + if (![line hasPrefix:@"//"]) { + [jsonLines addObject:line]; + } + } + + return [[jsonLines componentsJoinedByString:@"\n"] dataUsingEncoding:NSUTF8StringEncoding]; +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSThreeDS2ServiceTests.m b/Stripe3DS2/Stripe3DS2Tests/STDSThreeDS2ServiceTests.m new file mode 100644 index 00000000..237b5127 --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSThreeDS2ServiceTests.m @@ -0,0 +1,62 @@ +// +// STDSThreeDS2ServiceTests.m +// Stripe3DS2Tests +// +// Created by Cameron Sabol on 1/22/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSAlreadyInitializedException.h" +#import "STDSConfigParameters.h" +#import "STDSInvalidInputException.h" +#import "STDSThreeDS2Service.h" +#import "STDSNotInitializedException.h" + +@interface STDSThreeDS2ServiceTests : XCTestCase + +@end + +@implementation STDSThreeDS2ServiceTests + +- (void)testInitialize { + STDSThreeDS2Service *service = [[STDSThreeDS2Service alloc] init]; + XCTAssertNoThrow([service initializeWithConfig:[[STDSConfigParameters alloc] init] + locale:nil + uiSettings:nil], + @"Should not throw with valid input and first call to initialize"); + + XCTAssertThrowsSpecific([service initializeWithConfig:[[STDSConfigParameters alloc] init] + locale:nil + uiSettings:nil], + STDSAlreadyInitializedException, + @"Should throw STDSAlreadyInitializedException if called again with valid input."); + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wnonnull" + XCTAssertThrowsSpecific([service initializeWithConfig:nil + locale:nil + uiSettings:nil], + STDSInvalidInputException, + @"Should throw STDSInvalidInputException for nil config even if already initialized."); + + service = [[STDSThreeDS2Service alloc] init]; + XCTAssertThrowsSpecific([service initializeWithConfig:nil + locale:nil + uiSettings:nil], + STDSInvalidInputException, + @"Should throw STDSInvalidInputException for nil config on first initialize."); +#pragma clang diagnostic pop + + XCTAssertNoThrow([service initializeWithConfig:[[STDSConfigParameters alloc] init] + locale:nil + uiSettings:nil], + @"Should not throw with valid input and first call to initialize even after invalid input"); + + service = [[STDSThreeDS2Service alloc] init]; + XCTAssertThrowsSpecific(service.warnings, STDSNotInitializedException); + +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSTransactionTest.m b/Stripe3DS2/Stripe3DS2Tests/STDSTransactionTest.m new file mode 100644 index 00000000..3dd1187c --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSTransactionTest.m @@ -0,0 +1,157 @@ +// +// STDSTransactionTest.m +// Stripe3DS2Tests +// +// Created by Yuki Tokuhiro on 3/22/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSTransaction+Private.h" +#import "STDSInvalidInputException.h" +#import "STDSChallengeParameters.h" +#import "STDSChallengeStatusReceiver.h" +#import "STDSRuntimeErrorEvent.h" +#import "STDSProtocolErrorEvent.h" +#import "STDSStripe3DS2Error.h" +#import "STDSTransaction.h" +#import "STDSErrorMessage.h" +#import "NSError+Stripe3DS2.h" + +@interface STDSTransaction (Private) +@property (nonatomic, weak) id challengeStatusReceiver; + +- (void)_handleError:(NSError *)error; +- (NSString *)_sdkAppIdentifier; +@end + +@interface STDSTransactionTest : XCTestCase +@property (nonatomic, copy) void (^didErrorWithProtocolErrorEvent)(STDSProtocolErrorEvent *); +@property (nonatomic, copy) void (^didErrorWithRuntimeErrorEvent)(STDSRuntimeErrorEvent *); +@end + +@implementation STDSTransactionTest + +- (void)tearDown { + self.didErrorWithRuntimeErrorEvent = nil; + self.didErrorWithProtocolErrorEvent = nil; + [super tearDown]; +} + +#pragma mark - Timeout + +- (void)testTimeoutBelow5Throws { + STDSTransaction *transaction = [STDSTransaction new]; + STDSChallengeParameters *challengeParameters = [[STDSChallengeParameters alloc] init]; + XCTAssertThrowsSpecific([transaction doChallengeWithViewController:[UIViewController new] + challengeParameters:challengeParameters + challengeStatusReceiver:self + timeout:4 * 60], STDSInvalidInputException); +} + +- (void)testTimeoutFires { + STDSTransaction *transaction = [STDSTransaction new]; + STDSChallengeParameters *challengeParameters = [[STDSChallengeParameters alloc] init]; + + // Assert timer is scheduled to fire 5 minutes from now, give or take 1 second + NSInteger timeout = 300; + [transaction doChallengeWithViewController:[UIViewController new] + challengeParameters:challengeParameters + challengeStatusReceiver:self + timeout:timeout]; + XCTAssertTrue(transaction.timeoutTimer.isValid); + NSTimeInterval secondsIntoTheFutureTimerWillFire = [transaction.timeoutTimer.fireDate timeIntervalSinceNow]; + XCTAssertLessThanOrEqual(fabs(secondsIntoTheFutureTimerWillFire - (timeout)), 1); +} + +#pragma mark - Error Handling + +- (void)testHandleUnknownMessageTypeError { + NSError *error = [NSError errorWithDomain:STDSStripe3DS2ErrorDomain code:STDSErrorCodeUnknownMessageType userInfo:nil]; + [self _expectProtocolErrorEventForError:error validator:^(STDSProtocolErrorEvent *protocolErrorEvent) { + XCTAssertEqualObjects(protocolErrorEvent.errorMessage.errorCode, @"101"); + }]; +} + +- (void)testHandleInvalidJSONError { + NSError *error = [NSError _stds_invalidJSONFieldError:@"invalid field"]; + [self _expectProtocolErrorEventForError:error validator:^(STDSProtocolErrorEvent *protocolErrorEvent) { + XCTAssertEqualObjects(protocolErrorEvent.errorMessage.errorCode, @"203"); + XCTAssertEqualObjects(protocolErrorEvent.errorMessage.errorDetails, @"invalid field"); + }]; +} + +- (void)testHandleMissingJSONError { + NSError *error = [NSError _stds_missingJSONFieldError:@"missing field"]; + [self _expectProtocolErrorEventForError:error validator:^(STDSProtocolErrorEvent *protocolErrorEvent) { + XCTAssertEqualObjects(protocolErrorEvent.errorMessage.errorCode, @"201"); + XCTAssertEqualObjects(protocolErrorEvent.errorMessage.errorDetails, @"missing field"); + }]; +} + +- (void)testHandleReceivedErrorMessage { + STDSErrorMessage *receivedErrorMessage = [[STDSErrorMessage alloc] initWithErrorCode:@"" errorComponent:@"" errorDescription:@"" errorDetails:nil messageVersion:@"" acsTransactionIdentifier:@"" errorMessageType:@""]; + NSError *error = [NSError errorWithDomain:STDSStripe3DS2ErrorDomain + code:STDSErrorCodeReceivedErrorMessage + userInfo:@{STDSStripe3DS2ErrorMessageErrorKey: receivedErrorMessage}]; + + [self _expectProtocolErrorEventForError:error validator:^(STDSProtocolErrorEvent *protocolErrorEvent) { + XCTAssertEqualObjects(protocolErrorEvent.errorMessage, receivedErrorMessage); + }]; +} + +- (void)testHandleNetworkConnectionLostError { + NSError *error = [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorNetworkConnectionLost + userInfo:nil]; + [self _expectRuntimeErrorEventForError:error validator:^(STDSRuntimeErrorEvent *runtimeErrorEvent) { + XCTAssertEqualObjects(runtimeErrorEvent.errorCode, [@(NSURLErrorNetworkConnectionLost) stringValue]); + }]; +} + +- (void)_expectProtocolErrorEventForError:(NSError *)error validator:(void (^)(STDSProtocolErrorEvent *))protocolErrorEventChecker { + STDSTransaction *transaction = [STDSTransaction new]; + transaction.challengeStatusReceiver = self; + XCTestExpectation *expectation = [self expectationWithDescription:@"Call didErrorWithProtocolErrorEvent"]; + self.didErrorWithProtocolErrorEvent = ^(STDSProtocolErrorEvent *protocolErrorEvent) { + protocolErrorEventChecker(protocolErrorEvent); + [expectation fulfill]; + }; + [transaction _handleError:error]; + [self waitForExpectationsWithTimeout:1 handler:nil]; +} + +- (void)_expectRuntimeErrorEventForError:(NSError *)error validator:(void (^)(STDSRuntimeErrorEvent *))runtimeErrorEventChecker { + STDSTransaction *transaction = [STDSTransaction new]; + transaction.challengeStatusReceiver = self; + XCTestExpectation *expectation = [self expectationWithDescription:@"Call didErrorWithRuntimeErrorEvent"]; + self.didErrorWithRuntimeErrorEvent = ^(STDSRuntimeErrorEvent *runtimeErrorEvent) { + runtimeErrorEventChecker(runtimeErrorEvent); + [expectation fulfill]; + }; + [transaction _handleError:error]; + [self waitForExpectationsWithTimeout:1 handler:nil]; +} + +#pragma mark - STDSChallengeStatusReceiver + +- (void)transaction:(nonnull STDSTransaction *)transaction didCompleteChallengeWithCompletionEvent:(nonnull STDSCompletionEvent *)completionEvent {} + +- (void)transaction:(nonnull STDSTransaction *)transaction didErrorWithProtocolErrorEvent:(nonnull STDSProtocolErrorEvent *)protocolErrorEvent { + if (self.didErrorWithProtocolErrorEvent) { + self.didErrorWithProtocolErrorEvent(protocolErrorEvent); + } +} + +- (void)transaction:(nonnull STDSTransaction *)transaction didErrorWithRuntimeErrorEvent:(nonnull STDSRuntimeErrorEvent *)runtimeErrorEvent { + if (self.didErrorWithRuntimeErrorEvent) { + self.didErrorWithRuntimeErrorEvent(runtimeErrorEvent); + } +} + +- (void)transactionDidCancel:(nonnull STDSTransaction *)transaction {} + +- (void)transactionDidTimeOut:(nonnull STDSTransaction *)transaction {} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSUICustomizationTests.m b/Stripe3DS2/Stripe3DS2Tests/STDSUICustomizationTests.m new file mode 100644 index 00000000..9d231acf --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSUICustomizationTests.m @@ -0,0 +1,249 @@ +// +// STDSUICustomizationTests.m +// Stripe3DS2Tests +// +// Created by Andrew Harrison on 3/14/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import +#import "STDSUICustomization.h" + +@interface STDSUICustomizationTests : XCTestCase + +@end + +@implementation STDSUICustomizationTests + +// The following helper methods return customization objects with properties different than the default. + +- (STDSNavigationBarCustomization *)_customNavigationBar { + STDSNavigationBarCustomization *custom = [STDSNavigationBarCustomization new]; + custom.font = [UIFont italicSystemFontOfSize:1]; + custom.textColor = UIColor.blueColor; + custom.barTintColor = UIColor.redColor; + custom.barStyle = UIBarStyleBlack; + custom.translucent = NO; + custom.headerText = @"foo"; + custom.buttonText = @"bar"; + return custom; +} + +- (STDSLabelCustomization *)_customLabel { + STDSLabelCustomization *custom = [STDSLabelCustomization new]; + custom.font = [UIFont italicSystemFontOfSize:1]; + custom.textColor = UIColor.blueColor; + custom.headingTextColor = UIColor.redColor; + custom.headingFont = [UIFont italicSystemFontOfSize:2]; + return custom; +} + +- (STDSTextFieldCustomization *)_customTextField { + STDSTextFieldCustomization *custom = [STDSTextFieldCustomization new]; + custom.font = [UIFont italicSystemFontOfSize:1]; + custom.textColor = UIColor.blueColor; + custom.borderWidth = -1; + custom.borderColor = UIColor.redColor; + custom.cornerRadius = -8; + custom.keyboardAppearance = UIKeyboardAppearanceAlert; + custom.placeholderTextColor = UIColor.greenColor; + return custom; +} + +- (STDSButtonCustomization *)_customButton { + STDSButtonCustomization *custom = [[STDSButtonCustomization alloc] initWithBackgroundColor:UIColor.redColor cornerRadius:-1]; + custom.font = [UIFont italicSystemFontOfSize:1]; + custom.textColor = UIColor.blueColor; + custom.titleStyle = STDSButtonTitleStyleLowercase; + return custom; +} + +- (STDSFooterCustomization *)_customFooter { + STDSFooterCustomization *custom = [STDSFooterCustomization new]; + custom.font = [UIFont italicSystemFontOfSize:1]; + custom.textColor = UIColor.blueColor; + custom.backgroundColor = UIColor.redColor; + custom.chevronColor = UIColor.greenColor; + custom.headingTextColor = UIColor.grayColor; + custom.headingFont = [UIFont italicSystemFontOfSize:2]; + return custom; +} + +- (STDSSelectionCustomization *)_customSelection { + STDSSelectionCustomization *custom = [STDSSelectionCustomization new]; + custom.primarySelectedColor = UIColor.redColor; + custom.secondarySelectedColor = UIColor.blueColor; + custom.unselectedBorderColor = UIColor.brownColor; + custom.unselectedBackgroundColor = UIColor.cyanColor; + return custom; +} + +#pragma mark - Copying + +- (void)testUICustomizationDeepCopy { + // Make a STDSUICustomization instance with all non-default properties + STDSButtonCustomization *submitButtonCustomization = [self _customButton]; + STDSButtonCustomization *continueButtonCustomization = [self _customButton]; + continueButtonCustomization.cornerRadius = -2; + STDSButtonCustomization *nextButtonCustomization = [self _customButton]; + nextButtonCustomization.cornerRadius = -3; + STDSButtonCustomization *cancelButtonCustomization = [self _customButton]; + cancelButtonCustomization.cornerRadius = -4; + STDSButtonCustomization *resendButtonCustomization = [self _customButton]; + resendButtonCustomization.cornerRadius = -5; + + STDSNavigationBarCustomization *navigationBarCustomization = [self _customNavigationBar]; + STDSLabelCustomization *labelCustomization = [self _customLabel]; + STDSTextFieldCustomization *textFieldCustomization = [self _customTextField]; + STDSFooterCustomization *footerCustomization = [self _customFooter]; + STDSSelectionCustomization *selectionCustomization = [self _customSelection]; + + STDSUICustomization *uiCustomization = [[STDSUICustomization alloc] init]; + uiCustomization.footerCustomization = footerCustomization; + uiCustomization.selectionCustomization = selectionCustomization; + [uiCustomization setButtonCustomization:submitButtonCustomization forType:STDSUICustomizationButtonTypeSubmit]; + [uiCustomization setButtonCustomization:continueButtonCustomization forType:STDSUICustomizationButtonTypeContinue]; + [uiCustomization setButtonCustomization:nextButtonCustomization forType:STDSUICustomizationButtonTypeNext]; + [uiCustomization setButtonCustomization:cancelButtonCustomization forType:STDSUICustomizationButtonTypeCancel]; + [uiCustomization setButtonCustomization:resendButtonCustomization forType:STDSUICustomizationButtonTypeResend]; + uiCustomization.navigationBarCustomization = navigationBarCustomization; + uiCustomization.labelCustomization = labelCustomization; + uiCustomization.textFieldCustomization = textFieldCustomization; + uiCustomization.backgroundColor = UIColor.redColor; + uiCustomization.activityIndicatorViewStyle = UIActivityIndicatorViewStyleLarge; + uiCustomization.blurStyle = UIBlurEffectStyleDark; + uiCustomization.preferredStatusBarStyle = UIStatusBarStyleLightContent; + + STDSUICustomization *copy = [uiCustomization copy]; + XCTAssertNotNil([copy buttonCustomizationForButtonType:STDSUICustomizationButtonTypeNext]); + XCTAssertNotNil(copy.navigationBarCustomization); + XCTAssertNotNil(copy.labelCustomization); + XCTAssertNotNil(copy.textFieldCustomization); + XCTAssertNotNil(copy.footerCustomization); + XCTAssertNotNil(copy.selectionCustomization); + + /// The pointers do not reference the same objects. + XCTAssertNotEqual([uiCustomization buttonCustomizationForButtonType:STDSUICustomizationButtonTypeSubmit], [copy buttonCustomizationForButtonType:STDSUICustomizationButtonTypeSubmit]); + XCTAssertNotEqual([uiCustomization buttonCustomizationForButtonType:STDSUICustomizationButtonTypeContinue], [copy buttonCustomizationForButtonType:STDSUICustomizationButtonTypeContinue]); + XCTAssertNotEqual([uiCustomization buttonCustomizationForButtonType:STDSUICustomizationButtonTypeNext], [copy buttonCustomizationForButtonType:STDSUICustomizationButtonTypeNext]); + XCTAssertNotEqual([uiCustomization buttonCustomizationForButtonType:STDSUICustomizationButtonTypeCancel], [copy buttonCustomizationForButtonType:STDSUICustomizationButtonTypeCancel]); + XCTAssertNotEqual([uiCustomization buttonCustomizationForButtonType:STDSUICustomizationButtonTypeResend], [copy buttonCustomizationForButtonType:STDSUICustomizationButtonTypeResend]); + XCTAssertNotEqual(uiCustomization.navigationBarCustomization, copy.navigationBarCustomization); + XCTAssertNotEqual(uiCustomization.labelCustomization, copy.labelCustomization); + XCTAssertNotEqual(uiCustomization.textFieldCustomization, copy.textFieldCustomization); + XCTAssertNotEqual(uiCustomization.footerCustomization, copy.footerCustomization); + XCTAssertNotEqual(uiCustomization.selectionCustomization, copy.selectionCustomization); + + /// The properties have been successfully copied. + XCTAssertEqualObjects(uiCustomization.backgroundColor, copy.backgroundColor); + XCTAssertEqual(uiCustomization.activityIndicatorViewStyle, copy.activityIndicatorViewStyle); + XCTAssertEqual(uiCustomization.blurStyle, copy.blurStyle); + // A different test case will cover that our custom classes implemented copy correctly; just sanity check one property here + XCTAssertEqual([uiCustomization buttonCustomizationForButtonType:STDSUICustomizationButtonTypeSubmit].cornerRadius, submitButtonCustomization.cornerRadius); + XCTAssertEqual([uiCustomization buttonCustomizationForButtonType:STDSUICustomizationButtonTypeContinue].cornerRadius, continueButtonCustomization.cornerRadius); + XCTAssertEqual([uiCustomization buttonCustomizationForButtonType:STDSUICustomizationButtonTypeNext].cornerRadius, nextButtonCustomization.cornerRadius); + XCTAssertEqual([uiCustomization buttonCustomizationForButtonType:STDSUICustomizationButtonTypeCancel].cornerRadius, cancelButtonCustomization.cornerRadius); + XCTAssertEqual([uiCustomization buttonCustomizationForButtonType:STDSUICustomizationButtonTypeResend].cornerRadius, resendButtonCustomization.cornerRadius); + XCTAssertEqualObjects(uiCustomization.navigationBarCustomization.font, copy.navigationBarCustomization.font); + XCTAssertEqualObjects(uiCustomization.labelCustomization.font, copy.labelCustomization.font); + XCTAssertEqualObjects(uiCustomization.textFieldCustomization.font, copy.textFieldCustomization.font); + XCTAssertEqualObjects(uiCustomization.footerCustomization.font, copy.footerCustomization.font); + XCTAssertEqualObjects(uiCustomization.selectionCustomization.primarySelectedColor, copy.selectionCustomization.primarySelectedColor); + XCTAssertEqual(uiCustomization.preferredStatusBarStyle, copy.preferredStatusBarStyle); +} + +- (void)testButtonCustomizationIsCopied { + STDSButtonCustomization *buttonCustomization = [self _customButton]; + + /// The pointers do not reference the same objects. + STDSButtonCustomization *copy = [buttonCustomization copy]; + XCTAssertNotEqual(buttonCustomization, copy); + + /// The properties have been successfully copied. + XCTAssertEqual(buttonCustomization.cornerRadius, copy.cornerRadius); + XCTAssertEqual(buttonCustomization.backgroundColor, copy.backgroundColor); + XCTAssertEqual(buttonCustomization.font, copy.font); + XCTAssertEqual(buttonCustomization.textColor, copy.textColor); + XCTAssertEqual(buttonCustomization.titleStyle, buttonCustomization.titleStyle); +} + +- (void)testNavigationBarCustomizationIsCopied { + STDSNavigationBarCustomization *navigationBarCustomization = [self _customNavigationBar]; + + /// The pointers do not reference the same objects. + STDSNavigationBarCustomization *copy = [navigationBarCustomization copy]; + XCTAssertNotEqual(navigationBarCustomization, copy); + + /// The properties have been successfully copied. + XCTAssertEqualObjects(navigationBarCustomization.headerText, copy.headerText); + XCTAssertEqualObjects(navigationBarCustomization.buttonText, copy.buttonText); + XCTAssertEqualObjects(navigationBarCustomization.barTintColor, copy.barTintColor); + XCTAssertEqualObjects(navigationBarCustomization.font, copy.font); + XCTAssertEqualObjects(navigationBarCustomization.textColor, copy.textColor); + XCTAssertEqual(navigationBarCustomization.barStyle, copy.barStyle); + XCTAssertEqual(navigationBarCustomization.translucent, copy.translucent); +} + +- (void)testLabelCustomizationIsCopied { + STDSLabelCustomization *labelCustomization = [self _customLabel]; + + /// The pointers do not reference the same objects. + STDSLabelCustomization *copy = [labelCustomization copy]; + XCTAssertNotEqual(labelCustomization, copy); + + /// The properties have been successfully copied. + XCTAssertEqualObjects(labelCustomization.headingTextColor, copy.headingTextColor); + XCTAssertEqualObjects(labelCustomization.headingFont, copy.headingFont); + XCTAssertEqualObjects(labelCustomization.font, copy.font); + XCTAssertEqualObjects(labelCustomization.textColor, copy.textColor); +} + +- (void)testTextFieldCustomizationIsCopied { + STDSTextFieldCustomization *textFieldCustomization = [self _customTextField]; + + /// The pointers do not reference the same objects. + STDSTextFieldCustomization *copy = [textFieldCustomization copy]; + XCTAssertNotEqual(textFieldCustomization, copy); + + /// The properties have been successfully copied. + XCTAssertEqual(textFieldCustomization.borderWidth, copy.borderWidth); + XCTAssertEqualObjects(textFieldCustomization.borderColor, copy.borderColor); + XCTAssertEqual(textFieldCustomization.cornerRadius, copy.cornerRadius); + XCTAssertEqualObjects(textFieldCustomization.font, copy.font); + XCTAssertEqualObjects(textFieldCustomization.textColor, copy.textColor); + XCTAssertEqual(textFieldCustomization.keyboardAppearance, copy.keyboardAppearance); + XCTAssertEqualObjects(textFieldCustomization.placeholderTextColor, copy.placeholderTextColor); +} + +- (void)testFooterCustomizationIsCopied { + STDSFooterCustomization *footerCustomization = [self _customFooter]; + + /// The pointers do not reference the same objects. + STDSFooterCustomization *copy = [footerCustomization copy]; + XCTAssertNotEqual(footerCustomization, copy); + + /// The properties have been successfully copied. + XCTAssertEqualObjects(footerCustomization.textColor, copy.textColor); + XCTAssertEqualObjects(footerCustomization.font, copy.font); + XCTAssertEqualObjects(footerCustomization.backgroundColor, copy.backgroundColor); + XCTAssertEqualObjects(footerCustomization.chevronColor, copy.chevronColor); + XCTAssertEqualObjects(footerCustomization.headingTextColor, copy.headingTextColor); + XCTAssertEqualObjects(footerCustomization.headingFont, copy.headingFont); +} + +- (void)testSelectionCustomizationIsCopied { + STDSSelectionCustomization *customization = [self _customSelection]; + + /// The pointers do not reference the same objects. + STDSSelectionCustomization *copy = [customization copy]; + XCTAssertNotEqual(customization, copy); + + /// The properties have been successfully copied. + XCTAssertEqualObjects(customization.primarySelectedColor, copy.primarySelectedColor); + XCTAssertEqualObjects(customization.secondarySelectedColor, copy.secondarySelectedColor); + XCTAssertEqualObjects(customization.unselectedBorderColor, copy.unselectedBorderColor); + XCTAssertEqualObjects(customization.unselectedBackgroundColor, copy.unselectedBackgroundColor); + +} + +@end diff --git a/Stripe3DS2/Stripe3DS2Tests/STDSWarningTests.m b/Stripe3DS2/Stripe3DS2Tests/STDSWarningTests.m new file mode 100644 index 00000000..ec559c5d --- /dev/null +++ b/Stripe3DS2/Stripe3DS2Tests/STDSWarningTests.m @@ -0,0 +1,26 @@ +// +// STDSWarningTests.m +// Stripe3DS2Tests +// +// Created by Cameron Sabol on 2/12/19. +// Copyright © 2019 Stripe. All rights reserved. +// + +#import + +#import "STDSWarning.h" + +@interface STDSWarningTests : XCTestCase + +@end + +@implementation STDSWarningTests + +- (void)testWarning { + STDSWarning *warning = [[STDSWarning alloc] initWithIdentifier:@"test_id" message:@"test_message" severity:STDSWarningSeverityMedium]; + XCTAssertEqual(warning.identifier, @"test_id", @"Identifier was not set correctly."); + XCTAssertEqual(warning.message, @"test_message", @"Message was not set correctly."); + XCTAssertEqual(warning.severity, STDSWarningSeverityMedium, @"Severity was not set correctly."); +} + +@end diff --git a/Stripe3DS2/exported_symbols.txt b/Stripe3DS2/exported_symbols.txt new file mode 100644 index 00000000..981c8756 --- /dev/null +++ b/Stripe3DS2/exported_symbols.txt @@ -0,0 +1,13 @@ +_OBJC_CLASS_$_STDSButtonCustomization +_OBJC_CLASS_$_STDSChallengeParameters +_OBJC_CLASS_$_STDSConfigParameters +_OBJC_CLASS_$_STDSFooterCustomization +_OBJC_CLASS_$_STDSJSONEncoder +_OBJC_CLASS_$_STDSLabelCustomization +_OBJC_CLASS_$_STDSNavigationBarCustomization +_OBJC_CLASS_$_STDSSelectionCustomization +_OBJC_CLASS_$_STDSTextFieldCustomization +_OBJC_CLASS_$_STDSThreeDS2Service +_OBJC_CLASS_$_STDSUICustomization +_OBJC_CLASS_$_STDSSwiftTryCatch +_STDSAuthenticationResponseFromJSON diff --git a/StripeApplePay/Project.swift b/StripeApplePay/Project.swift new file mode 100644 index 00000000..020478d5 --- /dev/null +++ b/StripeApplePay/Project.swift @@ -0,0 +1,15 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.stripeFramework( + name: "StripeApplePay", + dependencies: [ + .project(target: "StripeCore", path: "//StripeCore"), + ], + unitTestOptions: .testOptions( + dependencies: [ + .project(target: "StripeCoreTestUtils", path: "//StripeCore"), + ], + usesStubs: true + ) +) diff --git a/StripeApplePay/README.md b/StripeApplePay/README.md new file mode 100644 index 00000000..9c225745 --- /dev/null +++ b/StripeApplePay/README.md @@ -0,0 +1,36 @@ +# Stripe Apple Pay iOS SDK + +StripeApplePay is a lightweight Apple Pay SDK intended for building App Clips or other size-constrained apps. + +## Table of contents + + +- [Stripe Apple Pay iOS SDK](#stripe-apple-pay-ios-sdk) +- [Table of contents](#table-of-contents) + - [Requirements](#requirements) + - [Getting started](#getting-started) + - [Integration](#integration) + - [Example](#example) + - [Manual linking](#manual-linking) + + + +## Requirements + +The Stripe Apple Pay SDK is compatible with apps targeting iOS 13.0 or above. + +## Getting started + +### Integration + +Get started with our [📚 Apple Pay integration guide](https://stripe.com/docs/apple-pay?platform=ios) and [example project](../Example/AppClipExample), or [📘 browse the SDK reference](https://stripe.dev/stripe-ios/stripe-applepay/index.html) for fine-grained documentation of all the classes and methods in the SDK. + +### Example + +[AppClipExample](../Example/AppClipExample) – This example demonstrates how to offer Apple Pay in an App Clip. + +## Manual linking + +If you link this library manually, use a version from our [releases](https://github.com/stripe/stripe-ios/releases) page and make sure to embed all of the following frameworks: +- `StripeCore.xcframework` +- `StripeApplePay.xcframework` diff --git a/StripeApplePay/StripeApplePay.xcodeproj/project.pbxproj b/StripeApplePay/StripeApplePay.xcodeproj/project.pbxproj new file mode 100644 index 00000000..4c4ed77b --- /dev/null +++ b/StripeApplePay/StripeApplePay.xcodeproj/project.pbxproj @@ -0,0 +1,610 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + 01AA289840E0AC9229A8CF63 /* StripeApplePay.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 29BFCC7B0FCEA743A857B51F /* StripeApplePay.framework */; }; + 09EB0F7E346CB22144515E67 /* SetupIntent+API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30DDE851AD7450BE70381337 /* SetupIntent+API.swift */; }; + 1990346BA0B39ADD47210E18 /* StripeApplePay.h in Headers */ = {isa = PBXBuildFile; fileRef = 0C1D3421B1B2BB91FAA66620 /* StripeApplePay.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 22E1F50D066A294A316052ED /* PaymentIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2398CA91B601B5FC7C8FB48 /* PaymentIntent.swift */; }; + 234FA38DC46927B23871A75D /* SetupIntentParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D7CF2B75A797D6B2B5A04B4 /* SetupIntentParams.swift */; }; + 2D6CD6872A00B6FE0243C3F5 /* PKPayment+Stripe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 906F7217DBF694BF42F23458 /* PKPayment+Stripe.swift */; }; + 2EBB230815383A8402D71146 /* STPPaymentMethodFunctionalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 905182496A07DE4F056A3EAA /* STPPaymentMethodFunctionalTest.swift */; }; + 43682CEAB00A93868FA3188A /* SetupIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2A354F50C5D563436A0068 /* SetupIntent.swift */; }; + 463680AADB8CED6E962CD45A /* PaymentIntentParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 478361D29DF10F2B43F7A1D2 /* PaymentIntentParams.swift */; }; + 4AA6A66246DD30798E5CC5F7 /* PaymentIntent+API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EAD8979A20E239E16257E4 /* PaymentIntent+API.swift */; }; + 4ADC5356764DC5E3F1C1D51B /* CardBrand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DBC2BC8C0A60983D0948A11 /* CardBrand.swift */; }; + 4B2DE4109D876CCBDC53C2A3 /* StripeCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62ECC1AA5583E4104C073B63 /* StripeCore.framework */; }; + 4CE57B5BF79D1515F27A18A3 /* Address.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF86CF1EFE4AC1A46F055202 /* Address.swift */; }; + 505A82AADE15E2B2B99C5D32 /* Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = 594F0478E94E6FA2F551EA0A /* Token.swift */; }; + 526AE8381F232C9FEABFFDCD /* OHHTTPStubsSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 8ED017D12DE81D3ABD013768 /* OHHTTPStubsSwift */; }; + 53BA33D02E07DFF1393DF0E4 /* PaymentMethod+API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 264583DBFFA42C864220D7FF /* PaymentMethod+API.swift */; }; + 59AEBEB856DB8A0118F1D0DE /* STPAnalyticsClient+Payments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A70C7D203A80129DBB9304 /* STPAnalyticsClient+Payments.swift */; }; + 59C1DB9EF052987BD20B65A3 /* BillingDetails+ApplePay.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FD028203455E50A637227C /* BillingDetails+ApplePay.swift */; }; + 62AFEE5A1E32BD84588CA233 /* STPTelemetryClientTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4B4DF82BA51CC68265B0795 /* STPTelemetryClientTest.swift */; }; + 7C7C92AFED77FC4C5D26DC36 /* BillingDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C563CA8E1D005A453C762703 /* BillingDetails.swift */; }; + 848843F1145350ABF540D69F /* ShippingDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C08710B3CD45DE9DCE2055A3 /* ShippingDetails.swift */; }; + 90286A48FA6C350BDEC227D0 /* TelemetryInjectionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 446F888DB3EC0FC1FE9B9377 /* TelemetryInjectionTest.swift */; }; + 9F50D23599CD0B1270F8C295 /* STPApplePayContext+LegacySupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7023208C7E3B3044F553DFDD /* STPApplePayContext+LegacySupport.swift */; }; + A69598FE8095AFA9898297A9 /* Token+API.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81AB96E6201D534220ABB9D /* Token+API.swift */; }; + A79510332C88568637C9E867 /* STPAnalyticsClient+ApplePayTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E9055B54A5634B636570E3 /* STPAnalyticsClient+ApplePayTest.swift */; }; + AB48B0CBFFEFC46E01C76B6C /* STPAPIClient+PaymentsCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894B7D67F2364A12DD133CF4 /* STPAPIClient+PaymentsCore.swift */; }; + BCC7454DB40673493DF940F5 /* STPAnalyticsClient+PaymentsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBD5C52C679274D830AA5B93 /* STPAnalyticsClient+PaymentsAPI.swift */; }; + C0D4B1753F584397DD1AD946 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 87A189B89110D3A8EF052425 /* XCTest.framework */; }; + C6552A9ADEA160B9BBAF3A10 /* STPApplePayContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4AEF10AF7900AF7ED0EDE21 /* STPApplePayContext.swift */; }; + C6A333FBB72EE91849DD6202 /* STPAPIClient+ApplePay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 855AB5D51581CE6ABCCD2493 /* STPAPIClient+ApplePay.swift */; }; + CB78059C8EB6A072C30A98DA /* STPTelemetryClientFunctionalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B7AA620790EC4257132D738 /* STPTelemetryClientFunctionalTest.swift */; }; + CECAB0CB1CE4D7BD1EF897F0 /* Blocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5439AD0D1236F6A21EE93CB3 /* Blocks.swift */; }; + D8D613D3A85B81F8C4386E08 /* OHHTTPStubs in Frameworks */ = {isa = PBXBuildFile; productRef = CB64080D8A41D33BC3DEEAF8 /* OHHTTPStubs */; }; + E1E7B153B169D0A6363ADD4B /* PaymentMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D1E8A9F849655BAC63DB2FD /* PaymentMethod.swift */; }; + EEF84AC6C1EBF27BF6AAC0BF /* StripeCoreTestUtils.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0FA663325484F2D515613494 /* StripeCoreTestUtils.framework */; }; + F42BC784EDBD141C90E74A5F /* PKContact+Stripe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133A7483EDB9F1B1CFFDB9ED /* PKContact+Stripe.swift */; }; + F59D5A3F2C5849CD465062A5 /* StripeCore+Import.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88664A990EE5D99076E88987 /* StripeCore+Import.swift */; }; + FADFD3B7E3832928E254FAF1 /* PaymentMethodParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 468654E242DFE2F85FF422EB /* PaymentMethodParams.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 6DAB385DAFF8DCB87110CC7E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 95898E5CA0A1871DDFFB66B0 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 747C9FE1743C0B4444303569; + remoteInfo = StripeApplePay; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + F937383B644A63BD5FC4A081 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + FF3CB7A978895136380088BB /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0B7AA620790EC4257132D738 /* STPTelemetryClientFunctionalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPTelemetryClientFunctionalTest.swift; sourceTree = ""; }; + 0C1D3421B1B2BB91FAA66620 /* StripeApplePay.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StripeApplePay.h; sourceTree = ""; }; + 0FA663325484F2D515613494 /* StripeCoreTestUtils.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCoreTestUtils.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 133A7483EDB9F1B1CFFDB9ED /* PKContact+Stripe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKContact+Stripe.swift"; sourceTree = ""; }; + 20A70C7D203A80129DBB9304 /* STPAnalyticsClient+Payments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPAnalyticsClient+Payments.swift"; sourceTree = ""; }; + 229446797CC5FB07DB344D81 /* StripeApplePayTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StripeApplePayTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 264583DBFFA42C864220D7FF /* PaymentMethod+API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PaymentMethod+API.swift"; sourceTree = ""; }; + 29BFCC7B0FCEA743A857B51F /* StripeApplePay.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeApplePay.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 30DDE851AD7450BE70381337 /* SetupIntent+API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SetupIntent+API.swift"; sourceTree = ""; }; + 3D7CF2B75A797D6B2B5A04B4 /* SetupIntentParams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupIntentParams.swift; sourceTree = ""; }; + 40D5ED47331313B6AD8B6F46 /* Project-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Debug.xcconfig"; sourceTree = ""; }; + 425B19195E4B86F77229252D /* StripeiOS Tests-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS Tests-Debug.xcconfig"; sourceTree = ""; }; + 446F888DB3EC0FC1FE9B9377 /* TelemetryInjectionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryInjectionTest.swift; sourceTree = ""; }; + 468654E242DFE2F85FF422EB /* PaymentMethodParams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentMethodParams.swift; sourceTree = ""; }; + 478361D29DF10F2B43F7A1D2 /* PaymentIntentParams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentIntentParams.swift; sourceTree = ""; }; + 49B0926F8F65BC0102B99BF4 /* StripeiOS-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS-Debug.xcconfig"; sourceTree = ""; }; + 4EE8157F3536C238AC337337 /* StripeiOS-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS-Release.xcconfig"; sourceTree = ""; }; + 5439AD0D1236F6A21EE93CB3 /* Blocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Blocks.swift; sourceTree = ""; }; + 594F0478E94E6FA2F551EA0A /* Token.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Token.swift; sourceTree = ""; }; + 62ECC1AA5583E4104C073B63 /* StripeCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 67711D1BC01BA83323EA1F6B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 6D1E8A9F849655BAC63DB2FD /* PaymentMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentMethod.swift; sourceTree = ""; }; + 7023208C7E3B3044F553DFDD /* STPApplePayContext+LegacySupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPApplePayContext+LegacySupport.swift"; sourceTree = ""; }; + 79B089D69DBA1A0BCF4503B6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 855AB5D51581CE6ABCCD2493 /* STPAPIClient+ApplePay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPAPIClient+ApplePay.swift"; sourceTree = ""; }; + 87A189B89110D3A8EF052425 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; + 88664A990EE5D99076E88987 /* StripeCore+Import.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StripeCore+Import.swift"; sourceTree = ""; }; + 894B7D67F2364A12DD133CF4 /* STPAPIClient+PaymentsCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPAPIClient+PaymentsCore.swift"; sourceTree = ""; }; + 8DBC2BC8C0A60983D0948A11 /* CardBrand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardBrand.swift; sourceTree = ""; }; + 905182496A07DE4F056A3EAA /* STPPaymentMethodFunctionalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPPaymentMethodFunctionalTest.swift; sourceTree = ""; }; + 906F7217DBF694BF42F23458 /* PKPayment+Stripe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PKPayment+Stripe.swift"; sourceTree = ""; }; + 93EAD8979A20E239E16257E4 /* PaymentIntent+API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PaymentIntent+API.swift"; sourceTree = ""; }; + BDAEEEB96953ECBB9C68CF1C /* StripeiOS Tests-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS Tests-Release.xcconfig"; sourceTree = ""; }; + C08710B3CD45DE9DCE2055A3 /* ShippingDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingDetails.swift; sourceTree = ""; }; + C563CA8E1D005A453C762703 /* BillingDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BillingDetails.swift; sourceTree = ""; }; + CB01F67DF47C468825F8FF6A /* Project-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Release.xcconfig"; sourceTree = ""; }; + CC2A354F50C5D563436A0068 /* SetupIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupIntent.swift; sourceTree = ""; }; + D2398CA91B601B5FC7C8FB48 /* PaymentIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentIntent.swift; sourceTree = ""; }; + D81AB96E6201D534220ABB9D /* Token+API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Token+API.swift"; sourceTree = ""; }; + E4AEF10AF7900AF7ED0EDE21 /* STPApplePayContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPApplePayContext.swift; sourceTree = ""; }; + E4B4DF82BA51CC68265B0795 /* STPTelemetryClientTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPTelemetryClientTest.swift; sourceTree = ""; }; + E6E9055B54A5634B636570E3 /* STPAnalyticsClient+ApplePayTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPAnalyticsClient+ApplePayTest.swift"; sourceTree = ""; }; + F4FD028203455E50A637227C /* BillingDetails+ApplePay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BillingDetails+ApplePay.swift"; sourceTree = ""; }; + FBD5C52C679274D830AA5B93 /* STPAnalyticsClient+PaymentsAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPAnalyticsClient+PaymentsAPI.swift"; sourceTree = ""; }; + FF86CF1EFE4AC1A46F055202 /* Address.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Address.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 2A690CB3AEAD3A05D0DF3D47 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4B2DE4109D876CCBDC53C2A3 /* StripeCore.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 92797DEFFA4201115E38DEAD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C0D4B1753F584397DD1AD946 /* XCTest.framework in Frameworks */, + 01AA289840E0AC9229A8CF63 /* StripeApplePay.framework in Frameworks */, + EEF84AC6C1EBF27BF6AAC0BF /* StripeCoreTestUtils.framework in Frameworks */, + D8D613D3A85B81F8C4386E08 /* OHHTTPStubs in Frameworks */, + 526AE8381F232C9FEABFFDCD /* OHHTTPStubsSwift in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0D2F315747B25043D0135CBC /* Analytics */ = { + isa = PBXGroup; + children = ( + 20A70C7D203A80129DBB9304 /* STPAnalyticsClient+Payments.swift */, + FBD5C52C679274D830AA5B93 /* STPAnalyticsClient+PaymentsAPI.swift */, + ); + path = Analytics; + sourceTree = ""; + }; + 18E783E8B2360F08F2FCDEB9 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 87A189B89110D3A8EF052425 /* XCTest.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 1A99D698FD7EE445DA2366ED /* StripeApplePay */ = { + isa = PBXGroup; + children = ( + B7FC50BAFE8B7A10FA7EA283 /* Source */, + 67711D1BC01BA83323EA1F6B /* Info.plist */, + 0C1D3421B1B2BB91FAA66620 /* StripeApplePay.h */, + ); + path = StripeApplePay; + sourceTree = ""; + }; + 2F76FD687FF6507E5B2C1A3F /* ApplePayContext */ = { + isa = PBXGroup; + children = ( + 855AB5D51581CE6ABCCD2493 /* STPAPIClient+ApplePay.swift */, + E4AEF10AF7900AF7ED0EDE21 /* STPApplePayContext.swift */, + 7023208C7E3B3044F553DFDD /* STPApplePayContext+LegacySupport.swift */, + ); + path = ApplePayContext; + sourceTree = ""; + }; + 3581723D3A3FF58EE3E59033 /* BuildConfigurations */ = { + isa = PBXGroup; + children = ( + 40D5ED47331313B6AD8B6F46 /* Project-Debug.xcconfig */, + CB01F67DF47C468825F8FF6A /* Project-Release.xcconfig */, + 425B19195E4B86F77229252D /* StripeiOS Tests-Debug.xcconfig */, + BDAEEEB96953ECBB9C68CF1C /* StripeiOS Tests-Release.xcconfig */, + 49B0926F8F65BC0102B99BF4 /* StripeiOS-Debug.xcconfig */, + 4EE8157F3536C238AC337337 /* StripeiOS-Release.xcconfig */, + ); + name = BuildConfigurations; + path = ../BuildConfigurations; + sourceTree = ""; + }; + 3E8665C11A450F2FB7D2F2E7 /* Models */ = { + isa = PBXGroup; + children = ( + FF86CF1EFE4AC1A46F055202 /* Address.swift */, + C563CA8E1D005A453C762703 /* BillingDetails.swift */, + 8DBC2BC8C0A60983D0948A11 /* CardBrand.swift */, + D2398CA91B601B5FC7C8FB48 /* PaymentIntent.swift */, + 478361D29DF10F2B43F7A1D2 /* PaymentIntentParams.swift */, + 6D1E8A9F849655BAC63DB2FD /* PaymentMethod.swift */, + 468654E242DFE2F85FF422EB /* PaymentMethodParams.swift */, + CC2A354F50C5D563436A0068 /* SetupIntent.swift */, + 3D7CF2B75A797D6B2B5A04B4 /* SetupIntentParams.swift */, + C08710B3CD45DE9DCE2055A3 /* ShippingDetails.swift */, + 594F0478E94E6FA2F551EA0A /* Token.swift */, + ); + path = Models; + sourceTree = ""; + }; + 56479504C3EB58BC9E0C860A = { + isa = PBXGroup; + children = ( + DC6E0D845E48183E2C732ECF /* Project */, + 18E783E8B2360F08F2FCDEB9 /* Frameworks */, + 7C21384B46F40CB3F23DD515 /* Products */, + ); + sourceTree = ""; + }; + 6FAB736FE07EDFA07F922A75 /* API */ = { + isa = PBXGroup; + children = ( + 3E8665C11A450F2FB7D2F2E7 /* Models */, + 93EAD8979A20E239E16257E4 /* PaymentIntent+API.swift */, + 264583DBFFA42C864220D7FF /* PaymentMethod+API.swift */, + 30DDE851AD7450BE70381337 /* SetupIntent+API.swift */, + D81AB96E6201D534220ABB9D /* Token+API.swift */, + ); + path = API; + sourceTree = ""; + }; + 7261423B1B83AF12EE2FEA40 /* PaymentsCore */ = { + isa = PBXGroup; + children = ( + 0B7AA620790EC4257132D738 /* STPTelemetryClientFunctionalTest.swift */, + E4B4DF82BA51CC68265B0795 /* STPTelemetryClientTest.swift */, + ); + path = PaymentsCore; + sourceTree = ""; + }; + 7C21384B46F40CB3F23DD515 /* Products */ = { + isa = PBXGroup; + children = ( + 29BFCC7B0FCEA743A857B51F /* StripeApplePay.framework */, + 229446797CC5FB07DB344D81 /* StripeApplePayTests.xctest */, + 62ECC1AA5583E4104C073B63 /* StripeCore.framework */, + 0FA663325484F2D515613494 /* StripeCoreTestUtils.framework */, + ); + name = Products; + sourceTree = ""; + }; + 823E8B8D6F322BF2BCC4C030 /* PaymentsCore */ = { + isa = PBXGroup; + children = ( + 0D2F315747B25043D0135CBC /* Analytics */, + 6FAB736FE07EDFA07F922A75 /* API */, + 9FE0B80D161B8DB9C70902BF /* Categories */, + ); + path = PaymentsCore; + sourceTree = ""; + }; + 9FE0B80D161B8DB9C70902BF /* Categories */ = { + isa = PBXGroup; + children = ( + 894B7D67F2364A12DD133CF4 /* STPAPIClient+PaymentsCore.swift */, + ); + path = Categories; + sourceTree = ""; + }; + A4676BE706A785EA50A1FEBE /* StripeApplePayTests */ = { + isa = PBXGroup; + children = ( + 7261423B1B83AF12EE2FEA40 /* PaymentsCore */, + 79B089D69DBA1A0BCF4503B6 /* Info.plist */, + E6E9055B54A5634B636570E3 /* STPAnalyticsClient+ApplePayTest.swift */, + 905182496A07DE4F056A3EAA /* STPPaymentMethodFunctionalTest.swift */, + 446F888DB3EC0FC1FE9B9377 /* TelemetryInjectionTest.swift */, + ); + path = StripeApplePayTests; + sourceTree = ""; + }; + B7FC50BAFE8B7A10FA7EA283 /* Source */ = { + isa = PBXGroup; + children = ( + 2F76FD687FF6507E5B2C1A3F /* ApplePayContext */, + E1B6C6692BA02E5DD1AED20B /* Extensions */, + 823E8B8D6F322BF2BCC4C030 /* PaymentsCore */, + 5439AD0D1236F6A21EE93CB3 /* Blocks.swift */, + 88664A990EE5D99076E88987 /* StripeCore+Import.swift */, + ); + path = Source; + sourceTree = ""; + }; + DC6E0D845E48183E2C732ECF /* Project */ = { + isa = PBXGroup; + children = ( + 3581723D3A3FF58EE3E59033 /* BuildConfigurations */, + 1A99D698FD7EE445DA2366ED /* StripeApplePay */, + A4676BE706A785EA50A1FEBE /* StripeApplePayTests */, + ); + name = Project; + sourceTree = ""; + }; + E1B6C6692BA02E5DD1AED20B /* Extensions */ = { + isa = PBXGroup; + children = ( + F4FD028203455E50A637227C /* BillingDetails+ApplePay.swift */, + 133A7483EDB9F1B1CFFDB9ED /* PKContact+Stripe.swift */, + 906F7217DBF694BF42F23458 /* PKPayment+Stripe.swift */, + ); + path = Extensions; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 82FBB6E9E55342EADEEE1865 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 1990346BA0B39ADD47210E18 /* StripeApplePay.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 747C9FE1743C0B4444303569 /* StripeApplePay */ = { + isa = PBXNativeTarget; + buildConfigurationList = 49AD325F0339BB78EE36ABC4 /* Build configuration list for PBXNativeTarget "StripeApplePay" */; + buildPhases = ( + 82FBB6E9E55342EADEEE1865 /* Headers */, + 0FA9CF39340C39235D831C53 /* Sources */, + 52997B8984C3A02398E230F4 /* Resources */, + FF3CB7A978895136380088BB /* Embed Frameworks */, + 2A690CB3AEAD3A05D0DF3D47 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = StripeApplePay; + productName = StripeApplePay; + productReference = 29BFCC7B0FCEA743A857B51F /* StripeApplePay.framework */; + productType = "com.apple.product-type.framework"; + }; + ACAFA21CF224F80EFAEFDC2F /* StripeApplePayTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 48EC33D43FBA14658B0E9567 /* Build configuration list for PBXNativeTarget "StripeApplePayTests" */; + buildPhases = ( + C5D7D85B45B4D8BECDF5B7CD /* Sources */, + 200F36028817645AC1730398 /* Resources */, + F937383B644A63BD5FC4A081 /* Embed Frameworks */, + 92797DEFFA4201115E38DEAD /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 9D2CBE0177D04009899ECFED /* PBXTargetDependency */, + ); + name = StripeApplePayTests; + packageProductDependencies = ( + CB64080D8A41D33BC3DEEAF8 /* OHHTTPStubs */, + 8ED017D12DE81D3ABD013768 /* OHHTTPStubsSwift */, + ); + productName = StripeApplePayTests; + productReference = 229446797CC5FB07DB344D81 /* StripeApplePayTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 95898E5CA0A1871DDFFB66B0 /* Project object */ = { + isa = PBXProject; + attributes = { + TargetAttributes = { + }; + }; + buildConfigurationList = 2182982C71AEF088A6D2AA6A /* Build configuration list for PBXProject "StripeApplePay" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 56479504C3EB58BC9E0C860A; + packageReferences = ( + B45F7C9F63270FAF880F5EEF /* XCRemoteSwiftPackageReference "OHHTTPStubs" */, + ); + productRefGroup = 7C21384B46F40CB3F23DD515 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 747C9FE1743C0B4444303569 /* StripeApplePay */, + ACAFA21CF224F80EFAEFDC2F /* StripeApplePayTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 200F36028817645AC1730398 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 52997B8984C3A02398E230F4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 0FA9CF39340C39235D831C53 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C6A333FBB72EE91849DD6202 /* STPAPIClient+ApplePay.swift in Sources */, + 9F50D23599CD0B1270F8C295 /* STPApplePayContext+LegacySupport.swift in Sources */, + C6552A9ADEA160B9BBAF3A10 /* STPApplePayContext.swift in Sources */, + CECAB0CB1CE4D7BD1EF897F0 /* Blocks.swift in Sources */, + 59C1DB9EF052987BD20B65A3 /* BillingDetails+ApplePay.swift in Sources */, + F42BC784EDBD141C90E74A5F /* PKContact+Stripe.swift in Sources */, + 2D6CD6872A00B6FE0243C3F5 /* PKPayment+Stripe.swift in Sources */, + 4CE57B5BF79D1515F27A18A3 /* Address.swift in Sources */, + 7C7C92AFED77FC4C5D26DC36 /* BillingDetails.swift in Sources */, + 4ADC5356764DC5E3F1C1D51B /* CardBrand.swift in Sources */, + 22E1F50D066A294A316052ED /* PaymentIntent.swift in Sources */, + 463680AADB8CED6E962CD45A /* PaymentIntentParams.swift in Sources */, + E1E7B153B169D0A6363ADD4B /* PaymentMethod.swift in Sources */, + FADFD3B7E3832928E254FAF1 /* PaymentMethodParams.swift in Sources */, + 43682CEAB00A93868FA3188A /* SetupIntent.swift in Sources */, + 234FA38DC46927B23871A75D /* SetupIntentParams.swift in Sources */, + 848843F1145350ABF540D69F /* ShippingDetails.swift in Sources */, + 505A82AADE15E2B2B99C5D32 /* Token.swift in Sources */, + 4AA6A66246DD30798E5CC5F7 /* PaymentIntent+API.swift in Sources */, + 53BA33D02E07DFF1393DF0E4 /* PaymentMethod+API.swift in Sources */, + 09EB0F7E346CB22144515E67 /* SetupIntent+API.swift in Sources */, + A69598FE8095AFA9898297A9 /* Token+API.swift in Sources */, + 59AEBEB856DB8A0118F1D0DE /* STPAnalyticsClient+Payments.swift in Sources */, + BCC7454DB40673493DF940F5 /* STPAnalyticsClient+PaymentsAPI.swift in Sources */, + AB48B0CBFFEFC46E01C76B6C /* STPAPIClient+PaymentsCore.swift in Sources */, + F59D5A3F2C5849CD465062A5 /* StripeCore+Import.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C5D7D85B45B4D8BECDF5B7CD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CB78059C8EB6A072C30A98DA /* STPTelemetryClientFunctionalTest.swift in Sources */, + 62AFEE5A1E32BD84588CA233 /* STPTelemetryClientTest.swift in Sources */, + A79510332C88568637C9E867 /* STPAnalyticsClient+ApplePayTest.swift in Sources */, + 2EBB230815383A8402D71146 /* STPPaymentMethodFunctionalTest.swift in Sources */, + 90286A48FA6C350BDEC227D0 /* TelemetryInjectionTest.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 9D2CBE0177D04009899ECFED /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = StripeApplePay; + target = 747C9FE1743C0B4444303569 /* StripeApplePay */; + targetProxy = 6DAB385DAFF8DCB87110CC7E /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 13B57D9AA89321957F3ACF3A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = BDAEEEB96953ECBB9C68CF1C /* StripeiOS Tests-Release.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeApplePayTests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeApplePayTests; + PRODUCT_NAME = StripeApplePayTests; + SDKROOT = iphoneos; + }; + name = Release; + }; + 15219003E9DE09D774FB72BB /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4EE8157F3536C238AC337337 /* StripeiOS-Release.xcconfig */; + buildSettings = { + INFOPLIST_FILE = StripeApplePay/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "com.stripe.stripe-apple-pay"; + PRODUCT_NAME = StripeApplePay; + SDKROOT = iphoneos; + }; + name = Release; + }; + 6EEB3D72619B3E4C89798C09 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 49B0926F8F65BC0102B99BF4 /* StripeiOS-Debug.xcconfig */; + buildSettings = { + INFOPLIST_FILE = StripeApplePay/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "com.stripe.stripe-apple-pay"; + PRODUCT_NAME = StripeApplePay; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 73AE406DB5197038572F40A7 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = CB01F67DF47C468825F8FF6A /* Project-Release.xcconfig */; + buildSettings = { + }; + name = Release; + }; + 7CA6A9C0F1D4C842935D7CEC /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 40D5ED47331313B6AD8B6F46 /* Project-Debug.xcconfig */; + buildSettings = { + }; + name = Debug; + }; + EB1D00A86E16D71A6153C2A0 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 425B19195E4B86F77229252D /* StripeiOS Tests-Debug.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeApplePayTests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeApplePayTests; + PRODUCT_NAME = StripeApplePayTests; + SDKROOT = iphoneos; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 2182982C71AEF088A6D2AA6A /* Build configuration list for PBXProject "StripeApplePay" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7CA6A9C0F1D4C842935D7CEC /* Debug */, + 73AE406DB5197038572F40A7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 48EC33D43FBA14658B0E9567 /* Build configuration list for PBXNativeTarget "StripeApplePayTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EB1D00A86E16D71A6153C2A0 /* Debug */, + 13B57D9AA89321957F3ACF3A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 49AD325F0339BB78EE36ABC4 /* Build configuration list for PBXNativeTarget "StripeApplePay" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6EEB3D72619B3E4C89798C09 /* Debug */, + 15219003E9DE09D774FB72BB /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + B45F7C9F63270FAF880F5EEF /* XCRemoteSwiftPackageReference "OHHTTPStubs" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/eurias-stripe/OHHTTPStubs"; + requirement = { + branch = master; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 8ED017D12DE81D3ABD013768 /* OHHTTPStubsSwift */ = { + isa = XCSwiftPackageProductDependency; + productName = OHHTTPStubsSwift; + }; + CB64080D8A41D33BC3DEEAF8 /* OHHTTPStubs */ = { + isa = XCSwiftPackageProductDependency; + productName = OHHTTPStubs; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 95898E5CA0A1871DDFFB66B0 /* Project object */; +} diff --git a/StripeApplePay/StripeApplePay.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/StripeApplePay/StripeApplePay.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/StripeApplePay/StripeApplePay.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/StripeApplePay/StripeApplePay.xcodeproj/xcshareddata/xcschemes/StripeApplePay.xcscheme b/StripeApplePay/StripeApplePay.xcodeproj/xcshareddata/xcschemes/StripeApplePay.xcscheme new file mode 100644 index 00000000..64ccb761 --- /dev/null +++ b/StripeApplePay/StripeApplePay.xcodeproj/xcshareddata/xcschemes/StripeApplePay.xcscheme @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StripeApplePay/StripeApplePay/Info.plist b/StripeApplePay/StripeApplePay/Info.plist new file mode 100644 index 00000000..cd4a496b --- /dev/null +++ b/StripeApplePay/StripeApplePay/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(CURRENT_PROJECT_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/StripeApplePay/StripeApplePay/Source/ApplePayContext/STPAPIClient+ApplePay.swift b/StripeApplePay/StripeApplePay/Source/ApplePayContext/STPAPIClient+ApplePay.swift new file mode 100644 index 00000000..d00717db --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/ApplePayContext/STPAPIClient+ApplePay.swift @@ -0,0 +1,44 @@ +// +// STPAPIClient+ApplePay.swift +// StripeApplePay +// +// Created by Jack Flintermann on 12/19/14. +// Copyright © 2014 Stripe, Inc. All rights reserved. +// + +import Foundation +import PassKit +@_spi(STP) import StripeCore + +/// STPAPIClient extensions to create Stripe Tokens, Sources, or PaymentMethods from Apple Pay PKPayment objects. +extension STPAPIClient { + /// Converts Stripe errors into the appropriate Apple Pay error, for use in `PKPaymentAuthorizationResult`. + /// If the error can be fixed by the customer within the Apple Pay sheet, we return an NSError that can be displayed in the Apple Pay sheet. + /// Otherwise, the original error is returned, resulting in the Apple Pay sheet being dismissed. You should display the error message to the customer afterwards. + /// Currently, we convert billing address related errors into a PKPaymentError that helpfully points to the billing address field in the Apple Pay sheet. + /// Note that Apple Pay should prevent most card errors (e.g. invalid CVC, expired cards) when you add a card to the wallet. + /// - Parameter stripeError: An error from the Stripe SDK. + @objc(pkPaymentErrorForStripeError:) + public class func pkPaymentError(forStripeError stripeError: Error?) -> Error? { + guard let stripeError = stripeError else { + return nil + } + + if (stripeError as NSError).domain == STPError.stripeDomain + && ((stripeError as NSError).userInfo[STPError.cardErrorCodeKey] as? String + == STPCardErrorCode.incorrectZip.rawValue) + { + var userInfo = (stripeError as NSError).userInfo + var errorCode: PKPaymentError.Code = .unknownError + errorCode = .billingContactInvalidError + userInfo[PKPaymentErrorKey.postalAddressUserInfoKey.rawValue] = + CNPostalAddressPostalCodeKey + return NSError( + domain: STPError.stripeDomain, + code: errorCode.rawValue, + userInfo: userInfo + ) + } + return stripeError + } +} diff --git a/StripeApplePay/StripeApplePay/Source/ApplePayContext/STPApplePayContext+LegacySupport.swift b/StripeApplePay/StripeApplePay/Source/ApplePayContext/STPApplePayContext+LegacySupport.swift new file mode 100644 index 00000000..c71eb499 --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/ApplePayContext/STPApplePayContext+LegacySupport.swift @@ -0,0 +1,55 @@ +// +// STPApplePayContext+LegacySupport.swift +// StripeApplePay +// +// Created by David Estes on 1/25/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +import PassKit + +/// Internal Apple Pay class. Do not use. +/// :nodoc: +@objc @_spi(STP) public class _stpinternal_ApplePayContextDidCreatePaymentMethodStorage: NSObject { + @_spi(STP) public weak var delegate: _stpinternal_STPApplePayContextDelegateBase? + @_spi(STP) public var context: STPApplePayContext + @_spi(STP) public var paymentMethod: StripeAPI.PaymentMethod + @_spi(STP) public var paymentInformation: PKPayment + @_spi(STP) public var completion: STPIntentClientSecretCompletionBlock + + @_spi(STP) public init( + delegate: _stpinternal_STPApplePayContextDelegateBase, + context: STPApplePayContext, + paymentMethod: StripeAPI.PaymentMethod, + paymentInformation: PKPayment, + completion: @escaping STPIntentClientSecretCompletionBlock + ) { + self.delegate = delegate + self.context = context + self.paymentMethod = paymentMethod + self.paymentInformation = paymentInformation + self.completion = completion + } +} + +/// Internal Apple Pay class. Do not use. +/// :nodoc: +@objc @_spi(STP) public class _stpinternal_ApplePayContextDidCompleteStorage: NSObject { + @_spi(STP) public weak var delegate: _stpinternal_STPApplePayContextDelegateBase? + @_spi(STP) public var context: STPApplePayContext + @_spi(STP) public var status: STPApplePayContext.PaymentStatus + @_spi(STP) public var error: Error? + + @_spi(STP) public init( + delegate: _stpinternal_STPApplePayContextDelegateBase, + context: STPApplePayContext, + status: STPApplePayContext.PaymentStatus, + error: Error? + ) { + self.delegate = delegate + self.context = context + self.status = status + self.error = error + } +} diff --git a/StripeApplePay/StripeApplePay/Source/ApplePayContext/STPApplePayContext.swift b/StripeApplePay/StripeApplePay/Source/ApplePayContext/STPApplePayContext.swift new file mode 100644 index 00000000..a9825dc4 --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/ApplePayContext/STPApplePayContext.swift @@ -0,0 +1,763 @@ +// +// STPApplePayContext.swift +// StripeApplePay +// +// Created by Yuki Tokuhiro on 2/20/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import Foundation +import ObjectiveC +import PassKit +@_spi(STP) import StripeCore + +/// :nodoc: +@objc public protocol _stpinternal_STPApplePayContextDelegateBase: NSObjectProtocol { + /// Called when the user selects a new shipping method. The delegate should determine + /// shipping costs based on the shipping method and either the shipping address supplied in the original + /// PKPaymentRequest or the address fragment provided by the last call to paymentAuthorizationController: + /// didSelectShippingContact:completion:. + /// You must invoke the completion block with an updated array of PKPaymentSummaryItem objects. + @objc(applePayContext:didSelectShippingMethod:handler:) + optional func applePayContext( + _ context: STPApplePayContext, + didSelect shippingMethod: PKShippingMethod, + handler: @escaping (_ update: PKPaymentRequestShippingMethodUpdate) -> Void + ) + + /// Called when the user has selected a new shipping address. You should inspect the + /// address and must invoke the completion block with an updated array of PKPaymentSummaryItem objects. + /// @note To maintain privacy, the shipping information is anonymized. For example, in the United States it only includes the city, state, and zip code. This provides enough information to calculate shipping costs, without revealing sensitive information until the user actually approves the purchase. + /// Receive full shipping information in the paymentInformation passed to `applePayContext:didCreatePaymentMethod:paymentInformation:completion:` + @objc optional func applePayContext( + _ context: STPApplePayContext, + didSelectShippingContact contact: PKContact, + handler: @escaping (_ update: PKPaymentRequestShippingContactUpdate) -> Void + ) + + /// Optionally configure additional information on your PKPaymentAuthorizationResult. + /// This closure will be called after the PaymentIntent or SetupIntent is confirmed, but before + /// the Apple Pay sheet has been closed. + /// In your implementation, you can configure the PKPaymentAuthorizationResult to add custom fields, such as `orderDetails`. + /// See https://developer.apple.com/documentation/passkit/pkpaymentauthorizationresult for all configuration options. + /// This method is optional. If you implement this, you must call the handler block with the PKPaymentAuthorizationResult on the main queue. + /// WARNING: If you do not call the completion handler, your app will hang until the Apple Pay sheet times out. + @objc optional func applePayContext( + _ context: STPApplePayContext, + willCompleteWithResult authorizationResult: PKPaymentAuthorizationResult, + handler: @escaping (_ authorizationResult: PKPaymentAuthorizationResult) -> Void + ) +} + +/// Implement the required methods of this delegate to supply a PaymentIntent to ApplePayContext and be notified of the completion of the Apple Pay payment. +/// You may also implement the optional delegate methods to handle shipping methods and shipping address changes e.g. to verify you can ship to the address, or update the payment amount. +public protocol ApplePayContextDelegate: _stpinternal_STPApplePayContextDelegateBase { + /// Called after the customer has authorized Apple Pay. Implement this method to call the completion block with the client secret of a PaymentIntent or SetupIntent. + /// - Parameters: + /// - paymentMethod: The PaymentMethod that represents the customer's Apple Pay payment method. + /// If you create the PaymentIntent with confirmation_method=manual, pass `paymentMethod.id` as the payment_method and confirm=true. Otherwise, you can ignore this parameter. + /// - paymentInformation: The underlying PKPayment created by Apple Pay. + /// If you create the PaymentIntent with confirmation_method=manual, you can collect shipping information using its `shippingContact` and `shippingMethod` properties. + /// - completion: Call this with the PaymentIntent or SetupIntent client secret, or the error that occurred creating the PaymentIntent or SetupIntent. + func applePayContext( + _ context: STPApplePayContext, + didCreatePaymentMethod paymentMethod: StripeAPI.PaymentMethod, + paymentInformation: PKPayment, + completion: @escaping STPIntentClientSecretCompletionBlock + ) + + /// Called after the Apple Pay sheet is dismissed with the result of the payment. + /// Your implementation could stop a spinner and display a receipt view or error to the customer, for example. + /// - Parameters: + /// - status: The status of the payment + /// - error: The error that occurred, if any. + func applePayContext( + _ context: STPApplePayContext, + didCompleteWith status: STPApplePayContext.PaymentStatus, + error: Error? + ) +} + +/// A helper class that implements Apple Pay. +/// Usage looks like this: +/// 1. Initialize this class with a PKPaymentRequest describing the payment request (amount, line items, required shipping info, etc) +/// 2. Call presentApplePayOnViewController:completion: to present the Apple Pay sheet and begin the payment process +/// 3 (optional): If you need to respond to the user changing their shipping information/shipping method, implement the optional delegate methods +/// 4. When the user taps 'Buy', this class uses the PaymentIntent that you supply in the applePayContext:didCreatePaymentMethod:completion: delegate method to complete the payment +/// 5. After payment completes/errors and the sheet is dismissed, this class informs you in the applePayContext:didCompleteWithStatus: delegate method +/// - seealso: https://stripe.com/docs/apple-pay#native for a full guide +/// - seealso: ApplePayExampleViewController for an example +@objc(STPApplePayContext) +public class STPApplePayContext: NSObject, PKPaymentAuthorizationControllerDelegate { + + /// A special string that can be passed in place of a intent client secret to force showing success and return a PaymentState of `success`. + /// - Note: ⚠️ If provided, the SDK performs no action to complete the payment or setup - it doesn't confirm a PaymentIntent or SetupIntent or handle next actions. + /// You should only use this if your integration can't create a PaymentIntent or SetupIntent. It is your responsibility to ensure that you only pass this value if the payment or set up is successful. + @_spi(STP) public static let COMPLETE_WITHOUT_CONFIRMING_INTENT = "COMPLETE_WITHOUT_CONFIRMING_INTENT" + + /// Initializes this class. + /// @note This may return nil if the request is invalid e.g. the user is restricted by parental controls, or can't make payments on any of the request's supported networks + /// @note If using Swift, using ApplePayContextDelegate is recommended over STPApplePayContextDelegate. + /// - Parameters: + /// - paymentRequest: The payment request to use with Apple Pay. + /// - delegate: The delegate. + @objc(initWithPaymentRequest:delegate:) + public required init?( + paymentRequest: PKPaymentRequest, + delegate: _stpinternal_STPApplePayContextDelegateBase? + ) { + STPAnalyticsClient.sharedClient.addClass(toProductUsageIfNecessary: STPApplePayContext.self) + if !StripeAPI.canSubmitPaymentRequest(paymentRequest) { + return nil + } + + authorizationController = PKPaymentAuthorizationController(paymentRequest: paymentRequest) + if authorizationController == nil { + return nil + } + + self.delegate = delegate + + super.init() + authorizationController?.delegate = self + } + + private var presentationWindow: UIWindow? + + /// Presents the Apple Pay sheet from the key window, starting the payment process. + /// @note This method should only be called once; create a new instance of STPApplePayContext every time you present Apple Pay. + /// - Parameters: + /// - completion: Called after the Apple Pay sheet is presented + @available(iOSApplicationExtension, unavailable) + @available(macCatalystApplicationExtension, unavailable) + @objc(presentApplePayWithCompletion:) + public func presentApplePay(completion: STPVoidBlock? = nil) { + let window = UIApplication.shared.windows.first { $0.isKeyWindow } + self.presentApplePay(from: window, completion: completion) + } + + /// Presents the Apple Pay sheet from the specified window, starting the payment process. + /// @note This method should only be called once; create a new instance of STPApplePayContext every time you present Apple Pay. + /// - Parameters: + /// - window: The UIWindow to host the Apple Pay sheet + /// - completion: Called after the Apple Pay sheet is presented + @objc(presentApplePayFromWindow:completion:) + public func presentApplePay(from window: UIWindow?, completion: STPVoidBlock? = nil) { + presentationWindow = window + guard !didPresentApplePay, let applePayController = self.authorizationController else { + assert( + false, + "This method should only be called once; create a new instance of STPApplePayContext every time you present Apple Pay." + ) + return + } + didPresentApplePay = true + + // This instance (and the associated Objective-C bridge object, if any) must live so + // that the apple pay sheet is dismissed; until then, the app is effectively frozen. + objc_setAssociatedObject( + applePayController, + UnsafeRawPointer(&kApplePayContextAssociatedObjectKey), + self, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + + applePayController.present { (_) in + DispatchQueue.main.async { + completion?() + } + } + } + + /// Presents the Apple Pay sheet from the specified view controller, starting the payment process. + /// @note This method should only be called once; create a new instance of STPApplePayContext every time you present Apple Pay. + /// @deprecated A presenting UIViewController is no longer needed. Use presentApplePay(completion:) instead. + /// - Parameters: + /// - viewController: The UIViewController instance to present the Apple Pay sheet on + /// - completion: Called after the Apple Pay sheet is presented + @objc(presentApplePayOnViewController:completion:) + @available( + *, + deprecated, + message: "Use `presentApplePay(completion:)` instead.", + renamed: "presentApplePay(completion:)" + ) + public func presentApplePay( + on viewController: UIViewController, + completion: STPVoidBlock? = nil + ) { + let window = viewController.viewIfLoaded?.window + presentApplePay(from: window, completion: completion) + } + + /// The API Client to use to make requests. + /// Defaults to `STPAPIClient.shared` + @objc public var apiClient: STPAPIClient = STPAPIClient.shared + /// ApplePayContext passes this to the /confirm endpoint for PaymentIntents if it did not collect shipping details itself. + /// :nodoc: + @_spi(STP) public var shippingDetails: StripeAPI.ShippingDetails? + private weak var delegate: _stpinternal_STPApplePayContextDelegateBase? + @objc var authorizationController: PKPaymentAuthorizationController? + @_spi(STP) public var returnUrl: String? + + @_spi(STP) @frozen public enum ConfirmType { + case client + case server + /// The merchant backend used the special string instead of a intent client secret, so we completed the payment without confirming an intent. + case none + } + /// Tracks where the call to confirm the PaymentIntent or SetupIntent happened. + @_spi(STP) public var confirmType: ConfirmType? + // Internal state + private var paymentState: PaymentState = .notStarted + private var error: Error? + /// YES if the flow cancelled or timed out. This toggles which delegate method (didFinish or didAuthorize) calls our didComplete delegate method + private var didCancelOrTimeoutWhilePending = false + private var didPresentApplePay = false + + /// :nodoc: + @objc public override func responds(to aSelector: Selector!) -> Bool { + // ApplePayContextDelegate exposes methods that map 1:1 to PKPaymentAuthorizationControllerDelegate methods + // We want this method to return YES for these methods IFF they are implemented by our delegate + + // Why not simply implement the methods to call their equivalents on self.delegate? + // The implementation of e.g. didSelectShippingMethod must call the completion block. + // If the user does not implement e.g. didSelectShippingMethod, we don't know the correct PKPaymentSummaryItems to pass to the completion block + // (it may have changed since we were initialized due to another delegate method) + if let equivalentDelegateSelector = _delegateToAppleDelegateMapping()[aSelector] { + return delegate?.responds(to: equivalentDelegateSelector) ?? false + } else { + return super.responds(to: aSelector) + } + } + + // MARK: - Private Helper + func _delegateToAppleDelegateMapping() -> [Selector: Selector] { + // We need this type to disambiguate from the other PKACDelegate.didSelect:handler: method + // HACK: This signature changed in Xcode 14, we need to check the compiler version to choose the right signature. + #if compiler(>=5.7) + typealias pkDidSelectShippingMethodSignature = + (any PKPaymentAuthorizationControllerDelegate) -> ( + ( + PKPaymentAuthorizationController, + PKShippingMethod, + @escaping (PKPaymentRequestShippingMethodUpdate) -> Void + ) -> Void + )? + #else + typealias pkDidSelectShippingMethodSignature = ( + (PKPaymentAuthorizationControllerDelegate) -> ( + PKPaymentAuthorizationController, PKShippingMethod, + @escaping (PKPaymentRequestShippingMethodUpdate) -> Void + ) -> Void + )? + #endif + + let pk_didSelectShippingMethod = #selector( + (PKPaymentAuthorizationControllerDelegate.paymentAuthorizationController( + _: + didSelectShippingMethod: + handler: + )) as pkDidSelectShippingMethodSignature) + let stp_didSelectShippingMethod = #selector( + _stpinternal_STPApplePayContextDelegateBase.applePayContext(_:didSelect:handler:)) + let pk_didSelectShippingContact = #selector( + PKPaymentAuthorizationControllerDelegate.paymentAuthorizationController( + _: + didSelectShippingContact: + handler: + )) + let stp_didSelectShippingContact = #selector( + _stpinternal_STPApplePayContextDelegateBase.applePayContext( + _: + didSelectShippingContact: + handler: + )) + + return [ + pk_didSelectShippingMethod: stp_didSelectShippingMethod, + pk_didSelectShippingContact: stp_didSelectShippingContact, + ] + } + + func _end() { + if let authorizationController = authorizationController { + objc_setAssociatedObject( + authorizationController, + UnsafeRawPointer(&kApplePayContextAssociatedObjectKey), + nil, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + authorizationController = nil + delegate = nil + } + + func _shippingDetails(from payment: PKPayment) -> StripeAPI.ShippingDetails? { + guard let address = payment.shippingContact?.postalAddress, + let name = payment.shippingContact?.name + else { + // The shipping address street and name are required parameters for a valid .ShippingDetails + // Return `shippingDetails` instead + return shippingDetails + } + + let addressParams = StripeAPI.ShippingDetails.Address( + city: address.city, + country: address.isoCountryCode, + line1: address.street, + postalCode: address.postalCode, + state: address.state + ) + + let formatter = PersonNameComponentsFormatter() + formatter.style = .long + let shippingParams = StripeAPI.ShippingDetails( + address: addressParams, + name: formatter.string(from: name), + phone: payment.shippingContact?.phoneNumber?.stringValue + ) + + return shippingParams + } + + // MARK: - PKPaymentAuthorizationControllerDelegate + /// :nodoc: + @objc(paymentAuthorizationController:didAuthorizePayment:handler:) + public func paymentAuthorizationController( + _ controller: PKPaymentAuthorizationController, + didAuthorizePayment payment: PKPayment, + handler completion: @escaping (PKPaymentAuthorizationResult) -> Void + ) { + // Some observations (on iOS 12 simulator): + // - The docs say localizedDescription can be shown in the Apple Pay sheet, but I haven't seen this. + // - If you call the completion block w/ a status of .failure and an error, the user is prompted to try again. + + _completePayment(with: payment) { status, error in + let errors = [STPAPIClient.pkPaymentError(forStripeError: error)].compactMap({ $0 }) + let result = PKPaymentAuthorizationResult(status: status, errors: errors) + if self.delegate?.responds( + to: #selector( + _stpinternal_STPApplePayContextDelegateBase.applePayContext( + _: + willCompleteWithResult: + handler: + )) + ) + ?? false + { + self.delegate?.applePayContext?( + self, + willCompleteWithResult: result, + handler: { newResult in + completion(newResult) + } + ) + } else { + completion(result) + } + } + } + + /// :nodoc: + @objc + public func paymentAuthorizationController( + _ controller: PKPaymentAuthorizationController, + didSelectShippingMethod shippingMethod: PKShippingMethod, + handler completion: @escaping (PKPaymentRequestShippingMethodUpdate) -> Void + ) { + if delegate?.responds( + to: #selector( + _stpinternal_STPApplePayContextDelegateBase.applePayContext(_:didSelect:handler:)) + ) + ?? false + { + delegate?.applePayContext?(self, didSelect: shippingMethod, handler: completion) + } + } + + /// :nodoc: + @objc + public func paymentAuthorizationController( + _ controller: PKPaymentAuthorizationController, + didSelectShippingContact contact: PKContact, + handler completion: @escaping (PKPaymentRequestShippingContactUpdate) -> Void + ) { + if delegate?.responds( + to: #selector( + _stpinternal_STPApplePayContextDelegateBase.applePayContext( + _: + didSelectShippingContact: + handler: + )) + ) ?? false { + delegate?.applePayContext?(self, didSelectShippingContact: contact, handler: completion) + } + } + + /// :nodoc: + @objc public func paymentAuthorizationControllerDidFinish( + _ controller: PKPaymentAuthorizationController + ) { + // Note: If you don't dismiss the VC, the UI disappears, the VC blocks interaction, and this method gets called again. + // Note: This method is called if the user cancels (taps outside the sheet) or Apple Pay times out (empirically 30 seconds) + switch paymentState { + case .notStarted: + controller.dismiss { + stpDispatchToMainThreadIfNecessary { + self.callDidCompleteDelegate(status: .userCancellation, error: nil) + self._end() + } + } + case .pending: + // We can't cancel a pending payment. If we dismiss the VC now, the customer might interact with the app and miss seeing the result of the payment - risking a double charge, chargeback, etc. + // Instead, we'll dismiss and notify our delegate when the payment finishes. + didCancelOrTimeoutWhilePending = true + case .error: + controller.dismiss { + stpDispatchToMainThreadIfNecessary { + self.callDidCompleteDelegate(status: .error, error: self.error) + self._end() + } + } + case .success: + controller.dismiss { + stpDispatchToMainThreadIfNecessary { + self.callDidCompleteDelegate(status: .success, error: nil) + self._end() + } + } + } + } + + /// :nodoc: + @objc public func presentationWindow( + for controller: PKPaymentAuthorizationController + ) -> UIWindow? { + return presentationWindow + } + + // MARK: - Helpers + func _completePayment( + with payment: PKPayment, + completion: @escaping (PKPaymentAuthorizationStatus, Error?) -> Void + ) { + // Helper to handle annoying logic around "Do I call completion block or dismiss + call delegate?" + let handleFinalState: ((PaymentState, Error?) -> Void) = { state, error in + switch state { + case .error: + self.paymentState = .error + self.error = error + if self.didCancelOrTimeoutWhilePending { + self.authorizationController?.dismiss { + DispatchQueue.main.async { + self.callDidCompleteDelegate(status: .error, error: self.error) + self._end() + } + } + } else { + completion(PKPaymentAuthorizationStatus.failure, error) + } + return + case .success: + self.paymentState = .success + if self.didCancelOrTimeoutWhilePending { + self.authorizationController?.dismiss { + DispatchQueue.main.async { + self.callDidCompleteDelegate(status: .success, error: nil) + self._end() + } + } + } else { + completion(PKPaymentAuthorizationStatus.success, nil) + } + return + case .pending, .notStarted: + assert(false, "Invalid final state") + return + } + } + + // 1. Create PaymentMethod + StripeAPI.PaymentMethod.create(apiClient: apiClient, payment: payment) { result in + guard let paymentMethod = try? result.get(), self.authorizationController != nil else { + if case .failure(let error) = result { + handleFinalState(.error, error) + } else { + handleFinalState(.error, nil) + } + return + } + + let paymentMethodCompletion: STPIntentClientSecretCompletionBlock = { + clientSecret, + intentCreationError in + guard let clientSecret = clientSecret, intentCreationError == nil, + self.authorizationController != nil + else { + handleFinalState(.error, intentCreationError) + return + } + + guard clientSecret != STPApplePayContext.COMPLETE_WITHOUT_CONFIRMING_INTENT else { + self.confirmType = STPApplePayContext.ConfirmType.none + handleFinalState(.success, nil) + return + } + + if StripeAPI.SetupIntentConfirmParams.isClientSecretValid(clientSecret) { + // 3a. Retrieve the SetupIntent and see if we need to confirm it client-side + StripeAPI.SetupIntent.get(apiClient: self.apiClient, clientSecret: clientSecret) + { + result in + guard let setupIntent = try? result.get(), + self.authorizationController != nil + else { + if case .failure(let error) = result { + handleFinalState(.error, error) + } else { + handleFinalState(.error, nil) + } + return + } + + switch setupIntent.status { + case .requiresConfirmation, .requiresAction, .requiresPaymentMethod: + self.confirmType = .client + // 4a. Confirm the SetupIntent + self.paymentState = .pending // After this point, we can't cancel + var confirmParams = StripeAPI.SetupIntentConfirmParams( + clientSecret: clientSecret + ) + confirmParams.paymentMethod = paymentMethod.id + confirmParams.useStripeSdk = true + confirmParams.returnUrl = self.returnUrl + + StripeAPI.SetupIntent.confirm( + apiClient: self.apiClient, + params: confirmParams + ) { + result in + guard let setupIntent = try? result.get(), + self.authorizationController != nil, + setupIntent.status == .succeeded + else { + if case .failure(let error) = result { + handleFinalState(.error, error) + } else { + handleFinalState(.error, nil) + } + return + } + + handleFinalState(.success, nil) + } + case .succeeded: + self.confirmType = .server + handleFinalState(.success, nil) + case .canceled, .processing, .unknown, .unparsable, .none: + handleFinalState( + .error, + Self.makeUnknownError( + message: + "The SetupIntent is in an unexpected state: \(setupIntent.status!)" + ) + ) + } + } + } else { + let paymentIntentClientSecret = clientSecret + // 3b. Retrieve the PaymentIntent and see if we need to confirm it client-side + StripeAPI.PaymentIntent.get( + apiClient: self.apiClient, + clientSecret: paymentIntentClientSecret + ) { result in + guard let paymentIntent = try? result.get(), + self.authorizationController != nil + else { + if case .failure(let error) = result { + handleFinalState(.error, error) + } else { + handleFinalState(.error, nil) + } + return + } + + if paymentIntent.confirmationMethod == .automatic + && (paymentIntent.status == .requiresPaymentMethod + || paymentIntent.status == .requiresConfirmation) + { + self.confirmType = .client + // 4b. Confirm the PaymentIntent + + var paymentIntentParams = StripeAPI.PaymentIntentParams( + clientSecret: paymentIntentClientSecret + ) + paymentIntentParams.paymentMethod = paymentMethod.id + paymentIntentParams.useStripeSdk = true + // If a merchant attaches shipping to the PI on their server, the /confirm endpoint will error if we update shipping with a “requires secret key” error message. + // To accommodate this, don't attach if our shipping is the same as the PI's shipping + if paymentIntent.shipping != self._shippingDetails(from: payment) { + paymentIntentParams.shipping = self._shippingDetails(from: payment) + } + + self.paymentState = .pending // After this point, we can't cancel + + // We don't use PaymentHandler because we can't handle next actions as-is - we'd need to dismiss the Apple Pay VC. + StripeAPI.PaymentIntent.confirm( + apiClient: self.apiClient, + params: paymentIntentParams + ) { + result in + guard let postConfirmPI = try? result.get(), + postConfirmPI.status == .succeeded + || postConfirmPI.status == .requiresCapture + else { + if case .failure(let error) = result { + handleFinalState(.error, error) + } else { + handleFinalState(.error, nil) + } + return + } + handleFinalState(.success, nil) + } + } else if paymentIntent.status == .succeeded + || paymentIntent.status == .requiresCapture + { + self.confirmType = .server + handleFinalState(.success, nil) + } else { + let unknownError = Self.makeUnknownError( + message: + "The PaymentIntent is in an unexpected state. If you pass confirmation_method = manual when creating the PaymentIntent, also pass confirm = true. If server-side confirmation fails, double check you are passing the error back to the client." + ) + handleFinalState(.error, unknownError) + } + } + } + } + // 2. Fetch PaymentIntent/SetupIntent client secret from delegate + let legacyDelegateSelector = NSSelectorFromString( + "applePayContext:didCreatePaymentMethod:paymentInformation:completion:" + ) + if let delegate = self.delegate { + if let delegate = delegate as? ApplePayContextDelegate { + delegate.applePayContext( + self, + didCreatePaymentMethod: paymentMethod, + paymentInformation: payment, + completion: paymentMethodCompletion + ) + } else if delegate.responds(to: legacyDelegateSelector), + let helperClass = NSClassFromString("STPApplePayContextLegacyHelper") + { + let legacyStorage = _stpinternal_ApplePayContextDidCreatePaymentMethodStorage( + delegate: delegate, + context: self, + paymentMethod: paymentMethod, + paymentInformation: payment, + completion: paymentMethodCompletion + ) + helperClass.performDidCreatePaymentMethod(legacyStorage) + } else { + assertionFailure( + "An STPApplePayContext's delegate must conform to ApplePayContextDelegate or STPApplePayContextDelegate." + ) + } + } + } + } + + func callDidCompleteDelegate(status: PaymentStatus, error: Error?) { + if let delegate = self.delegate { + if let delegate = delegate as? ApplePayContextDelegate { + delegate.applePayContext(self, didCompleteWith: status, error: error) + } else if delegate.responds( + to: NSSelectorFromString("applePayContext:didCompleteWithStatus:error:") + ) { + if let helperClass = NSClassFromString("STPApplePayContextLegacyHelper") { + let legacyStorage = _stpinternal_ApplePayContextDidCompleteStorage( + delegate: delegate, + context: self, + status: status, + error: error + ) + helperClass.performDidComplete(legacyStorage) + } + } else { + assertionFailure( + "An STPApplePayContext's delegate must conform to ApplePayContextDelegate or STPApplePayContextDelegate." + ) + } + } + + } + + @_spi(STP) public static func makeUnknownError(message: String) -> NSError { + let userInfo = [ + NSLocalizedDescriptionKey: NSError.stp_unexpectedErrorMessage(), + STPError.errorMessageKey: message, + ] + return NSError( + domain: STPError.STPPaymentHandlerErrorDomain, + code: STPPaymentHandlerErrorCodeIntentStatusErrorCode, + userInfo: userInfo + ) + } + + /// This is STPPaymentHandlerErrorCode.intentStatusErrorCode.rawValue, which we don't want to vend from this framework. + fileprivate static let STPPaymentHandlerErrorCodeIntentStatusErrorCode = 3 + + enum PaymentState { + case notStarted + case pending + case error + case success + } + + /// An enum representing the status of a payment requested from the user. + @frozen public enum PaymentStatus { + /// The payment succeeded. + case success + /// The payment failed due to an unforeseen error, such as the user's Internet connection being offline. + case error + /// The user cancelled the payment (for example, by hitting "cancel" in the Apple Pay dialog). + case userCancellation + } +} + +/// :nodoc: +@_spi(STP) extension STPApplePayContext: STPAnalyticsProtocol { + @_spi(STP) public static var stp_analyticsIdentifier: String { + return "STPApplePayContext" + } +} + +/// :nodoc: +class ModernApplePayContext: STPAnalyticsProtocol { + @_spi(STP) public static var stp_analyticsIdentifier: String { + return "ModernApplePayContext" + } +} + +private var kSTPApplePayContextAssociatedObjectKey = 0 +enum STPPaymentState: Int { + case notStarted + case pending + case error + case success +} + +private class _stpinternal_STPApplePayContextLegacyHelper: NSObject { + @objc class func performDidCreatePaymentMethod( + _ storage: _stpinternal_ApplePayContextDidCreatePaymentMethodStorage + ) { + // Placeholder to allow this to be called on AnyObject + } + @objc class func performDidComplete(_ storage: _stpinternal_ApplePayContextDidCompleteStorage) { + // Placeholder to allow this to be called on AnyObject + } +} + +private var kApplePayContextAssociatedObjectKey = 0 diff --git a/StripeApplePay/StripeApplePay/Source/Blocks.swift b/StripeApplePay/StripeApplePay/Source/Blocks.swift new file mode 100644 index 00000000..e434acbc --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/Blocks.swift @@ -0,0 +1,18 @@ +// +// Blocks.swift +// StripeApplePay +// +// Created by David Estes on 1/6/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// An empty block, called with no arguments, returning nothing. +public typealias STPVoidBlock = () -> Void + +/// A block to be run with the client secret of a PaymentIntent or SetupIntent. +/// - Parameters: +/// - clientSecret: The client secret of the PaymentIntent or SetupIntent. See https://stripe.com/docs/api/payment_intents/object#payment_intent_object-client_secret +/// - error: The error that occurred when creating the Intent, or nil if none occurred. +public typealias STPIntentClientSecretCompletionBlock = (String?, Error?) -> Void diff --git a/StripeApplePay/StripeApplePay/Source/Extensions/BillingDetails+ApplePay.swift b/StripeApplePay/StripeApplePay/Source/Extensions/BillingDetails+ApplePay.swift new file mode 100644 index 00000000..8e1ed5c2 --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/Extensions/BillingDetails+ApplePay.swift @@ -0,0 +1,101 @@ +// +// BillingDetails+ApplePay.swift +// StripeApplePay +// +// Created by David Estes on 8/9/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import PassKit +@_spi(STP) import StripeCore + +extension StripeContact { + /// Initializes a new Contact with data from an PassKit contact. + /// - Parameter contact: The PassKit contact you want to populate the Contact from. + /// - Returns: A new Contact with data copied from the passed in contact. + init( + pkContact contact: PKContact + ) { + let nameComponents = contact.name + if let nameComponents = nameComponents { + givenName = stringIfHasContentsElseNil(nameComponents.givenName) + familyName = stringIfHasContentsElseNil(nameComponents.familyName) + + name = stringIfHasContentsElseNil( + PersonNameComponentsFormatter.localizedString(from: nameComponents, style: .default) + ) + } + email = stringIfHasContentsElseNil(contact.emailAddress) + if let phoneNumber = contact.phoneNumber { + phone = sanitizedPhoneString(from: phoneNumber) + } else { + phone = nil + } + setAddressFromCNPostal(contact.postalAddress) + } + + private func sanitizedPhoneString(from phoneNumber: CNPhoneNumber) -> String? { + return stringIfHasContentsElseNil( + STPNumericStringValidator.sanitizedNumericString(for: phoneNumber.stringValue) + ) + } + + private mutating func setAddressFromCNPostal(_ address: CNPostalAddress?) { + line1 = stringIfHasContentsElseNil(address?.street) + city = stringIfHasContentsElseNil(address?.city) + state = stringIfHasContentsElseNil(address?.state) + postalCode = stringIfHasContentsElseNil(address?.postalCode) + country = stringIfHasContentsElseNil(address?.isoCountryCode.uppercased()) + } +} + +extension StripeAPI.BillingDetails { + init?( + from payment: PKPayment + ) { + var billingDetails: StripeAPI.BillingDetails? + if payment.billingContact != nil { + billingDetails = StripeAPI.BillingDetails() + if let billingContact = payment.billingContact { + let billingAddress = StripeContact(pkContact: billingContact) + billingDetails?.name = billingAddress.name + billingDetails?.email = billingAddress.email + billingDetails?.phone = billingAddress.phone + if billingContact.postalAddress != nil { + billingDetails?.address = StripeAPI.BillingDetails.Address( + contact: billingAddress + ) + } + } + } + + // The phone number and email in the "Contact" panel in the Apple Pay dialog go into the shippingContact, + // not the billingContact. To work around this, we should fill the billingDetails' email and phone + // number from the shippingDetails. + if payment.shippingContact != nil { + var shippingAddress: StripeContact? + if let shippingContact = payment.shippingContact { + shippingAddress = StripeContact(pkContact: shippingContact) + } + if billingDetails?.email == nil && shippingAddress?.email != nil { + if billingDetails == nil { + billingDetails = StripeAPI.BillingDetails() + } + billingDetails?.email = shippingAddress?.email + } + if billingDetails?.phone == nil && shippingAddress?.phone != nil { + if billingDetails == nil { + billingDetails = StripeAPI.BillingDetails() + } + billingDetails?.phone = shippingAddress?.phone + } + } + + if let billingDetails = billingDetails { + self = billingDetails + } else { + return nil + } + } +} diff --git a/StripeApplePay/StripeApplePay/Source/Extensions/PKContact+Stripe.swift b/StripeApplePay/StripeApplePay/Source/Extensions/PKContact+Stripe.swift new file mode 100644 index 00000000..03db9af0 --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/Extensions/PKContact+Stripe.swift @@ -0,0 +1,26 @@ +// +// PKContact+Stripe.swift +// StripeApplePay +// +// Created by David Estes on 11/16/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import PassKit + +extension PKContact { + @_spi(STP) public var addressParams: [AnyHashable: Any] { + var params: [AnyHashable: Any] = [:] + let stpAddress = StripeContact(pkContact: self) + + params["name"] = stpAddress.name + params["address_line1"] = stpAddress.line1 + params["address_city"] = stpAddress.city + params["address_state"] = stpAddress.state + params["address_zip"] = stpAddress.postalCode + params["address_country"] = stpAddress.country + + return params + } +} diff --git a/StripeApplePay/StripeApplePay/Source/Extensions/PKPayment+Stripe.swift b/StripeApplePay/StripeApplePay/Source/Extensions/PKPayment+Stripe.swift new file mode 100644 index 00000000..2d85c930 --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/Extensions/PKPayment+Stripe.swift @@ -0,0 +1,32 @@ +// +// PKPayment+Stripe.swift +// StripeApplePay +// +// Created by Ben Guo on 7/2/15. +// Copyright © 2015 Stripe, Inc. All rights reserved. +// + +import PassKit + +extension PKPayment { + /// Returns true if the instance is a payment from the simulator. + @_spi(STP) public func stp_isSimulated() -> Bool { + return token.transactionIdentifier == "Simulated Identifier" + } + + /// Returns a fake transaction identifier with the expected ~-separated format. + @_spi(STP) public class func stp_testTransactionIdentifier() -> String { + var uuid = UUID().uuidString + uuid = uuid.replacingOccurrences(of: "~", with: "") + + // Simulated cards don't have enough info yet. For now, use a fake Visa number + let number = "4242424242424242" + + // Without the original PKPaymentRequest, we'll need to use fake data here. + let amount = NSDecimalNumber(string: "0") + let cents = NSNumber(value: amount.multiplying(byPowerOf10: 2).intValue).stringValue + let currency = "USD" + let identifier = ["ApplePayStubs", number, cents, currency, uuid].joined(separator: "~") + return identifier + } +} diff --git a/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/Address.swift b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/Address.swift new file mode 100644 index 00000000..dc80d6dd --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/Address.swift @@ -0,0 +1,43 @@ +// +// Address.swift +// StripeApplePay +// +// Created by David Estes on 8/9/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +/// An internal struct for handling contacts. This is not encodable/decodable for use with the Stripe API. +struct StripeContact { + /// The user's full name (e.g. "Jane Doe") + public var name: String? + + /// The first line of the user's street address (e.g. "123 Fake St") + public var line1: String? + + /// The apartment, floor number, etc of the user's street address (e.g. "Apartment 1A") + public var line2: String? + + /// The city in which the user resides (e.g. "San Francisco") + public var city: String? + + /// The state in which the user resides (e.g. "CA") + public var state: String? + + /// The postal code in which the user resides (e.g. "90210") + public var postalCode: String? + + /// The ISO country code of the address (e.g. "US") + public var country: String? + + /// The phone number of the address (e.g. "8885551212") + public var phone: String? + + /// The email of the address (e.g. "jane@doe.com") + public var email: String? + + internal var givenName: String? + internal var familyName: String? +} diff --git a/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/BillingDetails.swift b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/BillingDetails.swift new file mode 100644 index 00000000..1871ec79 --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/BillingDetails.swift @@ -0,0 +1,67 @@ +// +// BillingDetails.swift +// StripeApplePay +// +// Created by David Estes on 7/15/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + /// Billing information associated with a `STPPaymentMethod` that may be used or required by particular types of payment methods. + /// - seealso: https://stripe.com/docs/api/payment_methods/object#payment_method_object-billing_details + public struct BillingDetails: UnknownFieldsCodable { + /// Billing address. + public var address: Address? + + /// The billing address, a property sent in a PaymentMethod response + public struct Address: UnknownFieldsCodable { + /// The first line of the user's street address (e.g. "123 Fake St") + public var line1: String? + + /// The apartment, floor number, etc of the user's street address (e.g. "Apartment 1A") + public var line2: String? + + /// The city in which the user resides (e.g. "San Francisco") + public var city: String? + + /// The state in which the user resides (e.g. "CA") + public var state: String? + + /// The postal code in which the user resides (e.g. "90210") + public var postalCode: String? + + /// The ISO country code of the address (e.g. "US") + public var country: String? + + public var _additionalParametersStorage: NonEncodableParameters? + public var _allResponseFieldsStorage: NonEncodableParameters? + } + + /// Email address. + public var email: String? + /// Full name. + public var name: String? + /// Billing phone number (including extension). + public var phone: String? + + public var _additionalParametersStorage: NonEncodableParameters? + public var _allResponseFieldsStorage: NonEncodableParameters? + } + +} + +extension StripeAPI.BillingDetails.Address { + init( + contact: StripeContact + ) { + self.city = contact.city + self.country = contact.country + self.line1 = contact.line1 + self.line2 = contact.line2 + self.postalCode = contact.postalCode + self.state = contact.state + } +} diff --git a/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/CardBrand.swift b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/CardBrand.swift new file mode 100644 index 00000000..0ca26890 --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/CardBrand.swift @@ -0,0 +1,33 @@ +// +// CardBrand.swift +// StripeApplePay +// +// Created by David Estes on 4/14/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +extension StripeAPI { + /// The various card brands to which a payment card can belong. + enum CardBrand: String, SafeEnumCodable { + /// Visa card + case visa = "Visa" + /// American Express card + case amex = "American Express" + /// Mastercard card + case mastercard = "MasterCard" + /// Discover card + case discover = "Discover" + /// JCB card + case JCB = "JCB" + /// Diners Club card + case dinersClub = "Diners Club" + /// UnionPay card + case unionPay = "UnionPay" + /// An unknown card brand type + case unknown = "Unknown" + /// An unparsable card brand + case unparsable + } +} diff --git a/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/PaymentIntent.swift b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/PaymentIntent.swift new file mode 100644 index 00000000..24cbfcb2 --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/PaymentIntent.swift @@ -0,0 +1,149 @@ +// +// PaymentIntent.swift +// StripeApplePay +// +// Created by David Estes on 6/29/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + @_spi(STP) public struct PaymentIntent: UnknownFieldsDecodable { + // TODO: (MOBILESDK-468) Add modern bindings for more PaymentIntent fields + /// The Stripe ID of the PaymentIntent. + @_spi(STP) public let id: String + + /// The client secret used to fetch this PaymentIntent + @_spi(STP) public let clientSecret: String + + /// Amount intended to be collected by this PaymentIntent. + @_spi(STP) public let amount: Int + + /// If status is `.canceled`, when the PaymentIntent was canceled. + @_spi(STP) public let canceledAt: Date? + + /// Capture method of this PaymentIntent + @_spi(STP) public let captureMethod: CaptureMethod + + /// Confirmation method of this PaymentIntent + @_spi(STP) public let confirmationMethod: ConfirmationMethod + + /// When the PaymentIntent was created. + @_spi(STP) public let created: Date + + /// The currency associated with the PaymentIntent. + @_spi(STP) public let currency: String + + /// The `description` field of the PaymentIntent. + /// An arbitrary string attached to the object. Often useful for displaying to users. + @_spi(STP) public let stripeDescription: String? + + /// Whether or not this PaymentIntent was created in livemode. + @_spi(STP) public let livemode: Bool + + /// Email address that the receipt for the resulting payment will be sent to. + @_spi(STP) public let receiptEmail: String? + + /// The Stripe ID of the Source used in this PaymentIntent. + @_spi(STP) public let sourceId: String? + + /// The Stripe ID of the PaymentMethod used in this PaymentIntent. + @_spi(STP) public let paymentMethodId: String? + + /// Status of the PaymentIntent + @_spi(STP) public let status: Status + + /// Shipping information for this PaymentIntent. + @_spi(STP) public let shipping: ShippingDetails? + + /// Status types for a PaymentIntent + @frozen @_spi(STP) public enum Status: String, SafeEnumCodable { + /// Unknown status + case unknown + /// This PaymentIntent requires a PaymentMethod or Source + case requiresPaymentMethod = "requires_payment_method" + /// This PaymentIntent requires a Source + /// Deprecated: Use STPPaymentIntentStatusRequiresPaymentMethod instead. + @available( + *, + deprecated, + message: "Use STPPaymentIntentStatus.requiresPaymentMethod instead", + renamed: "STPPaymentIntentStatus.requiresPaymentMethod" + ) + case requiresSource = "requires_source" + /// This PaymentIntent needs to be confirmed + case requiresConfirmation = "requires_confirmation" + /// The selected PaymentMethod or Source requires additional authentication steps. + /// Additional actions found via `next_action` + case requiresAction = "requires_action" + /// The selected Source requires additional authentication steps. + /// Additional actions found via `next_source_action` + /// Deprecated: Use STPPaymentIntentStatusRequiresAction instead. + @available( + *, + deprecated, + message: "Use STPPaymentIntentStatus.requiresAction instead", + renamed: "STPPaymentIntentStatus.requiresAction" + ) + case requiresSourceAction = "requires_source_action" + /// Stripe is processing this PaymentIntent + case processing + /// The payment has succeeded + case succeeded + /// Indicates the payment must be captured, for STPPaymentIntentCaptureMethodManual + case requiresCapture = "requires_capture" + /// This PaymentIntent was canceled and cannot be changed. + case canceled + + case unparsable + // TODO: This is @frozen because of a bug in the Xcode 12.2 Swift compiler. + // Remove @frozen after Xcode 12.2 support has been dropped. + } + + @frozen @_spi(STP) public enum ConfirmationMethod: String, SafeEnumCodable { + /// Unknown confirmation method + case unknown + /// Confirmed via publishable key + case manual + /// Confirmed via secret key + case automatic + + case unparsable + // TODO: This is @frozen because of a bug in the Xcode 12.2 Swift compiler. + // Remove @frozen after Xcode 12.2 support has been dropped. + } + + @frozen @_spi(STP) public enum CaptureMethod: String, SafeEnumCodable { + /// Unknown capture method + case unknown + /// The PaymentIntent will be automatically captured + case automatic + /// The PaymentIntent must be manually captured once it has the status + /// `.requiresCapture` + case manual + + case unparsable + // TODO: This is @frozen because of a bug in the Xcode 12.2 Swift compiler. + // Remove @frozen after Xcode 12.2 support has been dropped. + } + + @_spi(STP) public var _allResponseFieldsStorage: NonEncodableParameters? + } +} + +extension StripeAPI.PaymentIntent { + /// Helper function for extracting PaymentIntent id from the Client Secret. + /// This avoids having to pass around both the id and the secret. + /// - Parameter clientSecret: The `client_secret` from the PaymentIntent + internal static func id(fromClientSecret clientSecret: String) -> String? { + // see parseClientSecret from stripe-js-v3 + let components = clientSecret.components(separatedBy: "_secret_") + if components.count >= 2 && components[0].hasPrefix("pi_") { + return components[0] + } else { + return nil + } + } +} diff --git a/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/PaymentIntentParams.swift b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/PaymentIntentParams.swift new file mode 100644 index 00000000..5dfe11a8 --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/PaymentIntentParams.swift @@ -0,0 +1,101 @@ +// +// PaymentIntentParams.swift +// StripeApplePay +// +// Created by David Estes on 6/29/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + @_spi(STP) public struct PaymentIntentParams: UnknownFieldsEncodable { + /// The client secret of the PaymentIntent. Required + @_spi(STP) public let clientSecret: String + + @_spi(STP) public init( + clientSecret: String + ) { + self.clientSecret = clientSecret + } + + @_spi(STP) public var id: String? { + return PaymentIntent.id(fromClientSecret: clientSecret) + } + + /// Provide a supported `PaymentMethodParams` object, and Stripe will create a + /// PaymentMethod during PaymentIntent confirmation. + /// @note alternative to `paymentMethodId` + @_spi(STP) public var paymentMethodData: PaymentMethodParams? + + /// Provide an already created PaymentMethod's id, and it will be used to confirm the PaymentIntent. + /// @note alternative to `paymentMethodParams` + @_spi(STP) public var paymentMethod: String? + + /// Provide an already created Source's id, and it will be used to confirm the PaymentIntent. + @_spi(STP) public var sourceId: String? + + /// Email address that the receipt for the resulting payment will be sent to. + @_spi(STP) public var receiptEmail: String? + + /// `@YES` to save this PaymentIntent’s PaymentMethod or Source to the associated Customer, + /// if the PaymentMethod/Source is not already attached. + /// This should be a boolean NSNumber, so that it can be `nil` + @_spi(STP) public var savePaymentMethod: Bool? + + /// The URL to redirect your customer back to after they authenticate or cancel + /// their payment on the payment method’s app or site. + /// This should probably be a URL that opens your iOS app. + @_spi(STP) public var returnURL: String? + + /// When provided, this property indicates how you intend to use the payment method that your customer provides after the current payment completes. + /// If applicable, additional authentication may be performed to comply with regional legislation or network rules required to enable the usage of the same payment method for additional payments. + @_spi(STP) public var setupFutureUsage: SetupFutureUsage? + + /// A boolean number to indicate whether you intend to use the Stripe SDK's functionality to handle any PaymentIntent next actions. + /// If set to false, STPPaymentIntent.nextAction will only ever contain a redirect url that can be opened in a webview or mobile browser. + /// When set to true, the nextAction may contain information that the Stripe SDK can use to perform native authentication within your + /// app. + @_spi(STP) public var useStripeSdk: Bool? + + /// Shipping information. + @_spi(STP) public var shipping: ShippingDetails? + + /// Indicates how you intend to use the payment method that your customer provides after the current payment completes. + /// If applicable, additional authentication may be performed to comply with regional legislation or network rules required to enable the usage of the same payment method for additional payments. + /// - seealso: https://stripe.com/docs/api/payment_intents/object#payment_intent_object-setup_future_usage + @frozen @_spi(STP) public enum SetupFutureUsage: String, SafeEnumCodable { + /// Unknown value. Update your SDK, or use `allResponseFields` for custom handling. + case unknown + /// No value was provided. + case none + /// Indicates you intend to only reuse the payment method when the customer is in your checkout flow. + case onSession + /// Indicates you intend to reuse the payment method when the customer may or may not be in your checkout flow. + case offSession + + case unparsable + // TODO: This is @frozen because of a bug in the Xcode 12.2 Swift compiler. + // Remove @frozen after Xcode 12.2 support has been dropped. + } + + @_spi(STP) public var _additionalParametersStorage: NonEncodableParameters? + } +} + +extension StripeAPI.PaymentIntentParams { + static internal let isClientSecretValidRegex: NSRegularExpression = try! NSRegularExpression( + pattern: "^pi_[^_]+_secret_[^_]+$", + options: [] + ) + + @_spi(STP) public static func isClientSecretValid(_ clientSecret: String) -> Bool { + return + (isClientSecretValidRegex.numberOfMatches( + in: clientSecret, + options: .anchored, + range: NSRange(location: 0, length: clientSecret.count) + )) == 1 + } +} diff --git a/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/PaymentMethod.swift b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/PaymentMethod.swift new file mode 100644 index 00000000..235e47e2 --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/PaymentMethod.swift @@ -0,0 +1,176 @@ +// +// PaymentMethod.swift +// StripeApplePay +// +// Created by David Estes on 6/29/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + /// PaymentMethod objects represent your customer's payment instruments. They can be used with PaymentIntents to collect payments. + /// - seealso: https://stripe.com/docs/api/payment_methods + public struct PaymentMethod: UnknownFieldsDecodable { + /// The Stripe ID of the PaymentMethod. + public let id: String + + /// Time at which the object was created. Measured in seconds since the Unix epoch. + public var created: Date? + /// `YES` if the object exists in live mode or the value `NO` if the object exists in test mode. + public var livemode = false + + /// The type of the PaymentMethod. The corresponding, similarly named property contains additional information specific to the PaymentMethod type. + /// e.g. if the type is `Card`, the `card` property is also populated. + public var type: PaymentMethodType? + + /// The type of the PaymentMethod. + @frozen public enum PaymentMethodType: String, SafeEnumCodable { + /// A card payment method. + case card + /// An unknown type. + case unknown + case unparsable + // TODO: This is @frozen because of a bug in the Xcode 12.2 Swift compiler. + // Remove @frozen after Xcode 12.2 support has been dropped. + } + + /// Billing information associated with the PaymentMethod that may be used or required by particular types of payment methods. + public var billingDetails: BillingDetails? + /// The ID of the Customer to which this PaymentMethod is saved. Nil when the PaymentMethod has not been saved to a Customer. + public var customerId: String? + /// If this is a card PaymentMethod (ie `self.type == .card`), this contains additional details. + public var card: Card? + + /// :nodoc: + public struct Card: UnknownFieldsDecodable { + public var _allResponseFieldsStorage: NonEncodableParameters? + /// The issuer of the card. + public private(set) var brand: Brand = .unknown + + /// The various card brands to which a payment card can belong. + @frozen public enum Brand: String, SafeEnumCodable { + /// Visa + case visa + /// American Express + case amex + /// Mastercard + case mastercard + /// Discover + case discover + /// JCB + case jcb + /// Diners Club + case diners + /// UnionPay + case unionpay + /// An unknown card brand + case unknown + case unparsable + // TODO: This is @frozen because of a bug in the Xcode 12.2 Swift compiler. + // Remove @frozen after Xcode 12.2 support has been dropped. + } + + /// Two-letter ISO code representing the country of the card. + public private(set) var country: String? + /// Two-digit number representing the card’s expiration month. + public private(set) var expMonth: Int + /// Four-digit number representing the card’s expiration year. + public private(set) var expYear: Int + /// Card funding type. Can be credit, debit, prepaid, or unknown. + public private(set) var funding: String? + /// The last four digits of the card. + public private(set) var last4: String? + /// Uniquely identifies this particular card number. You can use this attribute to check whether two customers who’ve signed up with you are using the same card number, for example. + public private(set) var fingerprint: String? + + /// Contains information about card networks that can be used to process the payment. + public private(set) var networks: Networks? + + /// Contains details on how this Card maybe be used for 3D Secure authentication. + public private(set) var threeDSecureUsage: ThreeDSecureUsage? + + /// If this Card is part of a Card Wallet, this contains the details of the Card Wallet. + public private(set) var wallet: Wallet? + + public struct Networks: UnknownFieldsDecodable { + public var _allResponseFieldsStorage: NonEncodableParameters? + + /// All available networks for the card. + public private(set) var available: [String]? + /// The preferred network for the card if one exists. + public private(set) var preferred: String? + } + + /// Contains details on how a `Card` may be used for 3D Secure authentication. + public struct ThreeDSecureUsage: UnknownFieldsDecodable { + public var _allResponseFieldsStorage: NonEncodableParameters? + + /// `true` if 3D Secure is supported on this card. + public private(set) var supported = false + } + + public struct Wallet: UnknownFieldsDecodable { + public var _allResponseFieldsStorage: NonEncodableParameters? + /// The type of the Card Wallet. A matching property is populated if the type is `.masterpass` or `.visaCheckout` containing additional information specific to the Card Wallet type. + public private(set) var type: WalletType = .unknown + /// Contains additional Masterpass information, if the type of the Card Wallet is `STPPaymentMethodCardWalletTypeMasterpass` + public private(set) var masterpass: Masterpass? + /// Contains additional Visa Checkout information, if the type of the Card Wallet is `STPPaymentMethodCardWalletTypeVisaCheckout` + public private(set) var visaCheckout: VisaCheckout? + + /// The type of Card Wallet. + @frozen public enum WalletType: String, SafeEnumCodable { + /// Amex Express Checkout + case amexExpressCheckout = "amex_express_checkout" + /// Apple Pay + case applePay = "apple_pay" + /// Google Pay + case googlePay = "google_pay" + /// Masterpass + case masterpass = "masterpass" + /// Samsung Pay + case samsungPay = "samsung_pay" + /// Visa Checkout + case visaCheckout = "visa_checkout" + /// An unknown Card Wallet type. + case unknown = "unknown" + case unparsable + // TODO: This is @frozen because of a bug in the Xcode 12.2 Swift compiler. + // Remove @frozen after Xcode 12.2 support has been dropped. + } + + public struct Masterpass: UnknownFieldsDecodable { + public var _allResponseFieldsStorage: NonEncodableParameters? + + /// Owner’s verified email. Values are verified or provided by the payment method directly (and if supported) at the time of authorization or settlement. + public private(set) var email: String? + /// Owner’s verified email. Values are verified or provided by the payment method directly (and if supported) at the time of authorization or settlement. + public private(set) var name: String? + /// Owner’s verified billing address. Values are verified or provided by the payment method directly (and if supported) at the time of authorization or settlement. + public private(set) var billingAddress: BillingDetails.Address? + /// Owner’s verified shipping address. Values are verified or provided by the payment method directly (and if supported) at the time of authorization or settlement. + public private(set) var shippingAddress: BillingDetails.Address? + } + + /// A Visa Checkout Card Wallet + /// - seealso: https://stripe.com/docs/visa-checkout + public struct VisaCheckout: UnknownFieldsDecodable { + /// Owner’s verified email. Values are verified or provided by the payment method directly (and if supported) at the time of authorization or settlement. + public private(set) var email: String? + /// Owner’s verified email. Values are verified or provided by the payment method directly (and if supported) at the time of authorization or settlement. + public private(set) var name: String? + /// Owner’s verified billing address. Values are verified or provided by the payment method directly (and if supported) at the time of authorization or settlement. + public private(set) var billingAddress: BillingDetails.Address? + /// Owner’s verified shipping address. Values are verified or provided by the payment method directly (and if supported) at the time of authorization or settlement. + public private(set) var shippingAddress: BillingDetails.Address? + + public var _allResponseFieldsStorage: NonEncodableParameters? + } + } + } + + public var _allResponseFieldsStorage: NonEncodableParameters? + } +} diff --git a/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/PaymentMethodParams.swift b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/PaymentMethodParams.swift new file mode 100644 index 00000000..c790a9a7 --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/PaymentMethodParams.swift @@ -0,0 +1,73 @@ +// +// PaymentMethodParams.swift +// StripeApplePay +// +// Created by David Estes on 6/29/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + /// An object representing parameters used to create a PaymentMethod object. + /// - seealso: https://stripe.com/docs/api/payment_methods/create + @_spi(STP) public struct PaymentMethodParams: UnknownFieldsEncodable { + /// The type of payment method. + /// The associated property will contain additional information (e.g. `type == .card` means `card` should also be populated). + @_spi(STP) public var type: PaymentMethod.PaymentMethodType + + /// If this is a card PaymentMethod, this contains the user’s card details. + @_spi(STP) public var card: Card? + + /// Billing information associated with the PaymentMethod that may be used or required by particular types of payment methods. + @_spi(STP) public var billingDetails: BillingDetails? + + /// Used internally to identify the version of the SDK sending the request + @_spi(STP) public var paymentUserAgent: String? = { + return PaymentsSDKVariant.paymentUserAgent + }() + + /// :nodoc: + @_spi(STP) public struct Card: UnknownFieldsEncodable { + /// The card number, as a string without any separators. Ex. "4242424242424242" + @_spi(STP) public var number: String? + /// Number representing the card's expiration month. Ex. 1 + @_spi(STP) public var expMonth: Int? + /// Two- or four-digit number representing the card's expiration year. + @_spi(STP) public var expYear: Int? + /// For backwards compatibility, you can alternatively set this as a Stripe token (e.g., for Apple Pay) + @_spi(STP) public var token: String? + /// Card security code. It is highly recommended to always include this value. + @_spi(STP) public var cvc: String? + + /// The last 4 digits of the card's number, if it's been set, otherwise nil. + @_spi(STP) public var last4: String? { + if number != nil && (number?.count ?? 0) >= 4 { + return (number as NSString?)?.substring(from: (number?.count ?? 0) - 4) + } else { + return nil + } + } + @_spi(STP) public var _additionalParametersStorage: NonEncodableParameters? + } + + @_spi(STP) public var _additionalParametersStorage: NonEncodableParameters? + } +} + +extension StripeAPI.PaymentMethodParams.Card: CustomStringConvertible, CustomDebugStringConvertible, + CustomLeafReflectable +{ + @_spi(STP) public var debugDescription: String { + return description + } + + @_spi(STP) public var description: String { + return "Card \(last4 ?? "")" + } + + @_spi(STP) public var customMirror: Mirror { + return Mirror(reflecting: self.description) + } +} diff --git a/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/SetupIntent.swift b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/SetupIntent.swift new file mode 100644 index 00000000..a8369479 --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/SetupIntent.swift @@ -0,0 +1,58 @@ +// +// SetupIntent.swift +// StripeApplePay +// +// Created by David Estes on 6/29/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + @_spi(STP) public struct SetupIntent: UnknownFieldsDecodable { + @_spi(STP) public let id: String + // TODO: (MOBILESDK-467) Add modern bindings for more SetupIntent fields + @_spi(STP) public let status: SetupIntentStatus? + + /// Status types for an STPSetupIntent + @frozen @_spi(STP) public enum SetupIntentStatus: String, SafeEnumCodable { + /// Unknown status + case unknown + /// This SetupIntent requires a PaymentMethod + case requiresPaymentMethod = "requires_payment_method" + /// This SetupIntent needs to be confirmed + case requiresConfirmation = "requires_confirmation" + /// The selected PaymentMethod requires additional authentication steps. + /// Additional actions found via the `nextAction` property of `STPSetupIntent` + case requiresAction = "requires_action" + /// Stripe is processing this SetupIntent + case processing + /// The SetupIntent has succeeded + case succeeded + /// This SetupIntent was canceled and cannot be changed. + case canceled + + case unparsable + // TODO: This is @frozen because of a bug in the Xcode 12.2 Swift compiler. + // Remove @frozen after Xcode 12.2 support has been dropped. + } + + @_spi(STP) public var _allResponseFieldsStorage: NonEncodableParameters? + } +} + +extension StripeAPI.SetupIntent { + /// Helper function for extracting SetupIntent id from the Client Secret. + /// This avoids having to pass around both the id and the secret. + /// - Parameter clientSecret: The `client_secret` from the SetupIntent + internal static func id(fromClientSecret clientSecret: String) -> String? { + // see parseClientSecret from stripe-js-v3 + let components = clientSecret.components(separatedBy: "_secret_") + if components.count >= 2 && components[0].hasPrefix("seti_") { + return components[0] + } else { + return nil + } + } +} diff --git a/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/SetupIntentParams.swift b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/SetupIntentParams.swift new file mode 100644 index 00000000..72d750b1 --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/SetupIntentParams.swift @@ -0,0 +1,66 @@ +// +// SetupIntentParams.swift +// StripeApplePay +// +// Created by David Estes on 6/29/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + @_spi(STP) public struct SetupIntentConfirmParams: UnknownFieldsEncodable { + /// Generated by by Editor -> Refactor -> Generate Memberwise Initializer + @_spi(STP) public init( + clientSecret: String, + paymentMethodData: StripeAPI.PaymentMethodParams? = nil, + paymentMethod: String? = nil, + returnUrl: String? = nil, + useStripeSdk: Bool? = nil, + _additionalParametersStorage: NonEncodableParameters? = nil + ) { + self.clientSecret = clientSecret + self.paymentMethodData = paymentMethodData + self.paymentMethod = paymentMethod + self.returnUrl = returnUrl + self.useStripeSdk = useStripeSdk + self._additionalParametersStorage = _additionalParametersStorage + } + + /// The client secret of the SetupIntent. Required. + @_spi(STP) public let clientSecret: String + /// Provide a supported `PaymentMethodParams` object, and Stripe will create a + /// PaymentMethod during PaymentIntent confirmation. + /// @note alternative to `paymentMethodId` + @_spi(STP) public var paymentMethodData: PaymentMethodParams? + /// Provide an already created PaymentMethod's id, and it will be used to confirm the SetupIntent. + /// @note alternative to `paymentMethodParams` + @_spi(STP) public var paymentMethod: String? + /// The URL to redirect your customer back to after they authenticate or cancel + /// their payment on the payment method’s app or site. + /// This should probably be a URL that opens your iOS app. + @_spi(STP) public var returnUrl: String? + /// A boolean number to indicate whether you intend to use the Stripe SDK's functionality to handle any SetupIntent next actions. + /// If set to false, SetupIntent.nextAction will only ever contain a redirect url that can be opened in a webview or mobile browser. + /// When set to true, the nextAction may contain information that the Stripe SDK can use to perform native authentication within your + /// app. + @_spi(STP) public var useStripeSdk: Bool? + + @_spi(STP) public var _additionalParametersStorage: NonEncodableParameters? + + // MARK: - Utilities + static private let regex = try! NSRegularExpression( + pattern: "^seti_[^_]+_secret_[^_]+$", + options: [] + ) + @_spi(STP) public static func isClientSecretValid(_ clientSecret: String) -> Bool { + return + (regex.numberOfMatches( + in: clientSecret, + options: .anchored, + range: NSRange(location: 0, length: clientSecret.count) + )) == 1 + } + } +} diff --git a/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/ShippingDetails.swift b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/ShippingDetails.swift new file mode 100644 index 00000000..cec8a3bb --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/ShippingDetails.swift @@ -0,0 +1,93 @@ +// +// ShippingDetails.swift +// StripeApplePay +// +// Created by Yuki Tokuhiro on 8/4/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + @_spi(STP) public struct ShippingDetails: UnknownFieldsCodable, Equatable { + @_spi(STP) public init( + address: StripeAPI.ShippingDetails.Address, + name: String, + carrier: String? = nil, + phone: String? = nil, + trackingNumber: String? = nil, + _allResponseFieldsStorage: NonEncodableParameters? = nil, + _additionalParametersStorage: NonEncodableParameters? = nil + ) { + self.address = address + self.name = name + self.carrier = carrier + self.phone = phone + self.trackingNumber = trackingNumber + self._allResponseFieldsStorage = _allResponseFieldsStorage + self._additionalParametersStorage = _additionalParametersStorage + } + + /// Shipping address. + @_spi(STP) public var address: Address + + /// Recipient name. + @_spi(STP) public var name: String + + /// The delivery service that shipped a physical product, such as Fedex, UPS, USPS, etc. + @_spi(STP) public var carrier: String? + + /// Recipient phone (including extension). + @_spi(STP) public var phone: String? + + /// The tracking number for a physical product, obtained from the delivery service. If multiple tracking numbers were generated for this purchase, please separate them with commas. + @_spi(STP) public var trackingNumber: String? + + @_spi(STP) public var _allResponseFieldsStorage: NonEncodableParameters? + @_spi(STP) public var _additionalParametersStorage: NonEncodableParameters? + + @_spi(STP) public struct Address: UnknownFieldsCodable, Equatable { + @_spi(STP) public init( + city: String? = nil, + country: String? = nil, + line1: String, + line2: String? = nil, + postalCode: String? = nil, + state: String? = nil, + _allResponseFieldsStorage: NonEncodableParameters? = nil, + _additionalParametersStorage: NonEncodableParameters? = nil + ) { + self.city = city + self.country = country + self.line1 = line1 + self.line2 = line2 + self.postalCode = postalCode + self.state = state + self._allResponseFieldsStorage = _allResponseFieldsStorage + self._additionalParametersStorage = _additionalParametersStorage + } + + /// City/District/Suburb/Town/Village. + @_spi(STP) public var city: String? + + /// Two-letter country code (ISO 3166-1 alpha-2). + @_spi(STP) public var country: String? + + /// Address line 1 (Street address/PO Box/Company name). + @_spi(STP) public var line1: String + + /// Address line 2 (Apartment/Suite/Unit/Building). + @_spi(STP) public var line2: String? + + /// ZIP or postal code. + @_spi(STP) public var postalCode: String? + + /// State/County/Province/Region. + @_spi(STP) public var state: String? + + @_spi(STP) public var _allResponseFieldsStorage: NonEncodableParameters? + @_spi(STP) public var _additionalParametersStorage: NonEncodableParameters? + } + } +} diff --git a/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/Token.swift b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/Token.swift new file mode 100644 index 00000000..c3a3bba2 --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Models/Token.swift @@ -0,0 +1,130 @@ +// +// Token.swift +// StripeApplePay +// +// Created by David Estes on 7/14/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import PassKit +@_spi(STP) import StripeCore + +extension StripeAPI { + // Internal note: @_spi(StripeApplePayTokenization) is intended for limited public use. See https://docs.google.com/document/d/1Z9bTUBvDDufoqTaQeI3A0Cxdsoj_D0IkxdWX-GB-RTQ + @_spi(StripeApplePayTokenization) public struct Token: UnknownFieldsDecodable { + public var _allResponseFieldsStorage: NonEncodableParameters? + + /// The value of the token. You can store this value on your server and use it to make charges and customers. + /// - seealso: https://stripe.com/docs/payments/charges-api + public let id: String + /// Whether or not this token was created in livemode. Will be YES if you used your Live Publishable Key, and NO if you used your Test Publishable Key. + var livemode: Bool + /// The type of this token. + var type: TokenType + + /// Possible Token types + enum TokenType: String, SafeEnumCodable { + /// Account token type + case account + /// Bank account token type + case bankAccount = "bank_account" + /// Card token type + case card + /// PII token type + case PII = "pii" + /// CVC update token type + case cvcUpdate = "cvc_update" + case unparsable + } + + /// The credit card details that were used to create the token. Will only be set if the token was created via a credit card or Apple Pay, otherwise it will be + /// nil. + var card: Card? + // /// The bank account details that were used to create the token. Will only be set if the token was created with a bank account, otherwise it will be nil. + // Not yet implemented. + // var bankAccount: BankAccount? + /// When the token was created. + var created: Date? + + struct Card: UnknownFieldsDecodable { + var _allResponseFieldsStorage: NonEncodableParameters? + + /// The last 4 digits of the card. + var last4: String + /// For cards made with Apple Pay, this refers to the last 4 digits of the + /// "Device Account Number" for the tokenized card. For regular cards, it will + /// be nil. + var dynamicLast4: String? + /// Whether or not the card originated from Apple Pay. + var isApplePayCard: Bool { + return (allResponseFields["tokenization_method"] as? String) == "apple_pay" + } + /// The card's expiration month. 1-indexed (i.e. 1 == January) + var expMonth: Int + /// The card's expiration year. + var expYear: Int + /// The cardholder's name. + var name: String? + + /// City/District/Suburb/Town/Village. + var addressCity: String? + + /// Billing address country, if provided when creating card. + var addressCountry: String? + + /// Address line 1 (Street address/PO Box/Company name). + var addressLine1: String? + + /// If address_line1 was provided, results of the check. + var addressLine1Check: AddressCheck? + + /// Results of an address check. + enum AddressCheck: String, SafeEnumCodable { + case pass + case fail + case unavailable + case unchecked + case unparsable + } + + /// Address line 2 (Apartment/Suite/Unit/Building). + var addressLine2: String? + + /// State/County/Province/Region. + var addressState: String? + + /// ZIP or postal code. + var addressZip: String? + + /// If address_zip was provided, results of the check. + var addressZipCheck: AddressCheck? + + /// The issuer of the card. + var brand: CardBrand = .unknown + + /// The funding source for the card (credit, debit, prepaid, or other) + var funding: FundingType = .unknown + + /// The various funding sources for a payment card. + enum FundingType: String, SafeEnumCodable { + /// Debit card funding + case debit + /// Credit card funding + case credit + /// Prepaid card funding + case prepaid + /// An other or unknown type of funding source. + case unknown + case unparsable + } + + /// Two-letter ISO code representing the issuing country of the card. + var country: String? + /// This is only applicable when tokenizing debit cards to issue payouts to managed + /// accounts. You should not set it otherwise. The card can then be used as a + /// transfer destination for funds in this currency. + var currency: String? + } + } +} diff --git a/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/PaymentIntent+API.swift b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/PaymentIntent+API.swift new file mode 100644 index 00000000..76c49cf6 --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/PaymentIntent+API.swift @@ -0,0 +1,85 @@ +// +// PaymentIntent+API.swift +// StripeApplePay +// +// Created by David Estes on 8/10/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI.PaymentIntent { + /// A callback to be run with a PaymentIntent response from the Stripe API. + /// - Parameters: + /// - paymentIntent: The Stripe PaymentIntent from the response. Will be nil if an error occurs. - seealso: PaymentIntent + /// - error: The error returned from the response, or nil if none occurs. - seealso: StripeError.h for possible values. + @_spi(STP) public typealias PaymentIntentCompletionBlock = ( + Result + ) -> Void + + /// Retrieves the PaymentIntent object using the given secret. - seealso: https://stripe.com/docs/api#retrieve_payment_intent + /// - Parameters: + /// - secret: The client secret of the payment intent to be retrieved. Cannot be nil. + /// - completion: The callback to run with the returned PaymentIntent object, or an error. + @_spi(STP) public static func get( + apiClient: STPAPIClient = .shared, + clientSecret: String, + completion: @escaping PaymentIntentCompletionBlock + ) { + assert( + StripeAPI.PaymentIntentParams.isClientSecretValid(clientSecret), + "`secret` format does not match expected client secret formatting." + ) + guard let identifier = StripeAPI.PaymentIntent.id(fromClientSecret: clientSecret) else { + completion(.failure(StripeError.invalidRequest)) + return + } + let endpoint = "\(Resource)/\(identifier)" + let parameters: [String: String] = ["client_secret": clientSecret] + + apiClient.get(resource: endpoint, parameters: parameters, completion: completion) + } + + /// Confirms the PaymentIntent object with the provided params object. + /// At a minimum, the params object must include the `clientSecret`. + /// - seealso: https://stripe.com/docs/api#confirm_payment_intent + /// @note Use the `confirmPayment:withAuthenticationContext:completion:` method on `PaymentHandler` instead + /// of calling this method directly. It handles any authentication necessary for you. - seealso: https://stripe.com/docs/payments/3d-secure + /// - Parameters: + /// - paymentIntentParams: The `PaymentIntentParams` to pass to `/confirm` + /// - completion: The callback to run with the returned PaymentIntent object, or an error. + @_spi(STP) public static func confirm( + apiClient: STPAPIClient = .shared, + params: StripeAPI.PaymentIntentParams, + completion: @escaping PaymentIntentCompletionBlock + ) { + assert( + StripeAPI.PaymentIntentParams.isClientSecretValid(params.clientSecret), + "`paymentIntentParams.clientSecret` format does not match expected client secret formatting." + ) + + guard let identifier = StripeAPI.PaymentIntent.id(fromClientSecret: params.clientSecret) + else { + completion(.failure(StripeError.invalidRequest)) + return + } + let endpoint = "\(Resource)/\(identifier)/confirm" + + let type = params.paymentMethodData?.type.rawValue + STPAnalyticsClient.sharedClient.logPaymentIntentConfirmationAttempt( + paymentMethodType: type + ) + + // Add telemetry + var paramsWithTelemetry = params + if let pmAdditionalParams = paramsWithTelemetry.paymentMethodData?.additionalParameters { + paramsWithTelemetry.paymentMethodData?.additionalParameters = STPTelemetryClient.shared + .paramsByAddingTelemetryFields(toParams: pmAdditionalParams) + } + + apiClient.post(resource: endpoint, object: paramsWithTelemetry, completion: completion) + } + + static let Resource = "payment_intents" +} diff --git a/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/PaymentMethod+API.swift b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/PaymentMethod+API.swift new file mode 100644 index 00000000..f48e682c --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/PaymentMethod+API.swift @@ -0,0 +1,61 @@ +// +// PaymentMethod+API.swift +// StripeApplePay +// +// Created by David Estes on 8/10/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import PassKit +@_spi(STP) import StripeCore + +extension StripeAPI.PaymentMethod { + /// A callback to be run with a PaymentMethod response from the Stripe API. + /// - Parameters: + /// - paymentMethod: The Stripe PaymentMethod from the response. Will be nil if an error occurs. - seealso: PaymentMethod + /// - error: The error returned from the response, or nil if none occurs. - seealso: StripeError.h for possible values. + @_spi(STP) public typealias PaymentMethodCompletionBlock = ( + Result + ) -> Void + + static func create( + apiClient: STPAPIClient = .shared, + params: StripeAPI.PaymentMethodParams, + completion: @escaping PaymentMethodCompletionBlock + ) { + STPAnalyticsClient.sharedClient.logPaymentMethodCreationAttempt( + paymentMethodType: params.type.rawValue + ) + apiClient.post(resource: Resource, object: params, completion: completion) + } + + /// Converts a PKPayment object into a Stripe Payment Method using the Stripe API. + /// - Parameters: + /// - payment: The user's encrypted payment information as returned from a PKPaymentAuthorizationController. Cannot be nil. + /// - completion: The callback to run with the returned Stripe source (and any errors that may have occurred). + @_spi(STP) public static func create( + apiClient: STPAPIClient = .shared, + payment: PKPayment, + completion: @escaping PaymentMethodCompletionBlock + ) { + StripeAPI.Token.create(apiClient: apiClient, payment: payment) { (result) in + guard let token = try? result.get() else { + if case .failure(let error) = result { + completion(.failure(error)) + } else { + completion(.failure(NSError.stp_genericConnectionError())) + } + return + } + var cardParams = StripeAPI.PaymentMethodParams.Card() + cardParams.token = token.id + let billingDetails = StripeAPI.BillingDetails(from: payment) + var paymentMethodParams = StripeAPI.PaymentMethodParams(type: .card, card: cardParams) + paymentMethodParams.billingDetails = billingDetails + Self.create(apiClient: apiClient, params: paymentMethodParams, completion: completion) + } + } + + static let Resource = "payment_methods" +} diff --git a/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/SetupIntent+API.swift b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/SetupIntent+API.swift new file mode 100644 index 00000000..74fbfd82 --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/SetupIntent+API.swift @@ -0,0 +1,84 @@ +// +// SetupIntent+API.swift +// StripeApplePay +// +// Created by David Estes on 8/10/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI.SetupIntent { + /// A callback to be run with a SetupIntent response from the Stripe API. + /// - Parameters: + /// - setupIntent: The Stripe SetupIntent from the response. Will be nil if an error occurs. - seealso: SetupIntent + /// - error: The error returned from the response, or nil if none occurs. - seealso: StripeError.h for possible values. + @_spi(STP) public typealias SetupIntentCompletionBlock = (Result) + -> Void + + /// Retrieves the SetupIntent object using the given secret. - seealso: https://stripe.com/docs/api/setup_intents/retrieve + /// - Parameters: + /// - secret: The client secret of the SetupIntent to be retrieved. Cannot be nil. + /// - completion: The callback to run with the returned SetupIntent object, or an error. + @_spi(STP) public static func get( + apiClient: STPAPIClient = .shared, + clientSecret: String, + completion: @escaping SetupIntentCompletionBlock + ) { + assert( + StripeAPI.SetupIntentConfirmParams.isClientSecretValid(clientSecret), + "`secret` format does not match expected client secret formatting." + ) + guard let identifier = StripeAPI.SetupIntent.id(fromClientSecret: clientSecret) else { + completion(.failure(StripeError.invalidRequest)) + return + } + let endpoint = "\(Resource)/\(identifier)" + let parameters: [String: String] = ["client_secret": clientSecret] + + apiClient.get(resource: endpoint, parameters: parameters, completion: completion) + } + + /// Confirms the SetupIntent object with the provided params object. + /// At a minimum, the params object must include the `clientSecret`. + /// - seealso: https://stripe.com/docs/api/setup_intents/confirm + /// @note Use the `confirmSetupIntent:withAuthenticationContext:completion:` method on `PaymentHandler` instead + /// of calling this method directly. It handles any authentication necessary for you. - seealso: https://stripe.com/docs/payments/3d-secure + /// - Parameters: + /// - setupIntentParams: The `SetupIntentConfirmParams` to pass to `/confirm` + /// - completion: The callback to run with the returned PaymentIntent object, or an error. + @_spi(STP) public static func confirm( + apiClient: STPAPIClient = .shared, + params: StripeAPI.SetupIntentConfirmParams, + completion: @escaping SetupIntentCompletionBlock + ) { + assert( + StripeAPI.SetupIntentConfirmParams.isClientSecretValid(params.clientSecret), + "`setupIntentConfirmParams.clientSecret` format does not match expected client secret formatting." + ) + + guard let identifier = StripeAPI.SetupIntent.id(fromClientSecret: params.clientSecret) + else { + completion(.failure(StripeError.invalidRequest)) + return + } + let endpoint = "\(Resource)/\(identifier)/confirm" + + let type = params.paymentMethodData?.type.rawValue + STPAnalyticsClient.sharedClient.logSetupIntentConfirmationAttempt( + paymentMethodType: type + ) + + // Add telemetry + var paramsWithTelemetry = params + if let pmAdditionalParams = paramsWithTelemetry.paymentMethodData?.additionalParameters { + paramsWithTelemetry.paymentMethodData?.additionalParameters = STPTelemetryClient.shared + .paramsByAddingTelemetryFields(toParams: pmAdditionalParams) + } + + apiClient.post(resource: endpoint, object: paramsWithTelemetry, completion: completion) + } + + static let Resource = "setup_intents" +} diff --git a/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Token+API.swift b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Token+API.swift new file mode 100644 index 00000000..0622e5fe --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/PaymentsCore/API/Token+API.swift @@ -0,0 +1,92 @@ +// +// Token+API.swift +// StripeApplePay +// +// Created by David Estes on 8/10/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import PassKit +@_spi(STP) import StripeCore + +extension StripeAPI.Token { + /// A callback to be run with a token response from the Stripe API. + /// - Parameters: + /// - token: The Stripe token from the response. Will be nil if an error occurs. - seealso: STPToken + /// - error: The error returned from the response, or nil if none occurs. - seealso: StripeError.h for possible values. + @_spi(StripeApplePayTokenization) public typealias TokenCompletionBlock = (Result) -> Void + + /// Converts a PKPayment object into a Stripe token using the Stripe API. + /// - Parameters: + /// - payment: The user's encrypted payment information as returned from a PKPaymentAuthorizationController. Cannot be nil. + /// - completion: The callback to run with the returned Stripe token (and any errors that may have occurred). + @_spi(StripeApplePayTokenization) public static func create( + apiClient: STPAPIClient = .shared, + payment: PKPayment, + completion: @escaping TokenCompletionBlock + ) { + // Internal note: @_spi(StripeApplePayTokenization) is intended for limited public use. See https://docs.google.com/document/d/1Z9bTUBvDDufoqTaQeI3A0Cxdsoj_D0IkxdWX-GB-RTQ + let params = payment.stp_tokenParameters(apiClient: apiClient) + create( + apiClient: apiClient, + parameters: params, + completion: completion + ) + } + + static func create( + apiClient: STPAPIClient = .shared, + parameters: [String: Any], + completion: @escaping TokenCompletionBlock + ) { + let tokenType = STPAnalyticsClient.tokenType(fromParameters: parameters) + var mutableParams = parameters + STPTelemetryClient.shared.addTelemetryFields(toParams: &mutableParams) + mutableParams = STPAPIClient.paramsAddingPaymentUserAgent(mutableParams) + STPAnalyticsClient.sharedClient.logTokenCreationAttempt(tokenType: tokenType) + apiClient.post(resource: Resource, parameters: mutableParams, completion: completion) + STPTelemetryClient.shared.sendTelemetryData() + } + + static let Resource = "tokens" +} + +extension PKPayment { + func stp_tokenParameters(apiClient: STPAPIClient) -> [String: Any] { + let paymentString = String(data: self.token.paymentData, encoding: .utf8) + var payload: [String: Any] = [:] + payload["pk_token"] = paymentString + if let billingContact = self.billingContact { + payload["card"] = billingContact.addressParams + } + + assert( + !((paymentString?.count ?? 0) == 0 + && apiClient.publishableKey?.hasPrefix("pk_live") ?? false), + "The pk_token is empty. Using Apple Pay with an iOS Simulator while not in Stripe Test Mode will always fail." + ) + + let paymentInstrumentName = self.token.paymentMethod.displayName + if let paymentInstrumentName = paymentInstrumentName { + payload["pk_token_instrument_name"] = paymentInstrumentName + } + + let paymentNetwork = self.token.paymentMethod.network + if let paymentNetwork = paymentNetwork { + // Note: As of SDK 20.0.0, this will return `PKPaymentNetwork(_rawValue: MasterCard)`. + // We're intentionally leaving it this way: See RUN_MOBILESDK-125. + payload["pk_token_payment_network"] = paymentNetwork + } + + var transactionIdentifier = self.token.transactionIdentifier + if transactionIdentifier != "" { + if self.stp_isSimulated() { + transactionIdentifier = PKPayment.stp_testTransactionIdentifier() + } + payload["pk_token_transaction_id"] = transactionIdentifier + } + + return payload + } +} diff --git a/StripeApplePay/StripeApplePay/Source/PaymentsCore/Analytics/STPAnalyticsClient+Payments.swift b/StripeApplePay/StripeApplePay/Source/PaymentsCore/Analytics/STPAnalyticsClient+Payments.swift new file mode 100644 index 00000000..91cfbd1e --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/PaymentsCore/Analytics/STPAnalyticsClient+Payments.swift @@ -0,0 +1,28 @@ +// +// STPAnalyticsClient+Payments.swift +// StripeApplePay +// +// Created by David Estes on 1/24/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +/// An analytic specific to payments that serializes payment-specific +/// information into its params. +@_spi(STP) public protocol PaymentAnalytic: Analytic { + var productUsage: Set { get } + var additionalParams: [String: Any] { get } +} + +@_spi(STP) extension PaymentAnalytic { + public var params: [String: Any] { + var params = additionalParams + + params["apple_pay_enabled"] = NSNumber(value: StripeAPI.deviceSupportsApplePay()) + params["ocr_type"] = PaymentsSDKVariant.ocrTypeString + params["pay_var"] = PaymentsSDKVariant.variant + return params + } +} diff --git a/StripeApplePay/StripeApplePay/Source/PaymentsCore/Analytics/STPAnalyticsClient+PaymentsAPI.swift b/StripeApplePay/StripeApplePay/Source/PaymentsCore/Analytics/STPAnalyticsClient+PaymentsAPI.swift new file mode 100644 index 00000000..2793d27a --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/PaymentsCore/Analytics/STPAnalyticsClient+PaymentsAPI.swift @@ -0,0 +1,72 @@ +// +// STPAnalyticsClient+PaymentsAPI.swift +// StripeApplePay +// +// Created by David Estes on 1/21/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +extension STPAnalyticsClient { + // MARK: - Log events + + func logPaymentMethodCreationAttempt(paymentMethodType: String?) { + log( + analytic: PaymentAPIAnalytic( + event: .paymentMethodCreation, + productUsage: productUsage, + additionalParams: [ + "source_type": paymentMethodType ?? "unknown" + ] + ) + ) + } + + func logTokenCreationAttempt(tokenType: String?) { + log( + analytic: PaymentAPIAnalytic( + event: .tokenCreation, + productUsage: productUsage, + additionalParams: [ + "token_type": tokenType ?? "unknown" + ] + ) + ) + } + + func logPaymentIntentConfirmationAttempt( + paymentMethodType: String? + ) { + log( + analytic: PaymentAPIAnalytic( + event: .paymentMethodIntentCreation, + productUsage: productUsage, + additionalParams: [ + "source_type": paymentMethodType ?? "unknown" + ] + ) + ) + } + + func logSetupIntentConfirmationAttempt( + paymentMethodType: String? + ) { + log( + analytic: PaymentAPIAnalytic( + event: .setupIntentConfirmationAttempt, + productUsage: productUsage, + additionalParams: [ + "source_type": paymentMethodType ?? "unknown" + ] + ) + ) + } +} + +struct PaymentAPIAnalytic: PaymentAnalytic { + let event: STPAnalyticEvent + let productUsage: Set + let additionalParams: [String: Any] +} diff --git a/StripeApplePay/StripeApplePay/Source/PaymentsCore/Categories/STPAPIClient+PaymentsCore.swift b/StripeApplePay/StripeApplePay/Source/PaymentsCore/Categories/STPAPIClient+PaymentsCore.swift new file mode 100644 index 00000000..2ed46cf8 --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/PaymentsCore/Categories/STPAPIClient+PaymentsCore.swift @@ -0,0 +1,20 @@ +// +// STPAPIClient+PaymentsCore.swift +// StripeApplePay +// +// Created by David Estes on 1/25/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +extension STPAPIClient { + @_spi(STP) public class func paramsAddingPaymentUserAgent( + _ params: [String: Any] + ) -> [String: Any] { + var newParams = params + newParams["payment_user_agent"] = PaymentsSDKVariant.paymentUserAgent + return newParams + } +} diff --git a/StripeApplePay/StripeApplePay/Source/StripeCore+Import.swift b/StripeApplePay/StripeApplePay/Source/StripeCore+Import.swift new file mode 100644 index 00000000..123a8e98 --- /dev/null +++ b/StripeApplePay/StripeApplePay/Source/StripeCore+Import.swift @@ -0,0 +1,10 @@ +// +// StripeCore+Import.swift +// StripeApplePay +// +// Created by David Estes on 11/15/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_exported import StripeCore diff --git a/StripeApplePay/StripeApplePay/StripeApplePay.h b/StripeApplePay/StripeApplePay/StripeApplePay.h new file mode 100644 index 00000000..e6b9e0f8 --- /dev/null +++ b/StripeApplePay/StripeApplePay/StripeApplePay.h @@ -0,0 +1,18 @@ +// +// StripeApplePay.h +// StripeApplePay +// +// Created by David Estes on 11/8/21. +// + +#import + +//! Project version number for StripeApplePay. +FOUNDATION_EXPORT double StripeApplePayVersionNumber; + +//! Project version string for StripeApplePay. +FOUNDATION_EXPORT const unsigned char StripeApplePayVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/StripeApplePay/StripeApplePayTests/Info.plist b/StripeApplePay/StripeApplePayTests/Info.plist new file mode 100644 index 00000000..64d65ca4 --- /dev/null +++ b/StripeApplePay/StripeApplePayTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/StripeApplePay/StripeApplePayTests/PaymentsCore/STPTelemetryClientFunctionalTest.swift b/StripeApplePay/StripeApplePayTests/PaymentsCore/STPTelemetryClientFunctionalTest.swift new file mode 100644 index 00000000..9f70bfb5 --- /dev/null +++ b/StripeApplePay/StripeApplePayTests/PaymentsCore/STPTelemetryClientFunctionalTest.swift @@ -0,0 +1,67 @@ +// +// STPTelemetryClientFunctionalTest.swift +// StripeApplePayTests +// +// Created by Yuki Tokuhiro on 5/21/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import XCTest + +// swift-format-ignore +@testable @_spi(STP) import StripeApplePay + +// swift-format-ignore +@testable @_spi(STP) import StripeCore + +class STPTelemetryClientFunctionalTest: XCTestCase { + func testSendFraudDetectionData() { + // Sending telemetry without any FraudDetectionData... + FraudDetectionData.shared.sid = nil + FraudDetectionData.shared.sidCreationDate = nil + FraudDetectionData.shared.muid = nil + FraudDetectionData.shared.guid = nil + let sendTelemetry1 = expectation(description: "") + STPTelemetryClient.shared.sendTelemetryData(forceSend: true) { _ in + sendTelemetry1.fulfill() + } + waitForExpectations(timeout: 10, handler: nil) + // ...populates FraudDetectionData + let sid = FraudDetectionData.shared.sid + let muid = FraudDetectionData.shared.muid + let guid = FraudDetectionData.shared.guid + XCTAssertNotNil(sid) + XCTAssertNotNil(muid) + XCTAssertNotNil(guid) + + let sendTelemetry2 = expectation(description: "") + // Sending telemetry again... + STPTelemetryClient.shared.sendTelemetryData(forceSend: true) { _ in + sendTelemetry2.fulfill() + } + // ...gives the same FraudDetectionData + XCTAssertEqual(FraudDetectionData.shared.sid, sid) + XCTAssertEqual(FraudDetectionData.shared.muid, muid) + XCTAssertEqual(FraudDetectionData.shared.guid, guid) + guard let sidCreationDate = FraudDetectionData.shared.sidCreationDate else { + XCTFail() + return + } + // sanity check creation date looks right + XCTAssertTrue(sidCreationDate > Date(timeIntervalSinceNow: -10)) + waitForExpectations(timeout: 10, handler: nil) + + // Expiring the FraudDetectionData + FraudDetectionData.shared.sidCreationDate = Date(timeIntervalSinceNow: -999999) + let sendTelemetry3 = expectation(description: "") + // ...and sending telemetry again + STPTelemetryClient.shared.sendTelemetryData(forceSend: true) { _ in + sendTelemetry3.fulfill() + } + waitForExpectations(timeout: 10, handler: nil) + // ...gives the same muid and guid but different sid + XCTAssertEqual(FraudDetectionData.shared.muid, muid) + XCTAssertEqual(FraudDetectionData.shared.guid, guid) + XCTAssertNotEqual(FraudDetectionData.shared.sid, sid) + } +} diff --git a/StripeApplePay/StripeApplePayTests/PaymentsCore/STPTelemetryClientTest.swift b/StripeApplePay/StripeApplePayTests/PaymentsCore/STPTelemetryClientTest.swift new file mode 100644 index 00000000..8a730008 --- /dev/null +++ b/StripeApplePay/StripeApplePayTests/PaymentsCore/STPTelemetryClientTest.swift @@ -0,0 +1,75 @@ +// +// STPTelemetryClientTest.swift +// StripeApplePayTests +// +// Created by Yuki Tokuhiro on 9/24/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import XCTest + +// swift-format-ignore +@testable @_spi(STP) import StripeApplePay + +// swift-format-ignore +@testable @_spi(STP) import StripeCore + +class STPTelemetryClientTest: XCTestCase { + + func testAddTelemetryData() { + let sut = STPTelemetryClient.shared + var params: [String: Any] = [ + "foo": "bar" + ] + let exp = expectation(description: "delay") + DispatchQueue.main.asyncAfter( + deadline: DispatchTime.now() + Double(Int64(0.1 * Double(NSEC_PER_SEC))) + / Double(NSEC_PER_SEC), + execute: { + sut.addTelemetryFields(toParams: ¶ms) + XCTAssertNotNil(params) + exp.fulfill() + } + ) + waitForExpectations(timeout: 2, handler: nil) + } + + func testAdvancedFraudSignalsSwitch() { + XCTAssertTrue(StripeAPI.advancedFraudSignalsEnabled) + StripeAPI.advancedFraudSignalsEnabled = false + XCTAssertFalse(StripeAPI.advancedFraudSignalsEnabled) + } + + func testAddTelemetryFieldsWhenFraudDetectionDataEmpty() { + // Should not add any fields if fraudDetectionData is empty + FraudDetectionData.shared.reset() + var params: [String: Any] = [:] + STPTelemetryClient.shared.addTelemetryFields(toParams: ¶ms) + XCTAssertTrue(params.isEmpty) + } + + func testAddTelemetryFieldsWhenSIDExpired() { + // Should add muid, but not add sid if it's expired + var params: [String: Any] = [:] + FraudDetectionData.shared.sid = "expired" + FraudDetectionData.shared.sidCreationDate = Date(timeInterval: -30 * 60, since: Date()) + FraudDetectionData.shared.muid = "muid value" + FraudDetectionData.shared.guid = "guid value" + STPTelemetryClient.shared.addTelemetryFields(toParams: ¶ms) + XCTAssertEqual(params["muid"] as? String, "muid value") + XCTAssertEqual(params["guid"] as? String, "guid value") + XCTAssertNil(params["sid"] as? String) + } + + func testAddTelemetryFields() { + var params: [String: Any] = [:] + FraudDetectionData.shared.sid = "sid value" + FraudDetectionData.shared.muid = "muid value" + FraudDetectionData.shared.guid = "guid value" + FraudDetectionData.shared.sidCreationDate = Date() + STPTelemetryClient.shared.addTelemetryFields(toParams: ¶ms) + XCTAssertEqual(params["muid"] as? String, "muid value") + XCTAssertEqual(params["sid"] as? String, "sid value") + XCTAssertEqual(params["guid"] as? String, "guid value") + } +} diff --git a/StripeApplePay/StripeApplePayTests/STPAnalyticsClient+ApplePayTest.swift b/StripeApplePay/StripeApplePayTests/STPAnalyticsClient+ApplePayTest.swift new file mode 100644 index 00000000..a39e180c --- /dev/null +++ b/StripeApplePay/StripeApplePayTests/STPAnalyticsClient+ApplePayTest.swift @@ -0,0 +1,30 @@ +// +// STPAnalyticsClient+ApplePayTest.swift +// StripeApplePayTests +// +// Created by David Estes on 2/3/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +import XCTest + +// swift-format-ignore +@_spi(STP) @testable import StripeApplePay + +// swift-format-ignore +@_spi(STP) @testable import StripeCore + +class STPAnalyticsClientApplePayTest: XCTestCase { + func testApplePaySDKVariantPayload() throws { + // setup + let analytic = PaymentAPIAnalytic( + event: .paymentMethodCreation, + productUsage: [], + additionalParams: [:] + ) + let client = STPAnalyticsClient() + let payload = client.payload(from: analytic) + XCTAssertEqual("applepay", payload["pay_var"] as? String) + } +} diff --git a/StripeApplePay/StripeApplePayTests/STPPaymentMethodFunctionalTest.swift b/StripeApplePay/StripeApplePayTests/STPPaymentMethodFunctionalTest.swift new file mode 100644 index 00000000..d2037222 --- /dev/null +++ b/StripeApplePay/StripeApplePayTests/STPPaymentMethodFunctionalTest.swift @@ -0,0 +1,96 @@ +// +// STPPaymentMethodFunctionalTest.swift +// StripeApplePayTests +// +// Created by David Estes on 8/9/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore // for StripeError +import XCTest + +// swift-format-ignore +@_spi(STP) @testable import StripeApplePay + +let STPTestingDefaultPublishableKey = "pk_test_ErsyMEOTudSjQR8hh0VrQr5X008sBXGOu6" +public let STPTestingNetworkRequestTimeout: TimeInterval = 8 + +class STPPaymentMethodModernTest: XCTestCase { + func testCreateCardPaymentMethod() { + let expectation = self.expectation(description: "Created") + let apiClient = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + var params = StripeAPI.PaymentMethodParams(type: .card) + var card = StripeAPI.PaymentMethodParams.Card() + card.number = "4242424242424242" + card.expYear = 28 + card.expMonth = 12 + card.cvc = "100" + var billingAddress = StripeAPI.BillingDetails.Address() + billingAddress.city = "San Francisco" + billingAddress.country = "US" + billingAddress.line1 = "150 Townsend St" + billingAddress.line2 = "4th Floor" + billingAddress.postalCode = "94103" + billingAddress.state = "CA" + + var billingDetails = StripeAPI.BillingDetails() + billingDetails.address = billingAddress + billingDetails.email = "email@email.com" + billingDetails.name = "Isaac Asimov" + billingDetails.phone = "555-555-5555" + + params.card = card + params.billingDetails = billingDetails + + StripeAPI.PaymentMethod.create(apiClient: apiClient, params: params) { result in + let paymentMethod = try! result.get() + XCTAssertEqual(paymentMethod.card?.last4, "4242") + expectation.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testCreateCardPaymentMethodWithAdditionalAPIStuff() { + let expectation = self.expectation(description: "Created") + let apiClient = STPAPIClient(publishableKey: STPTestingDefaultPublishableKey) + var params = StripeAPI.PaymentMethodParams(type: .card) + var card = StripeAPI.PaymentMethodParams.Card() + card.number = "4242424242424242" + card.expYear = 28 + card.expMonth = 12 + card.cvc = "100" + var billingAddress = StripeAPI.BillingDetails.Address() + billingAddress.city = "San Francisco" + billingAddress.country = "US" + billingAddress.line1 = "150 Townsend St" + billingAddress.line2 = "4th Floor" + billingAddress.postalCode = "94103" + billingAddress.state = "CA" + billingAddress.additionalParameters = ["invalid_thing": "yes"] + + var billingDetails = StripeAPI.BillingDetails() + billingDetails.address = billingAddress + billingDetails.email = "email@email.com" + billingDetails.name = "Isaac Asimov" + billingDetails.phone = "555-555-5555" + + params.card = card + params.billingDetails = billingDetails + + StripeAPI.PaymentMethod.create(apiClient: apiClient, params: params) { result in + do { + _ = try result.get() + XCTFail("This request should fail") + } catch { + let stripeError = error as? StripeError + if case .apiError(let apiError) = stripeError { + XCTAssertEqual(apiError.code, "parameter_unknown") + XCTAssertEqual(apiError.param, "billing_details[address][invalid_thing]") + expectation.fulfill() + } + } + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/StripeApplePay/StripeApplePayTests/TelemetryInjectionTest.swift b/StripeApplePay/StripeApplePayTests/TelemetryInjectionTest.swift new file mode 100644 index 00000000..b337a440 --- /dev/null +++ b/StripeApplePay/StripeApplePayTests/TelemetryInjectionTest.swift @@ -0,0 +1,95 @@ +// +// TelemetryInjectionTest.swift +// StripeApplePayTests +// +// Created by David Estes on 2/9/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import AVFoundation +import Foundation +import OHHTTPStubs +import OHHTTPStubsSwift +import StripeCoreTestUtils +import XCTest + +// swift-format-ignore +@_spi(STP) @testable import StripeApplePay + +// swift-format-ignore +@_spi(STP) @testable import StripeCore + +class TelemetryInjectionTest: APIStubbedTestCase { + func testIntentConfirmAddsTelemetry() { + let apiClient = stubbedAPIClient() + + let piTelemetryExpectation = self.expectation(description: "saw pi telemetry") + let siTelemetryExpectation = self.expectation(description: "saw si telemetry") + + // As an implementation detail, OHHTTPStubs will run this block in `canInitWithRequest` in addition to + // `initWithRequest`. So it could be called more times than we expect. + // We don't have control over this behavior (CFNetwork drives it), so let's not worry + // about overfulfillment. + piTelemetryExpectation.assertForOverFulfill = false + siTelemetryExpectation.assertForOverFulfill = false + + stub { urlRequest in + if urlRequest.url!.absoluteString.contains("_intent") { + let ua = urlRequest.queryItems!.first(where: { + $0.name == "payment_method_data[payment_user_agent]" + })!.value! + XCTAssertTrue(ua.hasPrefix("stripe-ios/")) + let muid = urlRequest.queryItems!.first(where: { + $0.name == "payment_method_data[muid]" + })!.value! + let guid = urlRequest.queryItems!.first(where: { + $0.name == "payment_method_data[guid]" + })!.value! + XCTAssertNotNil(muid) + XCTAssertNotNil(guid) + if urlRequest.url!.absoluteString.contains("payment_intent") { + piTelemetryExpectation.fulfill() + } + if urlRequest.url!.absoluteString.contains("setup_intent") { + siTelemetryExpectation.fulfill() + } + return true + } + return false + } response: { _ in + // We don't care about the response + return HTTPStubsResponse() + } + + var params = StripeAPI.PaymentMethodParams(type: .card) + var card = StripeAPI.PaymentMethodParams.Card() + card.number = "4242424242424242" + card.expYear = 28 + card.expMonth = 12 + card.cvc = "100" + params.card = card + + // Set up telemetry data + StripeAPI.advancedFraudSignalsEnabled = true + FraudDetectionData.shared.sid = "sid" + FraudDetectionData.shared.muid = "muid" + FraudDetectionData.shared.guid = "guid" + FraudDetectionData.shared.sidCreationDate = Date() + + let piExpectation = self.expectation(description: "PI Confirmed") + var pip = StripeAPI.PaymentIntentParams(clientSecret: "pi_123_secret_abc") + pip.paymentMethodData = params + StripeAPI.PaymentIntent.confirm(apiClient: apiClient, params: pip) { _ in + piExpectation.fulfill() + } + + let siExpectation = self.expectation(description: "SI Confirmed") + var sip = StripeAPI.SetupIntentConfirmParams(clientSecret: "seti_123_secret_abc") + sip.paymentMethodData = params + StripeAPI.SetupIntent.confirm(apiClient: apiClient, params: sip) { _ in + siExpectation.fulfill() + } + + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } +} diff --git a/StripeCameraCore/Project.swift b/StripeCameraCore/Project.swift new file mode 100644 index 00000000..d755cfda --- /dev/null +++ b/StripeCameraCore/Project.swift @@ -0,0 +1,11 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.stripeFramework( + name: "StripeCameraCore", + dependencies: [ + .project(target: "StripeCore", path: "//StripeCore"), + ], + testUtilsOptions: .testOptions(), + unitTestOptions: .testOptions() +) diff --git a/StripeCameraCore/StripeCameraCore.xcodeproj/project.pbxproj b/StripeCameraCore/StripeCameraCore.xcodeproj/project.pbxproj new file mode 100644 index 00000000..ccfeffb1 --- /dev/null +++ b/StripeCameraCore/StripeCameraCore.xcodeproj/project.pbxproj @@ -0,0 +1,640 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + 0485C4A62D9444E75877E170 /* CameraExifMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76A4D11F53602B77F9F5B2E /* CameraExifMetadata.swift */; }; + 064567ECD2B71E5EB7D3A201 /* MockCameraPermissionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 832A750C892D9D3FC54D9CF3 /* MockCameraPermissionsManager.swift */; }; + 06FEEA450541E2D36D5A10FC /* CameraPermissionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0F925920743B648B04C949 /* CameraPermissionsManager.swift */; }; + 0F02BB3F9F7936D7BB593226 /* Torch.swift in Sources */ = {isa = PBXBuildFile; fileRef = D750C5508AB271C099351E42 /* Torch.swift */; }; + 10BCE429AA79F7F0D1A9F2D3 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7158E127DC7294CDABDEF5B8 /* XCTest.framework */; }; + 1EF9F2AB3E43769AB2BA6D04 /* StripeCameraCoreTestUtils.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2720B7BD13954418A8AC52E5 /* StripeCameraCoreTestUtils.framework */; }; + 28F23B4322876ED2BAC8C933 /* StripeCameraCoreTestUtils.h in Headers */ = {isa = PBXBuildFile; fileRef = DDD1039F321BAF147F162F7D /* StripeCameraCoreTestUtils.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 2A1AAD8E5A589E4E74B02A35 /* MockSimulatorCameraSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCE205F4CCF2D486967BA25 /* MockSimulatorCameraSession.swift */; }; + 2CEF8A27C8864A6198C69A5D /* CVPixelBuffer+StripeCameraCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A0B9FDC8622F6CDF9F8A4B4 /* CVPixelBuffer+StripeCameraCore.swift */; }; + 48B09ECBED820F3097779208 /* CGRect+StripeCameraCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B4106DF455718394E209C00 /* CGRect+StripeCameraCore.swift */; }; + 5F57E0BF10039F7055013066 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7158E127DC7294CDABDEF5B8 /* XCTest.framework */; }; + 6D44C32189C0CB409C9E4712 /* StripeCameraCore.h in Headers */ = {isa = PBXBuildFile; fileRef = E7FB32269B8E36C3CBDB370F /* StripeCameraCore.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 88A1AF2B173B4055D8F44A8C /* MockAppSettingsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F04D387EEF3A5D07ECD1F7E /* MockAppSettingsHelper.swift */; }; + 8F3073987F30B9B8DDF0D956 /* MockTestCameraSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8091DB42296B7EC745567AF7 /* MockTestCameraSession.swift */; }; + 9AFBF50F47562CB34903064E /* StripeCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8E7BCD0896ADAFFF5CCA738E /* StripeCore.framework */; }; + 9CE6EE572439AD5B22B77294 /* CameraPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79D46F44C7BC55B9B595AEF /* CameraPreviewView.swift */; }; + 9FC42D068D1719AB28C3068F /* AppSettingsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48DFFB1E2F90176D156E315B /* AppSettingsHelper.swift */; }; + A95255494DF27A7CAE9A70CE /* CameraSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBD8FEBAE0069BE88B75B0C5 /* CameraSession.swift */; }; + AB24784F49EB99E7C36509BD /* CGRect_StripeCameraCoreTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B5B6BCFF035A9E214EA9EA0 /* CGRect_StripeCameraCoreTest.swift */; }; + D2186D6E491660D0C2A9D577 /* StripeCameraCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6DBC37790A9F68168A89095B /* StripeCameraCore.framework */; }; + D9687FAD52D1896F28309580 /* UIImage+Buffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA969E606F5A3829BC9D4445 /* UIImage+Buffer.swift */; }; + E01A054861E5D82D18C7D224 /* StripeCameraCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6DBC37790A9F68168A89095B /* StripeCameraCore.framework */; }; + F181EEBCE3D2CAD043DAF0F0 /* UIDeviceOrientation+StripeCameraCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D9BDABE163D20DF20F2237D /* UIDeviceOrientation+StripeCameraCore.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 0321B7B71CC9BDDCF236B375 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = B93DF1E4460962AFB28CF144 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B50782C89D54809DFFCF80D0; + remoteInfo = StripeCameraCore; + }; + 55BD94DFEA7738F26C23A545 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = B93DF1E4460962AFB28CF144 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 6A948E338F0BF55DBFD83170; + remoteInfo = StripeCameraCoreTestUtils; + }; + BA0100EB31091CFA64BF12A6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = B93DF1E4460962AFB28CF144 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B50782C89D54809DFFCF80D0; + remoteInfo = StripeCameraCore; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 2F2CDDE07BFFE88706C6A908 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + BCA0A45A41520FB95F4E8D10 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + F97E2A90DA9F982FC293A4A9 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0A0B9FDC8622F6CDF9F8A4B4 /* CVPixelBuffer+StripeCameraCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CVPixelBuffer+StripeCameraCore.swift"; sourceTree = ""; }; + 0F4174046507759A9668F4EE /* Project-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Release.xcconfig"; sourceTree = ""; }; + 17E9A614AF4543DB62E7F5B4 /* StripeiOS Tests-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS Tests-Release.xcconfig"; sourceTree = ""; }; + 226FED2248EE3F96F217BEE9 /* StripeCameraCoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StripeCameraCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2720B7BD13954418A8AC52E5 /* StripeCameraCoreTestUtils.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCameraCoreTestUtils.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2B5B6BCFF035A9E214EA9EA0 /* CGRect_StripeCameraCoreTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGRect_StripeCameraCoreTest.swift; sourceTree = ""; }; + 3B4106DF455718394E209C00 /* CGRect+StripeCameraCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGRect+StripeCameraCore.swift"; sourceTree = ""; }; + 3D0F925920743B648B04C949 /* CameraPermissionsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPermissionsManager.swift; sourceTree = ""; }; + 44E9A4900C4B8D08A617488C /* StripeiOS Tests-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS Tests-Debug.xcconfig"; sourceTree = ""; }; + 48DFFB1E2F90176D156E315B /* AppSettingsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsHelper.swift; sourceTree = ""; }; + 4E0D85BEF8C269A46D5ECEAC /* StripeiOS-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS-Release.xcconfig"; sourceTree = ""; }; + 5DFD03E78034CFB59E244199 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 609D9ADB9E9898694A2A67FF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 6DBC37790A9F68168A89095B /* StripeCameraCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCameraCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7158E127DC7294CDABDEF5B8 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; + 7D9BDABE163D20DF20F2237D /* UIDeviceOrientation+StripeCameraCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDeviceOrientation+StripeCameraCore.swift"; sourceTree = ""; }; + 8091DB42296B7EC745567AF7 /* MockTestCameraSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTestCameraSession.swift; sourceTree = ""; }; + 832A750C892D9D3FC54D9CF3 /* MockCameraPermissionsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCameraPermissionsManager.swift; sourceTree = ""; }; + 83596C8ED3829B17A93490A6 /* StripeiOS-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS-Debug.xcconfig"; sourceTree = ""; }; + 8E7BCD0896ADAFFF5CCA738E /* StripeCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9F04D387EEF3A5D07ECD1F7E /* MockAppSettingsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAppSettingsHelper.swift; sourceTree = ""; }; + A79D46F44C7BC55B9B595AEF /* CameraPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPreviewView.swift; sourceTree = ""; }; + ABCCADF0E7337301A472E75C /* Project-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Debug.xcconfig"; sourceTree = ""; }; + B8D39F3EE77B9DF5FFBA4CAE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + BA969E606F5A3829BC9D4445 /* UIImage+Buffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Buffer.swift"; sourceTree = ""; }; + BCCE205F4CCF2D486967BA25 /* MockSimulatorCameraSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSimulatorCameraSession.swift; sourceTree = ""; }; + D750C5508AB271C099351E42 /* Torch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Torch.swift; sourceTree = ""; }; + D76A4D11F53602B77F9F5B2E /* CameraExifMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraExifMetadata.swift; sourceTree = ""; }; + DDD1039F321BAF147F162F7D /* StripeCameraCoreTestUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StripeCameraCoreTestUtils.h; sourceTree = ""; }; + E7FB32269B8E36C3CBDB370F /* StripeCameraCore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StripeCameraCore.h; sourceTree = ""; }; + FBD8FEBAE0069BE88B75B0C5 /* CameraSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraSession.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 267776DB1189FACBD4FA2AEA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5F57E0BF10039F7055013066 /* XCTest.framework in Frameworks */, + E01A054861E5D82D18C7D224 /* StripeCameraCore.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9CC1AF0A3093D51C5085D503 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 10BCE429AA79F7F0D1A9F2D3 /* XCTest.framework in Frameworks */, + D2186D6E491660D0C2A9D577 /* StripeCameraCore.framework in Frameworks */, + 1EF9F2AB3E43769AB2BA6D04 /* StripeCameraCoreTestUtils.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E3A6C5C1A5D47CEFAFD1853F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9AFBF50F47562CB34903064E /* StripeCore.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 00F9BB709FA21D59419FA5AE /* BuildConfigurations */ = { + isa = PBXGroup; + children = ( + ABCCADF0E7337301A472E75C /* Project-Debug.xcconfig */, + 0F4174046507759A9668F4EE /* Project-Release.xcconfig */, + 44E9A4900C4B8D08A617488C /* StripeiOS Tests-Debug.xcconfig */, + 17E9A614AF4543DB62E7F5B4 /* StripeiOS Tests-Release.xcconfig */, + 83596C8ED3829B17A93490A6 /* StripeiOS-Debug.xcconfig */, + 4E0D85BEF8C269A46D5ECEAC /* StripeiOS-Release.xcconfig */, + ); + name = BuildConfigurations; + path = ../BuildConfigurations; + sourceTree = ""; + }; + 03256F1FDB473FE93A57A38A /* Source */ = { + isa = PBXGroup; + children = ( + 170C84B7FB58FAA15283903E /* Categories */, + 7E719691677D20AEAEF0C120 /* Coordinators */, + 72FEB5C092565B365BBC3906 /* Views */, + D76A4D11F53602B77F9F5B2E /* CameraExifMetadata.swift */, + ); + path = Source; + sourceTree = ""; + }; + 0FB9D3D4625E1AEA6EE1CC22 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 7158E127DC7294CDABDEF5B8 /* XCTest.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 170C84B7FB58FAA15283903E /* Categories */ = { + isa = PBXGroup; + children = ( + 3B4106DF455718394E209C00 /* CGRect+StripeCameraCore.swift */, + 0A0B9FDC8622F6CDF9F8A4B4 /* CVPixelBuffer+StripeCameraCore.swift */, + 7D9BDABE163D20DF20F2237D /* UIDeviceOrientation+StripeCameraCore.swift */, + BA969E606F5A3829BC9D4445 /* UIImage+Buffer.swift */, + ); + path = Categories; + sourceTree = ""; + }; + 171BE48EE95E66F94D6CCC33 /* StripeCameraCoreTests */ = { + isa = PBXGroup; + children = ( + CC0771AB4883EEDAB5AA7D1C /* Unit */, + 5DFD03E78034CFB59E244199 /* Info.plist */, + ); + path = StripeCameraCoreTests; + sourceTree = ""; + }; + 18A8A17329DE3C8891103ECD /* Categories */ = { + isa = PBXGroup; + children = ( + 2B5B6BCFF035A9E214EA9EA0 /* CGRect_StripeCameraCoreTest.swift */, + ); + path = Categories; + sourceTree = ""; + }; + 27E56A8242FD577334930F5B /* StripeCameraCoreTestUtils */ = { + isa = PBXGroup; + children = ( + BCB41D804D8A44F6DEEF4B8E /* Mocks */, + B8D39F3EE77B9DF5FFBA4CAE /* Info.plist */, + DDD1039F321BAF147F162F7D /* StripeCameraCoreTestUtils.h */, + ); + path = StripeCameraCoreTestUtils; + sourceTree = ""; + }; + 5CE9B5F8E73CBC18E22AF375 /* Project */ = { + isa = PBXGroup; + children = ( + 00F9BB709FA21D59419FA5AE /* BuildConfigurations */, + E2CD33A048BB3FD49B4D47FD /* StripeCameraCore */, + 171BE48EE95E66F94D6CCC33 /* StripeCameraCoreTests */, + 27E56A8242FD577334930F5B /* StripeCameraCoreTestUtils */, + ); + name = Project; + sourceTree = ""; + }; + 72FEB5C092565B365BBC3906 /* Views */ = { + isa = PBXGroup; + children = ( + A79D46F44C7BC55B9B595AEF /* CameraPreviewView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 7E719691677D20AEAEF0C120 /* Coordinators */ = { + isa = PBXGroup; + children = ( + 48DFFB1E2F90176D156E315B /* AppSettingsHelper.swift */, + 3D0F925920743B648B04C949 /* CameraPermissionsManager.swift */, + FBD8FEBAE0069BE88B75B0C5 /* CameraSession.swift */, + BCCE205F4CCF2D486967BA25 /* MockSimulatorCameraSession.swift */, + D750C5508AB271C099351E42 /* Torch.swift */, + ); + path = Coordinators; + sourceTree = ""; + }; + BCB41D804D8A44F6DEEF4B8E /* Mocks */ = { + isa = PBXGroup; + children = ( + 9F04D387EEF3A5D07ECD1F7E /* MockAppSettingsHelper.swift */, + 832A750C892D9D3FC54D9CF3 /* MockCameraPermissionsManager.swift */, + 8091DB42296B7EC745567AF7 /* MockTestCameraSession.swift */, + ); + path = Mocks; + sourceTree = ""; + }; + CC0771AB4883EEDAB5AA7D1C /* Unit */ = { + isa = PBXGroup; + children = ( + 18A8A17329DE3C8891103ECD /* Categories */, + ); + path = Unit; + sourceTree = ""; + }; + E2B3561EEB5629A212FD2AB4 /* Products */ = { + isa = PBXGroup; + children = ( + 6DBC37790A9F68168A89095B /* StripeCameraCore.framework */, + 226FED2248EE3F96F217BEE9 /* StripeCameraCoreTests.xctest */, + 2720B7BD13954418A8AC52E5 /* StripeCameraCoreTestUtils.framework */, + 8E7BCD0896ADAFFF5CCA738E /* StripeCore.framework */, + ); + name = Products; + sourceTree = ""; + }; + E2CD33A048BB3FD49B4D47FD /* StripeCameraCore */ = { + isa = PBXGroup; + children = ( + 03256F1FDB473FE93A57A38A /* Source */, + 609D9ADB9E9898694A2A67FF /* Info.plist */, + E7FB32269B8E36C3CBDB370F /* StripeCameraCore.h */, + ); + path = StripeCameraCore; + sourceTree = ""; + }; + F72F2AFE9BE5E2CCBE7C6436 = { + isa = PBXGroup; + children = ( + 5CE9B5F8E73CBC18E22AF375 /* Project */, + 0FB9D3D4625E1AEA6EE1CC22 /* Frameworks */, + E2B3561EEB5629A212FD2AB4 /* Products */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 21D2298D6D8328B46AB437BB /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 6D44C32189C0CB409C9E4712 /* StripeCameraCore.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8FED65A79644B7F1CB19619B /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 28F23B4322876ED2BAC8C933 /* StripeCameraCoreTestUtils.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 6A948E338F0BF55DBFD83170 /* StripeCameraCoreTestUtils */ = { + isa = PBXNativeTarget; + buildConfigurationList = 12D1B5D975CF0E6EC4956438 /* Build configuration list for PBXNativeTarget "StripeCameraCoreTestUtils" */; + buildPhases = ( + 8FED65A79644B7F1CB19619B /* Headers */, + F2FB0D7ACB63AFA0A81A5FF6 /* Sources */, + 83C726705BB615A9940B98CC /* Resources */, + BCA0A45A41520FB95F4E8D10 /* Embed Frameworks */, + 267776DB1189FACBD4FA2AEA /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + D1513DE143A5DDFAB1AA4069 /* PBXTargetDependency */, + ); + name = StripeCameraCoreTestUtils; + productName = StripeCameraCoreTestUtils; + productReference = 2720B7BD13954418A8AC52E5 /* StripeCameraCoreTestUtils.framework */; + productType = "com.apple.product-type.framework"; + }; + B50782C89D54809DFFCF80D0 /* StripeCameraCore */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7056F053793C453C8750F440 /* Build configuration list for PBXNativeTarget "StripeCameraCore" */; + buildPhases = ( + 21D2298D6D8328B46AB437BB /* Headers */, + 62DE5865F3F7B8CB25F62395 /* Sources */, + 445990B4DBEECCD2FBA8B324 /* Resources */, + 2F2CDDE07BFFE88706C6A908 /* Embed Frameworks */, + E3A6C5C1A5D47CEFAFD1853F /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = StripeCameraCore; + productName = StripeCameraCore; + productReference = 6DBC37790A9F68168A89095B /* StripeCameraCore.framework */; + productType = "com.apple.product-type.framework"; + }; + F3DED9AB60FDAB786A737384 /* StripeCameraCoreTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6BB26614346BD154B399D073 /* Build configuration list for PBXNativeTarget "StripeCameraCoreTests" */; + buildPhases = ( + 85B8B0A9CAD01C91AD19797A /* Sources */, + 98362E27FEF7FBD502400444 /* Resources */, + F97E2A90DA9F982FC293A4A9 /* Embed Frameworks */, + 9CC1AF0A3093D51C5085D503 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + F102799B90F2BBF57EBDBBDC /* PBXTargetDependency */, + 24ECB0864CAE52D6ABF4503E /* PBXTargetDependency */, + ); + name = StripeCameraCoreTests; + productName = StripeCameraCoreTests; + productReference = 226FED2248EE3F96F217BEE9 /* StripeCameraCoreTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + B93DF1E4460962AFB28CF144 /* Project object */ = { + isa = PBXProject; + attributes = { + TargetAttributes = { + }; + }; + buildConfigurationList = C1216BCDB94950EBFA541D74 /* Build configuration list for PBXProject "StripeCameraCore" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = F72F2AFE9BE5E2CCBE7C6436; + productRefGroup = E2B3561EEB5629A212FD2AB4 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + B50782C89D54809DFFCF80D0 /* StripeCameraCore */, + 6A948E338F0BF55DBFD83170 /* StripeCameraCoreTestUtils */, + F3DED9AB60FDAB786A737384 /* StripeCameraCoreTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 445990B4DBEECCD2FBA8B324 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 83C726705BB615A9940B98CC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 98362E27FEF7FBD502400444 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 62DE5865F3F7B8CB25F62395 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0485C4A62D9444E75877E170 /* CameraExifMetadata.swift in Sources */, + 48B09ECBED820F3097779208 /* CGRect+StripeCameraCore.swift in Sources */, + 2CEF8A27C8864A6198C69A5D /* CVPixelBuffer+StripeCameraCore.swift in Sources */, + F181EEBCE3D2CAD043DAF0F0 /* UIDeviceOrientation+StripeCameraCore.swift in Sources */, + D9687FAD52D1896F28309580 /* UIImage+Buffer.swift in Sources */, + 9FC42D068D1719AB28C3068F /* AppSettingsHelper.swift in Sources */, + 06FEEA450541E2D36D5A10FC /* CameraPermissionsManager.swift in Sources */, + A95255494DF27A7CAE9A70CE /* CameraSession.swift in Sources */, + 2A1AAD8E5A589E4E74B02A35 /* MockSimulatorCameraSession.swift in Sources */, + 0F02BB3F9F7936D7BB593226 /* Torch.swift in Sources */, + 9CE6EE572439AD5B22B77294 /* CameraPreviewView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 85B8B0A9CAD01C91AD19797A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AB24784F49EB99E7C36509BD /* CGRect_StripeCameraCoreTest.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F2FB0D7ACB63AFA0A81A5FF6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 88A1AF2B173B4055D8F44A8C /* MockAppSettingsHelper.swift in Sources */, + 064567ECD2B71E5EB7D3A201 /* MockCameraPermissionsManager.swift in Sources */, + 8F3073987F30B9B8DDF0D956 /* MockTestCameraSession.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 24ECB0864CAE52D6ABF4503E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = StripeCameraCoreTestUtils; + target = 6A948E338F0BF55DBFD83170 /* StripeCameraCoreTestUtils */; + targetProxy = 55BD94DFEA7738F26C23A545 /* PBXContainerItemProxy */; + }; + D1513DE143A5DDFAB1AA4069 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = StripeCameraCore; + target = B50782C89D54809DFFCF80D0 /* StripeCameraCore */; + targetProxy = 0321B7B71CC9BDDCF236B375 /* PBXContainerItemProxy */; + }; + F102799B90F2BBF57EBDBBDC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = StripeCameraCore; + target = B50782C89D54809DFFCF80D0 /* StripeCameraCore */; + targetProxy = BA0100EB31091CFA64BF12A6 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 1555A494851A82726CF41684 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 17E9A614AF4543DB62E7F5B4 /* StripeiOS Tests-Release.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeCameraCoreTestUtils/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeCameraCoreTestUtils; + PRODUCT_NAME = StripeCameraCoreTestUtils; + SDKROOT = iphoneos; + }; + name = Release; + }; + 8101CFE889F8AC21AF12FC99 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 83596C8ED3829B17A93490A6 /* StripeiOS-Debug.xcconfig */; + buildSettings = { + INFOPLIST_FILE = StripeCameraCore/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "com.stripe.stripe-camera-core"; + PRODUCT_NAME = StripeCameraCore; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 85DD0786DE8D23550B2113B7 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = ABCCADF0E7337301A472E75C /* Project-Debug.xcconfig */; + buildSettings = { + }; + name = Debug; + }; + A6D92FFDFE11DCBEC32190D4 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 44E9A4900C4B8D08A617488C /* StripeiOS Tests-Debug.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeCameraCoreTests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeCameraCoreTests; + PRODUCT_NAME = StripeCameraCoreTests; + SDKROOT = iphoneos; + }; + name = Debug; + }; + B26CBFD3B42072A398AE6605 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 17E9A614AF4543DB62E7F5B4 /* StripeiOS Tests-Release.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeCameraCoreTests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeCameraCoreTests; + PRODUCT_NAME = StripeCameraCoreTests; + SDKROOT = iphoneos; + }; + name = Release; + }; + E1A07E117DC9E8051D871578 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4E0D85BEF8C269A46D5ECEAC /* StripeiOS-Release.xcconfig */; + buildSettings = { + INFOPLIST_FILE = StripeCameraCore/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "com.stripe.stripe-camera-core"; + PRODUCT_NAME = StripeCameraCore; + SDKROOT = iphoneos; + }; + name = Release; + }; + F11D6A5494AA72875ECC53A4 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0F4174046507759A9668F4EE /* Project-Release.xcconfig */; + buildSettings = { + }; + name = Release; + }; + FD80773E1582BF61724D6EDE /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 44E9A4900C4B8D08A617488C /* StripeiOS Tests-Debug.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeCameraCoreTestUtils/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeCameraCoreTestUtils; + PRODUCT_NAME = StripeCameraCoreTestUtils; + SDKROOT = iphoneos; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 12D1B5D975CF0E6EC4956438 /* Build configuration list for PBXNativeTarget "StripeCameraCoreTestUtils" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FD80773E1582BF61724D6EDE /* Debug */, + 1555A494851A82726CF41684 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 6BB26614346BD154B399D073 /* Build configuration list for PBXNativeTarget "StripeCameraCoreTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A6D92FFDFE11DCBEC32190D4 /* Debug */, + B26CBFD3B42072A398AE6605 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7056F053793C453C8750F440 /* Build configuration list for PBXNativeTarget "StripeCameraCore" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8101CFE889F8AC21AF12FC99 /* Debug */, + E1A07E117DC9E8051D871578 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C1216BCDB94950EBFA541D74 /* Build configuration list for PBXProject "StripeCameraCore" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 85DD0786DE8D23550B2113B7 /* Debug */, + F11D6A5494AA72875ECC53A4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = B93DF1E4460962AFB28CF144 /* Project object */; +} diff --git a/StripeCameraCore/StripeCameraCore.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/StripeCameraCore/StripeCameraCore.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/StripeCameraCore/StripeCameraCore.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/StripeCameraCore/StripeCameraCore.xcodeproj/xcshareddata/xcschemes/StripeCameraCore.xcscheme b/StripeCameraCore/StripeCameraCore.xcodeproj/xcshareddata/xcschemes/StripeCameraCore.xcscheme new file mode 100644 index 00000000..d616e1ac --- /dev/null +++ b/StripeCameraCore/StripeCameraCore.xcodeproj/xcshareddata/xcschemes/StripeCameraCore.xcscheme @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StripeCameraCore/StripeCameraCore/Info.plist b/StripeCameraCore/StripeCameraCore/Info.plist new file mode 100644 index 00000000..cd4a496b --- /dev/null +++ b/StripeCameraCore/StripeCameraCore/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(CURRENT_PROJECT_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/StripeCameraCore/StripeCameraCore/Source/CameraExifMetadata.swift b/StripeCameraCore/StripeCameraCore/Source/CameraExifMetadata.swift new file mode 100644 index 00000000..4860f94b --- /dev/null +++ b/StripeCameraCore/StripeCameraCore/Source/CameraExifMetadata.swift @@ -0,0 +1,46 @@ +// +// CameraExifMetadata.swift +// StripeCameraCore +// +// Created by Mel Ludowise on 4/14/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import CoreMedia +import Foundation +import ImageIO + +/// A helper to extract properties from an EXIF metadata dictionary +@_spi(STP) public struct CameraExifMetadata: Equatable { + public let brightnessValue: Double? + public let focalLength: Double? + public let lensModel: String? +} + +extension CameraExifMetadata { + public init?( + exifDictionary: [CFString: Any]? + ) { + guard let exifDictionary = exifDictionary else { + return nil + } + + self.init( + brightnessValue: exifDictionary[kCGImagePropertyExifBrightnessValue] as? Double, + focalLength: exifDictionary[kCGImagePropertyExifFocalLength] as? Double, + lensModel: exifDictionary[kCGImagePropertyExifLensModel] as? String + ) + } + + public init?( + sampleBuffer: CMSampleBuffer + ) { + self.init( + exifDictionary: CMGetAttachment( + sampleBuffer, + key: kCGImagePropertyExifDictionary, + attachmentModeOut: nil + ) as? [CFString: Any] + ) + } +} diff --git a/StripeCameraCore/StripeCameraCore/Source/Categories/CGRect+StripeCameraCore.swift b/StripeCameraCore/StripeCameraCore/Source/Categories/CGRect+StripeCameraCore.swift new file mode 100644 index 00000000..29c5661b --- /dev/null +++ b/StripeCameraCore/StripeCameraCore/Source/Categories/CGRect+StripeCameraCore.swift @@ -0,0 +1,80 @@ +// +// CGRect+StripeCameraCore.swift +// StripeCameraCore +// +// Created by Mel Ludowise on 12/14/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import CoreGraphics +import Foundation + +@_spi(STP) extension CGRect { + + /// Represents the bounds of a normalized coordinate system with range from (0,0) to (1,1) + public static let normalizedBounds = CGRect(x: 0, y: 0, width: 1, height: 1) + + /// - Returns: A `CGRect` that has its y-coordinates inverted between the + /// upper-left corner and lower-left corner. + /// + /// - Note: + /// This should only be used for rects that are using a normalized + /// coordinate system, meaning that the coordinate of the corner opposite + /// origin is (1,1) + public var invertedNormalizedCoordinates: CGRect { + return CGRect( + x: minX, + y: 1 - minY - height, + width: width, + height: height + ) + } + + /// Converts a rectangle that's using a normalized coordinate system from a + /// center-crop coordinate system to an un-cropped coordinate system + /// + /// Example, if the original size has a portrait aspect ratio, center-cropping + /// the rect will result in the square area: + /// ``` + /// +---------+ + /// | | + /// |---------| + /// | | + /// | | + /// | | + /// |---------| + /// | | + /// +---------+ + /// ``` + /// + /// This method converts the rect's coordinate relative to the center-cropped + /// area into coordinates relative to the original un-cropped area: + /// ``` + /// +---------+ + /// | | + /// +---------+ | | + /// | +--+ | | +--+ | + /// | | | | --> | | | | + /// | +--+ | | +--+ | + /// +---------+ | | + /// | | + /// +---------+ + /// ``` + /// + /// - Parameters: + /// - size: The original size of the un-cropped area. + public func convertFromNormalizedCenterCropSquare( + toOriginalSize originalSize: CGSize + ) -> CGRect { + let croppedWidth = min(originalSize.width, originalSize.height) + let scaleX = croppedWidth / originalSize.width + let scaleY = croppedWidth / originalSize.height + + return CGRect( + x: (minX - 0.5) * scaleX + 0.5, + y: (minY - 0.5) * scaleY + 0.5, + width: width * scaleX, + height: height * scaleY + ) + } +} diff --git a/StripeCameraCore/StripeCameraCore/Source/Categories/CVPixelBuffer+StripeCameraCore.swift b/StripeCameraCore/StripeCameraCore/Source/Categories/CVPixelBuffer+StripeCameraCore.swift new file mode 100644 index 00000000..6d147652 --- /dev/null +++ b/StripeCameraCore/StripeCameraCore/Source/Categories/CVPixelBuffer+StripeCameraCore.swift @@ -0,0 +1,18 @@ +// +// CVPixelBuffer+StripeCameraCore.swift +// StripeCameraCore +// +// Created by Mel Ludowise on 3/16/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import CoreVideo +import VideoToolbox + +@_spi(STP) extension CVPixelBuffer { + public func cgImage() -> CGImage? { + var cgImage: CGImage? + VTCreateCGImageFromCVPixelBuffer(self, options: nil, imageOut: &cgImage) + return cgImage + } +} diff --git a/StripeCameraCore/StripeCameraCore/Source/Categories/UIDeviceOrientation+StripeCameraCore.swift b/StripeCameraCore/StripeCameraCore/Source/Categories/UIDeviceOrientation+StripeCameraCore.swift new file mode 100644 index 00000000..e9bdbdf5 --- /dev/null +++ b/StripeCameraCore/StripeCameraCore/Source/Categories/UIDeviceOrientation+StripeCameraCore.swift @@ -0,0 +1,26 @@ +// +// UIDeviceOrientation+StripeCameraCore.swift +// StripeCameraCore +// +// Created by Mel Ludowise on 1/20/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import AVKit +import Foundation +import UIKit + +@_spi(STP) extension UIDeviceOrientation { + public var videoOrientation: AVCaptureVideoOrientation { + switch UIDevice.current.orientation { + case .portraitUpsideDown: + return .portraitUpsideDown + case .landscapeLeft: + return .landscapeRight + case .landscapeRight: + return .landscapeLeft + default: + return .portrait + } + } +} diff --git a/StripeCameraCore/StripeCameraCore/Source/Categories/UIImage+Buffer.swift b/StripeCameraCore/StripeCameraCore/Source/Categories/UIImage+Buffer.swift new file mode 100644 index 00000000..9d13e713 --- /dev/null +++ b/StripeCameraCore/StripeCameraCore/Source/Categories/UIImage+Buffer.swift @@ -0,0 +1,118 @@ +// Ignoring file format becase of compiler directive which would force the whole file to be +// indented, including import statements. +// swift-format-ignore-file + +// Taken from https://gist.github.com/createwithswift/30a058c2745c8b09e64e7b073485e516 +// +// MIT License +// +// Copyright (c) 2021 Create with Swift +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#if targetEnvironment(simulator) + +import CoreMedia +import UIKit + +extension UIImage { + @_spi(STP) public func convertToPixelBuffer() -> CVPixelBuffer? { + + let attributes = + [ + kCVPixelBufferCGImageCompatibilityKey: kCFBooleanTrue, + kCVPixelBufferCGBitmapContextCompatibilityKey: kCFBooleanTrue, + ] as CFDictionary + + var pixelBuffer: CVPixelBuffer? + + let status = CVPixelBufferCreate( + kCFAllocatorDefault, + Int(self.size.width), + Int(self.size.height), + kCVPixelFormatType_32ARGB, + attributes, + &pixelBuffer + ) + + guard status == kCVReturnSuccess else { + return nil + } + + CVPixelBufferLockBaseAddress(pixelBuffer!, CVPixelBufferLockFlags(rawValue: 0)) + + let pixelData = CVPixelBufferGetBaseAddress(pixelBuffer!) + let rgbColorSpace = CGColorSpaceCreateDeviceRGB() + + let context = CGContext( + data: pixelData, + width: Int(self.size.width), + height: Int(self.size.height), + bitsPerComponent: 8, + bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer!), + space: rgbColorSpace, + bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue + ) + + context?.translateBy(x: 0, y: self.size.height) + context?.scaleBy(x: 1.0, y: -1.0) + + UIGraphicsPushContext(context!) + self.draw(in: CGRect(x: 0, y: 0, width: self.size.width, height: self.size.height)) + UIGraphicsPopContext() + + CVPixelBufferUnlockBaseAddress(pixelBuffer!, CVPixelBufferLockFlags(rawValue: 0)) + + return pixelBuffer + } + + @_spi(STP) public func convertToSampleBuffer() -> CMSampleBuffer? { + guard let pixelBuffer = convertToPixelBuffer() else { + return nil + } + + var sampleBuffer: CMSampleBuffer? + var optionalVideoInfo: CMVideoFormatDescription? + var timingInfo: CMSampleTimingInfo = .invalid + + CMVideoFormatDescriptionCreateForImageBuffer( + allocator: nil, + imageBuffer: pixelBuffer, + formatDescriptionOut: &optionalVideoInfo + ) + guard let videoInfo = optionalVideoInfo else { + return nil + } + + CMSampleBufferCreateForImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pixelBuffer, + dataReady: true, + makeDataReadyCallback: nil, + refcon: nil, + formatDescription: videoInfo, + sampleTiming: &timingInfo, + sampleBufferOut: &sampleBuffer + ) + + return sampleBuffer + } +} + +#endif diff --git a/StripeCameraCore/StripeCameraCore/Source/Coordinators/AppSettingsHelper.swift b/StripeCameraCore/StripeCameraCore/Source/Coordinators/AppSettingsHelper.swift new file mode 100644 index 00000000..02b2ff78 --- /dev/null +++ b/StripeCameraCore/StripeCameraCore/Source/Coordinators/AppSettingsHelper.swift @@ -0,0 +1,43 @@ +// +// AppSettingsHelper.swift +// StripeCameraCore +// +// Created by Mel Ludowise on 12/3/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +public protocol AppSettingsHelperProtocol { + var canOpenAppSettings: Bool { get } + func openAppSettings() +} + +/// Helper class that opens the app's settings screen in the Settings app. +@_spi(STP) public class AppSettingsHelper: AppSettingsHelperProtocol { + + public static let shared = AppSettingsHelper() + + private(set) lazy var appSettingsUrl: URL? = URL(string: UIApplication.openSettingsURLString) + + private init() { + // Use shared instance instead of init + } + + /// `true` if the system is able to open the app's settings screen. + public var canOpenAppSettings: Bool { + guard let settingsUrl = appSettingsUrl else { + return false + } + return UIApplication.shared.canOpenURL(settingsUrl) + } + + /// Opens the app's settings screen, if possible. + public func openAppSettings() { + guard let settingsUrl = appSettingsUrl else { + return + } + UIApplication.shared.open(settingsUrl) + } +} diff --git a/StripeCameraCore/StripeCameraCore/Source/Coordinators/CameraPermissionsManager.swift b/StripeCameraCore/StripeCameraCore/Source/Coordinators/CameraPermissionsManager.swift new file mode 100644 index 00000000..36db983b --- /dev/null +++ b/StripeCameraCore/StripeCameraCore/Source/Coordinators/CameraPermissionsManager.swift @@ -0,0 +1,85 @@ +// +// CameraPermissionsManager.swift +// StripeCameraCore +// +// Created by Mel Ludowise on 12/3/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import AVKit +import Foundation + +@_spi(STP) public protocol CameraPermissionsManagerProtocol { + /// Completion block called when done requesting permissions. + /// + /// - Parameter granted: If camera permissions are granted for the app. + /// Value is `nil` if the authorization status cannot be determined. + typealias CompletionBlock = (_ granted: Bool?) -> Void + + var hasCameraAccess: Bool { get } + func requestCameraAccess( + completeOnQueue queue: DispatchQueue, + completion: @escaping CompletionBlock + ) +} + +/// Dependency-injectable class to assist with accessing and requesting camera authorization +@_spi(STP) public final class CameraPermissionsManager: CameraPermissionsManagerProtocol { + + public static let shared = CameraPermissionsManager() + + /// If this app currently has authorization to access the device's camera + public var hasCameraAccess: Bool { + return AVCaptureDevice.authorizationStatus(for: .video) == .authorized + } + + private init() { + // Use shared instance instead of init + } + + /// Requests camera permissions and calls completion block with result after retrieving them. + /// + /// - Parameters: + /// - completion: + /// - queue: DispatchQueue to complete the + /// + /// - Note: + /// If the user has already granted or denied camera permissions to the app, + /// this callback will respond immediately after `requestCameraAccess` is + /// called on the video feed and `showedPrompt` will be false. + /// + /// If the user has not yet granted or denied camera permissions to the app, + /// they will be prompted to do so. This callback will respond after the user + /// selects a response and `showedPrompt` will be true. + /// + public func requestCameraAccess( + completeOnQueue queue: DispatchQueue = .main, + completion: @escaping CompletionBlock + ) { + let wrappedCompletion: CompletionBlock = { granted in + queue.async { + completion(granted) + } + } + + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + wrappedCompletion(true) + + case .notDetermined: + AVCaptureDevice.requestAccess( + for: .video, + completionHandler: { granted in + wrappedCompletion(granted) + } + ) + + case .restricted, + .denied: + wrappedCompletion(false) + + default: + wrappedCompletion(nil) + } + } +} diff --git a/StripeCameraCore/StripeCameraCore/Source/Coordinators/CameraSession.swift b/StripeCameraCore/StripeCameraCore/Source/Coordinators/CameraSession.swift new file mode 100644 index 00000000..552e6b9b --- /dev/null +++ b/StripeCameraCore/StripeCameraCore/Source/Coordinators/CameraSession.swift @@ -0,0 +1,507 @@ +// +// CameraSession.swift +// StripeCameraCore +// +// Created by Jaime Park on 12/16/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import AVKit +@_spi(STP) import StripeCore + +@_spi(STP) @frozen public enum CameraSessionError: Error { + /// Can't find capture device to add + case captureDeviceNotFound + /// Session configuration has failed + case configurationFailed +} + +@_spi(STP) public protocol CameraSessionProtocol: AnyObject { + + var previewView: CameraPreviewView? { get set } + + func configureSession( + configuration: CameraSession.Configuration, + delegate: AVCaptureVideoDataOutputSampleBufferDelegate, + completeOn queue: DispatchQueue, + completion: @escaping (CameraSession.SetupResult) -> Void + ) + + func setVideoOrientation( + orientation: AVCaptureVideoOrientation + ) + + func toggleCamera( + to position: CameraSession.CameraPosition, + completeOn queue: DispatchQueue, + completion: @escaping (CameraSession.SetupResult) -> Void + ) + + func toggleTorch() + + func getCameraProperties() -> CameraSession.DeviceProperties? + + func startSession( + completeOn queue: DispatchQueue, + completion: @escaping () -> Void + ) + + func stopSession( + completeOn queue: DispatchQueue, + completion: @escaping () -> Void + ) +} + +@_spi(STP) public final class CameraSession: CameraSessionProtocol { + @frozen public enum SetupResult { + /// Session has successfully updated + case success + /// Session did not update due to an error + case failed(error: Error) + } + + public enum CameraPosition { + case front + case back + } + + public struct Configuration { + /// The initial position of camera: front or back + public let initialCameraPosition: CameraPosition + /// The initial video orientation of the camera session + public let initialOrientation: AVCaptureVideoOrientation + /// The capture device’s focus mode. + /// - Seealso: https://developer.apple.com/documentation/avfoundation/avcapturedevice/focusmode + public let focusMode: AVCaptureDevice.FocusMode? + /// The point of interest for focusing. + /// - Seealso: + /// https://developer.apple.com/documentation/avfoundation/avcapturedevice/focuspointofinterest + public let focusPointOfInterest: CGPoint? + /// A preset value of the quality of the capture session + public let sessionPreset: AVCaptureSession.Preset + /// Video settings for the video output + /// - Seealso: https://developer.apple.com/documentation/avfoundation/avcapturephotosettings/video_settings + public let outputSettings: [String: Any] + + /// - Parameters: + /// - initialCameraPosition: The initial position of camera: front or back + /// - initialOrientation: The initial video orientation of the camera session + /// - focusMode: The focus mode of the camera session + /// - focusPointOfInterest: The point of interest for focusing + /// - sessionPreset: A preset value of the quality of the capture session + /// - outputSettings: Video settings for the video output + public init( + initialCameraPosition: CameraPosition, + initialOrientation: AVCaptureVideoOrientation, + focusMode: AVCaptureDevice.FocusMode? = nil, + focusPointOfInterest: CGPoint? = nil, + sessionPreset: AVCaptureSession.Preset = .high, + outputSettings: [String: Any] = [:] + ) { + self.initialCameraPosition = initialCameraPosition + self.initialOrientation = initialOrientation + self.focusMode = focusMode + self.focusPointOfInterest = focusPointOfInterest + self.sessionPreset = sessionPreset + self.outputSettings = outputSettings + } + } + + public struct DeviceProperties: Equatable { + public let exposureDuration: CMTime + public let cameraDeviceType: AVCaptureDevice.DeviceType + public let isVirtualDevice: Bool? + public let lensPosition: Float + public let exposureISO: Float + public let isAdjustingFocus: Bool + } + + // MARK: - Properties + + public weak var previewView: CameraPreviewView? { + didSet { + guard oldValue !== previewView else { + return + } + + // Remove captureSession from previous view and add it to new one + oldValue?.setCaptureSession(nil, on: sessionQueue) + previewView?.setCaptureSession(session, on: sessionQueue) + } + } + + private let session: AVCaptureSession = AVCaptureSession() + private var captureConnection: AVCaptureConnection? + private let sessionQueue = DispatchQueue(label: "com.stripe.camera-session") + private var torchDevice: Torch? + private var setupResult: SetupResult? + + private var videoDeviceInput: AVCaptureDeviceInput? { + didSet { + if let videoDeviceInput = videoDeviceInput { + // Set torch with new capture input + self.torchDevice = Torch(device: videoDeviceInput.device) + } + } + } + + // MARK: - Public + + public init() { + // This is needed to expose init publicly + } + + /// Configures the camera session with the initial inputs and outputs. + /// + /// If the camera session has been configured already, then the configuration + /// is ignored and the previous setup result is passed to the completion block. + /// + /// - Parameters: + /// - configuration: Configuration settings for the session + /// - delegate: + /// - queue: DispatchQueue the completion block should be called on + /// - completion: A block executed when the session is done being configured + public func configureSession( + configuration: Configuration, + delegate: AVCaptureVideoDataOutputSampleBufferDelegate, + completeOn queue: DispatchQueue, + completion: @escaping (SetupResult) -> Void + ) { + sessionQueue.async { [weak self] in + guard let self = self else { return } + + // Check if already configured + if let setupResult = self.setupResult { + completion(setupResult) + return + } + + self.session.beginConfiguration() + self.session.sessionPreset = configuration.sessionPreset + self.session.commitConfiguration() + + self.configureSessionInput(with: configuration.initialCameraPosition).chained { + [weak self] _ -> Future in + guard let self = self else { + // If self has been deallocated before configuring output, return failure + let promise = Promise() + promise.reject(with: CameraSessionError.configurationFailed) + return promise + } + + return self.configureSessionOutput( + with: configuration.outputSettings, + orientation: configuration.initialOrientation, + focusMode: configuration.focusMode, + focusPointOfInterest: configuration.focusPointOfInterest, + delegate: delegate + ) + }.observe(on: queue) { [weak self] result in + self?.setupResult = result.setupResult + completion(result.setupResult) + } + } + } + + public func setFocus( + focusMode: AVCaptureDevice.FocusMode, + focusPointOfInterest: CGPoint? = nil, + completion: @escaping (Error?) -> Void + ) { + sessionQueue.async { [weak self] in + do { + try self?.setFocusOnCurrentQueue( + focusMode: focusMode, + focusPointOfInterest: focusPointOfInterest + ) + completion(nil) + } catch { + completion(error) + } + } + } + + /// Attempts to change the video orientation of both the session output + /// and the preview view layer. + /// + /// - Parameters: + /// - orientation: The desired video orientation + public func setVideoOrientation( + orientation: AVCaptureVideoOrientation + ) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + self.captureConnection?.videoOrientation = orientation + self.previewView?.videoPreviewLayer.connection?.videoOrientation = orientation + } + } + + /// Returns the properties from the camera device. + /// + /// - Note: This method can only be called on the camera session thread, + /// meaning it's only meant to be called from the output delegate's + /// `captureOutput` method. + public func getCameraProperties() -> CameraSession.DeviceProperties? { + dispatchPrecondition(condition: .onQueue(sessionQueue)) + + guard let device = videoDeviceInput?.device else { + return nil + } + + let isVirtualDevice = device.isVirtualDevice + + return .init( + exposureDuration: device.exposureDuration, + cameraDeviceType: device.deviceType, + isVirtualDevice: isVirtualDevice, + lensPosition: device.lensPosition, + exposureISO: device.iso, + isAdjustingFocus: device.isAdjustingFocus + ) + } + + /// Attempts to switch camera input to a new camera position. + /// - Parameters: + /// - position: The camera position to toggle to + /// - queue: DispatchQueue the completion block should be called on + /// - completion: A block executed when the camera has finished toggling + public func toggleCamera( + to position: CameraPosition, + completeOn queue: DispatchQueue, + completion: @escaping (SetupResult) -> Void + ) { + configureSessionInput(with: position).observe(on: queue) { result in + completion(result.setupResult) + } + } + + /// Attempts to toggle the torch on or off. + public func toggleTorch() { + self.torchDevice?.toggle() + } + + /// Starts the camera session, calling a completion block when the session has + /// been started. + /// + /// - Parameters: + /// - queue: The queue to call the completion block on + /// - completion: A block executed when the session has been started + public func startSession( + completeOn queue: DispatchQueue, + completion: @escaping () -> Void + ) { + sessionQueue.async { [weak self] in + defer { + queue.async { + completion() + } + } + + guard let self = self, + case .success = self.setupResult + else { + return + } + + self.session.startRunning() + } + } + + /// Stop the camera session + public func stopSession( + completeOn queue: DispatchQueue, + completion: @escaping () -> Void + ) { + sessionQueue.async { [weak self] in + defer { + queue.async { + completion() + } + } + + guard let self = self, + case .success = self.setupResult + else { + return + } + + self.session.stopRunning() + } + } +} + +// MARK: - Private + +extension CameraSession { + fileprivate func configureSessionInput( + with position: CameraPosition + ) -> Future { + let promise = Promise() + + sessionQueue.async { [weak self] in + guard let self = self else { return } + + self.session.beginConfiguration() + defer { + self.session.commitConfiguration() + } + + do { + // Remove inputs + self.session.inputs.forEach { + self.session.removeInput($0) + } + + let newVideoDeviceInput = try self.captureDeviceInput(position: position) + + // Add video input + guard self.session.canAddInput(newVideoDeviceInput) + else { + promise.reject(with: CameraSessionError.configurationFailed) + return + } + self.session.addInput(newVideoDeviceInput) + + // Keep reference to video device input + self.videoDeviceInput = newVideoDeviceInput + + promise.resolve(with: ()) + } catch { + promise.reject(with: error) + } + } + + return promise + } + + fileprivate func configureSessionOutput( + with videoSettings: [String: Any], + orientation: AVCaptureVideoOrientation, + focusMode: AVCaptureDevice.FocusMode?, + focusPointOfInterest: CGPoint?, + delegate: AVCaptureVideoDataOutputSampleBufferDelegate + ) -> Future { + let promise = Promise() + + sessionQueue.async { [weak self] in + guard let self = self else { return } + + self.session.beginConfiguration() + defer { + self.session.commitConfiguration() + } + + let videoOutput = AVCaptureVideoDataOutput() + videoOutput.videoSettings = videoSettings + videoOutput.alwaysDiscardsLateVideoFrames = true + videoOutput.setSampleBufferDelegate(delegate, queue: self.sessionQueue) + + guard self.session.canAddOutput(videoOutput) else { + promise.reject(with: CameraSessionError.configurationFailed) + return + } + + // Add output to session + self.session.addOutput(videoOutput) + + // Update output connection reference + self.captureConnection = videoOutput.connection(with: .video) + + // Update new output and previewLayer orientation + self.setVideoOrientation(orientation: orientation) + + // Set focus if needed + guard let focusMode = focusMode else { + promise.resolve(with: ()) + return + } + + promise.fulfill { [weak self] in + try self?.setFocusOnCurrentQueue( + focusMode: focusMode, + focusPointOfInterest: focusPointOfInterest + ) + } + } + + return promise + } + + fileprivate func captureDeviceInput(position: CameraPosition) throws -> AVCaptureDeviceInput { + let captureDevices = AVCaptureDevice.DiscoverySession( + deviceTypes: position.captureDeviceTypes, + mediaType: .video, + position: position.captureDevicePosition + ) + + guard let captureDevice = captureDevices.devices.first else { + throw CameraSessionError.captureDeviceNotFound + } + + return try AVCaptureDeviceInput(device: captureDevice) + } + + fileprivate func setFocusOnCurrentQueue( + focusMode: AVCaptureDevice.FocusMode, + focusPointOfInterest: CGPoint? + ) throws { + dispatchPrecondition(condition: .onQueue(sessionQueue)) + + guard let device = videoDeviceInput?.device else { + return + } + + try device.lockForConfiguration() + if device.isFocusModeSupported(focusMode) { + device.focusMode = focusMode + } + + if let focusPointOfInterest = focusPointOfInterest, + device.isFocusPointOfInterestSupported + { + device.focusPointOfInterest = focusPointOfInterest + } + + device.unlockForConfiguration() + } +} + +// MARK: - CameraPosition + +extension CameraSession.CameraPosition { + /// Returns a list of camera devices, ordered by preferred device, for this + /// camera position. + var captureDeviceTypes: [AVCaptureDevice.DeviceType] { + switch self { + case .front: + return [.builtInTrueDepthCamera, .builtInWideAngleCamera] + + case .back: + return [.builtInTripleCamera, .builtInDualCamera, .builtInDualWideCamera, .builtInWideAngleCamera] + } + } + + var captureDevicePosition: AVCaptureDevice.Position { + switch self { + case .front: + return .front + case .back: + return .back + } + } +} + +// MARK: - Result + +/// Helper to convert a Result into SetupResult +extension Result where Success == Void, Failure == Error { + fileprivate var setupResult: CameraSession.SetupResult { + switch self { + case .success: + return .success + case .failure(let error): + return .failed(error: error) + } + } +} diff --git a/StripeCameraCore/StripeCameraCore/Source/Coordinators/MockSimulatorCameraSession.swift b/StripeCameraCore/StripeCameraCore/Source/Coordinators/MockSimulatorCameraSession.swift new file mode 100644 index 00000000..ef0bdf74 --- /dev/null +++ b/StripeCameraCore/StripeCameraCore/Source/Coordinators/MockSimulatorCameraSession.swift @@ -0,0 +1,220 @@ +// +// MockSimulatorCameraSession.swift +// StripeCameraCore +// +// Created by Mel Ludowise on 1/21/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +// Ignoring file formatt becase of compiler directive which would force the whole file to be +// indented, including import statements. +// swift-format-ignore-file + +#if targetEnvironment(simulator) + +import AVKit +import Foundation +@_spi(STP) import StripeCore + +/// Mocks a CameraSession on the simulator. +@_spi(STP) public final class MockSimulatorCameraSession: CameraSessionProtocol { + + enum Error: Swift.Error { + case sessionNotConfigured + case noImages + } + + static let mockSampleBufferTimeInterval: TimeInterval = 0.1 + + private let images: [UIImage] + private var nextImageToReturn: Int = 0 + private var currentImage: UIImage? + private let sessionQueue = DispatchQueue(label: "com.stripe.mock-simulator-camera-session") + private let videoOutput = AVCaptureVideoDataOutput() + private lazy var captureConnection = AVCaptureConnection( + inputPorts: [], + output: videoOutput + ) + private var mockSampleBufferTimer: Timer? + weak private var delegate: AVCaptureVideoDataOutputSampleBufferDelegate? + + // MARK: - Public + + private var isConfigured: Bool = false + private var cameraPosition: CameraSession.CameraPosition = .front + + public weak var previewView: CameraPreviewView? { + didSet { + setPreviewViewToCurrentImage() + } + } + + public init( + images: [UIImage] + ) { + self.images = images + } + + public func configureSession( + configuration: CameraSession.Configuration, + delegate: AVCaptureVideoDataOutputSampleBufferDelegate, + completeOn queue: DispatchQueue, + completion: @escaping (CameraSession.SetupResult) -> Void + ) { + sessionQueue.async { [weak self] in + let wrappedCompletion = { setupResult in + queue.async { + completion(setupResult) + } + } + + guard let self = self else { return } + self.delegate = delegate + + guard !self.images.isEmpty else { + self.isConfigured = false + wrappedCompletion(.failed(error: Error.noImages)) + return + } + + self.cameraPosition = configuration.initialCameraPosition + self.isConfigured = true + wrappedCompletion(.success) + } + } + + public func setVideoOrientation(orientation: AVCaptureVideoOrientation) { + // no-op + } + + public func toggleCamera( + to position: CameraSession.CameraPosition, + completeOn queue: DispatchQueue, + completion: @escaping (CameraSession.SetupResult) -> Void + ) { + sessionQueue.async { [weak self] in + guard let self = self else { return } + + let wrappedCompletion = { setupResult in + queue.async { + completion(setupResult) + } + } + + guard self.isConfigured else { + wrappedCompletion(.failed(error: Error.sessionNotConfigured)) + return + } + + self.cameraPosition = position + wrappedCompletion(.success) + } + } + + public func getCameraProperties() -> CameraSession.DeviceProperties? { + return .init( + exposureDuration: CMTime(value: 0, timescale: 1), + cameraDeviceType: .builtInDualCamera, + isVirtualDevice: nil, + lensPosition: 0, + exposureISO: 0, + isAdjustingFocus: false + ) + } + + public func toggleTorch() { + // no-op + } + + public func startSession( + completeOn queue: DispatchQueue, + completion: @escaping () -> Void + ) { + sessionQueue.async { [weak self] in + guard let self = self else { return } + + defer { + queue.async { + completion() + } + } + + if self.isConfigured && self.currentImage == nil { + self.currentImage = self.images.stp_boundSafeObject(at: self.nextImageToReturn) + self.setPreviewViewToCurrentImage() + } + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.mockSampleBufferTimer = Timer.scheduledTimer( + timeInterval: MockSimulatorCameraSession.mockSampleBufferTimeInterval, + target: self, + selector: #selector(self.mockSampleBufferDelegateCallback), + userInfo: nil, + repeats: true + ) + } + } + } + + public func stopSession( + completeOn queue: DispatchQueue, + completion: @escaping () -> Void + ) { + sessionQueue.async { [weak self] in + guard let self = self else { return } + + defer { + queue.async { + completion() + } + } + + self.mockSampleBufferTimer?.invalidate() + + if self.currentImage != nil { + self.nextImageToReturn += 1 + } + self.currentImage = nil + } + } +} + +extension MockSimulatorCameraSession { + @objc fileprivate func mockSampleBufferDelegateCallback() { + sessionQueue.async { [weak self] in + guard let self = self, + var image = self.currentImage + else { + return + } + + // Flip image horizontally if mocking front-facing camera + if self.cameraPosition == .front, + let cgImage = image.cgImage + { + image = UIImage(cgImage: cgImage, scale: image.scale, orientation: .upMirrored) + } + + guard let sampleBuffer = image.convertToSampleBuffer() else { + return + } + + self.delegate?.captureOutput?( + self.videoOutput, + didOutput: sampleBuffer, + from: self.captureConnection + ) + } + } + + fileprivate func setPreviewViewToCurrentImage() { + DispatchQueue.main.async { [weak self, weak currentImage] in + guard let self = self else { return } + self.previewView?.layer.contents = currentImage?.cgImage + self.previewView?.layer.contentsGravity = .resizeAspectFill + } + } +} + +#endif diff --git a/StripeCameraCore/StripeCameraCore/Source/Coordinators/Torch.swift b/StripeCameraCore/StripeCameraCore/Source/Coordinators/Torch.swift new file mode 100644 index 00000000..caa5ef05 --- /dev/null +++ b/StripeCameraCore/StripeCameraCore/Source/Coordinators/Torch.swift @@ -0,0 +1,53 @@ +// +// Torch.swift +// StripeCameraCore +// +// Created by Mel Ludowise on 12/1/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import AVFoundation +import Foundation + +struct Torch { + enum State { + case off + case on + } + let device: AVCaptureDevice? + var state: State + var lastStateChange: Date + var level: Float + + init( + device: AVCaptureDevice + ) { + self.state = .off + self.lastStateChange = Date() + if device.hasTorch { + self.device = device + if device.isTorchActive { + self.state = .on + } + } else { + self.device = nil + } + self.level = 1.0 + } + + mutating func toggle() { + self.state = self.state == .on ? .off : .on + do { + try self.device?.lockForConfiguration() + if self.state == .on { + try self.device?.setTorchModeOn(level: self.level) + } else { + self.device?.torchMode = .off + } + } catch { + // no-op + } + // Always unlock when we're done even if `setTorchModeOn` threw + self.device?.unlockForConfiguration() + } +} diff --git a/StripeCameraCore/StripeCameraCore/Source/Views/CameraPreviewView.swift b/StripeCameraCore/StripeCameraCore/Source/Views/CameraPreviewView.swift new file mode 100644 index 00000000..61a8ed97 --- /dev/null +++ b/StripeCameraCore/StripeCameraCore/Source/Views/CameraPreviewView.swift @@ -0,0 +1,81 @@ +// +// CameraPreviewView.swift +// StripeCameraCore +// +// Created by Mel Ludowise on 12/1/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import AVFoundation +import UIKit + +@_spi(STP) public class CameraPreviewView: UIView { + var videoPreviewLayer: AVCaptureVideoPreviewLayer { + return layer as! AVCaptureVideoPreviewLayer + } + + /// Camera session that configures the video feed for this view. + /// + /// - Note: + /// A session can only be used on one `PreviewView` at a time. Setting the same + /// session on a different `PreviewView` will remove it from the previous one. + public weak var session: CameraSessionProtocol? { + didSet { + guard oldValue !== session else { + return + } + oldValue?.previewView = nil + session?.previewView = self + } + } + + // MARK: Initialization + + public init() { + super.init(frame: .zero) + + videoPreviewLayer.videoGravity = .resizeAspectFill + } + + required init?( + coder aDecoder: NSCoder + ) { + super.init(coder: aDecoder) + } + + // MARK: UIView + + public override class var layerClass: AnyClass { + return AVCaptureVideoPreviewLayer.self + } + + // MARK: Internal + + /// Sets the video capture session in thread-safe way that doesn't block main + /// thread when configuring the session. + /// + /// - Parameters: + /// - captureSession: The capture session to set on this view + /// - cameraSessionQueue: The CameraSession's queue to configure the session + func setCaptureSession( + _ captureSession: AVCaptureSession?, + on cameraSessionQueue: DispatchQueue + ) { + // Get reference to videoPreviewLayer on main queue then dispatch to + // worker queue to set session so it doesn't block main. + + let mainWorkItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + cameraSessionQueue.async { + [weak captureSession, weak videoPreviewLayer = self.videoPreviewLayer] in + videoPreviewLayer?.session = captureSession + } + } + + if Thread.isMainThread { + mainWorkItem.perform() + } else { + DispatchQueue.main.async(execute: mainWorkItem) + } + } +} diff --git a/StripeCameraCore/StripeCameraCore/StripeCameraCore.h b/StripeCameraCore/StripeCameraCore/StripeCameraCore.h new file mode 100644 index 00000000..6cb45611 --- /dev/null +++ b/StripeCameraCore/StripeCameraCore/StripeCameraCore.h @@ -0,0 +1,18 @@ +// +// StripeCameraCore.h +// StripeCameraCore +// +// Created by Mel Ludowise on 11/15/21. +// + +#import + +//! Project version number for StripeCameraCore. +FOUNDATION_EXPORT double StripeCameraCoreVersionNumber; + +//! Project version string for StripeCameraCore. +FOUNDATION_EXPORT const unsigned char StripeCameraCoreVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/StripeCameraCore/StripeCameraCoreTestUtils/Info.plist b/StripeCameraCore/StripeCameraCoreTestUtils/Info.plist new file mode 100644 index 00000000..9bcb2444 --- /dev/null +++ b/StripeCameraCore/StripeCameraCoreTestUtils/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/StripeCameraCore/StripeCameraCoreTestUtils/Mocks/MockAppSettingsHelper.swift b/StripeCameraCore/StripeCameraCoreTestUtils/Mocks/MockAppSettingsHelper.swift new file mode 100644 index 00000000..608e1d1f --- /dev/null +++ b/StripeCameraCore/StripeCameraCoreTestUtils/Mocks/MockAppSettingsHelper.swift @@ -0,0 +1,21 @@ +// +// MockAppSettingsHelper.swift +// StripeCameraCoreTestUtils +// +// Created by Mel Ludowise on 12/3/21. +// + +import Foundation +@_spi(STP) import StripeCameraCore + +@_spi(STP) public class MockAppSettingsHelper: AppSettingsHelperProtocol { + + public var canOpenAppSettings = false + public private(set) var didOpenAppSettings = false + + public init() {} + + public func openAppSettings() { + didOpenAppSettings = true + } +} diff --git a/StripeCameraCore/StripeCameraCoreTestUtils/Mocks/MockCameraPermissionsManager.swift b/StripeCameraCore/StripeCameraCoreTestUtils/Mocks/MockCameraPermissionsManager.swift new file mode 100644 index 00000000..811451be --- /dev/null +++ b/StripeCameraCore/StripeCameraCoreTestUtils/Mocks/MockCameraPermissionsManager.swift @@ -0,0 +1,41 @@ +// +// MockCameraPermissionsManager.swift +// StripeCameraCoreTestUtils +// +// Created by Mel Ludowise on 12/3/21. +// + +import Foundation +@_spi(STP) import StripeCameraCore +import XCTest + +@_spi(STP) public class MockCameraPermissionsManager: CameraPermissionsManagerProtocol { + + public var hasCameraAccess = false + + public private(set) var didRequestCameraAccess = false + public let didCompleteExpectation = XCTestExpectation( + description: "MockCameraPermissionsManager completion did finish" + ) + + private var completion: CompletionBlock = { _ in } + + public init() {} + + public func requestCameraAccess( + completeOnQueue queue: DispatchQueue, + completion: @escaping CompletionBlock + ) { + self.completion = { granted in + queue.async { [weak self] in + completion(granted) + self?.didCompleteExpectation.fulfill() + } + } + didRequestCameraAccess = true + } + + public func respondToRequest(granted: Bool?) { + completion(granted) + } +} diff --git a/StripeCameraCore/StripeCameraCoreTestUtils/Mocks/MockTestCameraSession.swift b/StripeCameraCore/StripeCameraCoreTestUtils/Mocks/MockTestCameraSession.swift new file mode 100644 index 00000000..5ba9bea0 --- /dev/null +++ b/StripeCameraCore/StripeCameraCoreTestUtils/Mocks/MockTestCameraSession.swift @@ -0,0 +1,180 @@ +// +// MockTestCameraSession.swift +// StripeCameraCoreTestUtils +// +// Created by Mel Ludowise on 1/21/22. +// + +import AVKit +import Foundation +@_spi(STP)@testable import StripeCameraCore +import XCTest + +@_spi(STP) public final class MockTestCameraSession: CameraSessionProtocol { + + /// Mock image to display in previewView. Should be used for snapshot tests + public var mockImage: UIImage? { + didSet { + setPreviewViewToMockImage() + } + } + + public var previewView: CameraPreviewView? { + didSet { + setPreviewViewToMockImage() + } + } + + public var mockDeviceProperties = CameraSession.DeviceProperties( + exposureDuration: CMTime(), + cameraDeviceType: .builtInDualCamera, + isVirtualDevice: nil, + lensPosition: 0, + exposureISO: 0, + isAdjustingFocus: false + ) + + public init() { + // no-op + } + + // MARK: configureSession + + private var configureCompletion: ((CameraSession.SetupResult) -> Void)? + public private(set) var configureSessionCompletionExp = XCTestExpectation( + description: "configureSession completion block called" + ) + + public private(set) var sessionPreset: AVCaptureSession.Preset? + public private(set) var outputSettings: [String: Any]? + + public func configureSession( + configuration: CameraSession.Configuration, + delegate: AVCaptureVideoDataOutputSampleBufferDelegate, + completeOn queue: DispatchQueue, + completion: @escaping (CameraSession.SetupResult) -> Void + ) { + cameraPosition = configuration.initialCameraPosition + videoOrientation = configuration.initialOrientation + sessionPreset = configuration.sessionPreset + outputSettings = configuration.outputSettings + configureCompletion = { setupResult in + queue.async { [weak self] in + self?.configureSessionCompletionExp.fulfill() + completion(setupResult) + } + } + } + + public func respondToConfigureSession(setupResult: CameraSession.SetupResult) { + configureCompletion?(setupResult) + } + + // MARK: setVideoOrientation + + public private(set) var videoOrientation: AVCaptureVideoOrientation? + + public func setVideoOrientation(orientation: AVCaptureVideoOrientation) { + self.videoOrientation = orientation + } + + // MARK: toggleCamera + + private var toggleCameraCompletion: ((CameraSession.SetupResult) -> Void)? + public private(set) var cameraPosition: CameraSession.CameraPosition? + + public func toggleCamera( + to position: CameraSession.CameraPosition, + completeOn queue: DispatchQueue, + completion: @escaping (CameraSession.SetupResult) -> Void + ) { + toggleCameraCompletion = { setupResult in + queue.async { + completion(setupResult) + } + } + cameraPosition = position + } + + public func respondToToggleCamera(setupResult: CameraSession.SetupResult) { + toggleCameraCompletion?(setupResult) + } + + // MARK: toggleTorch + + public private(set) var didToggleTorch = false + + public func toggleTorch() { + didToggleTorch = true + } + + // MARK: getCameraProperties + + public func getCameraProperties() -> CameraSession.DeviceProperties? { + return mockDeviceProperties + } + + // MARK: startSession + + private var startSessionCompletion: (() -> Void)? + public private(set) var startSessionCompletionExp = XCTestExpectation( + description: "startSession completion block called" + ) + + public func startSession( + completeOn queue: DispatchQueue, + completion: @escaping () -> Void + ) { + startSessionCompletion = { + queue.async { [weak self] in + self?.startSessionCompletionExp.fulfill() + completion() + } + } + } + + public func respondToStartSession() { + startSessionCompletion?() + } + + // MARK: stopSession + + private var stopSessionCompletion: (() -> Void)? + public private(set) var stopSessionCompletionExp = XCTestExpectation( + description: "stopSession completion block called" + ) + + public func stopSession( + completeOn queue: DispatchQueue, + completion: @escaping () -> Void + ) { + stopSessionCompletion = { + queue.async { [weak self] in + self?.stopSessionCompletionExp.fulfill() + completion() + } + } + } + + public func respondToStopSession() { + stopSessionCompletion?() + } + + // MARK: previewView + + func setPreviewViewToMockImage() { + let block = { [weak self] in + guard let self = self else { return } + self.previewView?.layer.contents = self.mockImage?.cgImage + self.previewView?.layer.contentsGravity = .resizeAspectFill + } + + // Only dispatch to main async if necessary so this can run + // synchronously for snapshot tests. + if Thread.isMainThread { + block() + } else { + DispatchQueue.main.async(execute: block) + } + } +} diff --git a/StripeCameraCore/StripeCameraCoreTestUtils/StripeCameraCoreTestUtils.h b/StripeCameraCore/StripeCameraCoreTestUtils/StripeCameraCoreTestUtils.h new file mode 100644 index 00000000..1f3c16b0 --- /dev/null +++ b/StripeCameraCore/StripeCameraCoreTestUtils/StripeCameraCoreTestUtils.h @@ -0,0 +1,18 @@ +// +// StripeCameraCoreTestUtils.h +// StripeCameraCoreTestUtils +// +// Created by Mel Ludowise on 11/15/21. +// + +#import + +//! Project version number for StripeCameraCoreTestUtils. +FOUNDATION_EXPORT double StripeCameraCoreTestUtilsVersionNumber; + +//! Project version string for StripeCameraCoreTestUtils. +FOUNDATION_EXPORT const unsigned char StripeCameraCoreTestUtilsVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/StripeCameraCore/StripeCameraCoreTests/Info.plist b/StripeCameraCore/StripeCameraCoreTests/Info.plist new file mode 100644 index 00000000..64d65ca4 --- /dev/null +++ b/StripeCameraCore/StripeCameraCoreTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/StripeCameraCore/StripeCameraCoreTests/Unit/Categories/CGRect_StripeCameraCoreTest.swift b/StripeCameraCore/StripeCameraCoreTests/Unit/Categories/CGRect_StripeCameraCoreTest.swift new file mode 100644 index 00000000..317135ec --- /dev/null +++ b/StripeCameraCore/StripeCameraCoreTests/Unit/Categories/CGRect_StripeCameraCoreTest.swift @@ -0,0 +1,68 @@ +// +// CGRect_StripeCameraCoreTest.swift +// StripeCameraCoreTests +// +// Created by Mel Ludowise on 12/14/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import CoreGraphics +@_spi(STP) import StripeCameraCore +import XCTest + +class CGRect_StripeCameraCoreTest: XCTestCase { + func testInvertedNormalizedCoordinates() { + let rect = CGRect(x: 0.1, y: 0.1, width: 0.5, height: 0.5) + let invertedRect = rect.invertedNormalizedCoordinates + + XCTAssertEqual(invertedRect.minX, 0.1) + XCTAssertEqual(invertedRect.minY, 0.4) + XCTAssertEqual(invertedRect.width, 0.5) + XCTAssertEqual(invertedRect.height, 0.5) + } + + func testConvertFromNormalizedCenterCropSquareLandscape() { + // Centered-square rect corresponds to (350,0) 900x900 + let originalSize = CGSize(width: 1600, height: 900) + + // Corresponds to actual coordinates of + // (350+900*0.25,900*0.25) 900*0.25 x 900*0.25 + // = (575,225) 225x225 + let normalizedSquareRect = CGRect(x: 0.25, y: 0.25, width: 0.25, height: 0.25) + + // Should have coordinates of + // (575/1600, 225/900) 225/1600 x 225/900 + // = (0.359375,0.25) 0.140625 x 0.25 + let convertedRect = normalizedSquareRect.convertFromNormalizedCenterCropSquare( + toOriginalSize: originalSize + ) + + XCTAssertEqual(convertedRect.minX, 0.359375) + XCTAssertEqual(convertedRect.minY, 0.25) + XCTAssertEqual(convertedRect.width, 0.140625) + XCTAssertEqual(convertedRect.height, 0.25) + } + + func testConvertFromNormalizedCenterCropSquarePortrait() { + // Centered-square rect corresponds to (0,350) 900x900 + let originalSize = CGSize(width: 900, height: 1600) + + // Corresponds to actual coordinates of + // (900*0.25,350+900*0.25) 900*0.25 x 900*0.25 + // = (225,575) 225x225 + let normalizedSquareRect = CGRect(x: 0.25, y: 0.25, width: 0.25, height: 0.25) + + // Should have coordinates of + // (225/900, 575/1600) 225/900 x 225/1600 + // = (0.25,0.359375) 0.25 x 0.140625 + let convertedRect = normalizedSquareRect.convertFromNormalizedCenterCropSquare( + toOriginalSize: originalSize + ) + + XCTAssertEqual(convertedRect.minX, 0.25) + XCTAssertEqual(convertedRect.minY, 0.359375) + XCTAssertEqual(convertedRect.width, 0.25) + XCTAssertEqual(convertedRect.height, 0.140625) + } + +} diff --git a/StripeCardScan/BuildConfigurations/StripeCardScan-Debug.xcconfig b/StripeCardScan/BuildConfigurations/StripeCardScan-Debug.xcconfig new file mode 100644 index 00000000..0f5765ca --- /dev/null +++ b/StripeCardScan/BuildConfigurations/StripeCardScan-Debug.xcconfig @@ -0,0 +1,7 @@ +// +// StripeCardScan-Debug.xcconfig +// + +#include "../../BuildConfigurations/StripeiOS-Debug.xcconfig" + +APPLICATION_EXTENSION_API_ONLY = NO diff --git a/StripeCardScan/BuildConfigurations/StripeCardScan-Release.xcconfig b/StripeCardScan/BuildConfigurations/StripeCardScan-Release.xcconfig new file mode 100644 index 00000000..e99bbccc --- /dev/null +++ b/StripeCardScan/BuildConfigurations/StripeCardScan-Release.xcconfig @@ -0,0 +1,7 @@ +// +// StripeCardScan-Release.xcconfig +// + +#include "../../BuildConfigurations/StripeiOS-Release.xcconfig" + +APPLICATION_EXTENSION_API_ONLY = NO diff --git a/StripeCardScan/Project.swift b/StripeCardScan/Project.swift new file mode 100644 index 00000000..cf588d14 --- /dev/null +++ b/StripeCardScan/Project.swift @@ -0,0 +1,32 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.stripeFramework( + name: "StripeCardScan", + targetSettings: .settings( + configurations: [ + .debug( + name: "Debug", + xcconfig: "BuildConfigurations/StripeCardScan-Debug.xcconfig" + ), + .release( + name: "Release", + xcconfig: "BuildConfigurations/StripeCardScan-Release.xcconfig" + ), + ], + defaultSettings: .none + ), + resources: "StripeCardScan/Resources/**", + dependencies: [ + .project(target: "StripeCore", path: "//StripeCore"), + ], + unitTestOptions: .testOptions( + resources: [ + "StripeCardScanTests/Mock Data/**", + "StripeCardScanTests/Resources/**", + ], + dependencies: [ + .project(target: "StripeCoreTestUtils", path: "//StripeCore"), + ] + ) +) diff --git a/StripeCardScan/README.md b/StripeCardScan/README.md new file mode 100644 index 00000000..f82d8a31 --- /dev/null +++ b/StripeCardScan/README.md @@ -0,0 +1,87 @@ +# Stripe CardScan iOS SDK + +This library provides support for the standalone Stripe CardScan product. + +## Overview + +This library provides a user interface through which users can scan payment cards and extract information from them. It uses the Stripe Publishable Key to authenticate with Stripe services. + +Note that this is a standalone SDK and, while compatible with, does not directly integrate with the [PaymentIntent](https://stripe.com/docs/api/payment_intents) API nor with [next_action](https://stripe.com/docs/api/errors#errors-payment_intent-next_action). + +This library can be used entirely outside of a Stripe integration and with other payment processing providers. + +## Requirements + +- iOS 13.0 or higher +- XCode 14.1 or higher + +## Example + +See the [CardImageVerification Example](https://github.com/stripe/stripe-ios/tree/master/Example/CardImageVerification%20Example) directory for an example application that you can try for yourself! + +## Installation + +- Cocoapod + - `pod install StripeCardScan` +- SPM + - In Xcode, select File > Add Packages… and enter https://github.com/stripe/stripe-ios-spm as the repository URL. + - Select the latest version number from our releases page. + - Add the `StripeCardScan` product to the target of your app. + +## Integration +### Credit Card OCR +Add `CardScanSheet` in your view controller where you want to invoke the credit card scanning flow. + +1. Set up camera permissions + * The SDK uses the camera, so you'll need to add an description of camera usage to your Info.plist file: +![info.plist camera permissions](https://gblobscdn.gitbook.com/assets%2F-MAfqrnL3d-uke0sAFsI%2Fsync%2F573e3f05043e4d903189b5fb107d4b3565bdb11b.png?alt=media) +![camera permissions prompt](https://gblobscdn.gitbook.com/assets%2F-MAfqrnL3d-uke0sAFsI%2Fsync%2F0d7119d3cbe2f519e5e5c04b56fe43539e4435e1.png?alt=media) + + * Alternatively, you can add this permission directly to your Info.plist file: + ``` + NSCameraUsageDescriptionkey> + We need access to your camera to scan your cardstring> + ``` +2. Add `CardScanSheet` in your app where you want to invoke the scan flow + * Initialize `CardScanSheet` + * When it’s time to invoke the scan flow, display the sheet with `CardScanSheet.present()` + * When the verification flow is finished, the sheet will be dismissed and the completion block will be called with a [Result](https://stripe.dev/stripe-ios/) + +### Example Implementation +```swift + +import UIKit +import StripeCardScan + +class ViewController: UIViewController { + + @IBAction func cardScanSheetButtonPressed() { + let cardScanSheet = CardScanSheet() + + cardScanSheet.present(from: self) { [weak self] result in + switch result { + case .completed(let scannedCard): + /* + * The user scanned a card. The result of the scan are detailed + * in the `scannedCard` field of the result. + */ + print("scan success") + case .canceled: + /* + * The scan was canceled by the user. + */ + print("scan canceled") + case .failed(let error): + /* + * The scan failed. The displayable error is + * included in the `localizedDescription`. + */ + print("scan failed: \(error.localizedDescription)") + } + } + } +} +``` + +## Credit Card Verification +🚧 diff --git a/StripeCardScan/StripeCardScan.xcodeproj/project.pbxproj b/StripeCardScan/StripeCardScan.xcodeproj/project.pbxproj new file mode 100644 index 00000000..0264da3a --- /dev/null +++ b/StripeCardScan/StripeCardScan.xcodeproj/project.pbxproj @@ -0,0 +1,1212 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + 040020B8558B0489DF99F8B5 /* DetectedAllBoxes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7EE5FDCA3B075FE5BBAF42A /* DetectedAllBoxes.swift */; }; + 050B462804602A9F4DB58A9D /* Expiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F3DDAD14A9F2DEB98E236E /* Expiry.swift */; }; + 05188062E522359CE24912CF /* ScannedCardImageData+Verification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3EE6315FB1507E40F10D85 /* ScannedCardImageData+Verification.swift */; }; + 06711423AB563634EADFCCC0 /* VerifyCardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F912323832888F88EA218BA9 /* VerifyCardViewController.swift */; }; + 0838FEA7071EDCE1B633F093 /* PredictionAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B452EE8B96AF1B0691E7D43 /* PredictionAPI.swift */; }; + 0A4DCE2C98659B92DDB29B9A /* ScanAnalyticsManager+Tasks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6563E5CE5CD0439D5DBF35D7 /* ScanAnalyticsManager+Tasks.swift */; }; + 0ADD10809FEABDCB59A8FA72 /* CardType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 236AE86CE7DCB0DFA3B7C978 /* CardType.swift */; }; + 0D062E99363099A5728F3DCD /* ScanAnalyticsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27F6ECCDC721E4D46DAC4F48 /* ScanAnalyticsManagerTests.swift */; }; + 0D62200AA65D71F040037CC8 /* CardImageVerification_CardAdd_200.json in Resources */ = {isa = PBXBuildFile; fileRef = CF90067085243D998A1D6030 /* CardImageVerification_CardAdd_200.json */; }; + 0D8CA0E3EFF9694CAE4FB8F7 /* ScanConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9618F7B15B33EC5B339A94 /* ScanConfiguration.swift */; }; + 0EAA2314FEA8D05D24C498BD /* Torch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C827434739027B2DD43449EA /* Torch.swift */; }; + 10E840B00703A92CC487B324 /* CGRectExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7633166B4D9096FE995F08F0 /* CGRectExtension.swift */; }; + 164BF41B5C522B7338376BB2 /* BlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938958FCB9B918AD6CDF0F4E /* BlurView.swift */; }; + 17E34C9EF882AEAE47BECAB4 /* ErrorCorrection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BEC906101069A1E3C50FA51 /* ErrorCorrection.swift */; }; + 189DEBAF38F2FB88CCA39EA2 /* UxModel+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF3FCD1B2283A2B02EBAEF4F /* UxModel+Utils.swift */; }; + 18B63245A933E292345C9410 /* UxAndOcrMainLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87448BAE438EF08C55AA4B29 /* UxAndOcrMainLoop.swift */; }; + 19EF4634631CFC121DE21D1B /* CardImageVerificationDetailsResponseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DDAF8E33A3D7CD020AC904A /* CardImageVerificationDetailsResponseTest.swift */; }; + 19F7EC09C9B0ED11A4601E08 /* ScanStatsPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61E754E520B4E21920D1DA /* ScanStatsPayload.swift */; }; + 1AD72764EA8BCB66A9D7B637 /* CreditCardOcrPrediction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DEE8C18A3CECFF0751AB0D5 /* CreditCardOcrPrediction.swift */; }; + 1B10222A5121C9B2C3479FAB /* StripeCardScan.h in Headers */ = {isa = PBXBuildFile; fileRef = AB56DDBBF0E5BC10682C7D96 /* StripeCardScan.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 1B71815D5B3EC8AC21E2A202 /* UIImage+pixelBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2D9886B0ED119E8D1FB6448 /* UIImage+pixelBuffer.swift */; }; + 1DE075A700592250D87DF558 /* ScannedCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76C70F9A1879ED8B41639945 /* ScannedCard.swift */; }; + 20DCEB955D9E0660EAB3ABEB /* InterfaceOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D0D8C2909455B76E246468 /* InterfaceOrientation.swift */; }; + 20DF2E9D5A543360DF3A4DDA /* DetectedBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = B53D407C0AC8D44ECC0C3C52 /* DetectedBox.swift */; }; + 213A91107E76434972A1F848 /* SSDOcr.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F3E329C37141862BD4A4DE7 /* SSDOcr.swift */; }; + 25FAF64D4FE93BF38E807ECA /* VerifyFramesAPIBindingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4301B281F5E0A5BACBBD68CC /* VerifyFramesAPIBindingsTests.swift */; }; + 2A9ECCF086685AD607D82492 /* AppleOcr.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E24CF248F312DDE025D662A /* AppleOcr.swift */; }; + 2D042FD2E8A0C8236596CE54 /* CardScanFraudData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D41E45A285FEF48E9528997 /* CardScanFraudData.swift */; }; + 2F3FA5E8CCBF7F77106A8268 /* EndToEndTestDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3ECBF071E7507C9D41F232 /* EndToEndTestDataSource.swift */; }; + 30E3E90F9C8E3D3E8FCA869E /* PreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC12B6E0657203AAB765468C /* PreviewView.swift */; }; + 35F2BB10395405DB6C1E31B5 /* SSDOcrOutputExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA89DA7FF10BC3A7B2D102FB /* SSDOcrOutputExtensions.swift */; }; + 36525A0F774E7FA055D8B525 /* CardBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEDE71D7051DDC98698C4057 /* CardBase.swift */; }; + 3736756FBB875060C86E2777 /* CardScanSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14BC8B1C28C3F6C8330164E0 /* CardScanSheet.swift */; }; + 3F5B9466E9FC13CA29218750 /* ImageHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091163ED5E37922332F502B1 /* ImageHelpers.swift */; }; + 3FCC183583090FD0246D9336 /* ScanBaseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A790FC6FAEC3F88FB2C26E27 /* ScanBaseViewController.swift */; }; + 41366AB64512BB7E4248A904 /* OcrDDUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0B91384C63CBFB790C8EE6 /* OcrDDUtils.swift */; }; + 41F2E4475B9B19350C31F255 /* PaymentCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67C8AB382B81DE27C8614F6D /* PaymentCard.swift */; }; + 420FF119A7147335D802AB14 /* CardImageVerificationSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12F09D21BAC44C461B1AD7CB /* CardImageVerificationSheet.swift */; }; + 43AA83BFF8DEFC09D406669F /* CardVerifyStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B3242DC2FCDF20D51D23AE1 /* CardVerifyStateMachine.swift */; }; + 449DD2A5D1F3FF94524D3CD6 /* STPAPIClient+CardImageVerificationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC4F647EB512813078B9A6F /* STPAPIClient+CardImageVerificationTest.swift */; }; + 45C8D17FED02529B7FC4E8C3 /* STPLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6418F87D5168A2A08D65A482 /* STPLocalizedString.swift */; }; + 47DCE237223D40B1EB183704 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCA2733CEC85F8E427CA99CB /* AppState.swift */; }; + 48580A7E92CC8EBFD2FD4C91 /* DetectedAllOcrBoxes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1441A7A39C36C2A634A882F5 /* DetectedAllOcrBoxes.swift */; }; + 48E4577B073DD9485BC62902 /* ImageCompressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AA128D69F3C32A4EF67CF0 /* ImageCompressionTests.swift */; }; + 49E1959C680AD68D1D345D6B /* DeviceUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB03A9253E8FAD07B739438 /* DeviceUtils.swift */; }; + 4ACBD9754F304CE4F70CAE43 /* MainLoopStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D550382D6320388A5BA75A1 /* MainLoopStateMachine.swift */; }; + 4B01B9F4FB647908AA5276E2 /* StripeCoreTestUtils.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 17FA90EE37CF1E6A49532491 /* StripeCoreTestUtils.framework */; }; + 4D1016654AFB3D9EE26092B7 /* PredictionUtilOcr.swift in Sources */ = {isa = PBXBuildFile; fileRef = 795E739553651C8AC40E6AC1 /* PredictionUtilOcr.swift */; }; + 52519CA3928967768049164E /* CardImageVerification_CardSet_200.json in Resources */ = {isa = PBXBuildFile; fileRef = BDD0C14472FA163BD395DBD3 /* CardImageVerification_CardSet_200.json */; }; + 5382B471868AA7D95BBD2B3A /* SSDOcrDetect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AEB912151CB564AE21D073C /* SSDOcrDetect.swift */; }; + 54469A1A5D77BAD54061BEA1 /* ScanStatsPayload+Tasks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1CD398A6AFB6747D095C59 /* ScanStatsPayload+Tasks.swift */; }; + 5488DA1817A0C9F780EF69B2 /* CardVerifyFraudData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63FA7635BAFDC7DD54B1A731 /* CardVerifyFraudData.swift */; }; + 59889C85D6D25B3222776B28 /* synthetic_test_image.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 4D9820BC2FFB68C808FB9507 /* synthetic_test_image.jpg */; }; + 5B1749A19231B20E2A081EB9 /* OcrObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DC83A169A82484B9F60E452 /* OcrObject.swift */; }; + 60AF52B9EFFF12EBABE4D221 /* CreditCardOcrImplementation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05B28BBCB062B6AE5A3B7CBC /* CreditCardOcrImplementation.swift */; }; + 65C1EB5447CD5DF162CDEC2B /* Bouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA16EAC68E04D4FDE67DB807 /* Bouncer.swift */; }; + 6E9908C612ECD2E0AEA3DFF8 /* AtomicPropertyWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B548B9626366BB0F110873 /* AtomicPropertyWrapper.swift */; }; + 70B5FF75EF5DC8FFB23B95F7 /* UxModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C038EFADF52B2477FC934EE0 /* UxModelTests.swift */; }; + 74330F51A91DC3A617F7AE42 /* SimpleScanViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E1DA01CB00CBDE36735EDD /* SimpleScanViewController.swift */; }; + 77443628E02F3AEFF112314D /* CardScanMockData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 746AAB9327BDE6791F9393B0 /* CardScanMockData.swift */; }; + 77BB4BE6E5E03626936453C6 /* STPAPIClient+CardImageVerification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E54DFA3C47D94639297588 /* STPAPIClient+CardImageVerification.swift */; }; + 79A96C88970E4E61BAC47412 /* ScanStatsPayloadAPIBindingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9ADC4CC0B4ADFE0A5427E /* ScanStatsPayloadAPIBindingsTests.swift */; }; + 7B0CF214778E17FEC721602E /* PredictionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BBE2CADB8D8E98D6EEE10C0 /* PredictionResult.swift */; }; + 7C71166DACDB829F8CBC383D /* NMS.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD9FF5C9DD64BE92DB0927D /* NMS.swift */; }; + 7CED1B42C94A49D6BF98C9E4 /* VideoFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42510E09D32547455CDDBEF /* VideoFeed.swift */; }; + 7D9C9C2A26EF11F0C5D67D7C /* CGrect+utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD87F9971C25C4B99138318F /* CGrect+utils.swift */; }; + 8463F2DB039C481D26A24BAA /* PostDetectionAlgorithm.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC9F1B20AEB433DE3CE0042C /* PostDetectionAlgorithm.swift */; }; + 84975E58102A5D8C62C6C4E5 /* String+Localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69A2DB0837C11D8829C027CF /* String+Localized.swift */; }; + 858CDA751309CA04202B6203 /* MachineLearningResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF17FD18E52CB3508AD895E3 /* MachineLearningResult.swift */; }; + 86635536450EE7D583B8BD59 /* StripeCore+Import.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A1E35C6DF336F34B6C7AEA /* StripeCore+Import.swift */; }; + 8CF112F4889C96617504A931 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 6C27E58C1953533C68D43361 /* Localizable.strings */; }; + 8E4B117272F5B17A1E6AD609 /* UxModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF173627F545C88CB1551B0 /* UxModel.swift */; }; + 8FC373E15C1169677F563901 /* OcrPriorsGen.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED780E8B4C14EE39F853B3A1 /* OcrPriorsGen.swift */; }; + 9188179F13E5EA15E0419580 /* ScanStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66A49708CF5D37C7CB267732 /* ScanStats.swift */; }; + 93D4785D8B91F12B6DDDE203 /* UxAnalyzer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F85031ADAE5928D977465C2 /* UxAnalyzer.swift */; }; + 9FA5A312032FC54B35BB604E /* SoftNMS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00A93BFDEE4072A2A9C30397 /* SoftNMS.swift */; }; + A060350B93E3369463AE2898 /* StripeCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2E889051D7CD76D4FB9084A1 /* StripeCore.framework */; }; + A7014CF97250D97BC683FAC8 /* CreditCardUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8735A24B634F3B9060CADE45 /* CreditCardUtils.swift */; }; + A7F92FB7213E66A7D15A1BE3 /* SSDOcr.mlmodelc in Resources */ = {isa = PBXBuildFile; fileRef = E73DC7DD9C3E3395DF3F5A29 /* SSDOcr.mlmodelc */; }; + A8D0A57687A7CF9F389D7BDB /* ScanAnalyticsManager+Managers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A337FE5C3BE4FAB001E5520 /* ScanAnalyticsManager+Managers.swift */; }; + AB21982DC43977754F237756 /* VerificationFramesData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24C2345660A95FBF90BF4850 /* VerificationFramesData.swift */; }; + ACC3C1A295E43983F67F858E /* FrameData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C926149A434F4F3BC961D9 /* FrameData.swift */; }; + AE144927F21D5DF9A8C0EF79 /* ScannedCardDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15541EEEE784EECDD3E7399D /* ScannedCardDetails.swift */; }; + AFA334F5007A4C141A96FC2E /* VerifyFrames.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FAA99A64BCD00A336A5E01 /* VerifyFrames.swift */; }; + AFB2482D559BCC08E96C3615 /* CardImageVerificationIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD5004FC0706B2660B4C6EA0 /* CardImageVerificationIntent.swift */; }; + B00954CF5F2A15B72CB94FA5 /* VerifyCardAddViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B38020E8E33CA893CBA6F3 /* VerifyCardAddViewController.swift */; }; + B2DF7862E5F80ACD8DEE9317 /* UxModel.mlmodelc in Resources */ = {isa = PBXBuildFile; fileRef = F4721524B6285C507681CC4D /* UxModel.mlmodelc */; }; + B47F583CE0F239881877E5D3 /* ZoomedInCGImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10A26712A38A91726C314329 /* ZoomedInCGImage.swift */; }; + B66697826FEA9FCE81DDD6F4 /* ActiveStateComputation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EBC1F86A03CF9AAC6FE5825 /* ActiveStateComputation.swift */; }; + BDEA39DE5D3775AD2A4BFB6C /* CardImageVerificationControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F24DEA029F94A359B7A571BB /* CardImageVerificationControllerTests.swift */; }; + BEE2BFA103DE985D057A0F9D /* ScanStatsPayload+Common.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028DA95BD9A0DA8AD69162CB /* ScanStatsPayload+Common.swift */; }; + BFFA3E8CE79BFFE47530FAF6 /* DetectedSSDBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2EF6799FCE0817D9E3A536B /* DetectedSSDBox.swift */; }; + C2C41F5E568BF767C8232E74 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 14E5A51A77928A2700E23321 /* XCTest.framework */; }; + C3B9A4A30443A38C2D17BE8E /* CardNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 311A5A7EF789248E35BB1FEC /* CardNetwork.swift */; }; + C57BCF07EDF419D36ED4E690 /* ScanEventsProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D1FBA1B4676CC5E9F785D51 /* ScanEventsProtocol.swift */; }; + C5DBC36A41FB70C5DCCC97C7 /* SimpleScanViewController+Verify.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34F806DF921888A9657E2928 /* SimpleScanViewController+Verify.swift */; }; + C633115B02A0CE24AC55A747 /* OcrDD.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD9AF72B522A1E535A4EC81 /* OcrDD.swift */; }; + C7259DA76E6AB9BF53735D19 /* Array+utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA457DF27DC45455B8E1345 /* Array+utils.swift */; }; + C7A941539DBCFA790DCDB0B0 /* NonNameWords.swift in Sources */ = {isa = PBXBuildFile; fileRef = F96A85E323CFDA8BA35BC293 /* NonNameWords.swift */; }; + C8E2E98B7ED108804E0A0E24 /* SSDCreditCardOcr.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B00395C6A2B14A6D56735B9 /* SSDCreditCardOcr.swift */; }; + CBA4FE649A3B21DAC3A61E5B /* CancellationReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53C171A1336B7FB0C321D194 /* CancellationReason.swift */; }; + CC07F702B9EC043ACB0AC1E5 /* ScannedCardImageData.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAE45D8AC7BBA5DC433A9383 /* ScannedCardImageData.swift */; }; + CCB7722FDB8E6E68E90F3D12 /* CreditCardOcrPrediction+expiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E72DD18B9FCB80ABEDE2689 /* CreditCardOcrPrediction+expiry.swift */; }; + CED42084C5FC46C17E357AD5 /* ScanAnalyticsManager+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97CA4D8286B196A49B8D535 /* ScanAnalyticsManager+Helpers.swift */; }; + D2030CA1B3B7DAF3229189AD /* Image+utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE9C295E982F92DE2418AE1 /* Image+utils.swift */; }; + D27E02429E5DC8B28DFE8945 /* CardImageVerificationDetailsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 256455DAEDCDDB1AEAF6A278 /* CardImageVerificationDetailsResponse.swift */; }; + D42EEFBB72CD0AB00C6B4728 /* String+Sha256.swift in Sources */ = {isa = PBXBuildFile; fileRef = 732D3AB4CE25CC5C26A352B1 /* String+Sha256.swift */; }; + D6F0E1C93997BB36AF0608C0 /* StringResourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A72927DA8A946FDD03843DE /* StringResourceTests.swift */; }; + DB74787AF4EEF0E506DD0E1C /* Data+Sha256.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63662689AD097C859B8759B1 /* Data+Sha256.swift */; }; + DBE3EE5DAEEEA44D6C7ECD4E /* SSDOcr+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505DF34D76A56FEC2B8453F9 /* SSDOcr+Utils.swift */; }; + DDBCE05A3794129A9A04D511 /* AppleCreditCardOcr.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EDF39435211B456B7579739 /* AppleCreditCardOcr.swift */; }; + DF352D12199B55B659A11795 /* DetectedSSDOcrBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D58BEEC9D88537E32260CBA /* DetectedSSDOcrBox.swift */; }; + E43FE03D43CB0D7BAA73745A /* StripeCardScan.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DD6884D0B3347BBF2E61B1D6 /* StripeCardScan.framework */; }; + E4EC278027AF802C39C74C48 /* CardImageVerificationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B313BA373EF6B567BF40A240 /* CardImageVerificationController.swift */; }; + E70A7E38A7D858031F900649 /* CardScanSheetError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF4C0BF8E55D53B004FABCA /* CardScanSheetError.swift */; }; + E90CC9715EC99F6B7FAC98FA /* CardImageVerificationSheetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545E03D5167516D28C8A9F08 /* CardImageVerificationSheetConfiguration.swift */; }; + EA0F179DF887D94E19138A5E /* AsyncModelLoading.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF53C194DDC96160505D54D5 /* AsyncModelLoading.swift */; }; + EA2DBA78722CD65ED599190D /* CardScanMisc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA3AD0FDF25BE452AE4ED34 /* CardScanMisc.swift */; }; + EB061FA550AFFE2AB2E348DF /* ScanAnalyticsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69E2630C0FC7C9DDD551FD67 /* ScanAnalyticsManager.swift */; }; + EE1CB7D3FA2B44DC17E6FF4C /* OcrMainLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E38717F47ED06C115E058DE /* OcrMainLoop.swift */; }; + EF96103F82491640651C49F6 /* AppInfoUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 362A27BB81840A22104C9A49 /* AppInfoUtils.swift */; }; + F0ED2BFE143DD07337D389E5 /* CornerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E012270975B6DC09A8199F /* CornerView.swift */; }; + F16FAC158C6B0A7687547B4B /* FadeInAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3CAFFFE55918BB4939868B6 /* FadeInAnimation.swift */; }; + F4E4941F5AA2EC2C2CC4F7FB /* StripeCardScanBundleLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F97A4CE5104309B720D603 /* StripeCardScanBundleLocator.swift */; }; + F9F19A6F2245863FEA485335 /* CreditCardOcrResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDE92803C69CE8253D73A25F /* CreditCardOcrResult.swift */; }; + FE55286899CFB0E34356D4D6 /* StrictModeFramesTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6D4DB87B26D439686392AE /* StrictModeFramesTest.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + F7C4E731844D6B46A4FBCACD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 9A6BF50E5B02355004AC6020 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 03CF79975A56288F02F20E52; + remoteInfo = StripeCardScan; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 20B092D23CA44561EF997447 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + 451E97BA25D9CE29EDAB7618 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 00A93BFDEE4072A2A9C30397 /* SoftNMS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftNMS.swift; sourceTree = ""; }; + 0197A1615C3FA86907CB6186 /* StripeCardScan-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeCardScan-Debug.xcconfig"; sourceTree = ""; }; + 028DA95BD9A0DA8AD69162CB /* ScanStatsPayload+Common.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ScanStatsPayload+Common.swift"; sourceTree = ""; }; + 03E2C12C0D172DD7DAE33399 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; + 048ED08A6959593F896B3AC8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 05B28BBCB062B6AE5A3B7CBC /* CreditCardOcrImplementation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreditCardOcrImplementation.swift; sourceTree = ""; }; + 06F97A4CE5104309B720D603 /* StripeCardScanBundleLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeCardScanBundleLocator.swift; sourceTree = ""; }; + 091163ED5E37922332F502B1 /* ImageHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageHelpers.swift; sourceTree = ""; }; + 0B00395C6A2B14A6D56735B9 /* SSDCreditCardOcr.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSDCreditCardOcr.swift; sourceTree = ""; }; + 0BB35800FB840EFC76240DC7 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; + 0C7BAE904C7E1D7A9FDF35F1 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; + 0D1FBA1B4676CC5E9F785D51 /* ScanEventsProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanEventsProtocol.swift; sourceTree = ""; }; + 0DC83A169A82484B9F60E452 /* OcrObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OcrObject.swift; sourceTree = ""; }; + 10311DC6AC19B7A73A8F930F /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; + 10A26712A38A91726C314329 /* ZoomedInCGImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomedInCGImage.swift; sourceTree = ""; }; + 11D3465E527ABADBD2485A93 /* ms-MY */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ms-MY"; path = "ms-MY.lproj/Localizable.strings"; sourceTree = ""; }; + 12F09D21BAC44C461B1AD7CB /* CardImageVerificationSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardImageVerificationSheet.swift; sourceTree = ""; }; + 1441A7A39C36C2A634A882F5 /* DetectedAllOcrBoxes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectedAllOcrBoxes.swift; sourceTree = ""; }; + 14795A45279E8F328AB54920 /* Project-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Debug.xcconfig"; sourceTree = ""; }; + 14BC8B1C28C3F6C8330164E0 /* CardScanSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardScanSheet.swift; sourceTree = ""; }; + 14E5A51A77928A2700E23321 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; + 15541EEEE784EECDD3E7399D /* ScannedCardDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannedCardDetails.swift; sourceTree = ""; }; + 17BCBB8B34EBA904BB245CBD /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 17FA90EE37CF1E6A49532491 /* StripeCoreTestUtils.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCoreTestUtils.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 1A337FE5C3BE4FAB001E5520 /* ScanAnalyticsManager+Managers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ScanAnalyticsManager+Managers.swift"; sourceTree = ""; }; + 1C94EE6DB0E3114A38DD9CFE /* pl-PL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pl-PL"; path = "pl-PL.lproj/Localizable.strings"; sourceTree = ""; }; + 1FD9AF72B522A1E535A4EC81 /* OcrDD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OcrDD.swift; sourceTree = ""; }; + 236AE86CE7DCB0DFA3B7C978 /* CardType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardType.swift; sourceTree = ""; }; + 24C2345660A95FBF90BF4850 /* VerificationFramesData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationFramesData.swift; sourceTree = ""; }; + 256455DAEDCDDB1AEAF6A278 /* CardImageVerificationDetailsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardImageVerificationDetailsResponse.swift; sourceTree = ""; }; + 2667E0E0C7DC6CDAF03F8B40 /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Localizable.strings"; sourceTree = ""; }; + 27F4BF66273070C2054BBAC0 /* cs-CZ */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "cs-CZ"; path = "cs-CZ.lproj/Localizable.strings"; sourceTree = ""; }; + 27F6ECCDC721E4D46DAC4F48 /* ScanAnalyticsManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanAnalyticsManagerTests.swift; sourceTree = ""; }; + 2D61E754E520B4E21920D1DA /* ScanStatsPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanStatsPayload.swift; sourceTree = ""; }; + 2E889051D7CD76D4FB9084A1 /* StripeCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2F3E329C37141862BD4A4DE7 /* SSDOcr.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSDOcr.swift; sourceTree = ""; }; + 2F9447632965B9BCE1E595E4 /* StripeCardScanTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StripeCardScanTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 311A5A7EF789248E35BB1FEC /* CardNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardNetwork.swift; sourceTree = ""; }; + 34F806DF921888A9657E2928 /* SimpleScanViewController+Verify.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SimpleScanViewController+Verify.swift"; sourceTree = ""; }; + 362A27BB81840A22104C9A49 /* AppInfoUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoUtils.swift; sourceTree = ""; }; + 3938F9E3F0F267045D3FDDEB /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; + 3A9C2B8A3B96E194CED6841C /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; + 3BC4F647EB512813078B9A6F /* STPAPIClient+CardImageVerificationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPAPIClient+CardImageVerificationTest.swift"; sourceTree = ""; }; + 3DEE8C18A3CECFF0751AB0D5 /* CreditCardOcrPrediction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreditCardOcrPrediction.swift; sourceTree = ""; }; + 3E24CF248F312DDE025D662A /* AppleOcr.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleOcr.swift; sourceTree = ""; }; + 42454BA950282D9ADB9C6557 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + 4301B281F5E0A5BACBBD68CC /* VerifyFramesAPIBindingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifyFramesAPIBindingsTests.swift; sourceTree = ""; }; + 45C9A1A26405DEAD8E11F13D /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; + 4A1CD398A6AFB6747D095C59 /* ScanStatsPayload+Tasks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ScanStatsPayload+Tasks.swift"; sourceTree = ""; }; + 4B452EE8B96AF1B0691E7D43 /* PredictionAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictionAPI.swift; sourceTree = ""; }; + 4B89D81A4099CC71A3FBC133 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; + 4BBE2CADB8D8E98D6EEE10C0 /* PredictionResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictionResult.swift; sourceTree = ""; }; + 4CA3AD0FDF25BE452AE4ED34 /* CardScanMisc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardScanMisc.swift; sourceTree = ""; }; + 4D9820BC2FFB68C808FB9507 /* synthetic_test_image.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = synthetic_test_image.jpg; sourceTree = ""; }; + 4DA27FFBD426C871C5BD254E /* zh-HK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-HK"; path = "zh-HK.lproj/Localizable.strings"; sourceTree = ""; }; + 4F85031ADAE5928D977465C2 /* UxAnalyzer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UxAnalyzer.swift; sourceTree = ""; }; + 505DF34D76A56FEC2B8453F9 /* SSDOcr+Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SSDOcr+Utils.swift"; sourceTree = ""; }; + 53C171A1336B7FB0C321D194 /* CancellationReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancellationReason.swift; sourceTree = ""; }; + 545E03D5167516D28C8A9F08 /* CardImageVerificationSheetConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardImageVerificationSheetConfiguration.swift; sourceTree = ""; }; + 58A5F2CEEAAB78AA425A5737 /* lt-LT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "lt-LT"; path = "lt-LT.lproj/Localizable.strings"; sourceTree = ""; }; + 5969433D88B5BD3B7F56B2BD /* sk-SK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sk-SK"; path = "sk-SK.lproj/Localizable.strings"; sourceTree = ""; }; + 5A6D5C8E51C23370E889F75F /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/Localizable.strings"; sourceTree = ""; }; + 5A72927DA8A946FDD03843DE /* StringResourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringResourceTests.swift; sourceTree = ""; }; + 5CF9ADC4CC0B4ADFE0A5427E /* ScanStatsPayloadAPIBindingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanStatsPayloadAPIBindingsTests.swift; sourceTree = ""; }; + 5D58BEEC9D88537E32260CBA /* DetectedSSDOcrBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectedSSDOcrBox.swift; sourceTree = ""; }; + 5E72DD18B9FCB80ABEDE2689 /* CreditCardOcrPrediction+expiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CreditCardOcrPrediction+expiry.swift"; sourceTree = ""; }; + 6186A0AAB96E236DEF0B5B79 /* en-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-GB"; path = "en-GB.lproj/Localizable.strings"; sourceTree = ""; }; + 63662689AD097C859B8759B1 /* Data+Sha256.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Sha256.swift"; sourceTree = ""; }; + 63FA7635BAFDC7DD54B1A731 /* CardVerifyFraudData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardVerifyFraudData.swift; sourceTree = ""; }; + 6418F87D5168A2A08D65A482 /* STPLocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPLocalizedString.swift; sourceTree = ""; }; + 64A1E35C6DF336F34B6C7AEA /* StripeCore+Import.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StripeCore+Import.swift"; sourceTree = ""; }; + 6563E5CE5CD0439D5DBF35D7 /* ScanAnalyticsManager+Tasks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ScanAnalyticsManager+Tasks.swift"; sourceTree = ""; }; + 66A49708CF5D37C7CB267732 /* ScanStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanStats.swift; sourceTree = ""; }; + 67C8AB382B81DE27C8614F6D /* PaymentCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentCard.swift; sourceTree = ""; }; + 69A2DB0837C11D8829C027CF /* String+Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Localized.swift"; sourceTree = ""; }; + 69B38020E8E33CA893CBA6F3 /* VerifyCardAddViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifyCardAddViewController.swift; sourceTree = ""; }; + 69E2630C0FC7C9DDD551FD67 /* ScanAnalyticsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanAnalyticsManager.swift; sourceTree = ""; }; + 6BEC906101069A1E3C50FA51 /* ErrorCorrection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorCorrection.swift; sourceTree = ""; }; + 6C3EE6315FB1507E40F10D85 /* ScannedCardImageData+Verification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ScannedCardImageData+Verification.swift"; sourceTree = ""; }; + 6EBC1F86A03CF9AAC6FE5825 /* ActiveStateComputation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveStateComputation.swift; sourceTree = ""; }; + 6F41FBE3D90B5F6518E42051 /* fil */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fil; path = fil.lproj/Localizable.strings; sourceTree = ""; }; + 732D3AB4CE25CC5C26A352B1 /* String+Sha256.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Sha256.swift"; sourceTree = ""; }; + 746AAB9327BDE6791F9393B0 /* CardScanMockData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardScanMockData.swift; sourceTree = ""; }; + 7633166B4D9096FE995F08F0 /* CGRectExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGRectExtension.swift; sourceTree = ""; }; + 76C70F9A1879ED8B41639945 /* ScannedCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannedCard.swift; sourceTree = ""; }; + 77D0D8C2909455B76E246468 /* InterfaceOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterfaceOrientation.swift; sourceTree = ""; }; + 795E739553651C8AC40E6AC1 /* PredictionUtilOcr.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictionUtilOcr.swift; sourceTree = ""; }; + 79A802EA46840CD45CBECEEA /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + 7D41E45A285FEF48E9528997 /* CardScanFraudData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardScanFraudData.swift; sourceTree = ""; }; + 7D550382D6320388A5BA75A1 /* MainLoopStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainLoopStateMachine.swift; sourceTree = ""; }; + 7DBB233BD36CBAE2700A4511 /* Project-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Release.xcconfig"; sourceTree = ""; }; + 7DDAF8E33A3D7CD020AC904A /* CardImageVerificationDetailsResponseTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardImageVerificationDetailsResponseTest.swift; sourceTree = ""; }; + 7E38717F47ED06C115E058DE /* OcrMainLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OcrMainLoop.swift; sourceTree = ""; }; + 8094FE7CCD3CDA8B914EC534 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; + 84E54DFA3C47D94639297588 /* STPAPIClient+CardImageVerification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPAPIClient+CardImageVerification.swift"; sourceTree = ""; }; + 8735A24B634F3B9060CADE45 /* CreditCardUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreditCardUtils.swift; sourceTree = ""; }; + 87448BAE438EF08C55AA4B29 /* UxAndOcrMainLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UxAndOcrMainLoop.swift; sourceTree = ""; }; + 88F89EC392AFBE060336B63F /* sl-SI */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sl-SI"; path = "sl-SI.lproj/Localizable.strings"; sourceTree = ""; }; + 89BBC06EC107C8BDEF79BB3D /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; + 8AEB912151CB564AE21D073C /* SSDOcrDetect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSDOcrDetect.swift; sourceTree = ""; }; + 8C227CA761586744976C952D /* nn-NO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "nn-NO"; path = "nn-NO.lproj/Localizable.strings"; sourceTree = ""; }; + 8CA457DF27DC45455B8E1345 /* Array+utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+utils.swift"; sourceTree = ""; }; + 8EDF39435211B456B7579739 /* AppleCreditCardOcr.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleCreditCardOcr.swift; sourceTree = ""; }; + 8FB602C9E909E31BAE6703D6 /* ca-ES */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ca-ES"; path = "ca-ES.lproj/Localizable.strings"; sourceTree = ""; }; + 938958FCB9B918AD6CDF0F4E /* BlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurView.swift; sourceTree = ""; }; + 9593D6788C4DBF554735E2B6 /* mt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mt; path = mt.lproj/Localizable.strings; sourceTree = ""; }; + 96F3DDAD14A9F2DEB98E236E /* Expiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Expiry.swift; sourceTree = ""; }; + 9A256BCB7822DD94572DDA07 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; + 9B3242DC2FCDF20D51D23AE1 /* CardVerifyStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardVerifyStateMachine.swift; sourceTree = ""; }; + 9C3890F6A5A4FF68F95746DC /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/Localizable.strings; sourceTree = ""; }; + 9E4CF9A676A86169070DD54D /* ro-RO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ro-RO"; path = "ro-RO.lproj/Localizable.strings"; sourceTree = ""; }; + A2EF6799FCE0817D9E3A536B /* DetectedSSDBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectedSSDBox.swift; sourceTree = ""; }; + A3C926149A434F4F3BC961D9 /* FrameData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameData.swift; sourceTree = ""; }; + A612DDE1110EA970BF69B33D /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; + A67EDE2BCCC081DEDD684AAC /* StripeiOS Tests-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS Tests-Release.xcconfig"; sourceTree = ""; }; + A6E1DA01CB00CBDE36735EDD /* SimpleScanViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleScanViewController.swift; sourceTree = ""; }; + A790FC6FAEC3F88FB2C26E27 /* ScanBaseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanBaseViewController.swift; sourceTree = ""; }; + A7EE5FDCA3B075FE5BBAF42A /* DetectedAllBoxes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectedAllBoxes.swift; sourceTree = ""; }; + AB56DDBBF0E5BC10682C7D96 /* StripeCardScan.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StripeCardScan.h; sourceTree = ""; }; + AB64890F80D91D9D7B92197D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; + ABB38869FD175D7BD6BB3890 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; + AC9F1B20AEB433DE3CE0042C /* PostDetectionAlgorithm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostDetectionAlgorithm.swift; sourceTree = ""; }; + ACD9FF5C9DD64BE92DB0927D /* NMS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NMS.swift; sourceTree = ""; }; + ADCB34B81919043FA35E8BC3 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; + AEE9C295E982F92DE2418AE1 /* Image+utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+utils.swift"; sourceTree = ""; }; + AF17FD18E52CB3508AD895E3 /* MachineLearningResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MachineLearningResult.swift; sourceTree = ""; }; + B0A7946C3A1E943FB72F3FB7 /* bg-BG */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "bg-BG"; path = "bg-BG.lproj/Localizable.strings"; sourceTree = ""; }; + B2D9886B0ED119E8D1FB6448 /* UIImage+pixelBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+pixelBuffer.swift"; sourceTree = ""; }; + B313BA373EF6B567BF40A240 /* CardImageVerificationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardImageVerificationController.swift; sourceTree = ""; }; + B3CAFFFE55918BB4939868B6 /* FadeInAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FadeInAnimation.swift; sourceTree = ""; }; + B53D407C0AC8D44ECC0C3C52 /* DetectedBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectedBox.swift; sourceTree = ""; }; + B97CA4D8286B196A49B8D535 /* ScanAnalyticsManager+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ScanAnalyticsManager+Helpers.swift"; sourceTree = ""; }; + BAE45D8AC7BBA5DC433A9383 /* ScannedCardImageData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannedCardImageData.swift; sourceTree = ""; }; + BC0B91384C63CBFB790C8EE6 /* OcrDDUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OcrDDUtils.swift; sourceTree = ""; }; + BD87F9971C25C4B99138318F /* CGrect+utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGrect+utils.swift"; sourceTree = ""; }; + BDD0C14472FA163BD395DBD3 /* CardImageVerification_CardSet_200.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = CardImageVerification_CardSet_200.json; sourceTree = ""; }; + BEDE71D7051DDC98698C4057 /* CardBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardBase.swift; sourceTree = ""; }; + BEEE30227F7D34546F0FBB58 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + C038EFADF52B2477FC934EE0 /* UxModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UxModelTests.swift; sourceTree = ""; }; + C264A676DA41344551BA6661 /* StripeCardScan-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeCardScan-Release.xcconfig"; sourceTree = ""; }; + C3E012270975B6DC09A8199F /* CornerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CornerView.swift; sourceTree = ""; }; + C827434739027B2DD43449EA /* Torch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Torch.swift; sourceTree = ""; }; + CA16EAC68E04D4FDE67DB807 /* Bouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bouncer.swift; sourceTree = ""; }; + CB48DDCA458A62DCE342F517 /* et-EE */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "et-EE"; path = "et-EE.lproj/Localizable.strings"; sourceTree = ""; }; + CD5004FC0706B2660B4C6EA0 /* CardImageVerificationIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardImageVerificationIntent.swift; sourceTree = ""; }; + CD6D4DB87B26D439686392AE /* StrictModeFramesTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StrictModeFramesTest.swift; sourceTree = ""; }; + CDB03A9253E8FAD07B739438 /* DeviceUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceUtils.swift; sourceTree = ""; }; + CF3FCD1B2283A2B02EBAEF4F /* UxModel+Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UxModel+Utils.swift"; sourceTree = ""; }; + CF90067085243D998A1D6030 /* CardImageVerification_CardAdd_200.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = CardImageVerification_CardAdd_200.json; sourceTree = ""; }; + D0AA128D69F3C32A4EF67CF0 /* ImageCompressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCompressionTests.swift; sourceTree = ""; }; + D42510E09D32547455CDDBEF /* VideoFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFeed.swift; sourceTree = ""; }; + D532C440ADCD017AD326299B /* el-GR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "el-GR"; path = "el-GR.lproj/Localizable.strings"; sourceTree = ""; }; + D9B548B9626366BB0F110873 /* AtomicPropertyWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtomicPropertyWrapper.swift; sourceTree = ""; }; + DA3ECBF071E7507C9D41F232 /* EndToEndTestDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndToEndTestDataSource.swift; sourceTree = ""; }; + DC12B6E0657203AAB765468C /* PreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewView.swift; sourceTree = ""; }; + DCA2733CEC85F8E427CA99CB /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + DCF4C0BF8E55D53B004FABCA /* CardScanSheetError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardScanSheetError.swift; sourceTree = ""; }; + DD6884D0B3347BBF2E61B1D6 /* StripeCardScan.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCardScan.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + DDF173627F545C88CB1551B0 /* UxModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UxModel.swift; sourceTree = ""; }; + E4FAA99A64BCD00A336A5E01 /* VerifyFrames.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifyFrames.swift; sourceTree = ""; }; + E73DC7DD9C3E3395DF3F5A29 /* SSDOcr.mlmodelc */ = {isa = PBXFileReference; path = SSDOcr.mlmodelc; sourceTree = ""; }; + EA89DA7FF10BC3A7B2D102FB /* SSDOcrOutputExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSDOcrOutputExtensions.swift; sourceTree = ""; }; + ED780E8B4C14EE39F853B3A1 /* OcrPriorsGen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OcrPriorsGen.swift; sourceTree = ""; }; + EDE92803C69CE8253D73A25F /* CreditCardOcrResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreditCardOcrResult.swift; sourceTree = ""; }; + EF53C194DDC96160505D54D5 /* AsyncModelLoading.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncModelLoading.swift; sourceTree = ""; }; + F1FCC855CCB0816414A4BEC1 /* StripeiOS Tests-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS Tests-Debug.xcconfig"; sourceTree = ""; }; + F24DEA029F94A359B7A571BB /* CardImageVerificationControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardImageVerificationControllerTests.swift; sourceTree = ""; }; + F3AF32D7DF7EE1420D16291D /* fr-CA */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "fr-CA"; path = "fr-CA.lproj/Localizable.strings"; sourceTree = ""; }; + F4721524B6285C507681CC4D /* UxModel.mlmodelc */ = {isa = PBXFileReference; path = UxModel.mlmodelc; sourceTree = ""; }; + F87D884128F85E7F16BCBA9B /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; + F912323832888F88EA218BA9 /* VerifyCardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifyCardViewController.swift; sourceTree = ""; }; + F96A85E323CFDA8BA35BC293 /* NonNameWords.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonNameWords.swift; sourceTree = ""; }; + FD40E90452A83FFBBF65C8D8 /* lv-LV */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "lv-LV"; path = "lv-LV.lproj/Localizable.strings"; sourceTree = ""; }; + FF9618F7B15B33EC5B339A94 /* ScanConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanConfiguration.swift; sourceTree = ""; }; + FFE89E65E767D03B92383C76 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 1639B69C00B8C21CEBF08A55 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C2C41F5E568BF767C8232E74 /* XCTest.framework in Frameworks */, + E43FE03D43CB0D7BAA73745A /* StripeCardScan.framework in Frameworks */, + 4B01B9F4FB647908AA5276E2 /* StripeCoreTestUtils.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 29BDDDCECDEC71451BAAB324 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A060350B93E3369463AE2898 /* StripeCore.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 01BEE24ED60FE104004E8F2C /* CompiledModels */ = { + isa = PBXGroup; + children = ( + E73DC7DD9C3E3395DF3F5A29 /* SSDOcr.mlmodelc */, + F4721524B6285C507681CC4D /* UxModel.mlmodelc */, + ); + path = CompiledModels; + sourceTree = ""; + }; + 0DFCF5271EC90615420288B3 /* Api */ = { + isa = PBXGroup; + children = ( + C8B14FA3DC29B84F382E6D1F /* Models */, + 256455DAEDCDDB1AEAF6A278 /* CardImageVerificationDetailsResponse.swift */, + 84E54DFA3C47D94639297588 /* STPAPIClient+CardImageVerification.swift */, + ); + path = Api; + sourceTree = ""; + }; + 0E245ED03746B2F8EE73B5F9 /* StripeCardScanTests */ = { + isa = PBXGroup; + children = ( + C8CC6DB5A60043E8A373BC6F /* Helpers */, + 40C830CD6973E7AD722C2213 /* Mock Data */, + 3172AE79E1A187D904BD1022 /* Resources */, + A35971ECF566C2769A608A06 /* Unit */, + 048ED08A6959593F896B3AC8 /* Info.plist */, + ); + path = StripeCardScanTests; + sourceTree = ""; + }; + 10507D99D0E81A181A25B4BC /* MLModels */ = { + isa = PBXGroup; + children = ( + EF53C194DDC96160505D54D5 /* AsyncModelLoading.swift */, + 2F3E329C37141862BD4A4DE7 /* SSDOcr.swift */, + 505DF34D76A56FEC2B8453F9 /* SSDOcr+Utils.swift */, + DDF173627F545C88CB1551B0 /* UxModel.swift */, + CF3FCD1B2283A2B02EBAEF4F /* UxModel+Utils.swift */, + ); + path = MLModels; + sourceTree = ""; + }; + 105B9420C58B9406F7939324 = { + isa = PBXGroup; + children = ( + 36C8C7335EC9F37DE4446270 /* Project */, + D275033F1B615793C2F8B8E0 /* Frameworks */, + 72B7637B9160BBA39556BA0A /* Products */, + ); + sourceTree = ""; + }; + 20CA55CAA02DC2833AD1C426 /* ML Models */ = { + isa = PBXGroup; + children = ( + C038EFADF52B2477FC934EE0 /* UxModelTests.swift */, + ); + path = "ML Models"; + sourceTree = ""; + }; + 25575C2DBB0C8AAA658EC26E /* JSON */ = { + isa = PBXGroup; + children = ( + CF90067085243D998A1D6030 /* CardImageVerification_CardAdd_200.json */, + BDD0C14472FA163BD395DBD3 /* CardImageVerification_CardSet_200.json */, + ); + path = JSON; + sourceTree = ""; + }; + 3172AE79E1A187D904BD1022 /* Resources */ = { + isa = PBXGroup; + children = ( + 4D9820BC2FFB68C808FB9507 /* synthetic_test_image.jpg */, + ); + path = Resources; + sourceTree = ""; + }; + 3228566B727B9D25D13A3BAB /* Resources */ = { + isa = PBXGroup; + children = ( + 01BEE24ED60FE104004E8F2C /* CompiledModels */, + 6A6547B8AC5A170A0092DCF5 /* Localizations */, + ); + path = Resources; + sourceTree = ""; + }; + 324D2CF5354B3283BC0C49ED /* CardVerify */ = { + isa = PBXGroup; + children = ( + 0DFCF5271EC90615420288B3 /* Api */, + BC0070EA31577F5573D7B787 /* Card Image Verification */, + 69D6875F84C12C2E18B96B01 /* Helpers */, + CA16EAC68E04D4FDE67DB807 /* Bouncer.swift */, + BEDE71D7051DDC98698C4057 /* CardBase.swift */, + 7D41E45A285FEF48E9528997 /* CardScanFraudData.swift */, + 4CA3AD0FDF25BE452AE4ED34 /* CardScanMisc.swift */, + 63FA7635BAFDC7DD54B1A731 /* CardVerifyFraudData.swift */, + 9B3242DC2FCDF20D51D23AE1 /* CardVerifyStateMachine.swift */, + B3CAFFFE55918BB4939868B6 /* FadeInAnimation.swift */, + A3C926149A434F4F3BC961D9 /* FrameData.swift */, + 67C8AB382B81DE27C8614F6D /* PaymentCard.swift */, + 34F806DF921888A9657E2928 /* SimpleScanViewController+Verify.swift */, + 06F97A4CE5104309B720D603 /* StripeCardScanBundleLocator.swift */, + 4F85031ADAE5928D977465C2 /* UxAnalyzer.swift */, + 87448BAE438EF08C55AA4B29 /* UxAndOcrMainLoop.swift */, + 69B38020E8E33CA893CBA6F3 /* VerifyCardAddViewController.swift */, + F912323832888F88EA218BA9 /* VerifyCardViewController.swift */, + 10A26712A38A91726C314329 /* ZoomedInCGImage.swift */, + ); + path = CardVerify; + sourceTree = ""; + }; + 36C8C7335EC9F37DE4446270 /* Project */ = { + isa = PBXGroup; + children = ( + 79C01535652A2F89EB22D9A9 /* BuildConfigurations */, + 6C3738C5D8A82E9A3C5AFC85 /* BuildConfigurations */, + DF93066F033C46A7B100FC82 /* StripeCardScan */, + 0E245ED03746B2F8EE73B5F9 /* StripeCardScanTests */, + ); + name = Project; + sourceTree = ""; + }; + 3C728AAC6B6FF615A064C757 /* CardUtils */ = { + isa = PBXGroup; + children = ( + 311A5A7EF789248E35BB1FEC /* CardNetwork.swift */, + 236AE86CE7DCB0DFA3B7C978 /* CardType.swift */, + 8735A24B634F3B9060CADE45 /* CreditCardUtils.swift */, + 96F3DDAD14A9F2DEB98E236E /* Expiry.swift */, + ); + path = CardUtils; + sourceTree = ""; + }; + 40C830CD6973E7AD722C2213 /* Mock Data */ = { + isa = PBXGroup; + children = ( + 25575C2DBB0C8AAA658EC26E /* JSON */, + ); + path = "Mock Data"; + sourceTree = ""; + }; + 620D5B89096A8B2B2202E6EB /* CreditCardOcr */ = { + isa = PBXGroup; + children = ( + 8EDF39435211B456B7579739 /* AppleCreditCardOcr.swift */, + 05B28BBCB062B6AE5A3B7CBC /* CreditCardOcrImplementation.swift */, + 3DEE8C18A3CECFF0751AB0D5 /* CreditCardOcrPrediction.swift */, + EDE92803C69CE8253D73A25F /* CreditCardOcrResult.swift */, + 6BEC906101069A1E3C50FA51 /* ErrorCorrection.swift */, + AF17FD18E52CB3508AD895E3 /* MachineLearningResult.swift */, + 7D550382D6320388A5BA75A1 /* MainLoopStateMachine.swift */, + F96A85E323CFDA8BA35BC293 /* NonNameWords.swift */, + 7E38717F47ED06C115E058DE /* OcrMainLoop.swift */, + 0DC83A169A82484B9F60E452 /* OcrObject.swift */, + 0B00395C6A2B14A6D56735B9 /* SSDCreditCardOcr.swift */, + ); + path = CreditCardOcr; + sourceTree = ""; + }; + 66F259BA8091A6C5D28C4963 /* MLRuntime */ = { + isa = PBXGroup; + children = ( + 6EBC1F86A03CF9AAC6FE5825 /* ActiveStateComputation.swift */, + DCA2733CEC85F8E427CA99CB /* AppState.swift */, + A7EE5FDCA3B075FE5BBAF42A /* DetectedAllBoxes.swift */, + 1441A7A39C36C2A634A882F5 /* DetectedAllOcrBoxes.swift */, + B53D407C0AC8D44ECC0C3C52 /* DetectedBox.swift */, + A2EF6799FCE0817D9E3A536B /* DetectedSSDBox.swift */, + 5D58BEEC9D88537E32260CBA /* DetectedSSDOcrBox.swift */, + ACD9FF5C9DD64BE92DB0927D /* NMS.swift */, + 1FD9AF72B522A1E535A4EC81 /* OcrDD.swift */, + BC0B91384C63CBFB790C8EE6 /* OcrDDUtils.swift */, + ED780E8B4C14EE39F853B3A1 /* OcrPriorsGen.swift */, + AC9F1B20AEB433DE3CE0042C /* PostDetectionAlgorithm.swift */, + 4B452EE8B96AF1B0691E7D43 /* PredictionAPI.swift */, + 4BBE2CADB8D8E98D6EEE10C0 /* PredictionResult.swift */, + 795E739553651C8AC40E6AC1 /* PredictionUtilOcr.swift */, + 00A93BFDEE4072A2A9C30397 /* SoftNMS.swift */, + 8AEB912151CB564AE21D073C /* SSDOcrDetect.swift */, + EA89DA7FF10BC3A7B2D102FB /* SSDOcrOutputExtensions.swift */, + ); + path = MLRuntime; + sourceTree = ""; + }; + 69D6875F84C12C2E18B96B01 /* Helpers */ = { + isa = PBXGroup; + children = ( + DA3ECBF071E7507C9D41F232 /* EndToEndTestDataSource.swift */, + 6418F87D5168A2A08D65A482 /* STPLocalizedString.swift */, + 69A2DB0837C11D8829C027CF /* String+Localized.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 6A6547B8AC5A170A0092DCF5 /* Localizations */ = { + isa = PBXGroup; + children = ( + 6C27E58C1953533C68D43361 /* Localizable.strings */, + ); + path = Localizations; + sourceTree = ""; + }; + 6C3738C5D8A82E9A3C5AFC85 /* BuildConfigurations */ = { + isa = PBXGroup; + children = ( + 14795A45279E8F328AB54920 /* Project-Debug.xcconfig */, + 7DBB233BD36CBAE2700A4511 /* Project-Release.xcconfig */, + F1FCC855CCB0816414A4BEC1 /* StripeiOS Tests-Debug.xcconfig */, + A67EDE2BCCC081DEDD684AAC /* StripeiOS Tests-Release.xcconfig */, + ); + name = BuildConfigurations; + path = ../BuildConfigurations; + sourceTree = ""; + }; + 72B7637B9160BBA39556BA0A /* Products */ = { + isa = PBXGroup; + children = ( + DD6884D0B3347BBF2E61B1D6 /* StripeCardScan.framework */, + 2F9447632965B9BCE1E595E4 /* StripeCardScanTests.xctest */, + 2E889051D7CD76D4FB9084A1 /* StripeCore.framework */, + 17FA90EE37CF1E6A49532491 /* StripeCoreTestUtils.framework */, + ); + name = Products; + sourceTree = ""; + }; + 79C01535652A2F89EB22D9A9 /* BuildConfigurations */ = { + isa = PBXGroup; + children = ( + 0197A1615C3FA86907CB6186 /* StripeCardScan-Debug.xcconfig */, + C264A676DA41344551BA6661 /* StripeCardScan-Release.xcconfig */, + ); + path = BuildConfigurations; + sourceTree = ""; + }; + 8AA53DD0D15C5BC1040EE334 /* Scan Analytics */ = { + isa = PBXGroup; + children = ( + 2D61E754E520B4E21920D1DA /* ScanStatsPayload.swift */, + 028DA95BD9A0DA8AD69162CB /* ScanStatsPayload+Common.swift */, + 4A1CD398A6AFB6747D095C59 /* ScanStatsPayload+Tasks.swift */, + ); + path = "Scan Analytics"; + sourceTree = ""; + }; + 9AE51241602B3092F45811E8 /* Extensions */ = { + isa = PBXGroup; + children = ( + 8CA457DF27DC45455B8E1345 /* Array+utils.swift */, + BD87F9971C25C4B99138318F /* CGrect+utils.swift */, + 7633166B4D9096FE995F08F0 /* CGRectExtension.swift */, + 5E72DD18B9FCB80ABEDE2689 /* CreditCardOcrPrediction+expiry.swift */, + AEE9C295E982F92DE2418AE1 /* Image+utils.swift */, + B2D9886B0ED119E8D1FB6448 /* UIImage+pixelBuffer.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 9FDF572663A7F311B8506E13 /* CardScan */ = { + isa = PBXGroup; + children = ( + A1C38CDC3470D8E7A9F39671 /* AppleOcr */, + 3C728AAC6B6FF615A064C757 /* CardUtils */, + 620D5B89096A8B2B2202E6EB /* CreditCardOcr */, + 9AE51241602B3092F45811E8 /* Extensions */, + 10507D99D0E81A181A25B4BC /* MLModels */, + 66F259BA8091A6C5D28C4963 /* MLRuntime */, + E2C9F4FF2603FAF1BD4567B4 /* UI */, + E86A9678BB840BD3EA2AEB94 /* Utils */, + ); + path = CardScan; + sourceTree = ""; + }; + A1C38CDC3470D8E7A9F39671 /* AppleOcr */ = { + isa = PBXGroup; + children = ( + 3E24CF248F312DDE025D662A /* AppleOcr.swift */, + ); + path = AppleOcr; + sourceTree = ""; + }; + A35971ECF566C2769A608A06 /* Unit */ = { + isa = PBXGroup; + children = ( + D6C5CA3B4336E2024BFB5037 /* API Bindings */, + 20CA55CAA02DC2833AD1C426 /* ML Models */, + F24DEA029F94A359B7A571BB /* CardImageVerificationControllerTests.swift */, + 7DDAF8E33A3D7CD020AC904A /* CardImageVerificationDetailsResponseTest.swift */, + D0AA128D69F3C32A4EF67CF0 /* ImageCompressionTests.swift */, + 27F6ECCDC721E4D46DAC4F48 /* ScanAnalyticsManagerTests.swift */, + CD6D4DB87B26D439686392AE /* StrictModeFramesTest.swift */, + 5A72927DA8A946FDD03843DE /* StringResourceTests.swift */, + ); + path = Unit; + sourceTree = ""; + }; + BC0070EA31577F5573D7B787 /* Card Image Verification */ = { + isa = PBXGroup; + children = ( + 53C171A1336B7FB0C321D194 /* CancellationReason.swift */, + B313BA373EF6B567BF40A240 /* CardImageVerificationController.swift */, + CD5004FC0706B2660B4C6EA0 /* CardImageVerificationIntent.swift */, + 12F09D21BAC44C461B1AD7CB /* CardImageVerificationSheet.swift */, + 545E03D5167516D28C8A9F08 /* CardImageVerificationSheetConfiguration.swift */, + DCF4C0BF8E55D53B004FABCA /* CardScanSheetError.swift */, + 69E2630C0FC7C9DDD551FD67 /* ScanAnalyticsManager.swift */, + B97CA4D8286B196A49B8D535 /* ScanAnalyticsManager+Helpers.swift */, + 1A337FE5C3BE4FAB001E5520 /* ScanAnalyticsManager+Managers.swift */, + 6563E5CE5CD0439D5DBF35D7 /* ScanAnalyticsManager+Tasks.swift */, + 76C70F9A1879ED8B41639945 /* ScannedCard.swift */, + BAE45D8AC7BBA5DC433A9383 /* ScannedCardImageData.swift */, + 6C3EE6315FB1507E40F10D85 /* ScannedCardImageData+Verification.swift */, + 64A1E35C6DF336F34B6C7AEA /* StripeCore+Import.swift */, + ); + path = "Card Image Verification"; + sourceTree = ""; + }; + C8B14FA3DC29B84F382E6D1F /* Models */ = { + isa = PBXGroup; + children = ( + 8AA53DD0D15C5BC1040EE334 /* Scan Analytics */, + 24C2345660A95FBF90BF4850 /* VerificationFramesData.swift */, + E4FAA99A64BCD00A336A5E01 /* VerifyFrames.swift */, + ); + path = Models; + sourceTree = ""; + }; + C8CC6DB5A60043E8A373BC6F /* Helpers */ = { + isa = PBXGroup; + children = ( + 746AAB9327BDE6791F9393B0 /* CardScanMockData.swift */, + 63662689AD097C859B8759B1 /* Data+Sha256.swift */, + 091163ED5E37922332F502B1 /* ImageHelpers.swift */, + 15541EEEE784EECDD3E7399D /* ScannedCardDetails.swift */, + 732D3AB4CE25CC5C26A352B1 /* String+Sha256.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + D275033F1B615793C2F8B8E0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 14E5A51A77928A2700E23321 /* XCTest.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + D6C5CA3B4336E2024BFB5037 /* API Bindings */ = { + isa = PBXGroup; + children = ( + 5CF9ADC4CC0B4ADFE0A5427E /* ScanStatsPayloadAPIBindingsTests.swift */, + 3BC4F647EB512813078B9A6F /* STPAPIClient+CardImageVerificationTest.swift */, + 4301B281F5E0A5BACBBD68CC /* VerifyFramesAPIBindingsTests.swift */, + ); + path = "API Bindings"; + sourceTree = ""; + }; + DF93066F033C46A7B100FC82 /* StripeCardScan */ = { + isa = PBXGroup; + children = ( + 3228566B727B9D25D13A3BAB /* Resources */, + F28CFA0B72751113E838303F /* Source */, + 17BCBB8B34EBA904BB245CBD /* Info.plist */, + AB56DDBBF0E5BC10682C7D96 /* StripeCardScan.h */, + ); + path = StripeCardScan; + sourceTree = ""; + }; + E2C9F4FF2603FAF1BD4567B4 /* UI */ = { + isa = PBXGroup; + children = ( + 938958FCB9B918AD6CDF0F4E /* BlurView.swift */, + 14BC8B1C28C3F6C8330164E0 /* CardScanSheet.swift */, + C3E012270975B6DC09A8199F /* CornerView.swift */, + 77D0D8C2909455B76E246468 /* InterfaceOrientation.swift */, + DC12B6E0657203AAB765468C /* PreviewView.swift */, + A790FC6FAEC3F88FB2C26E27 /* ScanBaseViewController.swift */, + FF9618F7B15B33EC5B339A94 /* ScanConfiguration.swift */, + 0D1FBA1B4676CC5E9F785D51 /* ScanEventsProtocol.swift */, + 66A49708CF5D37C7CB267732 /* ScanStats.swift */, + A6E1DA01CB00CBDE36735EDD /* SimpleScanViewController.swift */, + C827434739027B2DD43449EA /* Torch.swift */, + D42510E09D32547455CDDBEF /* VideoFeed.swift */, + ); + path = UI; + sourceTree = ""; + }; + E86A9678BB840BD3EA2AEB94 /* Utils */ = { + isa = PBXGroup; + children = ( + 362A27BB81840A22104C9A49 /* AppInfoUtils.swift */, + D9B548B9626366BB0F110873 /* AtomicPropertyWrapper.swift */, + CDB03A9253E8FAD07B739438 /* DeviceUtils.swift */, + ); + path = Utils; + sourceTree = ""; + }; + F28CFA0B72751113E838303F /* Source */ = { + isa = PBXGroup; + children = ( + 9FDF572663A7F311B8506E13 /* CardScan */, + 324D2CF5354B3283BC0C49ED /* CardVerify */, + ); + path = Source; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + B4E70071212998150E28FF9A /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 1B10222A5121C9B2C3479FAB /* StripeCardScan.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 03CF79975A56288F02F20E52 /* StripeCardScan */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3942B170A6D222D5B5128339 /* Build configuration list for PBXNativeTarget "StripeCardScan" */; + buildPhases = ( + B4E70071212998150E28FF9A /* Headers */, + 44F5D31B0E7A7BFCB9425841 /* Sources */, + 7FD545F62DB7C0787E9CD12D /* Resources */, + 451E97BA25D9CE29EDAB7618 /* Embed Frameworks */, + 29BDDDCECDEC71451BAAB324 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = StripeCardScan; + productName = StripeCardScan; + productReference = DD6884D0B3347BBF2E61B1D6 /* StripeCardScan.framework */; + productType = "com.apple.product-type.framework"; + }; + DCC6FFCFBE0B51B33F4CBB26 /* StripeCardScanTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 89AE00D6B3B382DD3D3A3C70 /* Build configuration list for PBXNativeTarget "StripeCardScanTests" */; + buildPhases = ( + 201682B08469C4AC551E9BF8 /* Sources */, + 54119F2010F36CB483CA88CC /* Resources */, + 20B092D23CA44561EF997447 /* Embed Frameworks */, + 1639B69C00B8C21CEBF08A55 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 82DAA855443FCAA25F64C5C0 /* PBXTargetDependency */, + ); + name = StripeCardScanTests; + productName = StripeCardScanTests; + productReference = 2F9447632965B9BCE1E595E4 /* StripeCardScanTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 9A6BF50E5B02355004AC6020 /* Project object */ = { + isa = PBXProject; + attributes = { + TargetAttributes = { + }; + }; + buildConfigurationList = CBB5D42795303F47D9D5F2F5 /* Build configuration list for PBXProject "StripeCardScan" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + "bg-BG", + "ca-ES", + "cs-CZ", + da, + de, + "el-GR", + en, + "en-GB", + es, + "es-419", + "et-EE", + fi, + fil, + fr, + "fr-CA", + hr, + hu, + id, + it, + ja, + ko, + "lt-LT", + "lv-LV", + "ms-MY", + mt, + nb, + nl, + "nn-NO", + "pl-PL", + "pt-BR", + "pt-PT", + "ro-RO", + ru, + "sk-SK", + "sl-SI", + sv, + tr, + vi, + "zh-HK", + "zh-Hans", + "zh-Hant", + ); + mainGroup = 105B9420C58B9406F7939324; + productRefGroup = 72B7637B9160BBA39556BA0A /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 03CF79975A56288F02F20E52 /* StripeCardScan */, + DCC6FFCFBE0B51B33F4CBB26 /* StripeCardScanTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 54119F2010F36CB483CA88CC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0D62200AA65D71F040037CC8 /* CardImageVerification_CardAdd_200.json in Resources */, + 52519CA3928967768049164E /* CardImageVerification_CardSet_200.json in Resources */, + 59889C85D6D25B3222776B28 /* synthetic_test_image.jpg in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7FD545F62DB7C0787E9CD12D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A7F92FB7213E66A7D15A1BE3 /* SSDOcr.mlmodelc in Resources */, + B2DF7862E5F80ACD8DEE9317 /* UxModel.mlmodelc in Resources */, + 8CF112F4889C96617504A931 /* Localizable.strings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 201682B08469C4AC551E9BF8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 77443628E02F3AEFF112314D /* CardScanMockData.swift in Sources */, + DB74787AF4EEF0E506DD0E1C /* Data+Sha256.swift in Sources */, + 3F5B9466E9FC13CA29218750 /* ImageHelpers.swift in Sources */, + AE144927F21D5DF9A8C0EF79 /* ScannedCardDetails.swift in Sources */, + D42EEFBB72CD0AB00C6B4728 /* String+Sha256.swift in Sources */, + 449DD2A5D1F3FF94524D3CD6 /* STPAPIClient+CardImageVerificationTest.swift in Sources */, + 79A96C88970E4E61BAC47412 /* ScanStatsPayloadAPIBindingsTests.swift in Sources */, + 25FAF64D4FE93BF38E807ECA /* VerifyFramesAPIBindingsTests.swift in Sources */, + BDEA39DE5D3775AD2A4BFB6C /* CardImageVerificationControllerTests.swift in Sources */, + 19EF4634631CFC121DE21D1B /* CardImageVerificationDetailsResponseTest.swift in Sources */, + 48E4577B073DD9485BC62902 /* ImageCompressionTests.swift in Sources */, + 70B5FF75EF5DC8FFB23B95F7 /* UxModelTests.swift in Sources */, + 0D062E99363099A5728F3DCD /* ScanAnalyticsManagerTests.swift in Sources */, + FE55286899CFB0E34356D4D6 /* StrictModeFramesTest.swift in Sources */, + D6F0E1C93997BB36AF0608C0 /* StringResourceTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 44F5D31B0E7A7BFCB9425841 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2A9ECCF086685AD607D82492 /* AppleOcr.swift in Sources */, + C3B9A4A30443A38C2D17BE8E /* CardNetwork.swift in Sources */, + 0ADD10809FEABDCB59A8FA72 /* CardType.swift in Sources */, + A7014CF97250D97BC683FAC8 /* CreditCardUtils.swift in Sources */, + 050B462804602A9F4DB58A9D /* Expiry.swift in Sources */, + DDBCE05A3794129A9A04D511 /* AppleCreditCardOcr.swift in Sources */, + 60AF52B9EFFF12EBABE4D221 /* CreditCardOcrImplementation.swift in Sources */, + 1AD72764EA8BCB66A9D7B637 /* CreditCardOcrPrediction.swift in Sources */, + F9F19A6F2245863FEA485335 /* CreditCardOcrResult.swift in Sources */, + 17E34C9EF882AEAE47BECAB4 /* ErrorCorrection.swift in Sources */, + 858CDA751309CA04202B6203 /* MachineLearningResult.swift in Sources */, + 4ACBD9754F304CE4F70CAE43 /* MainLoopStateMachine.swift in Sources */, + C7A941539DBCFA790DCDB0B0 /* NonNameWords.swift in Sources */, + EE1CB7D3FA2B44DC17E6FF4C /* OcrMainLoop.swift in Sources */, + 5B1749A19231B20E2A081EB9 /* OcrObject.swift in Sources */, + C8E2E98B7ED108804E0A0E24 /* SSDCreditCardOcr.swift in Sources */, + C7259DA76E6AB9BF53735D19 /* Array+utils.swift in Sources */, + 10E840B00703A92CC487B324 /* CGRectExtension.swift in Sources */, + 7D9C9C2A26EF11F0C5D67D7C /* CGrect+utils.swift in Sources */, + CCB7722FDB8E6E68E90F3D12 /* CreditCardOcrPrediction+expiry.swift in Sources */, + D2030CA1B3B7DAF3229189AD /* Image+utils.swift in Sources */, + 1B71815D5B3EC8AC21E2A202 /* UIImage+pixelBuffer.swift in Sources */, + EA0F179DF887D94E19138A5E /* AsyncModelLoading.swift in Sources */, + DBE3EE5DAEEEA44D6C7ECD4E /* SSDOcr+Utils.swift in Sources */, + 213A91107E76434972A1F848 /* SSDOcr.swift in Sources */, + 189DEBAF38F2FB88CCA39EA2 /* UxModel+Utils.swift in Sources */, + 8E4B117272F5B17A1E6AD609 /* UxModel.swift in Sources */, + B66697826FEA9FCE81DDD6F4 /* ActiveStateComputation.swift in Sources */, + 47DCE237223D40B1EB183704 /* AppState.swift in Sources */, + 040020B8558B0489DF99F8B5 /* DetectedAllBoxes.swift in Sources */, + 48580A7E92CC8EBFD2FD4C91 /* DetectedAllOcrBoxes.swift in Sources */, + 20DF2E9D5A543360DF3A4DDA /* DetectedBox.swift in Sources */, + BFFA3E8CE79BFFE47530FAF6 /* DetectedSSDBox.swift in Sources */, + DF352D12199B55B659A11795 /* DetectedSSDOcrBox.swift in Sources */, + 7C71166DACDB829F8CBC383D /* NMS.swift in Sources */, + C633115B02A0CE24AC55A747 /* OcrDD.swift in Sources */, + 41366AB64512BB7E4248A904 /* OcrDDUtils.swift in Sources */, + 8FC373E15C1169677F563901 /* OcrPriorsGen.swift in Sources */, + 8463F2DB039C481D26A24BAA /* PostDetectionAlgorithm.swift in Sources */, + 0838FEA7071EDCE1B633F093 /* PredictionAPI.swift in Sources */, + 7B0CF214778E17FEC721602E /* PredictionResult.swift in Sources */, + 4D1016654AFB3D9EE26092B7 /* PredictionUtilOcr.swift in Sources */, + 5382B471868AA7D95BBD2B3A /* SSDOcrDetect.swift in Sources */, + 35F2BB10395405DB6C1E31B5 /* SSDOcrOutputExtensions.swift in Sources */, + 9FA5A312032FC54B35BB604E /* SoftNMS.swift in Sources */, + 164BF41B5C522B7338376BB2 /* BlurView.swift in Sources */, + 3736756FBB875060C86E2777 /* CardScanSheet.swift in Sources */, + F0ED2BFE143DD07337D389E5 /* CornerView.swift in Sources */, + 20DCEB955D9E0660EAB3ABEB /* InterfaceOrientation.swift in Sources */, + 30E3E90F9C8E3D3E8FCA869E /* PreviewView.swift in Sources */, + 3FCC183583090FD0246D9336 /* ScanBaseViewController.swift in Sources */, + 0D8CA0E3EFF9694CAE4FB8F7 /* ScanConfiguration.swift in Sources */, + C57BCF07EDF419D36ED4E690 /* ScanEventsProtocol.swift in Sources */, + 9188179F13E5EA15E0419580 /* ScanStats.swift in Sources */, + 74330F51A91DC3A617F7AE42 /* SimpleScanViewController.swift in Sources */, + 0EAA2314FEA8D05D24C498BD /* Torch.swift in Sources */, + 7CED1B42C94A49D6BF98C9E4 /* VideoFeed.swift in Sources */, + EF96103F82491640651C49F6 /* AppInfoUtils.swift in Sources */, + 6E9908C612ECD2E0AEA3DFF8 /* AtomicPropertyWrapper.swift in Sources */, + 49E1959C680AD68D1D345D6B /* DeviceUtils.swift in Sources */, + D27E02429E5DC8B28DFE8945 /* CardImageVerificationDetailsResponse.swift in Sources */, + BEE2BFA103DE985D057A0F9D /* ScanStatsPayload+Common.swift in Sources */, + 54469A1A5D77BAD54061BEA1 /* ScanStatsPayload+Tasks.swift in Sources */, + 19F7EC09C9B0ED11A4601E08 /* ScanStatsPayload.swift in Sources */, + AB21982DC43977754F237756 /* VerificationFramesData.swift in Sources */, + AFA334F5007A4C141A96FC2E /* VerifyFrames.swift in Sources */, + 77BB4BE6E5E03626936453C6 /* STPAPIClient+CardImageVerification.swift in Sources */, + 65C1EB5447CD5DF162CDEC2B /* Bouncer.swift in Sources */, + CBA4FE649A3B21DAC3A61E5B /* CancellationReason.swift in Sources */, + E4EC278027AF802C39C74C48 /* CardImageVerificationController.swift in Sources */, + AFB2482D559BCC08E96C3615 /* CardImageVerificationIntent.swift in Sources */, + 420FF119A7147335D802AB14 /* CardImageVerificationSheet.swift in Sources */, + E90CC9715EC99F6B7FAC98FA /* CardImageVerificationSheetConfiguration.swift in Sources */, + E70A7E38A7D858031F900649 /* CardScanSheetError.swift in Sources */, + CED42084C5FC46C17E357AD5 /* ScanAnalyticsManager+Helpers.swift in Sources */, + A8D0A57687A7CF9F389D7BDB /* ScanAnalyticsManager+Managers.swift in Sources */, + 0A4DCE2C98659B92DDB29B9A /* ScanAnalyticsManager+Tasks.swift in Sources */, + EB061FA550AFFE2AB2E348DF /* ScanAnalyticsManager.swift in Sources */, + 1DE075A700592250D87DF558 /* ScannedCard.swift in Sources */, + 05188062E522359CE24912CF /* ScannedCardImageData+Verification.swift in Sources */, + CC07F702B9EC043ACB0AC1E5 /* ScannedCardImageData.swift in Sources */, + 86635536450EE7D583B8BD59 /* StripeCore+Import.swift in Sources */, + 36525A0F774E7FA055D8B525 /* CardBase.swift in Sources */, + 2D042FD2E8A0C8236596CE54 /* CardScanFraudData.swift in Sources */, + EA2DBA78722CD65ED599190D /* CardScanMisc.swift in Sources */, + 5488DA1817A0C9F780EF69B2 /* CardVerifyFraudData.swift in Sources */, + 43AA83BFF8DEFC09D406669F /* CardVerifyStateMachine.swift in Sources */, + F16FAC158C6B0A7687547B4B /* FadeInAnimation.swift in Sources */, + ACC3C1A295E43983F67F858E /* FrameData.swift in Sources */, + 2F3FA5E8CCBF7F77106A8268 /* EndToEndTestDataSource.swift in Sources */, + 45C8D17FED02529B7FC4E8C3 /* STPLocalizedString.swift in Sources */, + 84975E58102A5D8C62C6C4E5 /* String+Localized.swift in Sources */, + 41F2E4475B9B19350C31F255 /* PaymentCard.swift in Sources */, + C5DBC36A41FB70C5DCCC97C7 /* SimpleScanViewController+Verify.swift in Sources */, + F4E4941F5AA2EC2C2CC4F7FB /* StripeCardScanBundleLocator.swift in Sources */, + 93D4785D8B91F12B6DDDE203 /* UxAnalyzer.swift in Sources */, + 18B63245A933E292345C9410 /* UxAndOcrMainLoop.swift in Sources */, + B00954CF5F2A15B72CB94FA5 /* VerifyCardAddViewController.swift in Sources */, + 06711423AB563634EADFCCC0 /* VerifyCardViewController.swift in Sources */, + B47F583CE0F239881877E5D3 /* ZoomedInCGImage.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 82DAA855443FCAA25F64C5C0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = StripeCardScan; + target = 03CF79975A56288F02F20E52 /* StripeCardScan */; + targetProxy = F7C4E731844D6B46A4FBCACD /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 6C27E58C1953533C68D43361 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + B0A7946C3A1E943FB72F3FB7 /* bg-BG */, + 8FB602C9E909E31BAE6703D6 /* ca-ES */, + 27F4BF66273070C2054BBAC0 /* cs-CZ */, + 4B89D81A4099CC71A3FBC133 /* da */, + FFE89E65E767D03B92383C76 /* de */, + D532C440ADCD017AD326299B /* el-GR */, + BEEE30227F7D34546F0FBB58 /* en */, + 6186A0AAB96E236DEF0B5B79 /* en-GB */, + 79A802EA46840CD45CBECEEA /* es */, + 5A6D5C8E51C23370E889F75F /* es-419 */, + CB48DDCA458A62DCE342F517 /* et-EE */, + 03E2C12C0D172DD7DAE33399 /* fi */, + 6F41FBE3D90B5F6518E42051 /* fil */, + 3A9C2B8A3B96E194CED6841C /* fr */, + F3AF32D7DF7EE1420D16291D /* fr-CA */, + 9C3890F6A5A4FF68F95746DC /* hr */, + 0C7BAE904C7E1D7A9FDF35F1 /* hu */, + 10311DC6AC19B7A73A8F930F /* id */, + ABB38869FD175D7BD6BB3890 /* it */, + 8094FE7CCD3CDA8B914EC534 /* ja */, + F87D884128F85E7F16BCBA9B /* ko */, + 58A5F2CEEAAB78AA425A5737 /* lt-LT */, + FD40E90452A83FFBBF65C8D8 /* lv-LV */, + 11D3465E527ABADBD2485A93 /* ms-MY */, + 9593D6788C4DBF554735E2B6 /* mt */, + 9A256BCB7822DD94572DDA07 /* nb */, + AB64890F80D91D9D7B92197D /* nl */, + 8C227CA761586744976C952D /* nn-NO */, + 1C94EE6DB0E3114A38DD9CFE /* pl-PL */, + 45C9A1A26405DEAD8E11F13D /* pt-BR */, + 2667E0E0C7DC6CDAF03F8B40 /* pt-PT */, + 9E4CF9A676A86169070DD54D /* ro-RO */, + 42454BA950282D9ADB9C6557 /* ru */, + 5969433D88B5BD3B7F56B2BD /* sk-SK */, + 88F89EC392AFBE060336B63F /* sl-SI */, + 89BBC06EC107C8BDEF79BB3D /* sv */, + 3938F9E3F0F267045D3FDDEB /* tr */, + 0BB35800FB840EFC76240DC7 /* vi */, + ADCB34B81919043FA35E8BC3 /* zh-Hans */, + A612DDE1110EA970BF69B33D /* zh-Hant */, + 4DA27FFBD426C871C5BD254E /* zh-HK */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 5EA7D712D38589E2EE95AB3A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0197A1615C3FA86907CB6186 /* StripeCardScan-Debug.xcconfig */; + buildSettings = { + INFOPLIST_FILE = StripeCardScan/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "com.stripe.stripe-card-scan"; + PRODUCT_NAME = StripeCardScan; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 7F230CA2FE5A1AE4D1DC64D7 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7DBB233BD36CBAE2700A4511 /* Project-Release.xcconfig */; + buildSettings = { + }; + name = Release; + }; + DCF5B81249E77660231925A7 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F1FCC855CCB0816414A4BEC1 /* StripeiOS Tests-Debug.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeCardScanTests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeCardScanTests; + PRODUCT_NAME = StripeCardScanTests; + SDKROOT = iphoneos; + }; + name = Debug; + }; + EDDF50C51DE9D6BEF2ACB9E5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = C264A676DA41344551BA6661 /* StripeCardScan-Release.xcconfig */; + buildSettings = { + INFOPLIST_FILE = StripeCardScan/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "com.stripe.stripe-card-scan"; + PRODUCT_NAME = StripeCardScan; + SDKROOT = iphoneos; + }; + name = Release; + }; + EE63655B6AEB11CB8934D19B /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A67EDE2BCCC081DEDD684AAC /* StripeiOS Tests-Release.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeCardScanTests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeCardScanTests; + PRODUCT_NAME = StripeCardScanTests; + SDKROOT = iphoneos; + }; + name = Release; + }; + FBC92FF6082EA51664498AE4 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 14795A45279E8F328AB54920 /* Project-Debug.xcconfig */; + buildSettings = { + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 3942B170A6D222D5B5128339 /* Build configuration list for PBXNativeTarget "StripeCardScan" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5EA7D712D38589E2EE95AB3A /* Debug */, + EDDF50C51DE9D6BEF2ACB9E5 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 89AE00D6B3B382DD3D3A3C70 /* Build configuration list for PBXNativeTarget "StripeCardScanTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DCF5B81249E77660231925A7 /* Debug */, + EE63655B6AEB11CB8934D19B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CBB5D42795303F47D9D5F2F5 /* Build configuration list for PBXProject "StripeCardScan" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FBC92FF6082EA51664498AE4 /* Debug */, + 7F230CA2FE5A1AE4D1DC64D7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 9A6BF50E5B02355004AC6020 /* Project object */; +} diff --git a/StripeCardScan/StripeCardScan.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/StripeCardScan/StripeCardScan.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/StripeCardScan/StripeCardScan.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/StripeCardScan/StripeCardScan.xcodeproj/xcshareddata/xcschemes/StripeCardScan.xcscheme b/StripeCardScan/StripeCardScan.xcodeproj/xcshareddata/xcschemes/StripeCardScan.xcscheme new file mode 100644 index 00000000..be21f1df --- /dev/null +++ b/StripeCardScan/StripeCardScan.xcodeproj/xcshareddata/xcschemes/StripeCardScan.xcscheme @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StripeCardScan/StripeCardScan/Info.plist b/StripeCardScan/StripeCardScan/Info.plist new file mode 100644 index 00000000..cd4a496b --- /dev/null +++ b/StripeCardScan/StripeCardScan/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(CURRENT_PROJECT_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/analytics/coremldata.bin b/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/analytics/coremldata.bin new file mode 100644 index 00000000..77dbb917 Binary files /dev/null and b/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/analytics/coremldata.bin differ diff --git a/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/coremldata.bin b/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/coremldata.bin new file mode 100644 index 00000000..b6893365 Binary files /dev/null and b/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/coremldata.bin differ diff --git a/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/metadata.json b/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/metadata.json new file mode 100644 index 00000000..1673fa50 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/metadata.json @@ -0,0 +1,92 @@ +[ + { + "shortDescription" : "", + "metadataOutputVersion" : "3.0", + "outputSchema" : [ + { + "hasShapeFlexibility" : "0", + "isOptional" : "0", + "dataType" : "Float32", + "formattedType" : "MultiArray (Float32)", + "shortDescription" : "MultiArray of shape (1, 1, 1, 3420, 10). The first and second dimensions correspond to sequence and batch size, respectively", + "shape" : "[]", + "name" : "scores", + "type" : "MultiArray" + }, + { + "hasShapeFlexibility" : "0", + "isOptional" : "0", + "dataType" : "Float32", + "formattedType" : "MultiArray (Float32)", + "shortDescription" : "MultiArray of shape (1, 1, 1, 3420, 4). The first and second dimensions correspond to sequence and batch size, respectively", + "shape" : "[]", + "name" : "boxes", + "type" : "MultiArray" + }, + { + "hasShapeFlexibility" : "0", + "isOptional" : "0", + "dataType" : "Float32", + "formattedType" : "MultiArray (Float32)", + "shortDescription" : "MultiArray of shape (1, 1, 1, 3420, 1). The first and second dimensions correspond to sequence and batch size, respectively", + "shape" : "[]", + "name" : "filter", + "type" : "MultiArray" + } + ], + "version" : "", + "modelParameters" : [ + + ], + "author" : "", + "specificationVersion" : 2, + "license" : "", + "isUpdatable" : "0", + "availability" : { + "macOS" : "10.13.2", + "tvOS" : "11.2", + "watchOS" : "4.2", + "iOS" : "11.2", + "macCatalyst" : "11.2" + }, + "modelType" : { + "name" : "MLModelType_neuralNetwork" + }, + "inputSchema" : [ + { + "height" : "375", + "colorspace" : "RGB", + "isOptional" : "0", + "width" : "600", + "isColor" : "1", + "formattedType" : "Image (Color 600 × 375)", + "hasSizeFlexibility" : "0", + "type" : "Image", + "shortDescription" : "", + "name" : "0" + } + ], + "userDefinedMetadata" : { + + }, + "generatedClassName" : "SSDOcr", + "neuralNetworkLayerTypeHistogram" : { + "UnaryExp" : 1, + "Concat" : 2, + "ActivationSigmoidHard" : 2, + "Convolution" : 64, + "UnaryInverse" : 1, + "Flatten" : 2, + "ReduceSum" : 2, + "Reshape" : 5, + "Permute" : 7, + "Add" : 10, + "BatchNorm" : 60, + "Multiply" : 1, + "ActivationLinear" : 8, + "ActivationReLU" : 41, + "Slice" : 1 + }, + "method" : "predict" + } +] \ No newline at end of file diff --git a/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/model.espresso.net b/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/model.espresso.net new file mode 100644 index 00000000..c673fefa --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/model.espresso.net @@ -0,0 +1,2489 @@ +{ + "transform_params" : { + "0" : { + "bias_a" : 0, + "bias_g" : -0.99221789836883545, + "bias_r" : -0.99221789836883545, + "bias_b" : -0.99221789836883545, + "center_mean" : 0, + "is_network_bgr" : 0, + "scale" : 0.0077821011655032635 + } + }, + "properties" : { + + }, + "analyses" : { + + }, + "format_version" : 200, + "storage" : "model.espresso.weights", + "layers" : [ + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "377", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "379", + "K" : 3, + "blob_biases" : 1, + "stride_x" : 2, + "name" : "377", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 1, + "stride_y" : 2, + "has_biases" : 1, + "C" : 16, + "bottom" : "0", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 3 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "380", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "382", + "K" : 16, + "blob_biases" : 5, + "name" : "380", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 16, + "pad_t" : 1, + "has_biases" : 1, + "C" : 16, + "bottom" : "379", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 7 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "383", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "384", + "K" : 16, + "blob_biases" : 9, + "name" : "383", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 8, + "bottom" : "382", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 11 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "385", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "387", + "K" : 8, + "blob_biases" : 13, + "name" : "385", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 48, + "bottom" : "384", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 15 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "388", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "390", + "K" : 48, + "blob_biases" : 17, + "stride_x" : 2, + "name" : "388", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 48, + "pad_t" : 1, + "stride_y" : 2, + "has_biases" : 1, + "C" : 48, + "bottom" : "387", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 19 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "391", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "392", + "K" : 48, + "blob_biases" : 21, + "name" : "391", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 12, + "bottom" : "390", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 23 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "393", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "395", + "K" : 12, + "blob_biases" : 25, + "name" : "393", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 72, + "bottom" : "392", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 27 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "396", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "398", + "K" : 72, + "blob_biases" : 29, + "name" : "396", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 72, + "pad_t" : 1, + "has_biases" : 1, + "C" : 72, + "bottom" : "395", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 31 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "399", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "400", + "K" : 72, + "blob_biases" : 33, + "name" : "399", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 12, + "bottom" : "398", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 35 + }, + { + "bottom" : "392,400", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "401", + "top" : "401", + "type" : "elementwise", + "name" : "401", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "402", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "404", + "K" : 12, + "blob_biases" : 37, + "name" : "402", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 72, + "bottom" : "401", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 39 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "405", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "407", + "K" : 72, + "blob_biases" : 41, + "stride_x" : 2, + "name" : "405", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 72, + "pad_t" : 1, + "stride_y" : 2, + "has_biases" : 1, + "C" : 72, + "bottom" : "404", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 43 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "408", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "409", + "K" : 72, + "blob_biases" : 45, + "name" : "408", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 16, + "bottom" : "407", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 47 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "410", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "412", + "K" : 16, + "blob_biases" : 49, + "name" : "410", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 96, + "bottom" : "409", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 51 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "413", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "415", + "K" : 96, + "blob_biases" : 53, + "name" : "413", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 96, + "pad_t" : 1, + "has_biases" : 1, + "C" : 96, + "bottom" : "412", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 55 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "416", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "417", + "K" : 96, + "blob_biases" : 57, + "name" : "416", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 16, + "bottom" : "415", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 59 + }, + { + "bottom" : "409,417", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "418", + "top" : "418", + "type" : "elementwise", + "name" : "418", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "419", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "421", + "K" : 16, + "blob_biases" : 61, + "name" : "419", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 96, + "bottom" : "418", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 63 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "422", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "424", + "K" : 96, + "blob_biases" : 65, + "name" : "422", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 96, + "pad_t" : 1, + "has_biases" : 1, + "C" : 96, + "bottom" : "421", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 67 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "425", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "426", + "K" : 96, + "blob_biases" : 69, + "name" : "425", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 16, + "bottom" : "424", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 71 + }, + { + "bottom" : "418,426", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "427", + "top" : "427", + "type" : "elementwise", + "name" : "427", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "428", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "430", + "K" : 16, + "blob_biases" : 73, + "name" : "428", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 96, + "bottom" : "427", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 75 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "431", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "433", + "K" : 96, + "blob_biases" : 77, + "stride_x" : 2, + "name" : "431", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 96, + "pad_t" : 1, + "stride_y" : 2, + "has_biases" : 1, + "C" : 96, + "bottom" : "430", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 79 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "434", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "435", + "K" : 96, + "blob_biases" : 81, + "name" : "434", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 32, + "bottom" : "433", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 83 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "436", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "438", + "K" : 32, + "blob_biases" : 85, + "name" : "436", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 192, + "bottom" : "435", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 87 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "439", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "441", + "K" : 192, + "blob_biases" : 89, + "name" : "439", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 192, + "pad_t" : 1, + "has_biases" : 1, + "C" : 192, + "bottom" : "438", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 91 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "442", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "443", + "K" : 192, + "blob_biases" : 93, + "name" : "442", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 32, + "bottom" : "441", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 95 + }, + { + "bottom" : "435,443", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "444", + "top" : "444", + "type" : "elementwise", + "name" : "444", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "445", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "447", + "K" : 32, + "blob_biases" : 97, + "name" : "445", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 192, + "bottom" : "444", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 99 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "448", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "450", + "K" : 192, + "blob_biases" : 101, + "name" : "448", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 192, + "pad_t" : 1, + "has_biases" : 1, + "C" : 192, + "bottom" : "447", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 103 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "451", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "452", + "K" : 192, + "blob_biases" : 105, + "name" : "451", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 32, + "bottom" : "450", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 107 + }, + { + "bottom" : "444,452", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "453", + "top" : "453", + "type" : "elementwise", + "name" : "453", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "454", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "456", + "K" : 32, + "blob_biases" : 109, + "name" : "454", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 192, + "bottom" : "453", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 111 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "457", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "459", + "K" : 192, + "blob_biases" : 113, + "name" : "457", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 192, + "pad_t" : 1, + "has_biases" : 1, + "C" : 192, + "bottom" : "456", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 115 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "460", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "461", + "K" : 192, + "blob_biases" : 117, + "name" : "460", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 32, + "bottom" : "459", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 119 + }, + { + "bottom" : "453,461", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "462", + "top" : "462", + "type" : "elementwise", + "name" : "462", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "463", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "465", + "K" : 32, + "blob_biases" : 121, + "name" : "463", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 192, + "bottom" : "462", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 123 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "466", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "468", + "K" : 192, + "blob_biases" : 125, + "name" : "466", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 192, + "pad_t" : 1, + "has_biases" : 1, + "C" : 192, + "bottom" : "465", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 127 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "469", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "470", + "K" : 192, + "blob_biases" : 129, + "name" : "469", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 48, + "bottom" : "468", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 131 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "471", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "473", + "K" : 48, + "blob_biases" : 133, + "name" : "471", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 288, + "bottom" : "470", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 135 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "474", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "476", + "K" : 288, + "blob_biases" : 137, + "name" : "474", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 288, + "pad_t" : 1, + "has_biases" : 1, + "C" : 288, + "bottom" : "473", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 139 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "477", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "478", + "K" : 288, + "blob_biases" : 141, + "name" : "477", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 48, + "bottom" : "476", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 143 + }, + { + "bottom" : "470,478", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "479", + "top" : "479", + "type" : "elementwise", + "name" : "479", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "480", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "482", + "K" : 48, + "blob_biases" : 145, + "name" : "480", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 288, + "bottom" : "479", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 147 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "483", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "485", + "K" : 288, + "blob_biases" : 149, + "name" : "483", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 288, + "pad_t" : 1, + "has_biases" : 1, + "C" : 288, + "bottom" : "482", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 151 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "486", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "487", + "K" : 288, + "blob_biases" : 153, + "name" : "486", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 48, + "bottom" : "485", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 155 + }, + { + "bottom" : "479,487", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "488", + "top" : "488", + "type" : "elementwise", + "name" : "488", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "489", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "491", + "K" : 48, + "blob_biases" : 157, + "name" : "489", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 288, + "bottom" : "488", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 159 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "492", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "494", + "K" : 288, + "blob_biases" : 161, + "stride_x" : 2, + "name" : "492", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 288, + "pad_t" : 1, + "stride_y" : 2, + "has_biases" : 1, + "C" : 288, + "bottom" : "491", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 163 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "495", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "496", + "K" : 288, + "blob_biases" : 165, + "name" : "495", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 80, + "bottom" : "494", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 167 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "497", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "499", + "K" : 288, + "blob_biases" : 169, + "name" : "497", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 288, + "pad_t" : 1, + "has_biases" : 1, + "C" : 288, + "bottom" : "491", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 171 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "500", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "502", + "K" : 288, + "blob_biases" : 173, + "name" : "500", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 288, + "pad_t" : 1, + "has_biases" : 1, + "C" : 288, + "bottom" : "499", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 175 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "503", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "505", + "K" : 288, + "blob_biases" : 177, + "name" : "503", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 288, + "pad_t" : 1, + "has_biases" : 1, + "C" : 288, + "bottom" : "502", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 179 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "506", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "506", + "K" : 288, + "blob_biases" : 181, + "name" : "506", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 33, + "bottom" : "505", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 183 + }, + { + "axis_h" : 0, + "axis_w" : 2, + "bottom" : "506", + "axis_k" : 1, + "axis_n" : 3, + "axis_seq" : 4, + "weights" : { + + }, + "debug_info" : "507", + "top" : "507", + "type" : "transpose", + "name" : "507" + }, + { + "name" : "509", + "weights" : { + + }, + "dst_w" : 1, + "version" : 1, + "dst_n" : 0, + "type" : "reshape", + "dst_h" : 1, + "mode" : 0, + "bottom" : "507", + "debug_info" : "509", + "hint_fallback_from_metal" : 1, + "dst_seq" : -1, + "dst_k" : 11, + "top" : "509" + }, + { + "pad_r" : 1, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "510", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "511", + "K" : 288, + "blob_biases" : 185, + "name" : "510", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 288, + "pad_t" : 1, + "has_biases" : 1, + "C" : 288, + "bottom" : "491", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 187 + }, + { + "alpha" : 0.1666666716337204, + "bottom" : "511", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "512_scale_0_1", + "top" : "511_scale_0_1", + "type" : "activation", + "name" : "512_scale_0_1", + "beta" : 0 + }, + { + "bottom" : "511_scale_0_1", + "weights" : { + + }, + "mode" : 7, + "debug_info" : "512_clip_0_1", + "top" : "511_clip_0_1", + "type" : "activation", + "name" : "512_clip_0_1", + "beta" : 0 + }, + { + "alpha" : 6, + "bottom" : "511_clip_0_1", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "512", + "top" : "512", + "type" : "activation", + "name" : "512", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "513", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "513", + "K" : 288, + "blob_biases" : 189, + "name" : "513", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 12, + "bottom" : "512", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 191 + }, + { + "axis_h" : 0, + "axis_w" : 2, + "bottom" : "513", + "axis_k" : 1, + "axis_n" : 3, + "axis_seq" : 4, + "weights" : { + + }, + "debug_info" : "514", + "top" : "514", + "type" : "transpose", + "name" : "514" + }, + { + "name" : "524", + "weights" : { + + }, + "dst_w" : 4, + "version" : 1, + "dst_n" : 0, + "type" : "reshape", + "dst_h" : -1, + "mode" : 0, + "bottom" : "514", + "debug_info" : "524", + "dst_seq" : 1, + "dst_k" : 1, + "top" : "524" + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "525", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "527", + "K" : 80, + "blob_biases" : 193, + "name" : "525", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 480, + "bottom" : "496", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 195 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "528", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "530", + "K" : 480, + "blob_biases" : 197, + "name" : "528", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 480, + "pad_t" : 1, + "has_biases" : 1, + "C" : 480, + "bottom" : "527", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 199 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "531", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "532", + "K" : 480, + "blob_biases" : 201, + "name" : "531", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 80, + "bottom" : "530", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 203 + }, + { + "bottom" : "496,532", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "533", + "top" : "533", + "type" : "elementwise", + "name" : "533", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "534", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "536", + "K" : 80, + "blob_biases" : 205, + "name" : "534", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 480, + "bottom" : "533", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 207 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "537", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "539", + "K" : 480, + "blob_biases" : 209, + "name" : "537", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 480, + "pad_t" : 1, + "has_biases" : 1, + "C" : 480, + "bottom" : "536", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 211 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "540", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "541", + "K" : 480, + "blob_biases" : 213, + "name" : "540", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 80, + "bottom" : "539", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 215 + }, + { + "bottom" : "533,541", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "542", + "top" : "542", + "type" : "elementwise", + "name" : "542", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "543", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "545", + "K" : 80, + "blob_biases" : 217, + "name" : "543", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 480, + "bottom" : "542", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 219 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "546", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "548", + "K" : 480, + "blob_biases" : 221, + "name" : "546", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 480, + "pad_t" : 1, + "has_biases" : 1, + "C" : 480, + "bottom" : "545", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 223 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "549", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "550", + "K" : 480, + "blob_biases" : 225, + "name" : "549", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 160, + "bottom" : "548", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 227 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "551", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "553", + "K" : 160, + "blob_biases" : 229, + "name" : "551", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 1280, + "bottom" : "550", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 231 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "554", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "556", + "K" : 1280, + "blob_biases" : 233, + "name" : "554", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1280, + "pad_t" : 1, + "has_biases" : 1, + "C" : 1280, + "bottom" : "553", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 235 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "557", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "559", + "K" : 1280, + "blob_biases" : 237, + "name" : "557", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1280, + "pad_t" : 1, + "has_biases" : 1, + "C" : 1280, + "bottom" : "556", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 239 + }, + { + "pad_r" : 1, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "560", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "562", + "K" : 1280, + "blob_biases" : 241, + "name" : "560", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1280, + "pad_t" : 1, + "has_biases" : 1, + "C" : 1280, + "bottom" : "559", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 243 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "563", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "563", + "K" : 1280, + "blob_biases" : 245, + "name" : "563", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 33, + "bottom" : "562", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 247 + }, + { + "axis_h" : 0, + "axis_w" : 2, + "bottom" : "563", + "axis_k" : 1, + "axis_n" : 3, + "axis_seq" : 4, + "weights" : { + + }, + "debug_info" : "564", + "top" : "564", + "type" : "transpose", + "name" : "564" + }, + { + "name" : "566", + "weights" : { + + }, + "dst_w" : 1, + "version" : 1, + "dst_n" : 0, + "type" : "reshape", + "dst_h" : 1, + "mode" : 0, + "bottom" : "564", + "debug_info" : "566", + "dst_seq" : -1, + "dst_k" : 11, + "top" : "566" + }, + { + "pad_r" : 1, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "567", + "pad_fill_mode" : 0, + "pad_b" : 1, + "pad_l" : 1, + "top" : "568", + "K" : 1280, + "blob_biases" : 249, + "name" : "567", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1280, + "pad_t" : 1, + "has_biases" : 1, + "C" : 1280, + "bottom" : "553", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 251 + }, + { + "alpha" : 0.1666666716337204, + "bottom" : "568", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "569_scale_0_1", + "top" : "568_scale_0_1", + "type" : "activation", + "name" : "569_scale_0_1", + "beta" : 0 + }, + { + "bottom" : "568_scale_0_1", + "weights" : { + + }, + "mode" : 7, + "debug_info" : "569_clip_0_1", + "top" : "568_clip_0_1", + "type" : "activation", + "name" : "569_clip_0_1", + "beta" : 0 + }, + { + "alpha" : 6, + "bottom" : "568_clip_0_1", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "569", + "top" : "569", + "type" : "activation", + "name" : "569", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "570", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "570", + "K" : 1280, + "blob_biases" : 253, + "name" : "570", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 12, + "bottom" : "569", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 0, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 255 + }, + { + "axis_h" : 0, + "axis_w" : 2, + "bottom" : "570", + "axis_k" : 1, + "axis_n" : 3, + "axis_seq" : 4, + "weights" : { + + }, + "debug_info" : "571", + "top" : "571", + "type" : "transpose", + "name" : "571" + }, + { + "name" : "581", + "weights" : { + + }, + "dst_w" : 4, + "version" : 1, + "dst_n" : 0, + "type" : "reshape", + "dst_h" : -1, + "mode" : 0, + "bottom" : "571", + "debug_info" : "581", + "dst_seq" : 1, + "dst_k" : 1, + "top" : "581" + }, + { + "bottom" : "509,566", + "weights" : { + + }, + "simple_concat" : 1, + "hint_fallback_from_metal" : 1, + "debug_info" : "582", + "top" : "582", + "type" : "sequence_concat", + "name" : "582" + }, + { + "axis_h" : 2, + "axis_w" : 0, + "bottom" : "524", + "axis_k" : 1, + "axis_n" : 3, + "axis_seq" : 4, + "weights" : { + + }, + "debug_info" : "boxes_input_transpose0", + "top" : "boxes_524_transpose", + "type" : "transpose", + "name" : "boxes_input_transpose0" + }, + { + "axis_h" : 2, + "axis_w" : 0, + "bottom" : "581", + "axis_k" : 1, + "axis_n" : 3, + "axis_seq" : 4, + "weights" : { + + }, + "debug_info" : "boxes_input_transpose1", + "top" : "boxes_581_transpose", + "type" : "transpose", + "name" : "boxes_input_transpose1" + }, + { + "weights" : { + + }, + "debug_info" : "boxes", + "top" : "boxes_transpose", + "type" : "concat", + "name" : "boxes", + "bottom" : "boxes_524_transpose,boxes_581_transpose" + }, + { + "axis_seq" : 4, + "name" : "boxes_output_transpose0", + "axis_n" : 3, + "axis_h" : 2, + "type" : "transpose", + "attributes" : { + "is_output" : 1 + }, + "bottom" : "boxes_transpose", + "axis_w" : 0, + "axis_k" : 1, + "debug_info" : "boxes_output_transpose0", + "weights" : { + + }, + "top" : "boxes" + }, + { + "bottom" : "582", + "alpha" : 1, + "operation" : 27, + "weights" : { + + }, + "hint_fallback_from_metal" : 1, + "fused_relu" : 0, + "debug_info" : "584", + "top" : "584", + "type" : "elementwise", + "name" : "584", + "beta" : 0 + }, + { + "bottom" : "584", + "axis_mode" : 4, + "weights" : { + + }, + "mode" : 0, + "hint_fallback_from_metal" : 1, + "debug_info" : "585", + "use_version" : 1, + "top" : "585", + "type" : "reduce", + "name" : "585" + }, + { + "bottom" : "585", + "weights" : { + + }, + "mode" : 0, + "nd_axis" : 0, + "debug_info" : "587", + "top" : "587", + "type" : "flatten", + "name" : "587" + }, + { + "bottom" : "584", + "weights" : { + + }, + "mode" : 6, + "hint_fallback_from_metal" : 1, + "debug_info" : "588", + "top" : "588", + "type" : "activation", + "name" : "588", + "beta" : 0 + }, + { + "bottom" : "588", + "weights" : { + + }, + "mode" : 6, + "hint_fallback_from_metal" : 1, + "debug_info" : "589", + "top" : "589", + "type" : "activation", + "name" : "589", + "beta" : 0 + }, + { + "bottom" : "587", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "590", + "top" : "590", + "type" : "activation", + "name" : "590", + "beta" : 0 + }, + { + "bottom" : "590", + "alpha" : 1, + "operation" : 10, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "591_inverse", + "top" : "590_inverse", + "type" : "elementwise", + "name" : "591_inverse", + "beta" : 0 + }, + { + "bottom" : "589,590_inverse", + "alpha" : 1, + "operation" : 1, + "weights" : { + + }, + "hint_fallback_from_metal" : 1, + "fused_relu" : 0, + "debug_info" : "591", + "top" : "591", + "type" : "elementwise", + "name" : "591", + "beta" : 0 + }, + { + "bottom" : "591", + "weights" : { + + }, + "mode" : 6, + "hint_fallback_from_metal" : 1, + "debug_info" : "592", + "top" : "592", + "type" : "activation", + "name" : "592", + "beta" : 0 + }, + { + "bottom" : "592", + "end" : 11, + "start" : 1, + "weights" : { + + }, + "hint_fallback_from_metal" : 1, + "debug_info" : "593", + "axis" : 2, + "top" : "593", + "type" : "slice", + "name" : "593" + }, + { + "bottom" : "593", + "axis_mode" : 4, + "weights" : { + + }, + "mode" : 0, + "hint_fallback_from_metal" : 1, + "debug_info" : "594", + "use_version" : 1, + "top" : "594", + "type" : "reduce", + "name" : "594" + }, + { + "dst_seq" : 1, + "weights" : { + + }, + "dst_w" : 10, + "version" : 1, + "dst_n" : 0, + "type" : "reshape", + "dst_h" : -1, + "mode" : 0, + "attributes" : { + "is_output" : 1 + }, + "bottom" : "593", + "debug_info" : "scores", + "hint_fallback_from_metal" : 1, + "dst_k" : 1, + "name" : "scores", + "top" : "scores" + }, + { + "bottom" : "594", + "weights" : { + + }, + "mode" : 0, + "nd_axis" : 0, + "debug_info" : "filter", + "top" : "filter", + "type" : "flatten", + "name" : "filter", + "attributes" : { + "is_output" : 1 + } + } + ] +} \ No newline at end of file diff --git a/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/model.espresso.shape b/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/model.espresso.shape new file mode 100644 index 00000000..9e6d92c3 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/model.espresso.shape @@ -0,0 +1,661 @@ +{ + "layer_shapes" : { + "584" : { + "k" : 11, + "w" : 1, + "n" : 1, + "seq" : 3420, + "h" : 1 + }, + "407" : { + "k" : 72, + "w" : 75, + "n" : 1, + "h" : 47 + }, + "430" : { + "k" : 96, + "w" : 75, + "n" : 1, + "h" : 47 + }, + "569" : { + "k" : 1280, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "415" : { + "k" : 96, + "w" : 75, + "n" : 1, + "h" : 47 + }, + "502" : { + "k" : 288, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "592" : { + "k" : 11, + "w" : 1, + "n" : 1, + "seq" : 3420, + "h" : 1 + }, + "511_scale_0_1" : { + "k" : 288, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "585" : { + "k" : 1, + "w" : 1, + "n" : 1, + "seq" : 3420, + "h" : 1 + }, + "593" : { + "k" : 10, + "w" : 1, + "n" : 1, + "seq" : 3420, + "h" : 1 + }, + "424" : { + "k" : 96, + "w" : 75, + "n" : 1, + "h" : 47 + }, + "499" : { + "k" : 288, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "511" : { + "k" : 288, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "409" : { + "k" : 16, + "w" : 75, + "n" : 1, + "h" : 47 + }, + "594" : { + "k" : 1, + "w" : 1, + "n" : 1, + "seq" : 3420, + "h" : 1 + }, + "417" : { + "k" : 16, + "w" : 75, + "n" : 1, + "h" : 47 + }, + "filter" : { + "k" : 1, + "w" : 1, + "n" : 1, + "seq" : 3420, + "h" : 1 + }, + "512" : { + "k" : 288, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "587" : { + "k" : 1, + "w" : 1, + "n" : 1, + "seq" : 3420, + "h" : 1 + }, + "433" : { + "k" : 96, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "418" : { + "k" : 16, + "w" : 75, + "n" : 1, + "h" : 47 + }, + "441" : { + "k" : 192, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "505" : { + "k" : 288, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "426" : { + "k" : 16, + "w" : 75, + "n" : 1, + "h" : 47 + }, + "513" : { + "k" : 12, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "588" : { + "k" : 11, + "w" : 1, + "n" : 1, + "seq" : 3420, + "h" : 1 + }, + "568_scale_0_1" : { + "k" : 1280, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "506" : { + "k" : 33, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "427" : { + "k" : 16, + "w" : 75, + "n" : 1, + "h" : 47 + }, + "450" : { + "k" : 192, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "514" : { + "k" : 24, + "w" : 12, + "n" : 1, + "h" : 38 + }, + "435" : { + "k" : 32, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "568_clip_0_1" : { + "k" : 1280, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "589" : { + "k" : 11, + "w" : 1, + "n" : 1, + "seq" : 3420, + "h" : 1 + }, + "443" : { + "k" : 32, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "507" : { + "k" : 24, + "w" : 33, + "n" : 1, + "h" : 38 + }, + "530" : { + "k" : 480, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "590_inverse" : { + "k" : 1, + "w" : 1, + "n" : 1, + "seq" : 3420, + "h" : 1 + }, + "444" : { + "k" : 32, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "452" : { + "k" : 32, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "524" : { + "k" : 1, + "w" : 4, + "n" : 1, + "h" : 2736 + }, + "509" : { + "k" : 11, + "w" : 1, + "n" : 1, + "seq" : 2736, + "h" : 1 + }, + "532" : { + "k" : 80, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "453" : { + "k" : 32, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "438" : { + "k" : 192, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "461" : { + "k" : 32, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "382" : { + "k" : 16, + "w" : 300, + "n" : 1, + "h" : 188 + }, + "533" : { + "k" : 80, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "390" : { + "k" : 48, + "w" : 150, + "n" : 1, + "h" : 94 + }, + "541" : { + "k" : 80, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "462" : { + "k" : 32, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "470" : { + "k" : 48, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "447" : { + "k" : 192, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "542" : { + "k" : 80, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "527" : { + "k" : 480, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "550" : { + "k" : 160, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "384" : { + "k" : 8, + "w" : 300, + "n" : 1, + "h" : 188 + }, + "392" : { + "k" : 12, + "w" : 150, + "n" : 1, + "h" : 94 + }, + "456" : { + "k" : 192, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "511_clip_0_1" : { + "k" : 288, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "536" : { + "k" : 480, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "465" : { + "k" : 192, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "473" : { + "k" : 288, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "545" : { + "k" : 480, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "379" : { + "k" : 16, + "w" : 300, + "n" : 1, + "h" : 188 + }, + "553" : { + "k" : 1280, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "387" : { + "k" : 48, + "w" : 300, + "n" : 1, + "h" : 188 + }, + "395" : { + "k" : 72, + "w" : 150, + "n" : 1, + "h" : 94 + }, + "459" : { + "k" : 192, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "482" : { + "k" : 288, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "400" : { + "k" : 12, + "w" : 150, + "n" : 1, + "h" : 94 + }, + "539" : { + "k" : 480, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "562" : { + "k" : 1280, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "570" : { + "k" : 12, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "boxes_transpose" : { + "k" : 3420, + "w" : 4, + "n" : 1, + "h" : 1 + }, + "468" : { + "k" : 192, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "491" : { + "k" : 288, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "401" : { + "k" : 12, + "w" : 150, + "n" : 1, + "h" : 94 + }, + "476" : { + "k" : 288, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "563" : { + "k" : 33, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "548" : { + "k" : 480, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "571" : { + "k" : 12, + "w" : 12, + "n" : 1, + "h" : 19 + }, + "556" : { + "k" : 1280, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "564" : { + "k" : 12, + "w" : 33, + "n" : 1, + "h" : 19 + }, + "398" : { + "k" : 72, + "w" : 150, + "n" : 1, + "h" : 94 + }, + "0" : { + "k" : 3, + "w" : 600, + "n" : 1, + "h" : 375 + }, + "485" : { + "k" : 288, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "boxes" : { + "k" : 1, + "w" : 4, + "n" : 1, + "h" : 3420 + }, + "boxes_524_transpose" : { + "k" : 2736, + "w" : 4, + "n" : 1, + "h" : 1 + }, + "478" : { + "k" : 48, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "scores" : { + "k" : 1, + "w" : 10, + "n" : 1, + "h" : 3420 + }, + "boxes_581_transpose" : { + "k" : 684, + "w" : 4, + "n" : 1, + "h" : 1 + }, + "494" : { + "k" : 288, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "581" : { + "k" : 1, + "w" : 4, + "n" : 1, + "h" : 684 + }, + "404" : { + "k" : 72, + "w" : 150, + "n" : 1, + "h" : 94 + }, + "479" : { + "k" : 48, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "566" : { + "k" : 11, + "w" : 1, + "n" : 1, + "seq" : 684, + "h" : 1 + }, + "412" : { + "k" : 96, + "w" : 75, + "n" : 1, + "h" : 47 + }, + "487" : { + "k" : 48, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "559" : { + "k" : 1280, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "582" : { + "k" : 11, + "w" : 1, + "n" : 1, + "seq" : 3420, + "h" : 1 + }, + "590" : { + "k" : 1, + "w" : 1, + "n" : 1, + "seq" : 3420, + "h" : 1 + }, + "488" : { + "k" : 48, + "w" : 38, + "n" : 1, + "h" : 24 + }, + "421" : { + "k" : 96, + "w" : 75, + "n" : 1, + "h" : 47 + }, + "496" : { + "k" : 80, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "568" : { + "k" : 1280, + "w" : 19, + "n" : 1, + "h" : 12 + }, + "591" : { + "k" : 11, + "w" : 1, + "n" : 1, + "seq" : 3420, + "h" : 1 + } + } +} \ No newline at end of file diff --git a/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/model.espresso.weights b/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/model.espresso.weights new file mode 100644 index 00000000..727f6b6e Binary files /dev/null and b/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/model.espresso.weights differ diff --git a/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/model/coremldata.bin b/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/model/coremldata.bin new file mode 100644 index 00000000..ce9aa8bf Binary files /dev/null and b/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/model/coremldata.bin differ diff --git a/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/neural_network_optionals/coremldata.bin b/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/neural_network_optionals/coremldata.bin new file mode 100644 index 00000000..6459c295 Binary files /dev/null and b/StripeCardScan/StripeCardScan/Resources/CompiledModels/SSDOcr.mlmodelc/neural_network_optionals/coremldata.bin differ diff --git a/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/analytics/coremldata.bin b/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/analytics/coremldata.bin new file mode 100644 index 00000000..a859710c Binary files /dev/null and b/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/analytics/coremldata.bin differ diff --git a/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/coremldata.bin b/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/coremldata.bin new file mode 100644 index 00000000..5f4878e5 Binary files /dev/null and b/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/coremldata.bin differ diff --git a/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/metadata.json b/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/metadata.json new file mode 100644 index 00000000..9a994a9b --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/metadata.json @@ -0,0 +1,66 @@ +[ + { + "shortDescription" : "", + "metadataOutputVersion" : "3.0", + "outputSchema" : [ + { + "hasShapeFlexibility" : "0", + "isOptional" : "0", + "dataType" : "Double", + "formattedType" : "MultiArray (Double 3)", + "shortDescription" : "", + "shape" : "[3]", + "name" : "output1", + "type" : "MultiArray" + } + ], + "version" : "", + "modelParameters" : [ + + ], + "author" : "", + "specificationVersion" : 2, + "license" : "", + "isUpdatable" : "0", + "availability" : { + "macOS" : "10.13.2", + "tvOS" : "11.2", + "watchOS" : "4.2", + "iOS" : "11.2", + "macCatalyst" : "11.2" + }, + "modelType" : { + "name" : "MLModelType_neuralNetwork" + }, + "inputSchema" : [ + { + "height" : "224", + "colorspace" : "RGB", + "isOptional" : "0", + "width" : "224", + "isColor" : "1", + "formattedType" : "Image (Color 224 × 224)", + "hasSizeFlexibility" : "0", + "type" : "Image", + "shortDescription" : "", + "name" : "input1" + } + ], + "userDefinedMetadata" : { + "coremltoolsVersion" : "3.3" + }, + "generatedClassName" : "UxModel", + "neuralNetworkLayerTypeHistogram" : { + "ActivationLinear" : 72, + "ActivationReLU" : 36, + "Softmax" : 1, + "Add" : 10, + "PoolingAverage" : 1, + "UnaryThreshold" : 36, + "BatchNorm" : 53, + "Convolution" : 54, + "Reshape" : 2 + }, + "method" : "predict" + } +] \ No newline at end of file diff --git a/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/model.espresso.net b/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/model.espresso.net new file mode 100644 index 00000000..c289e4f7 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/model.espresso.net @@ -0,0 +1,3252 @@ +{ + "transform_params" : { + "input1" : { + "bias_a" : 0, + "bias_g" : 0, + "bias_r" : 0, + "bias_b" : 0, + "center_mean" : 0, + "is_network_bgr" : 0, + "scale" : 0.0039215688593685627 + } + }, + "properties" : { + + }, + "analyses" : { + + }, + "format_version" : 200, + "storage" : "model.espresso.weights", + "layers" : [ + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "conv2d_1", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_1_output_relu", + "K" : 3, + "blob_biases" : 1, + "stride_x" : 2, + "name" : "conv2d_1", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "stride_y" : 2, + "has_biases" : 1, + "C" : 16, + "bottom" : "input1", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 3 + }, + { + "alpha" : -1, + "bottom" : "activation_1_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_1__neg__", + "top" : "activation_1_output_relu_neg", + "type" : "activation", + "name" : "activation_1__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_1_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_1__clip__", + "top" : "activation_1_output_relu_clip", + "type" : "elementwise", + "name" : "activation_1__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_1_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_1_neg2", + "top" : "activation_1_output", + "type" : "activation", + "name" : "activation_1_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "conv2d_2", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_2_output_relu", + "K" : 16, + "blob_biases" : 5, + "name" : "conv2d_2", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 16, + "bottom" : "activation_1_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 7 + }, + { + "alpha" : -1, + "bottom" : "activation_2_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_2__neg__", + "top" : "activation_2_output_relu_neg", + "type" : "activation", + "name" : "activation_2__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_2_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_2__clip__", + "top" : "activation_2_output_relu_clip", + "type" : "elementwise", + "name" : "activation_2__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_2_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_2_neg2", + "top" : "activation_2_output", + "type" : "activation", + "name" : "activation_2_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "depthwise_conv2d_1", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_3_output_relu", + "K" : 16, + "blob_biases" : 9, + "name" : "depthwise_conv2d_1", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 16, + "pad_t" : 0, + "has_biases" : 1, + "C" : 16, + "bottom" : "activation_2_output", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 11 + }, + { + "alpha" : -1, + "bottom" : "activation_3_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_3__neg__", + "top" : "activation_3_output_relu_neg", + "type" : "activation", + "name" : "activation_3__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_3_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_3__clip__", + "top" : "activation_3_output_relu_clip", + "type" : "elementwise", + "name" : "activation_3__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_3_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_3_neg2", + "top" : "activation_3_output", + "type" : "activation", + "name" : "activation_3_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "conv2d_3", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "batch_normalization_4_output", + "K" : 16, + "blob_biases" : 13, + "name" : "conv2d_3", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 8, + "bottom" : "activation_3_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 15 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "conv2d_4", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_4_output_relu", + "K" : 8, + "blob_biases" : 17, + "name" : "conv2d_4", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 48, + "bottom" : "batch_normalization_4_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 19 + }, + { + "alpha" : -1, + "bottom" : "activation_4_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_4__neg__", + "top" : "activation_4_output_relu_neg", + "type" : "activation", + "name" : "activation_4__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_4_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_4__clip__", + "top" : "activation_4_output_relu_clip", + "type" : "elementwise", + "name" : "activation_4__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_4_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_4_neg2", + "top" : "activation_4_output", + "type" : "activation", + "name" : "activation_4_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "depthwise_conv2d_2", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_5_output_relu", + "K" : 48, + "blob_biases" : 21, + "stride_x" : 2, + "name" : "depthwise_conv2d_2", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 48, + "pad_t" : 0, + "stride_y" : 2, + "has_biases" : 1, + "C" : 48, + "bottom" : "activation_4_output", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 23 + }, + { + "alpha" : -1, + "bottom" : "activation_5_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_5__neg__", + "top" : "activation_5_output_relu_neg", + "type" : "activation", + "name" : "activation_5__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_5_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_5__clip__", + "top" : "activation_5_output_relu_clip", + "type" : "elementwise", + "name" : "activation_5__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_5_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_5_neg2", + "top" : "activation_5_output", + "type" : "activation", + "name" : "activation_5_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "conv2d_5", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "batch_normalization_7_output", + "K" : 48, + "blob_biases" : 25, + "name" : "conv2d_5", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 12, + "bottom" : "activation_5_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 27 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "conv2d_6", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_6_output_relu", + "K" : 12, + "blob_biases" : 29, + "name" : "conv2d_6", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 72, + "bottom" : "batch_normalization_7_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 31 + }, + { + "alpha" : -1, + "bottom" : "activation_6_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_6__neg__", + "top" : "activation_6_output_relu_neg", + "type" : "activation", + "name" : "activation_6__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_6_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_6__clip__", + "top" : "activation_6_output_relu_clip", + "type" : "elementwise", + "name" : "activation_6__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_6_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_6_neg2", + "top" : "activation_6_output", + "type" : "activation", + "name" : "activation_6_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "depthwise_conv2d_3", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_7_output_relu", + "K" : 72, + "blob_biases" : 33, + "name" : "depthwise_conv2d_3", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 72, + "pad_t" : 0, + "has_biases" : 1, + "C" : 72, + "bottom" : "activation_6_output", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 35 + }, + { + "alpha" : -1, + "bottom" : "activation_7_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_7__neg__", + "top" : "activation_7_output_relu_neg", + "type" : "activation", + "name" : "activation_7__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_7_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_7__clip__", + "top" : "activation_7_output_relu_clip", + "type" : "elementwise", + "name" : "activation_7__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_7_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_7_neg2", + "top" : "activation_7_output", + "type" : "activation", + "name" : "activation_7_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "conv2d_7", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "batch_normalization_10_output", + "K" : 72, + "blob_biases" : 37, + "name" : "conv2d_7", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 12, + "bottom" : "activation_7_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 39 + }, + { + "bottom" : "batch_normalization_10_output,batch_normalization_7_output", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "add_1", + "top" : "add_1_output", + "type" : "elementwise", + "name" : "add_1", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "conv2d_8", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_8_output_relu", + "K" : 12, + "blob_biases" : 41, + "name" : "conv2d_8", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 72, + "bottom" : "add_1_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 43 + }, + { + "alpha" : -1, + "bottom" : "activation_8_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_8__neg__", + "top" : "activation_8_output_relu_neg", + "type" : "activation", + "name" : "activation_8__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_8_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_8__clip__", + "top" : "activation_8_output_relu_clip", + "type" : "elementwise", + "name" : "activation_8__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_8_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_8_neg2", + "top" : "activation_8_output", + "type" : "activation", + "name" : "activation_8_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "depthwise_conv2d_4", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_9_output_relu", + "K" : 72, + "blob_biases" : 45, + "stride_x" : 2, + "name" : "depthwise_conv2d_4", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 72, + "pad_t" : 0, + "stride_y" : 2, + "has_biases" : 1, + "C" : 72, + "bottom" : "activation_8_output", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 47 + }, + { + "alpha" : -1, + "bottom" : "activation_9_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_9__neg__", + "top" : "activation_9_output_relu_neg", + "type" : "activation", + "name" : "activation_9__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_9_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_9__clip__", + "top" : "activation_9_output_relu_clip", + "type" : "elementwise", + "name" : "activation_9__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_9_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_9_neg2", + "top" : "activation_9_output", + "type" : "activation", + "name" : "activation_9_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "conv2d_9", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "batch_normalization_13_output", + "K" : 72, + "blob_biases" : 49, + "name" : "conv2d_9", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 16, + "bottom" : "activation_9_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 51 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "conv2d_10", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_10_output_relu", + "K" : 16, + "blob_biases" : 53, + "name" : "conv2d_10", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 96, + "bottom" : "batch_normalization_13_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 55 + }, + { + "alpha" : -1, + "bottom" : "activation_10_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_10__neg__", + "top" : "activation_10_output_relu_neg", + "type" : "activation", + "name" : "activation_10__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_10_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_10__clip__", + "top" : "activation_10_output_relu_clip", + "type" : "elementwise", + "name" : "activation_10__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_10_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_10_neg2", + "top" : "activation_10_output", + "type" : "activation", + "name" : "activation_10_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "depthwise_conv2d_5", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_11_output_relu", + "K" : 96, + "blob_biases" : 57, + "name" : "depthwise_conv2d_5", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 96, + "pad_t" : 0, + "has_biases" : 1, + "C" : 96, + "bottom" : "activation_10_output", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 59 + }, + { + "alpha" : -1, + "bottom" : "activation_11_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_11__neg__", + "top" : "activation_11_output_relu_neg", + "type" : "activation", + "name" : "activation_11__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_11_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_11__clip__", + "top" : "activation_11_output_relu_clip", + "type" : "elementwise", + "name" : "activation_11__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_11_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_11_neg2", + "top" : "activation_11_output", + "type" : "activation", + "name" : "activation_11_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "conv2d_11", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "batch_normalization_16_output", + "K" : 96, + "blob_biases" : 61, + "name" : "conv2d_11", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 16, + "bottom" : "activation_11_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 63 + }, + { + "bottom" : "batch_normalization_16_output,batch_normalization_13_output", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "add_2", + "top" : "add_2_output", + "type" : "elementwise", + "name" : "add_2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "conv2d_12", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_12_output_relu", + "K" : 16, + "blob_biases" : 65, + "name" : "conv2d_12", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 96, + "bottom" : "add_2_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 67 + }, + { + "alpha" : -1, + "bottom" : "activation_12_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_12__neg__", + "top" : "activation_12_output_relu_neg", + "type" : "activation", + "name" : "activation_12__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_12_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_12__clip__", + "top" : "activation_12_output_relu_clip", + "type" : "elementwise", + "name" : "activation_12__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_12_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_12_neg2", + "top" : "activation_12_output", + "type" : "activation", + "name" : "activation_12_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "depthwise_conv2d_6", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_13_output_relu", + "K" : 96, + "blob_biases" : 69, + "name" : "depthwise_conv2d_6", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 96, + "pad_t" : 0, + "has_biases" : 1, + "C" : 96, + "bottom" : "activation_12_output", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 71 + }, + { + "alpha" : -1, + "bottom" : "activation_13_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_13__neg__", + "top" : "activation_13_output_relu_neg", + "type" : "activation", + "name" : "activation_13__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_13_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_13__clip__", + "top" : "activation_13_output_relu_clip", + "type" : "elementwise", + "name" : "activation_13__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_13_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_13_neg2", + "top" : "activation_13_output", + "type" : "activation", + "name" : "activation_13_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "conv2d_13", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "batch_normalization_19_output", + "K" : 96, + "blob_biases" : 73, + "name" : "conv2d_13", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 16, + "bottom" : "activation_13_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 75 + }, + { + "bottom" : "batch_normalization_19_output,add_2_output", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "add_3", + "top" : "add_3_output", + "type" : "elementwise", + "name" : "add_3", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "conv2d_14", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_14_output_relu", + "K" : 16, + "blob_biases" : 77, + "name" : "conv2d_14", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 96, + "bottom" : "add_3_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 79 + }, + { + "alpha" : -1, + "bottom" : "activation_14_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_14__neg__", + "top" : "activation_14_output_relu_neg", + "type" : "activation", + "name" : "activation_14__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_14_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_14__clip__", + "top" : "activation_14_output_relu_clip", + "type" : "elementwise", + "name" : "activation_14__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_14_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_14_neg2", + "top" : "activation_14_output", + "type" : "activation", + "name" : "activation_14_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "depthwise_conv2d_7", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_15_output_relu", + "K" : 96, + "blob_biases" : 81, + "stride_x" : 2, + "name" : "depthwise_conv2d_7", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 96, + "pad_t" : 0, + "stride_y" : 2, + "has_biases" : 1, + "C" : 96, + "bottom" : "activation_14_output", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 83 + }, + { + "alpha" : -1, + "bottom" : "activation_15_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_15__neg__", + "top" : "activation_15_output_relu_neg", + "type" : "activation", + "name" : "activation_15__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_15_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_15__clip__", + "top" : "activation_15_output_relu_clip", + "type" : "elementwise", + "name" : "activation_15__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_15_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_15_neg2", + "top" : "activation_15_output", + "type" : "activation", + "name" : "activation_15_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "conv2d_15", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "batch_normalization_22_output", + "K" : 96, + "blob_biases" : 85, + "name" : "conv2d_15", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 32, + "bottom" : "activation_15_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 87 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "conv2d_16", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_16_output_relu", + "K" : 32, + "blob_biases" : 89, + "name" : "conv2d_16", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 192, + "bottom" : "batch_normalization_22_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 91 + }, + { + "alpha" : -1, + "bottom" : "activation_16_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_16__neg__", + "top" : "activation_16_output_relu_neg", + "type" : "activation", + "name" : "activation_16__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_16_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_16__clip__", + "top" : "activation_16_output_relu_clip", + "type" : "elementwise", + "name" : "activation_16__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_16_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_16_neg2", + "top" : "activation_16_output", + "type" : "activation", + "name" : "activation_16_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "depthwise_conv2d_8", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_17_output_relu", + "K" : 192, + "blob_biases" : 93, + "name" : "depthwise_conv2d_8", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 192, + "pad_t" : 0, + "has_biases" : 1, + "C" : 192, + "bottom" : "activation_16_output", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 95 + }, + { + "alpha" : -1, + "bottom" : "activation_17_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_17__neg__", + "top" : "activation_17_output_relu_neg", + "type" : "activation", + "name" : "activation_17__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_17_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_17__clip__", + "top" : "activation_17_output_relu_clip", + "type" : "elementwise", + "name" : "activation_17__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_17_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_17_neg2", + "top" : "activation_17_output", + "type" : "activation", + "name" : "activation_17_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "conv2d_17", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "batch_normalization_25_output", + "K" : 192, + "blob_biases" : 97, + "name" : "conv2d_17", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 32, + "bottom" : "activation_17_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 99 + }, + { + "bottom" : "batch_normalization_25_output,batch_normalization_22_output", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "add_4", + "top" : "add_4_output", + "type" : "elementwise", + "name" : "add_4", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "conv2d_18", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_18_output_relu", + "K" : 32, + "blob_biases" : 101, + "name" : "conv2d_18", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 192, + "bottom" : "add_4_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 103 + }, + { + "alpha" : -1, + "bottom" : "activation_18_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_18__neg__", + "top" : "activation_18_output_relu_neg", + "type" : "activation", + "name" : "activation_18__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_18_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_18__clip__", + "top" : "activation_18_output_relu_clip", + "type" : "elementwise", + "name" : "activation_18__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_18_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_18_neg2", + "top" : "activation_18_output", + "type" : "activation", + "name" : "activation_18_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "depthwise_conv2d_9", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_19_output_relu", + "K" : 192, + "blob_biases" : 105, + "name" : "depthwise_conv2d_9", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 192, + "pad_t" : 0, + "has_biases" : 1, + "C" : 192, + "bottom" : "activation_18_output", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 107 + }, + { + "alpha" : -1, + "bottom" : "activation_19_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_19__neg__", + "top" : "activation_19_output_relu_neg", + "type" : "activation", + "name" : "activation_19__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_19_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_19__clip__", + "top" : "activation_19_output_relu_clip", + "type" : "elementwise", + "name" : "activation_19__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_19_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_19_neg2", + "top" : "activation_19_output", + "type" : "activation", + "name" : "activation_19_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "conv2d_19", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "batch_normalization_28_output", + "K" : 192, + "blob_biases" : 109, + "name" : "conv2d_19", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 32, + "bottom" : "activation_19_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 111 + }, + { + "bottom" : "batch_normalization_28_output,add_4_output", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "add_5", + "top" : "add_5_output", + "type" : "elementwise", + "name" : "add_5", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "conv2d_20", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_20_output_relu", + "K" : 32, + "blob_biases" : 113, + "name" : "conv2d_20", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 192, + "bottom" : "add_5_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 115 + }, + { + "alpha" : -1, + "bottom" : "activation_20_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_20__neg__", + "top" : "activation_20_output_relu_neg", + "type" : "activation", + "name" : "activation_20__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_20_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_20__clip__", + "top" : "activation_20_output_relu_clip", + "type" : "elementwise", + "name" : "activation_20__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_20_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_20_neg2", + "top" : "activation_20_output", + "type" : "activation", + "name" : "activation_20_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "depthwise_conv2d_10", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_21_output_relu", + "K" : 192, + "blob_biases" : 117, + "name" : "depthwise_conv2d_10", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 192, + "pad_t" : 0, + "has_biases" : 1, + "C" : 192, + "bottom" : "activation_20_output", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 119 + }, + { + "alpha" : -1, + "bottom" : "activation_21_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_21__neg__", + "top" : "activation_21_output_relu_neg", + "type" : "activation", + "name" : "activation_21__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_21_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_21__clip__", + "top" : "activation_21_output_relu_clip", + "type" : "elementwise", + "name" : "activation_21__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_21_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_21_neg2", + "top" : "activation_21_output", + "type" : "activation", + "name" : "activation_21_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "conv2d_21", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "batch_normalization_31_output", + "K" : 192, + "blob_biases" : 121, + "name" : "conv2d_21", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 32, + "bottom" : "activation_21_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 123 + }, + { + "bottom" : "batch_normalization_31_output,add_5_output", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "add_6", + "top" : "add_6_output", + "type" : "elementwise", + "name" : "add_6", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "conv2d_22", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_22_output_relu", + "K" : 32, + "blob_biases" : 125, + "name" : "conv2d_22", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 192, + "bottom" : "add_6_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 127 + }, + { + "alpha" : -1, + "bottom" : "activation_22_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_22__neg__", + "top" : "activation_22_output_relu_neg", + "type" : "activation", + "name" : "activation_22__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_22_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_22__clip__", + "top" : "activation_22_output_relu_clip", + "type" : "elementwise", + "name" : "activation_22__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_22_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_22_neg2", + "top" : "activation_22_output", + "type" : "activation", + "name" : "activation_22_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "depthwise_conv2d_11", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_23_output_relu", + "K" : 192, + "blob_biases" : 129, + "name" : "depthwise_conv2d_11", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 192, + "pad_t" : 0, + "has_biases" : 1, + "C" : 192, + "bottom" : "activation_22_output", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 131 + }, + { + "alpha" : -1, + "bottom" : "activation_23_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_23__neg__", + "top" : "activation_23_output_relu_neg", + "type" : "activation", + "name" : "activation_23__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_23_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_23__clip__", + "top" : "activation_23_output_relu_clip", + "type" : "elementwise", + "name" : "activation_23__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_23_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_23_neg2", + "top" : "activation_23_output", + "type" : "activation", + "name" : "activation_23_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "conv2d_23", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "batch_normalization_34_output", + "K" : 192, + "blob_biases" : 133, + "name" : "conv2d_23", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 48, + "bottom" : "activation_23_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 135 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "conv2d_24", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_24_output_relu", + "K" : 48, + "blob_biases" : 137, + "name" : "conv2d_24", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 288, + "bottom" : "batch_normalization_34_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 139 + }, + { + "alpha" : -1, + "bottom" : "activation_24_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_24__neg__", + "top" : "activation_24_output_relu_neg", + "type" : "activation", + "name" : "activation_24__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_24_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_24__clip__", + "top" : "activation_24_output_relu_clip", + "type" : "elementwise", + "name" : "activation_24__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_24_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_24_neg2", + "top" : "activation_24_output", + "type" : "activation", + "name" : "activation_24_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "depthwise_conv2d_12", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_25_output_relu", + "K" : 288, + "blob_biases" : 141, + "name" : "depthwise_conv2d_12", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 288, + "pad_t" : 0, + "has_biases" : 1, + "C" : 288, + "bottom" : "activation_24_output", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 143 + }, + { + "alpha" : -1, + "bottom" : "activation_25_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_25__neg__", + "top" : "activation_25_output_relu_neg", + "type" : "activation", + "name" : "activation_25__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_25_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_25__clip__", + "top" : "activation_25_output_relu_clip", + "type" : "elementwise", + "name" : "activation_25__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_25_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_25_neg2", + "top" : "activation_25_output", + "type" : "activation", + "name" : "activation_25_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "conv2d_25", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "batch_normalization_37_output", + "K" : 288, + "blob_biases" : 145, + "name" : "conv2d_25", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 48, + "bottom" : "activation_25_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 147 + }, + { + "bottom" : "batch_normalization_37_output,batch_normalization_34_output", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "add_7", + "top" : "add_7_output", + "type" : "elementwise", + "name" : "add_7", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "conv2d_26", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_26_output_relu", + "K" : 48, + "blob_biases" : 149, + "name" : "conv2d_26", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 288, + "bottom" : "add_7_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 151 + }, + { + "alpha" : -1, + "bottom" : "activation_26_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_26__neg__", + "top" : "activation_26_output_relu_neg", + "type" : "activation", + "name" : "activation_26__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_26_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_26__clip__", + "top" : "activation_26_output_relu_clip", + "type" : "elementwise", + "name" : "activation_26__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_26_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_26_neg2", + "top" : "activation_26_output", + "type" : "activation", + "name" : "activation_26_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "depthwise_conv2d_13", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_27_output_relu", + "K" : 288, + "blob_biases" : 153, + "name" : "depthwise_conv2d_13", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 288, + "pad_t" : 0, + "has_biases" : 1, + "C" : 288, + "bottom" : "activation_26_output", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 155 + }, + { + "alpha" : -1, + "bottom" : "activation_27_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_27__neg__", + "top" : "activation_27_output_relu_neg", + "type" : "activation", + "name" : "activation_27__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_27_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_27__clip__", + "top" : "activation_27_output_relu_clip", + "type" : "elementwise", + "name" : "activation_27__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_27_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_27_neg2", + "top" : "activation_27_output", + "type" : "activation", + "name" : "activation_27_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "conv2d_27", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "batch_normalization_40_output", + "K" : 288, + "blob_biases" : 157, + "name" : "conv2d_27", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 48, + "bottom" : "activation_27_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 159 + }, + { + "bottom" : "batch_normalization_40_output,add_7_output", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "add_8", + "top" : "add_8_output", + "type" : "elementwise", + "name" : "add_8", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "conv2d_28", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_28_output_relu", + "K" : 48, + "blob_biases" : 161, + "name" : "conv2d_28", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 288, + "bottom" : "add_8_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 163 + }, + { + "alpha" : -1, + "bottom" : "activation_28_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_28__neg__", + "top" : "activation_28_output_relu_neg", + "type" : "activation", + "name" : "activation_28__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_28_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_28__clip__", + "top" : "activation_28_output_relu_clip", + "type" : "elementwise", + "name" : "activation_28__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_28_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_28_neg2", + "top" : "activation_28_output", + "type" : "activation", + "name" : "activation_28_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "depthwise_conv2d_14", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_29_output_relu", + "K" : 288, + "blob_biases" : 165, + "stride_x" : 2, + "name" : "depthwise_conv2d_14", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 288, + "pad_t" : 0, + "stride_y" : 2, + "has_biases" : 1, + "C" : 288, + "bottom" : "activation_28_output", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 167 + }, + { + "alpha" : -1, + "bottom" : "activation_29_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_29__neg__", + "top" : "activation_29_output_relu_neg", + "type" : "activation", + "name" : "activation_29__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_29_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_29__clip__", + "top" : "activation_29_output_relu_clip", + "type" : "elementwise", + "name" : "activation_29__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_29_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_29_neg2", + "top" : "activation_29_output", + "type" : "activation", + "name" : "activation_29_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "conv2d_29", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "batch_normalization_43_output", + "K" : 288, + "blob_biases" : 169, + "name" : "conv2d_29", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 80, + "bottom" : "activation_29_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 171 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "conv2d_30", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_30_output_relu", + "K" : 80, + "blob_biases" : 173, + "name" : "conv2d_30", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 480, + "bottom" : "batch_normalization_43_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 175 + }, + { + "alpha" : -1, + "bottom" : "activation_30_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_30__neg__", + "top" : "activation_30_output_relu_neg", + "type" : "activation", + "name" : "activation_30__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_30_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_30__clip__", + "top" : "activation_30_output_relu_clip", + "type" : "elementwise", + "name" : "activation_30__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_30_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_30_neg2", + "top" : "activation_30_output", + "type" : "activation", + "name" : "activation_30_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "depthwise_conv2d_15", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_31_output_relu", + "K" : 480, + "blob_biases" : 177, + "name" : "depthwise_conv2d_15", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 480, + "pad_t" : 0, + "has_biases" : 1, + "C" : 480, + "bottom" : "activation_30_output", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 179 + }, + { + "alpha" : -1, + "bottom" : "activation_31_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_31__neg__", + "top" : "activation_31_output_relu_neg", + "type" : "activation", + "name" : "activation_31__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_31_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_31__clip__", + "top" : "activation_31_output_relu_clip", + "type" : "elementwise", + "name" : "activation_31__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_31_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_31_neg2", + "top" : "activation_31_output", + "type" : "activation", + "name" : "activation_31_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "conv2d_31", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "batch_normalization_46_output", + "K" : 480, + "blob_biases" : 181, + "name" : "conv2d_31", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 80, + "bottom" : "activation_31_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 183 + }, + { + "bottom" : "batch_normalization_46_output,batch_normalization_43_output", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "add_9", + "top" : "add_9_output", + "type" : "elementwise", + "name" : "add_9", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "conv2d_32", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_32_output_relu", + "K" : 80, + "blob_biases" : 185, + "name" : "conv2d_32", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 480, + "bottom" : "add_9_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 187 + }, + { + "alpha" : -1, + "bottom" : "activation_32_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_32__neg__", + "top" : "activation_32_output_relu_neg", + "type" : "activation", + "name" : "activation_32__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_32_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_32__clip__", + "top" : "activation_32_output_relu_clip", + "type" : "elementwise", + "name" : "activation_32__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_32_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_32_neg2", + "top" : "activation_32_output", + "type" : "activation", + "name" : "activation_32_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "depthwise_conv2d_16", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_33_output_relu", + "K" : 480, + "blob_biases" : 189, + "name" : "depthwise_conv2d_16", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 480, + "pad_t" : 0, + "has_biases" : 1, + "C" : 480, + "bottom" : "activation_32_output", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 191 + }, + { + "alpha" : -1, + "bottom" : "activation_33_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_33__neg__", + "top" : "activation_33_output_relu_neg", + "type" : "activation", + "name" : "activation_33__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_33_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_33__clip__", + "top" : "activation_33_output_relu_clip", + "type" : "elementwise", + "name" : "activation_33__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_33_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_33_neg2", + "top" : "activation_33_output", + "type" : "activation", + "name" : "activation_33_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "conv2d_33", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "batch_normalization_49_output", + "K" : 480, + "blob_biases" : 193, + "name" : "conv2d_33", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 80, + "bottom" : "activation_33_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 195 + }, + { + "bottom" : "batch_normalization_49_output,add_9_output", + "alpha" : 1, + "operation" : 0, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "add_10", + "top" : "add_10_output", + "type" : "elementwise", + "name" : "add_10", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "conv2d_34", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_34_output_relu", + "K" : 80, + "blob_biases" : 197, + "name" : "conv2d_34", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 480, + "bottom" : "add_10_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 199 + }, + { + "alpha" : -1, + "bottom" : "activation_34_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_34__neg__", + "top" : "activation_34_output_relu_neg", + "type" : "activation", + "name" : "activation_34__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_34_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_34__clip__", + "top" : "activation_34_output_relu_clip", + "type" : "elementwise", + "name" : "activation_34__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_34_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_34_neg2", + "top" : "activation_34_output", + "type" : "activation", + "name" : "activation_34_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "depthwise_conv2d_17", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_35_output_relu", + "K" : 480, + "blob_biases" : 201, + "name" : "depthwise_conv2d_17", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 480, + "pad_t" : 0, + "has_biases" : 1, + "C" : 480, + "bottom" : "activation_34_output", + "weights" : { + + }, + "Nx" : 3, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 3, + "n_parallel" : 1, + "blob_weights_f16" : 203 + }, + { + "alpha" : -1, + "bottom" : "activation_35_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_35__neg__", + "top" : "activation_35_output_relu_neg", + "type" : "activation", + "name" : "activation_35__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_35_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_35__clip__", + "top" : "activation_35_output_relu_clip", + "type" : "elementwise", + "name" : "activation_35__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_35_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_35_neg2", + "top" : "activation_35_output", + "type" : "activation", + "name" : "activation_35_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "conv2d_35", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "batch_normalization_52_output", + "K" : 480, + "blob_biases" : 205, + "name" : "conv2d_35", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 160, + "bottom" : "activation_35_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 207 + }, + { + "pad_r" : 0, + "fused_relu" : 1, + "fused_tanh" : 0, + "debug_info" : "conv2d_36", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "activation_36_output_relu", + "K" : 160, + "blob_biases" : 209, + "name" : "conv2d_36", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 1280, + "bottom" : "batch_normalization_52_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 211 + }, + { + "alpha" : -1, + "bottom" : "activation_36_output_relu", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_36__neg__", + "top" : "activation_36_output_relu_neg", + "type" : "activation", + "name" : "activation_36__neg__", + "beta" : 0 + }, + { + "bottom" : "activation_36_output_relu_neg", + "alpha" : -6, + "operation" : 25, + "weights" : { + + }, + "fused_relu" : 0, + "debug_info" : "activation_36__clip__", + "top" : "activation_36_output_relu_clip", + "type" : "elementwise", + "name" : "activation_36__clip__", + "beta" : 0 + }, + { + "alpha" : -1, + "bottom" : "activation_36_output_relu_clip", + "weights" : { + + }, + "mode" : 6, + "debug_info" : "activation_36_neg2", + "top" : "activation_36_output", + "type" : "activation", + "name" : "activation_36_neg2", + "beta" : 0 + }, + { + "pad_r" : 0, + "debug_info" : "global_average_pooling2d_1", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "size_x" : 7, + "is_global" : 1, + "top" : "global_average_pooling2d_1_output", + "top_shape_style" : 0, + "stride_x" : 1, + "avg_or_max" : 0, + "average_count_exclude_padding" : 1, + "type" : "pool", + "name" : "global_average_pooling2d_1", + "pad_t" : 0, + "stride_y" : 1, + "bottom" : "activation_36_output", + "weights" : { + + }, + "pad_mode" : 2, + "size_y" : 7, + "pad_value" : 0 + }, + { + "name" : "reshape_1", + "weights" : { + + }, + "dst_w" : 1, + "version" : 1, + "dst_n" : 0, + "type" : "reshape", + "dst_h" : 1, + "mode" : 1, + "bottom" : "global_average_pooling2d_1_output", + "debug_info" : "reshape_1", + "dst_seq" : 1, + "dst_k" : 1280, + "top" : "reshape_1_output" + }, + { + "pad_r" : 0, + "fused_relu" : 0, + "fused_tanh" : 0, + "debug_info" : "conv2d_38", + "pad_fill_mode" : 0, + "pad_b" : 0, + "pad_l" : 0, + "top" : "conv2d_38_output", + "K" : 1280, + "blob_biases" : 213, + "name" : "conv2d_38", + "has_batch_norm" : 0, + "type" : "convolution", + "n_groups" : 1, + "pad_t" : 0, + "has_biases" : 1, + "C" : 3, + "bottom" : "reshape_1_output", + "weights" : { + + }, + "Nx" : 1, + "pad_mode" : 1, + "pad_value" : 0, + "Ny" : 1, + "n_parallel" : 1, + "blob_weights_f16" : 215 + }, + { + "bottom" : "conv2d_38_output", + "weights" : { + + }, + "debug_info" : "softmax", + "top" : "softmax_output", + "C" : 2, + "type" : "softmax", + "name" : "softmax" + }, + { + "name" : "reshape_3", + "weights" : { + + }, + "dst_w" : 1, + "version" : 1, + "dst_n" : 0, + "type" : "reshape", + "dst_h" : 1, + "mode" : 1, + "attributes" : { + "is_output" : 1 + }, + "bottom" : "softmax_output", + "debug_info" : "reshape_3", + "dst_k" : 3, + "dst_seq" : 1, + "top" : "output1" + } + ] +} \ No newline at end of file diff --git a/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/model.espresso.shape b/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/model.espresso.shape new file mode 100644 index 00000000..ad7ffbb8 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/model.espresso.shape @@ -0,0 +1,1066 @@ +{ + "layer_shapes" : { + "activation_32_output_relu_neg" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_3_output_relu_clip" : { + "k" : 16, + "w" : 112, + "n" : 1, + "h" : 112 + }, + "activation_24_output_relu" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_13_output_relu_neg" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_34_output" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_32_output_relu_clip" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_2_output_relu_neg" : { + "k" : 16, + "w" : 112, + "n" : 1, + "h" : 112 + }, + "activation_18_output_relu_neg" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "input1" : { + "k" : 3, + "w" : 224, + "n" : 1, + "h" : 224 + }, + "activation_27_output_relu_clip" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "reshape_1_output" : { + "k" : 1280, + "w" : 1, + "n" : 1, + "h" : 1 + }, + "batch_normalization_46_output" : { + "k" : 80, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_3_output" : { + "k" : 16, + "w" : 112, + "n" : 1, + "h" : 112 + }, + "activation_33_output" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_31_output_relu_neg" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_11_output_relu" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_27_output_relu" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_8_output_relu" : { + "k" : 72, + "w" : 56, + "n" : 1, + "h" : 56 + }, + "activation_23_output_relu_clip" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_18_output_relu_clip" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_32_output" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_17_output_relu_neg" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "add_2_output" : { + "k" : 16, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_20_output_relu_neg" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_31_output" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_8_output_relu_neg" : { + "k" : 72, + "w" : 56, + "n" : 1, + "h" : 56 + }, + "activation_14_output_relu_clip" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_6_output_relu" : { + "k" : 72, + "w" : 56, + "n" : 1, + "h" : 56 + }, + "activation_14_output_relu" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "batch_normalization_4_output" : { + "k" : 8, + "w" : 112, + "n" : 1, + "h" : 112 + }, + "activation_35_output_relu_neg" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "batch_normalization_43_output" : { + "k" : 80, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_16_output_relu_neg" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_30_output" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_26_output_relu" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "batch_normalization_34_output" : { + "k" : 48, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "batch_normalization_52_output" : { + "k" : 160, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_34_output_relu_clip" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_10_output_relu_clip" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_29_output_relu_clip" : { + "k" : 288, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_9_output" : { + "k" : 72, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_4_output_relu" : { + "k" : 48, + "w" : 112, + "n" : 1, + "h" : 112 + }, + "activation_24_output" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_8_output_relu_clip" : { + "k" : 72, + "w" : 56, + "n" : 1, + "h" : 56 + }, + "activation_3_output_relu_neg" : { + "k" : 16, + "w" : 112, + "n" : 1, + "h" : 112 + }, + "activation_34_output_relu_neg" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_30_output_relu_clip" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_25_output_relu_clip" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_13_output_relu" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_23_output" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_29_output_relu" : { + "k" : 288, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "add_6_output" : { + "k" : 32, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "output1" : { + "k" : 3, + "w" : 1, + "n" : 1, + "h" : 1 + }, + "activation_23_output_relu_neg" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_5_output_relu_clip" : { + "k" : 48, + "w" : 56, + "n" : 1, + "h" : 56 + }, + "activation_22_output" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "batch_normalization_40_output" : { + "k" : 48, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "batch_normalization_31_output" : { + "k" : 32, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_21_output_relu_clip" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_16_output_relu_clip" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_9_output_relu_neg" : { + "k" : 72, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_16_output_relu" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_8_output" : { + "k" : 72, + "w" : 56, + "n" : 1, + "h" : 56 + }, + "activation_19_output_relu_neg" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_21_output" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_22_output_relu_neg" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_28_output_relu" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_2_output_relu_clip" : { + "k" : 16, + "w" : 112, + "n" : 1, + "h" : 112 + }, + "activation_12_output_relu_clip" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "add_4_output" : { + "k" : 32, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_15_output" : { + "k" : 96, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_20_output" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "global_average_pooling2d_1_output" : { + "k" : 1280, + "w" : 1, + "n" : 1, + "h" : 1 + }, + "activation_4_output_relu_neg" : { + "k" : 48, + "w" : 112, + "n" : 1, + "h" : 112 + }, + "activation_19_output_relu" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_31_output_relu" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_14_output" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "batch_normalization_7_output" : { + "k" : 12, + "w" : 56, + "n" : 1, + "h" : 56 + }, + "batch_normalization_37_output" : { + "k" : 48, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_21_output_relu_neg" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "batch_normalization_28_output" : { + "k" : 32, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "batch_normalization_19_output" : { + "k" : 16, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_15_output_relu" : { + "k" : 96, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_5_output" : { + "k" : 48, + "w" : 56, + "n" : 1, + "h" : 56 + }, + "activation_13_output" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_36_output_relu_neg" : { + "k" : 1280, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_10_output_relu_neg" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_34_output_relu" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_12_output" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_5_output_relu" : { + "k" : 48, + "w" : 56, + "n" : 1, + "h" : 56 + }, + "activation_18_output_relu" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_30_output_relu" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_25_output_relu_neg" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "add_8_output" : { + "k" : 48, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_11_output" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_20_output_relu_clip" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "batch_normalization_25_output" : { + "k" : 32, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "batch_normalization_16_output" : { + "k" : 16, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_2_output" : { + "k" : 16, + "w" : 112, + "n" : 1, + "h" : 112 + }, + "activation_21_output_relu" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_4_output" : { + "k" : 48, + "w" : 112, + "n" : 1, + "h" : 112 + }, + "activation_3_output_relu" : { + "k" : 16, + "w" : 112, + "n" : 1, + "h" : 112 + }, + "activation_10_output" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_36_output" : { + "k" : 1280, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_7_output_relu_clip" : { + "k" : 72, + "w" : 56, + "n" : 1, + "h" : 56 + }, + "activation_33_output_relu" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_24_output_relu_neg" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_5_output_relu_neg" : { + "k" : 48, + "w" : 56, + "n" : 1, + "h" : 56 + }, + "activation_35_output_relu_clip" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_11_output_relu_clip" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_17_output_relu" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_35_output" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "add_5_output" : { + "k" : 32, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_1_output_relu" : { + "k" : 16, + "w" : 112, + "n" : 1, + "h" : 112 + }, + "activation_31_output_relu_clip" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_4_output_relu_clip" : { + "k" : 48, + "w" : 112, + "n" : 1, + "h" : 112 + }, + "activation_26_output_relu_clip" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_29_output" : { + "k" : 288, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_20_output_relu" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_36_output_relu" : { + "k" : 1280, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "batch_normalization_22_output" : { + "k" : 32, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_1_output" : { + "k" : 16, + "w" : 112, + "n" : 1, + "h" : 112 + }, + "batch_normalization_13_output" : { + "k" : 16, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_28_output_relu_neg" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "conv2d_38_output" : { + "k" : 3, + "w" : 1, + "n" : 1, + "h" : 1 + }, + "activation_32_output_relu" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_28_output" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_22_output_relu_clip" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_17_output_relu_clip" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_12_output_relu_neg" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_1_output_relu_clip" : { + "k" : 16, + "w" : 112, + "n" : 1, + "h" : 112 + }, + "activation_27_output" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "add_3_output" : { + "k" : 16, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_9_output_relu" : { + "k" : 72, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_23_output_relu" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "softmax_output" : { + "k" : 3, + "w" : 1, + "n" : 1, + "h" : 1 + }, + "activation_27_output_relu_neg" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_13_output_relu_clip" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_35_output_relu" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_30_output_relu_neg" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_26_output" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_6_output_relu_neg" : { + "k" : 72, + "w" : 56, + "n" : 1, + "h" : 56 + }, + "add_9_output" : { + "k" : 80, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_11_output_relu_neg" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "batch_normalization_10_output" : { + "k" : 12, + "w" : 56, + "n" : 1, + "h" : 56 + }, + "activation_33_output_relu_clip" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_7_output_relu" : { + "k" : 72, + "w" : 56, + "n" : 1, + "h" : 56 + }, + "activation_10_output_relu" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_28_output_relu_clip" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_25_output" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_26_output_relu_neg" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_7_output" : { + "k" : 72, + "w" : 56, + "n" : 1, + "h" : 56 + }, + "activation_22_output_relu" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_19_output" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "add_1_output" : { + "k" : 12, + "w" : 56, + "n" : 1, + "h" : 56 + }, + "activation_1_output_relu_neg" : { + "k" : 16, + "w" : 112, + "n" : 1, + "h" : 112 + }, + "activation_9_output_relu_clip" : { + "k" : 72, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_24_output_relu_clip" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_19_output_relu_clip" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_15_output_relu_neg" : { + "k" : 96, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_18_output" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "add_7_output" : { + "k" : 48, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_25_output_relu" : { + "k" : 288, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_2_output_relu" : { + "k" : 16, + "w" : 112, + "n" : 1, + "h" : 112 + }, + "activation_17_output" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_15_output_relu_clip" : { + "k" : 96, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_33_output_relu_neg" : { + "k" : 480, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "batch_normalization_49_output" : { + "k" : 80, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_6_output_relu_clip" : { + "k" : 72, + "w" : 56, + "n" : 1, + "h" : 56 + }, + "activation_14_output_relu_neg" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_6_output" : { + "k" : 72, + "w" : 56, + "n" : 1, + "h" : 56 + }, + "activation_16_output" : { + "k" : 192, + "w" : 14, + "n" : 1, + "h" : 14 + }, + "activation_7_output_relu_neg" : { + "k" : 72, + "w" : 56, + "n" : 1, + "h" : 56 + }, + "add_10_output" : { + "k" : 80, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_12_output_relu" : { + "k" : 96, + "w" : 28, + "n" : 1, + "h" : 28 + }, + "activation_29_output_relu_neg" : { + "k" : 288, + "w" : 7, + "n" : 1, + "h" : 7 + }, + "activation_36_output_relu_clip" : { + "k" : 1280, + "w" : 7, + "n" : 1, + "h" : 7 + } + } +} \ No newline at end of file diff --git a/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/model.espresso.weights b/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/model.espresso.weights new file mode 100644 index 00000000..d58a125e Binary files /dev/null and b/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/model.espresso.weights differ diff --git a/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/model/coremldata.bin b/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/model/coremldata.bin new file mode 100644 index 00000000..ee515f75 Binary files /dev/null and b/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/model/coremldata.bin differ diff --git a/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/neural_network_optionals/coremldata.bin b/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/neural_network_optionals/coremldata.bin new file mode 100644 index 00000000..6459c295 Binary files /dev/null and b/StripeCardScan/StripeCardScan/Resources/CompiledModels/UxModel.mlmodelc/neural_network_optionals/coremldata.bin differ diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/bg-BG.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/bg-BG.lproj/Localizable.strings new file mode 100644 index 00000000..377a1d6f --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/bg-BG.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Картата не съвпада"; + +"Enable camera access" = "Активиране на достъпа до камера"; + +"Enter card details manually" = "Ръчно въвеждане на данните на картата"; + +"To scan your card you'll need to update your phone settings" = "За да сканирате Вашата карта, трябва да актуализирате настройките на телефона си."; + +"Torch" = "Фенерче"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/ca-ES.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/ca-ES.lproj/Localizable.strings new file mode 100644 index 00000000..65a9acbf --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/ca-ES.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "La targeta no coincideix"; + +"Enable camera access" = "Habilita l'accés a la càmera"; + +"Enter card details manually" = "Introdueix manualment les dades de la targeta"; + +"To scan your card you'll need to update your phone settings" = "Per escanejar la teva targeta, caldrà que actualitzis la configuració del telèfon"; + +"Torch" = "Llanterna"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/cs-CZ.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/cs-CZ.lproj/Localizable.strings new file mode 100644 index 00000000..5345a1e9 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/cs-CZ.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Karta neodpovídá"; + +"Enable camera access" = "Povolit přístup k fotoaparátu"; + +"Enter card details manually" = "Zadejte podrobnosti o kartě ručně"; + +"To scan your card you'll need to update your phone settings" = "K naskenování karty budete muset aktualizovat nastavení telefonu"; + +"Torch" = "Baterka"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/da.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/da.lproj/Localizable.strings new file mode 100644 index 00000000..800855f7 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/da.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Kortet matcher ikke"; + +"Enable camera access" = "Aktiver kameraadgang"; + +"Enter card details manually" = "Indtast kortoplysninger manuelt"; + +"To scan your card you'll need to update your phone settings" = "Du skal opdatere dine telefonindstillinger for at scanne dit kort"; + +"Torch" = "Lommelygte"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/de.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/de.lproj/Localizable.strings new file mode 100644 index 00000000..e1216b09 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/de.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Karten stimmen nicht überein"; + +"Enable camera access" = "Kamerazugriff erlauben"; + +"Enter card details manually" = "Kartenangaben manuell eingeben"; + +"To scan your card you'll need to update your phone settings" = "Um die Karte scannen zu können, müssen Sie Ihre Telefoneinstellungen aktualisieren."; + +"Torch" = "Taschenlampe"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/el-GR.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/el-GR.lproj/Localizable.strings new file mode 100644 index 00000000..f388ead3 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/el-GR.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Η κάρτα δεν αντιστοιχεί"; + +"Enable camera access" = "Ενεργοποιήστε την πρόσβαση στην κάμερα"; + +"Enter card details manually" = "Εισαγάγετε τα στοιχεία της κάρτας με μη αυτόματο τρόπο"; + +"To scan your card you'll need to update your phone settings" = "Για να σαρώσετε την κάρτα σας, θα πρέπει να ενημερώσετε τις ρυθμίσεις τηλεφώνου σας"; + +"Torch" = "Φακός"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/en-GB.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/en-GB.lproj/Localizable.strings new file mode 100644 index 00000000..23581679 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/en-GB.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Card doesn't match"; + +"Enable camera access" = "Enable camera access"; + +"Enter card details manually" = "Enter card details manually"; + +"To scan your card you'll need to update your phone settings" = "To scan your card, you'll need to update your phone settings"; + +"Torch" = "Torch"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/en.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/en.lproj/Localizable.strings new file mode 100644 index 00000000..3dfd72af --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/en.lproj/Localizable.strings @@ -0,0 +1,15 @@ +/* Label of the error message when the scanned card mismatches the card on file */ +"Card doesn't match" = "Card doesn't match"; + +/* Label for button to take customer to camera settings */ +"Enable camera access" = "Enable camera access"; + +/* Label for button to enter card details manually instead of scanning */ +"Enter card details manually" = "Enter card details manually"; + +/* Label to explain that they need to update phone settings to scan */ +"To scan your card you'll need to update your phone settings" = "To scan your card you'll need to update your phone settings"; + +/* Label for the button that toggles the camera's torch */ +"Torch" = "Torch"; + diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/es-419.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/es-419.lproj/Localizable.strings new file mode 100644 index 00000000..d7d58f2f --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/es-419.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "La tarjeta no coincide."; + +"Enable camera access" = "Habilitar acceso a la cámara"; + +"Enter card details manually" = "Ingresar los datos de la tarjeta manualmente"; + +"To scan your card you'll need to update your phone settings" = "Para escanear la tarjeta, tendrás que actualizar la configuración de tu teléfono."; + +"Torch" = "Linterna"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/es.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/es.lproj/Localizable.strings new file mode 100644 index 00000000..9f10c359 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/es.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "La tarjeta no coincide"; + +"Enable camera access" = "Permitir acceso a la cámara"; + +"Enter card details manually" = "Introducir manualmente los datos de la tarjeta"; + +"To scan your card you'll need to update your phone settings" = "Para escanear la tarjeta, tienes que actualizar los ajustes de tu teléfono"; + +"Torch" = "Linterna"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/et-EE.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/et-EE.lproj/Localizable.strings new file mode 100644 index 00000000..1e48c27b --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/et-EE.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Kaardid ei ole vastavuses"; + +"Enable camera access" = "Luba juurdepääs kaamerale"; + +"Enter card details manually" = "Sisesta kaardi andmed käsitsi"; + +"To scan your card you'll need to update your phone settings" = "Kaardi skannimiseks tuleb uuendada telefoni seadeid"; + +"Torch" = "Valgusti"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/fi.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/fi.lproj/Localizable.strings new file mode 100644 index 00000000..38349b28 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/fi.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Kortit eivät vastaa toisiaan"; + +"Enable camera access" = "Ota kameraan pääsy käyttöön"; + +"Enter card details manually" = "Syötä kortin tiedot manuaalisesti"; + +"To scan your card you'll need to update your phone settings" = "Päivitä puhelimesi asetukset, jotta voit skannata korttisi"; + +"Torch" = "Taskulamppu"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/fil.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/fil.lproj/Localizable.strings new file mode 100644 index 00000000..132cd1cc --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/fil.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Hindi tugma ang card"; + +"Enable camera access" = "Paganahin ang access sa kamera"; + +"Enter card details manually" = "Manwal na ilagay ang mga detalye ng card"; + +"To scan your card you'll need to update your phone settings" = "Para ma-scan ang iyong card, kakailanganin mong i-update ang mga setting ng iyong telepono"; + +"Torch" = "Flashlight"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/fr-CA.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/fr-CA.lproj/Localizable.strings new file mode 100644 index 00000000..4b00078f --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/fr-CA.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "La carte ne correspond pas"; + +"Enable camera access" = "Autoriser l'accès à l'appareil photo"; + +"Enter card details manually" = "Saisir les informations de carte manuellement"; + +"To scan your card you'll need to update your phone settings" = "Pour scanner votre carte, vous devez mettre à jour les paramètres de votre téléphone."; + +"Torch" = "Lampe de poche"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/fr.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/fr.lproj/Localizable.strings new file mode 100644 index 00000000..f76d2fec --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/fr.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "La carte bancaire ne correspond pas"; + +"Enable camera access" = "Autoriser l'accès à l'appareil photo"; + +"Enter card details manually" = "Saisir les informations de carte bancaire manuellement"; + +"To scan your card you'll need to update your phone settings" = "Pour numériser votre carte bancaire, vous devrez mettre à jour les paramètres de votre téléphone."; + +"Torch" = "Torche"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/hr.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/hr.lproj/Localizable.strings new file mode 100644 index 00000000..4248ef8f --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/hr.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Dokument se ne podudara"; + +"Enable camera access" = "Omogući pristup kameri"; + +"Enter card details manually" = "Ručno unesi podatke"; + +"To scan your card you'll need to update your phone settings" = "Morate ažurirati postavke telefona prije skeniranja"; + +"Torch" = "Svjetiljka"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/hu.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/hu.lproj/Localizable.strings new file mode 100644 index 00000000..ffc4d7ab --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/hu.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Nem egyező kártya"; + +"Enable camera access" = "Kamerához való hozzáférés engedélyezése"; + +"Enter card details manually" = "Kártyaadatok megadása kézzel"; + +"To scan your card you'll need to update your phone settings" = "A kártya beolvasásához frissítse a telefonja beállításait"; + +"Torch" = "Vaku"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/id.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/id.lproj/Localizable.strings new file mode 100644 index 00000000..01a337da --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/id.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Kartu tidak cocok"; + +"Enable camera access" = "Aktifkan akses kamera"; + +"Enter card details manually" = "Masukkan detail kartu secara manual"; + +"To scan your card you'll need to update your phone settings" = "Untuk memindai kartu, Anda harus memperbarui pengaturan telepon Anda"; + +"Torch" = "Senter"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/it.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/it.lproj/Localizable.strings new file mode 100644 index 00000000..bce6ba4f --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/it.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Mancata corrispondenza carta"; + +"Enable camera access" = "Abilita fotocamera"; + +"Enter card details manually" = "Inserisci manualmente dati carta"; + +"To scan your card you'll need to update your phone settings" = "Per scansionare la carta, aggiorna le impostazioni del telefono."; + +"Torch" = "Torcia"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/ja.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/ja.lproj/Localizable.strings new file mode 100644 index 00000000..83b63935 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/ja.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "カードが一致しません"; + +"Enable camera access" = "カメラへのアクセスを有効にする"; + +"Enter card details manually" = "カード詳細を手動で入力する"; + +"To scan your card you'll need to update your phone settings" = "カードをスキャンするには、スマートフォンの設定を更新する必要があります"; + +"Torch" = "ライト"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/ko.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/ko.lproj/Localizable.strings new file mode 100644 index 00000000..5747f86a --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/ko.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "카드 불일치"; + +"Enable camera access" = "카메라 액세스 사용"; + +"Enter card details manually" = "카드 세부사항 직접 입력"; + +"To scan your card you'll need to update your phone settings" = "카드를 스캔하려면 휴대폰 설정을 업데이트해야 합니다."; + +"Torch" = "손전등"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/lt-LT.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/lt-LT.lproj/Localizable.strings new file mode 100644 index 00000000..49cd9470 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/lt-LT.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Kortelė neatitinka"; + +"Enable camera access" = "Įjungti prieigą prie fotoaparato"; + +"Enter card details manually" = "Įveskite kortelės duomenis rankiniu būdu"; + +"To scan your card you'll need to update your phone settings" = "Jei norite nuskaityti kortelę, turite atnaujinti telefono nustatymus"; + +"Torch" = "Žibintuvėlis"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/lv-LV.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/lv-LV.lproj/Localizable.strings new file mode 100644 index 00000000..fd62b950 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/lv-LV.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Karte nesakrīt"; + +"Enable camera access" = "Iespējot kameras piekļuvi"; + +"Enter card details manually" = "Manuāli ievadiet kartes informāciju"; + +"To scan your card you'll need to update your phone settings" = "Lai noskenētu karti, jums vajadzēs atjaunināt tālruņa iestatījumus"; + +"Torch" = "Lukturis"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/ms-MY.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/ms-MY.lproj/Localizable.strings new file mode 100644 index 00000000..7f5e1a85 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/ms-MY.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Kad tidak sepadan"; + +"Enable camera access" = "Aktifkan akses kamera"; + +"Enter card details manually" = "Masukkan butiran kad secara manual"; + +"To scan your card you'll need to update your phone settings" = "Untuk mengimbas kad anda, anda perlu mengemaskinikan tetapan telefon anda"; + +"Torch" = "Lampu suluh"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/mt.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/mt.lproj/Localizable.strings new file mode 100644 index 00000000..cfb41d55 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/mt.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Il-karta ma taqbilx"; + +"Enable camera access" = "Agħti aċċess għall-kamera"; + +"Enter card details manually" = "Daħħal id-dettalji tal-karta int stess"; + +"To scan your card you'll need to update your phone settings" = "Jeħtieġlek taġġorna s-settings tal-mowbajl tiegħek biex tkun tista' tiskennja l-karta tiegħek"; + +"Torch" = "Torċ"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/nb.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/nb.lproj/Localizable.strings new file mode 100644 index 00000000..4fc539e7 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/nb.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Kortet samsvarer ikke"; + +"Enable camera access" = "Aktiver kameratilgang"; + +"Enter card details manually" = "Angi kortopplysninger manuelt"; + +"To scan your card you'll need to update your phone settings" = "Oppdater telefoninnstillingene dine for å skanne kortet"; + +"Torch" = "Lommelykt"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/nl.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/nl.lproj/Localizable.strings new file mode 100644 index 00000000..f5562952 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/nl.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Betaalkaart komt niet overeen."; + +"Enable camera access" = "Toegang tot camera inschakelen"; + +"Enter card details manually" = "Gegevens van betaalkaart handmatig invoeren"; + +"To scan your card you'll need to update your phone settings" = "Werk de instellingen van je telefoon bij om je kaart te scannen."; + +"Torch" = "Zaklamp"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/nn-NO.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/nn-NO.lproj/Localizable.strings new file mode 100644 index 00000000..04e96708 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/nn-NO.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Kort samsvarer ikkje"; + +"Enable camera access" = "Aktiver kameratilgang"; + +"Enter card details manually" = "Skriv inn kortdetaljar manuelt"; + +"To scan your card you'll need to update your phone settings" = "For å skanne kortet må du oppdatere innstillingane på telefonen"; + +"Torch" = "Lommelykt"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/pl-PL.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/pl-PL.lproj/Localizable.strings new file mode 100644 index 00000000..129eefe0 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/pl-PL.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Karta nie pasuje"; + +"Enable camera access" = "Włącz dostęp do kamery"; + +"Enter card details manually" = "Wprowadź dane karty ręcznie"; + +"To scan your card you'll need to update your phone settings" = "Aby zeskanować kartę, musisz zaktualizować ustawienia telefonu"; + +"Torch" = "Latarka"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/pt-BR.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/pt-BR.lproj/Localizable.strings new file mode 100644 index 00000000..03613eb1 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/pt-BR.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "O cartão não corresponde ao registro"; + +"Enable camera access" = "Habilitar acesso à câmera"; + +"Enter card details manually" = "Inserir manualmente os dados do cartão"; + +"To scan your card you'll need to update your phone settings" = "Para ler seu cartão, atualize as configurações do celular"; + +"Torch" = "Lanterna"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/pt-PT.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/pt-PT.lproj/Localizable.strings new file mode 100644 index 00000000..82c2c3d8 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/pt-PT.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "O cartão não corresponde"; + +"Enable camera access" = "Ativar acesso à câmara"; + +"Enter card details manually" = "Introduzir detalhes do cartão manualmente"; + +"To scan your card you'll need to update your phone settings" = "Para analisar o seu cartão, precisa de atualizar as definições do telemóvel"; + +"Torch" = "Lanterna"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/ro-RO.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/ro-RO.lproj/Localizable.strings new file mode 100644 index 00000000..e4da7316 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/ro-RO.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Cardul nu se potrivește"; + +"Enable camera access" = "Activați accesul la cameră"; + +"Enter card details manually" = "Introduceți manual informațiile cardului"; + +"To scan your card you'll need to update your phone settings" = "Pentru a vă scana cardul, trebuie să actualizați setările telefonului"; + +"Torch" = "Lanternă"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/ru.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/ru.lproj/Localizable.strings new file mode 100644 index 00000000..366c7c17 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/ru.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Карта не соответствует"; + +"Enable camera access" = "Разрешить доступ к камере"; + +"Enter card details manually" = "Ввести реквизиты карты вручную"; + +"To scan your card you'll need to update your phone settings" = "Чтобы сканировать карту, нужно изменить настройки телефона."; + +"Torch" = "Фонарик"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/sk-SK.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/sk-SK.lproj/Localizable.strings new file mode 100644 index 00000000..c7aaeec9 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/sk-SK.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Karta sa nezhoduje"; + +"Enable camera access" = "Povoliť prístup k fotoaparátu"; + +"Enter card details manually" = "Zadať údaje o karte manuálne"; + +"To scan your card you'll need to update your phone settings" = "Ak chcete naskenovať kartu, budete musieť aktualizovať nastavenia telefónu"; + +"Torch" = "Svetlo"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/sl-SI.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/sl-SI.lproj/Localizable.strings new file mode 100644 index 00000000..53a576f3 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/sl-SI.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Kartica se ne ujema"; + +"Enable camera access" = "Omogoči dostop do kamere"; + +"Enter card details manually" = "Ročni vnos podatkov o kartici"; + +"To scan your card you'll need to update your phone settings" = "Če želite optično prebrati kartico, morate posodobiti nastavitve telefona."; + +"Torch" = "Bliskavica"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/sv.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/sv.lproj/Localizable.strings new file mode 100644 index 00000000..6bb31868 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/sv.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Kortet överensstämmer inte"; + +"Enable camera access" = "Aktivera kameraåtkomst"; + +"Enter card details manually" = "Ange kortinformationen manuellt"; + +"To scan your card you'll need to update your phone settings" = "För att skanna ditt kort behöver du uppdatera dina telefoninställningar"; + +"Torch" = "Ficklampa"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/tr.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/tr.lproj/Localizable.strings new file mode 100644 index 00000000..6d95a0e2 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/tr.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Kart eşleşmiyor"; + +"Enable camera access" = "Kamera erişimine izin ver"; + +"Enter card details manually" = "Kart bilgilerini manuel olarak girin"; + +"To scan your card you'll need to update your phone settings" = "Kartınızı taramak için telefon ayarlarınızı güncellemeniz gerekiyor"; + +"Torch" = "El feneri"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/vi.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/vi.lproj/Localizable.strings new file mode 100644 index 00000000..02d7c5b5 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/vi.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "Thẻ không khớp"; + +"Enable camera access" = "Bật truy cập máy ảnh"; + +"Enter card details manually" = "Nhập thông tin thẻ theo cách thủ công"; + +"To scan your card you'll need to update your phone settings" = "Để quét thẻ, quý vị cần cập nhật cài đặt điện thoại"; + +"Torch" = "Đèn pin"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/zh-HK.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/zh-HK.lproj/Localizable.strings new file mode 100644 index 00000000..7bdb471f --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/zh-HK.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "卡不匹配"; + +"Enable camera access" = "啟用相機存取"; + +"Enter card details manually" = "手動輸入銀行卡詳情"; + +"To scan your card you'll need to update your phone settings" = "要掃描銀行卡,您需要更新您的手機設置"; + +"Torch" = "手電筒"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/zh-Hans.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/zh-Hans.lproj/Localizable.strings new file mode 100644 index 00000000..46b3feb4 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "卡不匹配"; + +"Enable camera access" = "启用摄像头访问"; + +"Enter card details manually" = "手动输入银行卡详情"; + +"To scan your card you'll need to update your phone settings" = "要扫描银行卡,您需要更新您的手机设置"; + +"Torch" = "手电筒"; diff --git a/StripeCardScan/StripeCardScan/Resources/Localizations/zh-Hant.lproj/Localizable.strings b/StripeCardScan/StripeCardScan/Resources/Localizations/zh-Hant.lproj/Localizable.strings new file mode 100644 index 00000000..05aa848a --- /dev/null +++ b/StripeCardScan/StripeCardScan/Resources/Localizations/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,9 @@ +"Card doesn't match" = "卡不匹配"; + +"Enable camera access" = "啟用相機存取"; + +"Enter card details manually" = "手動輸入金融卡詳情"; + +"To scan your card you'll need to update your phone settings" = "要掃描金融卡,您需要更新您的手機設定"; + +"Torch" = "手電筒"; diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/AppleOcr/AppleOcr.swift b/StripeCardScan/StripeCardScan/Source/CardScan/AppleOcr/AppleOcr.swift new file mode 100644 index 00000000..19947a46 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/AppleOcr/AppleOcr.swift @@ -0,0 +1,79 @@ +import UIKit +import Vision + +struct AppleOcr { + static func configure() { + // warm up the model eventually + } + + static func convertToImageRect(boundingBox: VNRectangleObservation, imageSize: CGSize) -> CGRect + { + let topLeft = VNImagePointForNormalizedPoint( + boundingBox.topLeft, + Int(imageSize.width), + Int(imageSize.height) + ) + let bottomRight = VNImagePointForNormalizedPoint( + boundingBox.bottomRight, + Int(imageSize.width), + Int(imageSize.height) + ) + // flip it for top left (0,0) image coordinates + return CGRect( + x: topLeft.x, + y: imageSize.height - topLeft.y, + width: abs(bottomRight.x - topLeft.x), + height: abs(topLeft.y - bottomRight.y) + ) + + } + + static func performOcr(image: CGImage, completion: @escaping ([OcrObject]) -> Void) { + let textRequest = VNRecognizeTextRequest { request, _ in + let imageSize = CGSize(width: image.width, height: image.height) + + guard let results = request.results as? [VNRecognizedTextObservation], !results.isEmpty + else { + completion([]) + return + } + let outputObjects: [OcrObject] = results.compactMap { result in + guard let candidate = result.topCandidates(1).first, + let box = try? candidate.boundingBox( + for: candidate.string.startIndex.. Void) { + DispatchQueue.global(qos: .userInitiated).async { + performOcr(image: image) { complete($0) } + } + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/CardUtils/CardNetwork.swift b/StripeCardScan/StripeCardScan/Source/CardScan/CardUtils/CardNetwork.swift new file mode 100644 index 00000000..11775fa4 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/CardUtils/CardNetwork.swift @@ -0,0 +1,34 @@ +// +// CardNetwork.swift +// CardScan +// +// Created by Jaime Park on 1/31/20. +// + +import Foundation + +enum CardNetwork: Int { + case VISA + case MASTERCARD + case AMEX + case DISCOVER + case UNIONPAY + case JCB + case DINERSCLUB + case REGIONAL + case UNKNOWN + + func toString() -> String { + switch self { + case .VISA: return "Visa" + case .MASTERCARD: return "MasterCard" + case .AMEX: return "Amex" + case .DISCOVER: return "Discover" + case .UNIONPAY: return "Union Pay" + case .JCB: return "Jcb" + case .DINERSCLUB: return "Diners Club" + case .REGIONAL: return "Regional" + case .UNKNOWN: return "Unknown" + } + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/CardUtils/CardType.swift b/StripeCardScan/StripeCardScan/Source/CardScan/CardUtils/CardType.swift new file mode 100644 index 00000000..a028d388 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/CardUtils/CardType.swift @@ -0,0 +1,33 @@ +// +// CardType.swift +// CardScan +// +// Created by Adam Wushensky on 8/27/20. +// + +import Foundation + +enum CardType: Int { + case CREDIT + case DEBIT + case PREPAID + case UNKNOWN + + func toString() -> String { + switch self { + case .CREDIT: return "Credit" + case .DEBIT: return "Debit" + case .PREPAID: return "Prepaid" + case .UNKNOWN: return "Unknown" + } + } + + static func fromString(_ str: String) -> CardType { + switch str.lowercased() { + case "credit": return .CREDIT + case "debit": return .DEBIT + case "prepaid": return .PREPAID + default: return .UNKNOWN + } + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/CardUtils/CreditCardUtils.swift b/StripeCardScan/StripeCardScan/Source/CardScan/CardUtils/CreditCardUtils.swift new file mode 100644 index 00000000..f7cba144 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/CardUtils/CreditCardUtils.swift @@ -0,0 +1,390 @@ +import Foundation + +struct CreditCardUtils { + static let maxCvvLength = 3 + static let maxCvvLengthAmex = 4 + + static let maxPanLength = 16 + static let maxPanLengthAmericanExpress = 15 + static let maxPanLengthDinersClub = 14 + + private static let prefixesAmericanExpress = ["34", "37"] + private static let prefixesDinersClub = [ + "300", "301", "302", "303", "304", "305", "309", "36", "38", "39", + ] + private static let prefixesDiscover = ["6011", "64", "65"] + private static let prefixesJcb = ["35"] + private static let prefixesMastercard = [ + "2221", "2222", "2223", "2224", "2225", "2226", + "2227", "2228", "2229", "223", "224", "225", "226", + "227", "228", "229", "23", "24", "25", "26", "270", + "271", "2720", "50", "51", "52", "53", "54", "55", + "67", + ] + private static let prefixesUnionPay = ["62"] + private static let prefixesVisa = ["4"] + + static var prefixesRegional: [String] = [] + + private static var cardTypeMap: [(ClosedRange, CardType)]? + + /// Adds the BINs implemented by the MIR network in Russia as regional cards + static func addMirSupport() { + prefixesRegional += ["2200", "2201", "2202", "2203", "2204"] + } + + /// Checks if the card number is valid. + /// - Parameter cardNumber: The card number as a string . + /// - Returns: `true` if valid, `false` otherwise + static func isValidNumber(cardNumber: String) -> Bool { + return isValidLuhnNumber(cardNumber: cardNumber) && isValidLength(cardNumber: cardNumber) + } + + /// Checks if the card's cvv is valid + /// - Parameters: + /// - cvv: The cvv as a string + /// - network: The card's bank network + /// - Returns: `true` if valid, `false ` otherwise + static func isValidCvv(cvv: String, network: CardNetwork) -> Bool { + let cvv = cvv.trimmingCharacters(in: .whitespacesAndNewlines) + return (network == CardNetwork.AMEX && cvv.count == maxCvvLengthAmex) + || (cvv.count == maxCvvLength) + } + + /// Checks if the card's expiration date is valid + /// - Parameters: + /// - expMonth: The expiration month as a string + /// - expYear: The expiration year as a string + /// - Returns: `true` is both expiration month and year are valid, `false` otherwise + static func isValidDate(expMonth: String, expYear: String) -> Bool { + guard let expirationMonth = Int(expMonth.trimmingCharacters(in: .whitespacesAndNewlines)) + else { + return false + } + + guard let expirationYear = Int(expYear.trimmingCharacters(in: .whitespacesAndNewlines)) + else { + return false + } + + if !isValidMonth(expMonth: expirationMonth) { + return false + } else if !isValidYear(expYear: expirationYear) { + return false + } else { + return !hasMonthPassed(expMonth: expirationMonth, expYear: expirationYear) + } + } + + /// Checks if the card's expiration month is valid + /// - Parameter expMonth: The expiration month as an integer + /// - Returns: `true` if valid, `false` otherwise + static func isValidMonth(expMonth: Int) -> Bool { + return 1...12 ~= expMonth + } + + /// Checks if the card's expiration year is valid + /// - Parameter expYear: The expiration year as an integer + /// - Returns: `true` if valid, `false` otherwise + static func isValidYear(expYear: Int) -> Bool { + return !hasYearPassed(expYear: expYear) + } + + /// Checks if the card's expiration month has passed + /// - Parameters: + /// - expMonth: The expiration month as an integer + /// - expYear: The expiration year as an integer + /// - Returns: `true` if expiration month has passed current time, `false` otherwise + static func hasMonthPassed(expMonth: Int, expYear: Int) -> Bool { + let currentMonth = getCurrentMonth() + let currentYear = getCurrentYear() + + if hasYearPassed(expYear: expYear) { + return true + } else { + return normalizeYear(expYear: expYear) == currentYear && expMonth < currentMonth + } + } + + /// Checks if the card's expiration year has passed + /// - Parameter expYear: The expiration year as an integer + /// - Returns: `true` if expiration year has passed current time, `false` otherwise + static func hasYearPassed(expYear: Int) -> Bool { + let currentYear = getCurrentYear() + guard let expirationYear = normalizeYear(expYear: expYear) else { + return false + } + return expirationYear < currentYear + } + + /// Returns expiration year in four digits. If expiration year is two digits, it appends the current century to the beginning of the year + /// - Parameter expYear: The expiration year as an integer + /// - Returns: An `Int` of the four digit year, `nil` otherwise + static func normalizeYear(expYear: Int) -> Int? { + let currentYear = getCurrentYear() + var expirationYear: Int + + if 0...99 ~= expYear { + let currentYearToString = String(currentYear) + let currentYearPrefix = currentYearToString.prefix(2) + guard let concatExpYear = Int("\(currentYearPrefix)\(expYear)") else { + return nil + } + expirationYear = concatExpYear + } else { + expirationYear = expYear + } + + return expirationYear + } + + static func getCurrentYear() -> Int { + let date = Date() + let now = Calendar.current + let currentYear = now.component(.year, from: date) + return currentYear + } + + static func getCurrentMonth() -> Int { + let date = Date() + let now = Calendar.current + // The start of the month begins from 1 + let currentMonth = now.component(.month, from: date) + return currentMonth + } + + // https://en.wikipedia.org/wiki/Luhn_algorithm + // assume 16 digits are for MC and Visa (start with 4, 5) and 15 is for Amex + // which starts with 3 + /// Checks if the card number passes the Luhn's algorithm + /// - Parameter cardNumber: The card number as a string + /// - Returns: `true` if the card number is a valid Luhn number, `false` otherwise + static func isValidLuhnNumber(cardNumber: String) -> Bool { + if cardNumber.isEmpty || !isValidBin(cardNumber: cardNumber) { + return false + } + + var sum = 0 + let reversedCharacters = cardNumber.reversed().map { String($0) } + for (idx, element) in reversedCharacters.enumerated() { + guard let digit = Int(element) else { return false } + switch ((idx % 2 == 1), digit) { + case (true, 9): sum += 9 + case (true, 0...8): sum += (digit * 2) % 9 + default: sum += digit + } + } + return sum % 10 == 0 + } + + /// Checks if the card number contains a valid bin + /// - Parameter cardNumber: The card number as a string + /// - Returns: `true` if the card number contains a valid bin, `false` otherwise + static func isValidBin(cardNumber: String) -> Bool { + determineCardNetwork(cardNumber: cardNumber) != CardNetwork.UNKNOWN + } + + static func isValidLength(cardNumber: String) -> Bool { + return isValidLength( + cardNumber: cardNumber, + network: determineCardNetwork(cardNumber: cardNumber) + ) + } + + /// Checks if the inputted card number has a valid length in accordance with the card's bank network + /// - Parameters: + /// - cardNumber: The card number as a string + /// - network: The card's bank network + /// - Returns: `true` is card number is a valid length, `false` otherwise + static func isValidLength(cardNumber: String, network: CardNetwork) -> Bool { + let cardNumber = cardNumber.trimmingCharacters(in: .whitespacesAndNewlines) + let cardNumberLength = cardNumber.count + + if cardNumber.isEmpty || network == CardNetwork.UNKNOWN { + return false + } + + switch network { + case CardNetwork.AMEX: + return cardNumberLength == maxPanLengthAmericanExpress + case CardNetwork.DINERSCLUB: + return cardNumberLength == maxPanLengthDinersClub + default: + return cardNumberLength == maxPanLength + } + } + + /// Returns the card's issuer / bank network based on the card number + /// - Parameter cardNumber: The card number as a string + /// - Returns: The card's bank network as a CardNetwork enum + static func determineCardNetwork(cardNumber: String) -> CardNetwork { + let cardNumber = cardNumber.trimmingCharacters(in: .whitespacesAndNewlines) + + if cardNumber.isEmpty { + return CardNetwork.UNKNOWN + } + + switch true { + case hasAnyPrefix(cardNumber: cardNumber, prefixes: prefixesAmericanExpress): + return CardNetwork.AMEX + case hasAnyPrefix(cardNumber: cardNumber, prefixes: prefixesDiscover): + return CardNetwork.DISCOVER + case hasAnyPrefix(cardNumber: cardNumber, prefixes: prefixesJcb): + return CardNetwork.JCB + case hasAnyPrefix(cardNumber: cardNumber, prefixes: prefixesDinersClub): + return CardNetwork.DINERSCLUB + case hasAnyPrefix(cardNumber: cardNumber, prefixes: prefixesVisa): + return CardNetwork.VISA + case hasAnyPrefix(cardNumber: cardNumber, prefixes: prefixesMastercard): + return CardNetwork.MASTERCARD + case hasAnyPrefix(cardNumber: cardNumber, prefixes: prefixesUnionPay): + return CardNetwork.UNIONPAY + case hasAnyPrefix(cardNumber: cardNumber, prefixes: prefixesRegional): + return CardNetwork.REGIONAL + default: + return CardNetwork.UNKNOWN + } + } + + /// Returns the card's type (debit, credit, preiad, unknown) based on the card number + /// - Parameter cardNumber: The card number as a string + /// - Returns: The card's type as a CardType enum + static func determineCardType(cardNumber: String) -> CardType { + guard let iin = Int(cardNumber.prefix(6)) else { + return .UNKNOWN + } + + let cardTypes: [(ClosedRange, CardType)] + if let cardTypeMap = self.cardTypeMap { + cardTypes = cardTypeMap + } else { + guard + let filePath = StripeCardScanBundleLocator.resourcesBundle.path( + forResource: "card_types", + ofType: "txt" + ) + else { + // unable to find the file + return .UNKNOWN + } + + guard let contents = try? String(contentsOfFile: filePath) else { + // unable to read the contents of the file + return .UNKNOWN + } + + cardTypes = contents.components(separatedBy: "\n").compactMap { + let items = $0.components(separatedBy: ",") + guard items.count == 3 else { + return nil + } + + let cardType = CardType.fromString(items[2]) + guard let startIin = Int(items[0]), let endIin = Int(items[1]) else { + return nil + } + guard cardType != .UNKNOWN else { + return nil + } + + return (startIin...endIin, cardType) + } + + guard !cardTypes.isEmpty else { + return .UNKNOWN + } + + self.cardTypeMap = cardTypes + } + + return cardTypes.first { $0.0.contains(iin) }?.1 ?? .UNKNOWN + } + + /// Determines whether a card number belongs to any bank network according to their bin + /// - Parameters: + /// - cardNumber: The card number as a string + /// - prefixes: The set of bin prefixes used with certain bank networks + /// - Returns: `true` if card number belongs to a bank network, `false` otherwise + static func hasAnyPrefix(cardNumber: String, prefixes: [String]) -> Bool { + return prefixes.filter { cardNumber.hasPrefix($0) }.count > 0 + } + + // TODO: Will be replaced with `formatCardNumber` in future version + static func format(number: String) -> String { + return formatCardNumber(cardNumber: number) + } + + /// Returns the card number formatted for display + /// - Parameter cardNumber: The card number as a string + /// - Returns: The card number formatted + static func formatCardNumber(cardNumber: String) -> String { + if cardNumber.count == maxPanLength { + return format16(cardNumber: cardNumber) + } else if cardNumber.count == maxPanLengthAmericanExpress { + return format15(cardNumber: cardNumber) + } else { + return cardNumber + } + } + + /// Returns the card's expiration date formatted for display + /// - Parameters: + /// - expMonth: The expiration month as a string + /// - expYear: The expiration year as a string + /// - Returns: The card's expiration date formatted as MM/YY + static func formatExpirationDate(expMonth: String, expYear: String) -> String { + var month = expMonth + let year = "\(expYear.suffix(2))" + + if expMonth.count == 1 { + month = "0\(expMonth)" + } + + return "\(month)/\(year)" + } + + static func format15(cardNumber: String) -> String { + var displayNumber = "" + for (idx, char) in cardNumber.enumerated() { + if idx == 4 || idx == 10 { + displayNumber += " " + } + displayNumber += String(char) + } + return displayNumber + } + + static func format16(cardNumber: String) -> String { + var displayNumber = "" + for (idx, char) in cardNumber.enumerated() { + if (idx % 4) == 0 && idx != 0 { + displayNumber += " " + } + displayNumber += String(char) + } + return displayNumber + } +} + +// TODO: Added extension to make older network changes available, will remove in future version +extension CreditCardUtils { + static func isVisa(number: String) -> Bool { + return determineCardNetwork(cardNumber: number) == CardNetwork.VISA + } + + static func isAmex(number: String) -> Bool { + return determineCardNetwork(cardNumber: number) == CardNetwork.AMEX + } + + static func isDiscover(number: String) -> Bool { + return determineCardNetwork(cardNumber: number) == CardNetwork.DISCOVER + } + + static func isMastercard(number: String) -> Bool { + return determineCardNetwork(cardNumber: number) == CardNetwork.MASTERCARD + } + + static func isUnionPay(number: String) -> Bool { + return determineCardNetwork(cardNumber: number) == CardNetwork.UNIONPAY + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/CardUtils/Expiry.swift b/StripeCardScan/StripeCardScan/Source/CardScan/CardUtils/Expiry.swift new file mode 100644 index 00000000..64230d17 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/CardUtils/Expiry.swift @@ -0,0 +1,20 @@ +import Foundation + +struct Expiry: Hashable { + let string: String + let month: UInt + let year: UInt + + static func == (lhs: Expiry, rhs: Expiry) -> Bool { + return lhs.string == rhs.string + } + + func hash(into hasher: inout Hasher) { + self.string.hash(into: &hasher) + } + + func display() -> String { + let twoDigitYear = self.year % 100 + return String(format: "%02d/%02d", self.month, twoDigitYear) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/AppleCreditCardOcr.swift b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/AppleCreditCardOcr.swift new file mode 100644 index 00000000..4ebd1f07 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/AppleCreditCardOcr.swift @@ -0,0 +1,96 @@ +// +// AppleCreditCardOcr.swift +// ocr-playground-ios +// +// Created by Sam King on 3/20/20. +// Copyright © 2020 Sam King. All rights reserved. +// +import UIKit + +class AppleCreditCardOcr: CreditCardOcrImplementation { + override func recognizeCard( + in fullImage: CGImage, + roiRectangle: CGRect + ) -> CreditCardOcrPrediction { + guard let (image, roiForOcr) = fullImage.croppedImageForSsd(roiRectangle: roiRectangle) + else { + return CreditCardOcrPrediction.emptyPrediction(cgImage: fullImage) + } + + var pan: String? + var expiryMonth: String? + var expiryYear: String? + let semaphore = DispatchSemaphore(value: 0) + let startTime = Date() + var name: String? + var nameBox: CGRect? + var numberBox: CGRect? + var expiryBox: CGRect? + var nameCandidates: [OcrObject] = [] + AppleOcr.recognizeText(in: image) { results in + for result in results { + let predictedPan = CreditCardOcrPrediction.pan(result.text) + let expiry = CreditCardOcrPrediction.likelyExpiry(result.text) + if let (month, year) = expiry { + if CreditCardUtils.isValidDate(expMonth: month, expYear: year) { + if expiryMonth == nil { + expiryBox = result.rect + expiryMonth = month + } + if expiryYear == nil { expiryYear = year } + } + } + if pan == nil && predictedPan != nil { + pan = predictedPan + numberBox = result.rect + } + + let predictedName = AppleCreditCardOcr.likelyName(result.text) + if predictedName != nil { + nameCandidates.append(result) + } + } + + let minY = numberBox.map({ $0.minY - $0.height }) ?? expiryBox?.minY + let names = nameCandidates.filter { name in + let isInExpectedLocation = minY.map({ name.rect.minY >= ($0 - 5.0) }) ?? false + return name.confidence >= 0.5 && isInExpectedLocation + } + + // just pick the first one for now + if let nameResult = names.first { + name = AppleCreditCardOcr.likelyName(nameResult.text) + nameBox = nameResult.rect + } + + semaphore.signal() + } + semaphore.wait() + let duration = -startTime.timeIntervalSinceNow + self.computationTime += duration + self.frames += 1 + + return CreditCardOcrPrediction( + image: image, + ocrCroppingRectangle: roiForOcr, + number: pan, + expiryMonth: expiryMonth, + expiryYear: expiryYear, + name: name, + computationTime: duration, + numberBoxes: numberBox.map { [$0] }, + expiryBoxes: expiryBox.map { [$0] }, + nameBoxes: nameBox.map { [$0] } + ) + } + + static func likelyName(_ text: String) -> String? { + let words = text.split(separator: " ").map { String($0) } + let validWords = words.filter { + !NameWords.nonNameWordMatch($0) && NameWords.onlyLettersAndSpaces($0) + } + let validWordCount = validWords.count >= 2 + + return validWordCount ? validWords.joined(separator: " ") : nil + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/CreditCardOcrImplementation.swift b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/CreditCardOcrImplementation.swift new file mode 100644 index 00000000..b63b4c8d --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/CreditCardOcrImplementation.swift @@ -0,0 +1,42 @@ +// +// CreditCardOcr.swift +// ocr-playground-ios +// +// Created by Sam King on 3/19/20. +// Copyright © 2020 Sam King. All rights reserved. +// +import UIKit + +/// Base class for any OCR prediction systems. All implementations must override `recognizeCard` and update the `frames` +/// and `computationTime` member variables + +@_spi(STP) public class CreditCardOcrImplementation { + let dispatchQueue: ActiveStateComputation + var frames = 0 + var computationTime = 0.0 + let startTime = Date() + + var framesPerSecond: Double { + return Double(frames) / -startTime.timeIntervalSinceNow + } + + var mlFramesPerSecond: Double { + return Double(frames) / computationTime + } + + init( + dispatchQueueLabel: String + ) { + self.dispatchQueue = ActiveStateComputation(label: dispatchQueueLabel) + } + + init( + dispatchQueue: ActiveStateComputation + ) { + self.dispatchQueue = dispatchQueue + } + + func recognizeCard(in fullImage: CGImage, roiRectangle: CGRect) -> CreditCardOcrPrediction { + preconditionFailure("This method must be overridden") + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/CreditCardOcrPrediction.swift b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/CreditCardOcrPrediction.swift new file mode 100644 index 00000000..aa36135f --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/CreditCardOcrPrediction.swift @@ -0,0 +1,191 @@ +// +// CreditCardOcrPrediction.swift +// ocr-playground-ios +// +// Created by Sam King on 3/19/20. +// Copyright © 2020 Sam King. All rights reserved. +// + +import CoreGraphics +import Foundation + +struct UxFrameConfidenceValues { + let hasOcr: Bool + let uxPan: Double + let uxNoPan: Double + let uxNoCard: Double + + func toArray() -> [Double] { + return [hasOcr ? 1.0 : 0.0, uxPan, uxNoPan, uxNoCard] + } +} + +enum CenteredCardState { + case numberSide + case nonNumberSide + case noCard + + func hasCard() -> Bool { + return self == .numberSide || self == .nonNumberSide + } +} + +struct CreditCardOcrPrediction { + let image: CGImage + let ocrCroppingRectangle: CGRect + let number: String? + let expiryMonth: String? + let expiryYear: String? + let name: String? + let computationTime: Double + let numberBoxes: [CGRect]? + let expiryBoxes: [CGRect]? + let nameBoxes: [CGRect]? + + // this is only used by Card Verify and the Liveness check and filled in by the UxModel + var centeredCardState: CenteredCardState? + var uxFrameConfidenceValues: UxFrameConfidenceValues? + + init( + image: CGImage, + ocrCroppingRectangle: CGRect, + number: String?, + expiryMonth: String?, + expiryYear: String?, + name: String?, + computationTime: Double, + numberBoxes: [CGRect]?, + expiryBoxes: [CGRect]?, + nameBoxes: [CGRect]?, + centeredCardState: CenteredCardState? = nil, + uxFrameConfidenceValues: UxFrameConfidenceValues? = nil + ) { + + self.image = image + self.ocrCroppingRectangle = ocrCroppingRectangle + self.number = number + self.expiryMonth = expiryMonth + self.expiryYear = expiryYear + self.name = name + self.computationTime = computationTime + self.numberBoxes = numberBoxes + self.expiryBoxes = expiryBoxes + self.nameBoxes = nameBoxes + self.centeredCardState = centeredCardState + self.uxFrameConfidenceValues = uxFrameConfidenceValues + } + + func with(uxPrediction: UxModelOutput) -> CreditCardOcrPrediction { + let uxFrameConfidenceValues: UxFrameConfidenceValues? = { + if let (hasPan, hasNoPan, hasNoCard) = uxPrediction.confidenceValues() { + let hasOcr = self.number != nil + return UxFrameConfidenceValues( + hasOcr: hasOcr, + uxPan: hasPan, + uxNoPan: hasNoPan, + uxNoCard: hasNoCard + ) + } + return nil + }() + + return CreditCardOcrPrediction( + image: self.image, + ocrCroppingRectangle: self.ocrCroppingRectangle, + number: self.number, + expiryMonth: self.expiryMonth, + expiryYear: self.expiryYear, + name: self.name, + computationTime: self.computationTime, + numberBoxes: self.numberBoxes, + expiryBoxes: self.expiryBoxes, + nameBoxes: self.nameBoxes, + centeredCardState: uxPrediction.cardCenteredState(), + uxFrameConfidenceValues: uxFrameConfidenceValues + ) + } + + static func emptyPrediction(cgImage: CGImage) -> CreditCardOcrPrediction { + CreditCardOcrPrediction( + image: cgImage, + ocrCroppingRectangle: CGRect(), + number: nil, + expiryMonth: nil, + expiryYear: nil, + name: nil, + computationTime: 0.0, + numberBoxes: nil, + expiryBoxes: nil, + nameBoxes: nil + ) + } + + var expiryForDisplay: String? { + guard let month = expiryMonth, let year = expiryYear else { return nil } + return "\(month)/\(year)" + } + + var expiryAsUInt: (UInt, UInt)? { + guard let month = expiryMonth.flatMap({ UInt($0) }) else { return nil } + guard let year = expiryYear.flatMap({ UInt($0) }) else { return nil } + + return (month, year) + } + + var numberBox: CGRect? { + let xmin = numberBoxes?.map { $0.minX }.min() ?? 0.0 + let xmax = numberBoxes?.map { $0.maxX }.max() ?? 0.0 + let ymin = numberBoxes?.map { $0.minY }.min() ?? 0.0 + let ymax = numberBoxes?.map { $0.maxY }.max() ?? 0.0 + return CGRect(x: xmin, y: ymin, width: (xmax - xmin), height: (ymax - ymin)) + } + + var expiryBox: CGRect? { + return expiryBoxes.flatMap { $0.first } + } + + var numberBoxesInFullImageFrame: [CGRect]? { + guard let boxes = numberBoxes else { return nil } + let cropOrigin = ocrCroppingRectangle.origin + return boxes.map { + CGRect( + x: $0.origin.x + cropOrigin.x, + y: $0.origin.y + cropOrigin.y, + width: $0.size.width, + height: $0.size.height + ) + } + } + + static func likelyExpiry(_ string: String) -> (String, String)? { + guard let regex = try? NSRegularExpression(pattern: "^.*(0[1-9]|1[0-2])[./]([1-2][0-9])$") + else { + return nil + } + + let result = regex.matches(in: string, range: NSRange(string.startIndex..., in: string)) + + if result.count == 0 { + return nil + } + + guard let nsrange1 = result.first?.range(at: 1), + let range1 = Range(nsrange1, in: string) + else { return nil } + guard let nsrange2 = result.first?.range(at: 2), + let range2 = Range(nsrange2, in: string) + else { return nil } + + return (String(string[range1]), String(string[range2])) + } + + static func pan(_ text: String) -> String? { + let digitsAndSpace = text.reduce(true) { $0 && (($1 >= "0" && $1 <= "9") || $1 == " ") } + let number = text.compactMap { $0 >= "0" && $0 <= "9" ? $0 : nil }.map { String($0) } + .joined() + + guard digitsAndSpace else { return nil } + guard CreditCardUtils.isValidNumber(cardNumber: number) else { return nil } + return number + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/CreditCardOcrResult.swift b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/CreditCardOcrResult.swift new file mode 100644 index 00000000..ce2efb00 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/CreditCardOcrResult.swift @@ -0,0 +1,62 @@ +// +// CreditCardOcrResult.swift +// ocr-playground-ios +// +// Created by Sam King on 3/20/20. +// Copyright © 2020 Sam King. All rights reserved. +// + +import Foundation + +class CreditCardOcrResult: MachineLearningResult { + let mostRecentPrediction: CreditCardOcrPrediction + let number: String + let expiry: String? + let name: String? + let state: MainLoopState + + // this is only used by Card Verify and the Liveness check and filled in by the UxModel + var hasCenteredCard: CenteredCardState? + + init( + mostRecentPrediction: CreditCardOcrPrediction, + number: String, + expiry: String?, + name: String?, + state: MainLoopState, + duration: Double, + frames: Int + ) { + self.mostRecentPrediction = mostRecentPrediction + self.number = number + self.expiry = expiry + self.name = name + self.state = state + super.init(duration: duration, frames: frames) + } + + var expiryMonth: String? { + return expiry.flatMap { $0.split(separator: "/").first.map { String($0) } } + } + var expiryYear: String? { + return expiry.flatMap { $0.split(separator: "/").last.map { String($0) } } + } + + static func finishedWithNonNumberSideCard( + prediction: CreditCardOcrPrediction, + duration: Double, + frames: Int + ) -> CreditCardOcrResult { + let result = CreditCardOcrResult( + mostRecentPrediction: prediction, + number: "", + expiry: nil, + name: nil, + state: .finished, + duration: duration, + frames: frames + ) + result.hasCenteredCard = .nonNumberSide + return result + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/ErrorCorrection.swift b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/ErrorCorrection.swift new file mode 100644 index 00000000..f530cd94 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/ErrorCorrection.swift @@ -0,0 +1,95 @@ +import Foundation + +class ErrorCorrection { + let stateMachine: MainLoopStateMachine + var frames = 0 + var numbers: [String: Int] = [:] + var expiries: [String: Int] = [:] + var names: [String: Int] = [:] + let startTime = Date() + var mostRecentPrediction: CreditCardOcrPrediction? + + var framesPerSecond: Double { + return Double(frames) / -startTime.timeIntervalSinceNow + } + + init( + stateMachine: MainLoopStateMachine + ) { + self.stateMachine = stateMachine + } + + var number: String? { + return self.numbers.sorted { $0.1 > $1.1 }.map { $0.0 }.first + } + + func result() -> CreditCardOcrResult? { + guard stateMachine.loopState() != .initial else { return nil } + let predictedNumber = self.numbers.sorted { $0.1 > $1.1 }.map { $0.0 }.first + let predictedExpiry = self.expiries.sorted { $0.1 > $1.1 }.map { $0.0 }.first + let predictedName = self.names.sorted { $0.1 > $1.1 }.map { $0.0 }.first + guard let prediction = self.mostRecentPrediction else { return nil } + + guard let number = predictedNumber else { + // TODO(stk): this is a hack to deal with the case where we are finished + // but don't have a OCR (e.g., non-number side for liveness) + if stateMachine.loopState() == .finished { + return CreditCardOcrResult.finishedWithNonNumberSideCard( + prediction: prediction, + duration: -startTime.timeIntervalSinceNow, + frames: frames + ) + } + + if stateMachine.loopState() == .ocrIncorrect, let number = prediction.number { + return CreditCardOcrResult( + mostRecentPrediction: prediction, + number: number, + expiry: prediction.expiryForDisplay, + name: prediction.name, + state: stateMachine.loopState(), + duration: -startTime.timeIntervalSinceNow, + frames: frames + ) + } + + return nil + } + + return CreditCardOcrResult( + mostRecentPrediction: prediction, + number: number, + expiry: predictedExpiry, + name: predictedName, + state: stateMachine.loopState(), + duration: -startTime.timeIntervalSinceNow, + frames: frames + ) + } + + func add(prediction: CreditCardOcrPrediction) -> CreditCardOcrResult? { + self.frames += 1 + + let newState = stateMachine.event(prediction: prediction) + + if newState != .ocrIncorrect { + if let pan = prediction.number { + self.numbers[pan] = (self.numbers[pan] ?? 0) + 1 + } + if let expiry = prediction.expiryForDisplay { + self.expiries[expiry] = (self.expiries[expiry] ?? 0) + 1 + } + for name in prediction.name?.split(separator: "\n").map({ String($0) }) ?? [] { + self.names[name] = (self.names[name] ?? 0) + 1 + } + } + + self.mostRecentPrediction = prediction + + return result() + } + + func reset() -> ErrorCorrection { + return ErrorCorrection(stateMachine: stateMachine.reset()) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/MachineLearningResult.swift b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/MachineLearningResult.swift new file mode 100644 index 00000000..a65b9543 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/MachineLearningResult.swift @@ -0,0 +1,24 @@ +// +// MachineLearningResult.swift +// CardScan +// +// Created by Sam King on 4/30/20. +// + +import Foundation + +class MachineLearningResult { + let duration: Double + let frames: Int + var framePerSecond: Double { + return Double(frames) / duration + } + + init( + duration: Double, + frames: Int + ) { + self.duration = duration + self.frames = frames + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/MainLoopStateMachine.swift b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/MainLoopStateMachine.swift new file mode 100644 index 00000000..eab6c5b9 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/MainLoopStateMachine.swift @@ -0,0 +1,119 @@ +// +// MainLoopStateMachine.swift +// CardScan +// +// Created by Sam King on 8/5/20. +// + +import Foundation + +enum MainLoopState: Equatable { + case initial + case ocrOnly + case cardOnly + case ocrAndCard + case ocrIncorrect + case ocrDelayForCard + case ocrForceFlash + case finished + case nameAndExpiry +} + +protocol MainLoopStateMachine { + func loopState() -> MainLoopState + func event(prediction: CreditCardOcrPrediction) -> MainLoopState + func reset() -> MainLoopStateMachine +} + +// Note: This class is _not_ thread safe, it relies on syncrhonization +// from the `OcrMainLoop` +class OcrMainLoopStateMachine: NSObject, MainLoopStateMachine { + var state: MainLoopState = .initial + var startTimeForCurrentState = Date() + let errorCorrectionDurationSeconds = 2.0 + + override init() {} + + func loopState() -> MainLoopState { + return state + } + + func event(prediction: CreditCardOcrPrediction) -> MainLoopState { + let newState = transition(prediction: prediction) + if let newState = newState { + startTimeForCurrentState = Date() + state = newState + } + + return newState ?? state + } + + func transition(prediction: CreditCardOcrPrediction) -> MainLoopState? { + let timeInCurrentStateSeconds = -startTimeForCurrentState.timeIntervalSinceNow + let frameHasOcr = prediction.number != nil + + switch (state, timeInCurrentStateSeconds, frameHasOcr) { + case (.initial, _, true): + return .ocrOnly + case (.ocrOnly, errorCorrectionDurationSeconds..., _): + return .finished + default: + // no state transitions + return nil + } + } + + func reset() -> MainLoopStateMachine { + return OcrMainLoopStateMachine() + } +} + +class OcrAccurateMainLoopStateMachine: NSObject, MainLoopStateMachine { + var state: MainLoopState = .initial + var startTimeForCurrentState = Date() + var hasExpiryPrediction = false + + let minimumErrorCorrection = 2.0 + var maximumErrorCorrection = 4.0 + + func loopState() -> MainLoopState { + return state + } + + override init() {} + + init( + maxErrorCorrection: Double + ) { + self.maximumErrorCorrection = maxErrorCorrection + } + + func event(prediction: CreditCardOcrPrediction) -> MainLoopState { + let newState = transition(prediction: prediction) + if let newState = newState { + startTimeForCurrentState = Date() + state = newState + } + return newState ?? state + } + + func transition(prediction: CreditCardOcrPrediction) -> MainLoopState? { + let timeInCurrentStateSeconds = -startTimeForCurrentState.timeIntervalSinceNow + let frameHasOcr = prediction.number != nil + hasExpiryPrediction = hasExpiryPrediction || prediction.expiryForDisplay != nil + switch (state, timeInCurrentStateSeconds, frameHasOcr, hasExpiryPrediction) { + case (.initial, _, true, _): + return .ocrOnly + case (.ocrOnly, minimumErrorCorrection..., _, true): + return .finished + case (.ocrOnly, maximumErrorCorrection..., _, false): + return .finished + default: + // no state transitions + return nil + } + } + func reset() -> MainLoopStateMachine { + return OcrAccurateMainLoopStateMachine(maxErrorCorrection: maximumErrorCorrection) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/NonNameWords.swift b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/NonNameWords.swift new file mode 100644 index 00000000..3e222a12 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/NonNameWords.swift @@ -0,0 +1,43 @@ +// +// NonNameWords.swift +// ocr-playground-ios +// +// Created by Sam King on 3/23/20. +// Copyright © 2020 Sam King. All rights reserved. +// + +import Foundation + +struct NameWords { + static let blacklist: Set = [ + "customer", "debit", "visa", "mastercard", "navy", "american", "express", "thru", "good", + "authorized", "signature", "wells", "navy", "credit", "federal", + "union", "bank", "valid", "validfrom", "validthru", "llc", "business", "netspend", + "goodthru", "chase", "fargo", "hsbc", "usaa", "chaseo", "commerce", + "last", "of", "lastdayof", "check", "card", "inc", "first", "member", "since", + "american", "express", "republic", "bmo", "capital", "one", "capitalone", "platinum", + "expiry", "date", "expiration", "cash", "back", "td", "access", "international", "interac", + "nterac", "entreprise", "business", "md", "enterprise", "fifth", "third", "fifththird", + "world", "rewards", "citi", "member", "cardmember", "cardholder", "valued", "since", + "membersince", "cardmembersince", "cardholdersince", "freedom", "quicksilver", "penfed", + "use", "this", "card", "is", "subject", "to", "the", "inc", "not", "transferable", "gto", + "mgy", "sign", + ] + + static func nonNameWordMatch(_ text: String) -> Bool { + let lowerCase = text.lowercased() + return blacklist.contains(lowerCase) + } + + static func onlyLettersAndSpaces(_ text: String) -> Bool { + let lettersAndSpace = text.reduce(true) { acc, value in + let capitalLetter = value >= "A" && value <= "Z" + // for now we're only going to accept upper case names + // let lowerCaseLetter = value >= "a" && value <= "z" + let space = value == " " + return acc && (capitalLetter || space) + } + + return lettersAndSpace + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/OcrMainLoop.swift b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/OcrMainLoop.swift new file mode 100644 index 00000000..e18f2d16 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/OcrMainLoop.swift @@ -0,0 +1,311 @@ +/// This is the main loop for OCR. It runs one of our OCR systems in paralell with the Apple OCR system and +/// combines results. From a high level this implements a standard producer-consumer where the main +/// system will push images and ROI rectangles into the main loop and two Analyzers, or OCR systems +/// will consume the images. +/// +/// The producer, which pushes images will keep N (2 currently) images in the queue and when a new image +/// comes in it will remove old images leaving the N most recent images. That way we can try to get more +/// diversity in images by virtue of maximizing the time in between images that it reads. +/// +/// The consumers pull images from the queue and run the full OCR algorithm, including expiry extraction and +/// full error correction on the combined results. +/// +/// In terms of iOS abstractions, we make heavy use of dispatch queues. We have a single `mutexQueue` +/// that we use to mutate our shared state. This queue is a serial queue and our method for synchronizing +/// access. One thing to be careful with is we use `sync` in places to access our `mutexQueue`. This +/// method can lead to deadlock if you aren't careful. +/// +/// # Correcness criteria +/// We make heavy use of dispatch queues for paralellism, so it's important to be disciplined about how +/// we access shared state +/// +/// ## Shared state +/// All shared state updates need to happeon on the `mutexQueue` except for `machineLearningQueue`, +/// which we set at the constructor and access it read only. +/// +/// ## Delegate invocation +/// All invocations of delegate methods need to happen on the main queue, and for each prediction there +/// are one or more methods that may get called in order: +/// - `prediction` this happens on all predictions +/// - if the scan predicts a number, then `showCardDetails` happens with the current overall predicted number, expiry, and name +/// - if the scan is complete, then `complete` includes the final result +/// +/// To finalize results, we clear out the `mainLoopDelegate` after it's done +/// +/// It's important that we not update `scanStats` after complete is called or call any futher delegate functions, although +/// more predictions might come through after the fact +/// +/// We also expose `shouldUsePrediction` that delegates can implement to discard a prediction, but note that the `prediction` +/// method still fires even when this returns false. Note: `shouldUsePrediction` is called from the `mutexQueue` so handlers +/// don't need to synchronize but they may need to handle any computation that needs to happen on the main loop appropriately. +/// +/// ## userCancelled +/// One aspect to be careful with when someone invokes the `userCancelled` method is that there could be a race with OCR and it +/// could complete OCR in parallel with this call. The net result we want is if a caller calls this method we don't subsequenty fire any of +/// the `OcrMainLoopDelegate` methods and we want to make sure that `scanStats.success` is always `false` to correctly +/// denote that this scan failed. +/// +/// To handle this correctly we: +/// - use the `userDidCancel` variable here and in any of our blocks that run on the main dispatch queue. Since this call should +/// come from the main dispatch queue, those calls, where we invoke the callback methods, will run after this one and we prevent firing +/// their delegate methods. +/// - the logic to set `scanStats.success` will come on the `muxtexQueue`, but could execute either before or after this block runs. +/// - If it's before then this block will overwrite the `success` results with the unsuccessful result here. If the +/// - If it runs after, there is a check and it sets `scanStats.success` iff it isn't already set +/// - we use `sync` on the `muxtexQueue` to make sure that when this method returns any subsequent calls to `scanStats` are +/// always `success = false` +/// +/// ## backgrounding +/// We track when the app is in the active state and stop accepting images when it's inactive. When it becomes active we reset +/// the `errorCorrection` state before re-enabling computation. +/// +/// This backgrounding logic is less about correctness and more about making sure that the SDK doesn't send the caller predictions +/// at unexpected times by making sure that the app is active if and when it sends notifications of success. + +import UIKit + +protocol OcrMainLoopDelegate: AnyObject { + func complete(creditCardOcrResult: CreditCardOcrResult) + func prediction( + prediction: CreditCardOcrPrediction, + imageData: ScannedCardImageData, + state: MainLoopState + ) + func showCardDetails(number: String?, expiry: String?, name: String?) + func showCardDetailsWithFlash(number: String?, expiry: String?, name: String?) + func showWrongCard(number: String?, expiry: String?, name: String?) + func showNoCard() + func shouldUsePrediction( + errorCorrectedNumber: String?, + prediction: CreditCardOcrPrediction + ) -> Bool +} + +protocol MachineLearningLoop: AnyObject { + func push(imageData: ScannedCardImageData) +} + +class OcrMainLoop: MachineLearningLoop { + enum AnalyzerType { + case apple + case ssd + } + + var scanStats = ScanStats() + + weak var mainLoopDelegate: OcrMainLoopDelegate? + var errorCorrection = ErrorCorrection(stateMachine: OcrMainLoopStateMachine()) + var imageQueue: [ScannedCardImageData] = [] + var imageQueueSize = 2 + var analyzerQueue: [CreditCardOcrImplementation] = [] + let mutexQueue = DispatchQueue(label: "OcrMainLoopMutex") + var inBackground = false + var machineLearningQueues: [DispatchQueue] = [] + var userDidCancel = false + + init( + analyzers: [AnalyzerType] = [.ssd, .apple] + ) { + var ocrImplementations: [CreditCardOcrImplementation] = [] + for analyzer in analyzers { + let queueLabel = "\(analyzer) OCR ML" + switch analyzer { + case .ssd: + ocrImplementations.append(SSDCreditCardOcr(dispatchQueueLabel: queueLabel)) + case .apple: + ocrImplementations.append(AppleCreditCardOcr(dispatchQueueLabel: queueLabel)) + } + } + setupMl(ocrImplementations: ocrImplementations) + } + + /// Note: you must call this function in your constructor + func setupMl(ocrImplementations: [CreditCardOcrImplementation]) { + scanStats.model = "ssd+apple" + for ocrImplementation in ocrImplementations { + analyzerQueue.append(ocrImplementation) + } + registerAppNotifications() + } + + func reset() { + mutexQueue.async { + self.errorCorrection = self.errorCorrection.reset() + } + } + + static func warmUp() { + // TODO(stk): Implement this later + } + + // see the Correctness Criteria note in the comments above for why this is correct + // Make sure you call this from the main dispatch queue + func userCancelled() { + userDidCancel = true + mutexQueue.sync { [weak self] in + guard let self = self else { return } + self.scanStats.userCanceled = userDidCancel + if self.scanStats.success == nil { + self.scanStats.success = false + self.scanStats.endTime = Date() + self.mainLoopDelegate = nil + } + } + } + + func push(imageData: ScannedCardImageData) { + mutexQueue.sync { + guard !inBackground else { return } + // only keep the latest images + imageQueue.insert(imageData, at: 0) + while imageQueue.count > imageQueueSize { + _ = imageQueue.popLast() + } + + // if we have any analyzers waiting, fire them off now + guard let ocr = analyzerQueue.popLast() else { return } + analyzer(ocr: ocr) + } + } + + func postAnalyzerToQueueAndRun(ocr: CreditCardOcrImplementation) { + mutexQueue.async { [weak self] in + guard let self = self else { return } + self.analyzerQueue.insert(ocr, at: 0) + // only kick off the next analyzer if there is an image in the queue + if self.imageQueue.count > 0 { + guard let ocr = self.analyzerQueue.popLast() else { return } + self.analyzer(ocr: ocr) + } + } + } + + func analyzer(ocr: CreditCardOcrImplementation) { + ocr.dispatchQueue.async { [weak self] in + var scannedCardImageData: ScannedCardImageData? + + // grab an image and roi from the image queue. If the image queue is empty then add ourselves + // back to the analyzer queue + self?.mutexQueue.sync { + guard !(self?.inBackground ?? false) else { + self?.analyzerQueue.insert(ocr, at: 0) + return + } + guard let imageDataFromQueue = self?.imageQueue.popLast() else { + self?.analyzerQueue.insert(ocr, at: 0) + return + } + scannedCardImageData = imageDataFromQueue + } + + guard let imageData = scannedCardImageData else { return } + + // run our ML model, add ourselves back to the analyzer queue unless we have a result + // and the result is finished + let prediction = ocr.recognizeCard( + in: imageData.previewLayerImage, + roiRectangle: imageData.previewLayerViewfinderRect + ) + self?.mutexQueue.async { + guard let self = self else { return } + self.scanStats.scans += 1 + let delegate = self.mainLoopDelegate + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + guard !self.userDidCancel else { return } + delegate?.prediction( + prediction: prediction, + imageData: imageData, + state: self.errorCorrection.stateMachine.loopState() + ) + } + guard let result = self.combine(prediction: prediction), result.state == .finished + else { + self.postAnalyzerToQueueAndRun(ocr: ocr) + return + } + } + } + } + + func combine(prediction: CreditCardOcrPrediction) -> CreditCardOcrResult? { + guard + mainLoopDelegate?.shouldUsePrediction( + errorCorrectedNumber: errorCorrection.number, + prediction: prediction + ) ?? true + else { return nil } + guard let result = errorCorrection.add(prediction: prediction) else { return nil } + let delegate = mainLoopDelegate + if result.state == .finished && scanStats.success == nil { + scanStats.success = true + scanStats.endTime = Date() + mainLoopDelegate = nil + } + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + guard !self.userDidCancel else { return } + switch result.state { + case MainLoopState.initial, MainLoopState.cardOnly: + delegate?.showNoCard() + case MainLoopState.ocrIncorrect: + delegate?.showWrongCard( + number: result.number, + expiry: result.expiry, + name: result.name + ) + case MainLoopState.ocrOnly, MainLoopState.ocrAndCard, MainLoopState.ocrDelayForCard: + delegate?.showCardDetails( + number: result.number, + expiry: result.expiry, + name: result.name + ) + case .ocrForceFlash: + delegate?.showCardDetailsWithFlash( + number: result.number, + expiry: result.expiry, + name: result.name + ) + case MainLoopState.finished: + delegate?.complete(creditCardOcrResult: result) + case MainLoopState.nameAndExpiry: + break + } + } + return result + } + + // MARK: - backrounding logic + @objc func willResignActive() { + // make sure that no new images get pushed to our image buffer + // and we clear out the image buffer + mutexQueue.sync { + self.inBackground = true + self.imageQueue = [] + } + } + + @objc func didBecomeActive() { + mutexQueue.sync { + self.inBackground = false + self.errorCorrection = self.errorCorrection.reset() + } + } + + func registerAppNotifications() { + // We don't need to unregister these functions because the system will clean + // them up for us + NotificationCenter.default.addObserver( + self, + selector: #selector(self.willResignActive), + name: UIApplication.willResignActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.didBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/OcrObject.swift b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/OcrObject.swift new file mode 100644 index 00000000..9313dc62 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/OcrObject.swift @@ -0,0 +1,32 @@ +import UIKit + +struct OcrObject { + let rect: CGRect + let text: String + let confidence: Float + let imageSize: CGSize + + init( + text: String, + conf: Float, + textBox: CGRect, + imageSize: CGSize + ) { + self.text = text + self.confidence = conf + self.rect = textBox + self.imageSize = imageSize + } + + func toDict() -> [String: Any] { + return [ + "x_min": self.rect.minX / self.imageSize.width, + "y_min": self.rect.minY / self.imageSize.height, + "height": self.rect.height / self.imageSize.height, + "width": self.rect.width / self.imageSize.width, + "text": self.text, + "confidence": self.confidence, + ] + } + +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/SSDCreditCardOcr.swift b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/SSDCreditCardOcr.swift new file mode 100644 index 00000000..1f712e90 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/CreditCardOcr/SSDCreditCardOcr.swift @@ -0,0 +1,50 @@ +// +// SSDCreditCardOcr.swift +// CardScan +// +// Created by xaen on 5/15/20. +// +import UIKit + +class SSDCreditCardOcr: CreditCardOcrImplementation { + let ocr: OcrDD + + override init( + dispatchQueueLabel: String + ) { + ocr = OcrDD() + super.init(dispatchQueueLabel: dispatchQueueLabel) + } + + override func recognizeCard( + in fullImage: CGImage, + roiRectangle: CGRect + ) -> CreditCardOcrPrediction { + + guard + let (image, ocrRoiRectangle) = fullImage.croppedImageForSsd(roiRectangle: roiRectangle) + else { + return CreditCardOcrPrediction.emptyPrediction(cgImage: fullImage) + } + + let startTime = Date() + let number = ocr.perform(croppedCardImage: image) + let duration = -startTime.timeIntervalSinceNow + let numberBoxes = ocr.lastDetectedBoxes + + self.computationTime += duration + self.frames += 1 + return CreditCardOcrPrediction( + image: image, + ocrCroppingRectangle: ocrRoiRectangle, + number: number, + expiryMonth: nil, + expiryYear: nil, + name: nil, + computationTime: duration, + numberBoxes: numberBoxes, + expiryBoxes: nil, + nameBoxes: nil + ) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/Extensions/Array+utils.swift b/StripeCardScan/StripeCardScan/Source/CardScan/Extensions/Array+utils.swift new file mode 100644 index 00000000..93572b0a --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/Extensions/Array+utils.swift @@ -0,0 +1,16 @@ +// +// Array+utils.swift +// CardScan +// +// Created by Jaime Park on 6/11/21. +// + +import Foundation + +extension Array { + func chunked(into size: Int) -> [[Element]] { + return stride(from: 0, to: count, by: size).map { + Array(self[$0.. Float { + let areaCurrent = self.width * self.height + if areaCurrent <= 0 { + return 0 + } + + let areaNext = nextBox.width * nextBox.height + if areaNext <= 0 { + return 0 + } + + let intersectionMinX = max(self.minX, nextBox.minX) + let intersectionMinY = max(self.minY, nextBox.minY) + let intersectionMaxX = min(self.maxX, nextBox.maxX) + let intersectionMaxY = min(self.maxY, nextBox.maxY) + let intersectionArea = + max(intersectionMaxY - intersectionMinY, 0) + * max(intersectionMaxX - intersectionMinX, 0) + return Float(intersectionArea / (areaCurrent + areaNext - intersectionArea)) + } + +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/Extensions/CGrect+utils.swift b/StripeCardScan/StripeCardScan/Source/CardScan/Extensions/CGrect+utils.swift new file mode 100644 index 00000000..7609d957 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/Extensions/CGrect+utils.swift @@ -0,0 +1,18 @@ +// +// CGrect+utils.swift +// CardScan +// +// Created by Jaime Park on 6/11/21. +// + +import CoreGraphics + +extension CGRect { + func centerY() -> CGFloat { + return (minY / 2 + maxY / 2) + } + + func centerX() -> CGFloat { + return (minX / 2 + maxX / 2) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/Extensions/CreditCardOcrPrediction+expiry.swift b/StripeCardScan/StripeCardScan/Source/CardScan/Extensions/CreditCardOcrPrediction+expiry.swift new file mode 100644 index 00000000..2c0de5df --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/Extensions/CreditCardOcrPrediction+expiry.swift @@ -0,0 +1,14 @@ +import Foundation + +extension CreditCardOcrPrediction { + func expiryObject() -> Expiry? { + if let month = self.expiryMonth.flatMap({ UInt($0) }), + let year = self.expiryYear.flatMap({ UInt($0) }), + let expiryString = self.expiryForDisplay + { + return Expiry(string: expiryString, month: month, year: year) + } else { + return nil + } + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/Extensions/Image+utils.swift b/StripeCardScan/StripeCardScan/Source/CardScan/Extensions/Image+utils.swift new file mode 100644 index 00000000..bef9101d --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/Extensions/Image+utils.swift @@ -0,0 +1,270 @@ +// +// Image+utils.swift +// StripeCardScan +// +// Created by Sam King on 11/08/21. +// + +import CoreGraphics +import UIKit +import VideoToolbox + +extension CGSize { + func scaledAndCentered(centerIn: CGRect) -> CGRect { + let targetWidth = centerIn.width + let targetHeight = centerIn.height + + let scale = min(targetWidth / self.width, targetHeight / self.height) + + let scaledWidth = self.width * scale + let scaledHeight = self.height * scale + + let x = (targetWidth - scaledWidth) / 2.0 + let y = (targetHeight - scaledHeight) / 2.0 + + return CGRect(x: x, y: y, width: scaledWidth, height: scaledHeight) + } +} + +extension UIImage { + static func grayImage(size: CGSize) -> UIImage? { + UIGraphicsBeginImageContext(size) + UIColor.gray.setFill() + UIRectFill(CGRect(x: 0, y: 0, width: size.width, height: size.height)) + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return newImage + } +} + +extension CGImage { + + func extendedEdges(targetSize: CGSize) -> CGImage? { + var result: CGImage? + + let size = CGSize(width: self.width, height: self.height) + let targetRect = CGRect(x: 0, y: 0, width: targetSize.width, height: targetSize.height) + let centeredRect = size.scaledAndCentered(centerIn: targetRect) + + if let context = CGContext( + data: nil, + width: Int(targetRect.width), + height: Int(targetRect.height), + bitsPerComponent: self.bitsPerComponent, + bytesPerRow: self.bytesPerRow, + space: self.colorSpace!, + bitmapInfo: self.bitmapInfo.rawValue + ) { + + context.setFillColor(UIColor.white.cgColor) + context.fill([targetRect]) + + context.draw(self, in: centeredRect) + + result = context.makeImage() + } + + return result + } + + // Crop a full image + func croppedImageForSsd(roiRectangle: CGRect) -> (CGImage, CGRect)? { + + // add 10% to our ROI rectangle + let centerX = roiRectangle.origin.x + roiRectangle.size.width * 0.5 + let centerY = roiRectangle.origin.y + roiRectangle.size.height * 0.5 + + let width = + (roiRectangle.size.width * 1.1) < roiRectangle.size.width + ? (roiRectangle.size.width * 1.1) : roiRectangle.size.width + let height = 375.0 * width / 600.0 + let x = centerX - width * 0.5 + let y = centerY - height * 0.5 + + let ssdRoiRectangle = CGRect(x: x, y: y, width: width, height: height) + + if let image = self.cropping(to: ssdRoiRectangle) { + return (image, ssdRoiRectangle) + } else if let image = self.cropping(to: roiRectangle) { + // fall back if the crop was out of bounds + return (image, roiRectangle) + } + + return nil + } + + // crop a full image + func squareImageForUxModel(roiRectangle: CGRect) -> CGImage? { + // add 10% to our ROI rectangle and make it square centered at the ROI rectangle + let deltaX = roiRectangle.size.width * 0.1 + let deltaY = roiRectangle.size.width + deltaX - roiRectangle.height + + let roiPlusBuffer = CGRect( + x: roiRectangle.origin.x - deltaX * 0.5, + y: roiRectangle.origin.y - deltaY * 0.5, + width: roiRectangle.size.width + deltaX, + height: roiRectangle.size.height + deltaY + ) + + // if the expanded roi rectangle is too big, fall back to the tight roi rectangle + return self.cropping(to: roiPlusBuffer) ?? self.cropping(to: roiRectangle) + } + + // This cropping is used by the object detector + func squareCardImage(roiRectangle: CGRect) -> CGImage? { + let width = CGFloat(self.width) + let height = width + let centerY = (roiRectangle.maxY + roiRectangle.minY) * 0.5 + let cropRectangle = CGRect( + x: 0.0, + y: centerY - height * 0.5, + width: width, + height: height + ) + return self.cropping(to: cropRectangle) + } + + func drawBoundingBoxesOnImage(boxes: [(UIColor, CGRect)]) -> UIImage? { + let image = UIImage(cgImage: self) + let imageSize = image.size + let scale: CGFloat = 0 + UIGraphicsBeginImageContextWithOptions(imageSize, false, scale) + + image.draw(at: CGPoint(x: 0, y: 0)) + + UIGraphicsGetCurrentContext()?.setLineWidth(3.0) + + for (color, box) in boxes { + color.setStroke() + UIRectFrame(box) + } + + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return newImage + } + + func drawGrayToFillFullScreen(croppedImage: CGImage, targetSize: CGSize) -> CGImage? { + let image = UIImage(cgImage: croppedImage) + + UIGraphicsBeginImageContext(targetSize) + // Make whole image grey + UIColor.gray.setFill() + UIRectFill(CGRect(x: 0, y: 0, width: targetSize.width, height: targetSize.height)) + // Put in image in the center + image.draw( + in: CGRect( + x: 0.0, + y: (CGFloat(targetSize.height) - CGFloat(croppedImage.height)) / 2.0, + width: CGFloat(croppedImage.width), + height: CGFloat(croppedImage.height) + ) + ) + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return newImage?.cgImage + } + + func toFullScreenAndRoi( + previewViewFrame: CGRect, + regionOfInterestLabelFrame: CGRect + ) -> (CGImage, CGRect)? { + let imageCenterX = CGFloat(self.width) / 2.0 + let imageCenterY = CGFloat(self.height) / 2.0 + + let imageAspectRatio = CGFloat(self.height) / CGFloat(self.width) + let previewViewAspectRatio = previewViewFrame.height / previewViewFrame.width + + let pointsToPixel: CGFloat = + imageAspectRatio > previewViewAspectRatio + ? CGFloat(self.width) / previewViewFrame.width + : CGFloat(self.height) / previewViewFrame.height + + let cropRatio = CGFloat(16.0) / CGFloat(9.0) + var fullScreenCropHeight: CGFloat = CGFloat(self.height) + var fullScreenCropWidth: CGFloat = CGFloat(self.width) + + let previewViewHeight = previewViewFrame.height * pointsToPixel + let previewViewWidth = previewViewFrame.width * pointsToPixel + + // Get ratio to convert points to pixels + let fullScreenImage: CGImage? = { + // if image is already 16:9, no need to crop to match crop ratio + /// TODO(jaimepark): make sure this works + if abs(cropRatio - imageAspectRatio) < 0.0001 { + return self + } + + // imageAspectRatio not being 16:9 implies image being in landscape + // get width to first not cut out any card information + fullScreenCropWidth = previewViewFrame.width * pointsToPixel + fullScreenCropHeight = fullScreenCropWidth * (16.0 / 9.0) + let imageHeight = CGFloat(self.height) + + // If 16:9 crop height is larger than the image height itself (i.e. custom formsheet size height is much shorter than the width), crop the image with full height and add grey boxes + if fullScreenCropHeight > imageHeight { + guard + let croppedImage = self.cropping( + to: CGRect( + x: imageCenterX - fullScreenCropWidth / 2.0, + y: imageCenterY - imageHeight / 2.0, + width: fullScreenCropWidth, + height: imageHeight + ) + ) + else { return nil } + return self.drawGrayToFillFullScreen( + croppedImage: croppedImage, + targetSize: CGSize(width: fullScreenCropWidth, height: fullScreenCropHeight) + ) + } + + return self.cropping( + to: CGRect( + x: imageCenterX - fullScreenCropWidth / 2.0, + y: imageCenterY - fullScreenCropHeight / 2.0, + width: fullScreenCropWidth, + height: fullScreenCropHeight + ) + ) + }() + + let roiRect: CGRect? = { + let roiWidth = regionOfInterestLabelFrame.width * pointsToPixel + let roiHeight = regionOfInterestLabelFrame.height * pointsToPixel + + var roiCenterX = roiWidth / 2.0 + regionOfInterestLabelFrame.origin.x * pointsToPixel + var roiCenterY = roiHeight / 2.0 + regionOfInterestLabelFrame.origin.y * pointsToPixel + + if fullScreenCropHeight > previewViewHeight { + roiCenterY += (fullScreenCropHeight - previewViewHeight) / 2.0 + } + if fullScreenCropWidth > previewViewWidth { + roiCenterX += (fullScreenCropWidth - previewViewWidth) / 2.0 + } + + return CGRect( + x: roiCenterX - roiWidth / 2.0, + y: roiCenterY - roiHeight / 2.0, + width: roiWidth, + height: roiHeight + ) + }() + + guard let regionOfInterestRect = roiRect, let fullScreenCgImage = fullScreenImage else { + return nil + } + return (fullScreenCgImage, regionOfInterestRect) + } +} + +extension CVPixelBuffer { + func cgImage() -> CGImage? { + var cgImage: CGImage? + VTCreateCGImageFromCVPixelBuffer(self, options: nil, imageOut: &cgImage) + + return cgImage + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/Extensions/UIImage+pixelBuffer.swift b/StripeCardScan/StripeCardScan/Source/CardScan/Extensions/UIImage+pixelBuffer.swift new file mode 100644 index 00000000..959c22d0 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/Extensions/UIImage+pixelBuffer.swift @@ -0,0 +1,248 @@ +// Copyright (c) 2017 M.I. Hollemans +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// https://github.com/hollance/CoreMLHelpers + +import UIKit +import VideoToolbox + +extension UIImage { + /// Resizes the image to width x height and converts it to an RGB CVPixelBuffer. + func pixelBuffer(width: Int, height: Int) -> CVPixelBuffer? { + return pixelBuffer( + width: width, + height: height, + pixelFormatType: kCVPixelFormatType_32ARGB, + colorSpace: CGColorSpaceCreateDeviceRGB(), + alphaInfo: .noneSkipFirst + ) + } + + /// Resizes the image to width x height and converts it to a grayscale CVPixelBuffer. + func pixelBufferGray(width: Int, height: Int) -> CVPixelBuffer? { + return pixelBuffer( + width: width, + height: height, + pixelFormatType: kCVPixelFormatType_OneComponent8, + colorSpace: CGColorSpaceCreateDeviceGray(), + alphaInfo: .none + ) + } + + /// Convert to pixel buffer without resizing + func pixelBufferGray() -> CVPixelBuffer? { + return pixelBufferGray(width: Int(self.size.width), height: Int(self.size.height)) + } + + func pixelBuffer( + width: Int, + height: Int, + pixelFormatType: OSType, + colorSpace: CGColorSpace, + alphaInfo: CGImageAlphaInfo + ) -> CVPixelBuffer? { + var maybePixelBuffer: CVPixelBuffer? + let attrs = [ + kCVPixelBufferCGImageCompatibilityKey: kCFBooleanTrue, + kCVPixelBufferCGBitmapContextCompatibilityKey: kCFBooleanTrue, + ] + let status = CVPixelBufferCreate( + kCFAllocatorDefault, + width, + height, + pixelFormatType, + attrs as CFDictionary, + &maybePixelBuffer + ) + + guard status == kCVReturnSuccess, let pixelBuffer = maybePixelBuffer else { + return nil + } + + CVPixelBufferLockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0)) + let pixelData = CVPixelBufferGetBaseAddress(pixelBuffer) + + guard + let context = CGContext( + data: pixelData, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer), + space: colorSpace, + bitmapInfo: alphaInfo.rawValue + ) + else { + return nil + } + + UIGraphicsPushContext(context) + context.translateBy(x: 0, y: CGFloat(height)) + context.scaleBy(x: 1, y: -1) + self.draw(in: CGRect(x: 0, y: 0, width: width, height: height)) + UIGraphicsPopContext() + + CVPixelBufferUnlockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0)) + return pixelBuffer + } + + func areCornerPixelsBlack() -> Bool { + let pixelBuffer = self.pixelBufferGray()! + var result = true + + CVPixelBufferLockBaseAddress(pixelBuffer, CVPixelBufferLockFlags.readOnly) + let width = CVPixelBufferGetWidth(pixelBuffer) + let height = CVPixelBufferGetHeight(pixelBuffer) + let pixels = CVPixelBufferGetBaseAddress(pixelBuffer) + let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer) + // let pixelBufferIndex = x + y * bytesPerRow + var pixelValue = pixels?.load(fromByteOffset: 0, as: UInt8.self) ?? 0 + result = result && pixelValue == 0 + pixelValue = pixels?.load(fromByteOffset: (width - 1), as: UInt8.self) ?? 0 + result = result && pixelValue == 0 + pixelValue = pixels?.load(fromByteOffset: ((height - 1) * bytesPerRow), as: UInt8.self) ?? 0 + result = result && pixelValue == 0 + pixelValue = + pixels?.load(fromByteOffset: ((width - 1) + (height - 1) * bytesPerRow), as: UInt8.self) + ?? 0 + result = result && pixelValue == 0 + CVPixelBufferUnlockBaseAddress(pixelBuffer, CVPixelBufferLockFlags.readOnly) + + return result + } +} + +extension UIImage { + /// Creates a new UIImage from a CVPixelBuffer. + /// NOTE: This only works for RGB pixel buffers, not for grayscale. + convenience init?( + pixelBuffer: CVPixelBuffer + ) { + var cgImage: CGImage? + VTCreateCGImageFromCVPixelBuffer(pixelBuffer, options: nil, imageOut: &cgImage) + + if let cgImage = cgImage { + self.init(cgImage: cgImage) + } else { + return nil + } + } + + /// Creates a new UIImage from a CVPixelBuffer, using Core Image. + convenience init?( + pixelBuffer: CVPixelBuffer, + context: CIContext + ) { + let ciImage = CIImage(cvPixelBuffer: pixelBuffer) + let rect = CGRect( + x: 0, + y: 0, + width: CVPixelBufferGetWidth(pixelBuffer), + height: CVPixelBufferGetHeight(pixelBuffer) + ) + if let cgImage = context.createCGImage(ciImage, from: rect) { + self.init(cgImage: cgImage) + } else { + return nil + } + } +} + +extension UIImage { + /// Creates a new UIImage from an array of RGBA bytes. + @nonobjc class func fromByteArrayRGBA( + _ bytes: [UInt8], + width: Int, + height: Int, + scale: CGFloat = 0, + orientation: UIImage.Orientation = .up + ) -> UIImage? { + return fromByteArray( + bytes, + width: width, + height: height, + scale: scale, + orientation: orientation, + bytesPerRow: width * 4, + colorSpace: CGColorSpaceCreateDeviceRGB(), + alphaInfo: .premultipliedLast + ) + } + + /// Creates a new UIImage from an array of grayscale bytes. + @nonobjc class func fromByteArrayGray( + _ bytes: [UInt8], + width: Int, + height: Int, + scale: CGFloat = 0, + orientation: UIImage.Orientation = .up + ) -> UIImage? { + return fromByteArray( + bytes, + width: width, + height: height, + scale: scale, + orientation: orientation, + bytesPerRow: width, + colorSpace: CGColorSpaceCreateDeviceGray(), + alphaInfo: .none + ) + } + + @nonobjc class func fromByteArray( + _ bytes: [UInt8], + width: Int, + height: Int, + scale: CGFloat, + orientation: UIImage.Orientation, + bytesPerRow: Int, + colorSpace: CGColorSpace, + alphaInfo: CGImageAlphaInfo + ) -> UIImage? { + var image: UIImage? + bytes.withUnsafeBytes { ptr in + if let context = CGContext( + data: UnsafeMutableRawPointer(mutating: ptr.baseAddress!), + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: alphaInfo.rawValue + ), + let cgImage = context.makeImage() + { + image = UIImage(cgImage: cgImage, scale: scale, orientation: orientation) + } + } + return image + } +} + +extension UIImage { + static func blankGrayImage(width: Int, height: Int) -> UIImage? { + UIGraphicsBeginImageContext(CGSize(width: width, height: height)) + UIColor.gray.setFill() + UIRectFill(CGRect(x: 0, y: 0, width: width, height: height)) + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return newImage + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLModels/AsyncModelLoading.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLModels/AsyncModelLoading.swift new file mode 100644 index 00000000..5547d14f --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLModels/AsyncModelLoading.swift @@ -0,0 +1,66 @@ +// +// AsyncModelLoading.swift +// StripeCardScan +// +// Created by Scott Grant on 8/29/22. +// + +import CoreML + +protocol MLModelClassType { + static var urlOfModelInThisBundle: URL { get } +} + +protocol AsyncMLModelLoading { + associatedtype ModelClassType + + static func createModelClass(using model: MLModel) -> ModelClassType + static func asyncLoad( + contentsOf modelURL: URL, + configuration: MLModelConfiguration, + completionHandler handler: @escaping (Swift.Result) -> Void + ) +} + +extension AsyncMLModelLoading where ModelClassType: MLModelClassType { + static func asyncLoad( + contentsOf modelURL: URL = ModelClassType.urlOfModelInThisBundle, + configuration: MLModelConfiguration = MLModelConfiguration(), + completionHandler handler: @escaping (Swift.Result) -> Void + ) { + let deliverResult: (MLModel?, Error?) -> Void = { (model, error) in + if let error = error { + handler(.failure(error)) + } else if let model = model { + handler(.success(Self.createModelClass(using: model))) + } else { + fatalError( + "SPI failure: -[MLModel loadContentsOfURL:configuration::completionHandler:] vends nil for both model and error." + ) + } + } + + if #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) { + MLModel.__loadContents( + of: modelURL, + configuration: configuration, + completionHandler: deliverResult + ) + } else { + DispatchQueue.global(qos: .userInitiated).async { + var model: MLModel? + var error: Error? + + let result = Swift.Result { try MLModel(contentsOf: modelURL) } + switch result { + case .success(let m): + model = m + case .failure(let e): + error = e + } + + deliverResult(model, error) + } + } + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLModels/SSDOcr+Utils.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLModels/SSDOcr+Utils.swift new file mode 100644 index 00000000..b1628c25 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLModels/SSDOcr+Utils.swift @@ -0,0 +1,19 @@ +// +// SSDOcr+Utils.swift +// StripeCardScan +// +// Created by Scott Grant on 7/7/22. +// + +import CoreML + +extension SSDOcr: MLModelClassType { +} + +extension SSDOcr: AsyncMLModelLoading { + typealias ModelClassType = SSDOcr + + static func createModelClass(using model: MLModel) -> SSDOcr { + return SSDOcr(model: model) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLModels/SSDOcr.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLModels/SSDOcr.swift new file mode 100644 index 00000000..95ad81c7 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLModels/SSDOcr.swift @@ -0,0 +1,320 @@ +// +// SSDOcr.swift +// +// This file was automatically generated and should not be edited. +// + +import CoreML + +/// Model Prediction Input Type +@available(macOS 10.13.2, iOS 11.2, tvOS 11.2, watchOS 4.2, *) +class SSDOcrInput: MLFeatureProvider { + + /// 0 as color (kCVPixelFormatType_32BGRA) image buffer, 600 pixels wide by 375 pixels high + var _0: CVPixelBuffer + + var featureNames: Set { + return ["0"] + } + + func featureValue(for featureName: String) -> MLFeatureValue? { + if featureName == "0" { + return MLFeatureValue(pixelBuffer: _0) + } + return nil + } + + init( + _0: CVPixelBuffer + ) { + self._0 = _0 + } + + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + convenience init( + _0With _0: CGImage + ) throws { + let ___0 = try MLFeatureValue( + cgImage: _0, + pixelsWide: 600, + pixelsHigh: 375, + pixelFormatType: kCVPixelFormatType_32ARGB, + options: nil + ).imageBufferValue! + self.init(_0: ___0) + } + + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + convenience init( + _0At _0: URL + ) throws { + let ___0 = try MLFeatureValue( + imageAt: _0, + pixelsWide: 600, + pixelsHigh: 375, + pixelFormatType: kCVPixelFormatType_32ARGB, + options: nil + ).imageBufferValue! + self.init(_0: ___0) + } + + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + func set_0(with _0: CGImage) throws { + self._0 = try MLFeatureValue( + cgImage: _0, + pixelsWide: 600, + pixelsHigh: 375, + pixelFormatType: kCVPixelFormatType_32ARGB, + options: nil + ).imageBufferValue! + } + + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + func set_0(with _0: URL) throws { + self._0 = try MLFeatureValue( + imageAt: _0, + pixelsWide: 600, + pixelsHigh: 375, + pixelFormatType: kCVPixelFormatType_32ARGB, + options: nil + ).imageBufferValue! + } +} + +/// Model Prediction Output Type +@available(macOS 10.13.2, iOS 11.2, tvOS 11.2, watchOS 4.2, *) +class SSDOcrOutput: MLFeatureProvider { + + /// Source provided by CoreML + + private let provider: MLFeatureProvider + + /// MultiArray of shape (1, 1, 1, 3420, 10). The first and second dimensions correspond to sequence and batch size, respectively as multidimensional array of floats + lazy var scores: MLMultiArray = { + [unowned self] in return self.provider.featureValue(for: "scores")!.multiArrayValue + }()! + + /// MultiArray of shape (1, 1, 1, 3420, 4). The first and second dimensions correspond to sequence and batch size, respectively as multidimensional array of floats + lazy var boxes: MLMultiArray = { + [unowned self] in return self.provider.featureValue(for: "boxes")!.multiArrayValue + }()! + + /// MultiArray of shape (1, 1, 1, 3420, 1). The first and second dimensions correspond to sequence and batch size, respectively as multidimensional array of floats + lazy var filter: MLMultiArray = { + [unowned self] in return self.provider.featureValue(for: "filter")!.multiArrayValue + }()! + + var featureNames: Set { + return self.provider.featureNames + } + + func featureValue(for featureName: String) -> MLFeatureValue? { + return self.provider.featureValue(for: featureName) + } + + init( + scores: MLMultiArray, + boxes: MLMultiArray, + filter: MLMultiArray + ) { + self.provider = try! MLDictionaryFeatureProvider(dictionary: [ + "scores": MLFeatureValue(multiArray: scores), + "boxes": MLFeatureValue(multiArray: boxes), + "filter": MLFeatureValue(multiArray: filter), + ]) + } + + init( + features: MLFeatureProvider + ) { + self.provider = features + } +} + +/// Class for model loading and prediction +@available(macOS 10.13.2, iOS 11.2, tvOS 11.2, watchOS 4.2, *) +@_spi(STP) public class SSDOcr { + let model: MLModel + + /// URL of model assuming it was installed in the same bundle as this class + class var urlOfModelInThisBundle: URL { + let bundle = Bundle(for: self) + return bundle.url(forResource: "SSDOcr", withExtension: "mlmodelc")! + } + + /// Construct SSDOcr instance with an existing MLModel object. + /// + /// Usually the application does not use this initializer unless it makes a subclass of SSDOcr. + /// Such application may want to use `MLModel(contentsOfURL:configuration:)` and `SSDOcr.urlOfModelInThisBundle` to create a MLModel object to pass-in. + /// + /// - parameters: + /// - model: MLModel object + init( + model: MLModel + ) { + self.model = model + } + + /// Construct SSDOcr instance by automatically loading the model from the app's bundle. + @available( + *, + deprecated, + message: "Use init(configuration:) instead and handle errors appropriately." + ) + convenience init() { + try! self.init(contentsOf: type(of: self).urlOfModelInThisBundle) + } + + /// Construct a model with configuration + /// + /// - parameters: + /// - configuration: the desired model configuration + /// + /// - throws: an NSError object that describes the problem + @available(macOS 10.14, iOS 12.0, tvOS 12.0, watchOS 5.0, *) + convenience init( + configuration: MLModelConfiguration + ) throws { + try self.init( + contentsOf: type(of: self).urlOfModelInThisBundle, + configuration: configuration + ) + } + + /// Construct SSDOcr instance with explicit path to mlmodelc file + /// - parameters: + /// - modelURL: the file url of the model + /// + /// - throws: an NSError object that describes the problem + convenience init( + contentsOf modelURL: URL + ) throws { + try self.init(model: MLModel(contentsOf: modelURL)) + } + + /// Construct a model with URL of the .mlmodelc directory and configuration + /// + /// - parameters: + /// - modelURL: the file url of the model + /// - configuration: the desired model configuration + /// + /// - throws: an NSError object that describes the problem + @available(macOS 10.14, iOS 12.0, tvOS 12.0, watchOS 5.0, *) + convenience init( + contentsOf modelURL: URL, + configuration: MLModelConfiguration + ) throws { + try self.init(model: MLModel(contentsOf: modelURL, configuration: configuration)) + } + + /// Construct SSDOcr instance asynchronously with optional configuration. + /// + /// Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread. + /// + /// - parameters: + /// - configuration: the desired model configuration + /// - handler: the completion handler to be called when the model loading completes successfully or unsuccessfully + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + class func load( + configuration: MLModelConfiguration = MLModelConfiguration(), + completionHandler handler: @escaping (Swift.Result) -> Void + ) { + return self.load( + contentsOf: self.urlOfModelInThisBundle, + configuration: configuration, + completionHandler: handler + ) + } + + /// Construct SSDOcr instance asynchronously with URL of the .mlmodelc directory with optional configuration. + /// + /// Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread. + /// + /// - parameters: + /// - modelURL: the URL to the model + /// - configuration: the desired model configuration + /// - handler: the completion handler to be called when the model loading completes successfully or unsuccessfully + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + class func load( + contentsOf modelURL: URL, + configuration: MLModelConfiguration = MLModelConfiguration(), + completionHandler handler: @escaping (Swift.Result) -> Void + ) { + MLModel.__loadContents(of: modelURL, configuration: configuration) { (model, error) in + if let error = error { + handler(.failure(error)) + } else if let model = model { + handler(.success(SSDOcr(model: model))) + } else { + fatalError( + "SPI failure: -[MLModel loadContentsOfURL:configuration::completionHandler:] vends nil for both model and error." + ) + } + } + } + + /// Make a prediction using the structured interface + /// + /// - parameters: + /// - input: the input to the prediction as SSDOcrInput + /// + /// - throws: an NSError object that describes the problem + /// + /// - returns: the result of the prediction as SSDOcrOutput + func prediction(input: SSDOcrInput) throws -> SSDOcrOutput { + return try self.prediction(input: input, options: MLPredictionOptions()) + } + + /// Make a prediction using the structured interface + /// + /// - parameters: + /// - input: the input to the prediction as SSDOcrInput + /// - options: prediction options + /// + /// - throws: an NSError object that describes the problem + /// + /// - returns: the result of the prediction as SSDOcrOutput + func prediction(input: SSDOcrInput, options: MLPredictionOptions) throws -> SSDOcrOutput { + let outFeatures = try model.prediction(from: input, options: options) + return SSDOcrOutput(features: outFeatures) + } + + /// Make a prediction using the convenience interface + /// + /// - parameters: + /// - _0 as color (kCVPixelFormatType_32BGRA) image buffer, 600 pixels wide by 375 pixels high + /// + /// - throws: an NSError object that describes the problem + /// + /// - returns: the result of the prediction as SSDOcrOutput + func prediction(_0: CVPixelBuffer) throws -> SSDOcrOutput { + let input_ = SSDOcrInput(_0: _0) + return try self.prediction(input: input_) + } + + /// Make a batch prediction using the structured interface + /// + /// - parameters: + /// - inputs: the inputs to the prediction as [SSDOcrInput] + /// - options: prediction options + /// + /// - throws: an NSError object that describes the problem + /// + /// - returns: the result of the prediction as [SSDOcrOutput] + @available(macOS 10.14, iOS 12.0, tvOS 12.0, watchOS 5.0, *) + func predictions( + inputs: [SSDOcrInput], + options: MLPredictionOptions = MLPredictionOptions() + ) throws -> [SSDOcrOutput] { + let batchIn = MLArrayBatchProvider(array: inputs) + let batchOut = try model.predictions(from: batchIn, options: options) + var results: [SSDOcrOutput] = [] + results.reserveCapacity(inputs.count) + for i in 0.. UxModel { + return UxModel(model: model) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLModels/UxModel.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLModels/UxModel.swift new file mode 100644 index 00000000..12ec2425 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLModels/UxModel.swift @@ -0,0 +1,306 @@ +// +// UxModel.swift +// +// This file was automatically generated and should not be edited. +// + +import CoreML + +/// Model Prediction Input Type +@available(macOS 10.13.2, iOS 11.2, tvOS 11.2, watchOS 4.2, *) +class UxModelInput: MLFeatureProvider { + + /// input1 as color (kCVPixelFormatType_32BGRA) image buffer, 224 pixels wide by 224 pixels high + var input1: CVPixelBuffer + + var featureNames: Set { + return ["input1"] + } + + func featureValue(for featureName: String) -> MLFeatureValue? { + if featureName == "input1" { + return MLFeatureValue(pixelBuffer: input1) + } + return nil + } + + init( + input1: CVPixelBuffer + ) { + self.input1 = input1 + } + + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + convenience init( + input1With input1: CGImage + ) throws { + let __input1 = try MLFeatureValue( + cgImage: input1, + pixelsWide: 224, + pixelsHigh: 224, + pixelFormatType: kCVPixelFormatType_32ARGB, + options: nil + ).imageBufferValue! + self.init(input1: __input1) + } + + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + convenience init( + input1At input1: URL + ) throws { + let __input1 = try MLFeatureValue( + imageAt: input1, + pixelsWide: 224, + pixelsHigh: 224, + pixelFormatType: kCVPixelFormatType_32ARGB, + options: nil + ).imageBufferValue! + self.init(input1: __input1) + } + + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + func setInput1(with input1: CGImage) throws { + self.input1 = try MLFeatureValue( + cgImage: input1, + pixelsWide: 224, + pixelsHigh: 224, + pixelFormatType: kCVPixelFormatType_32ARGB, + options: nil + ).imageBufferValue! + } + + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + func setInput1(with input1: URL) throws { + self.input1 = try MLFeatureValue( + imageAt: input1, + pixelsWide: 224, + pixelsHigh: 224, + pixelFormatType: kCVPixelFormatType_32ARGB, + options: nil + ).imageBufferValue! + } +} + +/// Model Prediction Output Type +@available(macOS 10.13.2, iOS 11.2, tvOS 11.2, watchOS 4.2, *) +class UxModelOutput: MLFeatureProvider { + + /// Source provided by CoreML + + private let provider: MLFeatureProvider + + /// output1 as 3 element vector of doubles + lazy var output1: MLMultiArray = { + [unowned self] in return self.provider.featureValue(for: "output1")!.multiArrayValue + }()! + + var featureNames: Set { + return self.provider.featureNames + } + + func featureValue(for featureName: String) -> MLFeatureValue? { + return self.provider.featureValue(for: featureName) + } + + init( + output1: MLMultiArray + ) { + self.provider = try! MLDictionaryFeatureProvider(dictionary: [ + "output1": MLFeatureValue(multiArray: output1) + ]) + } + + init( + features: MLFeatureProvider + ) { + self.provider = features + } +} + +/// Class for model loading and prediction +@available(macOS 10.13.2, iOS 11.2, tvOS 11.2, watchOS 4.2, *) +@_spi(STP) public class UxModel { + let model: MLModel + + /// URL of model assuming it was installed in the same bundle as this class + class var urlOfModelInThisBundle: URL { + let bundle = Bundle(for: self) + return bundle.url(forResource: "UxModel", withExtension: "mlmodelc")! + } + + /// Construct UxModel instance with an existing MLModel object. + /// + /// Usually the application does not use this initializer unless it makes a subclass of UxModel. + /// Such application may want to use `MLModel(contentsOfURL:configuration:)` and `UxModel.urlOfModelInThisBundle` to create a MLModel object to pass-in. + /// + /// - parameters: + /// - model: MLModel object + init( + model: MLModel + ) { + self.model = model + } + + /// Construct UxModel instance by automatically loading the model from the app's bundle. + @available( + *, + deprecated, + message: "Use init(configuration:) instead and handle errors appropriately." + ) + convenience init() { + try! self.init(contentsOf: type(of: self).urlOfModelInThisBundle) + } + + /// Construct a model with configuration + /// + /// - parameters: + /// - configuration: the desired model configuration + /// + /// - throws: an NSError object that describes the problem + @available(macOS 10.14, iOS 12.0, tvOS 12.0, watchOS 5.0, *) + convenience init( + configuration: MLModelConfiguration + ) throws { + try self.init( + contentsOf: type(of: self).urlOfModelInThisBundle, + configuration: configuration + ) + } + + /// Construct UxModel instance with explicit path to mlmodelc file + /// - parameters: + /// - modelURL: the file url of the model + /// + /// - throws: an NSError object that describes the problem + convenience init( + contentsOf modelURL: URL + ) throws { + try self.init(model: MLModel(contentsOf: modelURL)) + } + + /// Construct a model with URL of the .mlmodelc directory and configuration + /// + /// - parameters: + /// - modelURL: the file url of the model + /// - configuration: the desired model configuration + /// + /// - throws: an NSError object that describes the problem + @available(macOS 10.14, iOS 12.0, tvOS 12.0, watchOS 5.0, *) + convenience init( + contentsOf modelURL: URL, + configuration: MLModelConfiguration + ) throws { + try self.init(model: MLModel(contentsOf: modelURL, configuration: configuration)) + } + + /// Construct UxModel instance asynchronously with optional configuration. + /// + /// Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread. + /// + /// - parameters: + /// - configuration: the desired model configuration + /// - handler: the completion handler to be called when the model loading completes successfully or unsuccessfully + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + class func load( + configuration: MLModelConfiguration = MLModelConfiguration(), + completionHandler handler: @escaping (Swift.Result) -> Void + ) { + return self.load( + contentsOf: self.urlOfModelInThisBundle, + configuration: configuration, + completionHandler: handler + ) + } + + /// Construct UxModel instance asynchronously with URL of the .mlmodelc directory with optional configuration. + /// + /// Model loading may take time when the model content is not immediately available (e.g. encrypted model). Use this factory method especially when the caller is on the main thread. + /// + /// - parameters: + /// - modelURL: the URL to the model + /// - configuration: the desired model configuration + /// - handler: the completion handler to be called when the model loading completes successfully or unsuccessfully + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + class func load( + contentsOf modelURL: URL, + configuration: MLModelConfiguration = MLModelConfiguration(), + completionHandler handler: @escaping (Swift.Result) -> Void + ) { + MLModel.__loadContents(of: modelURL, configuration: configuration) { (model, error) in + if let error = error { + handler(.failure(error)) + } else if let model = model { + handler(.success(UxModel(model: model))) + } else { + fatalError( + "SPI failure: -[MLModel loadContentsOfURL:configuration::completionHandler:] vends nil for both model and error." + ) + } + } + } + + /// Make a prediction using the structured interface + /// + /// - parameters: + /// - input: the input to the prediction as UxModelInput + /// + /// - throws: an NSError object that describes the problem + /// + /// - returns: the result of the prediction as UxModelOutput + func prediction(input: UxModelInput) throws -> UxModelOutput { + return try self.prediction(input: input, options: MLPredictionOptions()) + } + + /// Make a prediction using the structured interface + /// + /// - parameters: + /// - input: the input to the prediction as UxModelInput + /// - options: prediction options + /// + /// - throws: an NSError object that describes the problem + /// + /// - returns: the result of the prediction as UxModelOutput + func prediction(input: UxModelInput, options: MLPredictionOptions) throws -> UxModelOutput { + let outFeatures = try model.prediction(from: input, options: options) + return UxModelOutput(features: outFeatures) + } + + /// Make a prediction using the convenience interface + /// + /// - parameters: + /// - input1 as color (kCVPixelFormatType_32BGRA) image buffer, 224 pixels wide by 224 pixels high + /// + /// - throws: an NSError object that describes the problem + /// + /// - returns: the result of the prediction as UxModelOutput + func prediction(input1: CVPixelBuffer) throws -> UxModelOutput { + let input_ = UxModelInput(input1: input1) + return try self.prediction(input: input_) + } + + /// Make a batch prediction using the structured interface + /// + /// - parameters: + /// - inputs: the inputs to the prediction as [UxModelInput] + /// - options: prediction options + /// + /// - throws: an NSError object that describes the problem + /// + /// - returns: the result of the prediction as [UxModelOutput] + @available(macOS 10.14, iOS 12.0, tvOS 12.0, watchOS 5.0, *) + func predictions( + inputs: [UxModelInput], + options: MLPredictionOptions = MLPredictionOptions() + ) throws -> [UxModelOutput] { + let batchIn = MLArrayBatchProvider(array: inputs) + let batchOut = try model.predictions(from: batchIn, options: options) + var results: [UxModelOutput] = [] + results.reserveCapacity(inputs.count) + for i in 0.. Void] = [] + var isActive = false + + init( + label: String + ) { + self.queue = DispatchQueue(label: "ActiveStateComputation \(label)") + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.isActive = UIApplication.shared.applicationState == .active + + // We don't need to unregister these functions because the system will clean + // them up for us + NotificationCenter.default.addObserver( + self, + selector: #selector(self.willResignActive), + name: UIApplication.willResignActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.didBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + } + } + + func async(execute work: @escaping () -> Void) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + let state = UIApplication.shared.applicationState + guard state == .active, self.isActive else { + self.pendingComputations.append(work) + return + } + + self.queue.async { work() } + } + } + + @objc func willResignActive() { + assert(UIApplication.shared.applicationState == .active) + assert(Thread.isMainThread) + isActive = false + queue.sync {} + } + + @objc func didBecomeActive() { + assert(UIApplication.shared.applicationState == .active) + assert(Thread.isMainThread) + isActive = true + for work in pendingComputations { + queue.async { work() } + } + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/AppState.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/AppState.swift new file mode 100644 index 00000000..3ceff4cc --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/AppState.swift @@ -0,0 +1,22 @@ +import Foundation + +struct AppState { + + static let lock = DispatchSemaphore(value: 1) + static private var isInBackground = false + + static var inBackground: Bool { + get { + lock.wait() + let background = isInBackground + lock.signal() + return background + } + + set(value) { + lock.wait() + isInBackground = value + lock.signal() + } + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/DetectedAllBoxes.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/DetectedAllBoxes.swift new file mode 100644 index 00000000..66310e76 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/DetectedAllBoxes.swift @@ -0,0 +1,18 @@ +// +// DetectedAllBoxes.swift +// CardScan +// +// Created by Zain on 8/15/19. +// +/// Data structure used to store all the detected boxes per frame or scan + +struct DetectedAllBoxes { + var allBoxes: [DetectedSSDBox] = [] + + init() {} + + func toArray() -> [[String: Any]] { + let frameArray = self.allBoxes.map { $0.toDict() } + return frameArray + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/DetectedAllOcrBoxes.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/DetectedAllOcrBoxes.swift new file mode 100644 index 00000000..88e8eece --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/DetectedAllOcrBoxes.swift @@ -0,0 +1,23 @@ +// +// DetectedAllOcrBoxes.swift +// CardScan +// +// Created by xaen on 3/22/20. +// +import CoreGraphics +import Foundation + +struct DetectedAllOcrBoxes { + var allBoxes: [DetectedSSDOcrBox] = [] + + init() {} + + func toArray() -> [[String: Any]] { + let frameArray = self.allBoxes.map { $0.toDict() } + return frameArray + } + + func getBoundingBoxesOfDigits() -> [CGRect] { + return self.allBoxes.map { $0.rect } + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/DetectedBox.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/DetectedBox.swift new file mode 100644 index 00000000..36863411 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/DetectedBox.swift @@ -0,0 +1,62 @@ +import CoreGraphics +import Foundation + +/// Data structure to keep track of each box that the model detects. +/// +/// Note: the rect member is in the image's coordinate system. + +struct DetectedBox { + let rect: CGRect + let row: Int + let col: Int + let confidence: Double + let numRows: Int + let numCols: Int + let boxSize: CGSize + let cardSize: CGSize + let imageSize: CGSize + + init( + row: Int, + col: Int, + confidence: Double, + numRows: Int, + numCols: Int, + boxSize: CGSize, + cardSize: CGSize, + imageSize: CGSize + ) { + + // Resize the box to transform it from the model's coordinates into + // the image's coordinates + let w = boxSize.width * imageSize.width / cardSize.width + let h = boxSize.height * imageSize.height / cardSize.height + let x = (imageSize.width - w) / CGFloat(numCols - 1) * CGFloat(col) + let y = (imageSize.height - h) / CGFloat(numRows - 1) * CGFloat(row) + self.rect = CGRect(x: x, y: y, width: w, height: h) + self.row = row + self.col = col + self.confidence = confidence + self.numRows = numRows + self.numCols = numCols + self.boxSize = boxSize + self.cardSize = cardSize + self.imageSize = imageSize + } + + func move(row: Int, col: Int) -> DetectedBox? { + if row < 0 || row >= self.numRows || col < 0 || col >= self.numCols { + return nil + } + return DetectedBox( + row: row, + col: col, + confidence: self.confidence, + numRows: self.numRows, + numCols: self.numCols, + boxSize: self.boxSize, + cardSize: self.cardSize, + imageSize: self.imageSize + ) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/DetectedSSDBox.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/DetectedSSDBox.swift new file mode 100644 index 00000000..fffa3891 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/DetectedSSDBox.swift @@ -0,0 +1,51 @@ +// +// DetectedSSDBox.swift +// CardScan +// +// Created by Zain on 8/7/19. +// +import CoreGraphics +import Foundation + +struct DetectedSSDBox { + let rect: CGRect + let label: Int + let confidence: Float + let imgSize: CGSize + + init( + category: Int, + conf: Float, + XMin: Double, + YMin: Double, + XMax: Double, + YMax: Double, + imageSize: CGSize + ) { + + let XMin_ = XMin * Double(imageSize.width) + let XMax_ = XMax * Double(imageSize.width) + let YMin_ = YMin * Double(imageSize.height) + let YMax_ = YMax * Double(imageSize.height) + + self.label = category + self.confidence = conf + self.rect = CGRect(x: XMin_, y: YMin_, width: XMax_ - XMin_, height: YMax_ - YMin_) + self.imgSize = imageSize + } + + func toDict() -> [String: Any] { + // The model ouputs labels that are off by 1 + // compared to the previous versions and this line + // serves to retain the consistency. This label + // correction should be removed in the future. + return [ + "x_min": self.rect.minX / self.imgSize.width, + "y_min": self.rect.minY / self.imgSize.height, + "height": self.rect.height / self.imgSize.height, + "width": self.rect.width / self.imgSize.width, + "label": self.label - 1, + "confidence": self.confidence, + ] + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/DetectedSSDOcrBox.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/DetectedSSDOcrBox.swift new file mode 100644 index 00000000..4d2031f6 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/DetectedSSDOcrBox.swift @@ -0,0 +1,41 @@ +// +// DetectedSSDOcrBox.swift +// CardScan +// +// Created by xaen on 3/22/20. +// +import CoreGraphics +import Foundation + +struct DetectedSSDOcrBox { + let rect: CGRect + let label: Int + let confidence: Float + let imgSize: CGSize + + init( + category: Int, + conf: Float, + XMin: Double, + YMin: Double, + XMax: Double, + YMax: Double, + imageSize: CGSize + ) { + + let XMin_ = XMin * Double(imageSize.width) + let XMax_ = XMax * Double(imageSize.width) + let YMin_ = YMin * Double(imageSize.height) + let YMax_ = YMax * Double(imageSize.height) + + self.label = category + self.confidence = conf + self.rect = CGRect(x: XMin_, y: YMin_, width: XMax_ - XMin_, height: YMax_ - YMin_) + self.imgSize = imageSize + } + + func toDict() -> [String: Any] { + + return ["": ""] + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/NMS.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/NMS.swift new file mode 100644 index 00000000..4f5bc044 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/NMS.swift @@ -0,0 +1,75 @@ +// +// NMS.swift +// CardScan +// +// Created by Zain on 8/6/19. +// +import CoreGraphics +import Foundation +import os.log + +struct NMS { + static func hardNMS( + subsetBoxes: [[Float]], + probs: [Float], + iouThreshold: Float, + topK: Int, + candidateSize: Int + ) -> [Int] { + /// * I highly recommend checkout SOFT NMS Implementation of Facebook Detectron Framework + /// * + /// * Args: + /// * subsetBoxes (N, 4): boxes in corner-form and probabilities. + /// * iouThreshold: intersection over union threshold. + /// * topK: keep topK results. If k <= 0, keep all the results. + /// * candidateSize: only consider the candidates with the highest scores. + /// * + /// * Returns: + /// * pickedIndices: a list of indexes of the kept boxes + + let sorted = probs.enumerated().sorted(by: { $0.element > $1.element }) + var indices = sorted.map { $0.offset } + var current: Int = 0 + var currentBox = [Float]() + var pickedIndices = [Int]() + + if indices.count > 200 { + // TODO Fix This + indices = Array(indices[0..<200]) + os_log("Exceptional Situation more than 200 candiates found", type: .error) + } + + while indices.count > 0 { + current = indices.remove(at: 0) + pickedIndices.append(current) + + if topK > 0 && topK == pickedIndices.count { + break + } + currentBox = subsetBoxes[current] + + let currentBoxRect = CGRect( + x: Double(currentBox[0]), + y: Double(currentBox[1]), + width: Double(currentBox[2] - currentBox[0]), + height: Double(currentBox[3] - currentBox[1]) + ) + + indices.removeAll(where: { + currentBoxRect.iou( + nextBox: CGRect( + x: Double(subsetBoxes[$0][0]), + y: Double(subsetBoxes[$0][1]), + width: Double(subsetBoxes[$0][2] - subsetBoxes[$0][0]), + height: Double(subsetBoxes[$0][3] - subsetBoxes[$0][1]) + ) + ) >= iouThreshold + }) + + } + + return pickedIndices + + } + +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/OcrDD.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/OcrDD.swift new file mode 100644 index 00000000..96bccd9f --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/OcrDD.swift @@ -0,0 +1,27 @@ +// +// OcrDD.swift +// CardScan +// +// Created by xaen on 4/14/20. +// +import CoreGraphics +import Foundation +import UIKit + +class OcrDD { + var lastDetectedBoxes: [CGRect] = [] + var ssdOcr = SSDOcrDetect() + init() {} + + static func configure() { + let ssdOcr = SSDOcrDetect() + ssdOcr.warmUp() + } + + func perform(croppedCardImage: CGImage) -> String? { + let number = ssdOcr.predict(image: UIImage(cgImage: croppedCardImage)) + self.lastDetectedBoxes = ssdOcr.lastDetectedBoxes + return number + } + +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/OcrDDUtils.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/OcrDDUtils.swift new file mode 100644 index 00000000..8eaa7fcb --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/OcrDDUtils.swift @@ -0,0 +1,177 @@ +// +// OcrDDUtils.swift +// CardScan +// +// Created by xaen on 6/17/20. +// + +import UIKit + +struct OcrDDUtils { + static let offsetQuickRead: Float = 2.0 + static let falsePositiveTolerance: Float = 1.2 + static let minimumCardDigits = 12 + static let numOfQuickReadDigits = 16 + static let numOfQuickReadDigitsPerGroup = 4 + + static func isQuickRead(allBoxes: DetectedAllOcrBoxes) -> Bool { + + if (allBoxes.allBoxes.isEmpty) || (allBoxes.allBoxes.count != numOfQuickReadDigits) { + return false + } + + var boxCenters = [Float]() + var boxHeights = [Float]() + var aggregateDeviation: Float = 0 + + for idx in 0.. offsetQuickRead * medianHeight { + let quickReadGroups = allBoxes.allBoxes + .sorted(by: { return $0.rect.centerY() < $1.rect.centerY() }) + .chunked(into: 4) + .map { $0.sorted(by: { return $0.rect.centerX() < $1.rect.centerX() }) } + + guard let quickReadGroupFirstRowFirstDigit = quickReadGroups[0].first, + let quickReadGroupSecondRowFirstDigit = quickReadGroups[1].first, + let quickReadGroupFirstRowLastDigit = quickReadGroups[0].last, + let quickReadGroupSecondRowLastDigit = quickReadGroups[1].last + else { + return false + } + + if quickReadGroupSecondRowFirstDigit.rect.centerX() + < quickReadGroupFirstRowLastDigit.rect.centerX() + && quickReadGroupSecondRowLastDigit.rect.centerX() + > quickReadGroupFirstRowFirstDigit.rect.centerX() + { + return true + } + } + + return false + } + + static func processQuickRead(allBoxes: DetectedAllOcrBoxes) -> (String, [CGRect])? { + + if allBoxes.allBoxes.count != numOfQuickReadDigits { + return nil + } + + var _cardNumber: String = "" + var boxes: [CGRect] = [] + let sortedBoxes = allBoxes.allBoxes.sorted(by: { (left, right) -> Bool in + let leftAverageY = (left.rect.minY / 2 + left.rect.maxY / 2) + let rightAverageY = (right.rect.minY / 2 + right.rect.maxY / 2) + return leftAverageY < rightAverageY + }) + + var start = 0 + var end = numOfQuickReadDigitsPerGroup - 1 // since indices start with 0 + for _ in 0.. (String, [CGRect])? { + + if boxes.indices.contains(start) && boxes.indices.contains(end) { + var _groupNumber: String = "" + let groupSlice = boxes[start...end] + let group = Array(groupSlice) + let sortedGroup = group.sorted(by: { $0.rect.minX < $1.rect.minX }) + var sortedBoxes: [CGRect] = [] + + for idx in 0.. (String, [CGRect])? { + + if (allBoxes.allBoxes.isEmpty) || (allBoxes.allBoxes.count < minimumCardDigits) { + return nil + } + + var leftCordinates = [Float]() + var topCordinates = [Float]() + var bottomCordinates = [Float]() + var sortedBoxes = [CGRect]() + + for idx in 0.. [CGRect] { + + let imageHeight = 375 + let imageWidth = 600 + + let scaleHeight = Float(imageHeight) / Float(shrinkageHeight) + let scaleWidth = Float(imageWidth) / Float(shrinkageWidth) + + var boxes = [CGRect]() + var xCenter: Float + var yCenter: Float + var size: Float + var ratioOne: Float + var h: Float + var w: Float + + for j in 0.. [CGRect] { + + let priorsOne = OcrPriorsGen.genPriors( + featureMapSizeHeight: OcrPriorsGen.featureMapSizeBigHeight, + featureMapSizeWidth: OcrPriorsGen.featureMapSizeBigWidth, + shrinkageHeight: OcrPriorsGen.shrinkageSmallHeight, + shrinkageWidth: OcrPriorsGen.shrinkageSmallWidth, + boxSizeMin: OcrPriorsGen.boxSizeSmallLayerOne, + boxSizeMax: OcrPriorsGen.boxSizeBigLayerOne, + aspectRatioOne: OcrPriorsGen.aspectRatioOne, + noOfPriors: OcrPriorsGen.noOfPriorsPerLocation + ) + + let priorsTwo = OcrPriorsGen.genPriors( + featureMapSizeHeight: OcrPriorsGen.featureMapSizeSmallHeight, + featureMapSizeWidth: OcrPriorsGen.featureMapSizeSmallWidth, + shrinkageHeight: OcrPriorsGen.shrinkageBigHeight, + shrinkageWidth: OcrPriorsGen.shrinkageBigWidth, + boxSizeMin: OcrPriorsGen.boxSizeBigLayerOne, + boxSizeMax: OcrPriorsGen.boxSizeBigLayerTwo, + aspectRatioOne: OcrPriorsGen.aspectRatioOne, + noOfPriors: OcrPriorsGen.noOfPriorsPerLocation + ) + + let priorsCombined = priorsOne + priorsTwo + + return priorsCombined + + } +} + +extension Float { + func clamp(minimum: Float = 0.0, maximum: Float = 1.0) -> Float { + return max(minimum, min(maximum, self)) + } + +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/PostDetectionAlgorithm.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/PostDetectionAlgorithm.swift new file mode 100644 index 00000000..d4c41401 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/PostDetectionAlgorithm.swift @@ -0,0 +1,221 @@ +import Foundation + +/// Organize the boxes to find possible numbers. +/// +/// After running detection, the post processing algorithm will try to find +/// sequences of boxes that are plausible card numbers. The basic techniques +/// that it uses are non-maximum suppression and depth first search on box +/// sequences to find likely numbers. There are also a number of heuristics +/// for filtering out unlikely sequences. +struct PostDetectionAlgorithm { + let kNumberWordCount = 4 + let kAmexWordCount = 5 + let kMaxBoxesToDetect = 20 + let kDeltaRowForCombine = 2 + let kDeltaColForCombine = 2 + let kDeltaRowForHorizontalNumbers = 1 + let kDeltaColForVerticalNumbers = 1 + + let sortedBoxes: [DetectedBox] + let numRows: Int + let numCols: Int + + init( + boxes: [DetectedBox] + ) { + self.sortedBoxes = boxes.sorted { $0.confidence > $1.confidence }.prefix(kMaxBoxesToDetect) + .map { $0 } + + // it's ok if this doesn't match the card row/col counts because we only + // use this for our internal algorithms. I prefer doing this as it make + // proving array bounds easier since everything is local and as long as + // we only access arrays using row/col from our boxes then we'll always + // be in bounds + self.numRows = (self.sortedBoxes.map { $0.row }.max() ?? 0) + 1 + self.numCols = (self.sortedBoxes.map { $0.col }.max() ?? 0) + 1 + } + + /// Finds traditional numbers that are horizontal on a 16 digit card. + func horizontalNumbers() -> [[DetectedBox]] { + let boxes = self.combineCloseBoxes( + deltaRow: kDeltaRowForCombine, + deltaCol: kDeltaColForCombine + ) + let lines = self.findHorizontalNumbers(words: boxes, numberOfBoxes: kNumberWordCount) + + // boxes should be roughly evenly spaced, reject any that aren't + return lines.filter { line in + let deltas = zip(line, line.dropFirst()).map { box, nextBox in nextBox.col - box.col } + let maxDelta = deltas.max() ?? 0 + let minDelta = deltas.min() ?? 0 + + return (maxDelta - minDelta) <= 2 + } + } + + /// Used for Visa quick read where the digits are in groups of four but organized veritcally + func verticalNumbers() -> [[DetectedBox]] { + let boxes = self.combineCloseBoxes( + deltaRow: kDeltaRowForCombine, + deltaCol: kDeltaColForCombine + ) + let lines = self.findVerticalNumbers(words: boxes, numberOfBoxes: kNumberWordCount) + + // boxes should be roughly evenly spaced, reject any that aren't + return lines.filter { line in + let deltas = zip(line, line.dropFirst()).map { box, nextBox in nextBox.row - box.row } + let maxDelta = deltas.max() ?? 0 + let minDelta = deltas.min() ?? 0 + + return (maxDelta - minDelta) <= 2 + } + } + + /// Finds 15 digit horizontal Amex card numbers. + /// + /// Amex has groups of 4 6 5 numbers and our detection algorithm detects clusters of four + /// digits, but we did design it to detect the groups of four within the clusters of 5 and 6. + /// Thus, our goal with Amex is to find enough boxes of 4 to cover all of the amex digits. + func amexNumbers() -> [[DetectedBox]] { + let boxes = self.combineCloseBoxes(deltaRow: kDeltaRowForCombine, deltaCol: 1) + let lines = self.findHorizontalNumbers(words: boxes, numberOfBoxes: kAmexWordCount) + + return lines.filter { line in + let colDeltas = zip(line, line.dropFirst()).map { box, nextBox in nextBox.col - box.col + } + + // we have roughly evenly spaced clusters. A single box of four, a cluster of 6 and then + // a cluster of 5. We try to recognize the first and last few digits of the 5 and 6 + // cluster, and the 5 and 6 cluster are roughly evenly spaced but the boxes within + // are close + let evenColDeltas = colDeltas.enumerated().filter { $0.0 % 2 == 0 }.map { $0.1 } + let oddColDeltas = colDeltas.enumerated().filter { $0.0 % 2 == 1 }.map { $0.1 } + let evenOddDeltas = zip(evenColDeltas, oddColDeltas).map { even, odd in + Double(even) / Double(odd) + } + + return evenOddDeltas.reduce(true) { $0 && $1 >= 2.0 } + } + } + + /// Combine close boxes favoring high confidence boxes. + func combineCloseBoxes(deltaRow: Int, deltaCol: Int) -> [DetectedBox] { + var cardGrid: [[Bool]] = Array( + repeating: Array(repeating: false, count: self.numCols), + count: self.numRows + ) + + for box in self.sortedBoxes { + cardGrid[box.row][box.col] = true + } + + // since the boxes are sorted by confidence, go through them in order to + // result in only high confidence boxes winning. There are corner cases + // where this will leave extra boxes, but that's ok because we don't + // need to be perfect here + for box in self.sortedBoxes { + if cardGrid[box.row][box.col] == false { + continue + } + for row in (box.row - deltaRow)...(box.row + deltaRow) { + for col in (box.col - deltaCol)...(box.col + deltaCol) { + if row >= 0 && row < numRows && col >= 0 && col < numCols { + cardGrid[row][col] = false + } + } + } + // add this box back + cardGrid[box.row][box.col] = true + } + + return self.sortedBoxes.filter { cardGrid[$0.row][$0.col] } + } + + /// Find all boxes that form a sequence of four boxes. + /// + /// Does a depth first search on all boxes to find all boxes that form + /// a line with four boxes. The predicate dictates which boxes are added + /// so we have a separate prediate for horizontal vs vertical numbers. + func findNumbers( + currentLine: [DetectedBox], + words: [DetectedBox], + predicate: ((DetectedBox, DetectedBox) -> Bool), + numberOfBoxes: Int, + lines: inout [[DetectedBox]] + ) { + + if currentLine.count == numberOfBoxes { + lines.append(currentLine) + return + } + + if words.count == 0 { + return + } + + guard let currentWord = currentLine.last else { + return + } + + for (idx, word) in words.enumerated() { + if predicate(currentWord, word) { + findNumbers( + currentLine: (currentLine + [word]), + words: words.dropFirst(idx + 1).map { $0 }, + predicate: predicate, + numberOfBoxes: numberOfBoxes, + lines: &lines + ) + } + } + } + + func verticalAddBoxPredicate(_ currentWord: DetectedBox, _ nextWord: DetectedBox) -> Bool { + let deltaCol = kDeltaColForVerticalNumbers + return nextWord.row > currentWord.row && nextWord.col >= (currentWord.col - deltaCol) + && nextWord.col <= (currentWord.col + deltaCol) + } + + func horizontalAddBoxPredicate(_ currentWord: DetectedBox, _ nextWord: DetectedBox) -> Bool { + let deltaRow = kDeltaRowForHorizontalNumbers + return nextWord.col > currentWord.col && nextWord.row >= (currentWord.row - deltaRow) + && nextWord.row <= (currentWord.row + deltaRow) + } + + // Note: this is simple but inefficient. Since we're dealing with small + // lists (eg 20 items) it should be fine + func findHorizontalNumbers(words: [DetectedBox], numberOfBoxes: Int) -> [[DetectedBox]] { + let sortedWords = words.sorted { $0.col < $1.col } + var lines: [[DetectedBox]] = [[]] + + for (idx, word) in sortedWords.enumerated() { + findNumbers( + currentLine: [word], + words: sortedWords.dropFirst(idx + 1).map { $0 }, + predicate: horizontalAddBoxPredicate, + numberOfBoxes: numberOfBoxes, + lines: &lines + ) + } + + return lines + } + + func findVerticalNumbers(words: [DetectedBox], numberOfBoxes: Int) -> [[DetectedBox]] { + let sortedWords = words.sorted { $0.row < $1.row } + var lines: [[DetectedBox]] = [[]] + + for (idx, word) in sortedWords.enumerated() { + findNumbers( + currentLine: [word], + words: sortedWords.dropFirst(idx + 1).map { $0 }, + predicate: verticalAddBoxPredicate, + numberOfBoxes: numberOfBoxes, + lines: &lines + ) + } + + return lines + } + +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/PredictionAPI.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/PredictionAPI.swift new file mode 100644 index 00000000..3c23eb9d --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/PredictionAPI.swift @@ -0,0 +1,78 @@ +// +// PredictionAPI.swift +// CardScan +// +// Created by Zain on 8/6/19. +// + +import Foundation + +struct Result { + var pickedBoxProbs: [Float] + var pickedLabels: [Int] + var pickedBoxes: [[Float]] + + init() { + pickedBoxProbs = [Float]() + pickedLabels = [Int]() + pickedBoxes = [[Float]]() + } +} +struct PredictionAPI { + + /// * A utitliy struct that applies non-max supression to each class + /// * picks out the remaining boxes, the class probabilities for classes + /// * that are kept and composes all the information in one place to be returned as + /// * an object. + func predictionAPI( + scores: [[Float]], + boxes: [[Float]], + probThreshold: Float, + iouThreshold: Float, + candidateSize: Int, + topK: Int + ) -> Result { + var pickedBoxes: [[Float]] = [[Float]]() + var pickedLabels: [Int] = [Int]() + var pickedBoxProbs: [Float] = [Float]() + + for classIndex in 1.. probThreshold { + probs.append(scores[rowIndex][classIndex]) + subsetBoxes.append(boxes[rowIndex]) + } + } + + if probs.count == 0 { + continue + } + + indicies = NMS.hardNMS( + subsetBoxes: subsetBoxes, + probs: probs, + iouThreshold: iouThreshold, + topK: topK, + candidateSize: candidateSize + ) + + for idx in indicies { + pickedBoxProbs.append(probs[idx]) + pickedBoxes.append(subsetBoxes[idx]) + pickedLabels.append(classIndex) + } + } + var result: Result = Result() + result.pickedBoxProbs = pickedBoxProbs + result.pickedLabels = pickedLabels + result.pickedBoxes = pickedBoxes + + return result + + } + +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/PredictionResult.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/PredictionResult.swift new file mode 100644 index 00000000..69c28089 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/PredictionResult.swift @@ -0,0 +1,84 @@ +// +// PredictionResult.swift +// CardScan +// +// Created by Sam King on 11/16/18. +// +import CoreGraphics +import Foundation +import UIKit + +// +// The PredictionResult includes images of the bin and the last four. The OCR model returns clusters of 4 digits for +// the number so we use only the first 4 for the bin and the full last 4 as a single image +// +struct PredictionResult { + let cardWidth: CGFloat + let cardHeight: CGFloat + let numberBoxes: [CGRect] + let number: String + let cvvBoxes: [CGRect] + + func bin() -> String { + return String(number.prefix(6)) + } + + func last4() -> String { + return String(number.suffix(4)) + } + + static func translateBox( + from modelSize: CGSize, + to imageSize: CGSize, + for box: CGRect + ) -> CGRect { + let boxes = translateBoxes(from: modelSize, to: imageSize, for: [box]) + return boxes.first! + } + + static func translateBoxes( + from modelSize: CGSize, + to imageSize: CGSize, + for boxes: [CGRect] + ) -> [CGRect] { + let scaleX = imageSize.width / modelSize.width + let scaleY = imageSize.height / modelSize.height + + return boxes.map { + CGRect( + x: $0.origin.x * scaleX, + y: $0.origin.y * scaleY, + width: $0.size.width * scaleX, + height: $0.size.height * scaleY + ) + } + } + + func translateNumber(to originalImage: CGImage) -> [CGRect] { + let scaleX = CGFloat(originalImage.width) / self.cardWidth + let scaleY = CGFloat(originalImage.height) / self.cardHeight + + return self.numberBoxes.map { + CGRect( + x: $0.origin.x * scaleX, + y: $0.origin.y * scaleY, + width: $0.size.width * scaleX, + height: $0.size.height * scaleY + ) + } + } + + func extractImagePng(from image: CGImage, for box: CGRect) -> String? { + let uiImage = image.cropping(to: box).map { UIImage(cgImage: $0) } + return uiImage.flatMap { $0.pngData()?.base64EncodedString() } + } + + func resizeImage(image: UIImage, to size: CGSize) -> UIImage? { + UIGraphicsBeginImageContext(CGSize(width: size.width, height: size.height)) + image.draw(in: CGRect(x: 0, y: 0, width: size.width, height: size.height)) + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return newImage + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/PredictionUtilOcr.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/PredictionUtilOcr.swift new file mode 100644 index 00000000..661392aa --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/PredictionUtilOcr.swift @@ -0,0 +1,68 @@ +// +// PredictionUtilOcr.swift +// CardScan +// +// Created by xaen on 6/4/20. +// + +import Foundation + +struct PredictionUtilOcr { + + /// * A utitliy struct that applies non-max supression to each class + /// * picks out the remaining boxes, the class probabilities for classes + /// * that are kept and composes all the information in one place to be returned as + /// * an object. + func predictionUtil( + scores: [[Float]], + boxes: [[Float]], + probThreshold: Float, + iouThreshold: Float, + candidateSize: Int, + topK: Int + ) -> Result { + var pickedBoxes = [[Float]]() + var pickedLabels = [Int]() + var pickedBoxProbs = [Float]() + + for classIndex in 0.. probThreshold { + probs.append(scores[rowIndex][classIndex]) + subsetBoxes.append(boxes[rowIndex]) + } + } + + if probs.count == 0 { + continue + } + + let (_pickedBoxes, _pickedScores) = SoftNMS.softNMS( + subsetBoxes: subsetBoxes, + probs: probs, + probThreshold: probThreshold, + sigma: SSDOcrDetect.sigma, + topK: topK, + candidateSize: candidateSize + ) + + for idx in 0..<_pickedScores.count { + pickedBoxProbs.append(_pickedScores[idx]) + pickedBoxes.append(_pickedBoxes[idx]) + pickedLabels.append((classIndex + 1) % 10) + } + + } + var result: Result = Result() + result.pickedBoxProbs = pickedBoxProbs + result.pickedLabels = pickedLabels + result.pickedBoxes = pickedBoxes + + return result + + } + +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/SSDOcrDetect.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/SSDOcrDetect.swift new file mode 100644 index 00000000..3650af65 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/SSDOcrDetect.swift @@ -0,0 +1,207 @@ +// +// SSDOcrDetect.swift +// CardScan +// +// Created by xaen on 3/21/20. +// + +import CoreGraphics +import Foundation +import UIKit + +/// Documentation for SSD OCR + +@_spi(STP) public class SSDOcrDetect { + @AtomicProperty var ssdOcrModel: SSDOcr? + static var priors: [CGRect]? + + static var ssdOcrResource = "SSDOcr" + static let ssdOcrExtension = "mlmodelc" + + // SSD Model parameters + static let sigma: Float = 0.5 + let ssdOcrImageWidth = 600 + let ssdOcrImageHeight = 375 + let probThreshold: Float = 0.45 + let filterThreshold: Float = 0.39 + let iouThreshold: Float = 0.5 + let centerVariance: Float = 0.1 + let sizeVariance: Float = 0.2 + let candidateSize = 200 + let topK = 20 + + // Statistics about last prediction + var lastDetectedBoxes: [CGRect] = [] + static var hasPrintedInitError = false + + func warmUp() { + SSDOcrDetect.initializeModels() + UIGraphicsBeginImageContext( + CGSize( + width: ssdOcrImageWidth, + height: ssdOcrImageHeight + ) + ) + UIColor.white.setFill() + UIRectFill( + CGRect( + x: 0, + y: 0, + width: ssdOcrImageWidth, + height: ssdOcrImageHeight + ) + ) + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + guard let ssdOcrModel = ssdOcrModel else { + return + } + if let pixelBuffer = newImage?.pixelBuffer( + width: ssdOcrImageWidth, + height: ssdOcrImageHeight + ) { + let input = SSDOcrInput(_0: pixelBuffer) + _ = try? ssdOcrModel.prediction(input: input) + } + } + + @_spi(STP) public static func loadModelFromBundle() -> SSDOcr? { + guard + let ssdOcrUrl = StripeCardScanBundleLocator.resourcesBundle.url( + forResource: SSDOcrDetect.ssdOcrResource, + withExtension: SSDOcrDetect.ssdOcrExtension + ) + else { + return nil + } + + return try? SSDOcr(contentsOf: ssdOcrUrl) + } + + init() { + if SSDOcrDetect.priors == nil { + SSDOcrDetect.priors = OcrPriorsGen.combinePriors() + } + + loadModel() + } + + static func initializeModels() { + if SSDOcrDetect.priors == nil { + SSDOcrDetect.priors = OcrPriorsGen.combinePriors() + } + } + + private func loadModel() { + guard + let ssdOcrUrl = StripeCardScanBundleLocator.resourcesBundle.url( + forResource: SSDOcrDetect.ssdOcrResource, + withExtension: SSDOcrDetect.ssdOcrExtension + ) + else { + return + } + + SSDOcr.asyncLoad(contentsOf: ssdOcrUrl) { [weak self] result in + switch result { + case .success(let model): + self?.ssdOcrModel = model + case .failure(let error): + assertionFailure("Error loading model: \(error.localizedDescription)") + } + } + } + + func detectOcrObjects(prediction: SSDOcrOutput, image: UIImage) -> String? { + var DetectedOcrBoxes = DetectedAllOcrBoxes() + + var (scores, boxes, filterArray) = prediction.getScores(filterThreshold: filterThreshold) + let regularBoxes = prediction.convertLocationsToBoxes( + locations: boxes, + priors: SSDOcrDetect.priors ?? OcrPriorsGen.combinePriors(), + centerVariance: centerVariance, + sizeVariance: sizeVariance + ) + let cornerFormBoxes = prediction.centerFormToCornerForm(regularBoxes: regularBoxes) + + (scores, boxes) = prediction.filterScoresAndBoxes( + scores: scores, + boxes: cornerFormBoxes, + filterArray: filterArray, + filterThreshold: filterThreshold + ) + + if scores.isEmpty || boxes.isEmpty { + return nil + } + + let result: Result = PredictionUtilOcr().predictionUtil( + scores: scores, + boxes: boxes, + probThreshold: probThreshold, + iouThreshold: iouThreshold, + candidateSize: candidateSize, + topK: topK + ) + + for idx in 0.. String? { + + SSDOcrDetect.initializeModels() + guard + let pixelBuffer = image.pixelBuffer( + width: ssdOcrImageWidth, + height: ssdOcrImageHeight + ) + else { + return nil + + } + + guard let ocrDetectModel = ssdOcrModel else { + return nil + } + + let input = SSDOcrInput(_0: pixelBuffer) + + guard let prediction = try? ocrDetectModel.prediction(input: input) else { + return nil + } + return self.detectOcrObjects(prediction: prediction, image: image) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/SSDOcrOutputExtensions.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/SSDOcrOutputExtensions.swift new file mode 100644 index 00000000..b634dfd9 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/SSDOcrOutputExtensions.swift @@ -0,0 +1,176 @@ +// +// SSDOcrOutputExtensions.swift +// CardScan +// +// Created by xaen on 3/22/20. +// + +import Accelerate +import Foundation + +extension SSDOcrOutput { + + func getScores(filterThreshold: Float) -> ([[Float]], [[Float]], [Float]) { + let pointerScores = UnsafeMutablePointer(OpaquePointer(self.scores.dataPointer)) + let pointerBoxes = UnsafeMutablePointer(OpaquePointer(self.boxes.dataPointer)) + let pointerFilter = UnsafeMutablePointer(OpaquePointer(self.filter.dataPointer)) + + let numOfRowsScores = self.scores.shape[3].intValue + let numOfColsScores = self.scores.shape[4].intValue + + var scoresTest = [[Float]]( + repeating: [Float]( + repeating: 0.0, + count: numOfColsScores + ), + count: numOfRowsScores + ) + + let numOfRowsBoxes = self.boxes.shape[3].intValue + let numOfColsBoxes = self.boxes.shape[4].intValue + + var boxesTest = [[Float]]( + repeating: [Float]( + repeating: 0.0, + count: numOfColsBoxes + ), + count: numOfRowsBoxes + ) + + var filterArray = [Float]( + repeating: 0.0, + count: numOfRowsScores + ) + + for idx3 in 0.. filterThreshold { + + for idx in countScores.. [[Float]] { + let pointer = UnsafeMutablePointer(OpaquePointer(self.boxes.dataPointer)) + let numOfRows = self.boxes.shape[3].intValue + let numOfCols = self.boxes.shape[4].intValue + + var boxesTest = [[Float]]( + repeating: [Float]( + repeating: 0.0, + count: numOfCols + ), + count: numOfRows + ) + + for idx in 0.. [[Float]] { + + var resultArray: [[Float]] = Array.init() + var elementArray: [Float] = Array.init() + var elementCount: Int = 0 + for firstArray in nums { + for val in firstArray { + elementArray.append(val) + if elementArray.count >= c { + resultArray.append(elementArray) + elementArray.removeAll() + } + elementCount = elementCount + 1 + } + } + if elementCount != r * c { + resultArray = nums + } + return resultArray + } + + func convertLocationsToBoxes( + locations: [[Float]], + priors: [CGRect], + centerVariance: Float, + sizeVariance: Float + ) -> [[Float]] { + + /// SSD into boxes in the form of (center_x, center_y, h, w) + var boxes = [[Float]]() + + for i in 0.. [[Float]] { + + /// * corner form XMin, YMin, XMax, YMax + var cornerFormBoxes = regularBoxes + for i in 0.. ([[Float]], [[Float]]) { + + var prunnedScores = [[Float]]() + var prunnedBoxes = [[Float]]() + + for i in 0.. filterThreshold { + prunnedScores.append(scores[i]) + prunnedBoxes.append(boxes[i]) + } + } + return (prunnedScores, prunnedBoxes) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/SoftNMS.swift b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/SoftNMS.swift new file mode 100644 index 00000000..5393b47f --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/MLRuntime/SoftNMS.swift @@ -0,0 +1,80 @@ +// +// SoftNMS.swift +// CardScan +// +// Created by xaen on 4/3/20. +// + +import Accelerate +import Foundation + +struct SoftNMS { + static func softNMS( + subsetBoxes: [[Float]], + probs: [Float], + probThreshold: Float, + sigma: Float, + topK: Int, + candidateSize: Int + ) -> ([[Float]], [Float]) { + + var pickedBoxes = [[Float]]() + var pickedScores = [Float]() + + var subsetBoxes = subsetBoxes + var probs = probs + + while subsetBoxes.count > 0 { + var maxElement: Float = 0.0 + var vdspIndex: vDSP_Length = 0 + vDSP_maxvi(probs, 1, &maxElement, &vdspIndex, vDSP_Length(probs.count)) + let maxIdx = Int(vdspIndex) + + let currentBox = subsetBoxes[maxIdx] + pickedBoxes.append(subsetBoxes[maxIdx]) + pickedScores.append(maxElement) + + if subsetBoxes.count == 1 { + break + } + + // Take the last box and replace the max box with the last box + subsetBoxes.remove(at: maxIdx) + probs.remove(at: maxIdx) + + var ious = [Float](repeating: 0.0, count: subsetBoxes.count) + let currentBoxRect = CGRect( + x: Double(currentBox[0]), + y: Double(currentBox[1]), + width: Double(currentBox[2] - currentBox[0]), + height: Double(currentBox[3] - currentBox[1]) + ) + + for i in 0.. probThreshold { + probsPrunned.append(probs[i]) + subsetBoxesPrunned.append(subsetBoxes[i]) + } + } + probs = probsPrunned + subsetBoxes = subsetBoxesPrunned + } + + return (pickedBoxes, pickedScores) + } + +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/UI/BlurView.swift b/StripeCardScan/StripeCardScan/Source/CardScan/UI/BlurView.swift new file mode 100644 index 00000000..9efdf2af --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/UI/BlurView.swift @@ -0,0 +1,40 @@ +// +// BlurView.swift +// CardScan +// +// Created by Jaime Park on 8/15/19. +// + +import UIKit + +class BlurView: UIView { + required init?( + coder aDecoder: NSCoder + ) { + super.init(coder: aDecoder) + } + + override init( + frame: CGRect + ) { + super.init(frame: frame) + } + + func maskToRoi(roi: UIView) { + let maskLayer = CAShapeLayer() + let path = CGMutablePath() + let roiCornerRadius = roi.layer.cornerRadius + let roiFrame = roi.layer.frame + let roundedRectpath = UIBezierPath.init( + roundedRect: roiFrame, + cornerRadius: roiCornerRadius + ).cgPath + + path.addRect(self.layer.bounds) + path.addPath(roundedRectpath) + maskLayer.path = path + maskLayer.fillRule = .evenOdd + self.layer.mask = maskLayer + } + +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/UI/CardScanSheet.swift b/StripeCardScan/StripeCardScan/Source/CardScan/UI/CardScanSheet.swift new file mode 100644 index 00000000..cbd93550 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/UI/CardScanSheet.swift @@ -0,0 +1,85 @@ +// +// CardScanSheet.swift +// StripeCardScan +// +// Created by Scott Grant on 6/3/22. +// + +import Foundation +import UIKit + +/// The result of an attempt to scan a card +@frozen public enum CardScanSheetResult { + /// The customer completed the scan + case completed(card: ScannedCard) + + /// The customer canceled the scan + case canceled + + /// The attempt failed. + /// - Parameter error: The error encountered by the customer. You can display its `localizedDescription` to the customer. + case failed(error: Error) +} + +/// A drop-in class that presents a sheet for a customer to scan their card +public class CardScanSheet { + + public init() {} + + /// Presents a sheet for a customer to scan their card + /// - Parameter presentingViewController: The view controller to present a card scan sheet + /// - Parameter completion: Called with the result of the scan after the card scan sheet is dismissed + public func present( + from presentingViewController: UIViewController, + completion: @escaping (CardScanSheetResult) -> Void, + animated: Bool = true + ) { + // Guard against basic user error + guard presentingViewController.presentedViewController == nil else { + assertionFailure("presentingViewController is already presenting a view controller") + let error = CardScanSheetError.unknown( + debugDescription: "presentingViewController is already presenting a view controller" + ) + completion(.failed(error: error)) + return + } + + let vc = SimpleScanViewController() + vc.delegate = self + + // Overwrite completion closure to retain self until called + let overwrittenCompletion: (CardScanSheetResult) -> Void = { status in + // Dismiss if necessary + if vc.presentingViewController != nil { + vc.dismiss(animated: true) { + completion(status) + } + } else { + completion(status) + } + self.completion = nil + } + self.completion = overwrittenCompletion + + presentingViewController.present(vc, animated: animated) + } + + // MARK: - Internal Properties + + /// A user-supplied completion block. Nil until `present` is called. + var completion: ((CardScanSheetResult) -> Void)? +} + +extension CardScanSheet: SimpleScanDelegate { + func userDidCancelSimple(_ scanViewController: SimpleScanViewController) { + completion?(.canceled) + } + + func userDidScanCardSimple( + _ scanViewController: SimpleScanViewController, + creditCard: CreditCard + ) { + let scannedCard = ScannedCard(pan: creditCard.number) + completion?(.completed(card: scannedCard)) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/UI/CornerView.swift b/StripeCardScan/StripeCardScan/Source/CardScan/UI/CornerView.swift new file mode 100644 index 00000000..1722fc48 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/UI/CornerView.swift @@ -0,0 +1,72 @@ +import UIKit + +class CornerView: UIView { + required init?( + coder aDecoder: NSCoder + ) { + super.init(coder: aDecoder) + } + + override init( + frame: CGRect + ) { + super.init(frame: frame) + } + + func setFrameSize(roi: UIView) { + let borderWidth = self.layer.borderWidth + let width = roi.layer.bounds.width + 2 * borderWidth + let height = roi.layer.bounds.height + 2 * borderWidth + let cornerViewBoundRect = CGRect( + x: self.layer.bounds.origin.x, + y: self.layer.bounds.origin.y, + width: width, + height: height + ) + self.layer.bounds = cornerViewBoundRect + } + + func drawCorners() { + let maskShapeLayer = CAShapeLayer() + let maskPath = CGMutablePath() + + let boundX = self.layer.bounds.origin.x + let boundY = self.layer.bounds.origin.y + let boundWidth = self.layer.bounds.width + let boundHeight = self.layer.bounds.height + + let cornerMultiplier = CGFloat(0.1) + let cornerLength = self.layer.frame.width * cornerMultiplier + + // top left corner + maskPath.move(to: self.layer.bounds.origin) + maskPath.addLine(to: CGPoint(x: boundX + cornerLength, y: boundY)) + maskPath.addLine(to: CGPoint(x: boundX + cornerLength, y: boundY + cornerLength)) + maskPath.addLine(to: CGPoint(x: boundX, y: boundY + cornerLength)) + maskPath.closeSubpath() + + // top right corner + maskPath.move(to: CGPoint(x: boundWidth - cornerLength, y: boundY)) + maskPath.addLine(to: CGPoint(x: boundWidth, y: boundY)) + maskPath.addLine(to: CGPoint(x: boundWidth, y: boundY + cornerLength)) + maskPath.addLine(to: CGPoint(x: boundWidth - cornerLength, y: boundY + cornerLength)) + maskPath.closeSubpath() + + // bottom left corner + maskPath.move(to: CGPoint(x: boundX, y: boundHeight - cornerLength)) + maskPath.addLine(to: CGPoint(x: boundX + cornerLength, y: boundHeight - cornerLength)) + maskPath.addLine(to: CGPoint(x: boundX + cornerLength, y: boundHeight)) + maskPath.addLine(to: CGPoint(x: boundX, y: boundHeight)) + maskPath.closeSubpath() + + // bottom right corner + maskPath.move(to: CGPoint(x: boundWidth - cornerLength, y: boundHeight - cornerLength)) + maskPath.addLine(to: CGPoint(x: boundWidth, y: boundHeight - cornerLength)) + maskPath.addLine(to: CGPoint(x: boundWidth, y: boundHeight)) + maskPath.addLine(to: CGPoint(x: boundWidth - cornerLength, y: boundHeight)) + maskPath.closeSubpath() + + maskShapeLayer.path = maskPath + self.layer.mask = maskShapeLayer + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/UI/InterfaceOrientation.swift b/StripeCardScan/StripeCardScan/Source/CardScan/UI/InterfaceOrientation.swift new file mode 100644 index 00000000..95eb2b1c --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/UI/InterfaceOrientation.swift @@ -0,0 +1,56 @@ +import AVFoundation +// +// InterfaceOrientation.swift +// CardScan +// +// Created by Jaime Park on 4/23/20. +// +import UIKit + +extension UIWindow { + static var interfaceOrientation: UIInterfaceOrientation { + return + UIApplication.shared.windows + .first? + .windowScene? + .interfaceOrientation ?? .unknown + } + + static var interfaceOrientationToString: String { + switch self.interfaceOrientation { + case .portrait: return "portrait" + case .portraitUpsideDown: return "portrait_upside_down" + case .landscapeRight: return "landscape_right" + case .landscapeLeft: return "landscape_left" + case .unknown: return "unknown" + @unknown default: + return "unknown" + } + } +} + +extension AVCaptureVideoOrientation { + init?( + deviceOrientation: UIDeviceOrientation + ) { + switch deviceOrientation { + case .portrait: self = .portrait + case .portraitUpsideDown: self = .portraitUpsideDown + case .landscapeLeft: self = .landscapeRight + case .landscapeRight: self = .landscapeLeft + default: return nil + } + } + + init?( + interfaceOrientation: UIInterfaceOrientation + ) { + switch interfaceOrientation { + case .portrait: self = .portrait + case .portraitUpsideDown: self = .portraitUpsideDown + case .landscapeLeft: self = .landscapeLeft + case .landscapeRight: self = .landscapeRight + default: return nil + } + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/UI/PreviewView.swift b/StripeCardScan/StripeCardScan/Source/CardScan/UI/PreviewView.swift new file mode 100644 index 00000000..9b1040db --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/UI/PreviewView.swift @@ -0,0 +1,90 @@ +// Sample code project: AVCam-iOS: Using AVFoundation to Capture Images and Movies +// Version: 5.0 +// +// IMPORTANT: This Apple software is supplied to you by Apple +// Inc. ("Apple") in consideration of your agreement to the following +// terms, and your use, installation, modification or redistribution of +// this Apple software constitutes acceptance of these terms. If you do +// not agree with these terms, please do not use, install, modify or +// redistribute this Apple software. +// +// In consideration of your agreement to abide by the following terms, and +// subject to these terms, Apple grants you a personal, non-exclusive +// license, under Apple's copyrights in this original Apple software (the +// "Apple Software"), to use, reproduce, modify and redistribute the Apple +// Software, with or without modifications, in source and/or binary forms; +// provided that if you redistribute the Apple Software in its entirety and +// without modifications, you must retain this notice and the following +// text and disclaimers in all such redistributions of the Apple Software. +// Neither the name, trademarks, service marks or logos of Apple Inc. may +// be used to endorse or promote products derived from the Apple Software +// without specific prior written permission from Apple. Except as +// expressly stated in this notice, no other rights or licenses, express or +// implied, are granted by Apple herein, including but not limited to any +// patent rights that may be infringed by your derivative works or by other +// works in which the Apple Software may be incorporated. +// +// The Apple Software is provided by Apple on an "AS IS" basis. APPLE +// MAKES NO WARRANTIES, EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION +// THE IMPLIED WARRANTIES OF NON-INFRINGEMENT, MERCHANTABILITY AND FITNESS +// FOR A PARTICULAR PURPOSE, REGARDING THE APPLE SOFTWARE OR ITS USE AND +// OPERATION ALONE OR IN COMBINATION WITH YOUR PRODUCTS. +// +// IN NO EVENT SHALL APPLE BE LIABLE FOR ANY SPECIAL, INDIRECT, INCIDENTAL +// OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) ARISING IN ANY WAY OUT OF THE USE, REPRODUCTION, +// MODIFICATION AND/OR DISTRIBUTION OF THE APPLE SOFTWARE, HOWEVER CAUSED +// AND WHETHER UNDER THEORY OF CONTRACT, TORT (INCLUDING NEGLIGENCE), +// STRICT LIABILITY OR OTHERWISE, EVEN IF APPLE HAS BEEN ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. +// +// Copyright (C) 2015 Apple Inc. All Rights Reserved. + +import AVFoundation +import UIKit + +class PreviewView: UIView { + var videoPreviewLayer: AVCaptureVideoPreviewLayer { + guard let layer = layer as? AVCaptureVideoPreviewLayer else { + fatalError( + "Expected `AVCaptureVideoPreviewLayer` type for layer. Check PreviewView.layerClass implementation." + ) + } + layer.videoGravity = .resizeAspectFill + return layer + } + + var session: AVCaptureSession? { + get { + return videoPreviewLayer.session + } + set { + videoPreviewLayer.session = newValue + } + } + + // MARK: Initialization + + override init( + frame: CGRect + ) { + super.init(frame: frame) + } + + required init?( + coder aDecoder: NSCoder + ) { + super.init(coder: aDecoder) + } + + // MARK: UIView + + override class var layerClass: AnyClass { + return AVCaptureVideoPreviewLayer.self + } + + override func layoutSubviews() { + super.layoutSubviews() + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/UI/ScanBaseViewController.swift b/StripeCardScan/StripeCardScan/Source/CardScan/UI/ScanBaseViewController.swift new file mode 100644 index 00000000..4c1600f7 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/UI/ScanBaseViewController.swift @@ -0,0 +1,582 @@ +import AVKit +import UIKit +import Vision + +protocol TestingImageDataSource: AnyObject { + func nextSquareAndFullImage() -> CGImage? +} + +class ScanBaseViewController: UIViewController, AVCaptureVideoDataOutputSampleBufferDelegate, + AfterPermissions, OcrMainLoopDelegate +{ + + lazy var testingImageDataSource: TestingImageDataSource? = { + var result: TestingImageDataSource? + #if targetEnvironment(simulator) + if ProcessInfo.processInfo.environment["UITesting"] != nil { + result = EndToEndTestingImageDataSource() + } + #endif // targetEnvironment(simulator) + return result + }() + + var includeCardImage = false + var showDebugImageView = false + + var scanEventsDelegate: ScanEvents? + + static var isAppearing = false + static var isPadAndFormsheet: Bool = false + static let machineLearningQueue = DispatchQueue(label: "CardScanMlQueue") + private let machineLearningSemaphore = DispatchSemaphore(value: 1) + + private weak var debugImageView: UIImageView? + private weak var previewView: PreviewView? + private weak var regionOfInterestLabel: UIView? + private weak var blurView: BlurView? + private weak var cornerView: CornerView? + private var regionOfInterestLabelFrame: CGRect? + private var previewViewFrame: CGRect? + + var videoFeed = VideoFeed() + var initialVideoOrientation: AVCaptureVideoOrientation { + if ScanBaseViewController.isPadAndFormsheet { + return AVCaptureVideoOrientation(interfaceOrientation: UIWindow.interfaceOrientation) + ?? .portrait + } else { + return .portrait + } + } + + var scannedCardImage: UIImage? + private var isNavigationBarHidden: Bool? + var hideNavigationBar: Bool? + var regionOfInterestCornerRadius = CGFloat(10.0) + private var calledOnScannedCard = false + + /// Flag to keep track of first time pan is observed + private var firstPanObserved: Bool = false + /// Flag to keep track of first time frame is processed + private var firstImageProcessed: Bool = false + + var mainLoop: MachineLearningLoop? + private func ocrMainLoop() -> OcrMainLoop? { + mainLoop.flatMap { $0 as? OcrMainLoop } + } + // this is a hack to avoid changing our interface + var predictedName: String? + + // Child classes should override these functions + func onScannedCard( + number: String, + expiryYear: String?, + expiryMonth: String?, + scannedImage: UIImage? + ) {} + func showCardNumber(_ number: String, expiry: String?) {} + func showWrongCard(number: String?, expiry: String?, name: String?) {} + func showNoCard() {} + func onCameraPermissionDenied(showedPrompt: Bool) {} + func useCurrentFrameNumber(errorCorrectedNumber: String?, currentFrameNumber: String) -> Bool { + return true + } + + // MARK: Inits + init() { + super.init(nibName: nil, bundle: nil) + } + + required init?( + coder: NSCoder + ) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Torch Logic + func toggleTorch() { + self.ocrMainLoop()?.scanStats.torchOn = !(self.ocrMainLoop()?.scanStats.torchOn ?? false) + self.videoFeed.toggleTorch() + } + + func isTorchOn() -> Bool { + return self.videoFeed.isTorchOn() + } + + func hasTorchAndIsAvailable() -> Bool { + return self.videoFeed.hasTorchAndIsAvailable() + } + + func setTorchLevel(level: Float) { + if 0.0...1.0 ~= level { + self.videoFeed.setTorchLevel(level: level) + } + } + + static func configure(apiKey: String? = nil) { + // TODO: remove this and just use stripe's main configuration path + } + + static func supportedOrientationMaskOrDefault() -> UIInterfaceOrientationMask { + guard ScanBaseViewController.isAppearing else { + // If the ScanBaseViewController isn't appearing then fall back + // to getting the orientation mask from the infoDictionary, just like + // the system would do if the user didn't override the + // supportedInterfaceOrientationsFor method + let supportedOrientations = + (Bundle.main.infoDictionary?["UISupportedInterfaceOrientations"] as? [String]) ?? [ + "UIInterfaceOrientationPortrait" + ] + + let maskArray = supportedOrientations.map { option -> UIInterfaceOrientationMask in + switch option { + case "UIInterfaceOrientationPortrait": + return UIInterfaceOrientationMask.portrait + case "UIInterfaceOrientationPortraitUpsideDown": + return UIInterfaceOrientationMask.portraitUpsideDown + case "UIInterfaceOrientationLandscapeLeft": + return UIInterfaceOrientationMask.landscapeLeft + case "UIInterfaceOrientationLandscapeRight": + return UIInterfaceOrientationMask.landscapeRight + default: + return UIInterfaceOrientationMask.portrait + } + } + + let mask: UIInterfaceOrientationMask = maskArray.reduce( + UIInterfaceOrientationMask.portrait + ) { result, element in + return UIInterfaceOrientationMask(rawValue: result.rawValue | element.rawValue) + } + + return mask + } + return ScanBaseViewController.isPadAndFormsheet ? .allButUpsideDown : .portrait + } + + static func isCompatible() -> Bool { + return self.isCompatible(configuration: ScanConfiguration()) + } + + static func isCompatible(configuration: ScanConfiguration) -> Bool { + // check to see if the user has already denined camera permission + let authorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) + if authorizationStatus != .authorized && authorizationStatus != .notDetermined + && configuration.setPreviouslyDeniedDevicesAsIncompatible + { + return false + } + + // make sure that we don't run on iPhone 6 / 6plus or older + if configuration.runOnOldDevices { + return true + } + + return true + } + + func cancelScan() { + guard let ocrMainLoop = ocrMainLoop() else { + return + } + ocrMainLoop.userCancelled() + } + + func setupMask() { + guard let roi = self.regionOfInterestLabel else { return } + guard let blurView = self.blurView else { return } + blurView.maskToRoi(roi: roi) + } + + func setUpCorners() { + guard let roi = self.regionOfInterestLabel else { return } + guard let corners = self.cornerView else { return } + corners.setFrameSize(roi: roi) + corners.drawCorners() + } + + func permissionDidComplete(granted: Bool, showedPrompt: Bool) { + self.ocrMainLoop()?.scanStats.permissionGranted = granted + if !granted { + self.onCameraPermissionDenied(showedPrompt: showedPrompt) + } + ScanAnalyticsManager.shared.logCameraPermissionsTask(success: granted) + } + + // you must call setupOnViewDidLoad before calling this function and you have to call + // this function to get the camera going + func startCameraPreview() { + self.videoFeed.requestCameraAccess(permissionDelegate: self) + } + + internal func invokeFakeLoop() { + guard let dataSource = testingImageDataSource else { + return + } + + guard let fullTestingImage = dataSource.nextSquareAndFullImage() else { + return + } + + guard let roiFrame = self.regionOfInterestLabelFrame, + let previewViewFrame = self.previewViewFrame, + let roiRectInPixels = ScannedCardImageData.convertToPreviewLayerRect( + captureDeviceImage: fullTestingImage, + viewfinderRect: roiFrame, + previewViewRect: previewViewFrame + ) + else { + return + } + + mainLoop?.push( + imageData: ScannedCardImageData( + previewLayerImage: fullTestingImage, + previewLayerViewfinderRect: roiRectInPixels + ) + ) + } + + internal func startFakeCameraLoop() { + let timer = Timer(timeInterval: 0.1, repeats: true) { [weak self] _ in + self?.invokeFakeLoop() + } + RunLoop.main.add(timer, forMode: .default) + } + + func isSimulator() -> Bool { + #if targetEnvironment(simulator) + return true + #else + return false + #endif + } + + func setupOnViewDidLoad( + regionOfInterestLabel: UIView, + blurView: BlurView, + previewView: PreviewView, + cornerView: CornerView?, + debugImageView: UIImageView?, + torchLevel: Float? + ) { + + self.regionOfInterestLabel = regionOfInterestLabel + self.blurView = blurView + self.previewView = previewView + self.debugImageView = debugImageView + self.debugImageView?.contentMode = .scaleAspectFit + self.cornerView = cornerView + ScanBaseViewController.isPadAndFormsheet = + UIDevice.current.userInterfaceIdiom == .pad && self.modalPresentationStyle == .formSheet + + setNeedsStatusBarAppearanceUpdate() + regionOfInterestLabel.layer.masksToBounds = true + regionOfInterestLabel.layer.cornerRadius = self.regionOfInterestCornerRadius + regionOfInterestLabel.layer.borderColor = UIColor.white.cgColor + regionOfInterestLabel.layer.borderWidth = 2.0 + + if !ScanBaseViewController.isPadAndFormsheet { + UIDevice.current.setValue(UIDeviceOrientation.portrait.rawValue, forKey: "orientation") + } + + mainLoop = createOcrMainLoop() + + if testingImageDataSource != nil { + self.ocrMainLoop()?.imageQueueSize = 20 + } + + self.ocrMainLoop()?.mainLoopDelegate = self + self.previewView?.videoPreviewLayer.session = self.videoFeed.session + + self.videoFeed.pauseSession() + // Apple example app sets up in viewDidLoad: https://developer.apple.com/documentation/avfoundation/cameras_and_media_capture/avcam_building_a_camera_app + self.videoFeed.setup( + captureDelegate: self, + initialVideoOrientation: self.initialVideoOrientation, + completion: { success in + if self.previewView?.videoPreviewLayer.connection?.isVideoOrientationSupported + ?? false + { + self.previewView?.videoPreviewLayer.connection?.videoOrientation = + self.initialVideoOrientation + } + if let level = torchLevel { + self.setTorchLevel(level: level) + } + + if !success && self.testingImageDataSource != nil && self.isSimulator() { + self.startFakeCameraLoop() + } + } + ) + } + + func createOcrMainLoop() -> OcrMainLoop? { + OcrMainLoop() + } + + override var shouldAutorotate: Bool { + return true + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return ScanBaseViewController.isPadAndFormsheet ? .allButUpsideDown : .portrait + } + + override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { + return ScanBaseViewController.isPadAndFormsheet ? UIWindow.interfaceOrientation : .portrait + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + override func viewWillTransition( + to size: CGSize, + with coordinator: UIViewControllerTransitionCoordinator + ) { + super.viewWillTransition(to: size, with: coordinator) + + if let videoFeedConnection = self.videoFeed.videoDeviceConnection, + videoFeedConnection.isVideoOrientationSupported + { + videoFeedConnection.videoOrientation = + AVCaptureVideoOrientation(deviceOrientation: UIDevice.current.orientation) + ?? .portrait + } + if let previewViewConnection = self.previewView?.videoPreviewLayer.connection, + previewViewConnection.isVideoOrientationSupported + { + previewViewConnection.videoOrientation = + AVCaptureVideoOrientation(deviceOrientation: UIDevice.current.orientation) + ?? .portrait + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + ScanBaseViewController.isAppearing = true + /// Set beginning of scan session + ScanAnalyticsManager.shared.setScanSessionStartTime(time: Date()) + /// Check and log torch availability + ScanAnalyticsManager.shared.logTorchSupportTask( + supported: videoFeed.hasTorchAndIsAvailable() + ) + self.ocrMainLoop()?.reset() + self.calledOnScannedCard = false + self.videoFeed.willAppear() + self.isNavigationBarHidden = self.navigationController?.isNavigationBarHidden ?? true + let hideNavigationBar = self.hideNavigationBar ?? true + self.navigationController?.setNavigationBarHidden(hideNavigationBar, animated: animated) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + self.view.layoutIfNeeded() + guard let roiFrame = self.regionOfInterestLabel?.frame, + let previewViewFrame = self.previewView?.frame + else { return } + // store .frame to avoid accessing UI APIs in the machineLearningQueue + self.regionOfInterestLabelFrame = roiFrame + self.previewViewFrame = previewViewFrame + self.setUpCorners() + self.setupMask() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + self.ocrMainLoop()?.scanStats.orientation = UIWindow.interfaceOrientationToString + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + self.videoFeed.willDisappear() + self.navigationController?.setNavigationBarHidden( + self.isNavigationBarHidden ?? false, + animated: animated + ) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + ScanBaseViewController.isAppearing = false + } + + func getScanStats() -> ScanStats { + return self.ocrMainLoop()?.scanStats ?? ScanStats() + } + + func captureOutput( + _ output: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + if self.machineLearningSemaphore.wait(timeout: .now()) == .success { + ScanBaseViewController.machineLearningQueue.async { + self.captureOutputWork(sampleBuffer: sampleBuffer) + self.machineLearningSemaphore.signal() + } + } + } + + func captureOutputWork(sampleBuffer: CMSampleBuffer) { + guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { + return + } + + guard let fullCameraImage = pixelBuffer.cgImage() else { + return + } + + // confirm videoGravity settings in previewView. Calculations based on .resizeAspectFill + DispatchQueue.main.async { + assert(self.previewView?.videoPreviewLayer.videoGravity == .resizeAspectFill) + } + + guard let roiFrame = self.regionOfInterestLabelFrame, + let previewViewFrame = self.previewViewFrame, + let scannedImageData = ScannedCardImageData( + captureDeviceImage: fullCameraImage, + viewfinderRect: roiFrame, + previewViewRect: previewViewFrame + ) + else { + return + } + + // we allow apps that integrate to supply their own sequence of images + // for use in testing + if let dataSource = testingImageDataSource { + guard let fullTestingImage = dataSource.nextSquareAndFullImage() else { + return + } + mainLoop?.push( + imageData: ScannedCardImageData( + previewLayerImage: fullTestingImage, + previewLayerViewfinderRect: roiFrame + ) + ) + } else { + mainLoop?.push(imageData: scannedImageData) + } + } + + func updateDebugImageView(image: UIImage) { + self.debugImageView?.image = image + if self.debugImageView?.isHidden ?? false { + self.debugImageView?.isHidden = false + } + } + + // MARK: - OcrMainLoopComplete logic + func complete(creditCardOcrResult: CreditCardOcrResult) { + ocrMainLoop()?.mainLoopDelegate = nil + /// Stop the previewing when we are done + self.previewView?.videoPreviewLayer.session?.stopRunning() + /// Log total frames processed + ScanAnalyticsManager.shared.logMainLoopImageProcessedRepeatingTask( + .init(executions: self.getScanStats().scans) + ) + ScanAnalyticsManager.shared.logScanActivityTaskFromStartTime(event: .cardScanned) + + ScanBaseViewController.machineLearningQueue.async { + self.scanEventsDelegate?.onScanComplete(scanStats: self.getScanStats()) + } + + // hack to work around having to change our interface + predictedName = creditCardOcrResult.name + self.onScannedCard( + number: creditCardOcrResult.number, + expiryYear: creditCardOcrResult.expiryYear, + expiryMonth: creditCardOcrResult.expiryMonth, + scannedImage: scannedCardImage + ) + } + + func prediction( + prediction: CreditCardOcrPrediction, + imageData: ScannedCardImageData, + state: MainLoopState + ) { + if !firstImageProcessed { + ScanAnalyticsManager.shared.logScanActivityTaskFromStartTime( + event: .firstImageProcessed + ) + firstImageProcessed = true + } + + if self.showDebugImageView { + let numberBoxes = prediction.numberBoxes?.map { (UIColor.blue, $0) } ?? [] + let expiryBoxes = prediction.expiryBoxes?.map { (UIColor.red, $0) } ?? [] + let nameBoxes = prediction.nameBoxes?.map { (UIColor.green, $0) } ?? [] + + if self.debugImageView?.isHidden ?? false { + self.debugImageView?.isHidden = false + } + + self.debugImageView?.image = prediction.image.drawBoundingBoxesOnImage( + boxes: numberBoxes + expiryBoxes + nameBoxes + ) + } + + if prediction.number != nil && self.includeCardImage { + self.scannedCardImage = UIImage(cgImage: prediction.image) + } + + let isFlashForcedOn: Bool + switch state { + case .ocrForceFlash: isFlashForcedOn = true + default: isFlashForcedOn = false + } + + if let number = prediction.number { + if !firstPanObserved { + ScanAnalyticsManager.shared.logScanActivityTaskFromStartTime(event: .ocrPanObserved) + firstPanObserved = true + } + + let expiry = prediction.expiryObject() + + ScanBaseViewController.machineLearningQueue.async { + self.scanEventsDelegate?.onNumberRecognized( + number: number, + expiry: expiry, + imageData: imageData, + centeredCardState: prediction.centeredCardState, + flashForcedOn: isFlashForcedOn + ) + } + } else { + ScanBaseViewController.machineLearningQueue.async { + self.scanEventsDelegate?.onFrameDetected( + imageData: imageData, + centeredCardState: prediction.centeredCardState, + flashForcedOn: isFlashForcedOn + ) + } + } + } + + func showCardDetails(number: String?, expiry: String?, name: String?) { + guard let number = number else { return } + showCardNumber(number, expiry: expiry) + } + + func showCardDetailsWithFlash(number: String?, expiry: String?, name: String?) { + if !isTorchOn() { toggleTorch() } + guard let number = number else { return } + showCardNumber(number, expiry: expiry) + } + + func shouldUsePrediction( + errorCorrectedNumber: String?, + prediction: CreditCardOcrPrediction + ) -> Bool { + guard let predictedNumber = prediction.number else { return true } + return useCurrentFrameNumber( + errorCorrectedNumber: errorCorrectedNumber, + currentFrameNumber: predictedNumber + ) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/UI/ScanConfiguration.swift b/StripeCardScan/StripeCardScan/Source/CardScan/UI/ScanConfiguration.swift new file mode 100644 index 00000000..6c0c1ff5 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/UI/ScanConfiguration.swift @@ -0,0 +1,11 @@ +import Foundation + +enum ScanPerformance: Int { + case fast + case accurate +} + +class ScanConfiguration: NSObject { + var runOnOldDevices = false + var setPreviouslyDeniedDevicesAsIncompatible = false +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/UI/ScanEventsProtocol.swift b/StripeCardScan/StripeCardScan/Source/CardScan/UI/ScanEventsProtocol.swift new file mode 100644 index 00000000..f5e32c48 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/UI/ScanEventsProtocol.swift @@ -0,0 +1,26 @@ +// +// This protocol provides extensibility for inspecting scanning results as they +// happen. As the model detects a cc number it will invoke `onNumberRecognized` +// and when it's done it notifies via `onScanComplete`. +// +// Both of these methods will always be invoked on the machineLearningQueue +// serial dispatch queue. +// + +import CoreGraphics + +protocol ScanEvents { + mutating func onNumberRecognized( + number: String, + expiry: Expiry?, + imageData: ScannedCardImageData, + centeredCardState: CenteredCardState?, + flashForcedOn: Bool + ) + mutating func onScanComplete(scanStats: ScanStats) + mutating func onFrameDetected( + imageData: ScannedCardImageData, + centeredCardState: CenteredCardState?, + flashForcedOn: Bool + ) +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/UI/ScanStats.swift b/StripeCardScan/StripeCardScan/Source/CardScan/UI/ScanStats.swift new file mode 100644 index 00000000..23219299 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/UI/ScanStats.swift @@ -0,0 +1,84 @@ +// +// ScanStats.swift +// CardScan +// +// Created by Sam King on 11/13/18. +// +import CoreGraphics +import Foundation +import UIKit + +struct ScanStats { + var startTime = Date() + var scans = 0 + var flatDigitsRecognized = 0 + var flatDigitsDetected = 0 + var embossedDigitsRecognized = 0 + var embossedDigitsDetected = 0 + var torchOn = false + var orientation = "Portrait" + var success: Bool? + var endTime: Date? + var model: String? + var algorithm: String? + var bin: String? + var lastFlatBoxes: [CGRect]? + var lastEmbossedBoxes: [CGRect]? + var deviceType: String? + var numberRect: CGRect? + var expiryBoxes: [CGRect]? + var cardsDetected = 0 + var permissionGranted: Bool? + var userCanceled: Bool = false + + init() { + var systemInfo = utsname() + uname(&systemInfo) + var deviceType = "" + for char in Mirror(reflecting: systemInfo.machine).children { + guard let charDigit = (char.value as? Int8) else { + return + } + + if charDigit == 0 { + break + } + + deviceType += String(UnicodeScalar(UInt8(charDigit))) + } + + self.deviceType = deviceType + } + + func toDictionaryForAnalytics() -> [String: Any] { + return [ + "scans": self.scans, + "cards_detected": self.cardsDetected, + "torch_on": self.torchOn, + "orientation": self.orientation, + "success": self.success ?? false, + "duration": self.duration(), + "model": self.model ?? "unknown", + "permission_granted": self.permissionGranted.map { $0 ? "granted" : "denied" } + ?? "not_determined", + "device_type": self.deviceType ?? "", + "user_canceled": self.userCanceled, + ] + } + + func duration() -> Double { + guard let endTime = self.endTime else { + return 0.0 + } + + return endTime.timeIntervalSince(self.startTime) + } + + func image(from base64String: String?) -> UIImage? { + guard let string = base64String else { + return nil + } + + return Data(base64Encoded: string).flatMap { UIImage(data: $0) } + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/UI/SimpleScanViewController.swift b/StripeCardScan/StripeCardScan/Source/CardScan/UI/SimpleScanViewController.swift new file mode 100644 index 00000000..27fdfb07 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/UI/SimpleScanViewController.swift @@ -0,0 +1,542 @@ +@_spi(STP) import StripeCore +import UIKit + +// This class is all programmatic UI with a small bit of logic to handle +// the events that ScanBaseViewController expects subclasses to implement. +// Our goal is to have a fully featured Card Scan implementation with a +// minimal UI that people can customize fully. You can use this directly or +// you can subclass and customize it. If you'd like to use an off-the-shelf +// design as well, we suggest using the `ScanViewController`, which uses +// mature and well tested UI design patterns. +// +// The default UI looks something like this, with most of the constraints +// shown: +// +// ------------------------------------ +// | | | | +// |-Cancel Torch-| +// | | +// | | +// | | +// | | +// | | +// |------------Scan Card-------------| +// | | | +// | ------------------------------ | +// | | | | +// | | | | +// | | | | +// | |--4242 4242 4242 4242--| | +// | || 05/23 | | +// | ||-Sam King | | +// | | | | | +// | ------------------------------ | +// | | | | | +// | | | | | +// | | Enable camera permissions | | +// | | | | | +// | | | | | +// | |---To scan your card you...---| | +// | | +// | | +// | | +// ------------------------------------ +// +// For the UI we separate out the key components into three parts: +// - Five `*String` variables that we use to set the copy +// - For each component or group of components we have: +// - `setup*Ui` functions for setting the visual look and feel +// - `setup*Constraints for setting up autolayout +// - We have top level `setupUiComponents` and `setupConstraints` functions that do +// a small bit of setup and call the appropriate setup functions for each +// components +// +// And to customize the UI you can either override any of these functions or you +// can access components directly to adjust. Also, you're welcome to copy and paste +// this code and customize it to fit your needs -- we're fine with whatever makes +// the most sense for your app. + +protocol SimpleScanDelegate: AnyObject { + func userDidCancelSimple(_ scanViewController: SimpleScanViewController) + func userDidScanCardSimple( + _ scanViewController: SimpleScanViewController, + creditCard: CreditCard + ) +} + +class SimpleScanViewController: ScanBaseViewController { + + // used by ScanBase + var previewView: PreviewView = PreviewView() + var blurView: BlurView = BlurView() + var roiView: UIView = UIView() + var cornerView: CornerView? + + // our UI components + var descriptionText = UILabel() + var privacyLinkText = UITextView() + var privacyLinkTextHeightConstraint: NSLayoutConstraint? + + var closeButton: UIButton = { + var button = UIButton(type: .system) + button.setTitleColor(.white, for: .normal) + button.tintColor = .white + button.setTitle(SimpleScanViewController.closeButtonString, for: .normal) + return button + }() + + var torchButton: UIButton = { + var button = UIButton(type: .system) + button.setTitleColor(.white, for: .normal) + button.tintColor = .white + button.setTitle(SimpleScanViewController.torchButtonString, for: .normal) + return button + }() + + private var debugView: UIImageView? + var enableCameraPermissionsButton = UIButton(type: .system) + var enableCameraPermissionsText = UILabel() + + // Dynamic card details + var numberText = UILabel() + var expiryText = UILabel() + var nameText = UILabel() + var expiryLayoutView = UIView() + + // String + static var descriptionString = String.Localized.scan_card_title_capitalization + static var enableCameraPermissionString = String.Localized.enable_camera_access + static var enableCameraPermissionsDescriptionString = String.Localized.update_phone_settings + static var closeButtonString = String.Localized.close + static var torchButtonString = String.Localized.torch + static var privacyLinkString = String.Localized.scanCardExpectedPrivacyLinkText() + + weak var delegate: SimpleScanDelegate? + var scanPerformancePriority: ScanPerformance = .fast + var maxErrorCorrectionDuration: Double = 4.0 + + // MARK: Inits + override init() { + super.init() + if UIDevice.current.userInterfaceIdiom == .pad { + // For the iPad you can use the full screen style but you have to select "requires full screen" in + // the Info.plist to lock it in portrait mode. For iPads, we recommend using a formSheet, which + // handles all orientations correctly. + self.modalPresentationStyle = .formSheet + } else { + self.modalPresentationStyle = .fullScreen + } + } + + required init?( + coder: NSCoder + ) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + setupUiComponents() + setupConstraints() + + setupOnViewDidLoad( + regionOfInterestLabel: roiView, + blurView: blurView, + previewView: previewView, + cornerView: cornerView, + debugImageView: debugView, + torchLevel: 1.0 + ) + + setUpMainLoop(errorCorrectionDuration: maxErrorCorrectionDuration) + + startCameraPreview() + } + + // Removing targets manually since we are allowing custom buttons which retains button reference -> + // ARC doesn't automatically decrement its reference count -> + // Targets gets added on every setUpUi call. + // + // Figure out a better way of allow custom buttons programmatically instead of whole UI buttons. + override func viewDidDisappear(_ animated: Bool) { + closeButton.removeTarget(self, action: #selector(cancelButtonPress), for: .touchUpInside) + torchButton.removeTarget(self, action: #selector(torchButtonPress), for: .touchUpInside) + } + + func setUpMainLoop(errorCorrectionDuration: Double) { + if scanPerformancePriority == .accurate { + let mainLoop = self.mainLoop as? OcrMainLoop + mainLoop?.errorCorrection = ErrorCorrection( + stateMachine: OcrAccurateMainLoopStateMachine( + maxErrorCorrection: maxErrorCorrectionDuration + ) + ) + } + } + + // MARK: - Visual and UI event setup for UI components + func setupUiComponents() { + view.backgroundColor = .white + regionOfInterestCornerRadius = 15.0 + + let children: [UIView] = [ + previewView, blurView, roiView, descriptionText, closeButton, torchButton, numberText, + expiryText, nameText, expiryLayoutView, enableCameraPermissionsButton, + enableCameraPermissionsText, privacyLinkText, + ] + for child in children { + self.view.addSubview(child) + } + + setupPreviewViewUi() + setupBlurViewUi() + setupRoiViewUi() + setupCloseButtonUi() + setupTorchButtonUi() + setupDescriptionTextUi() + setupCardDetailsUi() + setupDenyUi() + setupPrivacyLinkTextUi() + + if showDebugImageView { + setupDebugViewUi() + } + } + + func setupPreviewViewUi() { + // no ui setup + } + + func setupBlurViewUi() { + blurView.backgroundColor = #colorLiteral( + red: 0.2411109507, + green: 0.271378696, + blue: 0.3280351758, + alpha: 0.7020547945 + ) + } + + func setupRoiViewUi() { + roiView.layer.borderColor = UIColor.white.cgColor + } + + func setupCloseButtonUi() { + closeButton.addTarget(self, action: #selector(cancelButtonPress), for: .touchUpInside) + } + + func setupTorchButtonUi() { + torchButton.addTarget(self, action: #selector(torchButtonPress), for: .touchUpInside) + } + + func setupDescriptionTextUi() { + descriptionText.text = SimpleScanViewController.descriptionString + descriptionText.textColor = .white + descriptionText.textAlignment = .center + descriptionText.font = descriptionText.font.withSize(30) + } + + func setupCardDetailsUi() { + numberText.isHidden = true + numberText.textColor = .white + numberText.textAlignment = .center + numberText.font = numberText.font.withSize(48) + numberText.adjustsFontSizeToFitWidth = true + numberText.minimumScaleFactor = 0.2 + + expiryText.isHidden = true + expiryText.textColor = .white + expiryText.textAlignment = .center + expiryText.font = expiryText.font.withSize(20) + + nameText.isHidden = true + nameText.textColor = .white + nameText.font = expiryText.font.withSize(20) + } + + func setupDenyUi() { + let text = SimpleScanViewController.enableCameraPermissionString + let attributedString = NSMutableAttributedString(string: text) + attributedString.addAttribute( + NSAttributedString.Key.underlineColor, + value: UIColor.white, + range: NSRange(location: 0, length: text.count) + ) + attributedString.addAttribute( + NSAttributedString.Key.foregroundColor, + value: UIColor.white, + range: NSRange(location: 0, length: text.count) + ) + attributedString.addAttribute( + NSAttributedString.Key.underlineStyle, + value: NSUnderlineStyle.single.rawValue, + range: NSRange(location: 0, length: text.count) + ) + let font = + enableCameraPermissionsButton.titleLabel?.font.withSize(20) + ?? UIFont.systemFont(ofSize: 20.0) + attributedString.addAttribute( + NSAttributedString.Key.font, + value: font, + range: NSRange(location: 0, length: text.count) + ) + enableCameraPermissionsButton.setAttributedTitle(attributedString, for: .normal) + enableCameraPermissionsButton.isHidden = true + + enableCameraPermissionsButton.addTarget( + self, + action: #selector(enableCameraPermissionsPress), + for: .touchUpInside + ) + + enableCameraPermissionsText.text = + SimpleScanViewController.enableCameraPermissionsDescriptionString + enableCameraPermissionsText.textColor = .white + enableCameraPermissionsText.textAlignment = .center + enableCameraPermissionsText.font = enableCameraPermissionsText.font.withSize(17) + enableCameraPermissionsText.numberOfLines = 3 + enableCameraPermissionsText.isHidden = true + } + + func setupPrivacyLinkTextUi() { + if let attributedString = SimpleScanViewController.privacyLinkString { + privacyLinkText.attributedText = attributedString + } + + privacyLinkText.textColor = .white + privacyLinkText.textAlignment = .center + privacyLinkText.font = descriptionText.font.withSize(14) + privacyLinkText.isEditable = false + privacyLinkText.dataDetectorTypes = .link + privacyLinkText.isScrollEnabled = false + privacyLinkText.backgroundColor = .clear + privacyLinkText.linkTextAttributes = [ + .foregroundColor: UIColor.white + ] + privacyLinkText.accessibilityIdentifier = "Privacy Link Text" + } + + func setupDebugViewUi() { + debugView = UIImageView() + guard let debugView = debugView else { return } + self.view.addSubview(debugView) + } + + // MARK: - Autolayout constraints + func setupConstraints() { + let children: [UIView] = [ + previewView, blurView, roiView, descriptionText, closeButton, torchButton, numberText, + expiryText, nameText, expiryLayoutView, enableCameraPermissionsButton, + enableCameraPermissionsText, privacyLinkText, + ] + for child in children { + child.translatesAutoresizingMaskIntoConstraints = false + } + + setupPreviewViewConstraints() + setupBlurViewConstraints() + setupRoiViewConstraints() + setupCloseButtonConstraints() + setupTorchButtonConstraints() + setupDescriptionTextConstraints() + setupCardDetailsConstraints() + setupDenyConstraints() + setupPrivacyLinkTextConstraints() + + if showDebugImageView { + setupDebugViewConstraints() + } + } + + func setupPreviewViewConstraints() { + // make it full screen + previewView.setAnchorsEqual(to: self.view) + } + + func setupBlurViewConstraints() { + blurView.setAnchorsEqual(to: self.previewView) + } + + func setupRoiViewConstraints() { + roiView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16).isActive = true + roiView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16).isActive = + true + roiView.heightAnchor.constraint(equalTo: roiView.widthAnchor, multiplier: 1.0 / 1.586) + .isActive = true + roiView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true + } + + func setupCloseButtonConstraints() { + let margins = view.layoutMarginsGuide + closeButton.topAnchor.constraint(equalTo: margins.topAnchor, constant: 16.0).isActive = true + closeButton.leadingAnchor.constraint(equalTo: margins.leadingAnchor).isActive = true + } + + func setupTorchButtonConstraints() { + let margins = view.layoutMarginsGuide + torchButton.topAnchor.constraint(equalTo: margins.topAnchor, constant: 16.0).isActive = true + torchButton.trailingAnchor.constraint(equalTo: margins.trailingAnchor).isActive = true + } + + func setupDescriptionTextConstraints() { + descriptionText.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32).isActive = + true + descriptionText.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32) + .isActive = true + descriptionText.bottomAnchor.constraint(equalTo: roiView.topAnchor, constant: -16).isActive = + true + } + + func setupCardDetailsConstraints() { + numberText.leadingAnchor.constraint(equalTo: roiView.leadingAnchor, constant: 32).isActive = + true + numberText.trailingAnchor.constraint(equalTo: roiView.trailingAnchor, constant: -32) + .isActive = true + numberText.centerYAnchor.constraint(equalTo: roiView.centerYAnchor).isActive = true + + nameText.leadingAnchor.constraint(equalTo: numberText.leadingAnchor).isActive = true + nameText.bottomAnchor.constraint(equalTo: roiView.bottomAnchor, constant: -16).isActive = + true + + expiryLayoutView.topAnchor.constraint(equalTo: numberText.bottomAnchor).isActive = true + expiryLayoutView.bottomAnchor.constraint(equalTo: nameText.topAnchor).isActive = true + expiryLayoutView.leadingAnchor.constraint(equalTo: numberText.leadingAnchor).isActive = true + expiryLayoutView.trailingAnchor.constraint(equalTo: numberText.trailingAnchor).isActive = + true + + expiryText.leadingAnchor.constraint(equalTo: expiryLayoutView.leadingAnchor).isActive = true + expiryText.trailingAnchor.constraint(equalTo: expiryLayoutView.trailingAnchor).isActive = + true + expiryText.centerYAnchor.constraint(equalTo: expiryLayoutView.centerYAnchor).isActive = true + } + + func setupDenyConstraints() { + NSLayoutConstraint.activate([ + enableCameraPermissionsButton.topAnchor.constraint( + equalTo: privacyLinkText.bottomAnchor, + constant: 32 + ), + enableCameraPermissionsButton.centerXAnchor.constraint(equalTo: roiView.centerXAnchor), + + enableCameraPermissionsText.topAnchor.constraint( + equalTo: enableCameraPermissionsButton.bottomAnchor, + constant: 32 + ), + enableCameraPermissionsText.leadingAnchor.constraint(equalTo: roiView.leadingAnchor), + enableCameraPermissionsText.trailingAnchor.constraint(equalTo: roiView.trailingAnchor), + ]) + } + + func setupPrivacyLinkTextConstraints() { + NSLayoutConstraint.activate([ + privacyLinkText.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32), + privacyLinkText.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32), + privacyLinkText.topAnchor.constraint(equalTo: roiView.bottomAnchor, constant: 16), + ]) + + privacyLinkTextHeightConstraint = privacyLinkText.heightAnchor.constraint( + equalToConstant: 0 + ) + } + + func setupDebugViewConstraints() { + guard let debugView = debugView else { return } + debugView.translatesAutoresizingMaskIntoConstraints = false + + debugView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true + debugView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true + debugView.widthAnchor.constraint(equalToConstant: 240).isActive = true + debugView.heightAnchor.constraint(equalTo: debugView.widthAnchor, multiplier: 1.0).isActive = + true + } + + // MARK: - Override some ScanBase functions + override func onScannedCard( + number: String, + expiryYear: String?, + expiryMonth: String?, + scannedImage: UIImage? + ) { + let card = CreditCard(number: number) + card.expiryMonth = expiryMonth + card.expiryYear = expiryYear + card.name = predictedName + card.image = scannedImage + + delegate?.userDidScanCardSimple(self, creditCard: card) + } + + func showScannedCardDetails(prediction: CreditCardOcrPrediction) { + guard let number = prediction.number else { + return + } + + numberText.text = CreditCardUtils.format(number: number) + if numberText.isHidden { + numberText.fadeIn() + } + + if let expiry = prediction.expiryForDisplay { + expiryText.text = expiry + if expiryText.isHidden { + expiryText.fadeIn() + } + } + + if let name = prediction.name { + nameText.text = name + if nameText.isHidden { + nameText.fadeIn() + } + } + } + + override func prediction( + prediction: CreditCardOcrPrediction, + imageData: ScannedCardImageData, + state: MainLoopState + ) { + super.prediction(prediction: prediction, imageData: imageData, state: state) + + showScannedCardDetails(prediction: prediction) + } + + override func onCameraPermissionDenied(showedPrompt: Bool) { + descriptionText.isHidden = true + torchButton.isHidden = true + + enableCameraPermissionsButton.isHidden = false + enableCameraPermissionsText.isHidden = false + privacyLinkTextHeightConstraint?.isActive = true + } + + // MARK: - UI event handlers + @objc func cancelButtonPress() { + delegate?.userDidCancelSimple(self) + self.cancelScan() + } + + @objc func torchButtonPress() { + toggleTorch() + } + + /// Warning: if the user navigates to settings and updates the setting, it'll suspend your app. + @objc func enableCameraPermissionsPress() { + guard let settingsUrl = URL(string: UIApplication.openSettingsURLString), + UIApplication.shared.canOpenURL(settingsUrl) + else { + return + } + + UIApplication.shared.open(settingsUrl) + } +} + +extension UIView { + func setAnchorsEqual(to otherView: UIView) { + self.topAnchor.constraint(equalTo: otherView.topAnchor).isActive = true + self.leadingAnchor.constraint(equalTo: otherView.leadingAnchor).isActive = true + self.trailingAnchor.constraint(equalTo: otherView.trailingAnchor).isActive = true + self.bottomAnchor.constraint(equalTo: otherView.bottomAnchor).isActive = true + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/UI/Torch.swift b/StripeCardScan/StripeCardScan/Source/CardScan/UI/Torch.swift new file mode 100644 index 00000000..267272a0 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/UI/Torch.swift @@ -0,0 +1,48 @@ +import AVFoundation +import Foundation + +struct Torch { + enum State { + case off + case on + } + let device: AVCaptureDevice? + var state: State + var lastStateChange: Date + var level: Float + + init( + device: AVCaptureDevice + ) { + self.state = .off + self.lastStateChange = Date() + if device.hasTorch { + self.device = device + if device.isTorchActive { self.state = .on } + } else { + self.device = nil + } + self.level = 1.0 + } + + /// TODO(jaimepark): Refactor + mutating func toggle() { + self.state = self.state == .on ? .off : .on + do { + try self.device?.lockForConfiguration() + if self.state == .on { + do { + try self.device?.setTorchModeOn(level: self.level) + } catch { + // no-op + } + } else { + self.device?.torchMode = .off + } + self.device?.unlockForConfiguration() + } catch { + // no-op + } + } + +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/UI/VideoFeed.swift b/StripeCardScan/StripeCardScan/Source/CardScan/UI/VideoFeed.swift new file mode 100644 index 00000000..8b1ccc49 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/UI/VideoFeed.swift @@ -0,0 +1,244 @@ +import AVKit +import VideoToolbox + +protocol AfterPermissions { + func permissionDidComplete(granted: Bool, showedPrompt: Bool) +} + +class VideoFeed { + private enum SessionSetupResult { + case success + case notAuthorized + case configurationFailed + } + + let session = AVCaptureSession() + private var isSessionRunning = false + private let sessionQueue = DispatchQueue(label: "session queue") + private var setupResult: SessionSetupResult = .success + var videoDeviceInput: AVCaptureDeviceInput! + var videoDevice: AVCaptureDevice? + var videoDeviceConnection: AVCaptureConnection? + var torch: Torch? + + func pauseSession() { + self.sessionQueue.suspend() + } + + func requestCameraAccess(permissionDelegate: AfterPermissions?) { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + self.sessionQueue.resume() + DispatchQueue.main.async { + permissionDelegate?.permissionDidComplete(granted: true, showedPrompt: false) + } + case .notDetermined: + AVCaptureDevice.requestAccess( + for: .video, + completionHandler: { granted in + if !granted { + self.setupResult = .notAuthorized + } + self.sessionQueue.resume() + DispatchQueue.main.async { + permissionDelegate?.permissionDidComplete( + granted: granted, + showedPrompt: true + ) + } + } + ) + + default: + // The user has previously denied access. + self.setupResult = .notAuthorized + DispatchQueue.main.async { + permissionDelegate?.permissionDidComplete(granted: false, showedPrompt: false) + } + } + } + + func setup( + captureDelegate: AVCaptureVideoDataOutputSampleBufferDelegate, + initialVideoOrientation: AVCaptureVideoOrientation, + completion: @escaping ((_ success: Bool) -> Void) + ) { + sessionQueue.async { + self.configureSession( + captureDelegate: captureDelegate, + initialVideoOrientation: initialVideoOrientation, + completion: completion + ) + } + } + + func configureSession( + captureDelegate: AVCaptureVideoDataOutputSampleBufferDelegate, + initialVideoOrientation: AVCaptureVideoOrientation, + completion: @escaping ((_ success: Bool) -> Void) + ) { + if setupResult != .success { + DispatchQueue.main.async { completion(false) } + return + } + + session.beginConfiguration() + + do { + var defaultVideoDevice: AVCaptureDevice? + + // Choose the back dual camera if available, otherwise default to a wide angle camera. + if let dualCameraDevice = AVCaptureDevice.default( + .builtInDualCamera, + for: .video, + position: .back + ) { + defaultVideoDevice = dualCameraDevice + } else if let backCameraDevice = AVCaptureDevice.default( + .builtInWideAngleCamera, + for: .video, + position: .back + ) { + // If the back dual camera is not available, default to the back wide angle camera. + defaultVideoDevice = backCameraDevice + } else if let frontCameraDevice = AVCaptureDevice.default( + .builtInWideAngleCamera, + for: .video, + position: .front + ) { + // In some cases where users break their phones, the back wide angle camera is not available. + // In this case, we should default to the front wide angle camera. + defaultVideoDevice = frontCameraDevice + } + + guard let myVideoDevice = defaultVideoDevice else { + setupResult = .configurationFailed + session.commitConfiguration() + DispatchQueue.main.async { completion(false) } + return + } + + self.videoDevice = myVideoDevice + self.torch = Torch(device: myVideoDevice) + let videoDeviceInput = try AVCaptureDeviceInput(device: myVideoDevice) + + self.setupVideoDeviceDefaults() + + if session.canAddInput(videoDeviceInput) { + session.addInput(videoDeviceInput) + self.videoDeviceInput = videoDeviceInput + } else { + setupResult = .configurationFailed + session.commitConfiguration() + DispatchQueue.main.async { completion(false) } + return + } + + let videoDeviceOutput = AVCaptureVideoDataOutput() + videoDeviceOutput.videoSettings = [ + kCVPixelBufferPixelFormatTypeKey as AnyHashable as! String: Int( + kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange + ), + ] + + videoDeviceOutput.alwaysDiscardsLateVideoFrames = true + let captureSessionQueue = DispatchQueue(label: "camera output queue") + videoDeviceOutput.setSampleBufferDelegate(captureDelegate, queue: captureSessionQueue) + guard session.canAddOutput(videoDeviceOutput) else { + setupResult = .configurationFailed + session.commitConfiguration() + DispatchQueue.main.async { completion(false) } + return + } + session.addOutput(videoDeviceOutput) + + if session.canSetSessionPreset(.high) { + session.sessionPreset = .high + } + + self.videoDeviceConnection = videoDeviceOutput.connection(with: .video) + if self.videoDeviceConnection?.isVideoOrientationSupported ?? false { + self.videoDeviceConnection?.videoOrientation = initialVideoOrientation + } + } catch { + setupResult = .configurationFailed + session.commitConfiguration() + DispatchQueue.main.async { completion(false) } + return + } + + session.commitConfiguration() + DispatchQueue.main.async { completion(true) } + } + + func setupVideoDeviceDefaults() { + guard let videoDevice = self.videoDevice else { + return + } + + guard (try? videoDevice.lockForConfiguration()) != nil else { + return + } + + if videoDevice.isFocusModeSupported(.continuousAutoFocus) { + videoDevice.focusMode = .continuousAutoFocus + if videoDevice.isSmoothAutoFocusSupported { + videoDevice.isSmoothAutoFocusEnabled = true + } + } + + if videoDevice.isExposureModeSupported(.continuousAutoExposure) { + videoDevice.exposureMode = .continuousAutoExposure + } + + if videoDevice.isWhiteBalanceModeSupported(.continuousAutoWhiteBalance) { + videoDevice.whiteBalanceMode = .continuousAutoWhiteBalance + } + + if videoDevice.isLowLightBoostSupported { + videoDevice.automaticallyEnablesLowLightBoostWhenAvailable = true + } + videoDevice.unlockForConfiguration() + } + + // MARK: - Torch Logic + func toggleTorch() { + self.torch?.toggle() + } + + func isTorchOn() -> Bool { + return self.torch?.state == Torch.State.on + } + + func hasTorchAndIsAvailable() -> Bool { + let hasTorch = self.torch?.device?.hasTorch ?? false + let isTorchAvailable = self.torch?.device?.isTorchAvailable ?? false + return hasTorch && isTorchAvailable + } + + func setTorchLevel(level: Float) { + self.torch?.level = level + } + + // MARK: - VC Lifecycle Logic + func willAppear() { + sessionQueue.async { + switch self.setupResult { + case .success: + self.session.startRunning() + self.isSessionRunning = self.session.isRunning + case _: + break + } + } + } + + func willDisappear() { + sessionQueue.async { + if self.setupResult == .success { + self.session.stopRunning() + self.isSessionRunning = self.session.isRunning + } + } + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/Utils/AppInfoUtils.swift b/StripeCardScan/StripeCardScan/Source/CardScan/Utils/AppInfoUtils.swift new file mode 100644 index 00000000..edeebd75 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/Utils/AppInfoUtils.swift @@ -0,0 +1,43 @@ +// +// AppInfoUtils.swift +// CardScan +// +// Created by Jaime Park on 4/15/21. +// + +import Foundation + +struct AppInfoUtils { + static let appPackageName: String? = getAppPackageName() + static let applicationId: String? = nil + static let libraryPackageName: String? = getLibraryPackageName() + static let sdkVersion: String = getSdkVersion() + static let sdkVersionCode: Int? = nil + static let sdkFlavor: String? = nil + static let isDebugBuild: Bool = getIsDebugBuild() + + static func getAppPackageName() -> String { + return Bundle.main.infoDictionary?[kCFBundleNameKey as String] as? String ?? "unknown" + } + + static func getLibraryPackageName() -> String? { + return Bundle.main.bundleIdentifier + } + + static func getSdkVersion() -> String { + return Bundle.main.infoDictionary?["CFBundleShortVersionString"].flatMap { $0 as? String } + ?? "unknown" + } + + static func getBuildVersion() -> String { + return Bundle.main.infoDictionary?["CFBundleVersion"].flatMap { $0 as? String } ?? "unknown" + } + + static func getIsDebugBuild() -> Bool { + #if DEBUG + return true + #else + return false + #endif + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/Utils/AtomicPropertyWrapper.swift b/StripeCardScan/StripeCardScan/Source/CardScan/Utils/AtomicPropertyWrapper.swift new file mode 100644 index 00000000..6507162e --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/Utils/AtomicPropertyWrapper.swift @@ -0,0 +1,43 @@ +// +// AtomicPropertyWrapper.swift +// StripeCardScan +// +// Created by Scott Grant on 7/5/22. +// + +import Foundation + +@propertyWrapper +class AtomicProperty { + + private var value: Value + + private lazy var lock: os_unfair_lock_t = { + let lock = os_unfair_lock_t.allocate(capacity: 1) + lock.initialize(to: os_unfair_lock_s()) + return lock + }() + + init( + wrappedValue value: Value + ) { + self.value = value + } + + deinit { + lock.deallocate() + } + + var wrappedValue: Value { + get { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + return value + } + set { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + value = newValue + } + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardScan/Utils/DeviceUtils.swift b/StripeCardScan/StripeCardScan/Source/CardScan/Utils/DeviceUtils.swift new file mode 100644 index 00000000..9625eae9 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardScan/Utils/DeviceUtils.swift @@ -0,0 +1,82 @@ +// +// DeviceUtils.swift +// CardScan +// +// Created by Jaime Park on 4/15/21. +// + +import CoreTelephony +import Foundation +import UIKit + +struct ClientIdsUtils { + static let vendorId: String? = getVendorId() + + static internal func getVendorId() -> String? { + return UIDevice.current.identifierForVendor?.uuidString + } +} + +struct DeviceUtils { + static let name: String = getDeviceType() + static let build: String = getBuildVersion() + static let bootCount: Int? = nil + static let locale: String? = getDeviceLocale() + static let carrier: String? = getCarrier() + static let networkOperator: String? = nil + static let phoneType: Int? = nil + static let phoneCount: Int? = nil + + static let osVersion: String = getOsVersion() + static let platform: String = "ios" + + static internal func getDeviceType() -> String { + var systemInfo = utsname() + uname(&systemInfo) + var deviceType = "" + for char in Mirror(reflecting: systemInfo.machine).children { + guard let charDigit = (char.value as? Int8) else { + return "" + } + + if charDigit == 0 { + break + } + + deviceType += String(UnicodeScalar(UInt8(charDigit))) + } + + return deviceType + } + + static internal func getBuildVersion() -> String { + return Bundle.main.infoDictionary?["CFBundleVersion"].flatMap { $0 as? String } ?? "0000" + } + + static internal func getDeviceLocale() -> String? { + return NSLocale.preferredLanguages.first + } + + static func getVendorId() -> String { + return UIDevice.current.identifierForVendor?.uuidString ?? "" + } + + static internal func getCarrier() -> String? { + let networkInfo = CTTelephonyNetworkInfo() + guard + let firstNamedCarrier = networkInfo.serviceSubscriberCellularProviders?.first(where: { + $0.value.carrierName != nil + })?.value + else { + return nil + } + + return firstNamedCarrier.carrierName + } + + static internal func getOsVersion() -> String { + let version = ProcessInfo().operatingSystemVersion + let osVersion = "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" + return osVersion + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Api/CardImageVerificationDetailsResponse.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Api/CardImageVerificationDetailsResponse.swift new file mode 100644 index 00000000..b16522e3 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Api/CardImageVerificationDetailsResponse.swift @@ -0,0 +1,68 @@ +// +// CardImageVerificationDetailsResponse.swift +// StripeCardScan +// +// Created by Jaime Park on 9/16/21. +// + +import Foundation +@_spi(STP) import StripeCore + +struct CardImageVerificationExpectedCard: Decodable { + let last4: String? + let issuer: String? +} + +struct CardImageVerificationImageSettings: Decodable { + var compressionRatio: Double? = 0.8 + var imageSize: [Double]? = [1080, 1920] +} + +enum CardImageVerificationFormat: String, SafeEnumCodable { + case heic = "heic" + case jpeg = "jpeg" + case webp = "webp" + case unparsable +} + +struct CardImageVerificationAcceptedImageConfigs: Decodable { + internal let defaultSettings: CardImageVerificationImageSettings? + internal let formatSettings: [CardImageVerificationFormat: CardImageVerificationImageSettings?]? + let preferredFormats: [CardImageVerificationFormat]? + + init( + defaultSettings: CardImageVerificationImageSettings? = CardImageVerificationImageSettings(), + formatSettings: [CardImageVerificationFormat: CardImageVerificationImageSettings?]? = nil, + preferredFormats: [CardImageVerificationFormat]? = [.jpeg] + ) { + self.defaultSettings = defaultSettings + self.formatSettings = formatSettings + self.preferredFormats = preferredFormats + } +} + +struct CardImageVerificationDetailsResponse: Decodable { + let expectedCard: CardImageVerificationExpectedCard + let acceptedImageConfigs: CardImageVerificationAcceptedImageConfigs? +} + +extension CardImageVerificationAcceptedImageConfigs { + func imageSettings(format: CardImageVerificationFormat) -> CardImageVerificationImageSettings { + var result = CardImageVerificationImageSettings() + + if let defaultSettings = defaultSettings { + result.compressionRatio = defaultSettings.compressionRatio ?? result.compressionRatio + result.imageSize = defaultSettings.imageSize ?? result.imageSize + } + + if let formatSpecificSettings = formatSettings?[format], + let formatSpecificSettings = formatSpecificSettings + { + result.compressionRatio = + formatSpecificSettings.compressionRatio ?? result.compressionRatio + result.imageSize = formatSpecificSettings.imageSize ?? result.imageSize + } + + return result + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Api/Models/Scan Analytics/ScanStatsPayload+Common.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Api/Models/Scan Analytics/ScanStatsPayload+Common.swift new file mode 100644 index 00000000..cc9e2454 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Api/Models/Scan Analytics/ScanStatsPayload+Common.swift @@ -0,0 +1,43 @@ +// +// ScanStatsPayload+Common.swift +// StripeCardScan +// +// Created by Jaime Park on 12/9/21. +// + +import Foundation +@_spi(STP) import StripeCore + +extension ScanAnalyticsPayload { + /// Default app info used when uploading scan stats + struct AppInfo: Encodable { + let appPackageName = Bundle.stp_applicationName() ?? "" + let build = Bundle.buildVersion() ?? "" + let isDebugBuild = AppInfoUtils.getIsDebugBuild() + let sdkVersion = StripeAPIConfiguration.STPSDKVersion + } + + /// Default device info used when uploading scan stats + struct DeviceInfo: Encodable { + /// API requirement but have no purpose + let deviceId = "Redacted" + let deviceType = DeviceUtils.getDeviceType() + let osVersion = DeviceUtils.getOsVersion() + let platform = "iOS" + /// API requirement but have no purpose + let vendorId = "Redacted" + } + + /// Configuration values set when before running a scan flow + struct ConfigurationInfo: Encodable { + let strictModeFrames: Int + } + + /// Information about the verification payload creation + struct PayloadInfo: Encodable, Equatable { + let imageCompressionType: String + let imageCompressionQuality: Double + /// Byte count of the image payload after it has been compressed and b64 encoded + let imagePayloadSize: Int + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Api/Models/Scan Analytics/ScanStatsPayload+Tasks.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Api/Models/Scan Analytics/ScanStatsPayload+Tasks.swift new file mode 100644 index 00000000..a028467b --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Api/Models/Scan Analytics/ScanStatsPayload+Tasks.swift @@ -0,0 +1,21 @@ +// +// ScanStatsPayload+Tasks.swift +// StripeCardScan +// +// Created by Jaime Park on 5/13/22. +// + +import Foundation + +/// Struct used to track a repeating event +struct ScanAnalyticsRepeatingTask: Encodable, Equatable { + /// Repeated tasks should record how many times the tasks has been repeated + let executions: Int +} + +/// Struct used to track a non-repeating event +struct ScanAnalyticsNonRepeatingTask: Encodable, Equatable { + let result: String + let startedAtMs: Int + let durationMs: Int +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Api/Models/Scan Analytics/ScanStatsPayload.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Api/Models/Scan Analytics/ScanStatsPayload.swift new file mode 100644 index 00000000..17dec4e7 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Api/Models/Scan Analytics/ScanStatsPayload.swift @@ -0,0 +1,62 @@ +// +// ScanStatsPayload.swift +// StripeCardScan +// +// Created by Jaime Park on 12/9/21. +// + +import Foundation +@_spi(STP) import StripeCore + +/// Parent payload structure for uploading scan stats +struct ScanStatsPayload: Encodable { + let clientSecret: String + let payload: ScanAnalyticsPayload +} + +struct ScanAnalyticsPayload: Encodable { + let app = AppInfo() + let configuration: ConfigurationInfo + let device = DeviceInfo() + /// API requirement but have no purpose + let instanceId: String = UUID().uuidString + let payloadInfo: PayloadInfo? + let payloadVersion = "2" + /// API requirement but have no purpose + let scanId: String = UUID().uuidString + let scanStats: ScanStatsTasks +} + +struct ScanStatsTasks: Encodable { + let repeatingTasks: RepeatingTasks + let tasks: NonRepeatingTasks +} + +struct NonRepeatingTasks: Encodable { + let cameraPermission: [ScanAnalyticsNonRepeatingTask] + let completionLoopDuration: [ScanAnalyticsNonRepeatingTask] + let imageCompressionDuration: [ScanAnalyticsNonRepeatingTask] + let mainLoopDuration: [ScanAnalyticsNonRepeatingTask] + let scanActivity: [ScanAnalyticsNonRepeatingTask] + let torchSupported: [ScanAnalyticsNonRepeatingTask] + + init( + cameraPermissionTask: ScanAnalyticsNonRepeatingTask, + completionLoopDuration: ScanAnalyticsNonRepeatingTask, + imageCompressionDuration: ScanAnalyticsNonRepeatingTask, + mainLoopDuration: ScanAnalyticsNonRepeatingTask, + scanActivityTasks: [ScanAnalyticsNonRepeatingTask], + torchSupportedTask: ScanAnalyticsNonRepeatingTask + ) { + self.cameraPermission = [cameraPermissionTask] + self.completionLoopDuration = [completionLoopDuration] + self.imageCompressionDuration = [imageCompressionDuration] + self.mainLoopDuration = [mainLoopDuration] + self.scanActivity = scanActivityTasks + self.torchSupported = [torchSupportedTask] + } +} + +struct RepeatingTasks: Encodable { + let mainLoopImagesProcessed: ScanAnalyticsRepeatingTask +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Api/Models/VerificationFramesData.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Api/Models/VerificationFramesData.swift new file mode 100644 index 00000000..1d61cfea --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Api/Models/VerificationFramesData.swift @@ -0,0 +1,46 @@ +// +// VerificationFramesData.swift +// StripeCardScan +// +// Created by Jaime Park on 11/19/21. +// + +import Foundation +@_spi(STP) import StripeCore +import UIKit + +struct VerificationFramesData: Encodable { + /// A base64 encoding of a scanned card image + let imageData: Data? + /// The bounds of the card view finder (measured in pixels) + let viewfinderMargins: ViewFinderMargins +} + +// Bounds of the scanner's card viewfinder (measured in pixels) +// ---------------------------------------------- +// | | | +// | upper | +// | 300 | +// | | | +// | ------------300------------ | +// | | | | +// | 1 | | +// |--left--0 |--right--| +// | 100 0---4242 4242 4242 4242---| 400 | +// | | 05/23 | | +// | --------------------------- | +// | | | +// | lower | +// | 400 | +// | | | +// ---------------------------------------------- +struct ViewFinderMargins: Encodable { + /// The amount of pixels from the left-most bound of the image to the left-most bound of the viewfinder + let left: Int + /// The amount of pixels from the top-most bound of the image to the top-most bound of the viewfinder + let upper: Int + /// The amount of pixels from the left-most bound of the image to the right-most bound of the viewfinder + let right: Int + /// The amount of pixels from the top-most bound of the image to the bottom-most bound of the viewfinder + let lower: Int +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Api/Models/VerifyFrames.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Api/Models/VerifyFrames.swift new file mode 100644 index 00000000..da43dd59 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Api/Models/VerifyFrames.swift @@ -0,0 +1,15 @@ +// +// VerifyFrames.swift +// StripeCardScan +// +// Created by Jaime Park on 11/19/21. +// + +import Foundation +@_spi(STP) import StripeCore + +struct VerifyFrames: Encodable { + let clientSecret: String + /// A base64 encoding of 5 `VerificationFramesData` entries + let verificationFramesData: String +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Api/STPAPIClient+CardImageVerification.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Api/STPAPIClient+CardImageVerification.swift new file mode 100644 index 00000000..076875eb --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Api/STPAPIClient+CardImageVerification.swift @@ -0,0 +1,95 @@ +// +// STPAPIClient+CardImageVerification.swift +// StripeCardScan +// +// Created by Jaime Park on 9/15/21. +// + +import Foundation +@_spi(STP) import StripeCore + +extension STPAPIClient { + /// Request used in the beginning of the scan flow to gather CIV details and update the UI with last4 and issuer + func fetchCardImageVerificationDetails( + cardImageVerificationSecret: String, + cardImageVerificationId: String + ) -> Promise { + let parameters: [String: Any] = ["client_secret": cardImageVerificationSecret] + let endpoint = APIEndpoints.fetchCardImageVerificationDetails(id: cardImageVerificationId) + return self.post(resource: endpoint, parameters: parameters) + } + + /// Request used to complete the verification flow by submitting the verification frames + func submitVerificationFrames( + cardImageVerificationId: String, + verifyFrames: VerifyFrames + ) -> Promise { + let endpoint = APIEndpoints.submitVerificationFrames(id: cardImageVerificationId) + return self.post(resource: endpoint, object: verifyFrames) + } + + func submitVerificationFrames( + cardImageVerificationId: String, + cardImageVerificationSecret: String, + verificationFramesData: [VerificationFramesData] + ) -> Promise { + do { + /// TODO: Replace this with writing the JSON to a string instead of a data + /// Encode the array of verification frames data into JSON + let jsonEncoder = JSONEncoder() + jsonEncoder.keyEncodingStrategy = .convertToSnakeCase + let jsonVerificationFramesData = try jsonEncoder.encode(verificationFramesData) + + /// Turn the JSON data into a string + let verificationFramesDataString = + String(data: jsonVerificationFramesData, encoding: .utf8) ?? "" + + /// Create a `VerifyFrames` object + let verifyFrames = VerifyFrames( + clientSecret: cardImageVerificationSecret, + verificationFramesData: verificationFramesDataString + ) + + return self.submitVerificationFrames( + cardImageVerificationId: cardImageVerificationId, + verifyFrames: verifyFrames + ) + } catch let error { + let promise = Promise() + promise.reject(with: error) + return promise + } + } + + /// Request used to upload analytics of a card scanning session + /// This will be a fire-and-forget request + @discardableResult + func uploadScanStats( + cardImageVerificationId: String, + cardImageVerificationSecret: String, + scanAnalyticsPayload: ScanAnalyticsPayload + ) -> Promise { + /// Create URL with endpoint + let endpoint = APIEndpoints.uploadScanStats(id: cardImageVerificationId) + /// Create scan stats payload with secret key + let payload = ScanStatsPayload( + clientSecret: cardImageVerificationSecret, + payload: scanAnalyticsPayload + ) + return self.post(resource: endpoint, object: payload) + } +} + +private struct APIEndpoints { + static func fetchCardImageVerificationDetails(id: String) -> String { + return "card_image_verifications/\(id)/initialize_client" + } + + static func submitVerificationFrames(id: String) -> String { + return "card_image_verifications/\(id)/verify_frames" + } + + static func uploadScanStats(id: String) -> String { + return "card_image_verifications/\(id)/scan_stats" + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Bouncer.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Bouncer.swift new file mode 100644 index 00000000..82ac4e6d --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Bouncer.swift @@ -0,0 +1,22 @@ +import Foundation + +class Bouncer: NSObject { + + // This is the configuration CardVerify users should use + static func configuration() -> ScanConfiguration { + let configuration = ScanConfiguration() + configuration.runOnOldDevices = true + return configuration + } + + // Call this method before scanning any cards + static func configure(apiKey: String, useExperimentalScreenDetectionModel: Bool = false) { + ScanBaseViewController.configure(apiKey: apiKey) + } + + static var useFlashFlow = false + + static func isCompatible() -> Bool { + return ScanBaseViewController.isCompatible(configuration: configuration()) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/CancellationReason.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/CancellationReason.swift new file mode 100644 index 00000000..45de40da --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/CancellationReason.swift @@ -0,0 +1,18 @@ +// +// CancellationReason.swift +// StripeCardScan +// +// Created by Jaime Park on 11/17/21. +// + +import Foundation + +/// The reason of the user initiated scan cancellation +public enum CancellationReason: String, Equatable { + /// User pressed the back button + case back + /// User closed the sheet view + case closed + /// User pressed the button which indicates that they can not scan the expected card + case userCannotScan +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/CardImageVerificationController.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/CardImageVerificationController.swift new file mode 100644 index 00000000..cdd1e3a9 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/CardImageVerificationController.swift @@ -0,0 +1,160 @@ +// +// CardImageVerificationController.swift +// StripeCardScan +// +// Created by Jaime Park on 11/17/21. +// + +import Foundation +@_spi(STP) import StripeCore +import UIKit + +protocol CardImageVerificationControllerDelegate: AnyObject { + func cardImageVerificationController( + _ controller: CardImageVerificationController, + didFinishWithResult result: CardImageVerificationSheetResult + ) +} + +class CardImageVerificationController { + weak var delegate: CardImageVerificationControllerDelegate? + + private let intent: CardImageVerificationIntent + private let configuration: CardImageVerificationSheet.Configuration + + init( + intent: CardImageVerificationIntent, + configuration: CardImageVerificationSheet.Configuration + ) { + self.intent = intent + self.configuration = configuration + } + + func present( + with expectedCard: CardImageVerificationExpectedCard, + and acceptedImageConfigs: CardImageVerificationAcceptedImageConfigs?, + from presentingViewController: UIViewController, + animated: Bool = true + ) { + /// Guard against basic user error + guard presentingViewController.presentedViewController == nil else { + assertionFailure("presentingViewController is already presenting a view controller") + let error = CardScanSheetError.unknown( + debugDescription: "presentingViewController is already presenting a view controller" + ) + self.delegate?.cardImageVerificationController( + self, + didFinishWithResult: .failed(error: error) + ) + return + } + + // TODO(jaimepark): Create controller that has configurable view and handles coordination / business logic + if let expectedCardLast4 = expectedCard.last4 { + /// Create the view controller for card-set-verification with expected card's last4 and issuer + let vc = VerifyCardViewController( + acceptedImageConfigs: acceptedImageConfigs, + configuration: configuration, + expectedCardLast4: expectedCardLast4, + expectedCardIssuer: expectedCard.issuer + ) + vc.verifyDelegate = self + presentingViewController.present(vc, animated: animated) + } else { + /// Create the view controller for card-add-verification + let vc = VerifyCardAddViewController( + acceptedImageConfigs: acceptedImageConfigs, + configuration: configuration + ) + vc.verifyDelegate = self + presentingViewController.present(vc, animated: animated) + } + } + + func dismissWithResult( + _ presentingViewController: UIViewController, + result: CardImageVerificationSheetResult + ) { + /// Fire-and-forget uploading the scan stats + ScanAnalyticsManager.shared.generateScanAnalyticsPayload(with: configuration) { + [weak self] payload in + guard let self = self, + let payload = payload + else { + return + } + + self.configuration.apiClient.uploadScanStats( + cardImageVerificationId: self.intent.id, + cardImageVerificationSecret: self.intent.clientSecret, + scanAnalyticsPayload: payload + ) + } + + /// Dismiss the view controller + presentingViewController.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.delegate?.cardImageVerificationController(self, didFinishWithResult: result) + } + } +} + +// MARK: Verify Card Add Delegate +extension CardImageVerificationController: VerifyViewControllerDelegate { + /// User scanned a card successfully. Submit verification frames data to complete verification flow + func verifyViewControllerDidFinish( + _ viewController: UIViewController, + verificationFramesData: [VerificationFramesData], + scannedCard: ScannedCard + ) { + let completionLoopTask = TrackableTask() + + /// Submit verification frames and wait for response for verification flow completion + configuration.apiClient.submitVerificationFrames( + cardImageVerificationId: intent.id, + cardImageVerificationSecret: intent.clientSecret, + verificationFramesData: verificationFramesData + ).observe { [weak self] result in + switch result { + case .success: + completionLoopTask.trackResult(.success) + ScanAnalyticsManager.shared.trackCompletionLoopDuration(task: completionLoopTask) + + self?.dismissWithResult( + viewController, + result: .completed(scannedCard: scannedCard) + ) + case .failure(let error): + completionLoopTask.trackResult(.failure) + ScanAnalyticsManager.shared.trackCompletionLoopDuration(task: completionLoopTask) + + self?.dismissWithResult( + viewController, + result: .failed(error: error) + ) + } + } + } + + /// User canceled the verification flow + func verifyViewControllerDidCancel( + _ viewController: UIViewController, + with reason: CancellationReason + ) { + dismissWithResult( + viewController, + result: .canceled(reason: reason) + ) + } + + /// Verification flow has failed + func verifyViewControllerDidFail( + _ viewController: UIViewController, + with error: Error + ) { + dismissWithResult( + viewController, + result: .failed(error: error) + ) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/CardImageVerificationIntent.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/CardImageVerificationIntent.swift new file mode 100644 index 00000000..4ce8a07f --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/CardImageVerificationIntent.swift @@ -0,0 +1,16 @@ +// +// CardImageVerificationIntent.swift +// StripeCardScan +// +// Created by Jaime Park on 11/22/21. +// + +import Foundation + +/// An internal type representing a Card Image Verification Intent +struct CardImageVerificationIntent { + /// The card verification intent id + let id: String + /// The card verification intent client secret + let clientSecret: String +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/CardImageVerificationSheet.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/CardImageVerificationSheet.swift new file mode 100644 index 00000000..309ce7a1 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/CardImageVerificationSheet.swift @@ -0,0 +1,127 @@ +// +// CardImageVerificationSheet.swift +// StripeCardScan +// +// Created by Jaime Park on 11/17/21. +// + +import Foundation +@_spi(STP) import StripeCore +import UIKit + +/// Typealias for backwards compatibility of new error type name +typealias CardImageVerificationSheetError = CardScanSheetError + +/// The result of an attempt to finish an card image verification flow +@frozen public enum CardImageVerificationSheetResult { + /// User completed the verification flow + case completed(scannedCard: ScannedCard) + /// User canceled out of the flow + case canceled(reason: CancellationReason) + /// Failed with error + case failed(error: Error) +} + +/// A drop-in class that presents a sheet for a user to verify their credit card +final public class CardImageVerificationSheet { + /// Initializes an `CardImageVerificationSheet` + /// - Parameters: + /// - cardImageVerificationIntentId: The id of a Stripe CardImageVerificationIntent object. + /// - cardImageVerificationIntentSecret: The client secret of a Stripe CardImageVerificationIntent object. + public init( + cardImageVerificationIntentId: String, + cardImageVerificationIntentSecret: String, + configuration: Configuration = Configuration() + ) { + // TODO(jaimepark): Add api analytics client as a param when integrating Stripe analytics + // TODO(jaimepark): Link public documentation for CIV intent when ready + self.configuration = configuration + self.intent = CardImageVerificationIntent( + id: cardImageVerificationIntentId, + clientSecret: cardImageVerificationIntentSecret + ) + } + + /// Presents a sheet for a customer to verify their card. + /// - Parameters: + /// - presentingViewController: The view controller to present the card image verification sheet. + /// - completion: Called with the result of the card image verification flow after the sheet is dismissed. + public func present( + from presentingViewController: UIViewController, + completion: @escaping (CardImageVerificationSheetResult) -> Void, + animated: Bool = true + ) { + /// Overwrite completion closure to retain self until called + let completion: (CardImageVerificationSheetResult) -> Void = { result in + completion(result) + self.completion = nil + } + self.completion = completion + + /// Configure the card image verification controller after retrieving the CIV details + load( + civId: intent.id, + civSecret: intent.clientSecret + ) { result in + switch result { + case .success(let response): + /// Initialize the civ controller + let cardImageVerificationController = + CardImageVerificationController( + intent: self.intent, + configuration: self.configuration + ) + cardImageVerificationController.delegate = self + /// Keep reference to the civ controller + self.verificationController = cardImageVerificationController + + /// Present the verify view controller + cardImageVerificationController.present( + with: response.expectedCard, + and: response.acceptedImageConfigs, + from: presentingViewController, + animated: animated + ) + case .failure(let error): + self.completion?(.failed(error: error)) + } + } + } + + private let configuration: Configuration + private let intent: CardImageVerificationIntent + /// Completion block called when the sheet is closed or fails to open + private var completion: ((CardImageVerificationSheetResult) -> Void)? + private var verificationController: CardImageVerificationController? +} + +extension CardImageVerificationSheet { + fileprivate typealias Result = Swift.Result + + /// Fetches the CIV optional card details + fileprivate func load( + civId: String, + civSecret: String, + completion: @escaping ((Result) -> Void) + ) { + /// Clear the scan analytics manager since it is the beginning of a new session + ScanAnalyticsManager.shared.reset() + + configuration.apiClient.fetchCardImageVerificationDetails( + cardImageVerificationSecret: civSecret, + cardImageVerificationId: civId + ).observe { result in + completion(result) + } + } +} + +// MARK: Card Image Verification Controller Delegate +extension CardImageVerificationSheet: CardImageVerificationControllerDelegate { + func cardImageVerificationController( + _ controller: CardImageVerificationController, + didFinishWithResult result: CardImageVerificationSheetResult + ) { + self.completion?(result) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/CardImageVerificationSheetConfiguration.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/CardImageVerificationSheetConfiguration.swift new file mode 100644 index 00000000..a92fbe8c --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/CardImageVerificationSheetConfiguration.swift @@ -0,0 +1,43 @@ +// +// CardImageVerificationSheetConfiguration.swift +// StripeCardScan +// +// Created by Jaime Park on 3/11/22. +// + +import Foundation +import UIKit + +// MARK: - Configuration +extension CardImageVerificationSheet { + public struct Configuration { + /// The API client instance used to make requests to Stripe + public var apiClient: STPAPIClient = STPAPIClient.shared + + /// The amount of frames that must have a centered, focused card before the + /// scan is allowed to terminate. This is an `experimental` feature that should + /// only be used with guidance from Stripe support. + @_spi(STP) public var strictModeFrames: StrictModeFrameCount = .none + + public init() {} + } + + /// Enum describing the amount of frames that must have a centered, focused card before the + /// scan is allowed to terminate. This is an `experimental` feature that should + /// only be used with guidance from Stripe support. + @_spi(STP) public enum StrictModeFrameCount: Int, Equatable { + case `none` + case low + case medium + case high + + internal var totalFrameCount: Int { + switch self { + case .none: return 0 + case .low: return 1 + case .medium: return CardVerifyFraudData.maxCompletionLoopFrames / 2 + case .high: return CardVerifyFraudData.maxCompletionLoopFrames + } + } + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/CardScanSheetError.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/CardScanSheetError.swift new file mode 100644 index 00000000..c727ad38 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/CardScanSheetError.swift @@ -0,0 +1,35 @@ +// +// CardScanSheetError.swift +// StripeCardScan +// +// Created by Jaime Park on 11/17/21. +// + +import Foundation +@_spi(STP) import StripeCore + +/// Errors specific to the `CardImageVerificationSheet`. +public enum CardScanSheetError: Error { + /// The provided client secret is invalid. + case invalidClientSecret + /// An unknown error. + case unknown(debugDescription: String) +} + +extension CardScanSheetError: LocalizedError { + /// Localized description of the error + public var localizedDescription: String { + return NSError.stp_unexpectedErrorMessage() + } +} + +extension CardScanSheetError: CustomDebugStringConvertible { + public var debugDescription: String { + switch self { + case .invalidClientSecret: + return "Invalid client secret" + case .unknown(let debugDescription): + return debugDescription + } + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScanAnalyticsManager+Helpers.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScanAnalyticsManager+Helpers.swift new file mode 100644 index 00000000..f5057f05 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScanAnalyticsManager+Helpers.swift @@ -0,0 +1,20 @@ +// +// ScanAnalyticsManager+Helpers.swift +// StripeCardScan +// +// Created by Jaime Park on 12/9/21. +// + +import UIKit + +extension Date { + var millisecondsSince1970: Int { + Int((self.timeIntervalSince1970 * 1000.0).rounded()) + } +} + +extension TimeInterval { + var milliseconds: Int { + Int((self * 1000.0).rounded()) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScanAnalyticsManager+Managers.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScanAnalyticsManager+Managers.swift new file mode 100644 index 00000000..442b4226 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScanAnalyticsManager+Managers.swift @@ -0,0 +1,51 @@ +// +// ScanAnalyticsManager+Managers.swift +// StripeCardScan +// +// Created by Jaime Park on 12/13/21. +// + +import Foundation + +/// Manager used to aggregate all non-repeating tasks +struct NonRepeatingTasksManager { + /// Default unknown values + var cameraPermissionTask: TrackableTask = TrackableTask() + var completionLoopDuration: TrackableTask = TrackableTask() + var imageCompressionDuration: TrackableTask = TrackableTask() + var mainLoopDuration: TrackableTask = TrackableTask() + var scanActivityTasks: [TrackableTask] = [] + var torchSupportedTask: TrackableTask = TrackableTask() + + /// Create API model + func generateNonRepeatingTasks() -> NonRepeatingTasks { + func unwrapTaskOrDefault(_ task: TrackableTask) -> ScanAnalyticsNonRepeatingTask { + return task.toAPIModel() + ?? .init( + result: ScanAnalyticsEvent.unknown.rawValue, + startedAtMs: -1, + durationMs: -1 + ) + } + + return .init( + cameraPermissionTask: unwrapTaskOrDefault(cameraPermissionTask), + completionLoopDuration: unwrapTaskOrDefault(completionLoopDuration), + imageCompressionDuration: unwrapTaskOrDefault(imageCompressionDuration), + mainLoopDuration: unwrapTaskOrDefault(mainLoopDuration), + scanActivityTasks: scanActivityTasks.compactMap { $0.toAPIModel() }, + torchSupportedTask: unwrapTaskOrDefault(torchSupportedTask) + ) + } +} + +/// Manager used to aggregate all repeating tasks +struct RepeatingTasksManager { + /// Default to negative number frames processed + var mainLoopImagesProcessed: ScanAnalyticsRepeatingTask = .init(executions: -1) + + /// Create API model + func generateRepeatingTasks() -> RepeatingTasks { + return .init(mainLoopImagesProcessed: mainLoopImagesProcessed) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScanAnalyticsManager+Tasks.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScanAnalyticsManager+Tasks.swift new file mode 100644 index 00000000..a16e4174 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScanAnalyticsManager+Tasks.swift @@ -0,0 +1,80 @@ +// +// ScanAnalyticsTasks.swift +// StripeCardScan +// +// Created by Jaime Park on 12/9/21. +// + +import Foundation + +/// Events to be logged during a scanning session +enum ScanAnalyticsEvent: String { + /// Non-repeating tasks + case success = "success" + case failure = "failure" + case torchSupported = "supported" + case torchUnsupported = "unsupported" + case firstImageProcessed = "first_image_processed" + case ocrPanObserved = "ocr_pan_observed" + case cardScanned = "card_scanned" + case enterCardManually = "enter_card_manually" + case unknown = "unknown" + case userCanceled = "user_canceled" + case userMissingCard = "user_missing_card" +} + +extension ScanAnalyticsNonRepeatingTask { + /// Many events will have a fixed start time. The duration will be measured from when the task is created. + init( + event: ScanAnalyticsEvent, + startTime: Date, + endTime: Date = Date() + ) { + self.init( + event: event, + startTime: startTime, + duration: endTime.timeIntervalSince(startTime) + ) + } + + init( + event: ScanAnalyticsEvent, + startTime: Date, + duration: TimeInterval + ) { + self.init( + result: event.rawValue, + startedAtMs: startTime.millisecondsSince1970, + durationMs: duration.milliseconds + ) + } +} + +/// Task object used to track the start time and duration. Internal object used for ScanAnalyticsManager +class TrackableTask { + var startTime: Date + var duration: TimeInterval? + var result: ScanAnalyticsEvent? + + init( + startTime: Date = Date() + ) { + self.startTime = startTime + } + + func trackResult(_ result: ScanAnalyticsEvent, recordDuration: Bool = true) { + self.duration = recordDuration ? Date().timeIntervalSince(startTime) : -1 + self.result = result + } + + func toAPIModel() -> ScanAnalyticsNonRepeatingTask? { + guard + let result = result, + let duration = duration + else { + return nil + } + + return .init(event: result, startTime: startTime, duration: duration) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScanAnalyticsManager.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScanAnalyticsManager.swift new file mode 100644 index 00000000..903f9a53 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScanAnalyticsManager.swift @@ -0,0 +1,160 @@ +// +// ScanAnalyticsManager.swift +// StripeCardScan +// +// Created by Jaime Park on 12/9/21. +// + +import Foundation +@_spi(STP) import StripeCore +import UIKit + +typealias PayloadInfo = ScanAnalyticsPayload.PayloadInfo + +/// Manager used to aggregate scan analytics +class ScanAnalyticsManager { + /// Shared scan analytics manager singleton + static private(set) var shared = ScanAnalyticsManager() + + private let mutexQueue = DispatchQueue(label: "com.stripe.ScanAnalyticsManager.MutexQueue") + /// The start of the scanning session + private var startTime: Date? + private var nonRepeatingTaskManager = NonRepeatingTasksManager() + private var payloadInfo: PayloadInfo? + private var repeatingTaskManager = RepeatingTasksManager() + + init() {} + + func setScanSessionStartTime(time: Date) { + mutexQueue.async { + self.startTime = time + } + } + + /// Create ScanAnalyticsPayload API model + func generateScanAnalyticsPayload( + with configuration: CardImageVerificationSheet.Configuration, + completion: @escaping ( + ScanAnalyticsPayload? + ) -> Void + ) { + mutexQueue.async { [weak self] in + guard let self = self else { + completion(nil) + return + } + + let configuration = ScanAnalyticsPayload.ConfigurationInfo( + strictModeFrames: configuration.strictModeFrames.totalFrameCount + ) + + let scanStatsTasks = ScanStatsTasks( + repeatingTasks: self.repeatingTaskManager.generateRepeatingTasks(), + tasks: self.nonRepeatingTaskManager.generateNonRepeatingTasks() + ) + + DispatchQueue.main.async { + completion( + ScanAnalyticsPayload( + configuration: configuration, + payloadInfo: self.payloadInfo, + scanStats: scanStatsTasks + ) + ) + } + } + } + + /// Keep track of most recent camera permission status + func logCameraPermissionsTask(success: Bool) { + mutexQueue.async { [weak self] in + /// Duration is not relevant for this task + let task = TrackableTask() + task.trackResult(success ? .success : .failure, recordDuration: false) + self?.nonRepeatingTaskManager.cameraPermissionTask = task + } + } + + /// Keep track of most recent torch status + func logTorchSupportTask(supported: Bool) { + mutexQueue.async { [weak self] in + /// Duration is not relevant for this task + let task = TrackableTask() + task.trackResult(supported ? .torchSupported : .torchUnsupported, recordDuration: false) + self?.nonRepeatingTaskManager.torchSupportedTask = task + } + } + + /// Keep track of scan activities of duration from beginning of scan session + func logScanActivityTaskFromStartTime(event: ScanAnalyticsEvent) { + mutexQueue.async { [weak self] in + guard let startTime = self?.startTime else { + assertionFailure("startTime is not set") + return + } + + let task = TrackableTask(startTime: startTime) + task.trackResult(event) + self?.logScanActivityTask(task: task) + } + } + + /// Keep track of scan activities and their start time, duration is not as important + func logScanActivityTask(event: ScanAnalyticsEvent) { + let task = TrackableTask(startTime: Date()) + task.trackResult(event, recordDuration: false) + self.logScanActivityTask(task: task) + } + + /// Keep track of the start time and duration of a scan event + func logScanActivityTask(task: TrackableTask) { + mutexQueue.async { [weak self] in + self?.nonRepeatingTaskManager.scanActivityTasks.append(task) + } + } + + /// Keep track of how many frames were processed this session + func logMainLoopImageProcessedRepeatingTask(_ task: ScanAnalyticsRepeatingTask) { + mutexQueue.async { [weak self] in + self?.repeatingTaskManager.mainLoopImagesProcessed = task + } + } + + /// Keep track of the payload data used for the verification payload creation + func logPayloadInfo(with payloadInfo: PayloadInfo) { + mutexQueue.async { [weak self] in + self?.payloadInfo = payloadInfo + } + } + + /// Keep track of the start time and duration of the completion loop + func trackCompletionLoopDuration(task: TrackableTask) { + mutexQueue.async { [weak self] in + self?.nonRepeatingTaskManager.completionLoopDuration = task + } + } + + /// Keep track of the start time and duration of the image compression + func trackImageCompressionDuration(task: TrackableTask) { + mutexQueue.async { [weak self] in + self?.nonRepeatingTaskManager.imageCompressionDuration = task + } + } + + /// Keep track of the start time and duration of the main loop + func trackMainLoopDuration(task: TrackableTask) { + mutexQueue.async { [weak self] in + self?.nonRepeatingTaskManager.mainLoopDuration = task + } + } + + /// Clear the scan analytics manager instance + func reset() { + mutexQueue.async { [weak self] in + self?.startTime = nil + self?.nonRepeatingTaskManager = NonRepeatingTasksManager() + self?.payloadInfo = nil + self?.repeatingTaskManager = RepeatingTasksManager() + } + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScannedCard.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScannedCard.swift new file mode 100644 index 00000000..a6de41fb --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScannedCard.swift @@ -0,0 +1,14 @@ +// +// ScannedCard.swift +// StripeCardScan +// +// Created by Jaime Park on 11/17/21. +// + +import Foundation + +/// An struct that contains the PAN of the scanned card during +/// the card image verification flow +public struct ScannedCard: Equatable { + public let pan: String +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScannedCardImageData+Verification.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScannedCardImageData+Verification.swift new file mode 100644 index 00000000..354671a4 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScannedCardImageData+Verification.swift @@ -0,0 +1,137 @@ +// +// VerificationScannedCardImageData.swift +// StripeCardScan +// +// Created by Jaime Park on 11/21/21. +// + +import Foundation +@_spi(STP) import StripeCore +import UIKit + +/// Image configurations used for verification flow +typealias ImageConfig = CardImageVerificationAcceptedImageConfigs + +struct CardImageVerificationImageMetadata { + let imageData: Data + let imageSize: CGSize + let compressionType: CardImageVerificationFormat + let compressionQuality: Double +} + +/// Methods used to transform a scanned card image data into `VerificationFramesData` +extension ScannedCardImageData { + /// Returns a `VerificationFramesData` object from the scanned image data + func toVerificationFramesData( + imageConfig: ImageConfig? + ) -> (VerificationFramesData, CardImageVerificationImageMetadata) { + let config = imageConfig ?? ImageConfig() + let encodedImageMetadata = toExpectedImageFormat( + image: previewLayerImage, + imageConfig: config + ) + let imageData = encodedImageMetadata.imageData + let size = encodedImageMetadata.imageSize + + // make sure to adjust the size of our viewFinderRect if jpeg conversion resized the image + let scaleX = size.width / CGFloat(previewLayerImage.width) + let scaleY = size.height / CGFloat(previewLayerImage.height) + let viewfinderMargins = toViewfinderMargins( + viewfinderRect: previewLayerViewfinderRect, + scaleX: scaleX, + scaleY: scaleY + ) + + return ( + VerificationFramesData(imageData: imageData, viewfinderMargins: viewfinderMargins), + encodedImageMetadata + ) + } + + /// Converts a CGImage into a base64 encoded string of a jpeg image + private func toExpectedImageFormat( + image: CGImage, + imageConfig: ImageConfig + ) -> CardImageVerificationImageMetadata { + /// Convert CGImage to UIImage + let uiImage = UIImage(cgImage: image) + + /// TODO(jaimepark): Resize with aspect ratio maintained if image is bigger than 1080 x 1920 + + for format in imageConfig.preferredFormats ?? [] { + if !isImageFormatSupported(format: format) { + continue + } + + let compressedImage = compressedImageForFormat( + image: uiImage, + format: format, + imageConfig: imageConfig + ) + if compressedImage.imageData.count > 0 { + return compressedImage + } + } + + return compressedImageForFormat(image: uiImage, format: .jpeg, imageConfig: imageConfig) + } + + /// Converts the view finder CGRect into a ViewFinderMargins object + private func toViewfinderMargins( + viewfinderRect: CGRect, + scaleX: CGFloat, + scaleY: CGFloat + ) -> ViewFinderMargins { + let left: Int = Int(viewfinderRect.origin.x * scaleX) + let right: Int = Int((viewfinderRect.width + viewfinderRect.origin.x) * scaleX) + let upper: Int = Int(viewfinderRect.origin.y * scaleY) + let lower: Int = Int((viewfinderRect.height + viewfinderRect.origin.y) * scaleY) + + return ViewFinderMargins(left: left, upper: upper, right: right, lower: lower) + } + + private func isImageFormatSupported(format: CardImageVerificationFormat) -> Bool { + format == .heic || format == .jpeg + } + + private func compressedImageForFormat( + image: UIImage, + format: CardImageVerificationFormat, + imageConfig: ImageConfig + ) -> CardImageVerificationImageMetadata { + let imageSettings = imageConfig.imageSettings(format: format) + let compressionRatio = imageSettings.compressionRatio ?? 1 + var result: CardImageVerificationImageMetadata = + .init( + imageData: Data(), + imageSize: .zero, + compressionType: format, + compressionQuality: compressionRatio + ) + + switch format { + case .heic: + let imageDataAndSize = image.heicDataAndDimensions(compressionQuality: compressionRatio) + + result = .init( + imageData: imageDataAndSize.imageData, + imageSize: imageDataAndSize.imageSize, + compressionType: format, + compressionQuality: compressionRatio + ) + case .jpeg: + let imageDataAndSize = image.jpegDataAndDimensions(compressionQuality: compressionRatio) + + result = .init( + imageData: imageDataAndSize.imageData, + imageSize: imageDataAndSize.imageSize, + compressionType: format, + compressionQuality: compressionRatio + ) + case .webp, .unparsable: + assertionFailure("Unsupported format requested for image.") + } + + return result + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScannedCardImageData.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScannedCardImageData.swift new file mode 100644 index 00000000..9f25d0e0 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/ScannedCardImageData.swift @@ -0,0 +1,96 @@ +// +// ScannedCardImageDataProtocol.swift +// StripeCardScan +// +// Created by Jaime Park on 11/21/21. +// + +import Foundation +import UIKit + +/// Data structure representing an image frame captured during the scanning flow. +struct ScannedCardImageData { + /// The image of the scanned card after it has been converted from AVCaptureSession to the video preview layer coordinate system + let previewLayerImage: CGImage + /// The viewfinder bounds after it has been converted from the AVCaptureSession to the video preview layer coordinate system + let previewLayerViewfinderRect: CGRect + + init( + previewLayerImage: CGImage, + previewLayerViewfinderRect: CGRect + ) { + self.previewLayerImage = previewLayerImage + self.previewLayerViewfinderRect = previewLayerViewfinderRect + } + + init?( + captureDeviceImage: CGImage, + viewfinderRect: CGRect, + previewViewRect: CGRect + ) { + guard + let previewLayerImage = + ScannedCardImageData + .convertToPreviewLayerImage( + captureDeviceImage: captureDeviceImage, + viewfinderRect: viewfinderRect, + previewViewRect: previewViewRect + ), + let previewLayerViewfinderRect = + ScannedCardImageData + .convertToPreviewLayerRect( + captureDeviceImage: captureDeviceImage, + viewfinderRect: viewfinderRect, + previewViewRect: previewViewRect + ) + else { + return nil + } + + self.init( + previewLayerImage: previewLayerImage, + previewLayerViewfinderRect: previewLayerViewfinderRect + ) + } +} + +/// TODO(jaimepark): Update conversion methods to calculate based on only AVCaputureSessionPreviewLayer. +/// Currently, .FullScreenAndRoi returns both the converted image and view finder rect. Once the conversion logic +/// is updated, the params will be updated and these functions will be DRY-ed up. +extension ScannedCardImageData { + /// Using legacy SDK logic, returns the AVCaptureDevice-to-preview view layer converted image + static func convertToPreviewLayerImage( + captureDeviceImage: CGImage, + viewfinderRect: CGRect, + previewViewRect: CGRect + ) -> CGImage? { + guard + let (convertedImage, _) = + captureDeviceImage.toFullScreenAndRoi( + previewViewFrame: previewViewRect, + regionOfInterestLabelFrame: viewfinderRect + ) + else { + return nil + } + return convertedImage + } + + /// Using legacy SDK logic, returns the AVCaptureDevice-to-preview view layer converted view finder rect + static func convertToPreviewLayerRect( + captureDeviceImage: CGImage, + viewfinderRect: CGRect, + previewViewRect: CGRect + ) -> CGRect? { + guard + let (_, convertedRect) = + captureDeviceImage.toFullScreenAndRoi( + previewViewFrame: previewViewRect, + regionOfInterestLabelFrame: viewfinderRect + ) + else { + return nil + } + return convertedRect + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/StripeCore+Import.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/StripeCore+Import.swift new file mode 100644 index 00000000..f9310dd4 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Card Image Verification/StripeCore+Import.swift @@ -0,0 +1,9 @@ +// +// StripeCore+Import.swift +// StripeCardScan +// +// Created by Jaime Park on 11/18/21. +// + +import Foundation +@_exported import StripeCore diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/CardBase.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/CardBase.swift new file mode 100644 index 00000000..050f5a3a --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/CardBase.swift @@ -0,0 +1,46 @@ +// +// CardBase.swift +// CardScan +// +// Created by Jaime Park on 1/31/20. +// + +import Foundation + +class CardBase: NSObject { + var last4: String + var bin: String? + var expMonth: String? + var expYear: String? + var isNewCard: Bool = false + + init( + last4: String, + bin: String?, + expMonth: String? = nil, + expYear: String? = nil + ) { + self.last4 = last4 + self.bin = bin + self.expMonth = expMonth + self.expYear = expYear + } + + func expiryForDisplay() -> String? { + guard let month = self.expMonth, let year = self.expYear else { + return nil + } + + return CreditCardUtils.formatExpirationDate(expMonth: month, expYear: year) + } + + func toOcrJson() -> [String: Any] { + var ocrJson: [String: Any] = [:] + ocrJson["last4"] = self.last4 + ocrJson["bin"] = self.bin + ocrJson["exp_month"] = self.expMonth + ocrJson["exp_year"] = self.expYear + + return ocrJson + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/CardScanFraudData.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/CardScanFraudData.swift new file mode 100644 index 00000000..9c5bccd3 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/CardScanFraudData.swift @@ -0,0 +1,180 @@ +/// This is our completion loop buffer. +/// +/// In general, our goal is to capture frames that will work best with our fraud models while capturing fraud behavior. As such: +/// 1. We give top priority to frames where the UX model finds a card _and_ we perform OCR on the frame successfully +/// 2. Next priority goes to frames that the UX model finds a card but it could _not_ perform OCR on the frame +/// 3. Lowest priority goes to frames that pass OCR but _not_ the UX model +/// +/// One consequence of this priority is that even if we perform OCR successfully we could end up with all frames _without_ +/// OCR because we give priority to UX. +/// +/// We can assume that any frames that pass OCR will be recent (within the last 2-3 seconds). This is _always_ true for +/// OCR, but for UX we might get a few older frames because our logic for starting non number side card scans requires a few +/// consecutive frames where the UX model detects a card. +/// +/// # Correctness +/// +/// This interface is thread safe, but all shared state access needs to happen in the `mutexQueue`. The mutexQueue +/// enforces ordering constraints and will process frames in the correct order as defined by the `ScanEvents` calling +/// sequence. +/// + +import CoreGraphics +import UIKit + +class CardScanFraudData: ScanEvents { + let mutexQueue = DispatchQueue(label: "Completion loop mutex queue") + + var last4: String? + var hasModelBeenCalled = false + var framesWithCards: [ScannedCardImageData] = [] + var framesWithCardsAndOcr: [ScannedCardImageData] = [] + var ocrOnlyFrames: [ScannedCardImageData] = [] + var framesWithFlashCardsAndOcr: [ScannedCardImageData] = [] + var framesWithFlashAndCards: [ScannedCardImageData] = [] + var framesWithFlashAndOcr: [ScannedCardImageData] = [] + let kMaxScans = 5 + let kMaxFlashScans = 3 + var requireOcrBeforeCapturingUxOnlyFrames = true + + var debugRetainImages = false + // Note: Only access these arrays on the main loop + var savedSquareImages: [CGImage]? + var savedFullImages: [CGImage]? + var savedNumberBoxes: [[CGRect]]? + + init() {} + + func onFrameDetected( + imageData: ScannedCardImageData, + centeredCardState: CenteredCardState?, + flashForcedOn: Bool + ) { + mutexQueue.async { + if self.hasModelBeenCalled { + return + } + + let hasCard = centeredCardState?.hasCard() ?? false + + let ocrFrameCount = self.framesWithCardsAndOcr.count + self.ocrOnlyFrames.count + + if hasCard && (ocrFrameCount > 0 || !self.requireOcrBeforeCapturingUxOnlyFrames) { + if flashForcedOn { + self.framesWithFlashAndCards.append(imageData) + } else { + self.framesWithCards.append(imageData) + } + } + + self.balanceFrames() + } + } + + func onNumberRecognized( + number: String, + expiry: Expiry?, + imageData: ScannedCardImageData, + centeredCardState: CenteredCardState?, + flashForcedOn: Bool + ) { + mutexQueue.async { + if self.hasModelBeenCalled { + return + } + + let hasCard = centeredCardState?.hasCard() ?? false + let scannedLastFour = String(number.suffix(4)) + + /// This method is used to put the frame data in it's appropriate list given the `flashForcedOn` and `hasCard` flag, + /// This method should be called on when we know that we want to keep the frame. + func appendFrameData(flashForcedOn: Bool, hasCard: Bool) { + if flashForcedOn { + if hasCard { + self.framesWithFlashCardsAndOcr.append(imageData) + } else { + self.framesWithFlashAndOcr.append(imageData) + } + } else if hasCard { + self.framesWithCardsAndOcr.append(imageData) + } else { + self.ocrOnlyFrames.append(imageData) + } + } + + // Check if we have a card set to be challenged + if let challengedLast4 = self.last4 { + guard challengedLast4 == scannedLastFour else { + // The set card to be challenged doesn't match the scanned card. + // Don't use this frame at all. + return + } + + // Given that the challenged card matches the frame's pan + last, put frame in appropriate list + appendFrameData(flashForcedOn: flashForcedOn, hasCard: hasCard) + + } else { + // If we don't have a card set to be challenged just add frameData + appendFrameData(flashForcedOn: flashForcedOn, hasCard: hasCard) + } + + self.balanceFrames() + } + } + + private func balanceFrames() { + self.framesWithCardsAndOcr = Array(self.framesWithCardsAndOcr.suffix(kMaxScans)) + + // make sure that we have a total of kMaxScans across OCR+UX, UX, and OCR frames giving priority to OCR+UX + let framesWithCardsToHold = + [kMaxScans - self.framesWithCardsAndOcr.count, 0].max() ?? kMaxScans + self.framesWithCards = Array(self.framesWithCards.suffix(framesWithCardsToHold)) + + let ocrFramesToHold = + [kMaxScans - self.framesWithCardsAndOcr.count - self.framesWithCards.count, 0].max() + ?? kMaxScans + self.ocrOnlyFrames = Array(self.ocrOnlyFrames.suffix(ocrFramesToHold)) + + // separately, keep kMaxFlashScans number of frames with the flash on + self.framesWithFlashCardsAndOcr = Array( + self.framesWithFlashCardsAndOcr.suffix(kMaxFlashScans) + ) + + let framesWithFlashCardsToHold = + [kMaxFlashScans - self.framesWithFlashCardsAndOcr.count, 0].max() ?? kMaxFlashScans + self.framesWithFlashAndCards = Array( + self.framesWithFlashAndCards.suffix(framesWithFlashCardsToHold) + ) + + let ocrFlashFramesToHold = + [ + kMaxFlashScans - self.framesWithFlashCardsAndOcr.count + - self.framesWithFlashAndCards.count, 0, + ].max() ?? kMaxFlashScans + self.framesWithFlashAndOcr = Array(self.framesWithFlashAndOcr.suffix(ocrFlashFramesToHold)) + } + + func onResultReady(scannedCardImagesData: [ScannedCardImageData]) { + // TODO: Run verification pipeline and report back + } + + func getCompletionLoopFrames() -> [ScannedCardImageData] { + let completionLoopFrames = + self.framesWithFlashAndOcr + self.framesWithFlashAndCards + + self.framesWithFlashCardsAndOcr + self.ocrOnlyFrames + self.framesWithCards + + self.framesWithCardsAndOcr + return Array(completionLoopFrames.suffix(kMaxScans + kMaxFlashScans)) + } + + func onScanComplete(scanStats: ScanStats) { + mutexQueue.async { + if self.hasModelBeenCalled { + return + } + self.hasModelBeenCalled = true + + let completionLoopFrames = self.getCompletionLoopFrames() + self.onResultReady(scannedCardImagesData: completionLoopFrames) + } + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/CardScanMisc.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/CardScanMisc.swift new file mode 100644 index 00000000..b05d8a16 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/CardScanMisc.swift @@ -0,0 +1,41 @@ +import AVKit + +protocol CaptureOutputDelegate { + func capture( + _ output: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) +} + +class CreditCard: NSObject { + var number: String + var expiryMonth: String? + var expiryYear: String? + var name: String? + var image: UIImage? + var cvv: String? + var postalCode: String? + + init( + number: String + ) { + self.number = number + } + + func expiryForDisplay() -> String? { + guard var month = self.expiryMonth, var year = self.expiryYear else { + return nil + } + + if month.count == 1 { + month = "0" + month + } + + if year.count == 4 { + year = String(year.suffix(2)) + } + + return "\(month)/\(year)" + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/CardVerifyFraudData.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/CardVerifyFraudData.swift new file mode 100644 index 00000000..99969102 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/CardVerifyFraudData.swift @@ -0,0 +1,75 @@ +import Foundation + +class CardVerifyFraudData: CardScanFraudData { + // one subtlety we have is that we might try to get results before the + // model is done running. Thus we record the model results for this object + // and keep track of any callers that try to get a response too early + // and notify them later. + // + // All data access is on the main queue + var verificationFrameDataResults: [VerificationFramesData]? + var resultCallbacks: [((_ response: [VerificationFramesData]) -> Void)] = [] + + var acceptedImageConfigs: CardImageVerificationAcceptedImageConfigs? + + static let maxCompletionLoopFrames = 5 + + init( + last4: String? = nil, + acceptedImageConfigs: CardImageVerificationAcceptedImageConfigs? = nil + ) { + super.init() + self.last4 = last4 + self.acceptedImageConfigs = acceptedImageConfigs + } + + override func onResultReady(scannedCardImagesData: [ScannedCardImageData]) { + DispatchQueue.main.async { + let imageCompressionTask = TrackableTask() + let processedVerificationFrames = scannedCardImagesData.compactMap { + $0.toVerificationFramesData(imageConfig: self.acceptedImageConfigs) + } + imageCompressionTask.trackResult( + processedVerificationFrames.count > 0 ? .success : .failure + ) + + let verificationFramesData = processedVerificationFrames.compactMap { $0.0 } + /// Use the image metadata from the most recent frame + let verificationImageMetadata = processedVerificationFrames.compactMap { $0.1 }.last + + /// Calculate the compressed and b64 encoded image sizes in bytes + let totalImagePayloadSizeInBytes = verificationFramesData.compactMap { $0.imageData } + .reduce(0) { $0 + $1.count } + + /// Log the verification payload info + image compression duration + ScanAnalyticsManager.shared.trackImageCompressionDuration(task: imageCompressionTask) + ScanAnalyticsManager.shared.logPayloadInfo( + with: .init( + imageCompressionType: verificationImageMetadata?.compressionType.rawValue + ?? "unknown", + imageCompressionQuality: verificationImageMetadata?.compressionQuality ?? 0.0, + imagePayloadSize: totalImagePayloadSizeInBytes + ) + ) + + self.verificationFrameDataResults = verificationFramesData + + for complete in self.resultCallbacks { + complete(verificationFramesData) + } + + self.resultCallbacks = [] + } + } + + func result(complete: @escaping ([VerificationFramesData]) -> Void) { + DispatchQueue.main.async { + guard let results = self.verificationFrameDataResults else { + self.resultCallbacks.append(complete) + return + } + + complete(results) + } + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/CardVerifyStateMachine.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/CardVerifyStateMachine.swift new file mode 100644 index 00000000..e4928770 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/CardVerifyStateMachine.swift @@ -0,0 +1,325 @@ +// +// CardVerifyStateMachine.swift +// CardVerify +// +// Created by Adam Wushensky on 8/7/20. +// + +import Foundation + +typealias StrictModeFramesCount = CardImageVerificationSheet.StrictModeFrameCount + +protocol CardVerifyStateMachineProtocol { + var requiredLastFour: String? { get } + var requiredBin: String? { get } + var strictModeFramesCount: StrictModeFramesCount { get } + var visibleMatchingCardCount: Int { get set } + + func resetCountAndReturnToInitialState() -> MainLoopState + func determineFinishedState() -> MainLoopState +} + +class CardVerifyStateMachine: OcrMainLoopStateMachine, CardVerifyStateMachineProtocol { + var requiredLastFour: String? + var requiredBin: String? + var strictModeFramesCount: StrictModeFramesCount + var visibleMatchingCardCount: Int = 0 + + let ocrAndCardStateDurationSeconds = 1.5 + let ocrOnlyStateDurationSeconds = 1.5 + let ocrDelayForCardStateDurationSeconds = 2.0 + let ocrIncorrectDurationSeconds = 2.0 + let ocrForceFlashDurationSeconds = 1.5 + + init( + requiredLastFour: String? = nil, + requiredBin: String? = nil, + strictModeFramesCount: CardImageVerificationSheet.StrictModeFrameCount + ) { + self.requiredLastFour = requiredLastFour + self.requiredBin = requiredBin + self.strictModeFramesCount = strictModeFramesCount + } + + convenience init( + requiredLastFour: String? = nil, + requiredBin: String? = nil + ) { + self.init( + requiredLastFour: requiredLastFour, + requiredBin: requiredBin, + strictModeFramesCount: .none + ) + } + + func resetCountAndReturnToInitialState() -> MainLoopState { + visibleMatchingCardCount = 0 + return .initial + } + + func determineFinishedState() -> MainLoopState { + if Bouncer.useFlashFlow { + return .ocrForceFlash + } + + /// The ocr and card state timer has elapsed. If visible card count hasn't been met within the time limit, then reset the timer and try again + return visibleMatchingCardCount >= strictModeFramesCount.totalFrameCount + ? .finished : resetCountAndReturnToInitialState() + } + + override func transition(prediction: CreditCardOcrPrediction) -> MainLoopState? { + let frameHasOcr = prediction.number != nil + let frameOcrMatchesRequiredLastFour = + requiredLastFour == nil + || String(prediction.number?.suffix(4) ?? "") == requiredLastFour + let frameOcrMatchesRequiredBin = + requiredBin == nil || String(prediction.number?.prefix(6) ?? "") == requiredBin + let frameOcrMatchesRequired = frameOcrMatchesRequiredBin && frameOcrMatchesRequiredLastFour + let frameHasCard = prediction.centeredCardState?.hasCard() ?? false + let secondsInState = -startTimeForCurrentState.timeIntervalSinceNow + + if frameHasCard && frameOcrMatchesRequired { + visibleMatchingCardCount += 1 + } + + switch (self.state, secondsInState, frameHasOcr, frameHasCard, frameOcrMatchesRequired) { + // MARK: Initial State + case (.initial, _, true, true, true): + // successful OCR and card + return .ocrAndCard + case (.initial, _, true, _, false): + // saw an incorrect card + return .ocrIncorrect + case (.initial, _, _, true, _): + // got a frame with a card + return .cardOnly + case (.initial, _, true, _, true): + // successful OCR and the card matches required + return .ocrOnly + + // MARK: Card Only State + case (.cardOnly, _, true, _, false): + // if we're cardOnly and we get a frame with OCR and it does not match the required card + return .ocrIncorrect + case (.cardOnly, _, true, _, true): + // if we're cardonly and we get a frame with OCR and it matches the required card + return .ocrAndCard + + // MARK: OCR Only State + case (.ocrOnly, _, _, true, _): + // if we're ocrOnly and we get a card + return .ocrAndCard + case (.ocrOnly, self.ocrOnlyStateDurationSeconds..., _, _, _): + // ocrOnly times out without getting a card + return .ocrDelayForCard + + // MARK: OCR and Card State + case (.ocrAndCard, self.ocrAndCardStateDurationSeconds..., _, _, _): + return determineFinishedState() + + // MARK: OCR Incorrect State + case (.ocrIncorrect, _, true, false, true): + // if we're ocrIncorrect and we get a valid pan + return .ocrOnly + case (.ocrIncorrect, _, true, true, true): + // if we're ocrIncorrect and we get a valid pan and card + return .ocrAndCard + case (.ocrIncorrect, _, true, _, false): + // if we're ocrIncorrect and we get another bad pan, restart the timer + return .ocrIncorrect + case (.ocrIncorrect, self.ocrIncorrectDurationSeconds..., _, _, _): + // if we're ocrIncorrect and the timer has elapsed + return resetCountAndReturnToInitialState() + + // MARK: OCR Delay for Card State + case (.ocrDelayForCard, _, _, true, _): + // if we're ocrDelayForCard and we get a card + return .ocrAndCard + case (.ocrDelayForCard, self.ocrDelayForCardStateDurationSeconds..., _, _, _): + // if we're ocrDelayForCard and we time out + return determineFinishedState() + + // MARK: OCR Force Flash State + case (.ocrForceFlash, self.ocrForceFlashDurationSeconds..., _, _, _): + return .finished + default: + return nil + } + } + + override func reset() -> MainLoopStateMachine { + return CardVerifyStateMachine( + requiredLastFour: requiredLastFour, + requiredBin: requiredBin, + strictModeFramesCount: strictModeFramesCount + ) + } +} + +class CardVerifyAccurateStateMachine: OcrMainLoopStateMachine, CardVerifyStateMachineProtocol { + var requiredLastFour: String? + var requiredBin: String? + var strictModeFramesCount: StrictModeFramesCount + var visibleMatchingCardCount: Int = 0 + + var hasNamePrediction = false + var hasExpiryPrediction = false + + let ocrAndCardStateDurationSeconds = 1.5 + let ocrOnlyStateDurationSeconds = 1.5 + let ocrDelayForCardStateDurationSeconds = 2.0 + let ocrIncorrectDurationSeconds = 2.0 + let ocrForceFlashDurationSeconds = 1.5 + var nameExpiryDurationSeconds = 4.0 + + init( + requiredLastFour: String? = nil, + requiredBin: String? = nil, + maxNameExpiryDurationSeconds: Double, + strictModeFramesCount: StrictModeFramesCount + ) { + self.requiredLastFour = requiredLastFour + self.requiredBin = requiredBin + self.nameExpiryDurationSeconds = maxNameExpiryDurationSeconds + self.strictModeFramesCount = strictModeFramesCount + } + + convenience init( + requiredLastFour: String? = nil, + requiredBin: String? = nil, + maxNameExpiryDurationSeconds: Double + ) { + self.init( + requiredLastFour: requiredLastFour, + requiredBin: requiredBin, + maxNameExpiryDurationSeconds: maxNameExpiryDurationSeconds, + strictModeFramesCount: .none + ) + } + + func resetCountAndReturnToInitialState() -> MainLoopState { + visibleMatchingCardCount = 0 + return .initial + } + + func determineFinishedState() -> MainLoopState { + if Bouncer.useFlashFlow { + return .ocrForceFlash + } + + /// The ocr and card state timer has elapsed. If visible card count hasn't been met within the time limit, then reset the timer and try again + return visibleMatchingCardCount >= strictModeFramesCount.totalFrameCount + ? .finished : resetCountAndReturnToInitialState() + } + + override func transition(prediction: CreditCardOcrPrediction) -> MainLoopState? { + hasExpiryPrediction = hasExpiryPrediction || prediction.expiryForDisplay != nil + hasNamePrediction = hasNamePrediction || prediction.name != nil + + let frameHasOcr = prediction.number != nil + let frameOcrMatchesRequiredLastFour = + requiredLastFour == nil + || String(prediction.number?.suffix(4) ?? "") == requiredLastFour + let frameOcrMatchesRequiredBin = + requiredBin == nil || String(prediction.number?.prefix(6) ?? "") == requiredBin + let frameOcrMatchesRequired = frameOcrMatchesRequiredBin && frameOcrMatchesRequiredLastFour + let frameHasCard = prediction.centeredCardState?.hasCard() ?? false + let hasNameAndExpiry = hasNamePrediction && hasExpiryPrediction + let secondsInState = -startTimeForCurrentState.timeIntervalSinceNow + + if frameHasCard && frameOcrMatchesRequired { + visibleMatchingCardCount += 1 + } + + switch ( + self.state, secondsInState, frameHasOcr, frameHasCard, frameOcrMatchesRequired, + hasNameAndExpiry + ) { + // MARK: Initial State + case (.initial, _, true, true, true, _): + // successful OCR and card + return .ocrAndCard + case (.initial, _, true, _, false, _): + // saw an incorrect card + return .ocrIncorrect + case (.initial, _, _, true, _, _): + // got a frame with a card + return .cardOnly + case (.initial, _, true, _, true, _): + // successful OCR and the card matches required + return .ocrOnly + + // MARK: Card Only State + case (.cardOnly, _, true, _, false, _): + // if we're cardOnly and we get a frame with OCR and it does not match the required card + return .ocrIncorrect + case (.cardOnly, _, true, _, true, _): + // if we're cardonly and we get a frame with OCR and it matches the required card + return .ocrAndCard + + // MARK: Ocr Only State + case (.ocrOnly, _, _, true, _, _): + // if we're ocrOnly and we get a card + return .ocrAndCard + case (.ocrOnly, self.ocrOnlyStateDurationSeconds..., _, _, _, _): + // ocrOnly times out without getting a card + return .ocrDelayForCard + + // MARK: Ocr Incorrect State + case (.ocrIncorrect, _, true, false, true, _): + // if we're ocrIncorrect and we get a valid pan + return .ocrOnly + case (.ocrIncorrect, _, true, true, true, _): + // if we're ocrIncorrect and we get a valid pan and card + return .ocrAndCard + case (.ocrIncorrect, _, true, _, false, _): + // if we're ocrIncorrect and we get another bad pan, restart the timer + return .ocrIncorrect + case (.ocrIncorrect, self.ocrIncorrectDurationSeconds..., _, _, _, _): + // if we're ocrIncorrect and the timer has elapsed + return .initial + + // MARK: Ocr and Card State + case (.ocrAndCard, self.ocrAndCardStateDurationSeconds..., _, _, _, false): + // if we're in ocr&card and dont have name&expiry + return .nameAndExpiry + case (.ocrAndCard, self.ocrAndCardStateDurationSeconds..., _, _, _, true): + // if we're in ocr&card and we have name&expiry + return determineFinishedState() + + // MARK: Ocr Delay For Card State + case (.ocrDelayForCard, self.ocrDelayForCardStateDurationSeconds..., _, _, _, false): + // if we're ocrDelayForCard, we time out, and we dont have name&expiry + return .nameAndExpiry + case (.ocrDelayForCard, self.ocrDelayForCardStateDurationSeconds..., _, _, _, true): + // if we're ocrDelayForCard, we time out but we have name&expiry + return determineFinishedState() + case (.ocrDelayForCard, _, _, true, _, _): + // if we're ocrDelayForCard and we get a card + return .ocrAndCard + + // MARK: Name And Expiry State + case (.nameAndExpiry, self.nameExpiryDurationSeconds..., _, _, _, _): + // if we're checking for name&expiry and we time out + return Bouncer.useFlashFlow ? .ocrForceFlash : .finished + case (.nameAndExpiry, _, _, _, _, true): + // if we're checking for name&expiry and we find name&expiry + return determineFinishedState() + + // MARK: Ocr Force Flash State + case (.ocrForceFlash, self.ocrForceFlashDurationSeconds..., _, _, _, _): + return .finished + default: + return nil + } + } + + override func reset() -> MainLoopStateMachine { + return CardVerifyAccurateStateMachine( + requiredLastFour: requiredLastFour, + requiredBin: requiredBin, + maxNameExpiryDurationSeconds: nameExpiryDurationSeconds, + strictModeFramesCount: strictModeFramesCount + ) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/FadeInAnimation.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/FadeInAnimation.swift new file mode 100644 index 00000000..6197d4da --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/FadeInAnimation.swift @@ -0,0 +1,67 @@ +// +// FadeInAnimation.swift +// CardScan +// +// Created by Jaime Park on 8/16/19. +// + +import UIKit + +extension UIView { + + func fadeIn(_ duration: TimeInterval? = 0.4, onCompletion: (() -> Void)? = nil) { + self.alpha = 0 + self.isHidden = false + UIView.animate( + withDuration: duration!, + animations: { self.alpha = 1 }, + completion: { (_: Bool) in + if let complete = onCompletion { complete() } + } + ) + } + + func fadeBorderColorIn( + _ duration: TimeInterval? = 0.4, + withColor: UIColor, + onCompletion: (() -> Void)? = nil + ) { + self.isHidden = false + UIView.animate( + withDuration: duration!, + animations: { self.layer.borderColor = withColor.cgColor }, + completion: { (_: Bool) in + if let complete = onCompletion { complete() } + } + ) + } + + func fadeOut(_ duration: TimeInterval? = 0.4, onCompletion: (() -> Void)? = nil) { + self.alpha = 1 + self.isHidden = true + UIView.animate( + withDuration: duration!, + animations: { self.alpha = 0 }, + completion: { (_: Bool) in + if let complete = onCompletion { complete() } + } + ) + } +} + +extension UILabel { + func fadeTextColorIn( + _ duration: TimeInterval? = 0.4, + withColor: UIColor, + onCompletion: (() -> Void)? = nil + ) { + self.isHidden = false + UIView.animate( + withDuration: duration!, + animations: { self.textColor = withColor }, + completion: { (_: Bool) in + if let complete = onCompletion { complete() } + } + ) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/FrameData.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/FrameData.swift new file mode 100644 index 00000000..3a734b44 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/FrameData.swift @@ -0,0 +1,40 @@ +import UIKit + +struct FrameData { + let bin: String? + let last4: String? + let expiry: Expiry? + let numberBoundingBox: CGRect? + let numberBoxesInFullImageFrame: [CGRect]? + let croppedCardSize: CGSize + let squareCardImage: CGImage + let fullCardImage: CGImage + let centeredCardState: CenteredCardState? + let ocrSuccess: Bool + let uxFrameConfidenceValues: UxFrameConfidenceValues? + let flashForcedOn: Bool + + func toDictForOcrFrame() -> [String: Any] { + var numberBox: [String: Any]? + + if let numberBoundingBox = self.numberBoundingBox { + numberBox = [ + "x_min": numberBoundingBox.minX / CGFloat(croppedCardSize.width), + "y_min": numberBoundingBox.minY / CGFloat(croppedCardSize.height), + "width": numberBoundingBox.width / CGFloat(croppedCardSize.width), + "height": numberBoundingBox.height / CGFloat(croppedCardSize.height), + "label": -1, + "confidence": 1, + ] + } + + var result: [String: Any] = [:] + result["bin"] = self.bin + result["last4"] = self.last4 + result["number_box"] = numberBox + result["exp_month"] = (self.expiry?.month).map { String($0) } + result["exp_year"] = (self.expiry?.year).map { String($0) } + + return result + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Helpers/EndToEndTestDataSource.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Helpers/EndToEndTestDataSource.swift new file mode 100644 index 00000000..65a3abd7 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Helpers/EndToEndTestDataSource.swift @@ -0,0 +1,48 @@ +// +// EndToEndTestDataSource.swift +// StripeCardScanTests +// +// Created by Scott Grant on 9/25/22. +// + +#if targetEnvironment(simulator) + + import Foundation + import UIKit + + class EndToEndTestingImageDataSource: TestingImageDataSource { + lazy var testImages: [UIImage] = { + let bundle = Bundle(for: EndToEndTestingImageDataSource.self) + let path = bundle.url(forResource: "synthetic_test_image", withExtension: "jpg")! + let image = UIImage(contentsOfFile: path.path)! + return [image] + }() + + lazy var currentTestImages: [CGImage]? = { + self.testImages.compactMap { $0.cgImage } + }() + + func nextSquareAndFullImage() -> CGImage? { + guard let targetSize = UIApplication.shared.windows.first?.frame.size else { + return nil + } + + guard let fullCardImage = self.currentTestImages?.first else { + return nil + } + + let resultImage = fullCardImage.extendedEdges(targetSize: targetSize) + + self.currentTestImages = self.currentTestImages?.dropFirst().map { $0 } + + guard let testImageCount = self.currentTestImages?.count else { return nil } + + if testImageCount == 0 { + self.currentTestImages = self.testImages.compactMap { $0.cgImage } + } + + return resultImage + } + } + +#endif // targetEnvironment(simulator) diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Helpers/STPLocalizedString.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Helpers/STPLocalizedString.swift new file mode 100644 index 00000000..e43018bf --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Helpers/STPLocalizedString.swift @@ -0,0 +1,16 @@ +// +// STPLocalizedString.swift +// StripeCardScan +// +// Created by Sam King on 12/8/21. +// + +import Foundation +@_spi(STP) import StripeCore + +@inline(__always) func STPLocalizedString(_ key: String, _ comment: String?) -> String { + return STPLocalizationUtils.localizedStripeString( + forKey: key, + bundleLocator: StripeCardScanBundleLocator.self + ) +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/Helpers/String+Localized.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/Helpers/String+Localized.swift new file mode 100644 index 00000000..049d9402 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/Helpers/String+Localized.swift @@ -0,0 +1,46 @@ +// +// String+Localized.swift +// StripeCardScan +// +// Created by Sam King on 12/8/21. +// + +import Foundation +@_spi(STP) import StripeCore + +extension String.Localized { + static var card_doesnt_match: String { + return STPLocalizedString( + "Card doesn't match", + "Label of the error message when the scanned card mismatches the card on file" + ) + } + + static var torch: String { + return STPLocalizedString( + "Torch", + "Label for the button that toggles the camera's torch" + ) + } + + static var enable_camera_access: String { + return STPLocalizedString( + "Enable camera access", + "Label for button to take customer to camera settings" + ) + } + + static var update_phone_settings: String { + return STPLocalizedString( + "To scan your card you'll need to update your phone settings", + "Label to explain that they need to update phone settings to scan" + ) + } + + static var enter_card_details_manually: String { + return STPLocalizedString( + "Enter card details manually", + "Label for button to enter card details manually instead of scanning" + ) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/PaymentCard.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/PaymentCard.swift new file mode 100644 index 00000000..ab82cb6d --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/PaymentCard.swift @@ -0,0 +1,76 @@ +// +// CreditCard.swift +// CardScan +// +// Created by Jaime Park on 7/22/19. +// + +import Foundation +import UIKit + +class PaymentCard: CardBase { + var number: String + var cvv: String? + var zip: String? + var network: CardNetwork + + enum Network: Int { + case VISA, MASTERCARD, AMEX, DISCOVER, UNIONPAY, UNKNOWN + + func toCardNetwork() -> CardNetwork { + switch self { + case .VISA: return CardNetwork.VISA + case .MASTERCARD: return CardNetwork.MASTERCARD + case .AMEX: return CardNetwork.AMEX + case .DISCOVER: return CardNetwork.DISCOVER + case .UNIONPAY: return CardNetwork.UNIONPAY + default: return CardNetwork.UNKNOWN + } + } + } + + init( + number: String, + expiryMonth: String?, + expiryYear: String?, + network: Network? + ) { + self.number = number + self.network = + network?.toCardNetwork() ?? CreditCardUtils.determineCardNetwork(cardNumber: number) + super.init( + last4: String(number.suffix(4)), + bin: nil, + expMonth: expiryMonth, + expYear: expiryYear + ) + } + + init( + last4: String, + bin: String?, + expiryMonth: String?, + expiryYear: String?, + network: Network? + ) { + self.number = last4 + self.network = network?.toCardNetwork() ?? CardNetwork.UNKNOWN + super.init(last4: last4, bin: bin, expMonth: expiryMonth, expYear: expiryYear) + } + + func isValidCvv() -> Bool { + guard let cvv = self.cvv else { + return false + } + + return CreditCardUtils.isValidCvv(cvv: cvv, network: self.network) + } + + func isValidDate() -> Bool { + guard let month = self.expMonth, let year = self.expYear else { + return false + } + + return CreditCardUtils.isValidDate(expMonth: month, expYear: year) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/SimpleScanViewController+Verify.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/SimpleScanViewController+Verify.swift new file mode 100644 index 00000000..29ee43c7 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/SimpleScanViewController+Verify.swift @@ -0,0 +1,24 @@ +import UIKit + +extension SimpleScanViewController { + func showFullScreenActivityIndicator() { + let container = UIView() + self.view.addSubview(container) + container.translatesAutoresizingMaskIntoConstraints = false + container.setAnchorsEqual(to: self.view) + container.backgroundColor = #colorLiteral(red: 0, green: 0, blue: 0, alpha: 0.7462275257) + + let activityIndicator = UIActivityIndicatorView() + + activityIndicator.style = .large + activityIndicator.color = .white + + activityIndicator.isHidden = false + activityIndicator.startAnimating() + container.addSubview(activityIndicator) + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + + activityIndicator.centerXAnchor.constraint(equalTo: container.centerXAnchor).isActive = true + activityIndicator.centerYAnchor.constraint(equalTo: container.centerYAnchor).isActive = true + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/StripeCardScanBundleLocator.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/StripeCardScanBundleLocator.swift new file mode 100644 index 00000000..673bb944 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/StripeCardScanBundleLocator.swift @@ -0,0 +1,20 @@ +// +// StripeCardScanBundleLocator.swift +// StripeCardScan +// +// Created by Sam King on 11/10/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +/// :nodoc: +final class StripeCardScanBundleLocator: BundleLocatorProtocol { + static let internalClass: AnyClass = StripeCardScanBundleLocator.self + static let bundleName = "StripeCardScan" + #if SWIFT_PACKAGE + static let spmResourcesBundle = Bundle.module + #endif + static let resourcesBundle = StripeCardScanBundleLocator.computeResourcesBundle() +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/UxAnalyzer.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/UxAnalyzer.swift new file mode 100644 index 00000000..6982eb1b --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/UxAnalyzer.swift @@ -0,0 +1,131 @@ +// +// Created by Sam King on 3/20/20. +// Copyright © 2020 Sam King. All rights reserved. +// +import UIKit + +@_spi(STP) public class UxAnalyzer: CreditCardOcrImplementation { + @AtomicProperty var uxModel: UxModel? + + static let uxResource = "UxModel" + static let uxExtension = "mlmodelc" + + let ocr: CreditCardOcrImplementation + + init( + with ocr: CreditCardOcrImplementation + ) { + self.ocr = ocr + uxModel = UxAnalyzer.loadModelFromBundle() + super.init(dispatchQueue: ocr.dispatchQueue) + } + + init( + asyncWith ocr: CreditCardOcrImplementation + ) { + self.ocr = ocr + super.init(dispatchQueue: ocr.dispatchQueue) + loadModel() + } + + @_spi(STP) public static func loadModelFromBundle() -> UxModel? { + let bundle = StripeCardScanBundleLocator.resourcesBundle + guard let url = bundle.url(forResource: UxAnalyzer.uxResource, withExtension: UxAnalyzer.uxExtension) else { + return nil + } + + return try? UxModel(contentsOf: url) + } + + override func recognizeCard( + in fullImage: CGImage, + roiRectangle: CGRect + ) -> CreditCardOcrPrediction { + guard let imageForUxModel = fullImage.squareImageForUxModel(roiRectangle: roiRectangle), + let uxModelPixelBuf = UIImage(cgImage: imageForUxModel).pixelBuffer( + width: 224, + height: 224 + ) + else { + return CreditCardOcrPrediction.emptyPrediction(cgImage: fullImage) + } + + // we already have parallel inference at the analyzer level so no need to run this prediction + // in parallel with the OCR prediction. Plus, this is iOS so the uxmodel prediction will be fast + guard let uxModel = uxModel, + let prediction = try? uxModel.prediction(input1: uxModelPixelBuf) + else { + return CreditCardOcrPrediction.emptyPrediction(cgImage: fullImage) + } + + return ocr.recognizeCard(in: fullImage, roiRectangle: roiRectangle).with( + uxPrediction: prediction + ) + } + + private func loadModel() { + guard + let uxModelUrl = StripeCardScanBundleLocator.resourcesBundle.url( + forResource: UxAnalyzer.uxResource, + withExtension: UxAnalyzer.uxExtension + ) + else { + return + } + + UxModel.asyncLoad(contentsOf: uxModelUrl) { [weak self] result in + switch result { + case .success(let model): + self?.uxModel = model + case .failure(let error): + assertionFailure("Error loading model: \(error.localizedDescription)") + } + } + } +} + +extension UxModelOutput { + func argMax() -> Int { + return self.argAndValueMax().0 + } + + func argAndValueMax() -> (Int, Double) { + var maxIdx = -1 + var maxValue = NSNumber(value: -1.0) + for idx in 0..<3 { + let index: [NSNumber] = [NSNumber(value: idx)] + let value = self.output1[index] + if value.doubleValue > maxValue.doubleValue { + maxIdx = idx + maxValue = value + } + } + + return (maxIdx, maxValue.doubleValue) + } + + func cardCenteredState() -> CenteredCardState { + switch self.argMax() { + case 0: + return .nonNumberSide + case 2: + return .numberSide + default: + return .noCard + } + } + + func confidenceValues() -> (Double, Double, Double)? { + let idxRange = 0..<3 + let indexValues = idxRange.map { [NSNumber(value: $0)] } + var confidenceValues = indexValues.map { self.output1[$0].doubleValue } + + guard let pan = confidenceValues.popLast(), let noCard = confidenceValues.popLast(), + let noPan = confidenceValues.popLast() + else { + return nil + } + + return (pan, noPan, noCard) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/UxAndOcrMainLoop.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/UxAndOcrMainLoop.swift new file mode 100644 index 00000000..74fe4742 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/UxAndOcrMainLoop.swift @@ -0,0 +1,17 @@ +class UxAndOcrMainLoop: OcrMainLoop { + init( + stateMachine: MainLoopStateMachine + ) { + super.init(analyzers: []) + + errorCorrection = ErrorCorrection(stateMachine: stateMachine) + + let ssdOcr = SSDCreditCardOcr(dispatchQueueLabel: "Ux+Ocr queue") + let appleOcr = AppleCreditCardOcr(dispatchQueueLabel: "apple queue") + + let ocrImplementations = [ + UxAnalyzer(asyncWith: ssdOcr), UxAnalyzer(asyncWith: appleOcr), + ] + setupMl(ocrImplementations: ocrImplementations) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/VerifyCardAddViewController.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/VerifyCardAddViewController.swift new file mode 100644 index 00000000..6d8a3b64 --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/VerifyCardAddViewController.swift @@ -0,0 +1,202 @@ +@_spi(STP) import StripeCore +import UIKit + +/// This class is a first cut on providing verification on card add (i.e., Zero Fraud). Currently it includes a manual entry button +/// and navigation to the `CardEntryViewController` where the user can complete the information that they add. + +class VerifyCardAddViewController: SimpleScanViewController { + typealias StrictModeFramesCount = CardImageVerificationSheet.StrictModeFrameCount + /// Set this variable to `false` to force the user to scan their card _without_ the option to enter all details manually + static var enableManualCardEntry = true + var enableManualEntry = enableManualCardEntry + + static var manualCardEntryButton = UIButton(type: .system) + static var closeButton: UIButton? + static var torchButton: UIButton? + + var debugRetainCompletionLoopImages = false + + static var manualCardEntryText = String.Localized.enter_card_details_manually + + // TODO(jaimepark): Remove on consolidation + weak var verifyDelegate: VerifyViewControllerDelegate? + + private let acceptedImageConfigs: CardImageVerificationAcceptedImageConfigs? + private let configuration: CardImageVerificationSheet.Configuration + private let mainLoopDurationTask: TrackableTask + + init( + acceptedImageConfigs: CardImageVerificationAcceptedImageConfigs?, + configuration: CardImageVerificationSheet.Configuration + ) { + self.acceptedImageConfigs = acceptedImageConfigs + self.configuration = configuration + self.mainLoopDurationTask = TrackableTask() + super.init() + } + + required init?(coder: NSCoder) { fatalError("not supported") } + + override func viewDidLoad() { + let fraudData = CardVerifyFraudData(acceptedImageConfigs: acceptedImageConfigs) + + if debugRetainCompletionLoopImages { + fraudData.debugRetainImages = true + } + + scanEventsDelegate = fraudData + + super.viewDidLoad() + } + + override func createOcrMainLoop() -> OcrMainLoop? { + var uxAndOcrMainLoop = UxAndOcrMainLoop( + stateMachine: CardVerifyStateMachine( + strictModeFramesCount: configuration.strictModeFrames + ) + ) + + if scanPerformancePriority == .accurate { + uxAndOcrMainLoop = UxAndOcrMainLoop( + stateMachine: CardVerifyAccurateStateMachine( + requiredLastFour: nil, + requiredBin: nil, + maxNameExpiryDurationSeconds: maxErrorCorrectionDuration, + strictModeFramesCount: configuration.strictModeFrames + ) + ) + } + + return uxAndOcrMainLoop + } + // MARK: - Set Up Manual Card Entry Button + override func setupUiComponents() { + if let closeButton = VerifyCardAddViewController.closeButton { + self.closeButton = closeButton + } + + if let torchButton = VerifyCardAddViewController.torchButton { + self.torchButton = torchButton + } + + super.setupUiComponents() + self.view.addSubview(VerifyCardAddViewController.manualCardEntryButton) + VerifyCardAddViewController.manualCardEntryButton.translatesAutoresizingMaskIntoConstraints = + false + setUpManualCardEntryButtonUI() + } + + override func setupConstraints() { + super.setupConstraints() + setUpManualCardEntryButtonConstraints() + } + + func setUpManualCardEntryButtonUI() { + VerifyCardAddViewController.manualCardEntryButton.isHidden = !enableManualEntry + + let text = VerifyCardAddViewController.manualCardEntryText + let attributedString = NSMutableAttributedString(string: text) + attributedString.addAttribute( + NSAttributedString.Key.underlineColor, + value: UIColor.white, + range: NSRange(location: 0, length: text.count) + ) + attributedString.addAttribute( + NSAttributedString.Key.foregroundColor, + value: UIColor.white, + range: NSRange(location: 0, length: text.count) + ) + attributedString.addAttribute( + NSAttributedString.Key.underlineStyle, + value: NSUnderlineStyle.single.rawValue, + range: NSRange(location: 0, length: text.count) + ) + let font = + VerifyCardAddViewController.manualCardEntryButton.titleLabel?.font.withSize(20) + ?? UIFont.systemFont(ofSize: 20.0) + attributedString.addAttribute( + NSAttributedString.Key.font, + value: font, + range: NSRange(location: 0, length: text.count) + ) + + VerifyCardAddViewController.manualCardEntryButton.setAttributedTitle( + attributedString, + for: .normal + ) + VerifyCardAddViewController.manualCardEntryButton.titleLabel?.textColor = .white + VerifyCardAddViewController.manualCardEntryButton.addTarget( + self, + action: #selector(manualCardEntryButtonPress), + for: .touchUpInside + ) + } + + func setUpManualCardEntryButtonConstraints() { + VerifyCardAddViewController.manualCardEntryButton.centerXAnchor.constraint( + equalTo: enableCameraPermissionsButton.centerXAnchor + ).isActive = true + VerifyCardAddViewController.manualCardEntryButton.centerYAnchor.constraint( + equalTo: enableCameraPermissionsButton.centerYAnchor + ).isActive = true + } + + // MARK: - Override some ScanBase functions + override func onScannedCard( + number: String, + expiryYear: String?, + expiryMonth: String?, + scannedImage: UIImage? + ) { + let card = CreditCard(number: number) + card.expiryYear = expiryYear + card.expiryMonth = expiryMonth + card.name = predictedName + + showFullScreenActivityIndicator() + guard let fraudData = self.scanEventsDelegate.flatMap({ $0 as? CardVerifyFraudData }) else { + self.verifyDelegate?.verifyViewControllerDidFail( + self, + with: CardScanSheetError.unknown(debugDescription: "CardVerifyFraudData not found") + ) + return + } + + fraudData.result { verificationFramesData in + /// Frames have been processed and the main loop completed, log accordingly + self.mainLoopDurationTask.trackResult(.success) + ScanAnalyticsManager.shared.trackMainLoopDuration(task: self.mainLoopDurationTask) + + self.verifyDelegate?.verifyViewControllerDidFinish( + self, + verificationFramesData: verificationFramesData, + scannedCard: ScannedCard(pan: number) + ) + } + } + + override func onCameraPermissionDenied(showedPrompt: Bool) { + super.onCameraPermissionDenied(showedPrompt: showedPrompt) + + if enableManualEntry { + enableCameraPermissionsButton.isHidden = true + } + } + + // MARK: - UI event handlers and other navigation functions + override func cancelButtonPress() { + /// User canceled the scan before the main loop completed, log with a failure + mainLoopDurationTask.trackResult(.failure) + ScanAnalyticsManager.shared.trackMainLoopDuration(task: mainLoopDurationTask) + ScanAnalyticsManager.shared.logScanActivityTask(event: .userCanceled) + verifyDelegate?.verifyViewControllerDidCancel(self, with: .back) + } + + @objc func manualCardEntryButtonPress() { + /// User canceled the exited before the main loop completed, log with a failure + mainLoopDurationTask.trackResult(.failure) + ScanAnalyticsManager.shared.trackMainLoopDuration(task: mainLoopDurationTask) + ScanAnalyticsManager.shared.logScanActivityTask(event: .userMissingCard) + verifyDelegate?.verifyViewControllerDidCancel(self, with: .userCannotScan) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/VerifyCardViewController.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/VerifyCardViewController.swift new file mode 100644 index 00000000..8d0160bb --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/VerifyCardViewController.swift @@ -0,0 +1,310 @@ +@_spi(STP) import StripeCore +/// Our high-level goal with this class is to implement the logic needed for our card verify check while +/// adding minimal UI effects, and for any UI effects that we do add make them easily customized +/// via overriding functions. +/// +/// This class builds off of the `SimpleScanViewController` for the UI, see that class or +/// our [docs ](https://docs.getbouncer.com/card-scan/ios-integration-guide/ios-customization-guide) +/// for more information on how to customize the look and feel of this view controller. +import UIKit + +/// TODO(jaimepark): Consolidate both add flow and card-set flow into a single view controller. +/// This means replacing `VerifyCardViewControllerDelegate` and `VerifyCardAddViewControllerDelegate` with this one. +protocol VerifyViewControllerDelegate: AnyObject { + /// TODO(jaimepark): Change view controller type after consolidation + + /// The scanning portion of the flow finished. Finish off verification flow by submitting verification frames data. + func verifyViewControllerDidFinish( + _ viewController: UIViewController, + verificationFramesData: [VerificationFramesData], + scannedCard: ScannedCard + ) + + /// User canceled the verification flow + func verifyViewControllerDidCancel( + _ viewController: UIViewController, + with reason: CancellationReason + ) + + /// The verification flow failed + func verifyViewControllerDidFail( + _ viewController: UIViewController, + with error: Error + ) +} + +class VerifyCardViewController: SimpleScanViewController { + typealias StrictModeFramesCount = CardImageVerificationSheet.StrictModeFrameCount + + // our UI components + var cardDescriptionText = UILabel() + static var closeButton: UIButton? + static var torchButton: UIButton? + + // configuration + private let expectedCardLast4: String + private let expectedCardIssuer: String? + private let acceptedImageConfigs: CardImageVerificationAcceptedImageConfigs? + private let configuration: CardImageVerificationSheet.Configuration + + // TODO(jaimepark): Put card brands from `Stripe` into `StripeCore` + var cardNetwork: CardNetwork? + + // String + static var wrongCardString = String.Localized.card_doesnt_match + + // for debugging + var debugRetainCompletionLoopImages = false + + weak var verifyDelegate: VerifyViewControllerDelegate? + + private var lastWrongCard: Date? + private let mainLoopDurationTask: TrackableTask + + var userId: String? + + init( + acceptedImageConfigs: CardImageVerificationAcceptedImageConfigs?, + configuration: CardImageVerificationSheet.Configuration, + expectedCardLast4: String, + expectedCardIssuer: String? + ) { + self.acceptedImageConfigs = acceptedImageConfigs + self.configuration = configuration + self.expectedCardLast4 = expectedCardLast4 + self.expectedCardIssuer = expectedCardIssuer + self.mainLoopDurationTask = TrackableTask() + + super.init() + } + + required init?(coder: NSCoder) { fatalError("not supported") } + + override func viewDidLoad() { + // setup our ML so that we use the UX model + OCR in the main loop + let fraudData = CardVerifyFraudData( + last4: expectedCardLast4, + acceptedImageConfigs: acceptedImageConfigs + ) + if debugRetainCompletionLoopImages { + fraudData.debugRetainImages = true + } + + scanEventsDelegate = fraudData + + super.viewDidLoad() + } + + override func createOcrMainLoop() -> OcrMainLoop? { + var uxAndOcrMainLoop = UxAndOcrMainLoop( + stateMachine: CardVerifyStateMachine( + requiredLastFour: expectedCardLast4, + requiredBin: nil, + strictModeFramesCount: configuration.strictModeFrames + ) + ) + + if scanPerformancePriority == .accurate { + uxAndOcrMainLoop = UxAndOcrMainLoop( + stateMachine: CardVerifyAccurateStateMachine( + requiredLastFour: expectedCardLast4, + requiredBin: nil, + maxNameExpiryDurationSeconds: maxErrorCorrectionDuration, + strictModeFramesCount: configuration.strictModeFrames + ) + ) + } + + return uxAndOcrMainLoop + } + + // MARK: - UI effects and customizations for the VerifyCard flow + + override func setupUiComponents() { + if let closeButton = VerifyCardViewController.closeButton { + self.closeButton = closeButton + } + + if let torchButton = VerifyCardViewController.torchButton { + self.torchButton = torchButton + } + + super.setupUiComponents() + + let children: [UIView] = [cardDescriptionText] + for child in children { + self.view.addSubview(child) + } + + setupCardDescriptionTextUI() + } + + func setupCardDescriptionTextUI() { + // TODO(jaimepark): Update text ui with viewmodel + // let network = bin.map { CreditCardUtils.determineCardNetwork(cardNumber: $0) } + // var text = "\(network.map { $0.toString() } ?? cardNetwork?.toString() ?? "")" + let text = "\(expectedCardIssuer ?? "") •••• \(expectedCardLast4)" + + cardDescriptionText.textColor = .white + cardDescriptionText.textAlignment = .center + cardDescriptionText.numberOfLines = 2 + cardDescriptionText.text = text + cardDescriptionText.isHidden = false + + } + + // MARK: - Autolayout constraints + override func setupConstraints() { + let children: [UIView] = [cardDescriptionText] + for child in children { + child.translatesAutoresizingMaskIntoConstraints = false + } + + super.setupConstraints() + + setupCardDescriptionTextConstraints() + } + + func setupCardDescriptionTextConstraints() { + cardDescriptionText.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32) + .isActive = true + cardDescriptionText.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32) + .isActive = true + cardDescriptionText.bottomAnchor.constraint(equalTo: roiView.topAnchor, constant: -16) + .isActive = true + } + + override func setupDescriptionTextConstraints() { + descriptionText.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32).isActive = + true + descriptionText.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32) + .isActive = true + descriptionText.bottomAnchor.constraint( + equalTo: cardDescriptionText.topAnchor, + constant: -16 + ).isActive = true + } + + // MARK: - Override some ScanBase functions + override func onScannedCard( + number: String, + expiryYear: String?, + expiryMonth: String?, + scannedImage: UIImage? + ) { + + let card = CreditCard(number: number) + card.expiryMonth = expiryMonth + card.expiryYear = expiryYear + card.name = predictedName + card.image = scannedImage + + showFullScreenActivityIndicator() + + guard let fraudData = self.scanEventsDelegate.flatMap({ $0 as? CardVerifyFraudData }) else { + self.verifyDelegate?.verifyViewControllerDidFail( + self, + with: CardScanSheetError.unknown(debugDescription: "CardVerifyFraudData not found") + ) + return + } + + fraudData.result { verificationFramesData in + /// Frames have been processed and the main loop completed, log accordingly + self.mainLoopDurationTask.trackResult(.success) + ScanAnalyticsManager.shared.trackMainLoopDuration(task: self.mainLoopDurationTask) + + self.verifyDelegate?.verifyViewControllerDidFinish( + self, + verificationFramesData: verificationFramesData, + scannedCard: ScannedCard(pan: number) + ) + } + } + + override func showScannedCardDetails(prediction: CreditCardOcrPrediction) {} + + override func showCardNumber(_ number: String, expiry: String?) { + DispatchQueue.main.async { + self.numberText.text = CreditCardUtils.format(number: number) + if self.numberText.isHidden { + self.numberText.fadeIn() + } + + if let expiry = expiry { + self.expiryText.text = expiry + if self.expiryText.isHidden { + self.expiryText.fadeIn() + } + } + + if let predictedName = self.predictedName { + self.nameText.text = predictedName + if self.nameText.isHidden { + self.nameText.fadeIn() + } + } + + if !self.descriptionText.isHidden { + self.descriptionText.fadeOut() + } + } + } + + override func showWrongCard(number: String?, expiry: String?, name: String?) { + DispatchQueue.main.async { + self.descriptionText.text = VerifyCardViewController.wrongCardString + + if !self.numberText.isHidden { + self.numberText.fadeOut() + } + + if !self.expiryText.isHidden { + self.expiryText.fadeOut() + } + + if !self.nameText.isHidden { + self.nameText.fadeOut() + } + + self.roiView.layer.borderColor = UIColor.red.cgColor + self.lastWrongCard = Date() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self = self else { return } + guard let lastWrongCard = self.lastWrongCard else { return } + if -lastWrongCard.timeIntervalSinceNow >= 0.5 { + self.showNoCard() + } + } + } + } + + override func showNoCard() { + DispatchQueue.main.async { + self.roiView.layer.borderColor = UIColor.white.cgColor + + self.descriptionText.text = SimpleScanViewController.descriptionString + + if !self.numberText.isHidden { + self.numberText.fadeOut() + } + + if !self.expiryText.isHidden { + self.expiryText.fadeOut() + } + + if !self.nameText.isHidden { + self.nameText.fadeOut() + } + } + } + + // MARK: - UI event handlers + override func cancelButtonPress() { + /// User canceled the scan before the main loop completed, log with a failure + mainLoopDurationTask.trackResult(.failure) + ScanAnalyticsManager.shared.trackMainLoopDuration(task: mainLoopDurationTask) + ScanAnalyticsManager.shared.logScanActivityTask(event: .userCanceled) + verifyDelegate?.verifyViewControllerDidCancel(self, with: .back) + } +} diff --git a/StripeCardScan/StripeCardScan/Source/CardVerify/ZoomedInCGImage.swift b/StripeCardScan/StripeCardScan/Source/CardVerify/ZoomedInCGImage.swift new file mode 100644 index 00000000..a0d5756c --- /dev/null +++ b/StripeCardScan/StripeCardScan/Source/CardVerify/ZoomedInCGImage.swift @@ -0,0 +1,161 @@ +// +// ZoomedInCGImage.swift +// CardScan +// +// Created by Jaime Park on 6/19/20. +// + +import UIKit + +class ZoomedInCGImage { + private let image: CGImage + private let imageWidth: CGFloat + private let imageHeight: CGFloat + private let imageMidHeight: CGFloat + private let imageMidWidth: CGFloat + private let imageCenterMinX: CGFloat + private let imageCenterMaxX: CGFloat + private let imageCenterMinY: CGFloat + private let imageCenterMaxY: CGFloat + + private let finalCropWidth: CGFloat = 448.0 + private let finalCropHeight: CGFloat = 448.0 + private let cropQuarterWidth: CGFloat = 112.0 // finalCropWidth / 4 + private let cropQuarterHeight: CGFloat = 112.0 // finalCropHeight / 4 + + init( + image: CGImage + ) { + self.image = image + self.imageWidth = CGFloat(image.width) + self.imageHeight = CGFloat(image.height) + self.imageMidHeight = imageHeight / 2.0 + self.imageMidWidth = imageWidth / 2.0 + self.imageCenterMinX = imageMidWidth - cropQuarterWidth + self.imageCenterMaxX = imageMidWidth + cropQuarterWidth + self.imageCenterMinY = imageMidHeight - cropQuarterHeight + self.imageCenterMaxY = imageMidHeight + cropQuarterHeight + } + + // Create a zoomed-in image by resizing image pieces in a 3x3 grid, row by row with createResizeLayer + func zoomedInImage() -> UIImage? { + guard + let topLayer = createResizeLayer( + yMin: 0.0, + imageHeight: imageCenterMinY, + cropHeight: cropQuarterHeight + ) + else { return nil } + guard + let midLayer = createResizeLayer( + yMin: imageCenterMinY, + imageHeight: cropQuarterHeight * 2, + cropHeight: cropQuarterHeight * 2 + ) + else { return nil } + guard + let bottomLayer = createResizeLayer( + yMin: imageCenterMaxY, + imageHeight: imageCenterMinY, + cropHeight: cropQuarterHeight + ) + else { return nil } + + let zoomedImageSize = CGSize(width: finalCropWidth, height: finalCropHeight) + UIGraphicsBeginImageContextWithOptions(zoomedImageSize, true, 1.0) + + topLayer.draw(in: CGRect(x: 0.0, y: 0.0, width: finalCropWidth, height: cropQuarterHeight)) + midLayer.draw( + in: CGRect( + x: 0.0, + y: cropQuarterHeight, + width: finalCropWidth, + height: cropQuarterHeight * 2 + ) + ) + bottomLayer.draw( + in: CGRect( + x: 0.0, + y: cropQuarterHeight * 3, + width: finalCropWidth, + height: cropQuarterHeight + ) + ) + + let zoomedImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return zoomedImage + } + + // Resize layer starting at image height coordinate (yMin) with the height within the image (imageHeight) resizing to the input cropping height (cropHeight) + private func createResizeLayer( + yMin: CGFloat, + imageHeight: CGFloat, + cropHeight: CGFloat + ) -> UIImage? { + let leftCropRect = CGRect(x: 0.0, y: yMin, width: imageCenterMinX, height: imageHeight) + let midCropRect = CGRect( + x: imageCenterMinX, + y: yMin, + width: cropQuarterWidth * 2, + height: imageHeight + ) + let rightCropRect = CGRect( + x: imageCenterMaxX, + y: yMin, + width: imageCenterMinX, + height: imageHeight + ) + + guard let leftCropImage = self.image.cropping(to: leftCropRect), + let leftResizedImage = resize( + image: leftCropImage, + targetSize: CGSize(width: cropQuarterWidth, height: cropHeight) + ) + else { return nil } + + guard let midCropImage = self.image.cropping(to: midCropRect), + let midResizedImage = resize( + image: midCropImage, + targetSize: CGSize(width: cropQuarterWidth * 2, height: cropHeight) + ) + else { return nil } + + guard let rightCropImage = self.image.cropping(to: rightCropRect), + let rightResizedImage = resize( + image: rightCropImage, + targetSize: CGSize(width: cropQuarterWidth, height: cropHeight) + ) + else { return nil } + + let layerSize = CGSize(width: finalCropWidth, height: cropHeight) + UIGraphicsBeginImageContextWithOptions(layerSize, false, 1.0) + + leftResizedImage.draw( + in: CGRect(x: 0.0, y: 0.0, width: cropQuarterWidth, height: cropHeight) + ) + midResizedImage.draw( + in: CGRect(x: cropQuarterWidth, y: 0.0, width: cropQuarterWidth * 2, height: cropHeight) + ) + rightResizedImage.draw( + in: CGRect(x: cropQuarterWidth * 3, y: 0.0, width: cropQuarterWidth, height: cropHeight) + ) + + let layerImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return layerImage + } + + private func resize(image: CGImage, targetSize: CGSize) -> UIImage? { + let image = UIImage(cgImage: image) + let rect = CGRect(x: 0, y: 0, width: targetSize.width, height: targetSize.height) + + UIGraphicsBeginImageContextWithOptions(targetSize, true, 1.0) + image.draw(in: rect) + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return newImage + } +} diff --git a/StripeCardScan/StripeCardScan/StripeCardScan.h b/StripeCardScan/StripeCardScan/StripeCardScan.h new file mode 100644 index 00000000..ab708ca0 --- /dev/null +++ b/StripeCardScan/StripeCardScan/StripeCardScan.h @@ -0,0 +1,18 @@ +// +// StripeCardScan.h +// StripeCardScan +// +// Created by Sam King on 11/8/21. +// + +#import + +//! Project version number for StripeCardScan. +FOUNDATION_EXPORT double StripeCardScanVersionNumber; + +//! Project version string for StripeCardScan. +FOUNDATION_EXPORT const unsigned char StripeCardScanVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/StripeCardScan/StripeCardScanTests/Helpers/CardScanMockData.swift b/StripeCardScan/StripeCardScanTests/Helpers/CardScanMockData.swift new file mode 100644 index 00000000..29a818e2 --- /dev/null +++ b/StripeCardScan/StripeCardScanTests/Helpers/CardScanMockData.swift @@ -0,0 +1,37 @@ +// +// CardScanMockData.swift +// StripeCardScanTests +// +// Created by Jaime Park on 11/16/21. +// + +import Foundation +import StripeCoreTestUtils + +@testable import StripeCardScan + +// note: This class is to find the test bundle +private class ClassForBundle {} + +// MARK: Responses +enum CardImageVerificationDetailsResponseMock: String, MockData { + var bundle: Bundle { return Bundle(for: ClassForBundle.self) } + + typealias ResponseType = CardImageVerificationDetailsResponse + + case cardImageVerification_cardSet_200 = "CardImageVerification_CardSet_200" + case cardImageVerification_cardAdd_200 = "CardImageVerification_CardAdd_200" +} + +struct CIVIntentMockData { + static let id = "civ_1234" + static let clientSecret = "civ_client_secret_1234" + + static var intent: CardImageVerificationIntent = { + let intent = CardImageVerificationIntent( + id: id, + clientSecret: clientSecret + ) + return intent + }() +} diff --git a/StripeCardScan/StripeCardScanTests/Helpers/Data+Sha256.swift b/StripeCardScan/StripeCardScanTests/Helpers/Data+Sha256.swift new file mode 100644 index 00000000..a9b4ce8d --- /dev/null +++ b/StripeCardScan/StripeCardScanTests/Helpers/Data+Sha256.swift @@ -0,0 +1,26 @@ +// +// Data+Sha256.swift +// StripeCardScanTests +// +// Created by Scott Grant on 9/21/22. +// + +import CommonCrypto +import Foundation + +// Adapted from https://stackoverflow.com/questions/25388747/sha256-in-swift +extension Data { + + /// A String containing the Sha256 hash of this Data's contents. + var sha256: String { + return digest().base64EncodedString() + } + + private func digest() -> Data { + let digestLength = Int(CC_SHA256_DIGEST_LENGTH) + var hash = [UInt8](repeating: 0, count: digestLength) + CC_SHA256([UInt8](self), UInt32(self.count), &hash) + + return Data(bytes: hash, count: digestLength) + } +} diff --git a/StripeCardScan/StripeCardScanTests/Helpers/ImageHelpers.swift b/StripeCardScan/StripeCardScanTests/Helpers/ImageHelpers.swift new file mode 100644 index 00000000..76e6ae42 --- /dev/null +++ b/StripeCardScan/StripeCardScanTests/Helpers/ImageHelpers.swift @@ -0,0 +1,40 @@ +// +// ImageHelpers.swift +// StripeCardScanTests +// +// Created by Sam King on 11/29/21. +// + +import UIKit + +struct ImageHelpers { + static func getTestImageAndRoiRectangle() -> (UIImage, CGRect) { + let bundle = Bundle(for: UxModelTests.self) + let path = bundle.url(forResource: "synthetic_test_image", withExtension: "jpg")! + let image = UIImage(contentsOfFile: path.path)! + let cardWidth = CGFloat(977.0) + let cardHeight = CGFloat(616.0) + let imageWidth = image.size.width + let imageHeight = image.size.height + + let roiRectangle = CGRect( + x: (imageWidth - cardWidth) * 0.5, + y: (imageHeight - cardHeight) * 0.5, + width: cardWidth, + height: cardHeight + ) + + return (image, roiRectangle) + } + + static func createBlankCGImage() -> CGImage { + let rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1)) + UIGraphicsBeginImageContextWithOptions(rect.size, false, 0.0) + UIColor.black.setFill() + UIRectFill(rect) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return image!.cgImage! + } +} diff --git a/StripeCardScan/StripeCardScanTests/Helpers/ScannedCardDetails.swift b/StripeCardScan/StripeCardScanTests/Helpers/ScannedCardDetails.swift new file mode 100644 index 00000000..f47362a5 --- /dev/null +++ b/StripeCardScan/StripeCardScanTests/Helpers/ScannedCardDetails.swift @@ -0,0 +1,32 @@ +// +// ScannedCardDetails.swift +// StripeCardScanTests +// +// Created by Jaime Park on 3/10/22. +// + +import CoreGraphics +import Foundation + +@testable import StripeCardScan + +struct ScannedCardDetails { + let number: String + let iin: String + let last4: String + let scannedImageData: ScannedCardImageData + + init( + number: String + ) { + self.number = number + self.iin = String(number.prefix(6)) + self.last4 = String(number.suffix(4)) + + /// Create discernable `ScannedCardImageData` by setting the preview layer rect to the iin & last4 + self.scannedImageData = ScannedCardImageData( + previewLayerImage: ImageHelpers.createBlankCGImage(), + previewLayerViewfinderRect: CGRect(x: 0, y: 0, width: Int(iin)!, height: Int(last4)!) + ) + } +} diff --git a/StripeCardScan/StripeCardScanTests/Helpers/String+Sha256.swift b/StripeCardScan/StripeCardScanTests/Helpers/String+Sha256.swift new file mode 100644 index 00000000..f9f0e036 --- /dev/null +++ b/StripeCardScan/StripeCardScanTests/Helpers/String+Sha256.swift @@ -0,0 +1,19 @@ +// +// String+Sha256.swift +// StripeCardScanTests +// +// Created by Scott Grant on 9/21/22. +// + +import Foundation + +extension String { + /// A String containing the Sha256 hash of this String's contents. + var sha256: String? { + guard let stringData = self.data(using: .utf8) else { + return nil + } + + return stringData.sha256 + } +} diff --git a/StripeCardScan/StripeCardScanTests/Info.plist b/StripeCardScan/StripeCardScanTests/Info.plist new file mode 100644 index 00000000..64d65ca4 --- /dev/null +++ b/StripeCardScan/StripeCardScanTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/StripeCardScan/StripeCardScanTests/Mock Data/JSON/CardImageVerification_CardAdd_200.json b/StripeCardScan/StripeCardScanTests/Mock Data/JSON/CardImageVerification_CardAdd_200.json new file mode 100644 index 00000000..adbeec02 --- /dev/null +++ b/StripeCardScan/StripeCardScanTests/Mock Data/JSON/CardImageVerification_CardAdd_200.json @@ -0,0 +1,31 @@ +{ + "accepted_image_configs": { + "default_settings": { + "compression_ratio": 0.6, + "image_count": 5, + "image_size": [ + 1080, + 1920 + ] + }, + "format_settings": { + "heic": { + "compression_ratio": 0.5 + }, + "jpeg": {}, + "webp": { + "image_count": 3 + } + }, + "preferred_formats": [ + "heic", + "webp", + "jpeg" + ] + }, + "expected_card": { + "issuer": null, + "last4": null + }, + "use_api_version": 2 +} diff --git a/StripeCardScan/StripeCardScanTests/Mock Data/JSON/CardImageVerification_CardSet_200.json b/StripeCardScan/StripeCardScanTests/Mock Data/JSON/CardImageVerification_CardSet_200.json new file mode 100644 index 00000000..5798d365 --- /dev/null +++ b/StripeCardScan/StripeCardScanTests/Mock Data/JSON/CardImageVerification_CardSet_200.json @@ -0,0 +1,32 @@ +{ + "accepted_image_configs": { + "default_settings": { + "compression_ratio": 0.8, + "image_size": [ + 1080, + 1920 + ] + }, + "format_settings": { + "heic": { + "compression_ratio": 0.5 + }, + "webp": { + "compression_ratio": 0.7, + "image_size": [ + 2160, + 1920 + ] + } + }, + "preferred_formats": [ + "heic", + "webp", + "jpeg" + ] + }, + "expected_card": { + "last4": "4242", + "issuer": "Visa" + } +} diff --git a/StripeCardScan/StripeCardScanTests/Resources/synthetic_test_image.jpg b/StripeCardScan/StripeCardScanTests/Resources/synthetic_test_image.jpg new file mode 100644 index 00000000..c63e5e56 Binary files /dev/null and b/StripeCardScan/StripeCardScanTests/Resources/synthetic_test_image.jpg differ diff --git a/StripeCardScan/StripeCardScanTests/Unit/API Bindings/STPAPIClient+CardImageVerificationTest.swift b/StripeCardScan/StripeCardScanTests/Unit/API Bindings/STPAPIClient+CardImageVerificationTest.swift new file mode 100644 index 00000000..3a0d0c3a --- /dev/null +++ b/StripeCardScan/StripeCardScanTests/Unit/API Bindings/STPAPIClient+CardImageVerificationTest.swift @@ -0,0 +1,383 @@ +// +// STPAPIClient+CardImageVerificationTest.swift +// StripeCardScanTests +// +// Created by Jaime Park on 11/16/21. +// + +import OHHTTPStubs +import OHHTTPStubsSwift +import StripeCoreTestUtils +import XCTest + +@testable import StripeCardScan +@testable@_spi(STP) import StripeCore + +class STPAPIClient_CardImageVerificationTest: APIStubbedTestCase { + /// The following test is mocking a flow where the merchant has set a card during the CIV intent creation. + /// It will check the following: + /// 1. The request URL has been constructed properly: /v1/card_image_verifications/:id/initialize_client + /// 2. The request body contains the client secret + /// 3. The response from request has details of card set during the CIV intent creation + func testFetchCardImageVerificationDetails_CardSet() throws { + let mockResponse = + try CardImageVerificationDetailsResponseMock.cardImageVerification_cardSet_200.data() + + /// Stub the request to get details of CIV intent + stub { request in + guard let httpBody = request.ohhttpStubs_httpBody else { + XCTFail("Expected an httpBody but found none") + return false + } + + XCTAssertNotNil(request.url) + XCTAssertEqual( + request.url?.absoluteString.contains( + "v1/card_image_verifications/\(CIVIntentMockData.id)/initialize_client" + ), + true + ) + XCTAssertEqual( + String(data: httpBody, encoding: .utf8), + "client_secret=\(CIVIntentMockData.clientSecret)" + ) + XCTAssertEqual(request.httpMethod, "POST") + + return true + } response: { _ in + return HTTPStubsResponse(data: mockResponse, statusCode: 200, headers: nil) + } + + let exp = expectation(description: "Request completed") + + /// Make request to get card details + let apiClient = stubbedAPIClient() + let promise = apiClient.fetchCardImageVerificationDetails( + cardImageVerificationSecret: CIVIntentMockData.clientSecret, + cardImageVerificationId: CIVIntentMockData.id + ) + + promise.observe { result in + switch result { + case .success(let response): + XCTAssertEqual(response.expectedCard.last4, "4242") + XCTAssertEqual(response.expectedCard.issuer, "Visa") + + XCTAssertNotNil(response.acceptedImageConfigs) + + XCTAssertEqual( + response.acceptedImageConfigs?.preferredFormats, + [.heic, .webp, .jpeg] + ) + + XCTAssertEqual( + response.acceptedImageConfigs?.defaultSettings?.compressionRatio, + 0.8 + ) + XCTAssertEqual( + response.acceptedImageConfigs?.defaultSettings?.imageSize, + [1080.0, 1920.0] + ) + + if let formatSettings = response.acceptedImageConfigs?.formatSettings { + let webpSettings = formatSettings[.webp] + XCTAssertEqual(webpSettings??.compressionRatio, 0.7) + XCTAssertEqual(webpSettings??.imageSize, [2160.0, 1920.0]) + } else { + XCTFail("Format Settings failed to parse") + } + case .failure(let error): + XCTFail("Request returned error \(error)") + } + exp.fulfill() + } + + wait(for: [exp], timeout: 1) + } + + /// The following test is mocking a flow where the merchant has not set a card during the CIV intent creation. + /// It will check the following: + /// 1. The request URL has been constructed properly: /v1/card_image_verifications/:id/initialize_client + /// 2. The request body contains the client secret + /// 3. The response from request is empty + func testFetchCardImageVerificationDetails_CardAdd() throws { + let mockResponse = + try CardImageVerificationDetailsResponseMock.cardImageVerification_cardAdd_200.data() + + /// Stub the request to get details of CIV intent + stub { request in + guard let httpBody = request.ohhttpStubs_httpBody else { + XCTFail("Expected an httpBody but found none") + return false + } + + XCTAssertNotNil(request.url) + XCTAssertEqual(request.httpMethod, "POST") + + if let url = request.url { + XCTAssertTrue( + url.path + == "/v1/card_image_verifications/\(CIVIntentMockData.id)/initialize_client" + || url.path + == "/v1/card_image_verifications/\(CIVIntentMockData.id)/scan_stats" + ) + + let bodyString = String(data: httpBody, encoding: .utf8)! + XCTAssertTrue( + bodyString.hasPrefix("client_secret=\(CIVIntentMockData.clientSecret)") + ) + } + + return true + } response: { _ in + return HTTPStubsResponse(data: mockResponse, statusCode: 200, headers: nil) + } + + let exp = expectation(description: "Request completed") + + /// Make request to get card details + let apiClient = stubbedAPIClient() + let promise = apiClient.fetchCardImageVerificationDetails( + cardImageVerificationSecret: CIVIntentMockData.clientSecret, + cardImageVerificationId: CIVIntentMockData.id + ) + + promise.observe { result in + switch result { + case .success(let response): + XCTAssertNil(response.expectedCard.last4) + XCTAssertNil(response.expectedCard.issuer) + case .failure(let error): + XCTFail("Request returned error \(error)") + } + exp.fulfill() + } + + wait(for: [exp], timeout: 1) + } + + /// The following test is mocking a flow where the collected verification frames are submitted to the server + /// It will check the following: + /// 1. The request URL has been constructed properly: /v1/card_image_verifications/:id/verify_frames + /// 2. The request body contains `client_secret` and `verification_frames_data` + /// 3. The response from request is empty + func testSubmitVerificationFrames() throws { + let base64EncodedVerificationFrames = "base64_encoded_list_of_verify_frames" + let mockResponse = "{}".data(using: .utf8)! + let mockParameter = VerifyFrames( + clientSecret: CIVIntentMockData.clientSecret, + verificationFramesData: base64EncodedVerificationFrames + ) + + /// Stub the request to submit verify frames + stub { request in + guard let httpBody = request.ohhttpStubs_httpBody else { + XCTFail("Expected an httpBody but found none") + return false + } + + XCTAssertNotNil(request.url) + XCTAssertEqual( + request.url?.absoluteString.contains( + "v1/card_image_verifications/\(CIVIntentMockData.id)/verify_frames" + ), + true + ) + XCTAssertEqual( + String(data: httpBody, encoding: .utf8), + "client_secret=\(CIVIntentMockData.clientSecret)&verification_frames_data=\(base64EncodedVerificationFrames)" + ) + XCTAssertEqual(request.httpMethod, "POST") + + return true + } response: { _ in + return HTTPStubsResponse(data: mockResponse, statusCode: 200, headers: nil) + } + + let exp = expectation(description: "Request completed") + + /// Make request to submit verification frames + let apiClient = stubbedAPIClient() + let promise = apiClient.submitVerificationFrames( + cardImageVerificationId: CIVIntentMockData.id, + verifyFrames: mockParameter + ) + + promise.observe { result in + switch result { + /// The successful response is an empty struct + case .success: + XCTAssert(true, "A response has been returned") + case .failure(let error): + XCTFail("Request returned error \(error)") + } + exp.fulfill() + } + + wait(for: [exp], timeout: 1) + } + + /// The following test is mocking a flow where the collected verification frames are submitted to the server + /// It will check the following. This test is using the expanded version of the request `submitVerificationFrames`: + /// 1. The request URL has been constructed properly: /v1/card_image_verifications/:id/verify_frames + /// 2. The request body contains `client_secret` and `verification_frames_data` + /// 3. The response from request is empty + func testSubmitVerificationFrames_Expanded() throws { + let verificationFrameData = VerificationFramesData( + imageData: "image_data".data(using: .utf8)!, + viewfinderMargins: ViewFinderMargins(left: 0, upper: 0, right: 0, lower: 0) + ) + + let mockResponse = "{}".data(using: .utf8)! + + /// The list of verification frame datas are encoded with snake_case before converting to a `VerifyFrames` object + let jsonEncoder = JSONEncoder() + jsonEncoder.keyEncodingStrategy = .convertToSnakeCase + let jsonVerificationFramesData = try jsonEncoder.encode([verificationFrameData]) + + /// Turn the JSON data into a string + let verificationFramesDataString = + String(data: jsonVerificationFramesData, encoding: .utf8) ?? "" + + let urlEncodedString = URLEncoder.string(byURLEncoding: verificationFramesDataString) + + // Stub the request to submit verify frames + stub { request in + guard let httpBody = request.ohhttpStubs_httpBody else { + XCTFail("Expected an httpBody but found none") + return false + } + + XCTAssertNotNil(request.url) + XCTAssertEqual( + request.url?.absoluteString.contains( + "v1/card_image_verifications/\(CIVIntentMockData.id)/verify_frames" + ), + true + ) + XCTAssertEqual( + String(data: httpBody, encoding: .utf8), + "client_secret=\(CIVIntentMockData.clientSecret)&verification_frames_data=\(urlEncodedString)" + ) + XCTAssertEqual(request.httpMethod, "POST") + + return true + } response: { _ in + return HTTPStubsResponse(data: mockResponse, statusCode: 200, headers: nil) + } + + let exp = expectation(description: "Request completed") + + /// Make request to submit verification frames + let apiClient = stubbedAPIClient() + let promise = apiClient.submitVerificationFrames( + cardImageVerificationId: CIVIntentMockData.id, + cardImageVerificationSecret: CIVIntentMockData.clientSecret, + verificationFramesData: [verificationFrameData] + ) + + promise.observe { result in + switch result { + /// The successful response is an empty struct + case .success: + XCTAssert(true, "A response has been returned") + case .failure(let error): + XCTFail("Request returned error \(error)") + } + exp.fulfill() + } + + wait(for: [exp], timeout: 1) + } + + /// The following test is mocking a flow where the collected scan analytics are uploaded to the server + /// It will check the following + /// 1. The request URL has been constructed properly: /v1/card_image_verifications/:id/scan_stats + /// 2. The response from request is empty + func testUploadScanStats() throws { + let startDate = Date() + let mockResponse = "{}".data(using: .utf8)! + let payload: ScanAnalyticsPayload = .init( + configuration: .init(strictModeFrames: 0), + payloadInfo: .init( + imageCompressionType: "heic", + imageCompressionQuality: 0.8, + imagePayloadSize: 4000 + ), + scanStats: .init( + repeatingTasks: .init( + mainLoopImagesProcessed: .init(executions: 1) + ), + tasks: .init( + cameraPermissionTask: .init(event: .success, startTime: startDate), + completionLoopDuration: .init(event: .success, startTime: startDate), + imageCompressionDuration: .init(event: .success, startTime: startDate), + mainLoopDuration: .init(event: .success, startTime: startDate), + scanActivityTasks: [ + .init(event: .torchSupported, startTime: startDate), + .init(event: .torchSupported, startTime: startDate), + ], + torchSupportedTask: .init(event: .torchSupported, startTime: startDate) + ) + ) + ) + + /// Stub the request to upload scan stats + /// Check request body more closely in a different test + stub { request in + /// Check that the http body exists + guard let httpBody = request.ohhttpStubs_httpBody, + let httpBodyQueryString = String(data: httpBody, encoding: .utf8) + else { + XCTFail("Expected an httpBody but found none") + return false + } + + XCTAssertNotNil(request.url) + XCTAssertEqual( + request.url?.absoluteString.contains( + "v1/card_image_verifications/\(CIVIntentMockData.id)/scan_stats" + ), + true + ) + /// Just check the existence of the parent-level payload fields + /// In-depth form data checking will be done in separate unit test + XCTAssertTrue( + httpBodyQueryString.contains("client_secret=\(CIVIntentMockData.clientSecret)"), + "http body does not contain client secret" + ) + XCTAssertTrue( + httpBodyQueryString.contains("payload["), + "http body does any payload info" + ) + XCTAssertEqual(request.httpMethod, "POST") + + return true + } response: { _ in + return HTTPStubsResponse(data: mockResponse, statusCode: 200, headers: nil) + } + + let exp = expectation(description: "Request completed") + + /// Make request to upload scan stats + let apiClient = stubbedAPIClient() + let promise = apiClient.uploadScanStats( + cardImageVerificationId: CIVIntentMockData.id, + cardImageVerificationSecret: CIVIntentMockData.clientSecret, + scanAnalyticsPayload: payload + ) + + promise.observe { result in + switch result { + /// The successful response is an empty struct + case .success: + XCTAssert(true, "A response has been returned") + case .failure(let error): + XCTFail("Request returned error \(error)") + } + exp.fulfill() + } + + wait(for: [exp], timeout: 1) + } +} diff --git a/StripeCardScan/StripeCardScanTests/Unit/API Bindings/ScanStatsPayloadAPIBindingsTests.swift b/StripeCardScan/StripeCardScanTests/Unit/API Bindings/ScanStatsPayloadAPIBindingsTests.swift new file mode 100644 index 00000000..22a4123c --- /dev/null +++ b/StripeCardScan/StripeCardScanTests/Unit/API Bindings/ScanStatsPayloadAPIBindingsTests.swift @@ -0,0 +1,357 @@ +// +// ScanStatsPayloadAPIBindingsTests.swift +// StripeCardScanTests +// +// Created by Jaime Park on 12/9/21. +// + +import XCTest + +@testable import StripeCardScan +@testable@_spi(STP) import StripeCore + +/// This test will check that the json and url encoding of the scan stats payload is structured as expected +class ScanStatsPayloadAPIBindingsTests: XCTestCase { + var startDate: Date! + var startDateMs: Int! + var nonRepeatingTasks: NonRepeatingTasks! + var repeatingTasks: RepeatingTasks! + var scanStatsTasks: ScanStatsTasks! + + override func setUp() { + let date = Date() + self.startDate = date + self.startDateMs = date.millisecondsSince1970 + + self.nonRepeatingTasks = .init( + cameraPermissionTask: .init( + result: ScanAnalyticsEvent.success.rawValue, + startedAtMs: startDateMs, + durationMs: -1 + ), + completionLoopDuration: .init( + result: ScanAnalyticsEvent.success.rawValue, + startedAtMs: startDateMs, + durationMs: -1 + ), + imageCompressionDuration: .init( + result: ScanAnalyticsEvent.success.rawValue, + startedAtMs: startDateMs, + durationMs: -1 + ), + mainLoopDuration: .init( + result: ScanAnalyticsEvent.success.rawValue, + startedAtMs: startDateMs, + durationMs: -1 + ), + scanActivityTasks: [ + .init( + result: ScanAnalyticsEvent.firstImageProcessed.rawValue, + startedAtMs: startDateMs, + durationMs: -1 + ), + .init( + result: ScanAnalyticsEvent.ocrPanObserved.rawValue, + startedAtMs: startDateMs, + durationMs: -1 + ), + ], + torchSupportedTask: .init( + result: ScanAnalyticsEvent.torchSupported.rawValue, + startedAtMs: startDateMs, + durationMs: -1 + ) + ) + self.repeatingTasks = .init( + mainLoopImagesProcessed: .init(executions: -1) + ) + self.scanStatsTasks = .init( + repeatingTasks: repeatingTasks, + tasks: nonRepeatingTasks + ) + } + + /// Check that scan stats tasks is encoded properly + func testScanStatsTasks() throws { + /// Check that scan stats tasks is encoded properly + let jsonDictionary = try scanStatsTasks.encodeJSONDictionary() + let tasksDictionary = jsonDictionary["tasks"] as! [String: Any] + let repeatingTaskDictionary = jsonDictionary["repeating_tasks"] as! [String: Any] + + XCTAssertEqual(tasksDictionary.count, 6) + XCTAssertEqual(repeatingTaskDictionary.count, 1) + } + + /// Check that non repeating tasks are encoded properly + func testNonRepeatingTasks() throws { + /// Check that the JSON dictionary is formed properly + let jsonDictionary = try nonRepeatingTasks.encodeJSONDictionary() + let jsonCameraPermissions = jsonDictionary["camera_permission"] as! [[String: Any]] + XCTAssertEqual(jsonCameraPermissions.count, 1) + XCTAssertEqual(jsonCameraPermissions[0]["result"] as! String, "success") + XCTAssertEqual(jsonCameraPermissions[0]["started_at_ms"] as? Int, startDateMs) + XCTAssertEqual(jsonCameraPermissions[0]["duration_ms"] as? Int, -1) + + let jsonTorchSupported = jsonDictionary["torch_supported"] as! [[String: Any]] + XCTAssertEqual(jsonTorchSupported.count, 1) + XCTAssertEqual(jsonTorchSupported[0]["result"] as! String, "supported") + XCTAssertEqual(jsonTorchSupported[0]["started_at_ms"] as? Int, startDateMs) + XCTAssertEqual(jsonTorchSupported[0]["duration_ms"] as? Int, -1) + + let jsonScanActivities = jsonDictionary["scan_activity"] as! [[String: Any]] + XCTAssertEqual(jsonScanActivities.count, 2) + XCTAssertEqual(jsonScanActivities[0]["result"] as! String, "first_image_processed") + XCTAssertEqual(jsonScanActivities[0]["started_at_ms"] as? Int, startDateMs) + XCTAssertEqual(jsonScanActivities[0]["duration_ms"] as? Int, -1) + XCTAssertEqual(jsonScanActivities[1]["result"] as! String, "ocr_pan_observed") + XCTAssertEqual(jsonScanActivities[1]["started_at_ms"] as? Int, startDateMs) + XCTAssertEqual(jsonScanActivities[1]["duration_ms"] as? Int, -1) + } + + /// Check that repeating tasks are encoded properly + func testRepeatingTasks() throws { + /// Check that the JSON dictionary is formed properly + let jsonDictionary = try repeatingTasks.encodeJSONDictionary() + let jsonMainLoop = jsonDictionary["main_loop_images_processed"] as! [String: Any] + XCTAssertEqual(jsonMainLoop["executions"] as? Int, -1) + } + + /// Check that the url encoded query string is structured properly + func testScanStatsPayloadQueryString() throws { + let scanStatsPayload: ScanStatsPayload = .init( + clientSecret: CIVIntentMockData.clientSecret, + payload: .init( + configuration: .init(strictModeFrames: 5), + payloadInfo: .init( + imageCompressionType: "heic", + imageCompressionQuality: 0.8, + imagePayloadSize: 4000 + ), + scanStats: scanStatsTasks + ) + ) + let jsonDictionary = try scanStatsPayload.encodeJSONDictionary() + /// Create query string + let queryString = URLEncoder.queryString(from: jsonDictionary) + + /// Check client secret + XCTAssertTrue( + queryString.contains("client_secret=civ_client_secret_1234"), + "client secret in query string is incorrect" + ) + /// Check that instance id exists (can't compare string since uuid is random) + XCTAssertTrue( + queryString.contains("payload[instance_id]="), + "instance id in query string dne" + ) + /// Check payload version + XCTAssertTrue( + queryString.contains("payload[payload_version]=2"), + "payload version in query string dne/is incorrect" + ) + /// Check that scan id exists (can't compare string since uuid is random) + XCTAssertTrue(queryString.contains("payload[scan_id]="), "scan id in query string dne") + /// Check that all the app info exists + XCTAssertTrue( + queryString.contains("payload[app][app_package_name]=xctest"), + "app: app package name in query string is incorrect" + ) + XCTAssertTrue( + queryString.contains("payload[app][is_debug_build]=true"), + "app: is debug build in query string is incorrect" + ) + XCTAssertTrue( + queryString.contains("payload[app][build]="), + "app: build in query string is incorrect" + ) + XCTAssertTrue( + queryString.contains( + "payload[app][sdk_version]=\(StripeAPIConfiguration.STPSDKVersion)" + ), + "app: sdk version in query string is incorrect" + ) + /// Check that all the configuration info exists + XCTAssertTrue( + queryString.contains("payload[configuration][strict_mode_frames]=5"), + "configuration: strict mode frames is incorrect" + ) + /// Check that all the payload info exists + XCTAssertTrue( + queryString.contains("payload[payload_info][image_compression_type]=heic"), + "payload info: image_compression_type is incorrect" + ) + XCTAssertTrue( + queryString.contains("payload[payload_info][image_compression_quality]=0.8"), + "payload info: image_compression_quality is incorrect" + ) + XCTAssertTrue( + queryString.contains("payload[payload_info][image_payload_size]=4000"), + "payload info: image_payload_size is incorrect" + ) + /// Check that all the device info exists + #if arch(x86_64) + XCTAssertTrue( + queryString.contains("payload[device][device_type]=x86_64"), + "device: device type in query string is incorrect" + ) + #elseif arch(arm64) + XCTAssertTrue( + queryString.contains("payload[device][device_type]=arm64"), + "device: device type in query string is incorrect" + ) + #endif + XCTAssertTrue( + queryString.contains("payload[device][device_id]=Redacted"), + "device: device id in query string dne" + ) + XCTAssertTrue( + queryString.contains("payload[device][os_version]="), + "device: os version in query string is incorrect" + ) + XCTAssertTrue( + queryString.contains("payload[device][platform]=iOS"), + "device: platform in query string is incorrect" + ) + XCTAssertTrue( + queryString.contains("payload[device][vendor_id]=Redacted"), + "device: vendor id in query string dne" + ) + /// Check that repeating tasks: main loop images processed exists + XCTAssertTrue( + queryString.contains( + "payload[scan_stats][repeating_tasks][main_loop_images_processed][executions]=-1" + ), + "repeating tasks: main loop image in query string is incorrect" + ) + /// Check that all the camera permissions info exists + XCTAssertTrue( + queryString.contains( + "payload[scan_stats][tasks][camera_permission][0][duration_ms]=-1" + ), + "Camera permission duration query string is incorrect" + ) + XCTAssertTrue( + queryString.contains( + "payload[scan_stats][tasks][camera_permission][0][result]=success" + ), + "Camera permission result query string is incorrect" + ) + XCTAssertTrue( + queryString.contains( + "payload[scan_stats][tasks][camera_permission][0][started_at_ms]=\(startDateMs!)" + ), + "Camera permission start time query string is incorrect" + ) + /// Check that all the torch supported info exists + XCTAssertTrue( + queryString.contains("payload[scan_stats][tasks][torch_supported][0][duration_ms]=-1"), + "Torch supported duration query string is incorrect" + ) + XCTAssertTrue( + queryString.contains( + "payload[scan_stats][tasks][torch_supported][0][result]=supported" + ), + "Torch supported result query string is incorrect" + ) + XCTAssertTrue( + queryString.contains( + "payload[scan_stats][tasks][torch_supported][0][started_at_ms]=\(startDateMs!)" + ), + "Torch supported start time query string is incorrect" + ) + + /// Check that all the main_loop_duration info exists + XCTAssertTrue( + queryString.contains( + "payload[scan_stats][tasks][main_loop_duration][0][duration_ms]=-1" + ), + "main_loop_duration duration query string is incorrect" + ) + XCTAssertTrue( + queryString.contains( + "payload[scan_stats][tasks][main_loop_duration][0][result]=success" + ), + "main_loop_duration result query string is incorrect" + ) + XCTAssertTrue( + queryString.contains( + "payload[scan_stats][tasks][main_loop_duration][0][started_at_ms]=\(startDateMs!)" + ), + "main_loop_duration start time query string is incorrect" + ) + + /// Check that all the image_compression_duration info exists + XCTAssertTrue( + queryString.contains( + "payload[scan_stats][tasks][image_compression_duration][0][duration_ms]=-1" + ), + "image_compression_durationn duration query string is incorrect" + ) + XCTAssertTrue( + queryString.contains( + "payload[scan_stats][tasks][image_compression_duration][0][result]=success" + ), + "image_compression_duration result query string is incorrect" + ) + XCTAssertTrue( + queryString.contains( + "payload[scan_stats][tasks][image_compression_duration][0][started_at_ms]=\(startDateMs!)" + ), + "image_compression_duration start time query string is incorrect" + ) + + /// Check that all the completion_loop_duration info exists + XCTAssertTrue( + queryString.contains( + "payload[scan_stats][tasks][completion_loop_duration][0][duration_ms]=-1" + ), + "completion_loop_duration duration query string is incorrect" + ) + XCTAssertTrue( + queryString.contains( + "payload[scan_stats][tasks][completion_loop_duration][0][result]=success" + ), + "completion_loop_duration result query string is incorrect" + ) + XCTAssertTrue( + queryString.contains( + "payload[scan_stats][tasks][completion_loop_duration][0][started_at_ms]=\(startDateMs!)" + ), + "completion_loop_duration start time query string is incorrect" + ) + + /// Check that all scan activities exists + XCTAssertTrue( + queryString.contains("payload[scan_stats][tasks][scan_activity][0][duration_ms]=-1"), + "Scan activity[0] duration query string is incorrect" + ) + XCTAssertTrue( + queryString.contains( + "payload[scan_stats][tasks][scan_activity][0][result]=first_image_processed" + ), + "Scan activity[0] result query string is incorrect" + ) + XCTAssertTrue( + queryString.contains( + "payload[scan_stats][tasks][scan_activity][0][started_at_ms]=\(startDateMs!)" + ), + "Scan activity[0] start time query string is incorrect" + ) + XCTAssertTrue( + queryString.contains("payload[scan_stats][tasks][scan_activity][1][duration_ms]=-1"), + "Scan activity[1] duration query string is incorrect" + ) + XCTAssertTrue( + queryString.contains( + "payload[scan_stats][tasks][scan_activity][1][result]=ocr_pan_observed" + ), + "Scan activity[1] result query string is incorrect" + ) + XCTAssertTrue( + queryString.contains( + "payload[scan_stats][tasks][scan_activity][1][started_at_ms]=\(startDateMs!)" + ), + "Scan activity[1] start time query string is incorrect" + ) + } +} diff --git a/StripeCardScan/StripeCardScanTests/Unit/API Bindings/VerifyFramesAPIBindingsTests.swift b/StripeCardScan/StripeCardScanTests/Unit/API Bindings/VerifyFramesAPIBindingsTests.swift new file mode 100644 index 00000000..64df6671 --- /dev/null +++ b/StripeCardScan/StripeCardScanTests/Unit/API Bindings/VerifyFramesAPIBindingsTests.swift @@ -0,0 +1,71 @@ +// +// VerifyFramesAPIBindingsTests.swift +// StripeCardScanTests +// +// Created by Jaime Park on 11/24/21. +// + +import XCTest + +@testable import StripeCardScan +@testable@_spi(STP) import StripeCore + +/// These tests are used to see if the encodable object has been encoded as expected by checking the following: +/// 1. The keys are properly set (snake case) +/// 2. The values are properly set +class VerifyFramesAPIBindingsTests: XCTestCase { + // The expected structure: + // { + // client_secret: "secret", + // verification_frames_data: "verification_frames_data" + // } + func testVerifyFrames() throws { + let verifyFrames = VerifyFrames( + clientSecret: CIVIntentMockData.clientSecret, + verificationFramesData: "verification_frames_data" + ) + + /// encodeJSONDictionary used when forming the request body + let jsonDictionary = try verifyFrames.encodeJSONDictionary() + + XCTAssertEqual(jsonDictionary["client_secret"] as! String, CIVIntentMockData.clientSecret) + XCTAssertEqual( + jsonDictionary["verification_frames_data"] as! String, + "verification_frames_data" + ) + } + + // The expected structure: + // { + // image_data: "image_data", + // viewfinder_margins: { + // left: 0, + // upper: 0, + // right: 0, + // lower: 0 + // } + // } + func testVerificationFramesData() throws { + let testData = "image_data".data(using: .utf8)! + + let verificationFramesData = VerificationFramesData( + imageData: testData, + viewfinderMargins: ViewFinderMargins( + left: 0, + upper: 0, + right: 0, + lower: 0 + ) + ) + + /// encodeJSONDictionary used when forming the request body + let jsonDictionary = try verificationFramesData.encodeJSONDictionary() + let jsonDictionaryViewfinderMargins = jsonDictionary["viewfinder_margins"] as! [String: Any] + + XCTAssertEqual(jsonDictionary["image_data"] as! String, "aW1hZ2VfZGF0YQ==") + XCTAssertEqual(jsonDictionaryViewfinderMargins["left"] as! Int, 0) + XCTAssertEqual(jsonDictionaryViewfinderMargins["upper"] as! Int, 0) + XCTAssertEqual(jsonDictionaryViewfinderMargins["right"] as! Int, 0) + XCTAssertEqual(jsonDictionaryViewfinderMargins["lower"] as! Int, 0) + } +} diff --git a/StripeCardScan/StripeCardScanTests/Unit/CardImageVerificationControllerTests.swift b/StripeCardScan/StripeCardScanTests/Unit/CardImageVerificationControllerTests.swift new file mode 100644 index 00000000..104c6537 --- /dev/null +++ b/StripeCardScan/StripeCardScanTests/Unit/CardImageVerificationControllerTests.swift @@ -0,0 +1,170 @@ +// +// CardImageVerificationControllerTests.swift +// StripeCardScanTests +// +// Created by Jaime Park on 11/18/21. +// + +import OHHTTPStubs +import OHHTTPStubsSwift +import StripeCoreTestUtils +import UIKit +import XCTest + +@testable import StripeCardScan + +class CardImageVerificationControllerTests: APIStubbedTestCase { + private let expectedCard = CardImageVerificationExpectedCard(last4: "1234", issuer: nil) + private var result: CardImageVerificationSheetResult? + private var resultExp: XCTestExpectation! + private var verifyFramesRequestExp: XCTestExpectation! + private var scanStatsRequestExp: XCTestExpectation! + private var baseViewController: UIViewController! + private var verificationSheetController: CardImageVerificationController! + + private let mockVerificationFrameData = VerificationFramesData( + imageData: "image_data".data(using: .utf8)!, + viewfinderMargins: ViewFinderMargins(left: 0, upper: 0, right: 0, lower: 0) + ) + + override func setUp() { + super.setUp() + self.resultExp = XCTestExpectation(description: "CIV Sheet result has been stored") + self.verifyFramesRequestExp = XCTestExpectation( + description: "A verify frames request has been stubbed" + ) + self.scanStatsRequestExp = XCTestExpectation( + description: "A scan stats request has been stubbed" + ) + self.baseViewController = UIViewController() + + var configuration = CardImageVerificationSheet.Configuration() + configuration.apiClient = stubbedAPIClient() + + let verificationSheetController = CardImageVerificationController( + intent: CIVIntentMockData.intent, + configuration: configuration + ) + verificationSheetController.delegate = self + + self.verificationSheetController = verificationSheetController + } + + /// This test simulates the verification view controller closing on back button press + func testFlowCanceled_Back() { + stubUploadScanStats() + + /// Invoke a `VerifyCardAddViewController` being created by not passing an expected card + verificationSheetController.present(with: expectedCard, and: nil, from: baseViewController) + verificationSheetController.verifyViewControllerDidCancel( + baseViewController, + with: .back + ) + + guard case .canceled(reason: .back) = result else { + XCTFail("Expected .canceled(reason: .back)") + return + } + + wait(for: [resultExp, scanStatsRequestExp], timeout: 1) + } + + /// This test simulates the verification view controller closing by pressing the manual button + func testFlowCanceled_Close() { + stubUploadScanStats() + + /// Invoke a `VerifyCardAddViewController` being created by not passing an expected card + verificationSheetController.present(with: expectedCard, and: nil, from: baseViewController) + verificationSheetController.verifyViewControllerDidCancel( + baseViewController, + with: .closed + ) + + guard case .canceled(reason: .closed) = result else { + XCTFail("Expected .canceled(reason: .closed)") + return + } + + wait(for: [resultExp, scanStatsRequestExp], timeout: 1) + } + + /// This test simulates the verification view controller completing the scan flow + func testFlowCompleted() { + stubSubmitVerificationFrames() + stubUploadScanStats() + + /// Invoke a `VerifyCardAddViewController` being created by not passing an expected card + verificationSheetController.present(with: expectedCard, and: nil, from: baseViewController) + + /// Mock the event where the scanning is complete and the verification frames data is passed back to be submitted for completion + verificationSheetController.verifyViewControllerDidFinish( + baseViewController, + verificationFramesData: [mockVerificationFrameData], + scannedCard: ScannedCard(pan: "4242") + ) + + /// Wait for submitVerificationFrames request to be made and the result to return + wait(for: [resultExp, verifyFramesRequestExp, scanStatsRequestExp], timeout: 1) + + guard case .completed(scannedCard: ScannedCard(pan: "4242")) = result else { + XCTFail("Expected .completed(scannedCard: ScannedCard(pan: \"4242\")") + return + } + } +} + +extension CardImageVerificationControllerTests: CardImageVerificationControllerDelegate { + func cardImageVerificationController( + _ controller: CardImageVerificationController, + didFinishWithResult result: CardImageVerificationSheetResult + ) { + self.result = result + resultExp.fulfill() + } +} + +extension CardImageVerificationControllerTests { + func stubSubmitVerificationFrames() { + let mockResponse = "{}".data(using: .utf8)! + + /// Stub the request to submit verify frames + stub { [weak self] request in + guard let requestUrl = request.url, + /// Check that the request is a POST request with an endpoint with the CIV id + requestUrl.absoluteString.contains( + "v1/card_image_verifications/\(CIVIntentMockData.id)/verify_frames" + ), + request.httpMethod == "POST" + else { + return false + } + + self?.verifyFramesRequestExp.fulfill() + return true + } response: { _ in + return HTTPStubsResponse(data: mockResponse, statusCode: 200, headers: nil) + } + } + + func stubUploadScanStats() { + let mockResponse = "{}".data(using: .utf8)! + + /// Stub the request to submit verify frames + stub { [weak self] request in + guard let requestUrl = request.url, + /// Check that the request is a POST request with an endpoint with the CIV id + requestUrl.absoluteString.contains( + "v1/card_image_verifications/\(CIVIntentMockData.id)/scan_stats" + ), + request.httpMethod == "POST" + else { + return false + } + + self?.scanStatsRequestExp.fulfill() + return true + } response: { _ in + return HTTPStubsResponse(data: mockResponse, statusCode: 200, headers: nil) + } + } +} diff --git a/StripeCardScan/StripeCardScanTests/Unit/CardImageVerificationDetailsResponseTest.swift b/StripeCardScan/StripeCardScanTests/Unit/CardImageVerificationDetailsResponseTest.swift new file mode 100644 index 00000000..708f9c72 --- /dev/null +++ b/StripeCardScan/StripeCardScanTests/Unit/CardImageVerificationDetailsResponseTest.swift @@ -0,0 +1,81 @@ +// +// CardImageVerificationDetailsResponseTest.swift +// StripeCardScanTests +// +// Created by Scott Grant on 5/11/22. +// + +import XCTest + +@testable@_spi(STP) import StripeCardScan +@testable@_spi(STP) import StripeCore + +class CardImageVerificationDetailsResponseTest: XCTestCase { + + func testExample() throws { + let json = """ + { + "accepted_image_configs": { + "default_settings": { + "compression_ratio": 0.8, + "image_size": [ + 1080, + 1920 + ] + }, + "format_settings": { + "heic": { + "compression_ratio": 0.5 + }, + "webp": { + "compression_ratio": 0.7, + "image_size": [ + 2160, + 1920 + ] + } + }, + "preferred_formats": [ + "heic", + "webp", + "jpeg" + ] + }, + "expected_card": { + "last4": "9012", + "issuer": "Visa" + } + } + """ + let jsonData = json.data(using: .utf8)! + + let responseObject: CardImageVerificationDetailsResponse = + try StripeJSONDecoder.decode(jsonData: jsonData) + + let acceptedImageConfigs = responseObject.acceptedImageConfigs + + let heicSettings = acceptedImageConfigs?.imageSettings(format: .heic) + XCTAssertNotNil(heicSettings) + + if let heicSettings = heicSettings { + XCTAssertEqual(heicSettings.compressionRatio!, 0.5) + XCTAssertEqual(heicSettings.imageSize!, [1080.0, 1920.0]) + } + + let jpegSettings = acceptedImageConfigs?.imageSettings(format: .jpeg) + XCTAssertNotNil(jpegSettings) + + if let jpegSettings = jpegSettings { + XCTAssertEqual(jpegSettings.compressionRatio!, 0.8) + XCTAssertEqual(jpegSettings.imageSize!, [1080.0, 1920.0]) + } + + let webpSettings = acceptedImageConfigs?.imageSettings(format: .webp) + XCTAssertNotNil(webpSettings) + + if let webpSettings = webpSettings { + XCTAssertEqual(webpSettings.compressionRatio!, 0.7) + XCTAssertEqual(webpSettings.imageSize!, [2160.0, 1920.0]) + } + } +} diff --git a/StripeCardScan/StripeCardScanTests/Unit/ImageCompressionTests.swift b/StripeCardScan/StripeCardScanTests/Unit/ImageCompressionTests.swift new file mode 100644 index 00000000..7243a405 --- /dev/null +++ b/StripeCardScan/StripeCardScanTests/Unit/ImageCompressionTests.swift @@ -0,0 +1,133 @@ +// +// ImageCompressionTests.swift +// StripeCardScanTests +// +// Created by Sam King on 11/29/21. +// + +import CoreServices +import UniformTypeIdentifiers +import XCTest + +@testable@_spi(STP) import StripeCardScan + +class ImageCompressionTests: XCTestCase { + + var image: CGImage? + var originalImageSize: CGSize? + var roiRectangle: CGRect? + + override func setUpWithError() throws { + let (image, roiRectangle) = ImageHelpers.getTestImageAndRoiRectangle() + + self.originalImageSize = image.size + self.image = image.cgImage + self.roiRectangle = roiRectangle + } + + func testFullSize() throws { + guard let image = image, let originalImageSize = originalImageSize, + let roiRectangle = roiRectangle + else { + throw "invalid setup" + } + + let scannedCard = ScannedCardImageData( + previewLayerImage: image, + previewLayerViewfinderRect: roiRectangle + ) + let (verificationFrame, _) = scannedCard.toVerificationFramesData(imageConfig: nil) + let imageData = verificationFrame.imageData + let newImage = UIImage(data: imageData!) + XCTAssertNotNil(newImage) + XCTAssertEqual(newImage?.size, originalImageSize) + XCTAssertTrue(verificationFrame.viewfinderMargins.equal(to: roiRectangle)) + } + + func testJPEG() throws { + guard let image = image, let originalImageSize = originalImageSize, + let roiRectangle = roiRectangle + else { + throw "invalid setup" + } + + let scannedCard = ScannedCardImageData( + previewLayerImage: image, + previewLayerViewfinderRect: roiRectangle + ) + let (verificationFrame, metadata) = scannedCard.toVerificationFramesData( + imageConfig: ImageConfig(preferredFormats: [.jpeg]) + ) + let imageData = verificationFrame.imageData + let newImage = UIImage(data: imageData!) + XCTAssertEqual(metadata.compressionType, .jpeg) + XCTAssertEqual(metadata.compressionQuality, 0.8) + XCTAssertNotNil(newImage) + XCTAssertEqual(newImage?.size, originalImageSize) + XCTAssertNotNil(newImage?.cgImage?.utType) + if let type = newImage?.cgImage?.utType { + if #available(iOS 14.0, *) { + XCTAssertEqual(type as String, UTType.jpeg.identifier) + } else { + XCTAssertEqual(type, kUTTypeJPEG) + } + } + XCTAssertTrue(verificationFrame.viewfinderMargins.equal(to: roiRectangle)) + } + + func testHEIC() throws { + guard let image = image, let originalImageSize = originalImageSize, + let roiRectangle = roiRectangle + else { + throw "invalid setup" + } + + let scannedCard = ScannedCardImageData( + previewLayerImage: image, + previewLayerViewfinderRect: roiRectangle + ) + let (verificationFrame, metadata) = scannedCard.toVerificationFramesData( + imageConfig: ImageConfig(preferredFormats: [.heic]) + ) + let imageData = verificationFrame.imageData + let newImage = UIImage(data: imageData!) + XCTAssertEqual(metadata.compressionType, .heic) + XCTAssertEqual(metadata.compressionQuality, 0.8) + XCTAssertNotNil(newImage) + XCTAssertEqual(newImage?.size, originalImageSize) + XCTAssertNotNil(newImage?.cgImage?.utType) + if let type = newImage?.cgImage?.utType { + if #available(iOS 14.0, *) { + XCTAssertEqual(type as String, UTType.heic.identifier) + } else { + XCTAssertEqual(type as String, "public.heic") + } + } + XCTAssertTrue(verificationFrame.viewfinderMargins.equal(to: roiRectangle)) + } +} + +extension ViewFinderMargins { + func equal(to rect: CGRect) -> Bool { + let left = Int(rect.origin.x) + let right = Int(rect.origin.x + rect.size.width) + let upper = Int(rect.origin.y) + let lower = Int(rect.origin.y + rect.size.height) + + return left == self.left && right == self.right && upper == self.upper + && lower == self.lower + } +} + +extension CGRect { + func scale(byX scaleX: CGFloat, byY scaleY: CGFloat) -> CGRect { + return CGRect( + x: self.origin.x * scaleX, + y: self.origin.y * scaleY, + width: self.size.width * scaleX, + height: self.size.height * scaleY + ) + } +} + +extension String: Error {} diff --git a/StripeCardScan/StripeCardScanTests/Unit/ML Models/UxModelTests.swift b/StripeCardScan/StripeCardScanTests/Unit/ML Models/UxModelTests.swift new file mode 100644 index 00000000..9c9a192f --- /dev/null +++ b/StripeCardScan/StripeCardScanTests/Unit/ML Models/UxModelTests.swift @@ -0,0 +1,48 @@ +// +// UxModelTests.swift +// CardVerifyTests +// +// Created by Sam King on 8/14/21. +// + +import XCTest + +@testable@_spi(STP) import StripeCardScan + +class UxModelTests: XCTestCase { + + var image: CGImage? + var roiRectangle: CGRect? + + override func setUpWithError() throws { + let (image, roiRectangle) = ImageHelpers.getTestImageAndRoiRectangle() + + self.image = image.cgImage + self.roiRectangle = roiRectangle + + } + + func testUxAndAppleAnalyzer() throws { + guard let image = image, let roiRectangle = roiRectangle else { + XCTAssert(false) + return + } + let ocr = AppleCreditCardOcr(dispatchQueueLabel: "test") + let uxAnalyzer = UxAnalyzer(with: ocr) + + let prediction = uxAnalyzer.recognizeCard(in: image, roiRectangle: roiRectangle) + XCTAssert(prediction.centeredCardState == .numberSide) + } + + func testUxAndSsdAnalyzer() throws { + guard let image = image, let roiRectangle = roiRectangle else { + XCTAssert(false) + return + } + let ocr = SSDCreditCardOcr(dispatchQueueLabel: "test") + let uxAnalyzer = UxAnalyzer(with: ocr) + + let prediction = uxAnalyzer.recognizeCard(in: image, roiRectangle: roiRectangle) + XCTAssert(prediction.centeredCardState == .numberSide) + } +} diff --git a/StripeCardScan/StripeCardScanTests/Unit/ScanAnalyticsManagerTests.swift b/StripeCardScan/StripeCardScanTests/Unit/ScanAnalyticsManagerTests.swift new file mode 100644 index 00000000..a2b06da5 --- /dev/null +++ b/StripeCardScan/StripeCardScanTests/Unit/ScanAnalyticsManagerTests.swift @@ -0,0 +1,165 @@ +// +// ScanAnalyticsManagerTests.swift +// StripeCardScanTests +// +// Created by Jaime Park on 5/10/22. +// + +import OHHTTPStubs +import StripeCoreTestUtils +import XCTest + +@testable@_spi(STP) import StripeCardScan + +class ScanAnalyticsManagerTests: XCTestCase { + private var scanAnalyticsManager: ScanAnalyticsManager! + private var generatePayloadExp: XCTestExpectation! + + override func setUp() { + super.setUp() + self.scanAnalyticsManager = ScanAnalyticsManager() + self.generatePayloadExp = expectation( + description: "Successfully generated the scan analytics payload" + ) + } + + /// This test checks that the scan analytics manager aggregates tasks and generates the payload object properly + func testGeneratePayload() { + let startTime = Date() + let task: TrackableTask = { + let task = TrackableTask() + task.trackResult(.success) + return task + }() + let payloadInfo = ScanAnalyticsPayload.PayloadInfo( + imageCompressionType: "heic", + imageCompressionQuality: 0.8, + imagePayloadSize: 4000 + ) + /// Log scan activity repeating and non-repeating tasks + scanAnalyticsManager.setScanSessionStartTime(time: startTime) + scanAnalyticsManager.logCameraPermissionsTask(success: false) + scanAnalyticsManager.logMainLoopImageProcessedRepeatingTask(.init(executions: 100)) + scanAnalyticsManager.logPayloadInfo(with: payloadInfo) + scanAnalyticsManager.logScanActivityTask(event: .firstImageProcessed) + scanAnalyticsManager.logTorchSupportTask(supported: false) + scanAnalyticsManager.trackCompletionLoopDuration(task: task) + scanAnalyticsManager.trackImageCompressionDuration(task: task) + scanAnalyticsManager.trackMainLoopDuration(task: task) + + /// Override tasks when values have changed + scanAnalyticsManager.logCameraPermissionsTask(success: true) + scanAnalyticsManager.logTorchSupportTask(supported: true) + + scanAnalyticsManager.generateScanAnalyticsPayload(with: .init()) { + [weak self] scanAnalyticsPayload in + guard let payload = scanAnalyticsPayload else { + XCTFail("Did not generate scan analytics payload") + return + } + + self?.generatePayloadExp.fulfill() + + /// Check the populated configuration and payload info + XCTAssertEqual(payload.configuration.strictModeFrames, 0) + XCTAssertEqual(payload.payloadInfo, payloadInfo) + + /// Check the populated scan activity + let payloadScanStats = payload.scanStats + XCTAssertEqual( + payloadScanStats.tasks.cameraPermission.first?.result, + ScanAnalyticsEvent.success.rawValue + ) + XCTAssertEqual( + payloadScanStats.tasks.completionLoopDuration.first?.result, + ScanAnalyticsEvent.success.rawValue + ) + XCTAssertEqual( + payloadScanStats.tasks.imageCompressionDuration.first?.result, + ScanAnalyticsEvent.success.rawValue + ) + XCTAssertEqual( + payloadScanStats.tasks.mainLoopDuration.first?.result, + ScanAnalyticsEvent.success.rawValue + ) + XCTAssertEqual( + payloadScanStats.tasks.torchSupported.first?.result, + ScanAnalyticsEvent.torchSupported.rawValue + ) + XCTAssertEqual( + payloadScanStats.tasks.scanActivity.first?.result, + ScanAnalyticsEvent.firstImageProcessed.rawValue + ) + XCTAssertEqual( + payloadScanStats.repeatingTasks.mainLoopImagesProcessed, + .init(executions: 100) + ) + } + + wait(for: [generatePayloadExp], timeout: 1) + } + + /// This test checks that the scan analytics manager resets properly + func testReset() { + let startTime = Date() + /// Log scan activity repeating and non-repeating tasks + scanAnalyticsManager.setScanSessionStartTime(time: startTime) + scanAnalyticsManager.logCameraPermissionsTask(success: false) + scanAnalyticsManager.logMainLoopImageProcessedRepeatingTask(.init(executions: 100)) + scanAnalyticsManager.logScanActivityTaskFromStartTime(event: .firstImageProcessed) + scanAnalyticsManager.logTorchSupportTask(supported: false) + + /// Reset the scan analytics manager + scanAnalyticsManager.reset() + + scanAnalyticsManager.generateScanAnalyticsPayload(with: .init()) { + [weak self] scanAnalyticsPayload in + guard let payload = scanAnalyticsPayload else { + XCTFail("Did not generate scan analytics payload") + return + } + + self?.generatePayloadExp.fulfill() + + /// Check the populated configuration + XCTAssertEqual(payload.configuration.strictModeFrames, 0) + XCTAssertNil( + payload.payloadInfo, + "A reset scan analytics manager should have a nil payload info" + ) + + /// Check the reset scan activity + let payloadScanStats = payload.scanStats + XCTAssertEqual( + payloadScanStats.tasks.cameraPermission.first?.result, + ScanAnalyticsEvent.unknown.rawValue + ) + XCTAssertEqual( + payloadScanStats.tasks.completionLoopDuration.first?.result, + ScanAnalyticsEvent.unknown.rawValue + ) + XCTAssertEqual( + payloadScanStats.tasks.imageCompressionDuration.first?.result, + ScanAnalyticsEvent.unknown.rawValue + ) + XCTAssertEqual( + payloadScanStats.tasks.mainLoopDuration.first?.result, + ScanAnalyticsEvent.unknown.rawValue + ) + XCTAssertEqual( + payloadScanStats.tasks.torchSupported.first?.result, + ScanAnalyticsEvent.unknown.rawValue + ) + XCTAssertEqual( + payloadScanStats.repeatingTasks.mainLoopImagesProcessed, + .init(executions: -1) + ) + XCTAssert( + payloadScanStats.tasks.scanActivity.isEmpty, + "A reset scan analytics manager should have an empty scan activity list" + ) + } + + wait(for: [generatePayloadExp], timeout: 1) + } +} diff --git a/StripeCardScan/StripeCardScanTests/Unit/StrictModeFramesTest.swift b/StripeCardScan/StripeCardScanTests/Unit/StrictModeFramesTest.swift new file mode 100644 index 00000000..e0966e54 --- /dev/null +++ b/StripeCardScan/StripeCardScanTests/Unit/StrictModeFramesTest.swift @@ -0,0 +1,159 @@ +// +// StrictModeFramesTest.swift +// StripeCardScanTests +// +// Created by Jaime Park on 3/10/22. +// + +import XCTest + +@testable@_spi(STP) import StripeCardScan + +class StrictFramesTests: XCTestCase { + let correctCardNumber = ScannedCardDetails(number: "0000111122223333") + let incorrectCardNumber = ScannedCardDetails(number: "7777888899990000") + + /// Test to check that `visibleMatchingCardCount` increments properly + func testVisibleMatchingCardCount() { + let cardVerifyStateMachine = CardVerifyStateMachine( + requiredLastFour: correctCardNumber.last4 + ) + /// First frame detected is a mismatch frame with a card present + transition( + stateMachine: cardVerifyStateMachine, + prediction: mismatchNumberPrediction(cardVisible: true) + ) + XCTAssertEqual(cardVerifyStateMachine.visibleMatchingCardCount, 0) + + /// Simulate transitioning state machine with 9 matching frames + var totalMatchedFramesWithCard = 9 + repeat { + transition( + stateMachine: cardVerifyStateMachine, + prediction: matchNumberPrediction(cardVisible: true) + ) + totalMatchedFramesWithCard -= 1 + } while totalMatchedFramesWithCard > 0 + + XCTAssertEqual(cardVerifyStateMachine.visibleMatchingCardCount, 9) + + /// Simulate transitioning to a mismatching frame. Visible card count should not change. + transition( + stateMachine: cardVerifyStateMachine, + prediction: mismatchNumberPrediction(cardVisible: true) + ) + XCTAssertEqual(cardVerifyStateMachine.visibleMatchingCardCount, 9) + } + + func testCardVerifyStateMachine_NotStrict_Success() { + let cardVerifyStateMachine = CardVerifyStateMachine( + requiredLastFour: correctCardNumber.last4 + ) + + /// Mock that: + /// 1. We are currently in the `ocrAndCard` state + /// 2. We have been in the `ocrAndCard` state for 1.5 seconds (the exact timeout limit) + /// 3. We have already accounted for 1 frames that have matching pan & detected a card + cardVerifyStateMachine.state = .ocrAndCard + cardVerifyStateMachine.startTimeForCurrentState = Date().addingTimeInterval(-1.5) + cardVerifyStateMachine.visibleMatchingCardCount = 1 + + /// Run through 1 more frame with matching pan & card detected fulfilling the strictModeFrame amount (0) + transition( + stateMachine: cardVerifyStateMachine, + prediction: matchNumberPrediction(cardVisible: true) + ) + + XCTAssertEqual(cardVerifyStateMachine.visibleMatchingCardCount, 2) + XCTAssertEqual(cardVerifyStateMachine.state, .finished) + } + + func testCardVerifyStateMachine_Strict_Success() { + /// Configure the Bouncer session with strict mode frames + let cardVerifyStateMachine = CardVerifyStateMachine( + requiredLastFour: correctCardNumber.last4, + strictModeFramesCount: .high + ) + + /// Mock that: + /// 1. We are currently in the `ocrAndCard` state + /// 2. We have been in the `ocrAndCard` state for 3 seconds (well passed the timeout limit) + /// 3. We have already accounted for 4 frames that have matching pan & detected a card + cardVerifyStateMachine.state = .ocrAndCard + cardVerifyStateMachine.startTimeForCurrentState = Date().addingTimeInterval(-3) + cardVerifyStateMachine.visibleMatchingCardCount = 4 + + /// Run through 1 more frame with matching pan & card detected fulfilling the strictModeFrame amount (5) + transition( + stateMachine: cardVerifyStateMachine, + prediction: matchNumberPrediction(cardVisible: true) + ) + + XCTAssertEqual(cardVerifyStateMachine.visibleMatchingCardCount, 5) + XCTAssertEqual(cardVerifyStateMachine.state, .finished) + } + + func testCardVerifyStateMachine_Reset() { + /// Configure the Bouncer session with strict mode frames + let cardVerifyStateMachine = CardVerifyStateMachine( + requiredLastFour: correctCardNumber.last4, + strictModeFramesCount: .high + ) + + /// Mock that: + /// 1. We are currently in the `ocrAndCard` state + /// 2. We have been in the `ocrAndCard` state for 3 seconds (well passed the timeout limit) + /// 3. We have only found 1 matching frame; Not enough to pass strict mode + cardVerifyStateMachine.state = .ocrAndCard + cardVerifyStateMachine.startTimeForCurrentState = Date().addingTimeInterval(-3) + cardVerifyStateMachine.visibleMatchingCardCount = 1 + + /// Run through 1 more frame with matching pan to trigger reset to initial state + transition( + stateMachine: cardVerifyStateMachine, + prediction: matchNumberPrediction(cardVisible: true) + ) + XCTAssertEqual(cardVerifyStateMachine.state, .initial) + XCTAssertEqual(cardVerifyStateMachine.visibleMatchingCardCount, 0) + + } +} + +extension StrictFramesTests { + func transition(stateMachine: CardVerifyStateMachine, prediction: CreditCardOcrPrediction) { + _ = stateMachine.event(prediction: prediction) + } + + func mismatchNumberPrediction(cardVisible: Bool) -> CreditCardOcrPrediction { + return generateOcrPrediction( + withNumber: incorrectCardNumber.number, + withCenteredCardState: cardVisible ? .numberSide : .noCard + ) + } + + func matchNumberPrediction(cardVisible: Bool) -> CreditCardOcrPrediction { + return generateOcrPrediction( + withNumber: correctCardNumber.number, + withCenteredCardState: cardVisible ? .numberSide : .noCard + ) + } + + func generateOcrPrediction( + withNumber number: String, + withCenteredCardState centeredCardState: CenteredCardState + ) -> CreditCardOcrPrediction { + return CreditCardOcrPrediction( + image: ImageHelpers.createBlankCGImage(), + ocrCroppingRectangle: CGRect(), + number: number, + expiryMonth: nil, + expiryYear: nil, + name: nil, + computationTime: 0.0, + numberBoxes: nil, + expiryBoxes: nil, + nameBoxes: nil, + centeredCardState: centeredCardState + ) + } +} diff --git a/StripeCardScan/StripeCardScanTests/Unit/StringResourceTests.swift b/StripeCardScan/StripeCardScanTests/Unit/StringResourceTests.swift new file mode 100644 index 00000000..01644c20 --- /dev/null +++ b/StripeCardScan/StripeCardScanTests/Unit/StringResourceTests.swift @@ -0,0 +1,27 @@ +// +// ImageCompressionTests.swift +// StripeCardScanTests +// +// Created by Scott Grant on 05/18/22. +// + +import CoreServices +import UniformTypeIdentifiers +import XCTest + +@testable@_spi(STP) import StripeCore + +class StringResourceTests: XCTestCase { + let privacyLinkExpectedSha = "lv51crZ0rBIUPUOnQm9zFlMPCrUEI+GVsa4QyHifTw0=" + + func testPrivacyLinkText() throws { + STPLocalizationUtils.overrideLanguage(to: "en") + + // This string is expected to go unaltered in the UI for CardScan. Changing it is against + // the terms of service for Stripe Card Scan. + let privacyLinkString = String.Localized.scan_card_privacy_link_text + XCTAssertEqual(privacyLinkString.sha256, privacyLinkExpectedSha) + + STPLocalizationUtils.overrideLanguage(to: nil) + } +} diff --git a/StripeCore/Project.swift b/StripeCore/Project.swift new file mode 100644 index 00000000..032e7a73 --- /dev/null +++ b/StripeCore/Project.swift @@ -0,0 +1,13 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.stripeFramework( + name: "StripeCore", + resources: "StripeCore/Resources/**", + testUtilsOptions: .testOptions( + resources: "StripeCoreTestUtils/Mock Files/**", + includesSnapshots: true, + usesStubs: true + ), + unitTestOptions: .testOptions(resources: "StripeCoreTests/Mock Files/**") +) diff --git a/StripeCore/StripeCore.xcodeproj/project.pbxproj b/StripeCore/StripeCore.xcodeproj/project.pbxproj new file mode 100644 index 00000000..6681f4d3 --- /dev/null +++ b/StripeCore/StripeCore.xcodeproj/project.pbxproj @@ -0,0 +1,1240 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + 02A26B79617FAE660C9EB506 /* StripeError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC0AF5BF0A4799D2C0C7445 /* StripeError.swift */; }; + 0709F5D265CC641E6DE1011D /* URLSession+Retry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B76840742D2CAF2931355A /* URLSession+Retry.swift */; }; + 08871FCD9E9E47681135431B /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54D4E87D67740BF3C05638FD /* XCTest.framework */; }; + 096274D0729AA8849FAD103C /* PaymentsSDKVariant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFB15A6996F610D627A42B3 /* PaymentsSDKVariant.swift */; }; + 0A78AD04075C43A4059C344E /* STPAnalyticsClientTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B16FDA9232BD4FBD4B13EC2 /* STPAnalyticsClientTest.swift */; }; + 0F4A1BAE6774B90C72F578CC /* MockAnalyticsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2C7099CB854E1E90424235C /* MockAnalyticsClient.swift */; }; + 12FF091C555F75B914464475 /* STPMultipartFormDataEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D803C2F907529E780B0296 /* STPMultipartFormDataEncoder.swift */; }; + 17CE96B50813CF626293CBF9 /* URLEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51179DB520568C246BF3AF0 /* URLEncoder.swift */; }; + 2991461DD354A6124CCF78DA /* STPLocalizationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 325E8108336F1178A10D139C /* STPLocalizationUtils.swift */; }; + 2AA9B01C8A2D2BADC4619629 /* NSCharacterSet+StripeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC5151C3B6CB4130E9C259A6 /* NSCharacterSet+StripeCore.swift */; }; + 2B98F4F0120888B12EF3B181 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = CFF68ABB8E67E83695FAD8EA /* Localizable.strings */; }; + 2D7A4FDBED7E3FA3D17BBB54 /* StripeCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 40A77DE176741B3A542FE890 /* StripeCore.framework */; }; + 330FDCF901D11882D4866DDE /* APIStubbedTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF82F68E8D3A8286FD31DB13 /* APIStubbedTestCase.swift */; }; + 35931C64F06BEB233A219869 /* NetworkDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19F97D1606B517158C7F75A /* NetworkDetector.swift */; }; + 3815D229613D52D7799805B0 /* AnalyticsClientV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 192E71FE64C5FDE929992CC4 /* AnalyticsClientV2.swift */; }; + 3B27DDDDC91F1599BF1469BB /* UserDefaults+PaymentsCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45F11FF08733CF61D880640D /* UserDefaults+PaymentsCore.swift */; }; + 3B9D69AB1CB61725C7A012B6 /* StripeServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCF062352C38A5173260C46A /* StripeServiceError.swift */; }; + 3D90376B1883E4BE64712197 /* MockAnalyticsClientV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27EB57C159E5F10F50D071E5 /* MockAnalyticsClientV2.swift */; }; + 3E9FC2CD06E1D5F6B09872E9 /* AnalyticsClientV2Test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440D8DFB25A1F7FBC01BE1D7 /* AnalyticsClientV2Test.swift */; }; + 3F275B08EB554772F2FE4E4E /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 762C486BE5DDF9B3BE7B3F45 /* DownloadManager.swift */; }; + 40AF3B2EB6B82C9A4DD61033 /* OHHTTPStubs in Frameworks */ = {isa = PBXBuildFile; productRef = 9AB19C81E50535A3407582B7 /* OHHTTPStubs */; }; + 42DE35681C71A931F65E0E7D /* Enums+CustomStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A8030BF88608CA86E295F18 /* Enums+CustomStringConvertible.swift */; }; + 44DE84C8BFB403E1FB7E2E82 /* StripeJSONDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B3D6ADD516777DE13E79792 /* StripeJSONDecoder.swift */; }; + 4506A7016EA7C45796D3A30D /* STPLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 758B9C9C2252A91FE4221702 /* STPLocalizedString.swift */; }; + 45DAE581F74EF7E11C64212B /* InstallMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21E64986F72C7BD8B1105A95 /* InstallMethod.swift */; }; + 48A6CCB4008A5060C2655C5F /* XCTestCase+Stripe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE48D0086BED21F9E837D0B /* XCTestCase+Stripe.swift */; }; + 4B2FAC57E03D8654A177C408 /* Dictionary+Stripe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7727AEEFD2FC880BADDA1872 /* Dictionary+Stripe.swift */; }; + 53D46A03B77577EE21F4B166 /* StripeCodableTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FCE36551600C3E53BEAF8F0 /* StripeCodableTest.swift */; }; + 552DA7969984C443617DBC3E /* STPMultipartFormDataPart.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C72BA9C44FF60A0E7BEF76 /* STPMultipartFormDataPart.swift */; }; + 5553D952F91D193D453D777D /* Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77CAB1A5AC107D8756CA1CBF /* Async.swift */; }; + 563A42FA383FA9AA5FA4CDCE /* String+StripeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7507497162F5684AEA59E301 /* String+StripeCore.swift */; }; + 59CA874015261241AC255907 /* FileDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C174081A48DD86978D270D /* FileDownloader.swift */; }; + 5E807567D7320A7D512127AF /* StripeCore.h in Headers */ = {isa = PBXBuildFile; fileRef = E60F4A38EEF5EA11568B3A64 /* StripeCore.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 62FD088E003BE06F5413FB4F /* StripeCoreBundleLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA5353BC5359E08128E116A /* StripeCoreBundleLocator.swift */; }; + 631D09E67497B49BBCA26192 /* UIView+StripeCoreTestingUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4E4C534285F49A97A04D2B8 /* UIView+StripeCoreTestingUtils.swift */; }; + 677951C643328D76E46720A5 /* StripeAPIConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32CB3702691056D3404A8C5F /* StripeAPIConfiguration.swift */; }; + 6A52ABC06783A90B9E339948 /* StripeFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73CE623A81057C4063A1E0C4 /* StripeFile.swift */; }; + 6B4156FCFAEDD1C73DC6EDAD /* iOSSnapshotTestCase in Frameworks */ = {isa = PBXBuildFile; productRef = 23D22B2C40BA7C182BCE50B2 /* iOSSnapshotTestCase */; }; + 6D68B868938BAB15A843B33C /* TestJSONEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF290DF69F5FD1004BBDECA /* TestJSONEncoder.swift */; }; + 6ED5C41DBDAB475BF1119E98 /* UnknownFields.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64635404BD4D5D62486A7626 /* UnknownFields.swift */; }; + 700E9DAD0407455F11F9F03E /* StripeCoreTestUtils.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A4F598BF353D8335B0340D5 /* StripeCoreTestUtils.framework */; }; + 71CD1AE29AA09552DF61131E /* StripeJSONShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86C675ABC9D68378DC699DED /* StripeJSONShared.swift */; }; + 72DA29CA8A750E8B00DBF3D4 /* STPError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C51E3FA5EE3587BB7BBC634 /* STPError.swift */; }; + 766FE8E61B44967F057ED424 /* AnalyticLoggableError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B890F162E1C247D5CA1A9E6 /* AnalyticLoggableError.swift */; }; + 772292156A4A80CEA9D9C487 /* ConnectionsSDKInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC3BCEEECB3E1485B18F0C4 /* ConnectionsSDKInterface.swift */; }; + 79DA4102C501FC2E53D946B5 /* STPAPIClient+ErrorResponseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E70193A9CA42A0C53E48C1 /* STPAPIClient+ErrorResponseTest.swift */; }; + 8310D598D6D40BAD23880D3F /* StripeCoreTestUtils.h in Headers */ = {isa = PBXBuildFile; fileRef = C666CC926642D7AA76E75B5B /* StripeCoreTestUtils.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 83790210FFC2DD764C042C8E /* STPDispatchFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591E592C9F3E5D4CB08A1847 /* STPDispatchFunctions.swift */; }; + 838BDB53C4F6AC3C0567DE6A /* KeyPathExpectation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 561E5F74D9E5676F6E72E93F /* KeyPathExpectation.swift */; }; + 84487D8E9B08106C89753536 /* Error_SerializeForLoggingTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3CA4B1855BF8D2F08F275A9 /* Error_SerializeForLoggingTest.swift */; }; + 87274985CE5E750FA8D34648 /* EmptyResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00D8E07F0DDBA1577A52156C /* EmptyResponse.swift */; }; + 87536D729B8201085E380C32 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54D4E87D67740BF3C05638FD /* XCTest.framework */; }; + 89DB623A200678B4E9845AF2 /* FraudDetectionData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E919FBEB852CFEA9517FCBDC /* FraudDetectionData.swift */; }; + 8AD68C8D00A0BCF94E5230DC /* UIImage+StripeCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28769EBA666E0BDFC69954F /* UIImage+StripeCoreTests.swift */; }; + 917B4E193E5A1233F1A2E80E /* StripeCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 40A77DE176741B3A542FE890 /* StripeCore.framework */; }; + 920832EE256E377572DD41EB /* STPTelemetryClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A466F6075DCC39D5A976FB22 /* STPTelemetryClient.swift */; }; + 934CCB00769674F13192A126 /* Dictionary+StripeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34895253739089BC125D2625 /* Dictionary+StripeTests.swift */; }; + 9504F199974F5AF7931A8485 /* DownloadManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D013DA69A308F6E0972B998 /* DownloadManagerTest.swift */; }; + 95156E152471058151076A51 /* Analytic.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF11F814A986AA710410FF8 /* Analytic.swift */; }; + 970D95FEA3BC216351DE3C5E /* StripeJSONEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD027F7695EC6F59CF32F4B9 /* StripeJSONEncoder.swift */; }; + 97D0B2120678A75F75648D84 /* STPAppInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9077630E4A5307B83B4BA19E /* STPAppInfo.swift */; }; + 9843D9B7D886C373C7AC71E4 /* NSMutableURLRequest+Stripe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BD3D02EBA8A732A9C832925 /* NSMutableURLRequest+Stripe.swift */; }; + 9DCBC08C182ED76A962961E7 /* NSURLComponents+Stripe.swift in Sources */ = {isa = PBXBuildFile; fileRef = D475CD59778FAC1028670AB5 /* NSURLComponents+Stripe.swift */; }; + 9FBA50345D53E82AA974F672 /* STPAPIClient+FileUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FC2FC535DAF9B340F9EA75 /* STPAPIClient+FileUpload.swift */; }; + A454F368C505B2D80F0D8B19 /* PluginDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CDBCD70CB220014972B49A5 /* PluginDetector.swift */; }; + A4DBBFD379C4E0120BD25C56 /* STPAPIClient+EmptyResponseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC70CDF482E22A29B11466F7 /* STPAPIClient+EmptyResponseTest.swift */; }; + A50CB2ACAC1DCF9539D76F25 /* NSArray+StripeCoreTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F2949BE5129DC4BBCD69B05 /* NSArray+StripeCoreTest.swift */; }; + A62AEDF871AC89489FE19A13 /* ServerErrorMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C188AD8AE244CD6076679095 /* ServerErrorMapper.swift */; }; + A987DFFA404EBD0788DAB21A /* UIImage+StripeCoreTestingUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1678E0A16D46DA2B4D3B3ECD /* UIImage+StripeCoreTestingUtils.swift */; }; + AE26BFFBE9B1242E10CA052F /* STPAnalyticsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DD5B6F1152FD1C4A508A103 /* STPAnalyticsClient.swift */; }; + B35FD03DD246CC4DD6C4F3C0 /* Decimal+StripeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06A516593E48DCFD5D98D702 /* Decimal+StripeCore.swift */; }; + B6D129B2DC90FA1F8A1F5BCB /* UIActivityIndicatorView+Stripe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599C8A64EB3988BCE32E2B05 /* UIActivityIndicatorView+Stripe.swift */; }; + C164984958CDC2C9CA4B6316 /* STPAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F9ED2ADD9406CF7534BB6C9 /* STPAPIClient.swift */; }; + C318A6B6CD599B06DA7CE706 /* NSBundle+Stripe_AppName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E852B53CF75A119D3810B41 /* NSBundle+Stripe_AppName.swift */; }; + C5BF4B8AE85FF72EC6382EC0 /* NSError+Stripe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E415507B0152B07796114CC /* NSError+Stripe.swift */; }; + C7C0EC68130760F73201F81B /* StripeCodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49424775D3233411D9C2473B /* StripeCodable.swift */; }; + C9B6C451F9A46C9FB031CE95 /* STPURLCallbackHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7647C1B64CA0F0087327413 /* STPURLCallbackHandler.swift */; }; + C9C320ADCCF1548D6562CE94 /* File_IdentityDocument.json in Resources */ = {isa = PBXBuildFile; fileRef = DC24A98C4020646F99456187 /* File_IdentityDocument.json */; }; + CA09DC1EC4142701B31F9673 /* UIImage+StripeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45D4100901F9445AC1FD453A /* UIImage+StripeCore.swift */; }; + CAF857D45689FBEF17627E80 /* BundleLocatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937066801E91C99C50192364 /* BundleLocatorProtocol.swift */; }; + CB1FB2383FAEE0194C39E4DE /* OHHTTPStubsSwift in Frameworks */ = {isa = PBXBuildFile; productRef = E86AC7DD5F4DE2780E0AC425 /* OHHTTPStubsSwift */; }; + CB8A47A5FD057112CB607DE9 /* MockData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5334916D2A4F927645C2569 /* MockData.swift */; }; + D144C3A657E5C16975CB2191 /* NSError+StripeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23506F3E93ECA5A96DCE7E31 /* NSError+StripeCore.swift */; }; + D22FAB2F1AE9AE43C1808747 /* StripeAPIConfiguration+Version.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F1BFF01B1EA57F6EA7BFBF1 /* StripeAPIConfiguration+Version.swift */; }; + D8FEF16798A791F5BF7EA4B2 /* NSArray+Stripe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21608B7BC24190523363CB3D /* NSArray+Stripe.swift */; }; + DA5A05459309B9B77ACDD736 /* STPDeviceUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C2AA21DAF340432CA98C67C /* STPDeviceUtils.swift */; }; + DAD4099D03E43A0CA89464CD /* StripeAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4336F071B0CE13306C8EB93 /* StripeAPI.swift */; }; + DF1EC524A1915E344687F5AC /* UIFont+Stripe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EAF12F161469C070B822369 /* UIFont+Stripe.swift */; }; + DFF3092E51B6C3ED81AB1448 /* String+Localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FF688F66CD047D08B3AE0CB /* String+Localized.swift */; }; + E2B25D45D457A76A782D9089 /* STPAnalyticEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B6C28853EF0316366FB8DC4 /* STPAnalyticEvent.swift */; }; + E344C20A07D8B8F33B530974 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4B2A342F16484705840F1B5 /* TestConstants.swift */; }; + EFE476BA387E91BE1D5D3E1D /* URLRequest+StripeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B75708617FB765AB211FD9A /* URLRequest+StripeTest.swift */; }; + EFF90360C85642F7F2898186 /* URLEncoderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F2E1D80D0342CF09CB05415 /* URLEncoderTest.swift */; }; + F5DB5D52E2668136FF6D70D6 /* NSMutableURLRequest+StripeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66CC52EF207F05E0EFAEACD8 /* NSMutableURLRequest+StripeTest.swift */; }; + F628BBE9FDA9D3A217ACA753 /* STPNumericStringValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D856053F99E32515DEDD8EDF /* STPNumericStringValidator.swift */; }; + FCACE815CE9073F3FE18C185 /* test_image.png in Resources */ = {isa = PBXBuildFile; fileRef = 8CB1E5F31B0941D8B096B360 /* test_image.png */; }; + FF5B9F1861A1DB4C2605BBD8 /* STPSnapshotVerifyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EF38A1DE6515748EC7515A /* STPSnapshotVerifyView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 773BD5B9B4846D879C13CFE1 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = CBF07079FA432D2BFCE24022 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3E987A4F06C4DE962E9F3CB8; + remoteInfo = StripeCoreTestUtils; + }; + 7FEF9BDDDD738CE997EC053B /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = CBF07079FA432D2BFCE24022 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7877B31445857B119EA45445; + remoteInfo = StripeCore; + }; + E1EC5611DA9FE94C4E0EF3A8 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = CBF07079FA432D2BFCE24022 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7877B31445857B119EA45445; + remoteInfo = StripeCore; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 18178F9338B4379A125C292F /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + 5D04C08A37B43A01ADAAA114 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + 5F3E583CF98B20ACEDBC39ED /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 00D8E07F0DDBA1577A52156C /* EmptyResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyResponse.swift; sourceTree = ""; }; + 013DF1F8EC4A4FFA1A38B725 /* StripeCoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StripeCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 019EC578C08FE61F858E1F1F /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; + 02D48D61A84BE5788EA61E59 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + 06A516593E48DCFD5D98D702 /* Decimal+StripeCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decimal+StripeCore.swift"; sourceTree = ""; }; + 0ACE2C3A8889C655E58EEA67 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; + 0B6C28853EF0316366FB8DC4 /* STPAnalyticEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPAnalyticEvent.swift; sourceTree = ""; }; + 160FD8F504CCAC864C27AA62 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; + 1678E0A16D46DA2B4D3B3ECD /* UIImage+StripeCoreTestingUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+StripeCoreTestingUtils.swift"; sourceTree = ""; }; + 181E67908573FC358CCED4BF /* bg-BG */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "bg-BG"; path = "bg-BG.lproj/Localizable.strings"; sourceTree = ""; }; + 192E71FE64C5FDE929992CC4 /* AnalyticsClientV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsClientV2.swift; sourceTree = ""; }; + 1DD5B6F1152FD1C4A508A103 /* STPAnalyticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPAnalyticsClient.swift; sourceTree = ""; }; + 1FCE36551600C3E53BEAF8F0 /* StripeCodableTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeCodableTest.swift; sourceTree = ""; }; + 2147B1CA0A1B65566D7AB7C6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; + 21608B7BC24190523363CB3D /* NSArray+Stripe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSArray+Stripe.swift"; sourceTree = ""; }; + 21E64986F72C7BD8B1105A95 /* InstallMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallMethod.swift; sourceTree = ""; }; + 23506F3E93ECA5A96DCE7E31 /* NSError+StripeCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSError+StripeCore.swift"; sourceTree = ""; }; + 27EB57C159E5F10F50D071E5 /* MockAnalyticsClientV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAnalyticsClientV2.swift; sourceTree = ""; }; + 2B16FDA9232BD4FBD4B13EC2 /* STPAnalyticsClientTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPAnalyticsClientTest.swift; sourceTree = ""; }; + 2B75708617FB765AB211FD9A /* URLRequest+StripeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+StripeTest.swift"; sourceTree = ""; }; + 2BD3D02EBA8A732A9C832925 /* NSMutableURLRequest+Stripe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSMutableURLRequest+Stripe.swift"; sourceTree = ""; }; + 2DE48D0086BED21F9E837D0B /* XCTestCase+Stripe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+Stripe.swift"; sourceTree = ""; }; + 2F1BFF01B1EA57F6EA7BFBF1 /* StripeAPIConfiguration+Version.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StripeAPIConfiguration+Version.swift"; sourceTree = ""; }; + 303695D2ECDFFCA9C1B68E53 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; + 31402722B97FC2D9A3A74E73 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; + 325E8108336F1178A10D139C /* STPLocalizationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPLocalizationUtils.swift; sourceTree = ""; }; + 32CB3702691056D3404A8C5F /* StripeAPIConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeAPIConfiguration.swift; sourceTree = ""; }; + 33A34DF206B0980BA1D2258F /* StripeiOS Tests-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS Tests-Release.xcconfig"; sourceTree = ""; }; + 34895253739089BC125D2625 /* Dictionary+StripeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+StripeTests.swift"; sourceTree = ""; }; + 34D803C2F907529E780B0296 /* STPMultipartFormDataEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPMultipartFormDataEncoder.swift; sourceTree = ""; }; + 3A4F598BF353D8335B0340D5 /* StripeCoreTestUtils.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCoreTestUtils.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3FECC037162676AF2E8DCAEC /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/Localizable.strings"; sourceTree = ""; }; + 3FFB15A6996F610D627A42B3 /* PaymentsSDKVariant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentsSDKVariant.swift; sourceTree = ""; }; + 40A497A121A7C792624E7948 /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/Localizable.strings; sourceTree = ""; }; + 40A77DE176741B3A542FE890 /* StripeCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 43D9EC920BB9059E0C652178 /* lt-LT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "lt-LT"; path = "lt-LT.lproj/Localizable.strings"; sourceTree = ""; }; + 440D8DFB25A1F7FBC01BE1D7 /* AnalyticsClientV2Test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsClientV2Test.swift; sourceTree = ""; }; + 45D4100901F9445AC1FD453A /* UIImage+StripeCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+StripeCore.swift"; sourceTree = ""; }; + 45F11FF08733CF61D880640D /* UserDefaults+PaymentsCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+PaymentsCore.swift"; sourceTree = ""; }; + 4689F6B4384244D9FD282560 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 48A3D6592296104A1512AE92 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; + 49424775D3233411D9C2473B /* StripeCodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeCodable.swift; sourceTree = ""; }; + 49538DBF8457D96707A2DA56 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; + 4A8030BF88608CA86E295F18 /* Enums+CustomStringConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Enums+CustomStringConvertible.swift"; sourceTree = ""; }; + 4C51E3FA5EE3587BB7BBC634 /* STPError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPError.swift; sourceTree = ""; }; + 4EC3BCEEECB3E1485B18F0C4 /* ConnectionsSDKInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionsSDKInterface.swift; sourceTree = ""; }; + 4FF290DF69F5FD1004BBDECA /* TestJSONEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestJSONEncoder.swift; sourceTree = ""; }; + 536085BA191EC2942523A7DB /* en-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-GB"; path = "en-GB.lproj/Localizable.strings"; sourceTree = ""; }; + 54D4E87D67740BF3C05638FD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; + 561E5F74D9E5676F6E72E93F /* KeyPathExpectation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyPathExpectation.swift; sourceTree = ""; }; + 58D2EB990E533C5D42635676 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 591E592C9F3E5D4CB08A1847 /* STPDispatchFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPDispatchFunctions.swift; sourceTree = ""; }; + 599C8A64EB3988BCE32E2B05 /* UIActivityIndicatorView+Stripe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivityIndicatorView+Stripe.swift"; sourceTree = ""; }; + 59EBF56CBE900B3EE80E73A5 /* Project-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Debug.xcconfig"; sourceTree = ""; }; + 5CE5D62D8BA864BFB38B70C5 /* mt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mt; path = mt.lproj/Localizable.strings; sourceTree = ""; }; + 5EAF12F161469C070B822369 /* UIFont+Stripe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Stripe.swift"; sourceTree = ""; }; + 625636EFF4844186C7A31FAF /* ro-RO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ro-RO"; path = "ro-RO.lproj/Localizable.strings"; sourceTree = ""; }; + 64635404BD4D5D62486A7626 /* UnknownFields.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnknownFields.swift; sourceTree = ""; }; + 66CC52EF207F05E0EFAEACD8 /* NSMutableURLRequest+StripeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSMutableURLRequest+StripeTest.swift"; sourceTree = ""; }; + 6CDBCD70CB220014972B49A5 /* PluginDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginDetector.swift; sourceTree = ""; }; + 6E852B53CF75A119D3810B41 /* NSBundle+Stripe_AppName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSBundle+Stripe_AppName.swift"; sourceTree = ""; }; + 6EEB07003465364DBAFA7DEB /* zh-HK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-HK"; path = "zh-HK.lproj/Localizable.strings"; sourceTree = ""; }; + 6F9ED2ADD9406CF7534BB6C9 /* STPAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPAPIClient.swift; sourceTree = ""; }; + 73CE623A81057C4063A1E0C4 /* StripeFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeFile.swift; sourceTree = ""; }; + 7507497162F5684AEA59E301 /* String+StripeCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+StripeCore.swift"; sourceTree = ""; }; + 758B9C9C2252A91FE4221702 /* STPLocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPLocalizedString.swift; sourceTree = ""; }; + 762C486BE5DDF9B3BE7B3F45 /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = ""; }; + 7727AEEFD2FC880BADDA1872 /* Dictionary+Stripe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+Stripe.swift"; sourceTree = ""; }; + 77814365E9D13DD0A3EF0DCD /* ca-ES */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ca-ES"; path = "ca-ES.lproj/Localizable.strings"; sourceTree = ""; }; + 77CAB1A5AC107D8756CA1CBF /* Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Async.swift; sourceTree = ""; }; + 794AB67D466C8947FB4A7CFE /* fil */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fil; path = fil.lproj/Localizable.strings; sourceTree = ""; }; + 7B890F162E1C247D5CA1A9E6 /* AnalyticLoggableError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticLoggableError.swift; sourceTree = ""; }; + 7F2E1D80D0342CF09CB05415 /* URLEncoderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLEncoderTest.swift; sourceTree = ""; }; + 80C548FC21ABF10F4E88B0D0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 84E70193A9CA42A0C53E48C1 /* STPAPIClient+ErrorResponseTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPAPIClient+ErrorResponseTest.swift"; sourceTree = ""; }; + 86C675ABC9D68378DC699DED /* StripeJSONShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeJSONShared.swift; sourceTree = ""; }; + 8B3D6ADD516777DE13E79792 /* StripeJSONDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeJSONDecoder.swift; sourceTree = ""; }; + 8CB1E5F31B0941D8B096B360 /* test_image.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = test_image.png; sourceTree = ""; }; + 8D54A5C18C898B385629EB3D /* ms-MY */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ms-MY"; path = "ms-MY.lproj/Localizable.strings"; sourceTree = ""; }; + 8F2949BE5129DC4BBCD69B05 /* NSArray+StripeCoreTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSArray+StripeCoreTest.swift"; sourceTree = ""; }; + 8FF688F66CD047D08B3AE0CB /* String+Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Localized.swift"; sourceTree = ""; }; + 9077630E4A5307B83B4BA19E /* STPAppInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPAppInfo.swift; sourceTree = ""; }; + 918D27F150CD87E3A5E6F377 /* cs-CZ */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "cs-CZ"; path = "cs-CZ.lproj/Localizable.strings"; sourceTree = ""; }; + 937066801E91C99C50192364 /* BundleLocatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleLocatorProtocol.swift; sourceTree = ""; }; + 971D5A6F04E598041BE789EA /* fr-CA */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "fr-CA"; path = "fr-CA.lproj/Localizable.strings"; sourceTree = ""; }; + 9761FF113E3F940E2B2BEBB1 /* StripeiOS Tests-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS Tests-Debug.xcconfig"; sourceTree = ""; }; + 9C2AA21DAF340432CA98C67C /* STPDeviceUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPDeviceUtils.swift; sourceTree = ""; }; + 9D013DA69A308F6E0972B998 /* DownloadManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManagerTest.swift; sourceTree = ""; }; + 9D4EB9EA1128F9DA9120BB04 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + 9E415507B0152B07796114CC /* NSError+Stripe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSError+Stripe.swift"; sourceTree = ""; }; + A0FBC76F73C3791701072BBA /* sk-SK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sk-SK"; path = "sk-SK.lproj/Localizable.strings"; sourceTree = ""; }; + A4336F071B0CE13306C8EB93 /* StripeAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeAPI.swift; sourceTree = ""; }; + A466F6075DCC39D5A976FB22 /* STPTelemetryClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPTelemetryClient.swift; sourceTree = ""; }; + A4E4C534285F49A97A04D2B8 /* UIView+StripeCoreTestingUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+StripeCoreTestingUtils.swift"; sourceTree = ""; }; + A5334916D2A4F927645C2569 /* MockData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockData.swift; sourceTree = ""; }; + A619F83E71E5076329177CED /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; + A626F821ECEB25DFC007DB71 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; + A7647C1B64CA0F0087327413 /* STPURLCallbackHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPURLCallbackHandler.swift; sourceTree = ""; }; + AA435F92864CA975753865DF /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; + AAE1CFBD0403500A5DCBDE71 /* sl-SI */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sl-SI"; path = "sl-SI.lproj/Localizable.strings"; sourceTree = ""; }; + ABF11F814A986AA710410FF8 /* Analytic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Analytic.swift; sourceTree = ""; }; + ACBD856B96CD58D2938B3F02 /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Localizable.strings"; sourceTree = ""; }; + ACCAA72D98FBC827A38282BB /* el-GR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "el-GR"; path = "el-GR.lproj/Localizable.strings"; sourceTree = ""; }; + AD027F7695EC6F59CF32F4B9 /* StripeJSONEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeJSONEncoder.swift; sourceTree = ""; }; + AF1FC608B88ACEA4C7438866 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; + AF82F68E8D3A8286FD31DB13 /* APIStubbedTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIStubbedTestCase.swift; sourceTree = ""; }; + B3CA4B1855BF8D2F08F275A9 /* Error_SerializeForLoggingTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Error_SerializeForLoggingTest.swift; sourceTree = ""; }; + B3F73CA77397EAE70480FF25 /* et-EE */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "et-EE"; path = "et-EE.lproj/Localizable.strings"; sourceTree = ""; }; + B8B76840742D2CAF2931355A /* URLSession+Retry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSession+Retry.swift"; sourceTree = ""; }; + BC5151C3B6CB4130E9C259A6 /* NSCharacterSet+StripeCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSCharacterSet+StripeCore.swift"; sourceTree = ""; }; + BCF062352C38A5173260C46A /* StripeServiceError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeServiceError.swift; sourceTree = ""; }; + C188AD8AE244CD6076679095 /* ServerErrorMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerErrorMapper.swift; sourceTree = ""; }; + C1C174081A48DD86978D270D /* FileDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDownloader.swift; sourceTree = ""; }; + C3DCE66C04A91C235972687D /* StripeiOS-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS-Debug.xcconfig"; sourceTree = ""; }; + C51179DB520568C246BF3AF0 /* URLEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLEncoder.swift; sourceTree = ""; }; + C666CC926642D7AA76E75B5B /* StripeCoreTestUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StripeCoreTestUtils.h; sourceTree = ""; }; + CB2721EE8E075E700FF3E58A /* StripeiOS-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS-Release.xcconfig"; sourceTree = ""; }; + CC70CDF482E22A29B11466F7 /* STPAPIClient+EmptyResponseTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPAPIClient+EmptyResponseTest.swift"; sourceTree = ""; }; + CD9288E147B8C9D33CCB5045 /* pl-PL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pl-PL"; path = "pl-PL.lproj/Localizable.strings"; sourceTree = ""; }; + D0EF38A1DE6515748EC7515A /* STPSnapshotVerifyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPSnapshotVerifyView.swift; sourceTree = ""; }; + D28769EBA666E0BDFC69954F /* UIImage+StripeCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+StripeCoreTests.swift"; sourceTree = ""; }; + D475CD59778FAC1028670AB5 /* NSURLComponents+Stripe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSURLComponents+Stripe.swift"; sourceTree = ""; }; + D856053F99E32515DEDD8EDF /* STPNumericStringValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPNumericStringValidator.swift; sourceTree = ""; }; + DC24A98C4020646F99456187 /* File_IdentityDocument.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = File_IdentityDocument.json; sourceTree = ""; }; + DDFAA07C8EBB5F5A5AC00E54 /* Project-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Release.xcconfig"; sourceTree = ""; }; + DE5E5D17713B48931D84BF42 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; + DEA5353BC5359E08128E116A /* StripeCoreBundleLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeCoreBundleLocator.swift; sourceTree = ""; }; + E19F97D1606B517158C7F75A /* NetworkDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkDetector.swift; sourceTree = ""; }; + E1C72BA9C44FF60A0E7BEF76 /* STPMultipartFormDataPart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPMultipartFormDataPart.swift; sourceTree = ""; }; + E4B2A342F16484705840F1B5 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = ""; }; + E60F4A38EEF5EA11568B3A64 /* StripeCore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StripeCore.h; sourceTree = ""; }; + E919FBEB852CFEA9517FCBDC /* FraudDetectionData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FraudDetectionData.swift; sourceTree = ""; }; + EA55726A0FE74A4D90A10C01 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + ECF3D265DCDD0D64F6D7E6B2 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; + EDBE83430B8FE494EF2AB5F3 /* nn-NO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "nn-NO"; path = "nn-NO.lproj/Localizable.strings"; sourceTree = ""; }; + F2C7099CB854E1E90424235C /* MockAnalyticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAnalyticsClient.swift; sourceTree = ""; }; + F4FC2FC535DAF9B340F9EA75 /* STPAPIClient+FileUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "STPAPIClient+FileUpload.swift"; sourceTree = ""; }; + F66C883A10AA9BAB7456BF03 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; + FEC0AF5BF0A4799D2C0C7445 /* StripeError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeError.swift; sourceTree = ""; }; + FEC4F08F659CDD39ED1A2BAC /* lv-LV */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "lv-LV"; path = "lv-LV.lproj/Localizable.strings"; sourceTree = ""; }; + FF816C87A745BA4F9186BDF5 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; + FFCE8DD57B10BD3C56885EA5 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 36C2CC9FAA8CF079757D35A7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 87536D729B8201085E380C32 /* XCTest.framework in Frameworks */, + 917B4E193E5A1233F1A2E80E /* StripeCore.framework in Frameworks */, + 6B4156FCFAEDD1C73DC6EDAD /* iOSSnapshotTestCase in Frameworks */, + 40AF3B2EB6B82C9A4DD61033 /* OHHTTPStubs in Frameworks */, + CB1FB2383FAEE0194C39E4DE /* OHHTTPStubsSwift in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 4417A26336170F53277E168C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 08871FCD9E9E47681135431B /* XCTest.framework in Frameworks */, + 2D7A4FDBED7E3FA3D17BBB54 /* StripeCore.framework in Frameworks */, + 700E9DAD0407455F11F9F03E /* StripeCoreTestUtils.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BCAC06556AB210EEF302C16A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0B14FED7BBB8A76630B7C0D0 /* BuildConfigurations */ = { + isa = PBXGroup; + children = ( + 59EBF56CBE900B3EE80E73A5 /* Project-Debug.xcconfig */, + DDFAA07C8EBB5F5A5AC00E54 /* Project-Release.xcconfig */, + 9761FF113E3F940E2B2BEBB1 /* StripeiOS Tests-Debug.xcconfig */, + 33A34DF206B0980BA1D2258F /* StripeiOS Tests-Release.xcconfig */, + C3DCE66C04A91C235972687D /* StripeiOS-Debug.xcconfig */, + CB2721EE8E075E700FF3E58A /* StripeiOS-Release.xcconfig */, + ); + name = BuildConfigurations; + path = ../BuildConfigurations; + sourceTree = ""; + }; + 150799C744EFBDF77919FDCB /* Products */ = { + isa = PBXGroup; + children = ( + 40A77DE176741B3A542FE890 /* StripeCore.framework */, + 013DF1F8EC4A4FFA1A38B725 /* StripeCoreTests.xctest */, + 3A4F598BF353D8335B0340D5 /* StripeCoreTestUtils.framework */, + ); + name = Products; + sourceTree = ""; + }; + 1726B28CF173B9D1D6426896 /* Categories */ = { + isa = PBXGroup; + children = ( + 1678E0A16D46DA2B4D3B3ECD /* UIImage+StripeCoreTestingUtils.swift */, + A4E4C534285F49A97A04D2B8 /* UIView+StripeCoreTestingUtils.swift */, + ); + path = Categories; + sourceTree = ""; + }; + 29DB7151F72CD0D44389A6C8 /* Helpers */ = { + isa = PBXGroup; + children = ( + 7F2E1D80D0342CF09CB05415 /* URLEncoderTest.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 2D68CA76F251A29A4F5AD031 /* DownloadManager */ = { + isa = PBXGroup; + children = ( + 762C486BE5DDF9B3BE7B3F45 /* DownloadManager.swift */, + ); + path = DownloadManager; + sourceTree = ""; + }; + 3A3F265C90373C3EF2F61523 /* Categories */ = { + isa = PBXGroup; + children = ( + 34895253739089BC125D2625 /* Dictionary+StripeTests.swift */, + 8F2949BE5129DC4BBCD69B05 /* NSArray+StripeCoreTest.swift */, + 66CC52EF207F05E0EFAEACD8 /* NSMutableURLRequest+StripeTest.swift */, + D28769EBA666E0BDFC69954F /* UIImage+StripeCoreTests.swift */, + ); + path = Categories; + sourceTree = ""; + }; + 4A2CD3EB30A5C54998ED92DD /* Mock Files */ = { + isa = PBXGroup; + children = ( + 8CB1E5F31B0941D8B096B360 /* test_image.png */, + ); + path = "Mock Files"; + sourceTree = ""; + }; + 4AD775367138C40FE2C98BAF /* Connections Bindings */ = { + isa = PBXGroup; + children = ( + 4EC3BCEEECB3E1485B18F0C4 /* ConnectionsSDKInterface.swift */, + ); + path = "Connections Bindings"; + sourceTree = ""; + }; + 4D7125C790190CE509BBA3DF /* UI */ = { + isa = PBXGroup; + children = ( + 599C8A64EB3988BCE32E2B05 /* UIActivityIndicatorView+Stripe.swift */, + 5EAF12F161469C070B822369 /* UIFont+Stripe.swift */, + ); + path = UI; + sourceTree = ""; + }; + 523A1ACB7F366E117F7FE2B4 /* Analytics */ = { + isa = PBXGroup; + children = ( + 440D8DFB25A1F7FBC01BE1D7 /* AnalyticsClientV2Test.swift */, + B3CA4B1855BF8D2F08F275A9 /* Error_SerializeForLoggingTest.swift */, + 2B16FDA9232BD4FBD4B13EC2 /* STPAnalyticsClientTest.swift */, + ); + path = Analytics; + sourceTree = ""; + }; + 5308E014F5451516D5EF5314 /* StripeCoreTestUtils */ = { + isa = PBXGroup; + children = ( + 1726B28CF173B9D1D6426896 /* Categories */, + B761C6E8D9BC02D59DBE5A38 /* Mock Files */, + BECA727F3CB319C263B09720 /* Mocks */, + AF82F68E8D3A8286FD31DB13 /* APIStubbedTestCase.swift */, + 58D2EB990E533C5D42635676 /* Info.plist */, + 561E5F74D9E5676F6E72E93F /* KeyPathExpectation.swift */, + D0EF38A1DE6515748EC7515A /* STPSnapshotVerifyView.swift */, + C666CC926642D7AA76E75B5B /* StripeCoreTestUtils.h */, + E4B2A342F16484705840F1B5 /* TestConstants.swift */, + 2B75708617FB765AB211FD9A /* URLRequest+StripeTest.swift */, + 2DE48D0086BED21F9E837D0B /* XCTestCase+Stripe.swift */, + ); + path = StripeCoreTestUtils; + sourceTree = ""; + }; + 596D621A8083DA1246F36BFB /* StripeCore */ = { + isa = PBXGroup; + children = ( + D053330294A0C39AE271E025 /* Resources */, + A42B59A6A0D70019833D6562 /* Source */, + 4689F6B4384244D9FD282560 /* Info.plist */, + E60F4A38EEF5EA11568B3A64 /* StripeCore.h */, + ); + path = StripeCore; + sourceTree = ""; + }; + 59A858862FE8D4B90A5D930F /* Categories */ = { + isa = PBXGroup; + children = ( + 06A516593E48DCFD5D98D702 /* Decimal+StripeCore.swift */, + 7727AEEFD2FC880BADDA1872 /* Dictionary+Stripe.swift */, + 4A8030BF88608CA86E295F18 /* Enums+CustomStringConvertible.swift */, + 21608B7BC24190523363CB3D /* NSArray+Stripe.swift */, + 6E852B53CF75A119D3810B41 /* NSBundle+Stripe_AppName.swift */, + BC5151C3B6CB4130E9C259A6 /* NSCharacterSet+StripeCore.swift */, + 9E415507B0152B07796114CC /* NSError+Stripe.swift */, + 23506F3E93ECA5A96DCE7E31 /* NSError+StripeCore.swift */, + 2BD3D02EBA8A732A9C832925 /* NSMutableURLRequest+Stripe.swift */, + D475CD59778FAC1028670AB5 /* NSURLComponents+Stripe.swift */, + 7507497162F5684AEA59E301 /* String+StripeCore.swift */, + 45D4100901F9445AC1FD453A /* UIImage+StripeCore.swift */, + ); + path = Categories; + sourceTree = ""; + }; + 66BA4CFF0FEE2E4813FB4D31 /* Analytics */ = { + isa = PBXGroup; + children = ( + ABF11F814A986AA710410FF8 /* Analytic.swift */, + 7B890F162E1C247D5CA1A9E6 /* AnalyticLoggableError.swift */, + 192E71FE64C5FDE929992CC4 /* AnalyticsClientV2.swift */, + E19F97D1606B517158C7F75A /* NetworkDetector.swift */, + 6CDBCD70CB220014972B49A5 /* PluginDetector.swift */, + 0B6C28853EF0316366FB8DC4 /* STPAnalyticEvent.swift */, + 1DD5B6F1152FD1C4A508A103 /* STPAnalyticsClient.swift */, + ); + path = Analytics; + sourceTree = ""; + }; + 69F23304563CCD7F2625E757 /* Localization */ = { + isa = PBXGroup; + children = ( + 325E8108336F1178A10D139C /* STPLocalizationUtils.swift */, + 758B9C9C2252A91FE4221702 /* STPLocalizedString.swift */, + 8FF688F66CD047D08B3AE0CB /* String+Localized.swift */, + ); + path = Localization; + sourceTree = ""; + }; + 6D488D8BF0ADC365242F73C8 /* API Bindings */ = { + isa = PBXGroup; + children = ( + C2430C1558B85CB4E87334A9 /* Models */, + 6F9ED2ADD9406CF7534BB6C9 /* STPAPIClient.swift */, + F4FC2FC535DAF9B340F9EA75 /* STPAPIClient+FileUpload.swift */, + 9077630E4A5307B83B4BA19E /* STPAppInfo.swift */, + 34D803C2F907529E780B0296 /* STPMultipartFormDataEncoder.swift */, + E1C72BA9C44FF60A0E7BEF76 /* STPMultipartFormDataPart.swift */, + A4336F071B0CE13306C8EB93 /* StripeAPI.swift */, + 32CB3702691056D3404A8C5F /* StripeAPIConfiguration.swift */, + 2F1BFF01B1EA57F6EA7BFBF1 /* StripeAPIConfiguration+Version.swift */, + FEC0AF5BF0A4799D2C0C7445 /* StripeError.swift */, + BCF062352C38A5173260C46A /* StripeServiceError.swift */, + ); + path = "API Bindings"; + sourceTree = ""; + }; + 705ECC4F6AE12F0021C3726D = { + isa = PBXGroup; + children = ( + 92F3912D23FF91835DDAFCEC /* Project */, + D8A0B2C221DBF456254B6A02 /* Frameworks */, + 150799C744EFBDF77919FDCB /* Products */, + ); + sourceTree = ""; + }; + 7FD20B12B83EC2F371B71C37 /* API Bindings */ = { + isa = PBXGroup; + children = ( + CC70CDF482E22A29B11466F7 /* STPAPIClient+EmptyResponseTest.swift */, + 84E70193A9CA42A0C53E48C1 /* STPAPIClient+ErrorResponseTest.swift */, + 1FCE36551600C3E53BEAF8F0 /* StripeCodableTest.swift */, + ); + path = "API Bindings"; + sourceTree = ""; + }; + 83EEC663D4FA92DC540C6CD8 /* Helpers */ = { + isa = PBXGroup; + children = ( + 77CAB1A5AC107D8756CA1CBF /* Async.swift */, + 937066801E91C99C50192364 /* BundleLocatorProtocol.swift */, + C1C174081A48DD86978D270D /* FileDownloader.swift */, + 21E64986F72C7BD8B1105A95 /* InstallMethod.swift */, + 3FFB15A6996F610D627A42B3 /* PaymentsSDKVariant.swift */, + C188AD8AE244CD6076679095 /* ServerErrorMapper.swift */, + 9C2AA21DAF340432CA98C67C /* STPDeviceUtils.swift */, + 591E592C9F3E5D4CB08A1847 /* STPDispatchFunctions.swift */, + 4C51E3FA5EE3587BB7BBC634 /* STPError.swift */, + D856053F99E32515DEDD8EDF /* STPNumericStringValidator.swift */, + A7647C1B64CA0F0087327413 /* STPURLCallbackHandler.swift */, + DEA5353BC5359E08128E116A /* StripeCoreBundleLocator.swift */, + C51179DB520568C246BF3AF0 /* URLEncoder.swift */, + B8B76840742D2CAF2931355A /* URLSession+Retry.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 8801864DFEC2305B2D7D9CB1 /* Localizations */ = { + isa = PBXGroup; + children = ( + CFF68ABB8E67E83695FAD8EA /* Localizable.strings */, + ); + path = Localizations; + sourceTree = ""; + }; + 92F3912D23FF91835DDAFCEC /* Project */ = { + isa = PBXGroup; + children = ( + 0B14FED7BBB8A76630B7C0D0 /* BuildConfigurations */, + 596D621A8083DA1246F36BFB /* StripeCore */, + 9EF2F5FC20874E0DD2A5D40C /* StripeCoreTests */, + 5308E014F5451516D5EF5314 /* StripeCoreTestUtils */, + ); + name = Project; + sourceTree = ""; + }; + 9529F0918C42664A7702489F /* DownloadManager */ = { + isa = PBXGroup; + children = ( + 9D013DA69A308F6E0972B998 /* DownloadManagerTest.swift */, + ); + path = DownloadManager; + sourceTree = ""; + }; + 9EF2F5FC20874E0DD2A5D40C /* StripeCoreTests */ = { + isa = PBXGroup; + children = ( + 523A1ACB7F366E117F7FE2B4 /* Analytics */, + 7FD20B12B83EC2F371B71C37 /* API Bindings */, + 3A3F265C90373C3EF2F61523 /* Categories */, + 9529F0918C42664A7702489F /* DownloadManager */, + D2E220B8DFC490E1837426FF /* External */, + 29DB7151F72CD0D44389A6C8 /* Helpers */, + 4A2CD3EB30A5C54998ED92DD /* Mock Files */, + EA55726A0FE74A4D90A10C01 /* Info.plist */, + ); + path = StripeCoreTests; + sourceTree = ""; + }; + A42B59A6A0D70019833D6562 /* Source */ = { + isa = PBXGroup; + children = ( + 66BA4CFF0FEE2E4813FB4D31 /* Analytics */, + 6D488D8BF0ADC365242F73C8 /* API Bindings */, + 59A858862FE8D4B90A5D930F /* Categories */, + E701DB3E90EB6EA0A1A8B54E /* Coder */, + 4AD775367138C40FE2C98BAF /* Connections Bindings */, + 2D68CA76F251A29A4F5AD031 /* DownloadManager */, + 83EEC663D4FA92DC540C6CD8 /* Helpers */, + 69F23304563CCD7F2625E757 /* Localization */, + CDC187FE4F4024F9D463D19E /* Telemetry */, + 4D7125C790190CE509BBA3DF /* UI */, + ); + path = Source; + sourceTree = ""; + }; + B761C6E8D9BC02D59DBE5A38 /* Mock Files */ = { + isa = PBXGroup; + children = ( + DC24A98C4020646F99456187 /* File_IdentityDocument.json */, + ); + path = "Mock Files"; + sourceTree = ""; + }; + BECA727F3CB319C263B09720 /* Mocks */ = { + isa = PBXGroup; + children = ( + F2C7099CB854E1E90424235C /* MockAnalyticsClient.swift */, + 27EB57C159E5F10F50D071E5 /* MockAnalyticsClientV2.swift */, + A5334916D2A4F927645C2569 /* MockData.swift */, + ); + path = Mocks; + sourceTree = ""; + }; + C2430C1558B85CB4E87334A9 /* Models */ = { + isa = PBXGroup; + children = ( + 00D8E07F0DDBA1577A52156C /* EmptyResponse.swift */, + 73CE623A81057C4063A1E0C4 /* StripeFile.swift */, + ); + path = Models; + sourceTree = ""; + }; + CDC187FE4F4024F9D463D19E /* Telemetry */ = { + isa = PBXGroup; + children = ( + E919FBEB852CFEA9517FCBDC /* FraudDetectionData.swift */, + A466F6075DCC39D5A976FB22 /* STPTelemetryClient.swift */, + 45F11FF08733CF61D880640D /* UserDefaults+PaymentsCore.swift */, + ); + path = Telemetry; + sourceTree = ""; + }; + D053330294A0C39AE271E025 /* Resources */ = { + isa = PBXGroup; + children = ( + 8801864DFEC2305B2D7D9CB1 /* Localizations */, + ); + path = Resources; + sourceTree = ""; + }; + D2E220B8DFC490E1837426FF /* External */ = { + isa = PBXGroup; + children = ( + 4FF290DF69F5FD1004BBDECA /* TestJSONEncoder.swift */, + ); + path = External; + sourceTree = ""; + }; + D8A0B2C221DBF456254B6A02 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 54D4E87D67740BF3C05638FD /* XCTest.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + E701DB3E90EB6EA0A1A8B54E /* Coder */ = { + isa = PBXGroup; + children = ( + 49424775D3233411D9C2473B /* StripeCodable.swift */, + 8B3D6ADD516777DE13E79792 /* StripeJSONDecoder.swift */, + AD027F7695EC6F59CF32F4B9 /* StripeJSONEncoder.swift */, + 86C675ABC9D68378DC699DED /* StripeJSONShared.swift */, + 64635404BD4D5D62486A7626 /* UnknownFields.swift */, + ); + path = Coder; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 05A892536A440B2BC219C11E /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 8310D598D6D40BAD23880D3F /* StripeCoreTestUtils.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6D58026052EFE4DFDBDD359E /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 5E807567D7320A7D512127AF /* StripeCore.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 03B50F59824D1D18AA073D57 /* StripeCoreTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5BFC042D8F3E5E305D374495 /* Build configuration list for PBXNativeTarget "StripeCoreTests" */; + buildPhases = ( + 7E01B8C349DCABCBA8852FF2 /* Sources */, + C6C0F33F30CA8B01DF9A723B /* Resources */, + 18178F9338B4379A125C292F /* Embed Frameworks */, + 4417A26336170F53277E168C /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 1EC7711EFEF07D6C119D7A70 /* PBXTargetDependency */, + 3670D483B4DC94B06A1CDEF6 /* PBXTargetDependency */, + ); + name = StripeCoreTests; + productName = StripeCoreTests; + productReference = 013DF1F8EC4A4FFA1A38B725 /* StripeCoreTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 3E987A4F06C4DE962E9F3CB8 /* StripeCoreTestUtils */ = { + isa = PBXNativeTarget; + buildConfigurationList = 793D3D10C90D10C84728C1DE /* Build configuration list for PBXNativeTarget "StripeCoreTestUtils" */; + buildPhases = ( + 05A892536A440B2BC219C11E /* Headers */, + FB221D4CA3C9AF254BA94D68 /* Sources */, + A6E0A12258BAD6C06BF7A2AE /* Resources */, + 5F3E583CF98B20ACEDBC39ED /* Embed Frameworks */, + 36C2CC9FAA8CF079757D35A7 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 08EA430A5FF96F1A708DE57B /* PBXTargetDependency */, + ); + name = StripeCoreTestUtils; + packageProductDependencies = ( + 23D22B2C40BA7C182BCE50B2 /* iOSSnapshotTestCase */, + 9AB19C81E50535A3407582B7 /* OHHTTPStubs */, + E86AC7DD5F4DE2780E0AC425 /* OHHTTPStubsSwift */, + ); + productName = StripeCoreTestUtils; + productReference = 3A4F598BF353D8335B0340D5 /* StripeCoreTestUtils.framework */; + productType = "com.apple.product-type.framework"; + }; + 7877B31445857B119EA45445 /* StripeCore */ = { + isa = PBXNativeTarget; + buildConfigurationList = A8F477F78CC20A59C791D5F1 /* Build configuration list for PBXNativeTarget "StripeCore" */; + buildPhases = ( + 6D58026052EFE4DFDBDD359E /* Headers */, + 97036C8C1B79423F1F57DD1B /* Sources */, + 5442591D82093A8B0397F70D /* Resources */, + 5D04C08A37B43A01ADAAA114 /* Embed Frameworks */, + BCAC06556AB210EEF302C16A /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = StripeCore; + productName = StripeCore; + productReference = 40A77DE176741B3A542FE890 /* StripeCore.framework */; + productType = "com.apple.product-type.framework"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + CBF07079FA432D2BFCE24022 /* Project object */ = { + isa = PBXProject; + attributes = { + TargetAttributes = { + }; + }; + buildConfigurationList = 490BD7E6715C734C2A99C55B /* Build configuration list for PBXProject "StripeCore" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + "bg-BG", + "ca-ES", + "cs-CZ", + da, + de, + "el-GR", + en, + "en-GB", + es, + "es-419", + "et-EE", + fi, + fil, + fr, + "fr-CA", + hr, + hu, + id, + it, + ja, + ko, + "lt-LT", + "lv-LV", + "ms-MY", + mt, + nb, + nl, + "nn-NO", + "pl-PL", + "pt-BR", + "pt-PT", + "ro-RO", + ru, + "sk-SK", + "sl-SI", + sv, + tr, + vi, + "zh-HK", + "zh-Hans", + "zh-Hant", + ); + mainGroup = 705ECC4F6AE12F0021C3726D; + packageReferences = ( + 9F8CE18BDCF6289B6266E92D /* XCRemoteSwiftPackageReference "OHHTTPStubs" */, + 1E364BF0E57ED61D2A87D929 /* XCRemoteSwiftPackageReference "ios-snapshot-test-case" */, + ); + productRefGroup = 150799C744EFBDF77919FDCB /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7877B31445857B119EA45445 /* StripeCore */, + 3E987A4F06C4DE962E9F3CB8 /* StripeCoreTestUtils */, + 03B50F59824D1D18AA073D57 /* StripeCoreTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 5442591D82093A8B0397F70D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2B98F4F0120888B12EF3B181 /* Localizable.strings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A6E0A12258BAD6C06BF7A2AE /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C9C320ADCCF1548D6562CE94 /* File_IdentityDocument.json in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C6C0F33F30CA8B01DF9A723B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FCACE815CE9073F3FE18C185 /* test_image.png in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 7E01B8C349DCABCBA8852FF2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A4DBBFD379C4E0120BD25C56 /* STPAPIClient+EmptyResponseTest.swift in Sources */, + 79DA4102C501FC2E53D946B5 /* STPAPIClient+ErrorResponseTest.swift in Sources */, + 53D46A03B77577EE21F4B166 /* StripeCodableTest.swift in Sources */, + 3E9FC2CD06E1D5F6B09872E9 /* AnalyticsClientV2Test.swift in Sources */, + 84487D8E9B08106C89753536 /* Error_SerializeForLoggingTest.swift in Sources */, + 0A78AD04075C43A4059C344E /* STPAnalyticsClientTest.swift in Sources */, + 934CCB00769674F13192A126 /* Dictionary+StripeTests.swift in Sources */, + A50CB2ACAC1DCF9539D76F25 /* NSArray+StripeCoreTest.swift in Sources */, + F5DB5D52E2668136FF6D70D6 /* NSMutableURLRequest+StripeTest.swift in Sources */, + 8AD68C8D00A0BCF94E5230DC /* UIImage+StripeCoreTests.swift in Sources */, + 9504F199974F5AF7931A8485 /* DownloadManagerTest.swift in Sources */, + 6D68B868938BAB15A843B33C /* TestJSONEncoder.swift in Sources */, + EFF90360C85642F7F2898186 /* URLEncoderTest.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97036C8C1B79423F1F57DD1B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 87274985CE5E750FA8D34648 /* EmptyResponse.swift in Sources */, + 6A52ABC06783A90B9E339948 /* StripeFile.swift in Sources */, + 9FBA50345D53E82AA974F672 /* STPAPIClient+FileUpload.swift in Sources */, + C164984958CDC2C9CA4B6316 /* STPAPIClient.swift in Sources */, + 97D0B2120678A75F75648D84 /* STPAppInfo.swift in Sources */, + 12FF091C555F75B914464475 /* STPMultipartFormDataEncoder.swift in Sources */, + 552DA7969984C443617DBC3E /* STPMultipartFormDataPart.swift in Sources */, + DAD4099D03E43A0CA89464CD /* StripeAPI.swift in Sources */, + D22FAB2F1AE9AE43C1808747 /* StripeAPIConfiguration+Version.swift in Sources */, + 677951C643328D76E46720A5 /* StripeAPIConfiguration.swift in Sources */, + 02A26B79617FAE660C9EB506 /* StripeError.swift in Sources */, + 3B9D69AB1CB61725C7A012B6 /* StripeServiceError.swift in Sources */, + 95156E152471058151076A51 /* Analytic.swift in Sources */, + 766FE8E61B44967F057ED424 /* AnalyticLoggableError.swift in Sources */, + 3815D229613D52D7799805B0 /* AnalyticsClientV2.swift in Sources */, + 35931C64F06BEB233A219869 /* NetworkDetector.swift in Sources */, + A454F368C505B2D80F0D8B19 /* PluginDetector.swift in Sources */, + E2B25D45D457A76A782D9089 /* STPAnalyticEvent.swift in Sources */, + AE26BFFBE9B1242E10CA052F /* STPAnalyticsClient.swift in Sources */, + B35FD03DD246CC4DD6C4F3C0 /* Decimal+StripeCore.swift in Sources */, + 4B2FAC57E03D8654A177C408 /* Dictionary+Stripe.swift in Sources */, + 42DE35681C71A931F65E0E7D /* Enums+CustomStringConvertible.swift in Sources */, + D8FEF16798A791F5BF7EA4B2 /* NSArray+Stripe.swift in Sources */, + C318A6B6CD599B06DA7CE706 /* NSBundle+Stripe_AppName.swift in Sources */, + 2AA9B01C8A2D2BADC4619629 /* NSCharacterSet+StripeCore.swift in Sources */, + C5BF4B8AE85FF72EC6382EC0 /* NSError+Stripe.swift in Sources */, + D144C3A657E5C16975CB2191 /* NSError+StripeCore.swift in Sources */, + 9843D9B7D886C373C7AC71E4 /* NSMutableURLRequest+Stripe.swift in Sources */, + 9DCBC08C182ED76A962961E7 /* NSURLComponents+Stripe.swift in Sources */, + 563A42FA383FA9AA5FA4CDCE /* String+StripeCore.swift in Sources */, + CA09DC1EC4142701B31F9673 /* UIImage+StripeCore.swift in Sources */, + C7C0EC68130760F73201F81B /* StripeCodable.swift in Sources */, + 44DE84C8BFB403E1FB7E2E82 /* StripeJSONDecoder.swift in Sources */, + 970D95FEA3BC216351DE3C5E /* StripeJSONEncoder.swift in Sources */, + 71CD1AE29AA09552DF61131E /* StripeJSONShared.swift in Sources */, + 6ED5C41DBDAB475BF1119E98 /* UnknownFields.swift in Sources */, + 772292156A4A80CEA9D9C487 /* ConnectionsSDKInterface.swift in Sources */, + 3F275B08EB554772F2FE4E4E /* DownloadManager.swift in Sources */, + 5553D952F91D193D453D777D /* Async.swift in Sources */, + CAF857D45689FBEF17627E80 /* BundleLocatorProtocol.swift in Sources */, + 59CA874015261241AC255907 /* FileDownloader.swift in Sources */, + 45DAE581F74EF7E11C64212B /* InstallMethod.swift in Sources */, + 096274D0729AA8849FAD103C /* PaymentsSDKVariant.swift in Sources */, + DA5A05459309B9B77ACDD736 /* STPDeviceUtils.swift in Sources */, + 83790210FFC2DD764C042C8E /* STPDispatchFunctions.swift in Sources */, + 72DA29CA8A750E8B00DBF3D4 /* STPError.swift in Sources */, + F628BBE9FDA9D3A217ACA753 /* STPNumericStringValidator.swift in Sources */, + C9B6C451F9A46C9FB031CE95 /* STPURLCallbackHandler.swift in Sources */, + A62AEDF871AC89489FE19A13 /* ServerErrorMapper.swift in Sources */, + 62FD088E003BE06F5413FB4F /* StripeCoreBundleLocator.swift in Sources */, + 17CE96B50813CF626293CBF9 /* URLEncoder.swift in Sources */, + 0709F5D265CC641E6DE1011D /* URLSession+Retry.swift in Sources */, + 2991461DD354A6124CCF78DA /* STPLocalizationUtils.swift in Sources */, + 4506A7016EA7C45796D3A30D /* STPLocalizedString.swift in Sources */, + DFF3092E51B6C3ED81AB1448 /* String+Localized.swift in Sources */, + 89DB623A200678B4E9845AF2 /* FraudDetectionData.swift in Sources */, + 920832EE256E377572DD41EB /* STPTelemetryClient.swift in Sources */, + 3B27DDDDC91F1599BF1469BB /* UserDefaults+PaymentsCore.swift in Sources */, + B6D129B2DC90FA1F8A1F5BCB /* UIActivityIndicatorView+Stripe.swift in Sources */, + DF1EC524A1915E344687F5AC /* UIFont+Stripe.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FB221D4CA3C9AF254BA94D68 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 330FDCF901D11882D4866DDE /* APIStubbedTestCase.swift in Sources */, + A987DFFA404EBD0788DAB21A /* UIImage+StripeCoreTestingUtils.swift in Sources */, + 631D09E67497B49BBCA26192 /* UIView+StripeCoreTestingUtils.swift in Sources */, + 838BDB53C4F6AC3C0567DE6A /* KeyPathExpectation.swift in Sources */, + 0F4A1BAE6774B90C72F578CC /* MockAnalyticsClient.swift in Sources */, + 3D90376B1883E4BE64712197 /* MockAnalyticsClientV2.swift in Sources */, + CB8A47A5FD057112CB607DE9 /* MockData.swift in Sources */, + FF5B9F1861A1DB4C2605BBD8 /* STPSnapshotVerifyView.swift in Sources */, + E344C20A07D8B8F33B530974 /* TestConstants.swift in Sources */, + EFE476BA387E91BE1D5D3E1D /* URLRequest+StripeTest.swift in Sources */, + 48A6CCB4008A5060C2655C5F /* XCTestCase+Stripe.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 08EA430A5FF96F1A708DE57B /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = StripeCore; + target = 7877B31445857B119EA45445 /* StripeCore */; + targetProxy = 7FEF9BDDDD738CE997EC053B /* PBXContainerItemProxy */; + }; + 1EC7711EFEF07D6C119D7A70 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = StripeCore; + target = 7877B31445857B119EA45445 /* StripeCore */; + targetProxy = E1EC5611DA9FE94C4E0EF3A8 /* PBXContainerItemProxy */; + }; + 3670D483B4DC94B06A1CDEF6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = StripeCoreTestUtils; + target = 3E987A4F06C4DE962E9F3CB8 /* StripeCoreTestUtils */; + targetProxy = 773BD5B9B4846D879C13CFE1 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + CFF68ABB8E67E83695FAD8EA /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 181E67908573FC358CCED4BF /* bg-BG */, + 77814365E9D13DD0A3EF0DCD /* ca-ES */, + 918D27F150CD87E3A5E6F377 /* cs-CZ */, + 31402722B97FC2D9A3A74E73 /* da */, + DE5E5D17713B48931D84BF42 /* de */, + ACCAA72D98FBC827A38282BB /* el-GR */, + 80C548FC21ABF10F4E88B0D0 /* en */, + 536085BA191EC2942523A7DB /* en-GB */, + 02D48D61A84BE5788EA61E59 /* es */, + 3FECC037162676AF2E8DCAEC /* es-419 */, + B3F73CA77397EAE70480FF25 /* et-EE */, + FFCE8DD57B10BD3C56885EA5 /* fi */, + 794AB67D466C8947FB4A7CFE /* fil */, + 48A3D6592296104A1512AE92 /* fr */, + 971D5A6F04E598041BE789EA /* fr-CA */, + 40A497A121A7C792624E7948 /* hr */, + ECF3D265DCDD0D64F6D7E6B2 /* hu */, + A619F83E71E5076329177CED /* id */, + 303695D2ECDFFCA9C1B68E53 /* it */, + A626F821ECEB25DFC007DB71 /* ja */, + 160FD8F504CCAC864C27AA62 /* ko */, + 43D9EC920BB9059E0C652178 /* lt-LT */, + FEC4F08F659CDD39ED1A2BAC /* lv-LV */, + 8D54A5C18C898B385629EB3D /* ms-MY */, + 5CE5D62D8BA864BFB38B70C5 /* mt */, + 0ACE2C3A8889C655E58EEA67 /* nb */, + 49538DBF8457D96707A2DA56 /* nl */, + EDBE83430B8FE494EF2AB5F3 /* nn-NO */, + CD9288E147B8C9D33CCB5045 /* pl-PL */, + 019EC578C08FE61F858E1F1F /* pt-BR */, + ACBD856B96CD58D2938B3F02 /* pt-PT */, + 625636EFF4844186C7A31FAF /* ro-RO */, + 9D4EB9EA1128F9DA9120BB04 /* ru */, + A0FBC76F73C3791701072BBA /* sk-SK */, + AAE1CFBD0403500A5DCBDE71 /* sl-SI */, + 2147B1CA0A1B65566D7AB7C6 /* sv */, + FF816C87A745BA4F9186BDF5 /* tr */, + F66C883A10AA9BAB7456BF03 /* vi */, + AA435F92864CA975753865DF /* zh-Hans */, + AF1FC608B88ACEA4C7438866 /* zh-Hant */, + 6EEB07003465364DBAFA7DEB /* zh-HK */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 1F32ABF11A7EE50134DB4EF3 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = DDFAA07C8EBB5F5A5AC00E54 /* Project-Release.xcconfig */; + buildSettings = { + }; + name = Release; + }; + 406CE0D780344B0E0C1C3FD3 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33A34DF206B0980BA1D2258F /* StripeiOS Tests-Release.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeCoreTestUtils/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeCoreTestUtils; + PRODUCT_NAME = StripeCoreTestUtils; + SDKROOT = iphoneos; + }; + name = Release; + }; + 5B5D93072F435361816D6E2D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = CB2721EE8E075E700FF3E58A /* StripeiOS-Release.xcconfig */; + buildSettings = { + INFOPLIST_FILE = StripeCore/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "com.stripe.stripe-core"; + PRODUCT_NAME = StripeCore; + SDKROOT = iphoneos; + }; + name = Release; + }; + 63A2A6AAA9584841EC2CE54A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9761FF113E3F940E2B2BEBB1 /* StripeiOS Tests-Debug.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeCoreTests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeCoreTests; + PRODUCT_NAME = StripeCoreTests; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 78054EDDE3060C858086FA90 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = C3DCE66C04A91C235972687D /* StripeiOS-Debug.xcconfig */; + buildSettings = { + INFOPLIST_FILE = StripeCore/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "com.stripe.stripe-core"; + PRODUCT_NAME = StripeCore; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 97012C251E738AC10133C8EE /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 59EBF56CBE900B3EE80E73A5 /* Project-Debug.xcconfig */; + buildSettings = { + }; + name = Debug; + }; + A8FBA9F7BAC07A05CC29D4E6 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9761FF113E3F940E2B2BEBB1 /* StripeiOS Tests-Debug.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeCoreTestUtils/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeCoreTestUtils; + PRODUCT_NAME = StripeCoreTestUtils; + SDKROOT = iphoneos; + }; + name = Debug; + }; + DDEA9A9EE069588F2D4E29B2 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33A34DF206B0980BA1D2258F /* StripeiOS Tests-Release.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeCoreTests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeCoreTests; + PRODUCT_NAME = StripeCoreTests; + SDKROOT = iphoneos; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 490BD7E6715C734C2A99C55B /* Build configuration list for PBXProject "StripeCore" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97012C251E738AC10133C8EE /* Debug */, + 1F32ABF11A7EE50134DB4EF3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5BFC042D8F3E5E305D374495 /* Build configuration list for PBXNativeTarget "StripeCoreTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 63A2A6AAA9584841EC2CE54A /* Debug */, + DDEA9A9EE069588F2D4E29B2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 793D3D10C90D10C84728C1DE /* Build configuration list for PBXNativeTarget "StripeCoreTestUtils" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A8FBA9F7BAC07A05CC29D4E6 /* Debug */, + 406CE0D780344B0E0C1C3FD3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A8F477F78CC20A59C791D5F1 /* Build configuration list for PBXNativeTarget "StripeCore" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 78054EDDE3060C858086FA90 /* Debug */, + 5B5D93072F435361816D6E2D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 1E364BF0E57ED61D2A87D929 /* XCRemoteSwiftPackageReference "ios-snapshot-test-case" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/uber/ios-snapshot-test-case"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.0.0; + }; + }; + 9F8CE18BDCF6289B6266E92D /* XCRemoteSwiftPackageReference "OHHTTPStubs" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/eurias-stripe/OHHTTPStubs"; + requirement = { + branch = master; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 23D22B2C40BA7C182BCE50B2 /* iOSSnapshotTestCase */ = { + isa = XCSwiftPackageProductDependency; + productName = iOSSnapshotTestCase; + }; + 9AB19C81E50535A3407582B7 /* OHHTTPStubs */ = { + isa = XCSwiftPackageProductDependency; + productName = OHHTTPStubs; + }; + E86AC7DD5F4DE2780E0AC425 /* OHHTTPStubsSwift */ = { + isa = XCSwiftPackageProductDependency; + productName = OHHTTPStubsSwift; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = CBF07079FA432D2BFCE24022 /* Project object */; +} diff --git a/StripeCore/StripeCore.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/StripeCore/StripeCore.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/StripeCore/StripeCore.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/StripeCore/StripeCore.xcodeproj/xcshareddata/xcschemes/StripeCore.xcscheme b/StripeCore/StripeCore.xcodeproj/xcshareddata/xcschemes/StripeCore.xcscheme new file mode 100644 index 00000000..6fbc12e5 --- /dev/null +++ b/StripeCore/StripeCore.xcodeproj/xcshareddata/xcschemes/StripeCore.xcscheme @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StripeCore/StripeCore/Info.plist b/StripeCore/StripeCore/Info.plist new file mode 100644 index 00000000..cd4a496b --- /dev/null +++ b/StripeCore/StripeCore/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(CURRENT_PROJECT_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/StripeCore/StripeCore/Resources/Localizations/bg-BG.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/bg-BG.lproj/Localizable.strings new file mode 100644 index 00000000..584a0a39 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/bg-BG.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Затваряне"; + +"Scan Card" = "Сканиране на картата"; + +"Scan card" = "Сканиране на карта"; + +"The IBAN you entered is invalid." = "IBAN, който сте въвели, е невалиден."; + +"There was an error processing your card -- try again in a few seconds" = "Възникна грешка при обработката на Вашата карта -- опитайте отново след няколко секунди"; + +"There was an unexpected error -- try again in a few seconds" = "Това беше неочаквана грешка -- опитайте отново след няколко секунди"; + +"Try again" = "Опитайте отново"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Използваме Stripe, за да потвърдим данните на картата Ви. Stripe може да използва и съхранява данните Ви в съответствие с правилата си за защита на личните данни. Научете повече"; + +"Your card has expired" = "Вашата карта е изтекла"; + +"Your card was declined" = "Вашата карта бе отхвърлена"; + +"Your card's expiration month is invalid" = "Месецът на валидност на Вашата карта е невалиден"; + +"Your card's expiration year is invalid" = "Годината на валидност на Вашата карта е невалидна"; + +"Your card's number is invalid" = "Номерът на Вашата карта е невалиден"; + +"Your card's security code is invalid" = "Кодът за сигурност на Вашата карта е невалиден"; + +"Your name is invalid." = "Името Ви е невалидно."; + +"Your payment method was declined." = "Начинът Ви на плащане беше отказан."; diff --git a/StripeCore/StripeCore/Resources/Localizations/ca-ES.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/ca-ES.lproj/Localizable.strings new file mode 100644 index 00000000..02e221ca --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/ca-ES.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Tanca"; + +"Scan Card" = "Escanejar targeta"; + +"Scan card" = "Escanejar targeta"; + +"The IBAN you entered is invalid." = "L'IBAN és incorrecte."; + +"There was an error processing your card -- try again in a few seconds" = "Hi ha hagut un error processant la teva targeta - torna-ho a intentar en uns segons"; + +"There was an unexpected error -- try again in a few seconds" = "Hi ha hagut un error inesperat - torna-ho a intentar en uns segons"; + +"Try again" = "Intenta-ho un altre cop"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Emprem Stripe per verificar els detalls de la vostra targeta. És possible que Stripe faci ús i emmagatzemi les dades d'acord amb la política de privadesa. Més informació"; + +"Your card has expired" = "La teva targeta ha caducat"; + +"Your card was declined" = "S'ha rebutjat la teva targeta"; + +"Your card's expiration month is invalid" = "El mes de caducitat de la teva targeta no és vàlid"; + +"Your card's expiration year is invalid" = "L'any de caducitat de la teva targeta no és vàlid"; + +"Your card's number is invalid" = "El número de la teva targeta no és vàlid"; + +"Your card's security code is invalid" = "El codi de seguretat de la teva targeta no és vàlid"; + +"Your name is invalid." = "El nom no és vàlid."; + +"Your payment method was declined." = "S'ha rebutjat el mètode de pagament."; diff --git a/StripeCore/StripeCore/Resources/Localizations/cs-CZ.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/cs-CZ.lproj/Localizable.strings new file mode 100644 index 00000000..2602882f --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/cs-CZ.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Zavřít"; + +"Scan Card" = "Naskenovat kartu"; + +"Scan card" = "Naskenovat kartu"; + +"The IBAN you entered is invalid." = "Číslo IBAN, které jste zadali, je neplatné."; + +"There was an error processing your card -- try again in a few seconds" = "Při zpracování Vaší karty došlo k chybě -- zkuste to za několik sekund znovu"; + +"There was an unexpected error -- try again in a few seconds" = "Došlo k neočekávané chybě -- zkuste to znovu za několik sekund"; + +"Try again" = "Zkusit znovu"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "K ověření údajů o vaší kartě používáme službu Stripe. Společnost Stripe může vaše údaje používat a ukládat v souladu se svými zásadami ochrany osobních údajů. Více informací"; + +"Your card has expired" = "Platnost Vaší karty uplynula"; + +"Your card was declined" = "Vaše karta byla odmítnuta"; + +"Your card's expiration month is invalid" = "Měsíc konce platnosti Vaší karty je neplatný"; + +"Your card's expiration year is invalid" = "Rok konce platnosti Vaší karty je neplatný"; + +"Your card's number is invalid" = "Číslo Vaší karty je neplatné"; + +"Your card's security code is invalid" = "Bezpečnostní kód Vaší karty je neplatný"; + +"Your name is invalid." = "Vaše jméno je neplatné."; + +"Your payment method was declined." = "Vaše platební metoda byla zamítnuta."; diff --git a/StripeCore/StripeCore/Resources/Localizations/da.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/da.lproj/Localizable.strings new file mode 100644 index 00000000..db47ad06 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/da.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Luk"; + +"Scan Card" = "Scan kort"; + +"Scan card" = "Scan kort"; + +"The IBAN you entered is invalid." = "Det indtastede IBAN-nummer er ugyldigt."; + +"There was an error processing your card -- try again in a few seconds" = "Der opstod en fejl under behandling af dit kort – prøv igen om et par sekunder"; + +"There was an unexpected error -- try again in a few seconds" = "Der opstod en uventet fejl – prøv igen om et par sekunder"; + +"Try again" = "Prøv igen"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Vi bruger Stripe til at bekræfte dine kortoplysninger. Stripe kan bruge og gemme dine oplysninger i henhold til selskabets Privatlivspolitik. Lær mere"; + +"Your card has expired" = "Dit kort er udløbet"; + +"Your card was declined" = "Dit kort blev afvist"; + +"Your card's expiration month is invalid" = "Kortets udløbsmåned er ugyldig"; + +"Your card's expiration year is invalid" = "Kortets udløbsår er ugyldigt"; + +"Your card's number is invalid" = "Kortnummeret er ugyldigt"; + +"Your card's security code is invalid" = "Kortets sikkerhedskode er ugyldig"; + +"Your name is invalid." = "Dit navn er ugyldigt."; + +"Your payment method was declined." = "Din betalingsmetode blev afvist."; diff --git a/StripeCore/StripeCore/Resources/Localizations/de.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/de.lproj/Localizable.strings new file mode 100644 index 00000000..c8ebab3f --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/de.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Schließen"; + +"Scan Card" = "Karte scannen"; + +"Scan card" = "Karte scannen"; + +"The IBAN you entered is invalid." = "Die eingegebene IBAN ist ungültig."; + +"There was an error processing your card -- try again in a few seconds" = "Fehler bei der Verarbeitung Ihrer Karte – versuchen Sie es in ein paar Sekunden noch einmal."; + +"There was an unexpected error -- try again in a few seconds" = "Unerwarteter Fehler – versuchen Sie es in ein paar Sekunden noch einmal."; + +"Try again" = "Erneut versuchen"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Wir nutzen Stripe, um Ihre Kartenangaben zu verifizieren. Stripe kann im Einklang mit seiner Datenschutzerklärung Ihre Daten verwenden und speichern. Mehr erfahren"; + +"Your card has expired" = "Ihre Karte ist abgelaufen."; + +"Your card was declined" = "Ihre Karte wurde abgelehnt."; + +"Your card's expiration month is invalid" = "Der Ablaufmonat Ihrer Karte ist ungültig"; + +"Your card's expiration year is invalid" = "Das Ablaufjahr Ihrer Karte ist ungültig."; + +"Your card's number is invalid" = "Die Kartennummer ist ungültig."; + +"Your card's security code is invalid" = "Der Sicherheitscode Ihrer Karte ist ungültig."; + +"Your name is invalid." = "Ihr Name ist ungültig."; + +"Your payment method was declined." = "Ihre Zahlungsmethode wurde abgelehnt."; diff --git a/StripeCore/StripeCore/Resources/Localizations/el-GR.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/el-GR.lproj/Localizable.strings new file mode 100644 index 00000000..aa5faa6d --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/el-GR.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Κλείσιμο"; + +"Scan Card" = "Σάρωση κάρτας"; + +"Scan card" = "Σάρωση κάρτας"; + +"The IBAN you entered is invalid." = "Ο κωδικός IBAN που εισαγάγατε δεν είναι έγκυρος."; + +"There was an error processing your card -- try again in a few seconds" = "Προέκυψε απροσδόκητο σφάλμα κατά την επεξεργασία της κάρτας σας -- δοκιμάστε ξανά σε μερικά δευτερόλεπτα"; + +"There was an unexpected error -- try again in a few seconds" = "Προέκυψε απροσδόκητο σφάλμα -- δοκιμάστε ξανά σε μερικά δευτερόλεπτα"; + +"Try again" = "Δοκιμάστε ξανά"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Χρησιμοποιούμε τη Stripe για να επαληθεύσουμε τα στοιχεία της κάρτας σας. Η Stripe μπορεί να χρησιμοποιεί και να αποθηκεύει τα δεδομένα σας σύμφωνα με την πολιτική απορρήτου της. Μάθετε περισσότερα"; + +"Your card has expired" = "Η κάρτα σας έχει λήξει"; + +"Your card was declined" = "Η κάρτα σας απορρίφθηκε"; + +"Your card's expiration month is invalid" = "Ο μήνας λήξης της κάρτας σας δεν είναι έγκυρος"; + +"Your card's expiration year is invalid" = "Το έτος λήξης της κάρτας σας δεν είναι έγκυρο"; + +"Your card's number is invalid" = "Ο αριθμός της κάρτας σας δεν είναι έγκυρος"; + +"Your card's security code is invalid" = "Ο κωδικός ασφαλείας της κάρτας σας δεν είναι έγκυρος"; + +"Your name is invalid." = "Το όνομά σας δεν είναι έγκυρο."; + +"Your payment method was declined." = "Η μέθοδος πληρωμής σας απορρίφθηκε."; diff --git a/StripeCore/StripeCore/Resources/Localizations/en-GB.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/en-GB.lproj/Localizable.strings new file mode 100644 index 00000000..c88d2cad --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/en-GB.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Close"; + +"Scan Card" = "Scan Card"; + +"Scan card" = "Scan card"; + +"The IBAN you entered is invalid." = "The IBAN you entered is invalid."; + +"There was an error processing your card -- try again in a few seconds" = "There was an error processing your card - please try again in a few seconds"; + +"There was an unexpected error -- try again in a few seconds" = "There was an unexpected error - please try again in a few seconds"; + +"Try again" = "Try again"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more"; + +"Your card has expired" = "Your card has expired"; + +"Your card was declined" = "Your card has been declined"; + +"Your card's expiration month is invalid" = "Your card's expiry month is invalid"; + +"Your card's expiration year is invalid" = "Your card's expiry year is invalid"; + +"Your card's number is invalid" = "Your card's number is invalid"; + +"Your card's security code is invalid" = "Your card's security code is invalid"; + +"Your name is invalid." = "Your name is invalid."; + +"Your payment method was declined." = "Your payment method was declined."; diff --git a/StripeCore/StripeCore/Resources/Localizations/en.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/en.lproj/Localizable.strings new file mode 100644 index 00000000..23b8cfb6 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/en.lproj/Localizable.strings @@ -0,0 +1,48 @@ +/* Text for close button */ +"Close" = "Close"; + +/* Text for button to scan a credit card */ +"Scan Card" = "Scan Card"; + +/* Button title to open camera to scan credit/debit card */ +"Scan card" = "Scan card"; + +/* An error message displayed when the customer's iban is invalid. */ +"The IBAN you entered is invalid." = "The IBAN you entered is invalid."; + +/* Error when there is a problem processing the credit card */ +"There was an error processing your card -- try again in a few seconds" = "There was an error processing your card -- try again in a few seconds"; + +/* Unexpected error, such as a 500 from Stripe or a JSON parse error */ +"There was an unexpected error -- try again in a few seconds" = "There was an unexpected error -- try again in a few seconds"; + +/* Text for a retry button */ +"Try again" = "Try again"; + +/* Informational text informing the user that Stripe is used to process data and a link to Stripe's privacy policy */ +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more"; + +/* Error when the card has already expired */ +"Your card has expired" = "Your card has expired"; + +/* Error when the card was declined by the credit card networks */ +"Your card was declined" = "Your card was declined"; + +/* Error when the card's expiration month is not valid */ +"Your card's expiration month is invalid" = "Your card's expiration month is invalid"; + +/* Error when the card's expiration year is not valid */ +"Your card's expiration year is invalid" = "Your card's expiration year is invalid"; + +/* Error when the card number is not valid */ +"Your card's number is invalid" = "Your card's number is invalid"; + +/* Error when the card's CVC is not valid */ +"Your card's security code is invalid" = "Your card's security code is invalid"; + +/* Error when customer's name is invalid */ +"Your name is invalid." = "Your name is invalid."; + +/* Error message when a payment method gets declined. */ +"Your payment method was declined." = "Your payment method was declined."; + diff --git a/StripeCore/StripeCore/Resources/Localizations/es-419.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/es-419.lproj/Localizable.strings new file mode 100644 index 00000000..300eb90e --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/es-419.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Cerrar"; + +"Scan Card" = "Escanear tarjeta"; + +"Scan card" = "Escanear tarjeta"; + +"The IBAN you entered is invalid." = "El IBAN que ingresaste no es válido."; + +"There was an error processing your card -- try again in a few seconds" = "Hubo un error al procesar tu tarjeta. Vuelve a intentar en unos segundos."; + +"There was an unexpected error -- try again in a few seconds" = "Se produjo un error inesperado. Vuelve a intentar en unos segundos."; + +"Try again" = "Reintentar"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Usamos Stripe para verificar los datos de tu tarjeta. Stripe puede usar y almacenar tus datos de acuerdo con la política de privacidad. Más información"; + +"Your card has expired" = "Tu tarjeta ha vencido."; + +"Your card was declined" = "Tu tarjeta fue rechazada."; + +"Your card's expiration month is invalid" = "El mes de vencimiento de tu tarjeta no es válido."; + +"Your card's expiration year is invalid" = "El año de vencimiento de tu tarjeta no es válido"; + +"Your card's number is invalid" = "El número de tarjeta no es válido"; + +"Your card's security code is invalid" = "El código de seguridad de tu tarjeta no es válido."; + +"Your name is invalid." = "El nombre no es válido."; + +"Your payment method was declined." = "Tu método de pago fue rechazado."; diff --git a/StripeCore/StripeCore/Resources/Localizations/es.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/es.lproj/Localizable.strings new file mode 100644 index 00000000..45c575ed --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/es.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Cerrar"; + +"Scan Card" = "Escanear tarjeta"; + +"Scan card" = "Escanear tarjeta"; + +"The IBAN you entered is invalid." = "El IBAN introducido no es válido."; + +"There was an error processing your card -- try again in a few seconds" = "Error al procesar la tarjeta. Vuelve a intentarlo en unos segundos."; + +"There was an unexpected error -- try again in a few seconds" = "Se ha producido un error inesperado. Vuelve a intentarlo en unos segundos"; + +"Try again" = "Reintentar"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Usamos Stripe para verificar los datos de tu tarjeta. Stripe puede usar y almacenar tus datos de acuerdo con la política de privacidad. Más información"; + +"Your card has expired" = "La tarjeta ha caducado"; + +"Your card was declined" = "La tarjeta ha sido rechazada"; + +"Your card's expiration month is invalid" = "El mes de caducidad de tu tarjeta no es válido"; + +"Your card's expiration year is invalid" = "El año de caducidad de tu tarjeta no es válido"; + +"Your card's number is invalid" = "El número de tarjeta no es válido"; + +"Your card's security code is invalid" = "El código de seguridad de tu tarjeta no es válido"; + +"Your name is invalid." = "El nombre no es válido."; + +"Your payment method was declined." = "Se ha rechazado tu método de pago."; diff --git a/StripeCore/StripeCore/Resources/Localizations/et-EE.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/et-EE.lproj/Localizable.strings new file mode 100644 index 00000000..773793ea --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/et-EE.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Sule"; + +"Scan Card" = "Skanni kaart"; + +"Scan card" = "Skanni kaart"; + +"The IBAN you entered is invalid." = "Sisestatud IBAN on kehtetu."; + +"There was an error processing your card -- try again in a few seconds" = "Kaardi töötlemisel esines tõrge – proovige mõne sekundi pärast uuesti"; + +"There was an unexpected error -- try again in a few seconds" = "Esines ootamatu tõrge – proovige mõne sekundi pärast uuesti"; + +"Try again" = "Proovi uuesti"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Kasutame teie kaardiandmete kontrollimiseks Stripe'i. Stripe võib teie andmeid kasutada ja säilitada vastavalt oma privaatsuspoliitikale. Vaata lähemalt"; + +"Your card has expired" = "Kaart on aegunud"; + +"Your card was declined" = "Kaart lükati tagasi"; + +"Your card's expiration month is invalid" = "Kaardi aegumiskuu on kehtetu"; + +"Your card's expiration year is invalid" = "Kaardi aegumisaasta on kehtetu"; + +"Your card's number is invalid" = "Kaardi number on kehtetu"; + +"Your card's security code is invalid" = "Kaardi turvakood on kehtetu"; + +"Your name is invalid." = "Teie nimi on kehtetu."; + +"Your payment method was declined." = "Teie makseviis lükati tagasi."; diff --git a/StripeCore/StripeCore/Resources/Localizations/fi.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/fi.lproj/Localizable.strings new file mode 100644 index 00000000..d1ce73d5 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/fi.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Sulje"; + +"Scan Card" = "Skannaa kortti"; + +"Scan card" = "Skannaa kortti"; + +"The IBAN you entered is invalid." = "Antamasi IBAN on virheellinen."; + +"There was an error processing your card -- try again in a few seconds" = "Kortin käsittelyssä tapahtui virhe – kokeile uudelleen muutaman sekunnin kuluttua"; + +"There was an unexpected error -- try again in a few seconds" = "Odottamaton virhe – kokeile uudelleen muutaman sekunnin kuluttua"; + +"Try again" = "Yritä uudelleen"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Käytämme Stripeä korttitietojen vahvistamiseen. Stripe voi käyttää ja säilyttää tietojasi heidän tietosuojaselosteensa mukaisesti. Lue lisää"; + +"Your card has expired" = "Korttisi on vanhentunut"; + +"Your card was declined" = "Korttiasi ei hyväksytty"; + +"Your card's expiration month is invalid" = "Kortin erääntymiskuukausi ei kelpaa"; + +"Your card's expiration year is invalid" = "Kortin erääntymisvuosi ei kelpaa"; + +"Your card's number is invalid" = "Kortin numero ei kelpaa"; + +"Your card's security code is invalid" = "Kortin turvakoodi ei kelpaa"; + +"Your name is invalid." = "Nimi on virheellinen."; + +"Your payment method was declined." = "Maksutapasi on hylätty."; diff --git a/StripeCore/StripeCore/Resources/Localizations/fil.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/fil.lproj/Localizable.strings new file mode 100644 index 00000000..f03968ea --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/fil.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Isara"; + +"Scan Card" = "I-scan ang kard"; + +"Scan card" = "I-scan ang kard"; + +"The IBAN you entered is invalid." = "Ang IBAN na iniligay mo ay imbalido."; + +"There was an error processing your card -- try again in a few seconds" = "Nagkaroon ng kamalian sa pagproseso ng iyong kard -- subukin muli sa ilang segundo"; + +"There was an unexpected error -- try again in a few seconds" = "Nagkaroon ng di-inaasahang kamalian -- subukin muli sa ilang segundo"; + +"Try again" = "Subukan muli"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Gumagamit kami ng Stripe para i-verify ang mga detalye ng iyong kard. Maaaring gamitin at imbakin ng Stripe ang iyong data ayon sa patakaran sa privacy nito. Alamin ang higit pa"; + +"Your card has expired" = "Napaso na ang iyong kard"; + +"Your card was declined" = "Tinanggihan ang iyong kard"; + +"Your card's expiration month is invalid" = "Ang buwan ng pagkapaso ng iyong kard ay di balido"; + +"Your card's expiration year is invalid" = "Ang taon ng pagkapaso ng iyong kard ay di balido"; + +"Your card's number is invalid" = "Ang numero ng iyong kard ay di balido"; + +"Your card's security code is invalid" = "Ang security code ng iyong kard ay di balido"; + +"Your name is invalid." = "Ang iyong pangalan ay imbalido."; + +"Your payment method was declined." = "Tinanggihan ang iyong paraan ng pagbabayad."; diff --git a/StripeCore/StripeCore/Resources/Localizations/fr-CA.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/fr-CA.lproj/Localizable.strings new file mode 100644 index 00000000..83832894 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/fr-CA.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Fermer"; + +"Scan Card" = "Scanner la carte"; + +"Scan card" = "Scanner la carte"; + +"The IBAN you entered is invalid." = "L'IBAN que vous avez saisi n'est pas valide."; + +"There was an error processing your card -- try again in a few seconds" = "Une erreur est survenue lors du traitement de votre carte. Réessayez dans quelques secondes."; + +"There was an unexpected error -- try again in a few seconds" = "Une erreur inattendue est survenue. Réessayez dans quelques secondes."; + +"Try again" = "Réessayer"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Nous faisons appel à Stripe pour vérifier les informations de votre carte. Stripe peut utiliser et stocker vos données conformément à sa politique de confidentialité. En savoir plus"; + +"Your card has expired" = "Votre carte a expiré"; + +"Your card was declined" = "Votre carte a été refusée"; + +"Your card's expiration month is invalid" = "Le mois d'expiration de votre carte n'est pas valide"; + +"Your card's expiration year is invalid" = "L'année d'expiration de votre carte n'est pas valide"; + +"Your card's number is invalid" = "Votre numéro de carte n'est pas valide"; + +"Your card's security code is invalid" = "Le code de sécurité de votre carte n'est pas valide"; + +"Your name is invalid." = "Votre nom n'est pas valide."; + +"Your payment method was declined." = "Votre moyen de paiement a été refusé."; diff --git a/StripeCore/StripeCore/Resources/Localizations/fr.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/fr.lproj/Localizable.strings new file mode 100644 index 00000000..f3ccd39e --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/fr.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Fermer"; + +"Scan Card" = "Scanner la carte"; + +"Scan card" = "Scanner la carte"; + +"The IBAN you entered is invalid." = "L'IBAN que vous avez saisi n'est pas valide."; + +"There was an error processing your card -- try again in a few seconds" = "Une erreur est survenue lors du traitement de votre carte. Réessayez dans quelques secondes."; + +"There was an unexpected error -- try again in a few seconds" = "Une erreur inattendue est survenue. Réessayez dans quelques secondes."; + +"Try again" = "Réessayer"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Nous faisons appel à Stripe pour vérifier les informations de votre carte. Stripe peut utiliser et stocker vos données conformément à sa politique de confidentialité. En savoir plus"; + +"Your card has expired" = "Votre carte est arrivée à expiration."; + +"Your card was declined" = "Votre carte a été refusée."; + +"Your card's expiration month is invalid" = "Le mois d'expiration de votre carte n'est pas valide."; + +"Your card's expiration year is invalid" = "L'année d'expiration de votre carte n'est pas valide."; + +"Your card's number is invalid" = "Votre numéro de carte n'est pas valide."; + +"Your card's security code is invalid" = "Le code de sécurité de votre carte n'est pas valide"; + +"Your name is invalid." = "Votre nom n'est pas valide."; + +"Your payment method was declined." = "Votre moyen de paiement a été refusé."; diff --git a/StripeCore/StripeCore/Resources/Localizations/hr.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/hr.lproj/Localizable.strings new file mode 100644 index 00000000..ce91f939 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/hr.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Zatvori"; + +"Scan Card" = "Skeniraj karticu"; + +"Scan card" = "Skeniraj karticu"; + +"The IBAN you entered is invalid." = "Uneseni IBAN nije valjan."; + +"There was an error processing your card -- try again in a few seconds" = "Došlo je do pogreške u obradi vaše kartice -- pokušajte ponovno za nekoliko sekundi"; + +"There was an unexpected error -- try again in a few seconds" = "Došlo je do neočekivane pogreške -- pokušajte ponovno za nekoliko sekundi"; + +"Try again" = "Pokušajte ponovo"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Koristimo Stripe za provjeru vaših podataka o kartici. Stripe može koristiti i pohranjivati vaše podatke u skladu sa svojim pravilima privatnosti. Saznajte više"; + +"Your card has expired" = "Vaša kartica je istekla"; + +"Your card was declined" = "Kartica je odbijena"; + +"Your card's expiration month is invalid" = "Mjesec isteka kartice nije valjan"; + +"Your card's expiration year is invalid" = "Godina isteka kartice nije valjana"; + +"Your card's number is invalid" = "Broj kartice nije valjan"; + +"Your card's security code is invalid" = "Sigurnosni kod vaše kartice nije valjan"; + +"Your name is invalid." = "Vaše ime je neispravno."; + +"Your payment method was declined." = "Vaš način plaćanja je odbijen."; diff --git a/StripeCore/StripeCore/Resources/Localizations/hu.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/hu.lproj/Localizable.strings new file mode 100644 index 00000000..2a5df893 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/hu.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Bezárás"; + +"Scan Card" = "Kártya beolvasása"; + +"Scan card" = "Kártya beolvasása"; + +"The IBAN you entered is invalid." = "A beírt IBAN-szám érvénytelen."; + +"There was an error processing your card -- try again in a few seconds" = "Hiba történt kártyája feldolgozása során, próbálja újra néhány másodperc múlva"; + +"There was an unexpected error -- try again in a few seconds" = "Váratlan hiba lépett fel, próbálja újra néhány másodperc múlva"; + +"Try again" = "Próbálja újra"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "A Stripe-ot használjuk a kártyájának az ellenőrzésére. A Stripe az adatvédelmi szabályzatának megfelelően használhatja és tárolhatja az Ön adatait. További tudnivalók"; + +"Your card has expired" = "A kártyája lejárt"; + +"Your card was declined" = "A kártyáját elutasítottuk"; + +"Your card's expiration month is invalid" = "Kártyájának lejárati hónapja érvénytelen"; + +"Your card's expiration year is invalid" = "A kártyája lejárati éve érvénytelen"; + +"Your card's number is invalid" = "Kártyaszáma érvénytelen"; + +"Your card's security code is invalid" = "Kártyájának biztonsági kódja érvénytelen"; + +"Your name is invalid." = "Érvénytelen név."; + +"Your payment method was declined." = "Fizetési módja elutasításra került."; diff --git a/StripeCore/StripeCore/Resources/Localizations/id.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/id.lproj/Localizable.strings new file mode 100644 index 00000000..0428f102 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/id.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Tutup"; + +"Scan Card" = "Pindai Kartu"; + +"Scan card" = "Pindai kartu"; + +"The IBAN you entered is invalid." = "IBAN yang Anda masukkan tidak valid."; + +"There was an error processing your card -- try again in a few seconds" = "Ada kesalahan saat memproses kartu Anda -- cobalah lagi dalam beberapa detik"; + +"There was an unexpected error -- try again in a few seconds" = "Ada kesalahan tak terduga -- cobalah lagi dalam beberapa detik"; + +"Try again" = "Coba lagi"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Kami menggunakan Stripe untuk memverifikasi detail kartu Anda. Stripe mungkin menggunakan dan menyimpan data Anda sesuai dengan kebijakan privasi mereka. Pelajari selengkapnya"; + +"Your card has expired" = "Kartu Anda telah kedaluwarsa"; + +"Your card was declined" = "Kartu Anda ditolak"; + +"Your card's expiration month is invalid" = "Bulan kedaluwarsa kartu Anda tidak valid"; + +"Your card's expiration year is invalid" = "Tahun kedaluwarsa kartu Anda tidak valid"; + +"Your card's number is invalid" = "Nomor kartu Anda tidak valid"; + +"Your card's security code is invalid" = "Kode keamanan kartu Anda tidak valid"; + +"Your name is invalid." = "Nama Anda tidak valid."; + +"Your payment method was declined." = "Metode pembayaran Anda ditolak."; diff --git a/StripeCore/StripeCore/Resources/Localizations/it.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/it.lproj/Localizable.strings new file mode 100644 index 00000000..3e02e03e --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/it.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Chiudi"; + +"Scan Card" = "Scansiona carta"; + +"Scan card" = "Scansiona carta"; + +"The IBAN you entered is invalid." = "L'IBAN inserito non è valido."; + +"There was an error processing your card -- try again in a few seconds" = "Si è verificato un errore durante l'elaborazione della carta. Riprova fra qualche secondo."; + +"There was an unexpected error -- try again in a few seconds" = "Si è verificato un errore imprevisto. Riprova fra qualche secondo."; + +"Try again" = "Riprova"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Utilizziamo Stripe per verificare i dati della carta. Come stabilito nella sua informativa sulla privacy, Stripe può utilizzare e conservare i tuoi dati. Ulteriori informazioni"; + +"Your card has expired" = "La carta è scaduta"; + +"Your card was declined" = "La carta è stata rifiutata"; + +"Your card's expiration month is invalid" = "Il mese di scadenza della carta non è valido"; + +"Your card's expiration year is invalid" = "L'anno di scadenza della carta non è valido"; + +"Your card's number is invalid" = "Il numero della carta non è valido"; + +"Your card's security code is invalid" = "Il codice di sicurezza della carta non è valido"; + +"Your name is invalid." = "Nome non valido."; + +"Your payment method was declined." = "La tua modalità di pagamento è stata rifiutata."; diff --git a/StripeCore/StripeCore/Resources/Localizations/ja.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/ja.lproj/Localizable.strings new file mode 100644 index 00000000..fb79bab8 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/ja.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "閉じる"; + +"Scan Card" = "クレジットカードをスキャン"; + +"Scan card" = "カードをスキャン"; + +"The IBAN you entered is invalid." = "入力した IBAN が無効です。"; + +"There was an error processing your card -- try again in a few seconds" = "クレジットカード情報の処理中にエラーが発生しました。数秒後にもう一度お試しください"; + +"There was an unexpected error -- try again in a few seconds" = "予期しないエラーが発生しました。数秒後にもう一度お試しください"; + +"Try again" = "もう一度お試しください"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Stripe を使用してカード詳細を確認します。Stripe はプライバシーポリシーに従ってお客様のデータを使用および保管することがあります。もっと知る"; + +"Your card has expired" = "クレジットカードの有効期限が切れています"; + +"Your card was declined" = "クレジットカードが拒否されました"; + +"Your card's expiration month is invalid" = "指定したクレジットカードの有効期限の月が無効です"; + +"Your card's expiration year is invalid" = "指定したクレジットカードの有効期限の年が無効です"; + +"Your card's number is invalid" = "指定したクレジットカードの番号が無効です"; + +"Your card's security code is invalid" = "指定したクレジットカードのセキュリティコードが無効です"; + +"Your name is invalid." = "名前が無効です。"; + +"Your payment method was declined." = "お客様の支払い方法が拒否されました。"; diff --git a/StripeCore/StripeCore/Resources/Localizations/ko.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/ko.lproj/Localizable.strings new file mode 100644 index 00000000..a6316bc9 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/ko.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "닫기"; + +"Scan Card" = "카드 스캔"; + +"Scan card" = "카드 스캔"; + +"The IBAN you entered is invalid." = "입력한 IBAN이 잘못되었습니다."; + +"There was an error processing your card -- try again in a few seconds" = "카드를 처리하는 동안 오류가 발생했습니다. 몇 초 후에 다시 시도하십시오."; + +"There was an unexpected error -- try again in a few seconds" = "예기치 않은 오류가 발생했습니다. 몇 초 후에 다시 시도하십시오."; + +"Try again" = "다시 시도"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Stripe를 통해 카드 세부사항을 확인합니다. Stripe에서 자사 개인정보정책에 따라 귀하의 데이터를 사용하고 저장할 수 있습니다. 자세히 알아보기"; + +"Your card has expired" = "카드가 만료되었습니다"; + +"Your card was declined" = "카드가 거절되었습니다"; + +"Your card's expiration month is invalid" = "카드의 만료 월이 유효하지 않습니다"; + +"Your card's expiration year is invalid" = "카드의 만료 연도가 유효하지 않습니다"; + +"Your card's number is invalid" = "카드 번호가 유효하지 않습니다"; + +"Your card's security code is invalid" = "카드의 보안 코드가 유효하지 않습니다"; + +"Your name is invalid." = "이름이 잘못되었습니다."; + +"Your payment method was declined." = "결제 방식이 거부되었습니다."; diff --git a/StripeCore/StripeCore/Resources/Localizations/lt-LT.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/lt-LT.lproj/Localizable.strings new file mode 100644 index 00000000..bb550e46 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/lt-LT.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Užverti"; + +"Scan Card" = "Skenuoti kortelę"; + +"Scan card" = "Nuskaityti kortelę"; + +"The IBAN you entered is invalid." = "Įvedėte neteisingą IBAN."; + +"There was an error processing your card -- try again in a few seconds" = "Įvyko kortelės apdorojimo klaida, po kelių sekundžių bandykite dar kartą"; + +"There was an unexpected error -- try again in a few seconds" = "Įvyko netikėta klaida, po kelių sekundžių bandykite dar kartą"; + +"Try again" = "Bandyti dar kartą"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Naudojame „Stripe“, kad patikrintume jūsų kortelės duomenis. „Stripe“ gali naudoti ir saugoti jūsų duomenis pagal savo privatumo politiką. Sužinokite daugiau"; + +"Your card has expired" = "Kortelė baigė galioti"; + +"Your card was declined" = "Kortelė buvo atmesta"; + +"Your card's expiration month is invalid" = "Negaliojantis kortelės galiojimo pabaigos mėnuo"; + +"Your card's expiration year is invalid" = "Kortelės galiojimo pabaigos metai neteisingi"; + +"Your card's number is invalid" = "Negaliojantis kortelės numeris"; + +"Your card's security code is invalid" = "Kortelės saugos kodas negalioja"; + +"Your name is invalid." = "Jūsų vardas neteisingas."; + +"Your payment method was declined." = "Jūsų mokėjimo būdas buvo atmestas."; diff --git a/StripeCore/StripeCore/Resources/Localizations/lv-LV.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/lv-LV.lproj/Localizable.strings new file mode 100644 index 00000000..36620f2b --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/lv-LV.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Aizvērt"; + +"Scan Card" = "Skenēt karti"; + +"Scan card" = "Skenēt karti"; + +"The IBAN you entered is invalid." = "Ievadītais IBAN nav derīgs."; + +"There was an error processing your card -- try again in a few seconds" = "Radās kļūda, apstrādājot jūsu karti — mēģiniet vēlreiz pēc dažām sekundēm"; + +"There was an unexpected error -- try again in a few seconds" = "Radusies negaidīta kļūda — mēģiniet vēlreiz pēc dažām sekundēm"; + +"Try again" = "Mēģināt vēlreiz"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Mēs izmantojam Stripe, lai verificētu jūsu kartes informāciju. Stripe var izmantot un uzglabāt jūsu datus saskaņā ar konfidencialitātes politiku. Uzzināt vairāk"; + +"Your card has expired" = "Kartes derīguma termiņš ir beidzies"; + +"Your card was declined" = "Karte tika noraidīta"; + +"Your card's expiration month is invalid" = "Kartes derīguma termiņa mēnesis nav derīgs"; + +"Your card's expiration year is invalid" = "Kartes derīguma termiņa gads nav derīgs"; + +"Your card's number is invalid" = "Kartes numurs nav derīgs"; + +"Your card's security code is invalid" = "Kartes drošības kods nav derīgs"; + +"Your name is invalid." = "Jūsu vārds nav derīgs."; + +"Your payment method was declined." = "Maksājuma veids tika noraidīts."; diff --git a/StripeCore/StripeCore/Resources/Localizations/ms-MY.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/ms-MY.lproj/Localizable.strings new file mode 100644 index 00000000..573cdc0d --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/ms-MY.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Tutup"; + +"Scan Card" = "Imbas Kad"; + +"Scan card" = "Imbas kad"; + +"The IBAN you entered is invalid." = "IBAN yang anda masukkan tidak sah."; + +"There was an error processing your card -- try again in a few seconds" = "Ada ralat semasa memproses kad anda -- cuba lagi dalam masa beberapa saat"; + +"There was an unexpected error -- try again in a few seconds" = "Ada ralat yang tidak dijangka -- cuba lagi dalam masa beberapa saat"; + +"Try again" = "Cuba lagi"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Kami menggunakan Stripe untuk mengesahkan butiran kad anda. Stripe mungkin menggunakan dan menyimpan data anda menurut dasar privasi Stripe. Ketahui selanjutnya"; + +"Your card has expired" = "Kad anda telah tamat tempoh"; + +"Your card was declined" = "Kad anda telah ditolak"; + +"Your card's expiration month is invalid" = "Bulan tamat tempoh kad anda tidak sah"; + +"Your card's expiration year is invalid" = "Tahun tamat tempoh kad anda tidak sah"; + +"Your card's number is invalid" = "Nombor kad anda tidak sah"; + +"Your card's security code is invalid" = "Kod keselamatan kad anda tidak sah"; + +"Your name is invalid." = "Nama anda tidak sah."; + +"Your payment method was declined." = "Kaedah pembayaran anda ditolak."; diff --git a/StripeCore/StripeCore/Resources/Localizations/mt.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/mt.lproj/Localizable.strings new file mode 100644 index 00000000..546deb95 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/mt.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Agħlaq"; + +"Scan Card" = "Skennja l-Kard"; + +"Scan card" = "Skennja l-karta"; + +"The IBAN you entered is invalid." = "L-IBAN li daħħalt mhux tajjeb."; + +"There was an error processing your card -- try again in a few seconds" = "Kien hemm żball fl-ipproċessar tal-kard tiegħek -- erġa' pprova wara ftit sekondi"; + +"There was an unexpected error -- try again in a few seconds" = "Kien hemm żball mhux mistenni -- erġa' pprova wara ftit sekondi"; + +"Try again" = "Erġa' pprova"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Aħna naħdmu ma' Stripe biex nivverifikaw id-dettalji tal-karta tiegħek. Stripe tista' tuża u taħżen id-dejta tiegħek skont il-politika tal-privatezza tagħha. Sir af iktar"; + +"Your card has expired" = "Il-kard tiegħek skadiet"; + +"Your card was declined" = "Il-kard tiegħek ma ġietx aċċettata"; + +"Your card's expiration month is invalid" = "Ix-xahar tal-iskadenza tal-kard tiegħek mhux validu"; + +"Your card's expiration year is invalid" = "Is-sena tal-iskadenza tal-kard tiegħek mhijiex valida"; + +"Your card's number is invalid" = "In-numru tal-kard tiegħek mhux validu"; + +"Your card's security code is invalid" = "Il-kodiċi tas-sigurtà tal-kard tiegħek mhux validu"; + +"Your name is invalid." = "L-isem li daħħalt mhux tajjeb."; + +"Your payment method was declined." = "Il-metodu tal-pagament tiegħek m'għaddiex."; diff --git a/StripeCore/StripeCore/Resources/Localizations/nb.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/nb.lproj/Localizable.strings new file mode 100644 index 00000000..af0a3a1e --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/nb.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Lukk"; + +"Scan Card" = "Skann kort"; + +"Scan card" = "Skann kort"; + +"The IBAN you entered is invalid." = "IBAN-nummeret du la inn er ugyldig."; + +"There was an error processing your card -- try again in a few seconds" = "Det oppstod en feil ved prosessering av kortet -- prøv igjen om et par sekunder"; + +"There was an unexpected error -- try again in a few seconds" = "En uventet feil har oppstått -- prøv igjen om et par sekunder"; + +"Try again" = "Prøv igjen"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Vi bruker Stripe til å verifisere kontoopplysningene. Stripe kan bruke og lagre opplysningene dine i henhold til personvernerklæringen sin. Finn ut mer"; + +"Your card has expired" = "Kortet ditt har utløpt"; + +"Your card was declined" = "Kortet ditt ble avvist"; + +"Your card's expiration month is invalid" = "Kortet sin utløpsmåned er ugyldig"; + +"Your card's expiration year is invalid" = "Kortet sitt utløpsår er ugyldig"; + +"Your card's number is invalid" = "Ugyldig kortnummer"; + +"Your card's security code is invalid" = "Ugyldig sikkerhetskode"; + +"Your name is invalid." = "Navnet er ugyldig."; + +"Your payment method was declined." = "Betalingsmetoden din ble avvist."; diff --git a/StripeCore/StripeCore/Resources/Localizations/nl.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/nl.lproj/Localizable.strings new file mode 100644 index 00000000..43792e41 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/nl.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Sluiten"; + +"Scan Card" = "Kaart scannen"; + +"Scan card" = "Betaalkaart scannen"; + +"The IBAN you entered is invalid." = "Het opgegeven IBAN-nummer is ongeldig."; + +"There was an error processing your card -- try again in a few seconds" = "Er is een fout met de verwerking van je kaart. Probeer het over enkele seconden opnieuw"; + +"There was an unexpected error -- try again in a few seconds" = "Er is een onverwachte fout opgetreden. Probeer het over enkele seconden opnieuw"; + +"Try again" = "Opnieuw proberen"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "We gebruiken Stripe om je kaartgegevens te verifiëren. Stripe kan je gegevens gebruiken en bewaren volgens hun privacybeleid. Meer informatie"; + +"Your card has expired" = "Je kaart is vervallen"; + +"Your card was declined" = "Je kaart is geweigerd"; + +"Your card's expiration month is invalid" = "De vervalmaand van je kaart is ongeldig"; + +"Your card's expiration year is invalid" = "Het vervaljaar van je kaart is ongeldig"; + +"Your card's number is invalid" = "Je kaartnummer is ongeldig"; + +"Your card's security code is invalid" = "De beveiligingscode van je kaart is ongeldig"; + +"Your name is invalid." = "Je naam is ongeldig."; + +"Your payment method was declined." = "Je betaalmethode is geweigerd."; diff --git a/StripeCore/StripeCore/Resources/Localizations/nn-NO.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/nn-NO.lproj/Localizable.strings new file mode 100644 index 00000000..70d88ad5 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/nn-NO.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Lukk"; + +"Scan Card" = "Skann kortet"; + +"Scan card" = "Skann kort"; + +"The IBAN you entered is invalid." = "Du skreiv inn eit ugyldig IBAN-nummer."; + +"There was an error processing your card -- try again in a few seconds" = "Det oppstod ei feil under behandlinga av kortet – prøv igjen om nokre få sekund"; + +"There was an unexpected error -- try again in a few seconds" = "Det oppstod ein uventa feil – prøv igjen om nokre få sekund"; + +"Try again" = "Prøv igjen"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Vi brukar Stripe til å verifisere kortdetaljane dine. Stripe kan bruke og lagre dataa dine i samsvar med personvernerklæringa deira. Finn ut meir"; + +"Your card has expired" = "Kortet har gått ut"; + +"Your card was declined" = "Kortet vart avvist"; + +"Your card's expiration month is invalid" = "Utløpsmånaden på kortet er ugyldig"; + +"Your card's expiration year is invalid" = "Utløpsåret på kortet ditt er ugyldig"; + +"Your card's number is invalid" = "Kortnummeret ditt er ugyldig"; + +"Your card's security code is invalid" = "Sikkerheitskoden til kortet er ugyldig"; + +"Your name is invalid." = "Namnet ditt er ugyldig."; + +"Your payment method was declined." = "Betalingsmåten din vart avvist."; diff --git a/StripeCore/StripeCore/Resources/Localizations/pl-PL.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/pl-PL.lproj/Localizable.strings new file mode 100644 index 00000000..7502f7eb --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/pl-PL.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Zamknij"; + +"Scan Card" = "Skanuj kartę"; + +"Scan card" = "Skanuj kartę"; + +"The IBAN you entered is invalid." = "Podany IBAN jest nieprawidłowy."; + +"There was an error processing your card -- try again in a few seconds" = "Podczas przetwarzania Twojej karty wystąpił błąd – spróbuj ponownie za kilka sekund"; + +"There was an unexpected error -- try again in a few seconds" = "Wystąpił nieoczekiwany błąd – spróbuj ponownie za kilka sekund"; + +"Try again" = "Spróbuj ponownie"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Korzystamy ze Stripe, aby weryfikować szczegóły Twojej karty. Stripe może używać Twoich danych i przechowywać je zgodnie ze swoją polityką prywatności. Dowiedz się więcej"; + +"Your card has expired" = "Ważność Twojej karty wygasła"; + +"Your card was declined" = "Karta została odrzucona"; + +"Your card's expiration month is invalid" = "Miesiąc daty ważności karty jest nieprawidłowy."; + +"Your card's expiration year is invalid" = "Rok daty ważności karty jest nieprawidłowy"; + +"Your card's number is invalid" = "Numer Twojej karty jest nieprawidłowy"; + +"Your card's security code is invalid" = "Kod bezpieczeństwa karty jest nieprawidłowy"; + +"Your name is invalid." = "Twoje imię i nazwisko jest nieprawidłowe."; + +"Your payment method was declined." = "Twoja metoda płatności została odrzucona."; diff --git a/StripeCore/StripeCore/Resources/Localizations/pt-BR.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/pt-BR.lproj/Localizable.strings new file mode 100644 index 00000000..4cede820 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/pt-BR.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Fechar"; + +"Scan Card" = "Digitalizar cartão"; + +"Scan card" = "Ler cartão"; + +"The IBAN you entered is invalid." = "O IBAN inserido é inválido."; + +"There was an error processing your card -- try again in a few seconds" = "Erro ao processar o cartão – tente novamente em alguns segundos"; + +"There was an unexpected error -- try again in a few seconds" = "Erro inesperado – tente novamente em alguns segundos"; + +"Try again" = "Tentar novamente"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Usamos a Stripe para verificar os dados do seu cartão. A Stripe pode usar e armazenar seus dados de acordo com a política de privacidade da empresa. Saiba mais"; + +"Your card has expired" = "O cartão expirou"; + +"Your card was declined" = "O cartão foi recusado"; + +"Your card's expiration month is invalid" = "O mês de expiração doi cartão é inválido"; + +"Your card's expiration year is invalid" = "O ano de expiração do cartão é inválido"; + +"Your card's number is invalid" = "O número do cartão é inválido"; + +"Your card's security code is invalid" = "O código de segurança do cartão é inválido"; + +"Your name is invalid." = "Nome inválido."; + +"Your payment method was declined." = "A forma de pagamento foi recusada."; diff --git a/StripeCore/StripeCore/Resources/Localizations/pt-PT.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/pt-PT.lproj/Localizable.strings new file mode 100644 index 00000000..ee185d0b --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/pt-PT.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Fechar"; + +"Scan Card" = "Ler cartão"; + +"Scan card" = "Ler cartão"; + +"The IBAN you entered is invalid." = "O IBAN que introduziu é inválido."; + +"There was an error processing your card -- try again in a few seconds" = "Ocorreu um erro ao processar o seu cartão -- tente novamente dentro de alguns segundos"; + +"There was an unexpected error -- try again in a few seconds" = "Ocorreu um erro inesperado -- tente novamente dentro de alguns segundos"; + +"Try again" = "Tentar novamente"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Utilizamos a Stripe para verificar os detalhes do seu cartão. A Stripe pode utilizar e armazenar os seus dados de acordo com a sua política de privacidade. Saiba mais"; + +"Your card has expired" = "O seu cartão expirou"; + +"Your card was declined" = "O seu cartão foi recusado"; + +"Your card's expiration month is invalid" = "O mês de validade do seu cartão é inválido"; + +"Your card's expiration year is invalid" = "O ano de validade do seu cartão é inválido"; + +"Your card's number is invalid" = "O número do seu cartão é inválido"; + +"Your card's security code is invalid" = "O código de segurança do seu cartão é inválido"; + +"Your name is invalid." = "O seu nome é inválido."; + +"Your payment method was declined." = "O seu método de pagamento foi recusado."; diff --git a/StripeCore/StripeCore/Resources/Localizations/ro-RO.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/ro-RO.lproj/Localizable.strings new file mode 100644 index 00000000..c0ddbdc3 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/ro-RO.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Închidere"; + +"Scan Card" = "Scanare card"; + +"Scan card" = "Scanare card"; + +"The IBAN you entered is invalid." = "Codul IBAN pe care l-ați introdus nu este valid."; + +"There was an error processing your card -- try again in a few seconds" = "A apărut o eroare la procesarea cardului dvs. -- încercați din nou în câteva secunde"; + +"There was an unexpected error -- try again in a few seconds" = "A apărut o eroare neașteptată -- încercați din nou în câteva secunde"; + +"Try again" = "Încercați din nou"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Folosim Stripe pentru a verifica detaliile cardului dvs. Stripe poate utiliza și stoca datele dvs. în conformitate cu politica sa de confidențialitate. Aflați mai multe"; + +"Your card has expired" = "Cardul dvs. a expirat"; + +"Your card was declined" = "Cardul dvs. a fost respins"; + +"Your card's expiration month is invalid" = "Luna de expirare a cardului dvs. nu este validă"; + +"Your card's expiration year is invalid" = "Anul de expirare al cardului dvs. nu este valid"; + +"Your card's number is invalid" = "Numărul cardului dvs. nu este valid"; + +"Your card's security code is invalid" = "Codul de securitate al cardului dvs. nu este valid"; + +"Your name is invalid." = "Numele dvs. nu este valid."; + +"Your payment method was declined." = "Metoda dvs. de plată a fost respinsă."; diff --git a/StripeCore/StripeCore/Resources/Localizations/ru.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/ru.lproj/Localizable.strings new file mode 100644 index 00000000..3face220 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/ru.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Закрыть"; + +"Scan Card" = "Сканировать карту"; + +"Scan card" = "Сканировать карту"; + +"The IBAN you entered is invalid." = "Введенный код IBAN содержит ошибку."; + +"There was an error processing your card -- try again in a few seconds" = "При обработке карты произошла ошибка. Подождите несколько секунд и повторите попытку"; + +"There was an unexpected error -- try again in a few seconds" = "Произошла непредвиденная ошибка. Подождите несколько секунд и повторите попытку."; + +"Try again" = "Повторите попытку"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Мы используем Stripe для проверки данных вашей карты. Stripe может использовать и хранить ваши данные в соответствии со своей политикой конфиденциальности. Подробнее"; + +"Your card has expired" = "Срок действия карты истек"; + +"Your card was declined" = "Карта отклонена"; + +"Your card's expiration month is invalid" = "Недопустимый месяц окончания действия карты"; + +"Your card's expiration year is invalid" = "Недопустимый год окончания действия карты"; + +"Your card's number is invalid" = "Номер карты недействителен"; + +"Your card's security code is invalid" = "Недопустимый код CVV/CVC карты"; + +"Your name is invalid." = "Недействительное имя."; + +"Your payment method was declined." = "Не удалось выполнить оплату этим способом."; diff --git a/StripeCore/StripeCore/Resources/Localizations/sk-SK.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/sk-SK.lproj/Localizable.strings new file mode 100644 index 00000000..b0d16f13 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/sk-SK.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Zatvoriť"; + +"Scan Card" = "Naskenovať kartu"; + +"Scan card" = "Naskenovať kartu"; + +"The IBAN you entered is invalid." = "Zadaný IBAN je neplatný."; + +"There was an error processing your card -- try again in a few seconds" = "Pri spracovaní vašej karty sa vyskytla chyba -- skúste znova o niekoľko sekúnd"; + +"There was an unexpected error -- try again in a few seconds" = "Vyskytla sa neočakávaná chyba -- skúste znova o niekoľko sekúnd"; + +"Try again" = "Skúsiť znova"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Na overenie údajov o vašej karte používame službu Stripe. Spoločnosť Stripe môže používať a uchovávať vaše údaje v súlade so zásadami ochrany osobných údajov. Ďalšie informácie"; + +"Your card has expired" = "Platnosť vašej karty vypršala"; + +"Your card was declined" = "Vaša karta bola odmietnutá"; + +"Your card's expiration month is invalid" = "Mesiac ukončenia platnosti vašej karty je neplatný"; + +"Your card's expiration year is invalid" = "Rok ukončenia platnosti vašej karty je neplatný"; + +"Your card's number is invalid" = "Číslo vašej karty je neplatné"; + +"Your card's security code is invalid" = "Bezpečnostný kód vašej karty je neplatný"; + +"Your name is invalid." = "Vaše meno je neplatné."; + +"Your payment method was declined." = "Váš spôsob platby bol odmietnutý."; diff --git a/StripeCore/StripeCore/Resources/Localizations/sl-SI.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/sl-SI.lproj/Localizable.strings new file mode 100644 index 00000000..fb120e85 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/sl-SI.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Zapri"; + +"Scan Card" = "Optično preberi kartico"; + +"Scan card" = "Optično preberi kartico"; + +"The IBAN you entered is invalid." = "Vnesena koda IBAN ni veljavna."; + +"There was an error processing your card -- try again in a few seconds" = "Pri obdelavi vaše kartice je prišlo do napake. Poskusite znova čez nekaj sekund."; + +"There was an unexpected error -- try again in a few seconds" = "Prišlo je do nepričakovane napake. Poskusite znova čez nekaj sekund."; + +"Try again" = "Poskusi znova"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Za preverjanje podatkov o vaši kartici uporabljamo storitev Stripe. Stripe lahko vaše podatke uporablja in hrani v skladu s svojim pravilnikom o zasebnosti. Več informacij"; + +"Your card has expired" = "Vaša kartica je potekla"; + +"Your card was declined" = "Vaša kartica je bila zavrnjena"; + +"Your card's expiration month is invalid" = "Mesec poteka vaše kartice ni veljaven"; + +"Your card's expiration year is invalid" = "Leto poteka vaše kartice ni veljavno"; + +"Your card's number is invalid" = "Številka vaše kartice ni veljavna"; + +"Your card's security code is invalid" = "Varnostna koda vaše kartice ni veljavna"; + +"Your name is invalid." = "Vaše ime ni veljavno."; + +"Your payment method was declined." = "Vaš način plačila je bil zavrnjen."; diff --git a/StripeCore/StripeCore/Resources/Localizations/sv.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/sv.lproj/Localizable.strings new file mode 100644 index 00000000..46080f53 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/sv.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Stäng"; + +"Scan Card" = "Skanna kort"; + +"Scan card" = "Skanna kort"; + +"The IBAN you entered is invalid." = "Angivet IBAN är ogiltigt."; + +"There was an error processing your card -- try again in a few seconds" = "Ett fel uppstod vid behandlingen av ditt kort - försök igen om ett par sekunder"; + +"There was an unexpected error -- try again in a few seconds" = "Ett oväntat fel uppstod - försök igen om ett par sekunder"; + +"Try again" = "Försök igen"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Vi använder Stripe för att verifiera dina kortuppgifter. Stripe kan komma att använda och lagra dina uppgifter i enlighet med sin integritetspolicy. Läs mer"; + +"Your card has expired" = "Kortet har löpt ut"; + +"Your card was declined" = "Ditt kort nekades"; + +"Your card's expiration month is invalid" = "Kortets utgångsmånad är ogiltig"; + +"Your card's expiration year is invalid" = "Kortets utgångsår är ogiltigt"; + +"Your card's number is invalid" = "Kortnumret är ogiltigt"; + +"Your card's security code is invalid" = "Kortets säkerhetskod är ogiltig"; + +"Your name is invalid." = "Ditt namn är ogiltigt."; + +"Your payment method was declined." = "Din betalningsmetod godkändes inte."; diff --git a/StripeCore/StripeCore/Resources/Localizations/tr.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/tr.lproj/Localizable.strings new file mode 100644 index 00000000..362d4dc0 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/tr.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Kapat"; + +"Scan Card" = "Kartı Tara"; + +"Scan card" = "Kartı tara"; + +"The IBAN you entered is invalid." = "Girdiğiniz IBAN geçersiz."; + +"There was an error processing your card -- try again in a few seconds" = "Kartınızla işlem yapılırken bir hata oluştu -- birazdan tekrar deneyin"; + +"There was an unexpected error -- try again in a few seconds" = "Beklenmedik bir hata oluştu -- birazdan tekrar deneyin"; + +"Try again" = "Tekrar dene"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Kart bilgilerinizi doğrulamak için Stripe kullanıyoruz. Stripe verilerinizi gizlilik politikasına uygun şekilde kullanabilir ve saklayabilir.Daha fazlasını öğrenin"; + +"Your card has expired" = "Kartınızın kullanım süresi dolmuş"; + +"Your card was declined" = "Kartınız reddedildi"; + +"Your card's expiration month is invalid" = "Kartınızın son kullanma ayı geçersiz"; + +"Your card's expiration year is invalid" = "Kartınızın son kullanma yılı geçersiz"; + +"Your card's number is invalid" = "Kart numaranız geçersiz"; + +"Your card's security code is invalid" = "Kartınızın güvenlik kodu geçersiz"; + +"Your name is invalid." = "Adınız geçersiz."; + +"Your payment method was declined." = "Ödeme yönteminiz reddedildi."; diff --git a/StripeCore/StripeCore/Resources/Localizations/vi.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/vi.lproj/Localizable.strings new file mode 100644 index 00000000..964581ae --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/vi.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "Đóng"; + +"Scan Card" = "Quét thẻ"; + +"Scan card" = "Quét thẻ"; + +"The IBAN you entered is invalid." = "IBAN quý vị đã nhập không hợp lệ."; + +"There was an error processing your card -- try again in a few seconds" = "Có lỗi bất ngờ khi xử lý thẻ -- hãy thử lại sau vài giây"; + +"There was an unexpected error -- try again in a few seconds" = "Có lỗi bất ngờ -- hãy thử lại sau vài giây"; + +"Try again" = "Thử lại"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "Chúng tôi sử dụng Stripe để xác minh chi tiết thẻ của bạn. Stripe có thể sử dụng và lưu trữ dữ liệu của bạn theo chính sách quyền riêng tư của họ. Tìm hiểu thêm"; + +"Your card has expired" = "Thẻ đã hết hạn"; + +"Your card was declined" = "Thẻ bị từ chối"; + +"Your card's expiration month is invalid" = "Tháng hết hạn trên thẻ không hợp lệ"; + +"Your card's expiration year is invalid" = "Năm hết hạn trên thẻ không hợp lệ"; + +"Your card's number is invalid" = "Số thẻ không đúng"; + +"Your card's security code is invalid" = "Mã bảo mật của thẻ không đúng"; + +"Your name is invalid." = "Tên của quý vị không hợp lệ."; + +"Your payment method was declined." = "Phương thức thanh toán của quý vị đã bị từ chối."; diff --git a/StripeCore/StripeCore/Resources/Localizations/zh-HK.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/zh-HK.lproj/Localizable.strings new file mode 100644 index 00000000..4fab6a8c --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/zh-HK.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "關閉"; + +"Scan Card" = "掃描銀行卡"; + +"Scan card" = "掃描卡"; + +"The IBAN you entered is invalid." = "您輸入的 IBAN 無效。"; + +"There was an error processing your card -- try again in a few seconds" = "處理您的卡時發生了錯誤 -- 請稍候再試"; + +"There was an unexpected error -- try again in a few seconds" = "發生了意外錯誤 -- 請稍候再試"; + +"Try again" = "重試"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "我們用 Stripe 來驗證您的銀行卡資訊。Stripe 可能會根據其私隱政策使用並存儲您的資料。瞭解更多"; + +"Your card has expired" = "您的卡已過期"; + +"Your card was declined" = "您的卡已被拒絕"; + +"Your card's expiration month is invalid" = "您的銀行卡的到期月份無效"; + +"Your card's expiration year is invalid" = "您的銀行卡的到期年份無效"; + +"Your card's number is invalid" = "您的卡號無效"; + +"Your card's security code is invalid" = "您的銀行卡的安全碼無效"; + +"Your name is invalid." = "您的姓名無效。"; + +"Your payment method was declined." = "您的支付方式被拒絕了。"; diff --git a/StripeCore/StripeCore/Resources/Localizations/zh-Hans.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/zh-Hans.lproj/Localizable.strings new file mode 100644 index 00000000..4f7e5dd1 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "关闭"; + +"Scan Card" = "扫描银行卡"; + +"Scan card" = "扫描卡"; + +"The IBAN you entered is invalid." = "您输入的 IBAN 无效。"; + +"There was an error processing your card -- try again in a few seconds" = "处理您的卡时发生错误 —— 请稍后再试"; + +"There was an unexpected error -- try again in a few seconds" = "发生了意外错误 —— 请过几秒钟再试"; + +"Try again" = "重试"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "我们用 Stripe 来验证您的银行卡信息。Stripe 可能会根据其隐私政策使用并存储您的数据。了解更多"; + +"Your card has expired" = "您的银行卡已过期"; + +"Your card was declined" = "您的卡已被拒绝"; + +"Your card's expiration month is invalid" = "您的银行卡的到期月份无效"; + +"Your card's expiration year is invalid" = "您的银行卡的到期年份无效。"; + +"Your card's number is invalid" = "您的卡号无效"; + +"Your card's security code is invalid" = "您的银行卡的安全码无效"; + +"Your name is invalid." = "您的姓名无效。"; + +"Your payment method was declined." = "您的支付方式被拒绝了。"; diff --git a/StripeCore/StripeCore/Resources/Localizations/zh-Hant.lproj/Localizable.strings b/StripeCore/StripeCore/Resources/Localizations/zh-Hant.lproj/Localizable.strings new file mode 100644 index 00000000..4ae13cc1 --- /dev/null +++ b/StripeCore/StripeCore/Resources/Localizations/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"Close" = "關閉"; + +"Scan Card" = "掃描金融卡"; + +"Scan card" = "掃描卡"; + +"The IBAN you entered is invalid." = "您輸入的 IBAN 無效。"; + +"There was an error processing your card -- try again in a few seconds" = "處理您的卡時發生錯誤 -- 請等待幾秒後再試"; + +"There was an unexpected error -- try again in a few seconds" = "發生了意外錯誤 -- 請等待幾秒後再試一次"; + +"Try again" = "重試"; + +"We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more" = "我們用 Stripe 來驗證您的金融卡資訊。Stripe 可能會根據其隱私政策使用並存儲您的資料。瞭解更多"; + +"Your card has expired" = "您的卡已過期"; + +"Your card was declined" = "您的卡已被拒絕"; + +"Your card's expiration month is invalid" = "您的金融卡的到期月份無效"; + +"Your card's expiration year is invalid" = "您的金融卡的到期年份無效"; + +"Your card's number is invalid" = "您的卡號無效"; + +"Your card's security code is invalid" = "您的金融卡的安全碼無效"; + +"Your name is invalid." = "您的名稱無效。"; + +"Your payment method was declined." = "您的支付方式被拒絕了。"; diff --git a/StripeCore/StripeCore/Source/API Bindings/Models/EmptyResponse.swift b/StripeCore/StripeCore/Source/API Bindings/Models/EmptyResponse.swift new file mode 100644 index 00000000..a0da190c --- /dev/null +++ b/StripeCore/StripeCore/Source/API Bindings/Models/EmptyResponse.swift @@ -0,0 +1,14 @@ +// +// EmptyResponse.swift +// StripeCore +// +// Created by Jaime Park on 11/19/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// This is an object representing an empty response from a request. +@_spi(STP) public struct EmptyResponse: UnknownFieldsDecodable { + public var _allResponseFieldsStorage: NonEncodableParameters? +} diff --git a/StripeCore/StripeCore/Source/API Bindings/Models/StripeFile.swift b/StripeCore/StripeCore/Source/API Bindings/Models/StripeFile.swift new file mode 100644 index 00000000..49a625fb --- /dev/null +++ b/StripeCore/StripeCore/Source/API Bindings/Models/StripeFile.swift @@ -0,0 +1,45 @@ +// +// StripeFile.swift +// +// Generated by swagger-codegen +// https://github.com/swagger-api/swagger-codegen +// + +import Foundation + +/// This is an object representing a file hosted on Stripe's servers. +/// +/// The file may have been uploaded by yourself using the +/// [create file](https://stripe.com/docs/api#create_file) request +/// (for example, when uploading dispute evidence) or it may have been created by Stripe +/// (for example, the results of a [Sigma scheduled query](#scheduled_queries)). +/// Related guide: [File Upload Guide](https://stripe.com/docs/file-upload). +@_spi(STP) public struct StripeFile: UnknownFieldsDecodable, Equatable { + @frozen public enum Purpose: String, SafeEnumCodable, Equatable { + // NOTE: If adding cases here that should also be available to the + // public API, please also add to `STPFilePurpose`. This is not + // necessary for cases that are only used internally. + + /// Dispute evidence file. + case disputeEvidence = "dispute_evidence" + /// Identity document file. + case identityDocument = "identity_document" + /// Identity document file used only internally. + case identityPrivate = "identity_private" + /// Not a valid purpose – only used for `SafeEnumCodable` conformance. + case unparsable = "" + } + /// Time at which the object was created. + /// + /// Measured in seconds since the Unix epoch. + public let created: Date + /// Unique identifier for the object. + public let id: String + /// The [purpose](https://stripe.com/docs/file-upload#uploading-a-file) of the uploaded file. + public let purpose: Purpose + /// The size in bytes of the file object. + public let size: Int + /// The type of the file returned (e.g., `csv`, `pdf`, `jpg`, or `png`). + public let type: String? + public var _allResponseFieldsStorage: NonEncodableParameters? +} diff --git a/StripeCore/StripeCore/Source/API Bindings/STPAPIClient+FileUpload.swift b/StripeCore/StripeCore/Source/API Bindings/STPAPIClient+FileUpload.swift new file mode 100644 index 00000000..36740aaa --- /dev/null +++ b/StripeCore/StripeCore/Source/API Bindings/STPAPIClient+FileUpload.swift @@ -0,0 +1,234 @@ +// +// STPAPIClient+FileUpload.swift +// StripeCore +// +// Created by Mel Ludowise on 11/9/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +extension StripeFile.Purpose { + /// See max purpose sizes https://stripe.com/docs/file-upload. + var maxBytes: Int? { + switch self { + case .identityDocument, + .identityPrivate: + return 16_000_000 + case .disputeEvidence: + return 5_000_000 + case .unparsable: + return nil + } + } +} + +/// STPAPIClient extensions to upload files. +extension STPAPIClient { + @_spi(STP) public typealias FileAndUploadMetrics = ( + file: StripeFile, + metrics: ImageUploadMetrics + ) + + /// Metrics returned in callback after image is uploaded to track performance. + @_spi(STP) public struct ImageUploadMetrics { + public let timeToUpload: TimeInterval + public let fileSizeBytes: Int + } + + @_spi(STP) public static let defaultImageFileName = "image" + + func data( + forUploadedImage image: UIImage, + compressionQuality: CGFloat, + purpose: String + ) -> Data { + // Get maxBytes if file purpose is known to the client + let maxBytes = StripeFile.Purpose(rawValue: purpose)?.maxBytes + return image.jpegDataAndDimensions( + maxBytes: maxBytes, + compressionQuality: compressionQuality + ).imageData + } + + /// Uses the Stripe file upload API to upload a JPEG encoded image. + /// + /// The image will be automatically resized down if: + /// 1. The given purpose is recognized by the client. + /// 2. It's larger than the maximum allowed file size for the given purpose. + /// + /// - Parameters: + /// - image: The image to be uploaded. + /// - compressionQuality: The compression quality to use when encoding the jpeg. + /// - purpose: The purpose of this file. + /// - fileName: The name of the uploaded file. The "jpeg" extension will + /// automatically be appended to this name. + /// - ownedBy: A Stripe-internal property that sets the owner of the file. + /// - ephemeralKeySecret: Authorization key, if applicable. + /// - completion: The callback to run with the returned Stripe file (and any + /// errors that may have occurred). + /// + /// - Note: + /// The provided `purpose` must match a supported Purpose by Stripe's File + /// Upload API, or the API will respond with an error. Generally, this should + /// match a value in `StripeFile.Purpose`, but can be specified by any string + /// when forwarding the value from a Stripe server response in situations + /// where the purpose is not yet encoded in the client SDK. + @_spi(STP) public func uploadImage( + _ image: UIImage, + compressionQuality: CGFloat = UIImage.defaultCompressionQuality, + purpose: String, + fileName: String = defaultImageFileName, + ownedBy: String? = nil, + ephemeralKeySecret: String? = nil, + completion: @escaping (Result) -> Void + ) { + uploadImageAndGetMetrics( + image, + compressionQuality: compressionQuality, + purpose: purpose, + fileName: fileName, + ownedBy: ownedBy, + ephemeralKeySecret: ephemeralKeySecret + ) { result in + completion(result.map { $0.file }) + } + } + + @_spi(STP) public func uploadImageAndGetMetrics( + _ image: UIImage, + compressionQuality: CGFloat = UIImage.defaultCompressionQuality, + purpose: String, + fileName: String = defaultImageFileName, + ownedBy: String? = nil, + ephemeralKeySecret: String? = nil, + completion: @escaping (Result) -> Void + ) { + let purposePart = STPMultipartFormDataPart() + purposePart.name = "purpose" + // `unparsable` is not a valid purpose + if purpose != StripeFile.Purpose.unparsable.rawValue, + let purposeData = purpose.data(using: .utf8) + { + purposePart.data = purposeData + } + + let imagePart = STPMultipartFormDataPart() + imagePart.name = "file" + imagePart.filename = "\(fileName).jpg" + imagePart.contentType = "image/jpeg" + imagePart.data = self.data( + forUploadedImage: image, + compressionQuality: compressionQuality, + purpose: purpose + ) + + let ownedByPart: STPMultipartFormDataPart? = ownedBy?.data(using: .utf8).map { ownedByData in + let part = STPMultipartFormDataPart() + part.name = "owned_by" + part.data = ownedByData + return part + } + + let boundary = STPMultipartFormDataEncoder.generateBoundary() + let parts = [purposePart, ownedByPart, imagePart].compactMap { $0 } + let data = STPMultipartFormDataEncoder.multipartFormData( + for: parts, + boundary: boundary + ) + + var request = configuredRequest( + for: URL(string: FileUploadURL)!, + using: ephemeralKeySecret + ) + request.httpMethod = HTTPMethod.post.rawValue + request.stp_setMultipartForm(data, boundary: boundary) + + let requestStartTime = Date() + sendRequest( + request: request, + completion: { (result: Result) in + let timeToUpload = Date().timeIntervalSince(requestStartTime) + completion( + result.map { + ( + file: $0, + metrics: .init( + timeToUpload: timeToUpload, + fileSizeBytes: imagePart.data?.count ?? 0 + ) + ) + } + ) + } + ) + } + + /// Uses the Stripe file upload API to upload a JPEG encoded image. + /// + /// The image will be automatically resized down if: + /// 1. The given purpose is recognized by the client. + /// 2. It's larger than the maximum allowed file size for the given purpose. + /// + /// - Parameters: + /// - image: The image to be uploaded. + /// - compressionQuality: The compression quality to use when encoding the jpeg. + /// - purpose: The purpose of this file. + /// - fileName: The name of the uploaded file. The "jpeg" extension will + /// automatically be appended to this name. + /// - ownedBy: A Stripe-internal property that sets the owner of the file. + /// - ephemeralKeySecret: Authorization key, if applicable. + /// + /// - Returns: A promise that resolves to a Stripe file, if successful, or an + /// error that may have occurred. + /// + /// - Note: + /// The provided `purpose` must match a supported Purpose by our API or the + /// API will return an error. Generally, this should match a value in + /// `StripeFile.Purpose`, but can be specified by any string for instances + /// where a Stripe endpoint needs to specify a newer purpose that the client + /// SDK does not recognize. + @_spi(STP) public func uploadImage( + _ image: UIImage, + compressionQuality: CGFloat = UIImage.defaultCompressionQuality, + purpose: String, + fileName: String = defaultImageFileName, + ownedBy: String? = nil, + ephemeralKeySecret: String? = nil + ) -> Future { + return uploadImageAndGetMetrics( + image, + compressionQuality: compressionQuality, + purpose: purpose, + fileName: fileName, + ownedBy: ownedBy, + ephemeralKeySecret: ephemeralKeySecret + ).chained { Promise(value: $0.file) } + } + + @_spi(STP) public func uploadImageAndGetMetrics( + _ image: UIImage, + compressionQuality: CGFloat = UIImage.defaultCompressionQuality, + purpose: String, + fileName: String = defaultImageFileName, + ownedBy: String? = nil, + ephemeralKeySecret: String? = nil + ) -> Future { + let promise = Promise() + uploadImageAndGetMetrics( + image, + compressionQuality: compressionQuality, + purpose: purpose, + fileName: fileName, + ownedBy: ownedBy, + ephemeralKeySecret: ephemeralKeySecret + ) { result in + promise.fullfill(with: result) + } + return promise + } + +} + +private let FileUploadURL = "https://uploads.stripe.com/v1/files" diff --git a/StripeCore/StripeCore/Source/API Bindings/STPAPIClient.swift b/StripeCore/StripeCore/Source/API Bindings/STPAPIClient.swift new file mode 100644 index 00000000..5b5fc489 --- /dev/null +++ b/StripeCore/StripeCore/Source/API Bindings/STPAPIClient.swift @@ -0,0 +1,547 @@ +// +// STPAPIClient.swift +// StripeCore +// +// Created by Jack Flintermann on 12/18/14. +// Copyright (c) 2014 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +/// A client for making connections to the Stripe API. +@objc public class STPAPIClient: NSObject { + /// The current version of this library. + @objc public static let STPSDKVersion = StripeAPIConfiguration.STPSDKVersion + + /// A shared singleton API client. + /// + /// By default, the SDK uses this instance to make API requests + /// eg in STPPaymentHandler, STPPaymentContext, STPCustomerContext, etc. + @objc(sharedClient) public static let shared: STPAPIClient = { + let client = STPAPIClient() + return client + }() + + /// The client's publishable key. + /// + /// The default value is `StripeAPI.defaultPublishableKey`. + @objc public var publishableKey: String? { + get { + if let publishableKey = _publishableKey { + return publishableKey + } + return StripeAPI.defaultPublishableKey + } + set { + _publishableKey = newValue + Self.validateKey(newValue) + } + } + var _publishableKey: String? + + /// A publishable key that only contains publishable keys and not secret keys. + /// + /// If a secret key is found, returns "[REDACTED_LIVE_KEY]". + var sanitizedPublishableKey: String? { + guard let publishableKey = publishableKey else { + return nil + } + + return (publishableKey.isSecretKey || publishableKeyIsUserKey) + ? "[REDACTED_LIVE_KEY]" : publishableKey + } + + // Stored STPPaymentConfiguration: Type checking handled in STPAPIClient+Payments.swift. + @_spi(STP) public var _stored_configuration: NSObject? + + /// In order to perform API requests on behalf of a connected account, e.g. to + /// create a Source or Payment Method on a connected account, set this property to the ID of the + /// account for which this request is being made. + /// + /// - seealso: https://stripe.com/docs/connect/authentication#authentication-via-the-stripe-account-header + @objc public var stripeAccount: String? + + /// Libraries wrapping the Stripe SDK should set this, so that Stripe can contact you + /// about future issues or critical updates. + /// + /// - seealso: https://stripe.com/docs/building-plugins#setappinfo + @objc public var appInfo: STPAppInfo? + + /// The API version used to communicate with Stripe. + @objc public static let apiVersion = APIVersion + + // MARK: Internal/private properties + @_spi(STP) public var apiURL: URL! = URL(string: APIBaseURL) + @_spi(STP) public var urlSession = URLSession( + configuration: StripeAPIConfiguration.sharedUrlSessionConfiguration + ) + + @_spi(STP) public var sourcePollers: [String: NSObject]? + @_spi(STP) public var sourcePollersQueue: DispatchQueue? + /// A set of beta headers to add to Stripe API requests e.g. `Set(["alipay_beta=v1"])`. + @_spi(STP) public var betas: Set = [] + + /// Returns `true` if `publishableKey` is actually a user key, `false` otherwise. + @_spi(STP) public var publishableKeyIsUserKey: Bool { + return publishableKey?.hasPrefix("uk_") ?? false + } + + /// Determines the `Stripe-Livemode` header value when the publishable key is a user key + @_spi(DashboardOnly) public var userKeyLiveMode = true + + // MARK: Initializers + override public init() { + sourcePollers = [:] + sourcePollersQueue = DispatchQueue(label: "com.stripe.sourcepollers") + } + + /// Initializes an API client with the given publishable key. + /// + /// - Parameter publishableKey: The publishable key to use. + /// - Returns: An instance of STPAPIClient. + @objc(initWithPublishableKey:) + public convenience init( + publishableKey: String + ) { + self.init() + self.publishableKey = publishableKey + } + + @_spi(STP) public func configuredRequest( + for url: URL, + using ephemeralKeySecret: String? = nil, + additionalHeaders: [String: String] = [:] + ) + -> URLRequest + { + var request = URLRequest(url: url) + var headers = defaultHeaders(ephemeralKeySecret: ephemeralKeySecret) + // additionalHeaders can overwrite defaultHeaders. + for (k, v) in additionalHeaders { headers[k] = v } + headers.forEach { key, value in + request.setValue(value, forHTTPHeaderField: key) + } + return request + } + + /// Headers common to all API requests for a given API Client. + func defaultHeaders(ephemeralKeySecret: String?) -> [String: String] { + var defaultHeaders: [String: String] = [:] + defaultHeaders["X-Stripe-User-Agent"] = STPAPIClient.stripeUserAgentDetails(with: appInfo) + var stripeVersion = APIVersion + for beta in betas { + stripeVersion += "; \(beta)" + } + defaultHeaders["Stripe-Version"] = stripeVersion + defaultHeaders["Stripe-Account"] = stripeAccount + for (k, v) in authorizationHeader(using: ephemeralKeySecret) { defaultHeaders[k] = v } + return defaultHeaders + } + + // MARK: Helpers + + static var didShowTestmodeKeyWarning = false + class func validateKey(_ publishableKey: String?) { + guard NSClassFromString("XCTest") == nil else { + return // no asserts in unit tests + } + guard let publishableKey = publishableKey, !publishableKey.isEmpty else { + assertionFailure( + "You must use a valid publishable key. For more info, see https://stripe.com/docs/keys" + ) + return + } + let secretKey = publishableKey.hasPrefix("sk_") + assert( + !secretKey, + "You are using a secret key. Use a publishable key instead. For more info, see https://stripe.com/docs/keys" + ) + #if !DEBUG + if publishableKey.lowercased().hasPrefix("pk_test") && !didShowTestmodeKeyWarning { + print( + "ℹ️ You're using your Stripe testmode key. Make sure to use your livemode key when submitting to the App Store!" + ) + didShowTestmodeKeyWarning = true + } + #endif + } + + class func stripeUserAgentDetails(with appInfo: STPAppInfo?) -> String { + var details: [String: String] = [ + // This SDK isn't in Objective-C anymore, but we sometimes check for + // 'objective-c' to enable iOS SDK-specific behavior in the API. + "lang": "objective-c", + "bindings_version": STPSDKVersion, + ] + let version = UIDevice.current.systemVersion + if version != "" { + details["os_version"] = version + } + var systemInfo = utsname() + uname(&systemInfo) + + // Thanks to https://stackoverflow.com/questions/26028918/how-to-determine-the-current-iphone-device-model + let machineMirror = Mirror(reflecting: systemInfo.machine) + let deviceType = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } + details["type"] = deviceType + let model = UIDevice.current.localizedModel + if model != "" { + details["model"] = model + } + + if let vendorIdentifier = UIDevice.current.identifierForVendor?.uuidString { + details["vendor_identifier"] = vendorIdentifier + } + if let appInfo = appInfo { + details["name"] = appInfo.name + details["partner_id"] = appInfo.partnerId + if appInfo.version != nil { + details["version"] = appInfo.version + } + if appInfo.url != nil { + details["url"] = appInfo.url + } + } + let data = try? JSONSerialization.data(withJSONObject: details, options: []) + return String(data: data ?? Data(), encoding: .utf8) ?? "" + } + + @_spi(STP) public func authorizationHeader( + using substituteAuthorizationBearer: String? = nil + ) -> [String: String] { + let authorizationBearer = substituteAuthorizationBearer ?? publishableKey ?? "" + var headers = ["Authorization": "Bearer " + authorizationBearer] + + if publishableKeyIsUserKey { + headers["Stripe-Livemode"] = userKeyLiveMode ? "true" : "false" + } + return headers + } + + @_spi(STP) public var isTestmode: Bool { + guard let publishableKey = publishableKey, !publishableKey.isEmpty else { + return false + } + return publishableKey.lowercased().hasPrefix("pk_test") || (publishableKeyIsUserKey && !userKeyLiveMode) + } +} + +private let APIVersion = "2020-08-27" +private let APIBaseURL = "https://api.stripe.com/v1" + +// MARK: Modern bindings +extension STPAPIClient { + /// Make a GET request using the passed parameters. + @_spi(STP) public func get( + resource: String, + parameters: [String: Any], + ephemeralKeySecret: String? = nil, + completion: @escaping ( + Result + ) -> Void + ) { + request( + method: .get, + parameters: parameters, + ephemeralKeySecret: ephemeralKeySecret, + resource: resource, + completion: completion + ) + } + + /// Make a GET request using the passed parameters. + @_spi(STP) public func get( + url: URL, + parameters: [String: Any], + ephemeralKeySecret: String? = nil, + completion: @escaping ( + Result + ) -> Void + ) { + request( + method: .get, + parameters: parameters, + ephemeralKeySecret: ephemeralKeySecret, + url: url, + completion: completion + ) + } + + /// Make a GET request using the passed parameters. + /// + /// - Returns: a promise that is fullfilled when the request is complete. + @_spi(STP) public func get( + resource: String, + parameters: [String: Any], + ephemeralKeySecret: String? = nil + ) -> Promise { + return request( + method: .get, + parameters: parameters, + ephemeralKeySecret: ephemeralKeySecret, + resource: resource + ) + } + + /// Make a POST request using the passed parameters. + @_spi(STP) public func post( + resource: String, + parameters: [String: Any], + ephemeralKeySecret: String? = nil, + completion: @escaping (Result) -> Void + ) { + request( + method: .post, + parameters: parameters, + ephemeralKeySecret: ephemeralKeySecret, + resource: resource, + completion: completion + ) + } + + /// Make a POST request using the passed parameters. + @_spi(STP) public func post( + url: URL, + parameters: [String: Any], + ephemeralKeySecret: String? = nil, + completion: @escaping (Result) -> Void + ) { + request( + method: .post, + parameters: parameters, + ephemeralKeySecret: ephemeralKeySecret, + url: url, + completion: completion + ) + } + + /// Make a POST request using the passed parameters. + /// + /// - Returns: a promise that is fullfilled when the request is complete. + @_spi(STP) public func post( + resource: String, + parameters: [String: Any], + ephemeralKeySecret: String? = nil + ) -> Promise { + return request( + method: .post, + parameters: parameters, + ephemeralKeySecret: ephemeralKeySecret, + resource: resource + ) + } + + func request( + method: HTTPMethod, + parameters: [String: Any], + ephemeralKeySecret: String?, + resource: String + ) -> Promise { + let promise = Promise() + self.request( + method: method, + parameters: parameters, + ephemeralKeySecret: ephemeralKeySecret, + resource: resource + ) { result in + promise.fullfill(with: result) + } + return promise + } + + func request( + method: HTTPMethod, + parameters: [String: Any], + ephemeralKeySecret: String?, + resource: String, + completion: @escaping (Result) -> Void + ) { + let url = apiURL.appendingPathComponent(resource) + request( + method: method, + parameters: parameters, + ephemeralKeySecret: ephemeralKeySecret, + url: url, + completion: completion + ) + } + + func request( + method: HTTPMethod, + parameters: [String: Any], + ephemeralKeySecret: String?, + url: URL, + completion: @escaping (Result) -> Void + ) { + var request = configuredRequest(for: url) + switch method { + case .get: + request.stp_addParameters(toURL: parameters) + case .post: + let formData = URLEncoder.queryString(from: parameters).data(using: .utf8) + request.httpBody = formData + request.setValue( + String(format: "%lu", UInt(formData?.count ?? 0)), + forHTTPHeaderField: "Content-Length" + ) + request.setValue( + "application/x-www-form-urlencoded", + forHTTPHeaderField: "Content-Type" + ) + } + + request.httpMethod = method.rawValue + for (k, v) in authorizationHeader(using: ephemeralKeySecret) { + request.setValue(v, forHTTPHeaderField: k) + } + + self.sendRequest(request: request, completion: completion) + } + + /// Make a POST request using the passed Encodable object. + /// + /// - Returns: a promise that is fullfilled when the request is complete. + @_spi(STP) public func post( + resource: String, + object: I, + ephemeralKeySecret: String? = nil + ) -> Promise { + let promise = Promise() + self.post( + resource: resource, + object: object, + ephemeralKeySecret: ephemeralKeySecret + ) { result in + promise.fullfill(with: result) + } + return promise + } + + /// Make a POST request using the passed Encodable object. + @_spi(STP) public func post( + resource: String, + object: I, + ephemeralKeySecret: String? = nil, + completion: @escaping (Result) -> Void + ) { + let url = apiURL.appendingPathComponent(resource) + post( + url: url, + object: object, + ephemeralKeySecret: ephemeralKeySecret, + completion: completion + ) + } + + /// Make a POST request using the passed Encodable object. + @_spi(STP) public func post( + url: URL, + object: I, + ephemeralKeySecret: String? = nil, + completion: @escaping (Result) -> Void + ) { + do { + let jsonDictionary = try object.encodeJSONDictionary() + let formData = URLEncoder.queryString(from: jsonDictionary).data(using: .utf8) + var request = configuredRequest( + for: url, + using: ephemeralKeySecret, + additionalHeaders: [ + "Content-Length": String(format: "%lu", UInt(formData?.count ?? 0)), + "Content-Type": "application/x-www-form-urlencoded", + ] + ) + request.httpBody = formData + request.httpMethod = HTTPMethod.post.rawValue + + self.sendRequest(request: request, completion: completion) + } catch { + // JSONEncoder can only throw two possible exceptions: + // `invalidFloatingPointValue`, which will never be thrown because of + // our encoder's NonConformingFloatEncodingStrategy. + // The other is `invalidValue` if the top-level object doesn't encode any values. + // This should ~never happen, and if it does the object will be empty, + // so it should be safe to return the un-redacted underlying error. + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + + func sendRequest( + request: URLRequest, + completion: @escaping (Result) -> Void + ) { + urlSession.stp_performDataTask( + with: request, + completionHandler: { (data, response, error) in + DispatchQueue.main.async { + completion( + STPAPIClient.decodeResponse(data: data, error: error, response: response) + ) + } + } + ) + } + + @_spi(STP) public static func decodeResponse( + data: Data?, + error: Error?, + response: URLResponse? + ) -> Result { + if let error = error { + return .failure(error) + } + guard let data = data else { + return .failure(NSError.stp_genericFailedToParseResponseError()) + } + + do { + /// HACK: We must first check if EmptyResponses contain an error since it'll always parse successfully. + if T.self == EmptyResponse.self, + let decodedStripeError = decodeStripeErrorResponse(data: data, response: response) + { + return .failure(decodedStripeError) + } + + let decodedObject: T = try StripeJSONDecoder.decode(jsonData: data) + return .success(decodedObject) + } catch { + // Try decoding the error from the service if one is available + if let decodedStripeError = decodeStripeErrorResponse(data: data, response: response) { + return .failure(decodedStripeError) + } else { + // Return decoding error directly + return .failure(error) + } + } + } + + /// Decodes request data to see if it can be parsed as a Stripe error. + private static func decodeStripeErrorResponse( + data: Data, + response: URLResponse? + ) -> StripeError? { + var decodedError: StripeError? + + if let decodedErrorResponse: StripeAPIErrorResponse = try? StripeJSONDecoder.decode( + jsonData: data + ), + var apiError = decodedErrorResponse.error + { + apiError.statusCode = (response as? HTTPURLResponse)?.statusCode + decodedError = StripeError.apiError(apiError) + } + + return decodedError + } + + enum HTTPMethod: String { + case get = "GET" + case post = "POST" + } +} diff --git a/StripeCore/StripeCore/Source/API Bindings/STPAppInfo.swift b/StripeCore/StripeCore/Source/API Bindings/STPAppInfo.swift new file mode 100644 index 00000000..8799b592 --- /dev/null +++ b/StripeCore/StripeCore/Source/API Bindings/STPAppInfo.swift @@ -0,0 +1,45 @@ +// +// STPAppInfo.swift +// StripeCore +// +// Created by Yuki Tokuhiro on 6/20/19. +// Copyright © 2019 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// Libraries wrapping the Stripe SDK should use this object to provide information about the +/// library, and set it in on `STPAPIClient`. +/// +/// This information is passed to Stripe so that we can contact you about future issues or +/// critical updates. +/// - seealso: https://stripe.com/docs/building-plugins#setappinfo +@objc public class STPAppInfo: NSObject { + /// Initializes an instance of `STPAppInfo`. + /// + /// - Parameters: + /// - name: The name of your library (e.g. "MyAwesomeLibrary"). + /// - partnerId: Your Stripe Partner ID (e.g. "pp_partner_1234"). Required for Stripe Verified Partners, optional otherwise. + /// - version: The version of your library (e.g. "1.2.34"). Optional. + /// - url: The website for your library (e.g. "https://myawesomelibrary.info"). Optional. + @objc public init( + name: String, + partnerId: String?, + version: String?, + url: String? + ) { + self.name = name + self.partnerId = partnerId + self.version = version + self.url = url + } + + /// The name of your library (e.g. "MyAwesomeLibrary"). + @objc public private(set) var name: String + /// Your Stripe Partner ID (e.g. "pp_partner_1234"). + @objc public private(set) var partnerId: String? + /// The version of your library (e.g. "1.2.34"). + @objc public private(set) var version: String? + /// The website for your library (e.g. "https://myawesomelibrary.info"). + @objc public private(set) var url: String? +} diff --git a/StripeCore/StripeCore/Source/API Bindings/STPMultipartFormDataEncoder.swift b/StripeCore/StripeCore/Source/API Bindings/STPMultipartFormDataEncoder.swift new file mode 100644 index 00000000..c63e8d9f --- /dev/null +++ b/StripeCore/StripeCore/Source/API Bindings/STPMultipartFormDataEncoder.swift @@ -0,0 +1,38 @@ +// +// STPMultipartFormDataEncoder.swift +// StripeCore +// +// Created by Charles Scalesse on 12/1/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// Encoder class to generate the HTTP body data for a multipart/form-data request. +/// +/// - seealso: https://www.w3.org/TR/html401/interact/forms.html#h-17.13.4 +class STPMultipartFormDataEncoder: NSObject { + /// Generates the HTTP body data from an array of parts. + class func multipartFormData(for parts: [STPMultipartFormDataPart], boundary: String) -> Data { + var data = Data() + let boundaryData = "--\(boundary)\r\n".data(using: .utf8) + + for part in parts { + if let boundaryData = boundaryData { + data.append(boundaryData) + } + data.append(part.composedData()) + } + + if let data1 = "--\(boundary)--\r\n".data(using: .utf8) { + data.append(data1) + } + + return data + } + + /// Generates a unique boundary string to be used between parts. + class func generateBoundary() -> String { + return "Stripe-iOS-\(UUID().uuidString)" + } +} diff --git a/StripeCore/StripeCore/Source/API Bindings/STPMultipartFormDataPart.swift b/StripeCore/StripeCore/Source/API Bindings/STPMultipartFormDataPart.swift new file mode 100644 index 00000000..553e11a8 --- /dev/null +++ b/StripeCore/StripeCore/Source/API Bindings/STPMultipartFormDataPart.swift @@ -0,0 +1,63 @@ +// +// STPMultipartFormDataPart.swift +// StripeCore +// +// Created by Charles Scalesse on 12/1/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// Represents a single part of a multipart/form-data upload. +/// +/// - seealso: https://www.w3.org/TR/html401/interact/forms.html#h-17.13.4 +class STPMultipartFormDataPart: NSObject { + /// The data for this part. + var data: Data? + /// The name for this part. + var name: String? + /// The filename for this part. + /// + /// As a rule of thumb, this can be ommitted when the data is just an encoded string. + /// However, this is typically required for other types of binary file data (like images). + var filename: String? + /// The content type for this part. + /// + /// When omitted, the multipart/form-data standard assumes text/plain. + var contentType: String? + + // MARK: - Data Composition + + /// Returns the fully-composed data for this part. + func composedData() -> Data { + var data = Data() + + var contentDisposition = "Content-Disposition: form-data; name=\"\(name ?? "")\"" + if filename != nil { + contentDisposition += "; filename=\"\(filename ?? "")\"" + } + contentDisposition += "\r\n" + + if let data1 = contentDisposition.data(using: .utf8) { + data.append(data1) + } + + var contentType = "" + if let _contentType = self.contentType { + contentType.append("Content-Type: \(_contentType)\r\n") + } + contentType += "\r\n" + if let data1 = contentType.data(using: .utf8) { + data.append(data1) + } + + if let _data = self.data { + data.append(_data) + } + if let data1 = "\r\n".data(using: .utf8) { + data.append(data1) + } + + return data + } +} diff --git a/StripeCore/StripeCore/Source/API Bindings/StripeAPI.swift b/StripeCore/StripeCore/Source/API Bindings/StripeAPI.swift new file mode 100644 index 00000000..ad194508 --- /dev/null +++ b/StripeCore/StripeCore/Source/API Bindings/StripeAPI.swift @@ -0,0 +1,190 @@ +// +// StripeAPI.swift +// StripeCore +// +// Created by Yuki Tokuhiro on 9/22/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import Foundation +import PassKit + +/// A top-level class that imports the rest of the Stripe SDK. +@objc public class StripeAPI: NSObject { + /// Set this to your Stripe publishable API key, obtained from https://dashboard.stripe.com/apikeys. + /// + /// Set this as early as possible in your application's lifecycle, preferably in your AppDelegate or SceneDelegate. + /// New instances of STPAPIClient will be initialized with this value. + /// @warning Make sure not to ship your test API keys to the App Store! This will log a warning if you use your test key in a release build. + @objc public static var defaultPublishableKey: String? + + /// Set this to your Stripe publishable API key, obtained from https://dashboard.stripe.com/apikeys. + /// + /// Set this as early as possible in your application's lifecycle, preferably in your AppDelegate or SceneDelegate. + /// New instances of STPAPIClient will be initialized with this value. + /// @warning Make sure not to ship your test API keys to the App Store! This will log a warning if you use your test key in a release build. + @objc public func setDefaultPublishableKey(_ publishableKey: String) { + StripeAPI.defaultPublishableKey = publishableKey + } + + /// A Boolean value that determines whether additional device data is sent to Stripe for fraud prevention. + /// + /// If YES, additional device signals will be sent to Stripe. + /// For more details on the information we collect, visit https://stripe.com/docs/disputes/prevention/advanced-fraud-detection + /// Disabling this setting will reduce Stripe's ability to protect your business from fraudulent payments. + /// The default value is YES. + @objc public static var advancedFraudSignalsEnabled: Bool = true + + /// If the SDK receives a "Too Many Requests" (429) status code from Stripe, + /// it will automatically retry the request. + /// + /// The default value is 3. + /// See https://stripe.com/docs/rate-limits for more information. + @objc public static var maxRetries = 3 + + // MARK: - Apple Pay + + /// Japanese users can enable JCB for Apple Pay by setting this to `YES`, + /// after they have been approved by JCB. + /// + /// The default value is NO. + /// @note JCB is only supported on iOS 10.1+ + @objc public class var jcbPaymentNetworkSupported: Bool { + get { + return self.additionalEnabledApplePayNetworks.contains(.JCB) + } + set(JCBPaymentNetworkSupported) { + if JCBPaymentNetworkSupported + && !self.additionalEnabledApplePayNetworks.contains(.JCB) + { + self.additionalEnabledApplePayNetworks = + self.additionalEnabledApplePayNetworks + [PKPaymentNetwork.JCB] + } else if !JCBPaymentNetworkSupported { + var updatedNetworks = self.additionalEnabledApplePayNetworks + updatedNetworks.removeAll { + $0 as AnyObject === PKPaymentNetwork.JCB as AnyObject + } + self.additionalEnabledApplePayNetworks = updatedNetworks + } + } + } + /// The SDK accepts Amex, Mastercard, Visa, and Discover for Apple Pay. + /// + /// Set this property to enable other card networks in addition to these. + /// For example, `additionalEnabledApplePayNetworks = [.JCB]` enables JCB (note this requires onboarding from JCB and Stripe). + @objc public static var additionalEnabledApplePayNetworks: [PKPaymentNetwork] = [] { + didSet { + // Reset deviceSupportsApplePay for the updated network list: + _deviceSupportsApplePay = nil + } + } + + /// Whether or not this device is capable of using Apple Pay. + /// + /// This checks both whether the device supports Apple Pay, as well as whether or not they have + /// stored Apple Pay cards on their device. + /// + /// - Parameter paymentRequest: The return value of this method depends on the + /// `supportedNetworks` property of this payment request, which by default should be + /// `[.amex, .masterCard, .visa, .discover]`. + /// - Returns: whether or not the user is currently able to pay with Apple Pay. + @objc public class func canSubmitPaymentRequest(_ paymentRequest: PKPaymentRequest) -> Bool { + if !self.deviceSupportsApplePay() { + return false + } + if paymentRequest.merchantIdentifier.isEmpty { + return false + } + // "In versions of iOS prior to version 12.0 and watchOS prior to version 5.0, the amount of the grand total must be greater than zero." + return paymentRequest.paymentSummaryItems.last?.amount.floatValue ?? 0.0 >= 0 + } + + class func supportedPKPaymentNetworks() -> [PKPaymentNetwork] { + return [ + .amex, + .masterCard, + .maestro, + .visa, + .discover, + ] + additionalEnabledApplePayNetworks + } + + /// Whether or not this can make Apple Pay payments via a card network supported + /// by Stripe. + /// + /// The Stripe supported Apple Pay card networks are: + /// American Express, Visa, Mastercard, Discover, Maestro. + /// Japanese users can enable JCB by setting `JCBPaymentNetworkSupported` to YES, + /// after they have been approved by JCB. + /// - Returns: YES if the device is currently able to make Apple Pay payments via one + /// of the supported networks. NO if the user does not have a saved card of a + /// supported type, or other restrictions prevent payment (such as parental controls). + @objc public class func deviceSupportsApplePay() -> Bool { + if let deviceSupportsApplePay = _deviceSupportsApplePay { + return deviceSupportsApplePay + } + let deviceSupportsApplePay = PKPaymentAuthorizationController.canMakePayments( + usingNetworks: self.supportedPKPaymentNetworks() + ) + _deviceSupportsApplePay = deviceSupportsApplePay + return deviceSupportsApplePay + } + + /// Cached value of deviceSupportsApplePay + /// `PKPaymentAuthorizationController.canMakePayments` is very slow on macOS Catalyst, so we only request once per process + /// or when the additional networks list changes. + /// This should only return `false` based on the current hardware or parental controls. We don't expect these to change + /// during the life of the process. + private static var _deviceSupportsApplePay: Bool? + + /// A convenience method to build a `PKPaymentRequest` with sane default values. + /// + /// You will still need to configure the `paymentSummaryItems` property to indicate + /// what the user is purchasing, as well as the optional `requiredShippingContactFields`, + /// `requiredBillingContactFields`, and `shippingMethods` properties to indicate + /// what additional contact information your application requires. + /// - Parameters: + /// - merchantIdentifier: Your Apple Merchant ID. + /// - countryCode: The two-letter code for the country where the payment + /// will be processed. This should be the country of your Stripe account. + /// - currencyCode: The three-letter code for the currency used by this + /// payment request. Apple Pay interprets the amounts provided by the summary items + /// attached to this request as amounts in this currency. + /// - Returns: a `PKPaymentRequest` with proper default values. + @objc(paymentRequestWithMerchantIdentifier:country:currency:) + public class func paymentRequest( + withMerchantIdentifier merchantIdentifier: String, + country countryCode: String, + currency currencyCode: String + ) -> PKPaymentRequest { + let paymentRequest = PKPaymentRequest() + paymentRequest.merchantIdentifier = merchantIdentifier + paymentRequest.supportedNetworks = self.supportedPKPaymentNetworks() + paymentRequest.merchantCapabilities = .capability3DS + paymentRequest.countryCode = countryCode.uppercased() + paymentRequest.currencyCode = currencyCode.uppercased() + paymentRequest.requiredBillingContactFields = Set([.postalAddress]) + return paymentRequest + } + + // MARK: - URL callbacks + + /// Call this method in your app delegate whenever you receive an URL in your + /// app delegate for a Stripe callback. + /// + /// For convenience, you can pass all URL's you receive in your app delegate + /// to this method first, and check the return value + /// to easily determine whether it is a callback URL that Stripe will handle + /// or if your app should process it normally. + /// If you are using a universal link URL, you will receive the callback in `application:continueUserActivity:restorationHandler:` + /// To learn more about universal links, see https://developer.apple.com/library/content/documentation/General/Conceptual/AppSearch/UniversalLinks.html + /// If you are using a native scheme URL, you will receive the callback in `application:openURL:options:` + /// To learn more about native url schemes, see https://developer.apple.com/library/content/documentation/iPhone/Conceptual/iPhoneOSProgrammingGuide/Inter-AppCommunication/Inter-AppCommunication.html#//apple_ref/doc/uid/TP40007072-CH6-SW10 + /// - Parameter url: The URL that you received in your app delegate + /// - Returns: YES if the URL is expected and will be handled by Stripe. NO otherwise. + @objc(handleStripeURLCallbackWithURL:) @discardableResult public static func handleURLCallback( + with url: URL + ) -> Bool { + return STPURLCallbackHandler.shared().handleURLCallback(url) + } +} diff --git a/StripeCore/StripeCore/Source/API Bindings/StripeAPIConfiguration+Version.swift b/StripeCore/StripeCore/Source/API Bindings/StripeAPIConfiguration+Version.swift new file mode 100644 index 00000000..64a42f27 --- /dev/null +++ b/StripeCore/StripeCore/Source/API Bindings/StripeAPIConfiguration+Version.swift @@ -0,0 +1,19 @@ +// +// StripeAPIConfiguration+Version.swift +// +// This file was generated by update_version.sh +// Do not edit this file directly. +// Instead, edit the `VERSION` file and run `ci_scripts/update_version.sh` +// + +import Foundation + +extension StripeAPIConfiguration { + /// The current version of this library. + public static let STPSDKVersion = "23.17.2" + + // NOTE: `STPSDKVersion` must be a hard-coded static string instead of + // dynamically generated from the bundle's `CFBundleShortVersionString` to + // ensure the correct value is returned when the SDK is statically linked. + +} diff --git a/StripeCore/StripeCore/Source/API Bindings/StripeAPIConfiguration.swift b/StripeCore/StripeCore/Source/API Bindings/StripeAPIConfiguration.swift new file mode 100644 index 00000000..ed1c0e36 --- /dev/null +++ b/StripeCore/StripeCore/Source/API Bindings/StripeAPIConfiguration.swift @@ -0,0 +1,16 @@ +// +// StripeAPIConfiguration.swift +// StripeCore +// +// Created by Mel Ludowise on 5/17/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// Shared configurations across all Stripe frameworks. +@_spi(STP) public struct StripeAPIConfiguration { + + public static let sharedUrlSessionConfiguration = URLSessionConfiguration.default + +} diff --git a/StripeCore/StripeCore/Source/API Bindings/StripeError.swift b/StripeCore/StripeCore/Source/API Bindings/StripeError.swift new file mode 100644 index 00000000..de097f27 --- /dev/null +++ b/StripeCore/StripeCore/Source/API Bindings/StripeError.swift @@ -0,0 +1,80 @@ +// +// StripeError.swift +// StripeCore +// +// Created by David Estes on 8/11/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// Error codes returned from STPAPIClient. +@_spi(STP) public enum StripeError: Error { + /// The server returned an API error. + case apiError(StripeAPIError) + + /// The request was invalid. + case invalidRequest + + /// Localized description of the error. + public var localizedDescription: String { + return errorDescription ?? NSError.stp_unexpectedErrorMessage() + } +} + +// MARK: - LocalizedError + +extension StripeError: LocalizedError { + @_spi(STP) public var errorDescription: String? { + switch self { + case .apiError(let apiError): + return apiError.errorUserInfoString(key: NSLocalizedDescriptionKey) + case .invalidRequest: + return nil + } + } + + @_spi(STP) public var failureReason: String? { + switch self { + case .apiError(let apiError): + return apiError.errorUserInfoString(key: NSLocalizedFailureReasonErrorKey) + case .invalidRequest: + return nil + } + } + + @_spi(STP) public var recoverySuggestion: String? { + switch self { + case .apiError(let apiError): + return apiError.errorUserInfoString(key: NSLocalizedRecoverySuggestionErrorKey) + case .invalidRequest: + return nil + } + } + + @_spi(STP) public var helpAnchor: String? { + switch self { + case .apiError(let apiError): + return apiError.errorUserInfoString(key: NSHelpAnchorErrorKey) + case .invalidRequest: + return nil + } + } +} + +extension StripeError: AnalyticLoggableError { + public func analyticLoggableSerializeForLogging() -> [String: Any] { + var code: Int + switch self { + case .apiError: + code = 0 + case .invalidRequest: + code = 1 + } + + return [ + "domain": (self as NSError).domain, + "code": code, + ] + } +} diff --git a/StripeCore/StripeCore/Source/API Bindings/StripeServiceError.swift b/StripeCore/StripeCore/Source/API Bindings/StripeServiceError.swift new file mode 100644 index 00000000..b5b5882f --- /dev/null +++ b/StripeCore/StripeCore/Source/API Bindings/StripeServiceError.swift @@ -0,0 +1,73 @@ +// +// StripeServiceError.swift +// StripeCore +// +// Created by David Estes on 8/11/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// An error returned from the Stripe API. +/// +/// https://stripe.com/docs/api/errors +@_spi(STP) public struct StripeAPIError: UnknownFieldsDecodable { + /// The type of error returned. + @_spi(STP) public var type: ErrorType + /// For some errors that could be handled programmatically, + /// a short string indicating the error code reported. + /// + /// https://stripe.com/docs/error-codes + @_spi(STP) public var code: String? + /// A URL to more information about the error code reported. + @_spi(STP) public var docUrl: URL? + /// A human-readable message providing more details about the error. + /// + /// For card errors, these messages can be shown to your users. + @_spi(STP) public var message: String? + /// If the error is parameter-specific, the parameter related to the error. + /// + /// For example, you can use this to display a message near the correct form field. + @_spi(STP) public var param: String? + /// The response’s HTTP status code. + @_spi(STP) public var statusCode: Int? + + // More information may be available in `allResponseFields`, including + // the PaymentIntent or PaymentMethod. + + /// Types of errors presented by the API. + @_spi(STP) public enum ErrorType: String, SafeEnumCodable { + case apiError = "api_error" + case cardError = "card_error" + case idempotencyError = "idempotency_error" + case invalidRequestError = "invalid_request_error" + case unparsable + } + + public var _allResponseFieldsStorage: NonEncodableParameters? +} + +@_spi(STP) public struct StripeAPIErrorResponse: UnknownFieldsDecodable { + @_spi(STP) public var error: StripeAPIError? + + public var _allResponseFieldsStorage: NonEncodableParameters? +} + +extension NSError { + static func stp_error(from stripeApiError: StripeAPIError) -> NSError? { + return stp_error( + errorType: stripeApiError.type.rawValue, + stripeErrorCode: stripeApiError.code, + stripeErrorMessage: stripeApiError.message, + errorParam: stripeApiError.param, + declineCode: nil, + httpResponse: nil + ) + } +} + +extension StripeAPIError { + func errorUserInfoString(key: String) -> String? { + return NSError.stp_error(from: self)?.userInfo[key] as? String + } +} diff --git a/StripeCore/StripeCore/Source/Analytics/Analytic.swift b/StripeCore/StripeCore/Source/Analytics/Analytic.swift new file mode 100644 index 00000000..d9f5f84d --- /dev/null +++ b/StripeCore/StripeCore/Source/Analytics/Analytic.swift @@ -0,0 +1,37 @@ +// +// Analytic.swift +// StripeCore +// +// Created by Mel Ludowise on 3/12/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// An analytic that can be logged to our analytics system. +@_spi(STP) public protocol Analytic { + var event: STPAnalyticEvent { get } + var params: [String: Any] { get } +} + +/// An error analytic that can be logged to our analytics system. +@_spi(STP) public protocol ErrorAnalytic: Analytic { + var error: Error { get } +} + +/// A generic analytic type. +/// +/// - NOTE: This should only be used to support legacy analytics. +/// Any new analytic events should create a new type and conform to `Analytic`. +@_spi(STP) public struct GenericAnalytic: Analytic { + public let event: STPAnalyticEvent + public let params: [String: Any] + + public init( + event: STPAnalyticEvent, + params: [String: Any] + ) { + self.event = event + self.params = params + } +} diff --git a/StripeCore/StripeCore/Source/Analytics/AnalyticLoggableError.swift b/StripeCore/StripeCore/Source/Analytics/AnalyticLoggableError.swift new file mode 100644 index 00000000..12b5b0f2 --- /dev/null +++ b/StripeCore/StripeCore/Source/Analytics/AnalyticLoggableError.swift @@ -0,0 +1,52 @@ +// +// AnalyticLoggableError.swift +// StripeCore +// +// Created by Nick Porter on 9/2/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// Defines a common loggable error to our analytics service. +@_spi(STP) public protocol AnalyticLoggableError: Error { + + /// Serializes this error for analytics logging. + /// + /// - Returns: A dictionary representing this error, not containing any PII or PDE + func analyticLoggableSerializeForLogging() -> [String: Any] +} + +/// Error types that conform to this protocol and String-based RawRepresentable +/// will automatically serialize the rawValue for analytics logging. +@_spi(STP) public protocol AnalyticLoggableStringError: Error { + var loggableType: String { get } +} + +@_spi(STP) extension AnalyticLoggableStringError +where Self: RawRepresentable, Self.RawValue == String { + public var loggableType: String { + return rawValue + } +} + +@_spi(STP) extension Error { + public func serializeForLogging() -> [String: Any] { + if let loggableError = self as? AnalyticLoggableError { + return loggableError.analyticLoggableSerializeForLogging() + } + let nsError = self as NSError + + var payload: [String: Any] = [ + "domain": nsError.domain, + ] + + if let stringError = self as? AnalyticLoggableStringError { + payload["type"] = stringError.loggableType + } else { + payload["code"] = nsError.code + } + + return payload + } +} diff --git a/StripeCore/StripeCore/Source/Analytics/AnalyticsClientV2.swift b/StripeCore/StripeCore/Source/Analytics/AnalyticsClientV2.swift new file mode 100644 index 00000000..a1f59af9 --- /dev/null +++ b/StripeCore/StripeCore/Source/Analytics/AnalyticsClientV2.swift @@ -0,0 +1,153 @@ +// +// AnalyticsClientV2.swift +// StripeCore +// +// Created by Mel Ludowise on 6/7/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +/// Dependency-injectable protocol for `AnalyticsClientV2`. +@_spi(STP) public protocol AnalyticsClientV2Protocol { + var clientId: String { get } + + func log(eventName: String, parameters: [String: Any]) +} + +/// Logs analytics to `r.stripe.com`. +/// +/// To log analytics to the legacy `q.stripe.com`, use `STPAnalyticsClient`. +@_spi(STP) public class AnalyticsClientV2: AnalyticsClientV2Protocol { + + static let loggerUrl = URL(string: "https://r.stripe.com/0")! + + public let clientId: String + public let origin: String + + private(set) var urlSession: URLSession = URLSession( + configuration: StripeAPIConfiguration.sharedUrlSessionConfiguration + ) + + /// Instantiates an AnalyticsClient capable of logging to a specific events table. + /// + /// - Parameters: + /// - clientId: The client identifier corresponding to `client_config.yaml`. + /// - origin: The origin corresponding to `r.stripe.com.conf`. + public init( + clientId: String, + origin: String + ) { + self.clientId = clientId + self.origin = origin + } + + static let shouldCollectAnalytics: Bool = { + #if targetEnvironment(simulator) + return false + #else + return NSClassFromString("XCTest") == nil + #endif + }() + + var requestHeaders: [String: String] { + return [ + "user-agent": "Stripe/v1 ios/\(StripeAPIConfiguration.STPSDKVersion)", + "origin": origin, + ] + } + + /// Helper to serialize errors to a dictionary that can be included in event parameters. + /// + /// - Parameters: + /// - error: The error to serialize. + /// - filePath: Optionally include the filePath of the call site that threw + /// the error. Only the name of the file (e.g. "MyClass.swift") + /// will be serialized and not the full path. + /// - line: Optionally include the line number of the call site that threw the error. + public static func serialize( + error: Error, + filePath: StaticString?, + line: UInt? + ) -> [String: Any] { + + var payload = error.serializeForLogging() + + if let filePath = filePath { + // The full file path can contain the device name, so only include the file name + let fileName = NSString(string: "\(filePath)").lastPathComponent + payload["file"] = fileName + } + if let line = line { + payload["line"] = line + } + + return payload + } + + public func log(eventName: String, parameters: [String: Any]) { + let payload = payload(withEventName: eventName, parameters: parameters) + + #if DEBUG + NSLog("LOG ANALYTICS: \(payload)") + #endif + + guard AnalyticsClientV2.shouldCollectAnalytics else { + return + } + + var request = URLRequest(url: AnalyticsClientV2.loggerUrl) + request.httpMethod = "POST" + request.stp_setFormPayload(payload.jsonEncodeNestedDicts(options: .sortedKeys)) + requestHeaders.forEach { key, value in + request.setValue(value, forHTTPHeaderField: key) + } + let task: URLSessionDataTask = urlSession.dataTask(with: request as URLRequest) + task.resume() + } +} + +extension AnalyticsClientV2Protocol { + public func makeCommonPayload() -> [String: Any] { + var payload: [String: Any] = [:] + + // Required by Analytics Event Logger + payload["client_id"] = self.clientId + payload["event_id"] = UUID().uuidString + payload["created"] = Date().timeIntervalSince1970 + + // Common payload + let version = UIDevice.current.systemVersion + if !version.isEmpty { + payload["os_version"] = version + } + payload["sdk_platform"] = "ios" + payload["sdk_version"] = StripeAPIConfiguration.STPSDKVersion + if let deviceType = STPDeviceUtils.deviceType { + payload["device_type"] = deviceType + } + payload["app_name"] = Bundle.stp_applicationName() ?? "" + payload["app_version"] = Bundle.stp_applicationVersion() ?? "" + payload["plugin_type"] = PluginDetector.shared.pluginType?.rawValue + payload["platform_info"] = [ + "install": InstallMethod.current.rawValue, + "app_bundle_id": Bundle.stp_applicationBundleId() ?? "", + ] + + return payload + } + + public func payload(withEventName eventName: String, parameters: [String: Any]) -> [String: Any] + { + var payload = makeCommonPayload() + payload["event_name"] = eventName + payload = payload.merging( + parameters, + uniquingKeysWith: { a, _ in + return a + } + ) + return payload + } +} diff --git a/StripeCore/StripeCore/Source/Analytics/NetworkDetector.swift b/StripeCore/StripeCore/Source/Analytics/NetworkDetector.swift new file mode 100644 index 00000000..ecbca43f --- /dev/null +++ b/StripeCore/StripeCore/Source/Analytics/NetworkDetector.swift @@ -0,0 +1,54 @@ +// +// NetworkDetector.swift +// StripeCore +// +// Created by Nick Porter on 7/5/23. +// + +import CoreTelephony +import Foundation +import SystemConfiguration + +/// A class which can detect the current network type of the device +class NetworkDetector { + + static func getConnectionType() -> String? { + guard let reachability = SCNetworkReachabilityCreateWithName(kCFAllocatorDefault, "www.stripe.com") else { + return nil + } + + var flags = SCNetworkReachabilityFlags() + SCNetworkReachabilityGetFlags(reachability, &flags) + + let isReachable = flags.contains(.reachable) + let isWWAN = flags.contains(.isWWAN) + + guard isReachable else { + return nil + } + + guard isWWAN else { + return "Wi-Fi" + } + + let networkInfo = CTTelephonyNetworkInfo() + let carrierType = networkInfo.serviceCurrentRadioAccessTechnology + + guard let carrierTypeName = carrierType?.first?.value else { + return "unknown" + } + + switch carrierTypeName { + case CTRadioAccessTechnologyGPRS, CTRadioAccessTechnologyEdge, CTRadioAccessTechnologyCDMA1x: + return "2G" + case CTRadioAccessTechnologyWCDMA, CTRadioAccessTechnologyHSDPA, CTRadioAccessTechnologyHSUPA, CTRadioAccessTechnologyCDMAEVDORev0, CTRadioAccessTechnologyCDMAEVDORevA, CTRadioAccessTechnologyCDMAEVDORevB, CTRadioAccessTechnologyeHRPD: + return "3G" + case CTRadioAccessTechnologyLTE: + return "4G" + default: + return "5G" + } + + } + +} diff --git a/StripeCore/StripeCore/Source/Analytics/PluginDetector.swift b/StripeCore/StripeCore/Source/Analytics/PluginDetector.swift new file mode 100644 index 00000000..88036034 --- /dev/null +++ b/StripeCore/StripeCore/Source/Analytics/PluginDetector.swift @@ -0,0 +1,47 @@ +// +// PluginDetector.swift +// StripeCore +// +// Created by Nick Porter on 10/1/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// A class which can detect if the host app is using a known cross-platform solution. +class PluginDetector { + + /// Shared instance of the `PluginDetector` to enable caching of the `pluginType`. + static let shared = PluginDetector() + + /// Represents all the known/tracked cross-platform solutions. + enum PluginType: String, CaseIterable { + case cordova + case flutter + case ionic + case reactNative = "react-native" + case unity + case xamarin + + /// Represents a known class contained in each cross-platform environment. + var className: String { + switch self { + case .cordova: return "CDVPlugin" + case .flutter: return "FlutterAppDelegate" + case .ionic: return "CAPPlugin" + case .reactNative: return "RCTBridge" + case .unity: return "UnityFramework" + case .xamarin: return "XamarinAssociatedObject" + } + } + } + + /// Determines if this app is running within a plugin environment. + /// + /// - Returns: returns the plugin type if found, otherwise nil. + lazy var pluginType: PluginType? = { + PluginType.allCases.first { type in + NSClassFromString(type.className) != nil + } + }() +} diff --git a/StripeCore/StripeCore/Source/Analytics/STPAnalyticEvent.swift b/StripeCore/StripeCore/Source/Analytics/STPAnalyticEvent.swift new file mode 100644 index 00000000..60e4772a --- /dev/null +++ b/StripeCore/StripeCore/Source/Analytics/STPAnalyticEvent.swift @@ -0,0 +1,177 @@ +// +// STPAnalyticEvent.swift +// StripeCore +// +// Created by Mel Ludowise on 3/12/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// Enumeration of all the analytic events logged by our SDK. +@_spi(STP) public enum STPAnalyticEvent: String { + // MARK: - Payment Creation + case tokenCreation = "stripeios.token_creation" + + // This was "stripeios.source_creation" in earlier SDKs, + // but we need to support both the old and new values forever. + case sourceCreation = "stripeios.source_creationn" + + case paymentMethodCreation = "stripeios.payment_method_creation" + case paymentMethodIntentCreation = "stripeios.payment_intent_confirmation" + case setupIntentConfirmationAttempt = "stripeios.setup_intent_confirmation" + + // MARK: - Payment Confirmation + case _3DS2AuthenticationRequestParamsFailed = + "stripeios.3ds2_authentication_request_params_failed" + case _3DS2AuthenticationAttempt = "stripeios.3ds2_authenticate" + case _3DS2FrictionlessFlow = "stripeios.3ds2_frictionless_flow" + case urlRedirectNextAction = "stripeios.url_redirect_next_action" + case _3DS2ChallengeFlowPresented = "stripeios.3ds2_challenge_flow_presented" + case _3DS2ChallengeFlowTimedOut = "stripeios.3ds2_challenge_flow_timed_out" + case _3DS2ChallengeFlowUserCanceled = "stripeios.3ds2_challenge_flow_canceled" + case _3DS2ChallengeFlowCompleted = "stripeios.3ds2_challenge_flow_completed" + case _3DS2ChallengeFlowErrored = "stripeios.3ds2_challenge_flow_errored" + case _3DS2RedirectUserCanceled = "stripeios.3ds2_redirect_canceled" + + // MARK: - Card Metadata + case cardMetadataLoadedTooSlow = "stripeios.card_metadata_loaded_too_slow" + case cardMetadataResponseFailure = "stripeios.card_metadata_load_failure" + case cardMetadataMissingRange = "stripeios.card_metadata_missing_range" + + // MARK: - Card Scanning + case cardScanSucceeded = "stripeios.cardscan_success" + case cardScanCancelled = "stripeios.cardscan_cancel" + + // MARK: - Identity Verification Flow + case verificationSheetPresented = "stripeios.idprod.verification_sheet.presented" + case verificationSheetClosed = "stripeios.idprod.verification_sheet.closed" + case verificationSheetFailed = "stripeios.idprod.verification_sheet.failed" + + // MARK: - FinancialConnections + case financialConnectionsSheetPresented = "stripeios.financialconnections.sheet.presented" + case financialConnectionsSheetClosed = "stripeios.financialconnections.sheet.closed" + case financialConnectionsSheetFailed = "stripeios.financialconnections.sheet.failed" + + // MARK: - PaymentSheet Init + case mcInitCustomCustomer = "mc_custom_init_customer" + case mcInitCompleteCustomer = "mc_complete_init_customer" + case mcInitCustomApplePay = "mc_custom_init_applepay" + case mcInitCompleteApplePay = "mc_complete_init_applepay" + case mcInitCustomCustomerApplePay = "mc_custom_init_customer_applepay" + case mcInitCompleteCustomerApplePay = "mc_complete_init_customer_applepay" + case mcInitCustomDefault = "mc_custom_init_default" + case mcInitCompleteDefault = "mc_complete_init_default" + + // MARK: - PaymentSheet Show + case mcShowCustomNewPM = "mc_custom_sheet_newpm_show" + case mcShowCustomSavedPM = "mc_custom_sheet_savedpm_show" + case mcShowCustomApplePay = "mc_custom_sheet_applepay_show" + case mcShowCustomLink = "mc_custom_sheet_link_show" + case mcShowCompleteNewPM = "mc_complete_sheet_newpm_show" + case mcShowCompleteSavedPM = "mc_complete_sheet_savedpm_show" + case mcShowCompleteApplePay = "mc_complete_sheet_applepay_show" + case mcShowCompleteLink = "mc_complete_sheet_link_show" + + // MARK: - PaymentSheet Payment + case mcPaymentCustomNewPMSuccess = "mc_custom_payment_newpm_success" + case mcPaymentCustomSavedPMSuccess = "mc_custom_payment_savedpm_success" + case mcPaymentCustomApplePaySuccess = "mc_custom_payment_applepay_success" + case mcPaymentCustomLinkSuccess = "mc_custom_payment_link_success" + + case mcPaymentCompleteNewPMSuccess = "mc_complete_payment_newpm_success" + case mcPaymentCompleteSavedPMSuccess = "mc_complete_payment_savedpm_success" + case mcPaymentCompleteApplePaySuccess = "mc_complete_payment_applepay_success" + case mcPaymentCompleteLinkSuccess = "mc_complete_payment_link_success" + + case mcPaymentCustomNewPMFailure = "mc_custom_payment_newpm_failure" + case mcPaymentCustomSavedPMFailure = "mc_custom_payment_savedpm_failure" + case mcPaymentCustomApplePayFailure = "mc_custom_payment_applepay_failure" + case mcPaymentCustomLinkFailure = "mc_custom_payment_link_failure" + + case mcPaymentCompleteNewPMFailure = "mc_complete_payment_newpm_failure" + case mcPaymentCompleteSavedPMFailure = "mc_complete_payment_savedpm_failure" + case mcPaymentCompleteApplePayFailure = "mc_complete_payment_applepay_failure" + case mcPaymentCompleteLinkFailure = "mc_complete_payment_link_failure" + + // MARK: - PaymentSheet Option Selected + case mcOptionSelectCustomNewPM = "mc_custom_paymentoption_newpm_select" + case mcOptionSelectCustomSavedPM = "mc_custom_paymentoption_savedpm_select" + case mcOptionSelectCustomApplePay = "mc_custom_paymentoption_applepay_select" + case mcOptionSelectCustomLink = "mc_custom_paymentoption_link_select" + case mcOptionSelectCompleteNewPM = "mc_complete_paymentoption_newpm_select" + case mcOptionSelectCompleteSavedPM = "mc_complete_paymentoption_savedpm_select" + case mcOptionSelectCompleteApplePay = "mc_complete_paymentoption_applepay_select" + case mcOptionSelectCompleteLink = "mc_complete_paymentoption_link_select" + + // MARK: - Link Signup + case linkSignupCheckboxChecked = "link.signup.checkbox_checked" + case linkSignupFlowPresented = "link.signup.flow_presented" + case linkSignupStart = "link.signup.start" + case linkSignupComplete = "link.signup.complete" + case linkSignupFailure = "link.signup.failure" + + // MARK: - Link Popup + case linkPopupShow = "link.popup.show" + case linkPopupSuccess = "link.popup.success" + case linkPopupCancel = "link.popup.cancel" + case linkPopupSkipped = "link.popup.skipped" + case linkPopupError = "link.popup.error" + case linkPopupLogout = "link.popup.logout" + + // MARK: - Link Misc + case linkAccountLookupFailure = "link.account_lookup.failure" + + // MARK: - LUXE + case luxeSerializeFailure = "luxe_serialize_failure" + case luxeUnknownActionsFailure = "luxe_unknown_actions_failure" + case luxeSpecSerializeFailure = "luxe_spec_serialize_failure" + + case luxeImageSelectorIconDownloaded = "luxe_image_selector_icon_downloaded" + case luxeImageSelectorIconFromBundle = "luxe_image_selector_icon_from_bundle" + case luxeImageSelectorIconNotFound = "luxe_image_selector_icon_not_found" + + // MARK: - Customer Sheet + case cs_add_payment_method_screen_presented = "cs_add_payment_method_screen_presented" + case cs_select_payment_method_screen_presented = "cs_select_payment_method_screen_presented" + + case cs_select_payment_method_screen_confirmed_savedpm_success = "cs_select_payment_method_screen_confirmed_savedpm_success" + case cs_select_payment_method_screen_confirmed_savedpm_failure = "cs_select_payment_method_screen_confirmed_savedpm_failure" + + case cs_select_payment_method_screen_edit_tapped = "cs_select_payment_method_screen_edit_tapped" + case cs_select_payment_method_screen_done_tapped = "cs_select_payment_method_screen_done_tapped" + + case cs_select_payment_method_screen_removepm_success = "cs_select_payment_method_screen_removepm_success" + case cs_select_payment_method_screen_removepm_failure = "cs_select_payment_method_screen_removepm_failure" + + case cs_add_payment_method_via_setupintent_success = "cs_add_payment_method_via_setup_intent_success" + case cs_add_payment_method_via_setupintent_canceled = "cs_add_payment_method_via_setupintent_canceled" + case cs_add_payment_method_via_setupintent_failure = "cs_add_payment_method_via_setup_intent_failure" + + case cs_add_payment_method_via_createAttach_success = "cs_add_payment_method_via_createAttach_success" + case cs_add_payment_method_via_createAttach_failure = "cs_add_payment_method_via_createAttach_failure" + + // MARK: - Address Element + case addressShow = "mc_address_show" + case addressCompleted = "mc_address_completed" + + // MARK: - PaymentMethodMessagingView + case paymentMethodMessagingViewLoadSucceeded = "pmmv_load_succeeded" + case paymentMethodMessagingViewLoadFailed = "pmmv_load_failed" + case paymentMethodMessagingViewTapped = "pmmv_tapped" + + // MARK: - PaymentSheet Force Success + case paymentSheetForceSuccess = "mc_force_success" + + // MARK: - PaymentSheet initialization + case paymentSheetLoadStarted = "mc_load_started" + case paymentSheetLoadSucceeded = "mc_load_succeeded" + case paymentSheetLoadFailed = "mc_load_failed" + + // MARK: - PaymentSheet dismiss + case paymentSheetDismissed = "mc_dismiss" + + // MARK: - PaymentSheet checkout + case paymentSheetCarouselPaymentMethodTapped = "mc_carousel_payment_method_tapped" + case paymentSheetConfirmButtonTapped = "mc_confirm_button_tapped" +} diff --git a/StripeCore/StripeCore/Source/Analytics/STPAnalyticsClient.swift b/StripeCore/StripeCore/Source/Analytics/STPAnalyticsClient.swift new file mode 100644 index 00000000..ade165e8 --- /dev/null +++ b/StripeCore/StripeCore/Source/Analytics/STPAnalyticsClient.swift @@ -0,0 +1,148 @@ +// +// STPAnalyticsClient.swift +// StripeCore +// +// Created by Ben Guo on 4/22/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +@_spi(STP) public protocol STPAnalyticsProtocol { + static var stp_analyticsIdentifier: String { get } +} + +@_spi(STP) public protocol STPAnalyticsClientProtocol { + func addClass(toProductUsageIfNecessary klass: T.Type) + func log(analytic: Analytic, apiClient: STPAPIClient) +} + +@_spi(STP) public class STPAnalyticsClient: NSObject, STPAnalyticsClientProtocol { + @objc public static let sharedClient = STPAnalyticsClient() + + @objc public var productUsage: Set = Set() + private var additionalInfoSet: Set = Set() + private(set) var urlSession: URLSession = URLSession( + configuration: StripeAPIConfiguration.sharedUrlSessionConfiguration + ) + + @objc public class func tokenType(fromParameters parameters: [AnyHashable: Any]) -> String? { + let parameterKeys = parameters.keys + + // Before SDK 23.0.0, this returned "card" for some Apple Pay payments. + if parameterKeys.contains("pk_token") { + return "apple_pay" + } + // these are currently mutually exclusive, so we can just run through and find the first match + let tokenTypes = ["account", "bank_account", "card", "pii", "cvc_update"] + if let type = tokenTypes.first(where: { parameterKeys.contains($0) }) { + return type + } + return nil + } + + public func addClass(toProductUsageIfNecessary klass: T.Type) { + objc_sync_enter(self) + _ = productUsage.insert(klass.stp_analyticsIdentifier) + objc_sync_exit(self) + } + + func addAdditionalInfo(_ info: String) { + _ = additionalInfoSet.insert(info) + } + + public func clearAdditionalInfo() { + additionalInfoSet.removeAll() + } + + // MARK: - Card Scanning + + @objc class func shouldCollectAnalytics() -> Bool { + #if targetEnvironment(simulator) + return false + #else + return NSClassFromString("XCTest") == nil + #endif + } + + public func additionalInfo() -> [String] { + return additionalInfoSet.sorted() + } + + func logPayload(_ payload: [String: Any]) { + #if DEBUG + NSLog("LOG ANALYTICS: \(payload)") + #endif + + guard type(of: self).shouldCollectAnalytics(), + let url = URL(string: "https://q.stripe.com") + else { + return + } + + var request = URLRequest(url: url) + request.stp_addParameters(toURL: payload) + let task: URLSessionDataTask = urlSession.dataTask(with: request as URLRequest) + task.resume() + } + + /// Creates a payload dictionary for the given analytic that includes the event name, + /// common payload, additional info, and product usage dictionary. + /// + /// - Parameters: + /// - analytic: The analytic to log. + /// - apiClient: The `STPAPIClient` instance with which this payload should be associated + /// (i.e. publishable key). Defaults to `STPAPIClient.shared`. + func payload(from analytic: Analytic, apiClient: STPAPIClient = .shared) -> [String: Any] { + var payload = commonPayload(apiClient) + + payload["event"] = analytic.event.rawValue + payload["additional_info"] = additionalInfo() + payload["product_usage"] = productUsage.sorted() + + // Attach error information if this is an error analytic + if let errorAnalytic = analytic as? ErrorAnalytic { + payload["error_dictionary"] = errorAnalytic.error.serializeForLogging() + } + + payload.merge(analytic.params) { (_, new) in new } + return payload + } + + /// Logs an analytic with a payload dictionary that includes the event name, common payload, + /// additional info, and product usage dictionary. + /// + /// - Parameters + /// - analytic: The analytic to log. + /// - apiClient: The `STPAPIClient` instance with which this payload should be associated + /// (i.e. publishable key). Defaults to `STPAPIClient.shared`. + public func log(analytic: Analytic, apiClient: STPAPIClient = .shared) { + logPayload(payload(from: analytic)) + } +} + +// MARK: - Helpers + +extension STPAnalyticsClient { + public func commonPayload(_ apiClient: STPAPIClient) -> [String: Any] { + var payload: [String: Any] = [:] + payload["bindings_version"] = StripeAPIConfiguration.STPSDKVersion + payload["analytics_ua"] = "analytics.stripeios-1.0" + let version = UIDevice.current.systemVersion + if !version.isEmpty { + payload["os_version"] = version + } + if let deviceType = STPDeviceUtils.deviceType { + payload["device_type"] = deviceType + } + payload["app_name"] = Bundle.stp_applicationName() ?? "" + payload["app_version"] = Bundle.stp_applicationVersion() ?? "" + payload["plugin_type"] = PluginDetector.shared.pluginType?.rawValue + payload["network_type"] = NetworkDetector.getConnectionType() + payload["install"] = InstallMethod.current.rawValue + payload["publishable_key"] = apiClient.sanitizedPublishableKey ?? "unknown" + + return payload + } +} diff --git a/StripeCore/StripeCore/Source/Categories/Decimal+StripeCore.swift b/StripeCore/StripeCore/Source/Categories/Decimal+StripeCore.swift new file mode 100644 index 00000000..3acc0257 --- /dev/null +++ b/StripeCore/StripeCore/Source/Categories/Decimal+StripeCore.swift @@ -0,0 +1,15 @@ +// +// Decimal+StripeCore.swift +// StripeCore +// +// Created by Mel Ludowise on 4/14/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) extension Decimal { + public var floatValue: Float { + return (self as NSDecimalNumber).floatValue + } +} diff --git a/StripeCore/StripeCore/Source/Categories/Dictionary+Stripe.swift b/StripeCore/StripeCore/Source/Categories/Dictionary+Stripe.swift new file mode 100644 index 00000000..8e69038d --- /dev/null +++ b/StripeCore/StripeCore/Source/Categories/Dictionary+Stripe.swift @@ -0,0 +1,82 @@ +// +// Dictionary+Stripe.swift +// StripeCore +// +// Created by David Estes on 10/18/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +extension Dictionary { + static func stp_deepMerge(old: Any, new: Any) throws -> Any { + if let oldDictionary = old as? [String: Any], + let newDictionary = new as? [String: Any] + { + return try oldDictionary.merging(newDictionary, uniquingKeysWith: stp_deepMerge) + } + return new + } + + /// Return the dictionary, minus any fields that also exist in + /// the passed dictionary. + func subtracting(_ subtractDict: Dictionary) -> Dictionary { + var newDict = self + for (key, value) in self { + if let equatableValue = value as? AnyHashable, + let equatableSubtractValue = subtractDict[key] as? AnyHashable + { + if equatableValue == equatableSubtractValue { + newDict.removeValue(forKey: key) + continue + } + } + if let dict1 = value as? Dictionary, + let dict2 = subtractDict[key] as? Dictionary + { + let subtractedDict = dict1.subtracting(dict2) + if subtractedDict.isEmpty { + newDict.removeValue(forKey: key) + } else { + newDict[key] = subtractedDict as? Value + } + } + } + return newDict + } +} + +extension Dictionary where Value == Any { + func jsonEncodeNestedDicts(options: JSONSerialization.WritingOptions = []) -> [Key: Any] { + return compactMapValues { value in + guard let dict = value as? Dictionary else { + return value + } + + // Note: An NSInvalidArgumentException can occur when the dict can't be + // serialized instead of throwing an error, resulting in an app crash. + // Call `isValidJSONObject` to ensure it's able to serialize the dict. + guard JSONSerialization.isValidJSONObject(dict), + let data = try? JSONSerialization.data(withJSONObject: dict, options: options) + else { + assertionFailure("Dictionary could not be serialized") + return nil + } + + return String(data: data, encoding: .utf8) + } + } +} + +// From https://talk.objc.io/episodes/S01E31-mutating-untyped-dictionaries +@_spi(STP) public extension Dictionary { + /// Example usage: `dict[jsonDict: "countries"]?[jsonDict: "japan"]?["capital"] = "berlin"` + subscript(jsonDict key: Key) -> [String: Any]? { + get { + return self[key] as? [String: Any] + } + set { + self[key] = newValue as? Value + } + } +} diff --git a/StripeCore/StripeCore/Source/Categories/Enums+CustomStringConvertible.swift b/StripeCore/StripeCore/Source/Categories/Enums+CustomStringConvertible.swift new file mode 100644 index 00000000..09d0b194 --- /dev/null +++ b/StripeCore/StripeCore/Source/Categories/Enums+CustomStringConvertible.swift @@ -0,0 +1,29 @@ +// +// Enums+CustomStringConvertible.swift +// Stripe +// +// Autogenerated by generate_objc_enum_string_values.rb +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +/// :nodoc: +extension STPErrorCode: CustomStringConvertible { + public var description: String { + switch self { + case .apiError: + return "apiError" + case .authenticationError: + return "authenticationError" + case .cancellationError: + return "cancellationError" + case .cardError: + return "cardError" + case .connectionError: + return "connectionError" + case .ephemeralKeyDecodingError: + return "ephemeralKeyDecodingError" + case .invalidRequestError: + return "invalidRequestError" + } + } +} diff --git a/StripeCore/StripeCore/Source/Categories/NSArray+Stripe.swift b/StripeCore/StripeCore/Source/Categories/NSArray+Stripe.swift new file mode 100644 index 00000000..b21be9e8 --- /dev/null +++ b/StripeCore/StripeCore/Source/Categories/NSArray+Stripe.swift @@ -0,0 +1,31 @@ +// +// NSArray+Stripe.swift +// StripeCore +// +// Created by Jack Flintermann on 1/19/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) extension Array { + public func stp_boundSafeObject(at index: Int) -> Element? { + if index + 1 > count || index < 0 { + return nil + } + return self[index] + } +} + +extension Array where Element == String { + public func caseInsensitiveContains(_ other: String) -> Bool { + return self.map { $0.uppercased() }.contains(other.uppercased()) + } +} + +extension Array where Element: Equatable { + @discardableResult public mutating func remove(_ element: Element) -> Element? { + guard let index = firstIndex(of: element) else { return nil } + return remove(at: index) + } +} diff --git a/StripeCore/StripeCore/Source/Categories/NSBundle+Stripe_AppName.swift b/StripeCore/StripeCore/Source/Categories/NSBundle+Stripe_AppName.swift new file mode 100644 index 00000000..0c459691 --- /dev/null +++ b/StripeCore/StripeCore/Source/Categories/NSBundle+Stripe_AppName.swift @@ -0,0 +1,32 @@ +// +// NSBundle+Stripe_AppName.swift +// StripeCore +// +// Created by Jack Flintermann on 4/20/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation + +extension Bundle { + @_spi(STP) public class func stp_applicationName() -> String? { + return self.main.infoDictionary?[kCFBundleNameKey as String] as? String + } + + @_spi(STP) public class func stp_applicationVersion() -> String? { + return self.main.infoDictionary?["CFBundleShortVersionString"] as? String + } + + @_spi(STP) public class func stp_applicationBundleId() -> String? { + return self.main.bundleIdentifier + } + + @_spi(STP) public class func buildVersion() -> String? { + return self.main.infoDictionary?["CFBundleVersion"] as? String + } + + @_spi(STP) public class var displayName: String? { + return self.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? self.main + .object(forInfoDictionaryKey: "CFBundleName") as? String + } +} diff --git a/StripeCore/StripeCore/Source/Categories/NSCharacterSet+StripeCore.swift b/StripeCore/StripeCore/Source/Categories/NSCharacterSet+StripeCore.swift new file mode 100644 index 00000000..c0b8e331 --- /dev/null +++ b/StripeCore/StripeCore/Source/Categories/NSCharacterSet+StripeCore.swift @@ -0,0 +1,21 @@ +// +// NSCharacterSet+StripeCore.swift +// StripeCore +// +// Created by Brian Dorfman on 6/9/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) extension CharacterSet { + public static let stp_asciiDigit = CharacterSet(charactersIn: "0123456789") + public static let stp_asciiLetters = CharacterSet( + charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + ) + public static let stp_invertedAsciiDigit = stp_asciiDigit.inverted + public static let stp_postalCode = CharacterSet( + charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789- " + ) + public static let stp_invertedPostalCode = stp_postalCode.inverted +} diff --git a/StripeCore/StripeCore/Source/Categories/NSError+Stripe.swift b/StripeCore/StripeCore/Source/Categories/NSError+Stripe.swift new file mode 100644 index 00000000..41b32090 --- /dev/null +++ b/StripeCore/StripeCore/Source/Categories/NSError+Stripe.swift @@ -0,0 +1,135 @@ +// +// NSError+Stripe.swift +// StripeCore +// +// Created by Brian Dorfman on 8/4/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import Foundation + +extension NSError { + @objc @_spi(STP) public class func stp_genericConnectionError() -> NSError { + let userInfo = [ + NSLocalizedDescriptionKey: self.stp_unexpectedErrorMessage(), + STPError.errorMessageKey: "There was an error connecting to Stripe.", + ] + return NSError( + domain: STPError.stripeDomain, + code: STPErrorCode.connectionError.rawValue, + userInfo: userInfo + ) + } + + @objc @_spi(STP) public class func stp_genericFailedToParseResponseError() -> NSError { + let userInfo = [ + NSLocalizedDescriptionKey: self.stp_unexpectedErrorMessage(), + STPError.errorMessageKey: + "The response from Stripe failed to get parsed into valid JSON.", + ] + return NSError( + domain: STPError.stripeDomain, + code: STPErrorCode.apiError.rawValue, + userInfo: userInfo + ) + } + + @objc @_spi(STP) public class func stp_ephemeralKeyDecodingError() -> NSError { + let userInfo = [ + NSLocalizedDescriptionKey: self.stp_unexpectedErrorMessage(), + STPError.errorMessageKey: + "Failed to decode the ephemeral key. Make sure your backend is sending the unmodified JSON of the ephemeral key to your app.", + ] + return NSError( + domain: STPError.stripeDomain, + code: STPErrorCode.ephemeralKeyDecodingError.rawValue, + userInfo: userInfo + ) + } + + @objc @_spi(STP) public class func stp_clientSecretError() -> NSError { + let userInfo = [ + NSLocalizedDescriptionKey: self.stp_unexpectedErrorMessage(), + STPError.errorMessageKey: + "The `secret` format does not match expected client secret formatting.", + ] + return NSError( + domain: STPError.stripeDomain, + code: STPErrorCode.invalidRequestError.rawValue, + userInfo: userInfo + ) + } + + // TODO(davide): We'll want to move these into StripePayments, once it exists. + + // MARK: Strings + @objc @_spi(STP) public class func stp_cardErrorInvalidNumberUserMessage() -> String { + return STPLocalizedString( + "Your card's number is invalid", + "Error when the card number is not valid" + ) + } + + @objc @_spi(STP) public class func stp_cardInvalidCVCUserMessage() -> String { + return STPLocalizedString( + "Your card's security code is invalid", + "Error when the card's CVC is not valid" + ) + } + + @objc @_spi(STP) public class func stp_cardErrorInvalidExpMonthUserMessage() -> String { + return STPLocalizedString( + "Your card's expiration month is invalid", + "Error when the card's expiration month is not valid" + ) + } + + @objc @_spi(STP) public class func stp_cardErrorInvalidExpYearUserMessage() -> String { + return STPLocalizedString( + "Your card's expiration year is invalid", + "Error when the card's expiration year is not valid" + ) + } + + @objc @_spi(STP) public class func stp_cardErrorExpiredCardUserMessage() -> String { + return STPLocalizedString( + "Your card has expired", + "Error when the card has already expired" + ) + } + + @objc @_spi(STP) public class func stp_cardErrorDeclinedUserMessage() -> String { + return STPLocalizedString( + "Your card was declined", + "Error when the card was declined by the credit card networks" + ) + } + + @objc @_spi(STP) public class func stp_genericDeclineErrorUserMessage() -> String { + return STPLocalizedString( + "Your payment method was declined.", + "Error message when a payment method gets declined." + ) + } + + @objc @_spi(STP) public class func stp_cardErrorProcessingErrorUserMessage() -> String { + return STPLocalizedString( + "There was an error processing your card -- try again in a few seconds", + "Error when there is a problem processing the credit card" + ) + } + + @_spi(STP) public static var stp_invalidOwnerName: String { + return STPLocalizedString( + "Your name is invalid.", + "Error when customer's name is invalid" + ) + } + + @_spi(STP) public static var stp_invalidBankAccountIban: String { + return STPLocalizedString( + "The IBAN you entered is invalid.", + "An error message displayed when the customer's iban is invalid." + ) + } +} diff --git a/StripeCore/StripeCore/Source/Categories/NSError+StripeCore.swift b/StripeCore/StripeCore/Source/Categories/NSError+StripeCore.swift new file mode 100644 index 00000000..5f3cbecf --- /dev/null +++ b/StripeCore/StripeCore/Source/Categories/NSError+StripeCore.swift @@ -0,0 +1,18 @@ +// +// NSError+StripeCore.swift +// StripeCore +// +// Created by Mel Ludowise on 7/7/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) extension NSError { + public class func stp_unexpectedErrorMessage() -> String { + return STPLocalizedString( + "There was an unexpected error -- try again in a few seconds", + "Unexpected error, such as a 500 from Stripe or a JSON parse error" + ) + } +} diff --git a/StripeCore/StripeCore/Source/Categories/NSMutableURLRequest+Stripe.swift b/StripeCore/StripeCore/Source/Categories/NSMutableURLRequest+Stripe.swift new file mode 100644 index 00000000..7747824f --- /dev/null +++ b/StripeCore/StripeCore/Source/Categories/NSMutableURLRequest+Stripe.swift @@ -0,0 +1,43 @@ +// +// NSMutableURLRequest+Stripe.swift +// StripeCore +// +// Created by Ben Guo on 4/22/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation + +extension URLRequest { + @_spi(STP) public mutating func stp_addParameters(toURL parameters: [String: Any]) { + guard let url = url else { + assertionFailure() + return + } + let urlString = url.absoluteString + let query = URLEncoder.queryString(from: parameters) + self.url = URL(string: urlString + (url.query != nil ? "&\(query)" : "?\(query)")) + } + + @_spi(STP) public mutating func stp_setFormPayload(_ formPayload: [String: Any]) { + let formData = URLEncoder.queryString(from: formPayload).data(using: .utf8) + httpBody = formData + setValue( + String(format: "%lu", UInt(formData?.count ?? 0)), + forHTTPHeaderField: "Content-Length" + ) + setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + } + + @_spi(STP) public mutating func stp_setMultipartForm(_ data: Data?, boundary: String?) { + httpBody = data + setValue( + String(format: "%lu", UInt(data?.count ?? 0)), + forHTTPHeaderField: "Content-Length" + ) + setValue( + "multipart/form-data; boundary=\(boundary ?? "")", + forHTTPHeaderField: "Content-Type" + ) + } +} diff --git a/StripeCore/StripeCore/Source/Categories/NSURLComponents+Stripe.swift b/StripeCore/StripeCore/Source/Categories/NSURLComponents+Stripe.swift new file mode 100644 index 00000000..b0bb90ce --- /dev/null +++ b/StripeCore/StripeCore/Source/Categories/NSURLComponents+Stripe.swift @@ -0,0 +1,63 @@ +// +// NSURLComponents+Stripe.swift +// StripeCore +// +// Created by Brian Dorfman on 1/26/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import Foundation + +extension NSURLComponents { + /// Returns or sets self.queryItems as a dictionary where all the keys are the item + /// names and the values are the values. + /// + /// When reading, if there are duplicate + /// names, earlier ones are overwritten by later ones. + @objc var stp_queryItemsDictionary: [String: String] { + get { + guard let queryItems = queryItems else { + return [:] + } + var queryItemsDict = [String: String]() + for queryItem in queryItems { + queryItemsDict[queryItem.name] = queryItem.value + } + return queryItemsDict + } + set(stp_queryItemsDictionary) { + var queryItems: [URLQueryItem] = [] + for (key, value) in stp_queryItemsDictionary { + queryItems.append(URLQueryItem(name: key, value: value)) + } + + self.queryItems = queryItems + } + } + + /// Returns YES if the passed in url matches self in scheme, host, and path, + /// AND all the query items in self are also in the passed + /// in components (as determined by `stp_queryItemsDictionary`) + /// This is used for URL routing style matching + /// - Parameter rhsComponents: The components to match against + /// - Returns: YES if there is a match, NO otherwise. + func stp_matchesURLComponents(_ rhsComponents: NSURLComponents) -> Bool { + var matches = + (scheme?.lowercased() == rhsComponents.scheme?.lowercased()) + && (host?.lowercased() == rhsComponents.host?.lowercased()) + && (path == rhsComponents.path) + + if matches { + let rhsQueryItems = rhsComponents.stp_queryItemsDictionary + + for queryItem in queryItems ?? [] { + if !(rhsQueryItems[queryItem.name] == queryItem.value) { + matches = false + break + } + } + } + + return matches + } +} diff --git a/StripeCore/StripeCore/Source/Categories/String+StripeCore.swift b/StripeCore/StripeCore/Source/Categories/String+StripeCore.swift new file mode 100644 index 00000000..43097f04 --- /dev/null +++ b/StripeCore/StripeCore/Source/Categories/String+StripeCore.swift @@ -0,0 +1,36 @@ +// +// String+StripeCore.swift +// StripeCore +// +// Created by Mel Ludowise on 9/16/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) extension String { + public func stp_stringByRemovingCharacters(from characterSet: CharacterSet) -> String { + return String(unicodeScalars.filter { !characterSet.contains($0) }) + } + + public var isSecretKey: Bool { + return self.hasPrefix("sk_") + } + + public var nonEmpty: String? { + stringIfHasContentsElseNil(self) + } +} + +@_spi(STP) public func stringIfHasContentsElseNil( + _ string: String? +) -> // MARK: - + String? +{ + guard let string = string, + !string.isEmpty + else { + return nil + } + return string +} diff --git a/StripeCore/StripeCore/Source/Categories/UIImage+StripeCore.swift b/StripeCore/StripeCore/Source/Categories/UIImage+StripeCore.swift new file mode 100644 index 00000000..ec170ced --- /dev/null +++ b/StripeCore/StripeCore/Source/Categories/UIImage+StripeCore.swift @@ -0,0 +1,162 @@ +// +// UIImage+StripeCore.swift +// StripeCore +// +// Created by Brian Dorfman on 4/25/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// +import AVFoundation +import UIKit + +@_spi(STP) public typealias ImageDataAndSize = (imageData: Data, imageSize: CGSize) + +extension UIImage { + @_spi(STP) public static let defaultCompressionQuality: CGFloat = 0.5 + + /// Encodes the image to jpeg at the specified compression quality. + /// + /// The image will be scaled down, if needed, to ensure its size does not exceed `maxBytes`. + /// + /// - Parameters: + /// - maxBytes: The maximum size of the allowed file. If value is nil, then + /// the image will not be scaled down. + /// - compressionQuality: The compression quality to use when encoding the jpeg. + /// - Returns: A tuple containing the following properties. + /// - `imageData`: Data object of the jpeg encoded image. + /// - `imageSize`: The dimensions of the the image that was encoded. + /// This size may be smaller than the original image size if the image + /// needed to be scaled down to fit the specified `maxBytes`. + @_spi(STP) public func jpegDataAndDimensions( + maxBytes: Int? = nil, + compressionQuality: CGFloat = defaultCompressionQuality + ) -> ImageDataAndSize { + dataAndDimensions( + maxBytes: maxBytes, + compressionQuality: compressionQuality + ) { image, quality in + image.jpegData(compressionQuality: quality) + } + } + + /// Encodes the image to heic at the specified compression quality. + /// + /// The image will be scaled down, if needed, to ensure its size does not exceed `maxBytes`. + /// + /// - Parameters: + /// - maxBytes: The maximum size of the allowed file. If value is nil, then + /// the image will not be scaled down. + /// - compressionQuality: The compression quality to use when encoding the jpeg. + /// - Returns: A tuple containing the following properties. + /// - `imageData`: Data object of the jpeg encoded image. + /// - `imageSize`: The dimensions of the the image that was encoded. + /// This size may be smaller than the original image size if the image + /// needed to be scaled down to fit the specified `maxBytes`. + @_spi(STP) public func heicDataAndDimensions( + maxBytes: Int? = nil, + compressionQuality: CGFloat = defaultCompressionQuality + ) -> ImageDataAndSize { + dataAndDimensions( + maxBytes: maxBytes, + compressionQuality: compressionQuality + ) { image, quality in + image.heicData(compressionQuality: quality) + } + } + + @_spi(STP) public func resized(to scale: CGFloat) -> UIImage? { + let newImageSize = CGSize( + width: CGFloat(floor(size.width * scale)), + height: CGFloat(floor(size.height * scale)) + ) + UIGraphicsBeginImageContextWithOptions(newImageSize, false, self.scale) + + defer { + UIGraphicsEndImageContext() + } + + draw(in: CGRect(x: 0, y: 0, width: newImageSize.width, height: newImageSize.height)) + return UIGraphicsGetImageFromCurrentImageContext() + } + + private func heicData(compressionQuality: CGFloat = UIImage.defaultCompressionQuality) -> Data? + { + [self].heicData(compressionQuality: compressionQuality) + } + + private func dataAndDimensions( + maxBytes: Int? = nil, + compressionQuality: CGFloat = defaultCompressionQuality, + imageDataProvider: ((_ image: UIImage, _ quality: CGFloat) -> Data?) + ) -> ImageDataAndSize { + var imageData = imageDataProvider(self, compressionQuality) + + guard imageData != nil else { + return (imageData: Data(), imageSize: .zero) + } + + var newImageSize = self.size + + // Try something smarter first + if let maxBytes = maxBytes, + (imageData?.count ?? 0) > maxBytes + { + var scale = CGFloat(1.0) + + // Assuming jpeg file size roughly scales linearly with area of the image + // which is ~correct (although breaks down at really small file sizes) + let percentSmallerNeeded = CGFloat(maxBytes) / CGFloat((imageData?.count ?? 0)) + + // Shrink to a little bit less than we need to try to ensure we're under + // (otherwise its likely our first pass will be over the limit due to + // compression variance and floating point rounding) + scale = scale * (percentSmallerNeeded - (percentSmallerNeeded * 0.05)) + + repeat { + if let newImage = resized(to: scale) { + newImageSize = newImage.size + imageData = imageDataProvider(newImage, compressionQuality) + } + + // If the smart thing doesn't work, just start scaling down a bit on a loop until we get there + scale *= 0.7 + } while (imageData?.count ?? 0) > maxBytes + } + + return (imageData: imageData!, imageSize: newImageSize) + } +} + +extension Array where Element: UIImage { + @_spi(STP) public func heicData( + compressionQuality: CGFloat = UIImage.defaultCompressionQuality + ) -> Data? { + guard let mutableData = CFDataCreateMutable(nil, 0) else { + return nil + } + + guard + let destination = CGImageDestinationCreateWithData( + mutableData, + AVFileType.heic as CFString, + self.count, + nil + ) + else { + return nil + } + + let properties = + [kCGImageDestinationLossyCompressionQuality: compressionQuality] as CFDictionary + + for image in self { + let cgImage = image.cgImage! + CGImageDestinationAddImage(destination, cgImage, properties) + } + + if CGImageDestinationFinalize(destination) { + return mutableData as Data + } + + return nil + } +} diff --git a/StripeCore/StripeCore/Source/Coder/StripeCodable.swift b/StripeCore/StripeCore/Source/Coder/StripeCodable.swift new file mode 100644 index 00000000..a5e516a1 --- /dev/null +++ b/StripeCore/StripeCore/Source/Coder/StripeCodable.swift @@ -0,0 +1,177 @@ +// +// StripeCodable.swift +// StripeCore +// +// Created by David Estes on 7/20/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +// This contains ~most of our custom encoding/decoding logic for handling unknown fields. +// +// It isn't intended for public use, but exposing objects with this protocol +// requires that we make the protocol itself public. +// + +import Foundation + +/// A Decodable object that retains unknown fields. +/// :nodoc: +public protocol UnknownFieldsDecodable: Decodable { + /// This should not be used directly. + /// Use the `allResponseFields` accessor instead. + /// :nodoc: + var _allResponseFieldsStorage: NonEncodableParameters? { get set } +} + +/// An Encodable object that allows unknown fields to be set. +/// :nodoc: +public protocol UnknownFieldsEncodable: Encodable { + /// This should not be used directly. + /// Use the `additionalParameters` accessor instead. + /// :nodoc: + var _additionalParametersStorage: NonEncodableParameters? { get set } +} + +/// A Decodable enum that sets an "unparsable" case +/// instead of failing on values that are unknown to the SDK. +/// :nodoc: +public protocol SafeEnumDecodable: Decodable { + /// If the value is unparsable, the result will be available in + /// the `allResponseFields` of the parent object. + static var unparsable: Self { get } + + // It'd be nice to include the value of the unparsable enum + // as an associated value, but Swift can't auto-generate the Codable + // keys if we do that. +} + +/// A Codable enum that sets an "unparsable" case +/// instead of failing on values that are unknown to the SDK. +/// :nodoc: +public protocol SafeEnumCodable: Encodable, SafeEnumDecodable {} + +extension UnknownFieldsDecodable { + /// A dictionary containing all response fields from the original JSON, + /// including unknown fields. + public internal(set) var allResponseFields: [String: Any] { + get { + self._allResponseFieldsStorage?.storage ?? [:] + } + set { + if self._allResponseFieldsStorage == nil { + self._allResponseFieldsStorage = NonEncodableParameters() + } + self._allResponseFieldsStorage!.storage = newValue + } + } + + static func decodedObject(jsonData: Data) throws -> Self { + return try StripeJSONDecoder.decode(jsonData: jsonData) + } +} + +extension UnknownFieldsEncodable { + /// You can use this property to add additional fields to an API request that + /// are not explicitly defined by the object's interface. + /// + /// This can be useful when using beta features that haven't been added to the Stripe SDK yet. + /// For example, if the /v1/tokens API began to accept a beta field called "test_field", + /// you might do the following: + /// + /// ```swift + /// var cardParams = PaymentMethodParams.Card() + /// // add card values + /// cardParams.additionalParameters = ["test_field": "example_value"] + /// PaymentsAPI.shared.createToken(withParameters: cardParams completion:...) + /// ``` + public var additionalParameters: [String: Any] { + get { + self._additionalParametersStorage?.storage ?? [:] + } + set { + if self._additionalParametersStorage == nil { + self._additionalParametersStorage = NonEncodableParameters() + } + self._additionalParametersStorage!.storage = newValue + } + } +} + +extension Encodable { + func encodeJSONDictionary(includingUnknownFields: Bool = true) throws -> [String: Any] { + let encoder = StripeJSONEncoder() + return try encoder.encodeJSONDictionary( + self, + includingUnknownFields: includingUnknownFields + ) + } +} + +@_spi(STP) public enum UnknownFieldsCodableFloats: String { + case PositiveInfinity = "Inf" + case NegativeInfinity = "-Inf" + case NaN = "nan" +} + +/// A protocol that conforms to both UnknownFieldsEncodable and UnknownFieldsDecodable. +/// :nodoc: +public protocol UnknownFieldsCodable: UnknownFieldsEncodable, UnknownFieldsDecodable {} + +/// This should not be used directly. +/// Use the `additionalParameters` and `allResponseFields` accessors instead. +/// :nodoc: +public struct NonEncodableParameters { + @_spi(STP) public internal(set) var storage: [String: Any] = [:] +} + +extension NonEncodableParameters: Decodable { + /// :nodoc: + public init( + from decoder: Decoder + ) throws { + // no-op + } +} + +extension NonEncodableParameters: Encodable { + /// :nodoc: + public func encode(to encoder: Encoder) throws { + // no-op + } +} + +extension NonEncodableParameters: Equatable { + /// :nodoc: + public static func == (lhs: NonEncodableParameters, rhs: NonEncodableParameters) -> Bool { + return NSDictionary(dictionary: lhs.storage).isEqual(to: rhs.storage) + } +} + +// The default debugging behavior for structs is to print *everything*, +// which is undesirable as it could contain card numbers or other PII. +// For now, override it to just give an overview of the struct. +extension NonEncodableParameters: CustomStringConvertible, CustomDebugStringConvertible, + CustomLeafReflectable +{ + /// :nodoc: + public var customMirror: Mirror { + return Mirror(reflecting: self.description) + } + + /// :nodoc: + public var debugDescription: String { + return description + } + + /// :nodoc: + public var description: String { + return "\(storage.count) fields" + } +} + +extension StripeJSONDecoder { + static func decode(jsonData: Data) throws -> T { + let decoder = StripeJSONDecoder() + return try decoder.decode(T.self, from: jsonData) + } +} diff --git a/StripeCore/StripeCore/Source/Coder/StripeJSONDecoder.swift b/StripeCore/StripeCore/Source/Coder/StripeJSONDecoder.swift new file mode 100644 index 00000000..912b09ae --- /dev/null +++ b/StripeCore/StripeCore/Source/Coder/StripeJSONDecoder.swift @@ -0,0 +1,710 @@ +// +// StripeJSONDecoder.swift +// StripeCore +// +// Copyright © 2022 Stripe, Inc. All rights reserved. +// +// This is a bridge between NSJSONSerialization and Decoder, including some Stripe-specific behavior. +// + +import Foundation + +@_spi(STP) public class StripeJSONDecoder { + @_spi(STP) public var userInfo: [CodingUserInfoKey: Any] = [:] + + @_spi(STP) public var inputFormatting: JSONSerialization.ReadingOptions = [] + + @_spi(STP) public func decode(_ type: T.Type, from data: Data) throws -> T + where T: Decodable { + var inputFormatting = self.inputFormatting + // We always allow fragments. (Though we mostly only use these for tests.) + inputFormatting.insert(.fragmentsAllowed) + let jsonObject: Any + do { + jsonObject = try JSONSerialization.jsonObject(with: data, options: inputFormatting) + } catch let error { + throw DecodingError.dataCorrupted( + .init( + codingPath: [], + debugDescription: "The given data was not valid JSON.", + underlyingError: error + ) + ) + } + guard let object = jsonObject as? NSObject else { + throw DecodingError.dataCorrupted( + .init( + codingPath: [], + debugDescription: "The given data could not be decoded from JSON.", + underlyingError: nil + ) + ) + } + let decoder = _stpinternal_JSONDecoder(jsonObject: object) + userInfo[UnknownFieldsDecodableSourceStorageKey] = data + decoder.userInfo = userInfo + let value: T = try decoder.castFromNSObject() + if var sdValue = value as? UnknownFieldsDecodable { + try sdValue.applyUnknownFieldDecodingTransforms(userInfo: userInfo, codingPath: []) + return sdValue as! T + } + return value + } +} + +private class _stpinternal_JSONDecoder: Decoder, STPDecodingContainerProtocol { + var userInfo: [CodingUserInfoKey: Any] = [:] + var codingPath: [CodingKey] = [] + var jsonObject: NSObject + + init( + jsonObject: NSObject + ) { + self.jsonObject = jsonObject + } + + func castFromNSObject() throws -> T where T: Decodable { + return try castFromNSObject(codingPath: codingPath, T.self, jsonObject) + } + + func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer + where Key: CodingKey { + guard let dict = jsonObject as? NSDictionary else { + throw DecodingError.typeMismatch( + NSDictionary.self, + .init( + codingPath: codingPath, + debugDescription: "KeyedContainer is not a dictionary", + underlyingError: nil + ) + ) + } + return KeyedDecodingContainer( + STPKeyedDecodingContainer( + codingPath: codingPath, + dict: dict, + allKeys: [], + userInfo: userInfo + ) + ) + } + + func unkeyedContainer() throws -> UnkeyedDecodingContainer { + var objectToDecode = jsonObject + + // The implementation of Codable encodes Dictionaries that have Keys that are not exactly + // `String.Type` or `Int.Type` as an `Array`. + // If we have a Dictionary that has Keys that derived from String we end up here. + // To solve this, just convert to an Array. + // see: https://github.com/apple/swift/blob/d2085d8b0ed69e40a10e555669bb6cc9b450d0b3/stdlib/public/core/Codable.swift.gyb#L1967 + // For the decoding see: https://github.com/apple/swift/blob/d2085d8b0ed69e40a10e555669bb6cc9b450d0b3/stdlib/public/core/Codable.swift.gyb#L2036 + // Interestingly the default implementation does not handle this which seems like a bug. + // See here: https://github.com/apple/swift-corelibs-foundation/blob/main/Sources/Foundation/JSONDecoder.swift + if let dict = objectToDecode as? NSDictionary { + let arrayCopy = NSMutableArray() + for (key, value) in dict { + arrayCopy.add(key) + arrayCopy.add(value) + } + + objectToDecode = arrayCopy + } + + guard let array = objectToDecode as? NSArray else { + throw DecodingError.typeMismatch( + NSArray.self, + .init( + codingPath: codingPath, + debugDescription: "UnkeyedContainer is not an array", + underlyingError: nil + ) + ) + } + return STPUnkeyedDecodingContainer(userInfo: userInfo, array: array, codingPath: codingPath) + } + + func singleValueContainer() throws -> SingleValueDecodingContainer { + return STPSingleValueDecodingContainer( + codingPath: codingPath, + userInfo: userInfo, + object: jsonObject + ) + } +} + +private protocol STPDecodingContainerProtocol { + var userInfo: [CodingUserInfoKey: Any] { + get set + } +} + +private struct STPKeyedDecodingContainer: STPDecodingContainerProtocol, + KeyedDecodingContainerProtocol +where K: CodingKey { + var codingPath: [CodingKey] + + var dict: NSDictionary + var allKeys: [K] + + var userInfo: [CodingUserInfoKey: Any] + + typealias Key = K + + func _dictionaryKey(from key: K) -> String { + let maintainExistingCase = userInfo[STPMaintainExistingCase] as? Bool ?? false + var key = key.stringValue + + if !maintainExistingCase { + key = URLEncoder.convertToSnakeCase(camelCase: key) + } + + return key + } + + func contains(_ key: K) -> Bool { + let key = _dictionaryKey(from: key) + return dict[key] != nil + } + + func _objectForKey(_ key: K) throws -> NSObject { + if !contains(key) { + throw DecodingError.keyNotFound( + key, + .init( + codingPath: codingPath, + debugDescription: "Key \(key) not found in \(codingPath)", + underlyingError: nil + ) + ) + } + + let key = _dictionaryKey(from: key) + return dict[key] as! NSObject + } + + func _decode(_ type: T.Type, forKey key: K) throws -> T where T: Decodable { + let newPath = codingPath + [key] + let value: T = try castFromNSObject(codingPath: newPath, type, _objectForKey(key)) + if var sdValue = value as? UnknownFieldsDecodable { + try sdValue.applyUnknownFieldDecodingTransforms(userInfo: userInfo, codingPath: newPath) + return sdValue as! T + } + return value + } + + func decodeNil(forKey key: K) throws -> Bool { + return (try _objectForKey(key) is NSNull) + } + + func decode(_ type: Bool.Type, forKey key: K) throws -> Bool { + return try _decode(type, forKey: key) + } + + func decode(_ type: String.Type, forKey key: K) throws -> String { + return try _decode(type, forKey: key) + } + + func decode(_ type: Double.Type, forKey key: K) throws -> Double { + return try _decode(type, forKey: key) + } + + func decode(_ type: Float.Type, forKey key: K) throws -> Float { + return try _decode(type, forKey: key) + } + + func decode(_ type: Int.Type, forKey key: K) throws -> Int { + return try _decode(type, forKey: key) + } + + func decode(_ type: Int8.Type, forKey key: K) throws -> Int8 { + return try _decode(type, forKey: key) + } + + func decode(_ type: Int16.Type, forKey key: K) throws -> Int16 { + return try _decode(type, forKey: key) + } + + func decode(_ type: Int32.Type, forKey key: K) throws -> Int32 { + return try _decode(type, forKey: key) + } + + func decode(_ type: Int64.Type, forKey key: K) throws -> Int64 { + return try _decode(type, forKey: key) + } + + func decode(_ type: UInt.Type, forKey key: K) throws -> UInt { + return try _decode(type, forKey: key) + } + + func decode(_ type: UInt8.Type, forKey key: K) throws -> UInt8 { + return try _decode(type, forKey: key) + } + + func decode(_ type: UInt16.Type, forKey key: K) throws -> UInt16 { + return try _decode(type, forKey: key) + } + + func decode(_ type: UInt32.Type, forKey key: K) throws -> UInt32 { + return try _decode(type, forKey: key) + } + + func decode(_ type: UInt64.Type, forKey key: K) throws -> UInt64 { + return try _decode(type, forKey: key) + } + + func decode(_ type: T.Type, forKey key: K) throws -> T where T: Decodable { + return try _decode(type, forKey: key) + } + + func nestedContainer( + keyedBy type: NestedKey.Type, + forKey key: K + ) throws -> KeyedDecodingContainer where NestedKey: CodingKey { + assertionFailure("nestedContainer(keyedBy:forKey:) is not implemented.") + return KeyedDecodingContainer( + STPKeyedDecodingContainer( + codingPath: [], + dict: NSMutableDictionary(), + allKeys: [], + userInfo: [:] + ) + ) + } + + func nestedUnkeyedContainer(forKey key: K) throws -> UnkeyedDecodingContainer { + assertionFailure("nestedUnkeyedContainer(forKey:) is not implemented.") + return STPUnkeyedDecodingContainer( + userInfo: [:], + array: NSArray(), + codingPath: [], + currentIndex: 0 + ) + } + + func superDecoder() throws -> Decoder { + assertionFailure("superDecoder() is not implemented.") + return _stpinternal_JSONDecoder(jsonObject: NSNull()) + } + + func superDecoder(forKey key: K) throws -> Decoder { + assertionFailure("superDecoder(forKey:) is not implemented.") + return _stpinternal_JSONDecoder(jsonObject: NSNull()) + } + +} + +private struct STPUnkeyedDecodingContainer: UnkeyedDecodingContainer, STPDecodingContainerProtocol { + var userInfo: [CodingUserInfoKey: Any] + + var array: NSArray + + var codingPath: [CodingKey] + + var count: Int? { + return array.count + } + + var isAtEnd: Bool { + return currentIndex >= count ?? 0 + } + + var currentIndex: Int = 0 + + mutating func _popObject() -> NSObject { + assert(!isAtEnd, "Tried to read past the end of the container.") + let object = array[currentIndex] as! NSObject + currentIndex += 1 + return object + } + + mutating func _decode(_ type: T.Type) throws -> T where T: Decodable { + let newPath = codingPath + [STPCodingKey(intValue: currentIndex)!] + + let value: T = try castFromNSObject(codingPath: newPath, type, _popObject()) + if var sdValue = value as? UnknownFieldsDecodable { + try sdValue.applyUnknownFieldDecodingTransforms(userInfo: userInfo, codingPath: newPath) + return sdValue as! T + } + return value + } + + mutating func decodeNil() throws -> Bool { + let object = _popObject() + return (object is NSNull) + } + + mutating func decode(_ type: Bool.Type) throws -> Bool { + return try _decode(type) + } + + mutating func decode(_ type: String.Type) throws -> String { + return try _decode(type) + } + + mutating func decode(_ type: Double.Type) throws -> Double { + return try _decode(type) + } + + mutating func decode(_ type: Float.Type) throws -> Float { + return try _decode(type) + } + + mutating func decode(_ type: Int.Type) throws -> Int { + return try _decode(type) + } + + mutating func decode(_ type: Int8.Type) throws -> Int8 { + return try _decode(type) + } + + mutating func decode(_ type: Int16.Type) throws -> Int16 { + return try _decode(type) + } + + mutating func decode(_ type: Int32.Type) throws -> Int32 { + return try _decode(type) + } + + mutating func decode(_ type: Int64.Type) throws -> Int64 { + return try _decode(type) + } + + mutating func decode(_ type: UInt.Type) throws -> UInt { + return try _decode(type) + } + + mutating func decode(_ type: UInt8.Type) throws -> UInt8 { + return try _decode(type) + } + + mutating func decode(_ type: UInt16.Type) throws -> UInt16 { + return try _decode(type) + } + + mutating func decode(_ type: UInt32.Type) throws -> UInt32 { + return try _decode(type) + } + + mutating func decode(_ type: UInt64.Type) throws -> UInt64 { + return try _decode(type) + } + + mutating func decode(_ type: T.Type) throws -> T where T: Decodable { + return try _decode(type) + } + + mutating func nestedContainer( + keyedBy type: NestedKey.Type + ) throws -> KeyedDecodingContainer where NestedKey: CodingKey { + assertionFailure("nestedContainer(keyedBy:) is not implemented.") + return KeyedDecodingContainer( + STPKeyedDecodingContainer( + codingPath: [], + dict: NSMutableDictionary(), + allKeys: [], + userInfo: [:] + ) + ) + } + + mutating func nestedUnkeyedContainer() throws -> UnkeyedDecodingContainer { + assertionFailure("nestedUnkeyedContainer(forKey:) is not implemented.") + return STPUnkeyedDecodingContainer(userInfo: [:], array: NSArray(), codingPath: []) + } + + mutating func superDecoder() throws -> Decoder { + assertionFailure("superDecoder() is not implemented.") + return _stpinternal_JSONDecoder(jsonObject: NSNull()) + } + +} + +private struct STPSingleValueDecodingContainer: SingleValueDecodingContainer, + STPDecodingContainerProtocol +{ + var codingPath: [CodingKey] + + var userInfo: [CodingUserInfoKey: Any] + + var object: NSObject + + func _decode(_ type: T.Type) throws -> T where T: Decodable { + let value: T = try castFromNSObject(codingPath: codingPath, type, object) + if var sdValue = value as? UnknownFieldsDecodable { + try sdValue.applyUnknownFieldDecodingTransforms( + userInfo: userInfo, + codingPath: codingPath + ) + return sdValue as! T + } + return value + } + + func decodeNil() -> Bool { + return object == NSNull() + } + + func decode(_ type: Bool.Type) throws -> Bool { + return try _decode(type) + } + + func decode(_ type: String.Type) throws -> String { + return try _decode(type) + } + + func decode(_ type: Double.Type) throws -> Double { + return try _decode(type) + } + + func decode(_ type: Float.Type) throws -> Float { + return try _decode(type) + } + + func decode(_ type: Int.Type) throws -> Int { + return try _decode(type) + } + + func decode(_ type: Int8.Type) throws -> Int8 { + return try _decode(type) + } + + func decode(_ type: Int16.Type) throws -> Int16 { + return try _decode(type) + } + + func decode(_ type: Int32.Type) throws -> Int32 { + return try _decode(type) + } + + func decode(_ type: Int64.Type) throws -> Int64 { + return try _decode(type) + } + + func decode(_ type: UInt.Type) throws -> UInt { + return try _decode(type) + } + + func decode(_ type: UInt8.Type) throws -> UInt8 { + return try _decode(type) + } + + func decode(_ type: UInt16.Type) throws -> UInt16 { + return try _decode(type) + } + + func decode(_ type: UInt32.Type) throws -> UInt32 { + return try _decode(type) + } + + func decode(_ type: UInt64.Type) throws -> UInt64 { + return try _decode(type) + } + + func decode(_ type: T.Type) throws -> T where T: Decodable { + return try _decode(type) + } + +} + +// MARK: Casting logic + +// These extensions help us maintain the type information for Arrays and Dictionaries within castFromNSObject, so that +// inner calls to castFromNSObject call the templated function without erasing the underlying type. +// I'm not sure if there's a cleaner way to do this... +private protocol _STPDecodableIsArray { + static var valueType: Decodable.Type { get } +} +extension Array: _STPDecodableIsArray where Element: Decodable { + static var valueType: Decodable.Type { return Element.self } +} +private protocol _STPDecodableIsDictionary { + static var valueType: Decodable.Type { get } +} +extension Dictionary: _STPDecodableIsDictionary where Key == String, Value: Decodable { + static var valueType: Decodable.Type { return Value.self } +} +extension Decodable { + fileprivate static func _castFromNSObject( + codingPath: [CodingKey] = [], + decodingContainer: STPDecodingContainerProtocol, + object: NSObject + ) throws -> Self { + return try decodingContainer.castFromNSObject(codingPath: codingPath, Self.self, object) + } +} + +extension STPDecodingContainerProtocol { + func castFromNSObject( + codingPath: [CodingKey] = [], + _ type: T.Type, + _ object: NSObject + ) throws -> T where T: Decodable { + switch type { + case is Double.Type: + switch object as? String { + case UnknownFieldsCodableFloats.PositiveInfinity.rawValue: + return Double.infinity as! T + case UnknownFieldsCodableFloats.NegativeInfinity.rawValue: + return -Double.infinity as! T + case UnknownFieldsCodableFloats.NaN.rawValue: + return Double.nan as! T + case .none, .some: + guard let value = object as? Double, let returnValue = value as? T else { + throw DecodingError.dataCorrupted( + .init( + codingPath: codingPath, + debugDescription: + "Parsed JSON number <\(object)> does not fit in \(type).", + underlyingError: nil + ) + ) + } + return returnValue + } + case is Float.Type: + switch object as? String { + case UnknownFieldsCodableFloats.PositiveInfinity.rawValue: + return Float.infinity as! T + case UnknownFieldsCodableFloats.NegativeInfinity.rawValue: + return -Float.infinity as! T + case UnknownFieldsCodableFloats.NaN.rawValue: + return Float.nan as! T + case .none, .some: + guard let value = object as? Float, let returnValue = value as? T else { + throw DecodingError.dataCorrupted( + .init( + codingPath: codingPath, + debugDescription: + "Parsed JSON number <\(object)> does not fit in \(type).", + underlyingError: nil + ) + ) + } + return returnValue + } + case is Bool.Type, + is Int.Type, + is Int8.Type, + is Int16.Type, + is Int32.Type, + is Int64.Type, + is UInt.Type, + is UInt8.Type, + is UInt16.Type, + is UInt32.Type, + is UInt64.Type, + is String.Type: + guard let value = object as? T else { + throw DecodingError.dataCorrupted( + .init( + codingPath: codingPath, + debugDescription: "Parsed JSON number <\(object)> does not fit in \(type).", + underlyingError: nil + ) + ) + } + return value + case is Decimal.Type: + guard let decimal = (object as? NSNumber)?.decimalValue else { + throw DecodingError.dataCorrupted( + .init( + codingPath: codingPath, + debugDescription: "Could not convert <\(object)> to \(type).", + underlyingError: nil + ) + ) + } + return decimal as! T + case is URL.Type: + guard let string = object as? String, let url = URL(string: string) else { + throw DecodingError.dataCorrupted( + .init( + codingPath: codingPath, + debugDescription: "Could not convert <\(object)> to \(type).", + underlyingError: nil + ) + ) + } + return url as! T + case is Data.Type: + guard let string = object as? String, let data = Data(base64Encoded: string) else { + throw DecodingError.dataCorrupted( + .init( + codingPath: codingPath, + debugDescription: "Could not convert <\(object)> to \(type).", + underlyingError: nil + ) + ) + } + return data as! T + case is Date.Type: + guard let ti = object as? TimeInterval else { + throw DecodingError.dataCorrupted( + .init( + codingPath: codingPath, + debugDescription: "Could not convert <\(object)> to \(type).", + underlyingError: nil + ) + ) + } + return Date(timeIntervalSince1970: ti) as! T + case is _STPDecodableIsDictionary.Type: + guard let dict = object as? [String: Any] else { + throw DecodingError.dataCorrupted( + .init( + codingPath: codingPath, + debugDescription: "Could not convert <\(object)> to \(type).", + underlyingError: nil + ) + ) + } + var convertedDict: [String: Any] = [:] + for (k, v) in dict { + let dictType = T.self as! (_STPDecodableIsDictionary.Type) + convertedDict[k] = try dictType.valueType._castFromNSObject( + codingPath: codingPath, + decodingContainer: self, + object: v as! NSObject + ) + } + return convertedDict as! T + case is _STPDecodableIsArray.Type: + guard let array = object as? [Any] else { + throw DecodingError.dataCorrupted( + .init( + codingPath: codingPath, + debugDescription: "Could not convert <\(object)> to \(type).", + underlyingError: nil + ) + ) + } + var convertedArray: [Any] = [] + for i in array { + let arrayType = T.self as! (_STPDecodableIsArray.Type) + convertedArray.append( + try arrayType.valueType._castFromNSObject( + codingPath: codingPath, + decodingContainer: self, + object: i as! NSObject + ) + ) + } + return convertedArray as! T + case is SafeEnumDecodable.Type: + do { + let decoder = _stpinternal_JSONDecoder(jsonObject: object) + decoder.userInfo = userInfo + decoder.codingPath = codingPath + return try T(from: decoder) + } catch Swift.DecodingError.dataCorrupted { + let enumDecodableType = T.self as! (SafeEnumDecodable.Type) + return enumDecodableType.unparsable as! T + } + default: + let decoder = _stpinternal_JSONDecoder(jsonObject: object) + decoder.userInfo = userInfo + decoder.codingPath = codingPath + return try T(from: decoder) + } + } +} diff --git a/StripeCore/StripeCore/Source/Coder/StripeJSONEncoder.swift b/StripeCore/StripeCore/Source/Coder/StripeJSONEncoder.swift new file mode 100644 index 00000000..fd85a1d1 --- /dev/null +++ b/StripeCore/StripeCore/Source/Coder/StripeJSONEncoder.swift @@ -0,0 +1,564 @@ +// +// StripeJSONEncoder.swift +// StripeCore +// +// Copyright © 2022 Stripe, Inc. All rights reserved. +// +// This is a bridge between NSJSONSerialization and Encoder, including some Stripe-specific behavior. +// + +import Foundation + +@_spi(STP) public class StripeJSONEncoder { + @_spi(STP) public var userInfo: [CodingUserInfoKey: Any] = [:] + + @_spi(STP) public var outputFormatting: JSONSerialization.WritingOptions = [] + + @_spi(STP) public func encode(_ value: T, includingUnknownFields: Bool = true) throws -> Data + where T: Encodable { + var outputFormatting = self.outputFormatting + outputFormatting.insert(.fragmentsAllowed) + + if value is UnknownFieldsEncodable { + let jsonDictionary = try self.encodeJSONDictionary( + value, + includingUnknownFields: includingUnknownFields + ) + return try JSONSerialization.data( + withJSONObject: jsonDictionary, + options: outputFormatting + ) + } else { + return try JSONSerialization.data( + withJSONObject: castToNSObject(value), + options: outputFormatting + ) + } + } + + @_spi(STP) public func encodeJSONDictionary( + _ value: T, + includingUnknownFields: Bool = true + ) throws -> [String: Any] where T: Encodable { + var outputFormatting = self.outputFormatting + outputFormatting.insert(.fragmentsAllowed) + + // Set up a dictionary on the encoder to fill with additionalAPIParameters during encoding + let dictionary = NSMutableDictionary() + userInfo[UnknownFieldsEncodableSourceStorageKey] = dictionary + userInfo[StripeIncludeUnknownFieldsKey] = includingUnknownFields + + if let seValue = value as? UnknownFieldsEncodable { + // Encode the top-level additionalAPIParameters into the userInfo + seValue.applyUnknownFieldEncodingTransforms(userInfo: userInfo, codingPath: []) + } + + // Encode the object to JSON data + let jsonData = try JSONSerialization.data( + withJSONObject: castToNSObject(value), + options: outputFormatting + ) + + // Convert the JSON data into a JSON dictionary + var jsonDictionary = + try JSONSerialization.jsonObject(with: jsonData, options: []) as! [String: Any] + + // Merge in the additional parameters we collected in our encoder userInfo's NSMutableDictionary during encoding + try jsonDictionary.merge( + dictionary as! [String: Any], + uniquingKeysWith: [String: Any].stp_deepMerge + ) + + return jsonDictionary + } +} + +// Make sure StripeJSONEncoder can call castToNSObject +extension StripeJSONEncoder: StripeEncodingContainer {} + +class _stpinternal_JSONEncoder: Encoder { + var codingPath: [CodingKey] = [] + + var userInfo: [CodingUserInfoKey: Any] = [:] + + enum ContainerValue { + case dict(NSMutableDictionary) + case array(NSMutableArray) + case singleValue(NSObject) + } + + private var container: ContainerValue? + + func container(keyedBy type: Key.Type) -> KeyedEncodingContainer + where Key: CodingKey { + let dict = NSMutableDictionary() + self.container = .dict(dict) + return KeyedEncodingContainer( + STPKeyedEncodingContainer(codingPath: codingPath, dict: dict, userInfo: userInfo) + ) + } + + func unkeyedContainer() -> UnkeyedEncodingContainer { + // We've been asked for an array, so initialize a top-level empty array to handle the case of an empty array. + let array = NSMutableArray() + self.container = .array(array) + return STPUnkeyedEncodingContainer(codingPath: codingPath, array: array, userInfo: userInfo) + } + + func singleValueContainer() -> SingleValueEncodingContainer { + self.container = .singleValue(NSNull()) + return STPSingleValueEncodingContainer( + codingPath: codingPath, + encodingBlock: { self.container = .singleValue($0) }, + userInfo: userInfo + ) + } + + /// Return the NSObject contained by this encoder: Either a dictionary, an array, or a single object. + var singleContainer: NSObject { + guard let container = container else { + assertionFailure("Called singleContainer on an empty decoder") + return NSNull() + } + switch container { + case .dict(let dict): + return dict + case .array(let array): + return array + case .singleValue(let singleValue): + return singleValue + } + } +} + +struct STPKeyedEncodingContainer: StripeEncodingContainer, KeyedEncodingContainerProtocol +where K: CodingKey { + var codingPath: [CodingKey] + + typealias Key = K + + var dict: NSMutableDictionary + var userInfo: [CodingUserInfoKey: Any] + + mutating private func encode(object: NSObject, forKey key: K) throws { + let maintainExistingCase = userInfo[STPMaintainExistingCase] as? Bool ?? false + + let stringKey = key.stringValue + let key = + maintainExistingCase ? stringKey : URLEncoder.convertToSnakeCase(camelCase: stringKey) + + dict[key] = object + } + + mutating func encodeNil(forKey key: K) throws { + try encode(object: NSNull(), forKey: key) + } + + mutating func encode(_ value: Bool, forKey key: K) throws { + try encode(object: castToNSObject(value), forKey: key) + } + + mutating func encode(_ value: String, forKey key: K) throws { + try encode(object: castToNSObject(value), forKey: key) + } + + mutating func encode(_ value: Double, forKey key: K) throws { + try encode(object: castToNSObject(value), forKey: key) + } + + mutating func encode(_ value: Float, forKey key: K) throws { + try encode(object: castToNSObject(value), forKey: key) + } + + mutating func encode(_ value: Int, forKey key: K) throws { + try encode(object: castToNSObject(value), forKey: key) + } + + mutating func encode(_ value: Int8, forKey key: K) throws { + try encode(object: castToNSObject(value), forKey: key) + } + + mutating func encode(_ value: Int16, forKey key: K) throws { + try encode(object: castToNSObject(value), forKey: key) + } + + mutating func encode(_ value: Int32, forKey key: K) throws { + try encode(object: castToNSObject(value), forKey: key) + } + + mutating func encode(_ value: Int64, forKey key: K) throws { + try encode(object: castToNSObject(value), forKey: key) + } + + mutating func encode(_ value: UInt, forKey key: K) throws { + try encode(object: castToNSObject(value), forKey: key) + } + + mutating func encode(_ value: UInt8, forKey key: K) throws { + try encode(object: castToNSObject(value), forKey: key) + } + + mutating func encode(_ value: UInt16, forKey key: K) throws { + try encode(object: castToNSObject(value), forKey: key) + } + + mutating func encode(_ value: UInt32, forKey key: K) throws { + try encode(object: castToNSObject(value), forKey: key) + } + + mutating func encode(_ value: UInt64, forKey key: K) throws { + try encode(object: castToNSObject(value), forKey: key) + } + + mutating func encode(_ value: T, forKey key: K) throws where T: Encodable { + if value is NonEncodableParameters { + // Don't encode this + return + } + let newPath = codingPath + [key] + if let seValue = value as? UnknownFieldsEncodable { + seValue.applyUnknownFieldEncodingTransforms(userInfo: userInfo, codingPath: newPath) + } + try encode(object: castToNSObject(codingPath: newPath, value), forKey: key) + } + + mutating func nestedContainer( + keyedBy keyType: NestedKey.Type, + forKey key: K + ) -> KeyedEncodingContainer where NestedKey: CodingKey { + // This is messy to support and we don't have any situations + // in the Stripe SDK where we want to use it. The implementation + // would probably look similar to superEncoder() above. + assertionFailure("nestedContainer(keyedBy:) is not implemented.") + return KeyedEncodingContainer( + STPKeyedEncodingContainer( + codingPath: codingPath + [key], + dict: NSMutableDictionary(), + userInfo: userInfo + ) + ) + } + + mutating func nestedUnkeyedContainer(forKey key: K) -> UnkeyedEncodingContainer { + // This is messy to support and we don't have any situations + // in the Stripe SDK where we want to use it. The implementation + // would probably look similar to superEncoder() above. + assertionFailure("nestedUnkeyedContainer(forKey:) is not implemented.") + return STPUnkeyedEncodingContainer( + codingPath: codingPath + [key], + array: NSMutableArray(), + userInfo: userInfo + ) + } + + mutating func superEncoder() -> Encoder { + // See above superEncoder() comment. + assertionFailure("superEncoder() is not implemented.") + return _stpinternal_JSONEncoder() + } + + mutating func superEncoder(forKey key: K) -> Encoder { + // See above superEncoder() comment. + assertionFailure("superEncoder(forKey:) is not implemented.") + return _stpinternal_JSONEncoder() + } +} + +struct STPUnkeyedEncodingContainer: UnkeyedEncodingContainer, StripeEncodingContainer { + var codingPath: [CodingKey] + + var count: Int = 0 + + var array: NSMutableArray + var userInfo: [CodingUserInfoKey: Any] + + mutating func encodeNil() throws { + array.add(NSNull()) + count += 1 + } + + mutating func encode(_ value: Bool) throws { + try array.add(castToNSObject(value)) + count += 1 + } + + mutating func encode(_ value: String) throws { + try array.add(castToNSObject(value)) + count += 1 + } + + mutating func encode(_ value: Double) throws { + try array.add(castToNSObject(value)) + count += 1 + } + + mutating func encode(_ value: Float) throws { + try array.add(castToNSObject(value)) + count += 1 + } + + mutating func encode(_ value: Int) throws { + try array.add(castToNSObject(value)) + count += 1 + } + + mutating func encode(_ value: Int8) throws { + try array.add(castToNSObject(value)) + count += 1 + } + + mutating func encode(_ value: Int16) throws { + try array.add(castToNSObject(value)) + count += 1 + } + + mutating func encode(_ value: Int32) throws { + try array.add(castToNSObject(value)) + count += 1 + } + + mutating func encode(_ value: Int64) throws { + try array.add(castToNSObject(value)) + count += 1 + } + + mutating func encode(_ value: UInt) throws { + try array.add(castToNSObject(value)) + count += 1 + } + + mutating func encode(_ value: UInt8) throws { + try array.add(castToNSObject(value)) + count += 1 + } + + mutating func encode(_ value: UInt16) throws { + try array.add(castToNSObject(value)) + count += 1 + } + + mutating func encode(_ value: UInt32) throws { + try array.add(castToNSObject(value)) + count += 1 + } + + mutating func encode(_ value: UInt64) throws { + try array.add(castToNSObject(value)) + count += 1 + } + + mutating func encode(_ value: T) throws where T: Encodable { + if value is NonEncodableParameters { + // Don't encode this + return + } + let newPath = codingPath + [STPCodingKey(intValue: count)!] + if let seValue = value as? UnknownFieldsEncodable { + seValue.applyUnknownFieldEncodingTransforms(userInfo: userInfo, codingPath: newPath) + } + try array.add(castToNSObject(codingPath: newPath, value)) + + count += 1 + } + + mutating func nestedContainer( + keyedBy keyType: NestedKey.Type + ) -> KeyedEncodingContainer where NestedKey: CodingKey { + // This is messy to support and we don't have any situations + // in the Stripe SDK where we want to use it. The implementation + // would probably look similar to superEncoder() below. + assertionFailure("nestedContainer(keyedBy:) is not implemented.") + return KeyedEncodingContainer( + STPKeyedEncodingContainer( + codingPath: codingPath, + dict: NSMutableDictionary(), + userInfo: userInfo + ) + ) + } + + mutating func nestedUnkeyedContainer() -> UnkeyedEncodingContainer { + // This is messy to support and we don't have any situations + // in the Stripe SDK where we want to use it. The implementation + // would probably look similar to superEncoder() below. + assertionFailure("nestedUnkeyedContainer() is not implemented.") + return STPUnkeyedEncodingContainer( + codingPath: codingPath, + array: NSMutableArray(), + userInfo: userInfo + ) + } + + mutating func superEncoder() -> Encoder { + // Super-encoding is messy and we don't have any situations in + // the Stripe SDK where a Codable inherits from another Codable. + // If you'd like to implement this, see + // https://forums.swift.org/t/writing-encoders-and-decoders-different-question/10232/5 + // for details. + assertionFailure("superEncoder() is not implemented.") + return _stpinternal_JSONEncoder() + } + +} + +struct STPSingleValueEncodingContainer: SingleValueEncodingContainer, StripeEncodingContainer { + var codingPath: [CodingKey] + + var encodingBlock: (NSObject) -> Void + var userInfo: [CodingUserInfoKey: Any] + + mutating func encodeNil() throws { + encodingBlock(NSNull()) + } + + mutating func encode(_ value: Bool) throws { + encodingBlock(try castToNSObject(value)) + } + + mutating func encode(_ value: String) throws { + encodingBlock(try castToNSObject(value)) + } + + mutating func encode(_ value: Double) throws { + encodingBlock(try castToNSObject(value)) + } + + mutating func encode(_ value: Float) throws { + encodingBlock(try castToNSObject(value)) + } + + mutating func encode(_ value: Int) throws { + encodingBlock(try castToNSObject(value)) + } + + mutating func encode(_ value: Int8) throws { + encodingBlock(try castToNSObject(value)) + } + + mutating func encode(_ value: Int16) throws { + encodingBlock(try castToNSObject(value)) + } + + mutating func encode(_ value: Int32) throws { + encodingBlock(try castToNSObject(value)) + } + + mutating func encode(_ value: Int64) throws { + encodingBlock(try castToNSObject(value)) + } + + mutating func encode(_ value: UInt) throws { + encodingBlock(try castToNSObject(value)) + } + + mutating func encode(_ value: UInt8) throws { + encodingBlock(try castToNSObject(value)) + } + + mutating func encode(_ value: UInt16) throws { + encodingBlock(try castToNSObject(value)) + } + + mutating func encode(_ value: UInt32) throws { + encodingBlock(try castToNSObject(value)) + } + + mutating func encode(_ value: UInt64) throws { + encodingBlock(try castToNSObject(value)) + } + + mutating func encode(_ value: T) throws where T: Encodable { + if value is NonEncodableParameters { + // Don't encode this + return + } + if let seValue = value as? UnknownFieldsEncodable { + seValue.applyUnknownFieldEncodingTransforms(userInfo: userInfo, codingPath: codingPath) + } + encodingBlock(try castToNSObject(value)) + } +} + +protocol StripeEncodingContainer { + var userInfo: [CodingUserInfoKey: Any] { + get set + } +} + +extension StripeEncodingContainer { + fileprivate func castToNSObject(codingPath: [CodingKey] = [], _ value: T) throws -> NSObject + where T: Encodable { + switch value { + case let n as Bool: + return n as NSObject + case let n as String: + return n as NSObject + case let n as Double: + if n == .infinity { + return UnknownFieldsCodableFloats.PositiveInfinity.rawValue as NSObject + } + if n == -.infinity { + return UnknownFieldsCodableFloats.NegativeInfinity.rawValue as NSObject + } + if n.isNaN { + return UnknownFieldsCodableFloats.NaN.rawValue as NSObject + } + return n as NSObject + case let n as Float: + if n == .infinity { + return UnknownFieldsCodableFloats.PositiveInfinity.rawValue as NSObject + } + if n == -.infinity { + return UnknownFieldsCodableFloats.NegativeInfinity.rawValue as NSObject + } + if n.isNaN { + return UnknownFieldsCodableFloats.NaN.rawValue as NSObject + } + return n as NSObject + case let n as Int: + return n as NSObject + case let n as Int8: + return n as NSObject + case let n as Int16: + return n as NSObject + case let n as Int32: + return n as NSObject + case let n as Int64: + return n as NSObject + case let n as UInt: + return n as NSObject + case let n as UInt8: + return n as NSObject + case let n as UInt16: + return n as NSObject + case let n as UInt32: + return n as NSObject + case let n as UInt64: + return n as NSObject + case let decimal as Decimal: + return NSDecimalNumber(decimal: decimal) + case let url as URL: + return url.absoluteString as NSObject + case let date as Date: + // Stripe expects an integer number of seconds since the Unix epoch + return Int(date.timeIntervalSince1970) as NSObject + case let data as Data: + // Stripe expects base64-encoded data + return data.base64EncodedString() as NSObject + case is [AnyHashable: Any]: + let encoder = _stpinternal_JSONEncoder() + encoder.userInfo = userInfo + // If this is a dictionary, don't apply transformations to the keys + encoder.userInfo[STPMaintainExistingCase] = true + encoder.codingPath = codingPath + try value.encode(to: encoder) + return encoder.singleContainer + default: + let encoder = _stpinternal_JSONEncoder() + encoder.userInfo = userInfo + encoder.codingPath = codingPath + try value.encode(to: encoder) + return encoder.singleContainer + } + } +} diff --git a/StripeCore/StripeCore/Source/Coder/StripeJSONShared.swift b/StripeCore/StripeCore/Source/Coder/StripeJSONShared.swift new file mode 100644 index 00000000..0381b42d --- /dev/null +++ b/StripeCore/StripeCore/Source/Coder/StripeJSONShared.swift @@ -0,0 +1,37 @@ +// +// StripeJSONShared.swift +// StripeCore +// +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +// Constants +internal let STPMaintainExistingCase = CodingUserInfoKey(rawValue: "_STPMaintainExistingCase")! + +internal struct STPCodingKey: CodingKey { + init?( + stringValue: String + ) { + self.stringValue = stringValue + } + + init?( + intValue: Int + ) { + self.intValue = intValue + self.stringValue = intValue.description + } + + init( + stringValue: String, + intValue: Int? + ) { + self.intValue = intValue + self.stringValue = stringValue + } + + var stringValue: String + var intValue: Int? +} diff --git a/StripeCore/StripeCore/Source/Coder/UnknownFields.swift b/StripeCore/StripeCore/Source/Coder/UnknownFields.swift new file mode 100644 index 00000000..281bfbe4 --- /dev/null +++ b/StripeCore/StripeCore/Source/Coder/UnknownFields.swift @@ -0,0 +1,98 @@ +// +// UnknownFields.swift +// StripeCore +// +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +extension UnknownFieldsEncodable { + func applyUnknownFieldEncodingTransforms( + userInfo: [CodingUserInfoKey: Any], + codingPath: [CodingKey] + ) { + if !(userInfo[StripeIncludeUnknownFieldsKey] as? Bool ?? false) { + // Don't include unknown fields. + return + } + // If we have additional parameters, add these to the parameters we're sending. + // Follow the encoder codingPath *up*, then store it in the userInfo + + // We can't modify the userInfo of the encoder directly, + // but we *can* give it a reference to an NSMutableDictionary + // and mutate that as we go. + if !self.additionalParameters.isEmpty, + let dictionary = userInfo[UnknownFieldsEncodableSourceStorageKey] + as? NSMutableDictionary + { + var mutateDictionary = dictionary + for path in codingPath { + // Make sure we're dealing with snake_case. + let snakeValue = URLEncoder.convertToSnakeCase(camelCase: path.stringValue) + // If the dictionary exists at that level, retrieve it as an NSMutableDictionary reference + if let subDictionary = mutateDictionary[snakeValue] as? NSMutableDictionary { + mutateDictionary = subDictionary + } else { + // If it does not exist, create an NSMutableDictionary at that level. + let newDictionary = NSMutableDictionary() + mutateDictionary[snakeValue] = newDictionary + mutateDictionary = newDictionary + } + } + // Merge the additionalParameters dictionary onto the existing dictionary. + mutateDictionary.addEntries(from: self.additionalParameters) + } + } +} + +extension UnknownFieldsDecodable { + mutating func applyUnknownFieldDecodingTransforms( + userInfo: [CodingUserInfoKey: Any], + codingPath: [CodingKey] + ) throws { + var object = self + + // Follow the encoder's codingPath down the userInfo JSON dictionary + if let originalJSON = userInfo[UnknownFieldsDecodableSourceStorageKey] as? Data, + var jsonDictionary = try JSONSerialization.jsonObject(with: originalJSON, options: []) + as? [String: Any] + { + for path in codingPath { + let snakeValue = URLEncoder.convertToSnakeCase(camelCase: path.stringValue) + // This will always be a dictionary. If it isn't then something is being + // decoded as an object by JSONDecoder, but a dictionary by JSONSerialization. + jsonDictionary = jsonDictionary[snakeValue] as! [String: Any] + } + // Set the allResponseFields dictionary, so that users can access unknown fields. + object.allResponseFields = jsonDictionary + + // If the wrapped value is also *encodable*, we'll want some special behavior + // so it can be re-encoded without losing the unknown fields. + // To do this, we'll: + // 1. Re-encode it (without unknown fields) to a dictionary + // 2. Subtract the "known fields" dictionay from our source dictionary + // 3. Set additionalParameters to the resulting dictionary, giving us + // a dictionary of only our missing or uninterpretable fields. + // When the object is later re-encoded, the additionalParameters will + // be re-added to the encoded JSON. + if var encodableValue = object as? UnknownFieldsEncodable { + let encodedDictionary = try encodableValue.encodeJSONDictionary( + includingUnknownFields: false + ) + encodableValue.additionalParameters = jsonDictionary.subtracting(encodedDictionary) + object = encodableValue as! Self + } + } + self = object + } +} + +let StripeIncludeUnknownFieldsKey = CodingUserInfoKey(rawValue: "_StripeIncludeUnknownFieldsKey")! + +let UnknownFieldsEncodableSourceStorageKey = CodingUserInfoKey( + rawValue: "_UnknownFieldsEncodableSourceStorageKey" +)! +let UnknownFieldsDecodableSourceStorageKey = CodingUserInfoKey( + rawValue: "_UnknownFieldsDecodableSourceStorageKey" +)! diff --git a/StripeCore/StripeCore/Source/Connections Bindings/ConnectionsSDKInterface.swift b/StripeCore/StripeCore/Source/Connections Bindings/ConnectionsSDKInterface.swift new file mode 100644 index 00000000..fb16de68 --- /dev/null +++ b/StripeCore/StripeCore/Source/Connections Bindings/ConnectionsSDKInterface.swift @@ -0,0 +1,37 @@ +// +// ConnectionsSDKInterface.swift +// StripeCore +// +// Created by Vardges Avetisyan on 2/24/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) @frozen public enum FinancialConnectionsSDKResult { + case completed(linkedBank: LinkedBank) + case cancelled + case failed(error: Error) +} + +@_spi(STP) public protocol FinancialConnectionsSDKInterface { + init() + func presentFinancialConnectionsSheet( + apiClient: STPAPIClient, + clientSecret: String, + returnURL: String?, + from presentingViewController: UIViewController, + completion: @escaping (FinancialConnectionsSDKResult) -> Void + ) +} + +// MARK: - Types + +@_spi(STP) public protocol LinkedBank { + var sessionId: String { get } + var accountId: String { get } + var displayName: String? { get } + var bankName: String? { get } + var last4: String? { get } + var instantlyVerified: Bool { get } +} diff --git a/StripeCore/StripeCore/Source/DownloadManager/DownloadManager.swift b/StripeCore/StripeCore/Source/DownloadManager/DownloadManager.swift new file mode 100644 index 00000000..f76b8cc6 --- /dev/null +++ b/StripeCore/StripeCore/Source/DownloadManager/DownloadManager.swift @@ -0,0 +1,232 @@ +// +// DownloadManager.swift +// StripeCore +// + +import CoreGraphics +import Foundation +import UIKit + +/// For internal SDK use only. +@objc(STP_Internal_DownloadManager) +@_spi(STP) public class DownloadManager: NSObject, URLSessionDelegate { + public typealias UpdateImageHandler = (UIImage) -> Void + + public static let sharedManager = DownloadManager() + + let downloadQueue: DispatchQueue + let downloadOperationQueue: OperationQueue + let session: URLSession! + + var imageCache: [String: UIImage] + var pendingRequests: [String: URLSessionTask] + var updateHandlers: [String: [UpdateImageHandler]] + + let imageCacheSemaphore: DispatchSemaphore + let pendingRequestsSemaphore: DispatchSemaphore + + let STPCacheExpirationInterval = (60 * 60 * 24 * 7) // 1 week + var urlCache: URLCache? + + public init( + urlSessionConfiguration: URLSessionConfiguration = .default + ) { + downloadQueue = DispatchQueue(label: "Stripe Download Cache", attributes: .concurrent) + downloadOperationQueue = OperationQueue() + downloadOperationQueue.underlyingQueue = downloadQueue + + let configuration = urlSessionConfiguration + if let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) + .first + { + let diskCacheURL = cachesURL.appendingPathComponent("STPCache") + // 5MB memory cache, 30MB Disk cache + let cache = URLCache( + memoryCapacity: 5_000_000, + diskCapacity: 30_000_000, + directory: diskCacheURL + ) + configuration.urlCache = cache + configuration.requestCachePolicy = .useProtocolCachePolicy + self.urlCache = cache + } + + session = URLSession(configuration: configuration) + + imageCache = [:] + pendingRequests = [:] + updateHandlers = [:] + + imageCacheSemaphore = DispatchSemaphore(value: 1) + pendingRequestsSemaphore = DispatchSemaphore(value: 1) + + super.init() + } +} + +// MARK: - Download management +extension DownloadManager { + public func downloadImage(url: URL, updateHandler: UpdateImageHandler?) -> UIImage { + if updateHandler == nil { + return downloadImageBlocking(url: url) + } else { + return downloadImageAsync(url: url, updateHandler: updateHandler) + } + } + + func downloadImageBlocking(url: URL) -> UIImage { + let imageName = imageNameFromURL(url: url) + if let image = cachedImageNamed(imageName) { + return image + } + + var blockingDownloadedImage: UIImage? + let updateHandler: UpdateImageHandler = { image in + blockingDownloadedImage = image + } + let blockingDownloadSemaphore = DispatchSemaphore(value: 0) + + let urlRequest = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy) + let task = self.session.downloadTask(with: url) { tempURL, response, _ in + guard let tempURL = tempURL, + let response = response, + let data = self.getDataFromURL(tempURL), + let image = self.persistToMemory(data, forImageName: imageName) + else { + blockingDownloadSemaphore.signal() + return + } + self.urlCache?.storeCachedResponse( + CachedURLResponse(response: response, data: data), + for: urlRequest + ) + updateHandler(image) + blockingDownloadSemaphore.signal() + } + task.resume() + blockingDownloadSemaphore.wait() + return blockingDownloadedImage ?? imagePlaceHolder() + } + + func downloadImageAsync(url: URL, updateHandler: UpdateImageHandler?) -> UIImage { + let imageName = imageNameFromURL(url: url) + if let image = cachedImageNamed(imageName) { + return image + } + let urlRequest = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy) + let task = self.session.downloadTask(with: url) { tempURL, response, _ in + guard let tempURL = tempURL, + let response = response, + let data = self.getDataFromURL(tempURL), + let image = self.persistToMemory(data, forImageName: imageName) + else { + self.pendingRequestsSemaphore.wait() + self.pendingRequests.removeValue(forKey: imageName) + self.pendingRequestsSemaphore.signal() + return + } + self.urlCache?.storeCachedResponse( + CachedURLResponse(response: response, data: data), + for: urlRequest + ) + + self.pendingRequestsSemaphore.wait() + self.pendingRequests.removeValue(forKey: imageName) + let updates = self.updateHandlers[imageName] ?? [] + self.updateHandlers.removeValue(forKey: imageName) + self.pendingRequestsSemaphore.signal() + + for updateHandler in updates { + updateHandler(image) + } + } + + self.pendingRequestsSemaphore.wait() + guard self.pendingRequests[imageName] == nil else { + addUpdateHandlerWithoutLocking(updateHandler, forImageName: imageName) + self.pendingRequestsSemaphore.signal() + return imagePlaceHolder() + } + self.pendingRequests[imageName] = task + addUpdateHandlerWithoutLocking(updateHandler, forImageName: imageName) + self.pendingRequestsSemaphore.signal() + task.resume() + + return imagePlaceHolder() + } + + func imageNameFromURL(url: URL) -> String { + return url.lastPathComponent + } + + func addUpdateHandlerWithoutLocking( + _ handler: UpdateImageHandler?, + forImageName imageName: String + ) { + guard let handler = handler else { + return + } + if let blocks = self.updateHandlers[imageName] { + self.updateHandlers[imageName] = blocks + [handler] + } else { + self.updateHandlers[imageName] = [handler] + } + } + + func getDataFromURL(_ tempURL: URL) -> Data? { + do { + let imageData = try Data(contentsOf: tempURL) + return imageData + } catch { + } + return nil + } +} + +// MARK: Image Cache +extension DownloadManager { + func resetMemoryCache() { + imageCacheSemaphore.wait() + self.imageCache = [:] + imageCacheSemaphore.signal() + } + + func resetDiskCache() { + self.urlCache?.removeAllCachedResponses() + } + + func persistToMemory(_ imageData: Data, forImageName imageName: String) -> UIImage? { + guard let image = UIImage(data: imageData, scale: UIScreen.main.scale) else { + return nil + } + imageCacheSemaphore.wait() + self.imageCache[imageName] = image + imageCacheSemaphore.signal() + return image + } + + func cachedImageNamed(_ imageName: String) -> UIImage? { + var image: UIImage? + imageCacheSemaphore.wait() + image = imageCache[imageName] + imageCacheSemaphore.signal() + return image + } +} + +// MARK: Image Placeholder +extension DownloadManager { + public func imagePlaceHolder() -> UIImage { + return imageWithSize(size: CGSize(width: 1.0, height: 1.0)) + } + + func imageWithSize(size: CGSize) -> UIImage { + let rect = CGRect(x: 0, y: 0, width: size.width, height: size.height) + UIGraphicsBeginImageContextWithOptions(size, false, 0.0) + UIColor.clear.set() + UIRectFill(rect) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return image! + } +} diff --git a/StripeCore/StripeCore/Source/Helpers/Async.swift b/StripeCore/StripeCore/Source/Helpers/Async.swift new file mode 100644 index 00000000..40ebc9ca --- /dev/null +++ b/StripeCore/StripeCore/Source/Helpers/Async.swift @@ -0,0 +1,153 @@ +// +// Async.swift +// StripeCore +// +// Created by Yuki Tokuhiro on 9/12/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// +// Futures and Promises. Delete this when the SDK is iOS13+ and use the Combine framework instead. +// +// Taken from https://github.com/JohnSundell/SwiftBySundell/blob/master/Blog/Under-the-hood-of-Futures-and-Promises.swift +// +// MIT License +// +// Copyright (c) 2017 John Sundell +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +@_spi(STP) public class Future { + public typealias Result = Swift.Result + + fileprivate var result: Result? { + // Observe whenever a result is assigned, and report it: + didSet { result.map(report) } + } + private var callbacks = [(Result) -> Void]() + + public func observe( + on queue: DispatchQueue? = nil, + using callback: @escaping (Result) -> Void + ) { + let wrappedCallback: (Result) -> Void + if let queue = queue { + wrappedCallback = { r in + queue.async { + callback(r) + } + } + } else { + wrappedCallback = callback + } + + // If a result has already been set, call the callback directly: + if let result = result { + return wrappedCallback(result) + } + + callbacks.append(wrappedCallback) + } + + private func report(result: Result) { + callbacks.forEach { $0(result) } + callbacks = [] + } + + public func chained( + on queue: DispatchQueue? = nil, + using closure: @escaping (Value) throws -> Future + ) -> Future { + // We'll start by constructing a "wrapper" promise that will be + // returned from this method: + let promise = Promise() + + // Observe the current future: + observe(on: queue) { result in + switch result { + case .success(let value): + do { + // Attempt to construct a new future using the value + // returned from the first one: + let future = try closure(value) + + // Observe the "nested" future, and once it + // completes, resolve/reject the "wrapper" future: + future.observe { result in + switch result { + case .success(let value): + promise.resolve(with: value) + case .failure(let error): + promise.reject(with: error) + } + } + } catch { + promise.reject(with: error) + } + case .failure(let error): + promise.reject(with: error) + } + } + + return promise + } +} + +@_spi(STP) public class Promise: Future { + public override init() { + super.init() + } + + public convenience init( + value: Value + ) { + self.init() + + // If the value was already known at the time the promise + // was constructed, we can report it directly: + result = .success(value) + } + + public convenience init( + error: Error + ) { + self.init() + result = .failure(error) + } + + public func resolve(with value: Value) { + result = .success(value) + } + + public func reject(with error: Error) { + result = .failure(error) + } + + public func fullfill(with result: Result) { + self.result = result + } + + public func fulfill(with block: () throws -> Value) { + do { + self.result = .success(try block()) + } catch { + self.result = .failure(error) + } + } +} diff --git a/StripeCore/StripeCore/Source/Helpers/BundleLocatorProtocol.swift b/StripeCore/StripeCore/Source/Helpers/BundleLocatorProtocol.swift new file mode 100644 index 00000000..03c147ae --- /dev/null +++ b/StripeCore/StripeCore/Source/Helpers/BundleLocatorProtocol.swift @@ -0,0 +1,75 @@ +// +// BundleLocatorProtocol.swift +// StripeCore +// +// Created by Brian Dorfman on 8/31/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) public protocol BundleLocatorProtocol { + /// A final class that is internal to the bundle implementing this protocol. + /// + /// - Note: The class must be `final` to ensure that it can't be subclassed, + /// which may change the result of `bundleForClass`. + static var internalClass: AnyClass { get } + + /// Name of the bundle. + static var bundleName: String { get } + + /// Cached result from `computeResourcesBundle()` so it doesn't need to be recomputed. + static var resourcesBundle: Bundle { get } + + #if SWIFT_PACKAGE + /// SPM Bundle, if available. + /// + /// Implementation should be should be `Bundle.module`. + static var spmResourcesBundle: Bundle { get } + #endif +} + +extension BundleLocatorProtocol { + /// Computes the bundle to fetch resources from. + /// + /// - Note: This should never be called directly. Instead, call `resourcesBundle`. + /// - Description: + /// Places to check: + /// 1. Swift Package Manager bundle. + /// 2. Stripe.bundle (for manual static installations and framework-less Cocoapods). + /// 3. Stripe.framework/Stripe.bundle (for framework-based Cocoapods). + /// 4. Stripe.framework (for Carthage, manual dynamic installations). + /// 5. main bundle (for people dragging all our files into their project). + public static func computeResourcesBundle() -> Bundle { + var ourBundle: Bundle? + + #if SWIFT_PACKAGE + ourBundle = spmResourcesBundle + #endif + + if ourBundle == nil { + ourBundle = Bundle(path: "\(bundleName).bundle") + } + + if ourBundle == nil { + // This might be the same as the previous check if not using a dynamic framework + if let path = Bundle(for: internalClass).path( + forResource: bundleName, + ofType: "bundle" + ) { + ourBundle = Bundle(path: path) + } + } + + if ourBundle == nil { + // This will be the same as mainBundle if not using a dynamic framework + ourBundle = Bundle(for: internalClass) + } + + if let ourBundle = ourBundle { + return ourBundle + } else { + return Bundle.main + } + } +} diff --git a/StripeCore/StripeCore/Source/Helpers/FileDownloader.swift b/StripeCore/StripeCore/Source/Helpers/FileDownloader.swift new file mode 100644 index 00000000..1dc7ddee --- /dev/null +++ b/StripeCore/StripeCore/Source/Helpers/FileDownloader.swift @@ -0,0 +1,75 @@ +// +// FileDownloader.swift +// StripeCore +// +// Created by Mel Ludowise on 2/1/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// Downloads files using a downloadTask. +@_spi(STP) public final class FileDownloader { + let urlSession: URLSession + + /// Initializes the `FileDownloader`. + /// + /// - Parameter urlSession: The session to use to download files with + public init( + urlSession: URLSession + ) { + self.urlSession = urlSession + } + + /// Downloads a file from the specified URL and returns a promise that will + /// resolve to the temporary local file location where the file was downloaded to. + /// + /// The temporary file will be deleted by the file system immediately after the + /// promise is observed. If the promise must not be observed on another + /// DispatchQueue or the file will be deleted before it can be observed. + /// + /// - Parameter remoteURL: The URL to download the file from. + public func downloadFileTemporarily(from remoteURL: URL) -> Future { + let promise = Promise() + + let request = URLRequest(url: remoteURL) + + let downloadTask = urlSession.downloadTask(with: request) { url, _, error in + + if let error = error { + return promise.reject(with: error) + } + + guard let url = url else { + return promise.reject(with: NSError.stp_genericConnectionError()) + } + + promise.resolve(with: url) + } + downloadTask.resume() + + return promise + } + + /// Downloads a file from the specified URL and returns a promise that will + /// resolve to the data contents of the file. + /// + /// - Parameters: + /// - remoteURL: The URL to download the file from + /// - fileReadingOptions: Options for reading the file after it's been downloaded locally. + public func downloadFile( + from remoteURL: URL, + fileReadingOptions: Data.ReadingOptions = [] + ) -> Future { + return downloadFileTemporarily(from: remoteURL).chained { fileURL in + let promise = Promise() + promise.fulfill { + return try Data( + contentsOf: fileURL, + options: fileReadingOptions + ) + } + return promise + } + } +} diff --git a/StripeCore/StripeCore/Source/Helpers/InstallMethod.swift b/StripeCore/StripeCore/Source/Helpers/InstallMethod.swift new file mode 100644 index 00000000..5660e0ee --- /dev/null +++ b/StripeCore/StripeCore/Source/Helpers/InstallMethod.swift @@ -0,0 +1,27 @@ +// +// InstallMethod.swift +// StripeCore +// +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +enum InstallMethod: String { + case cocoapods = "C" + case spm = "S" + case binary = "B" // Built via export_builds.sh + case xcode = "X" // Directly built via Xcode or xcodebuild + + static let current: InstallMethod = { + #if COCOAPODS + return .cocoapods + #elseif SWIFT_PACKAGE + return .spm + #elseif STRIPE_BUILD_PACKAGE + return .binary + #else + return .xcode + #endif + }() +} diff --git a/StripeCore/StripeCore/Source/Helpers/PaymentsSDKVariant.swift b/StripeCore/StripeCore/Source/Helpers/PaymentsSDKVariant.swift new file mode 100644 index 00000000..630920aa --- /dev/null +++ b/StripeCore/StripeCore/Source/Helpers/PaymentsSDKVariant.swift @@ -0,0 +1,57 @@ +// +// PaymentsSDKVariant.swift +// StripeCore +// +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) public class PaymentsSDKVariant { + @_spi(STP) public static let variant: String = { + if NSClassFromString("STPPaymentContext") != nil { + // This is the full legacy Payments SDK, including Basic Integration + return "legacy" + } + if NSClassFromString("STP_Internal_PaymentSheetViewController") != nil { + // This is the PaymentSheet SDK + return "paymentsheet" + } + if NSClassFromString("STPPaymentCardTextField") != nil { + // This is the Payments UI SDK + return "payments-ui" + } + if NSClassFromString("STPCardValidator") != nil { + // This is the API-only Payments SDK + return "payments-api" + } + if NSClassFromString("STPApplePayContext") != nil { + // This is only the Apple Pay SDK + return "applepay" + } + // This is a cryptid + return "unknown" + }() + + @_spi(STP) public static var ocrTypeString: String { + // "STPCardScanner" is STPCardScanner.stp_analyticsIdentifier, but STPCardScanner only exists in Stripe.framework. + if STPAnalyticsClient.sharedClient.productUsage.contains( + "STPCardScanner" + ) + || STPAnalyticsClient.sharedClient.productUsage.contains( + "STPCardScanner_legacy" + ) + { + return "stripe" + } + return "none" + } + + @_spi(STP) public static var paymentUserAgent: String { + var paymentUserAgent = "stripe-ios/\(STPAPIClient.STPSDKVersion)" + let variant = "variant.\(variant)" + let components = [paymentUserAgent, variant] + STPAnalyticsClient.sharedClient.productUsage + paymentUserAgent = components.joined(separator: "; ") + return paymentUserAgent + } +} diff --git a/StripeCore/StripeCore/Source/Helpers/STPDeviceUtils.swift b/StripeCore/StripeCore/Source/Helpers/STPDeviceUtils.swift new file mode 100644 index 00000000..5bd87007 --- /dev/null +++ b/StripeCore/StripeCore/Source/Helpers/STPDeviceUtils.swift @@ -0,0 +1,25 @@ +// +// STPDeviceUtils.swift +// StripeCore +// +// Created by Mel Ludowise on 3/8/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +struct STPDeviceUtils { + static var deviceType: String? { + var systemInfo: utsname = utsname() + uname(&systemInfo) + let machineMirror = Mirror(reflecting: systemInfo.machine) + let deviceType = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } + guard !deviceType.isEmpty else { + return nil + } + return deviceType + } +} diff --git a/StripeCore/StripeCore/Source/Helpers/STPDispatchFunctions.swift b/StripeCore/StripeCore/Source/Helpers/STPDispatchFunctions.swift new file mode 100644 index 00000000..a0c116e9 --- /dev/null +++ b/StripeCore/StripeCore/Source/Helpers/STPDispatchFunctions.swift @@ -0,0 +1,17 @@ +// +// STPDispatchFunctions.swift +// StripeCore +// +// Created by Brian Dorfman on 10/24/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) public func stpDispatchToMainThreadIfNecessary(_ block: @escaping () -> Void) { + if Thread.isMainThread { + block() + } else { + DispatchQueue.main.async(execute: block) + } +} diff --git a/StripeCore/StripeCore/Source/Helpers/STPError.swift b/StripeCore/StripeCore/Source/Helpers/STPError.swift new file mode 100644 index 00000000..b003452a --- /dev/null +++ b/StripeCore/StripeCore/Source/Helpers/STPError.swift @@ -0,0 +1,307 @@ +// +// STPError.swift +// StripeCore +// +// Created by Saikat Chakrabarti on 11/4/12. +// Copyright © 2012 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// Possible error code values for NSErrors with the `StripeDomain` domain. +@objc public enum STPErrorCode: Int { + /// Trouble connecting to Stripe. + @objc(STPConnectionError) case connectionError = 40 + /// Your request had invalid parameters. + @objc(STPInvalidRequestError) case invalidRequestError = 50 + /// No valid publishable API key provided. + @objc(STPAuthenticationError) case authenticationError = 51 + /// General-purpose API error. + @objc(STPAPIError) case apiError = 60 + /// Something was wrong with the given card details. + @objc(STPCardError) case cardError = 70 + /// The operation was cancelled. + @objc(STPCancellationError) case cancellationError = 80 + /// The ephemeral key could not be decoded. Make sure your backend is sending + /// the unmodified JSON of the ephemeral key to your app. + /// https://stripe.com/docs/mobile/ios/basic#prepare-your-api + @objc(STPEphemeralKeyDecodingError) case ephemeralKeyDecodingError = 1000 +} + +// MARK: - STPError + +// swift-format-ignore: DontRepeatTypeInStaticProperties +/// Top-level class for Stripe error constants. +@objc public class STPError: NSObject { + // MARK: userInfo keys + /// All Stripe iOS errors will be under this domain. + @objc public static let stripeDomain = "com.stripe.lib" + + // swift-format-ignore: DontRepeatTypeInStaticProperties + /// The error domain for errors in `STPPaymentHandler`. + @objc public static let STPPaymentHandlerErrorDomain = "STPPaymentHandlerErrorDomain" + + /// A human-readable message providing more details about the error. + /// For card errors, these messages can be shown to your users. + /// - seealso: https://stripe.com/docs/api/errors#errors-message + @objc public static let errorMessageKey = "com.stripe.lib:ErrorMessageKey" + /// An SDK-supplied "hint" that is intended to help you, the developer, fix the error. + @objc public static let hintKey = "com.stripe.lib:hintKey" + /// What went wrong with your STPCard (e.g., STPInvalidCVC). + /// + /// See below for full list). + @objc public static let cardErrorCodeKey = "com.stripe.lib:CardErrorCodeKey" + /// Which parameter on the STPCard had an error (e.g., "cvc"). + /// + /// Useful for marking up the right UI element. + @objc public static let errorParameterKey = "com.stripe.lib:ErrorParameterKey" + /// The error code returned by the Stripe API. + /// + /// - seealso: https://stripe.com/docs/api#errors-code + /// - seealso: https://stripe.com/docs/error-codes + @objc public static let stripeErrorCodeKey = "com.stripe.lib:StripeErrorCodeKey" + /// The error type returned by the Stripe API. + /// + /// - seealso: https://stripe.com/docs/api#errors-type + @objc public static let stripeErrorTypeKey = "com.stripe.lib:StripeErrorTypeKey" + /// If the value of `userInfo[stripeErrorCodeKey]` is `STPError.cardDeclined`, + /// the value for this key contains the decline code. + /// + /// - seealso: https://stripe.com/docs/declines/codes + @objc public static let stripeDeclineCodeKey = "com.stripe.lib:DeclineCodeKey" +} + +extension NSError { + @_spi(STP) public class Utils { + private static let apiErrorCodeToMessage: [String: String] = [ + "incorrect_number": NSError.stp_cardErrorInvalidNumberUserMessage(), + "invalid_number": NSError.stp_cardErrorInvalidNumberUserMessage(), + "invalid_expiry_month": NSError.stp_cardErrorInvalidExpMonthUserMessage(), + "invalid_expiry_year": NSError.stp_cardErrorInvalidExpYearUserMessage(), + "invalid_cvc": NSError.stp_cardInvalidCVCUserMessage(), + "expired_card": NSError.stp_cardErrorExpiredCardUserMessage(), + "incorrect_cvc": NSError.stp_cardInvalidCVCUserMessage(), + "card_declined": NSError.stp_cardErrorDeclinedUserMessage(), + "processing_error": NSError.stp_cardErrorProcessingErrorUserMessage(), + "invalid_owner_name": NSError.stp_invalidOwnerName, + "invalid_bank_account_iban": NSError.stp_invalidBankAccountIban, + "generic_decline": NSError.stp_genericDeclineErrorUserMessage(), + ] + + private static let apiErrorCodeToCardErrorCode: [String: STPCardErrorCode] = [ + "incorrect_number": .incorrectNumber, + "invalid_number": .invalidNumber, + "invalid_expiry_month": .invalidExpMonth, + "invalid_expiry_year": .invalidExpYear, + "invalid_cvc": .invalidCVC, + "expired_card": .expiredCard, + "incorrect_cvc": .invalidCVC, + "card_declined": .cardDeclined, + "processing_error": .processingError, + "incorrect_zip": .incorrectZip, + ] + + private init() {} + + @_spi(STP) public static func localizedMessage( + fromAPIErrorCode errorCode: String, + declineCode: String? = nil + ) -> String? { + return + (apiErrorCodeToMessage[errorCode] + ?? declineCode.flatMap { apiErrorCodeToMessage[$0] }) + } + + @_spi(STP) public static func cardErrorCode( + fromAPIErrorCode errorCode: String + ) -> STPCardErrorCode? { + return apiErrorCodeToCardErrorCode[errorCode] + } + } +} + +/// NSError extensions for creating error objects from Stripe API responses. +extension NSError { + @_spi(STP) public static func stp_error(from modernStripeError: StripeError) -> NSError? { + switch modernStripeError { + case .apiError(let stripeAPIError): + return stp_error(fromStripeResponse: ["error": stripeAPIError.allResponseFields]) + case .invalidRequest: + return NSError( + domain: STPError.stripeDomain, + code: STPErrorCode.invalidRequestError.rawValue, + userInfo: nil + ) + } + } + + @_spi(STP) public static func stp_error( + errorType: String?, + stripeErrorCode: String?, + stripeErrorMessage: String?, + errorParam: String?, + declineCode: Any?, + httpResponse: HTTPURLResponse? + ) -> NSError? { + var code = 0 + + var userInfo: [AnyHashable: Any] = [ + NSLocalizedDescriptionKey: self.stp_unexpectedErrorMessage(), + ] + userInfo[STPError.stripeErrorCodeKey] = stripeErrorCode ?? "" + userInfo[STPError.stripeErrorTypeKey] = errorType ?? "" + if let errorParam = errorParam { + userInfo[STPError.errorParameterKey] = URLEncoder.convertToCamelCase( + snakeCase: errorParam + ) + } + if let stripeErrorMessage = stripeErrorMessage { + userInfo[STPError.errorMessageKey] = stripeErrorMessage + userInfo[STPError.hintKey] = ServerErrorMapper.mobileErrorMessage( + from: stripeErrorMessage, + httpResponse: httpResponse + ) + } else { + userInfo[STPError.errorMessageKey] = + "Could not interpret the error response that was returned from Stripe." + } + if errorType == "api_error" { + code = STPErrorCode.apiError.rawValue + } else { + if errorType == "invalid_request_error" { + switch httpResponse?.statusCode { + case 401: + code = STPErrorCode.authenticationError.rawValue + default: + code = STPErrorCode.invalidRequestError.rawValue + } + } else if errorType == "card_error" { + code = STPErrorCode.cardError.rawValue + // see https://stripe.com/docs/api/errors#errors-message + userInfo[NSLocalizedDescriptionKey] = stripeErrorMessage + } else { + code = STPErrorCode.apiError.rawValue + } + + if let stripeErrorCode = stripeErrorCode, !stripeErrorCode.isEmpty { + if let cardErrorCode = Utils.cardErrorCode(fromAPIErrorCode: stripeErrorCode) { + if cardErrorCode == STPCardErrorCode.cardDeclined, + let decline_code = declineCode + { + userInfo[STPError.stripeDeclineCodeKey] = decline_code + } + userInfo[STPError.cardErrorCodeKey] = cardErrorCode.rawValue + } + + // If the server didn't send an error message, use a local one. + if stripeErrorMessage == nil { + let localizedMessage = Utils.localizedMessage( + fromAPIErrorCode: stripeErrorCode, + declineCode: declineCode as? String + ) + + if let localizedMessage = localizedMessage { + userInfo[NSLocalizedDescriptionKey] = localizedMessage + } + } + } + } + + return NSError( + domain: STPError.stripeDomain, + code: code, + userInfo: userInfo as? [String: Any] + ) + } + + @_spi(STP) public static func stp_error( + fromStripeResponse jsonDictionary: [AnyHashable: Any]?, + httpResponse: HTTPURLResponse? + ) -> NSError? { + // TODO: Refactor. A lot of this can be replaced by a lookup/decision table. Check Android implementation for cues. + guard let dict = (jsonDictionary as NSDictionary?), + let errorDictionary = dict["error"] as? NSDictionary + else { + return nil + } + let errorType = errorDictionary["type"] as? String + let errorParam = errorDictionary["param"] as? String + let stripeErrorMessage = errorDictionary["message"] as? String + let stripeErrorCode = errorDictionary["code"] as? String + let declineCode = errorDictionary["decline_code"] + + return stp_error( + errorType: errorType, + stripeErrorCode: stripeErrorCode, + stripeErrorMessage: stripeErrorMessage, + errorParam: errorParam, + declineCode: declineCode, + httpResponse: httpResponse + ) + } + + /// Creates an NSError object from a given Stripe API json response. + /// - Parameter jsonDictionary: The root dictionary from the JSON response. + /// - Returns: An NSError object with the error information from the JSON response, + /// or nil if there was no error information included in the JSON dictionary. + @objc(stp_errorFromStripeResponse:) public static func stp_error( + fromStripeResponse jsonDictionary: [AnyHashable: Any]? + ) + -> NSError? + { + stp_error(fromStripeResponse: jsonDictionary, httpResponse: nil) + } +} + +// MARK: STPCardErrorCodeKeys - + +/// Possible string values you may receive when there was an error tokenizing a card. +/// +/// These values will come back in the error `userInfo` dictionary +/// under the `STPCardErrorCodeKey` key. +public enum STPCardErrorCode: String { + /// The card number is not a valid credit card number. + case invalidNumber = "com.stripe.lib:InvalidNumber" + /// The card has an invalid expiration month. + case invalidExpMonth = "com.stripe.lib:InvalidExpiryMonth" + /// The card has an invalid expiration year. + case invalidExpYear = "com.stripe.lib:InvalidExpiryYear" + /// The card has an invalid CVC. + case invalidCVC = "com.stripe.lib:InvalidCVC" + /// The card number is incorrect. + case incorrectNumber = "com.stripe.lib:IncorrectNumber" + /// The card is expired. + case expiredCard = "com.stripe.lib:ExpiredCard" + /// The card was declined. + case cardDeclined = "com.stripe.lib:CardDeclined" + /// The card has an incorrect CVC. + case incorrectCVC = "com.stripe.lib:IncorrectCVC" + /// An error occured while processing this card. + case processingError = "com.stripe.lib:ProcessingError" + /// The postal code is incorrect. + case incorrectZip = "com.stripe.lib:IncorrectZip" +} + +// swift-format-ignore: DontRepeatTypeInStaticProperties +@objc extension STPError { + /// The card number is not a valid credit card number. + public static let invalidNumber = STPCardErrorCode.invalidNumber.rawValue + /// The card has an invalid expiration month. + public static let invalidExpMonth = STPCardErrorCode.invalidExpMonth.rawValue + /// The card has an invalid expiration year. + public static let invalidExpYear = STPCardErrorCode.invalidExpYear.rawValue + /// The card has an invalid CVC. + public static let invalidCVC = STPCardErrorCode.invalidCVC.rawValue + /// The card number is incorrect. + public static let incorrectNumber = STPCardErrorCode.incorrectNumber.rawValue + /// The card is expired. + public static let expiredCard = STPCardErrorCode.expiredCard.rawValue + /// The card was declined. + public static let cardDeclined = STPCardErrorCode.cardDeclined.rawValue + /// An error occured while processing this card. + public static let processingError = STPCardErrorCode.processingError.rawValue + /// The card has an incorrect CVC. + public static let incorrectCVC = STPCardErrorCode.incorrectCVC.rawValue + /// The postal code is incorrect. + public static let incorrectZip = STPCardErrorCode.incorrectZip.rawValue +} diff --git a/StripeCore/StripeCore/Source/Helpers/STPNumericStringValidator.swift b/StripeCore/StripeCore/Source/Helpers/STPNumericStringValidator.swift new file mode 100644 index 00000000..6f84c71c --- /dev/null +++ b/StripeCore/StripeCore/Source/Helpers/STPNumericStringValidator.swift @@ -0,0 +1,31 @@ +// +// STPNumericStringValidator.swift +// StripeCore +// +// Created by Cameron Sabol on 3/6/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) public enum STPTextValidationState: Int { + case empty + case incomplete + case complete + case invalid +} + +@_spi(STP) open class STPNumericStringValidator: NSObject { + /// Whether or not the target string contains only numeric characters. + @_spi(STP) public class func isStringNumeric(_ string: String) -> Bool { + return + (string as NSString).rangeOfCharacter(from: CharacterSet.stp_invertedAsciiDigit) + .location + == NSNotFound + } + + /// Returns a copy of the passed string with all non-numeric characters removed. + @_spi(STP) public class func sanitizedNumericString(for string: String) -> String { + return string.stp_stringByRemovingCharacters(from: CharacterSet.stp_invertedAsciiDigit) + } +} diff --git a/StripeCore/StripeCore/Source/Helpers/STPURLCallbackHandler.swift b/StripeCore/StripeCore/Source/Helpers/STPURLCallbackHandler.swift new file mode 100644 index 00000000..221a64e7 --- /dev/null +++ b/StripeCore/StripeCore/Source/Helpers/STPURLCallbackHandler.swift @@ -0,0 +1,92 @@ +// +// STPURLCallbackHandler.swift +// StripeCore +// +// Created by Brian Dorfman on 10/6/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) @objc public protocol STPURLCallbackListener: NSObjectProtocol { + func handleURLCallback(_ url: URL) -> Bool +} + +@_spi(STP) public class STPURLCallbackHandler: NSObject { + @_spi(STP) public static var sharedHandler: STPURLCallbackHandler = STPURLCallbackHandler() + + @objc @_spi(STP) public class func shared() -> STPURLCallbackHandler { + return sharedHandler + } + + @objc @discardableResult @_spi(STP) public func handleURLCallback(_ url: URL) -> Bool { + guard + let components = NSURLComponents( + url: url, + resolvingAgainstBaseURL: false + ) + else { + return false + } + + var resultsOrred = false + + for callback in callbacks { + if let listener = callback.listener { + if callback.urlComponents.stp_matchesURLComponents(components) { + resultsOrred = resultsOrred || listener.handleURLCallback(url) + } + } + } + + return resultsOrred + } + + @objc(registerListener:forURL:) @_spi(STP) public func register( + _ listener: STPURLCallbackListener, + for url: URL + ) { + + guard + let urlComponents = NSURLComponents( + url: url, + resolvingAgainstBaseURL: false + ) + else { + return + } + let callback = STPURLCallback(urlComponents: urlComponents, listener: listener) + var callbacksCopy = callbacks + callbacksCopy.append(callback) + callbacks = callbacksCopy + } + + @objc @_spi(STP) public func unregisterListener(_ listener: STPURLCallbackListener) { + var callbacksToRemove: [AnyHashable] = [] + + for callback in callbacks { + if listener.isEqual(callback.listener) { + callbacksToRemove.append(callback) + } + } + var callbacksCopy = callbacks + callbacksCopy = callbacksCopy.filter({ !callbacksToRemove.contains($0) }) + callbacks = callbacksCopy + } + + private var callbacks: [STPURLCallback] = [] +} + +class STPURLCallback: NSObject { + init( + urlComponents: NSURLComponents, + listener: STPURLCallbackListener + ) { + self.urlComponents = urlComponents + self.listener = listener + super.init() + } + + var urlComponents: NSURLComponents + weak var listener: STPURLCallbackListener? +} diff --git a/StripeCore/StripeCore/Source/Helpers/ServerErrorMapper.swift b/StripeCore/StripeCore/Source/Helpers/ServerErrorMapper.swift new file mode 100644 index 00000000..b608326e --- /dev/null +++ b/StripeCore/StripeCore/Source/Helpers/ServerErrorMapper.swift @@ -0,0 +1,95 @@ +// +// ServerErrorMapper.swift +// StripeCore +// +// Created by Nick Porter on 9/13/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +private enum ServerErrorPrefixes: String, CaseIterable { + case missingPublishableKey = "You did not provide an API key." + case invalidApiKey = "Invalid API Key provided" + case mismatchPublishableKey = + "The client_secret provided does not match the client_secret associated with" + case noSuchPaymentIntent = "No such payment_intent" + case noSuchSetupIntent = "No such setup_intent" + + /// Maps the corresponding `ServerErrorMapper` to a `MobileErrorMessage`. + /// + /// - Parameters: + /// - serverErrorMessage: the raw server error message from the server. + /// - httpResponse: the http response for the error. + /// - Returns: a `MobileErrorMessage` that maps to this `ServerErrorPrefixes`. + func mobileError( + from serverErrorMessage: String, + httpResponse: HTTPURLResponse + ) -> MobileErrorMessage { + switch self { + case .missingPublishableKey: + return MobileErrorMessage.missingPublishableKey + case .invalidApiKey: + if httpResponse.url?.absoluteString.hasPrefix( + "https://api.stripe.com/v1/payment_methods?customer=" + ) ?? false { + // User didn't set ephemeral key correctly + return MobileErrorMessage.invalidCustomerEphKey + } else { + // User didn't set publishable key correctly + return MobileErrorMessage.missingPublishableKey + } + case .mismatchPublishableKey: + return MobileErrorMessage.mismatchPublishableKey + case .noSuchPaymentIntent: + return MobileErrorMessage.noSuchPaymentIntent + case .noSuchSetupIntent: + return MobileErrorMessage.noSuchSetupIntent + } + } +} + +/// List of mobile friendly error messages for common upstream server errors. +private enum MobileErrorMessage: String { + case missingPublishableKey = + "No valid API key provided. Set `STPAPIClient.shared.publishableKey` to your publishable key, which you can find here: https://stripe.com/docs/keys" + + case invalidCustomerEphKey = + "Invalid customer ephemeral key secret. You can find more information at https://stripe.com/docs/payments/accept-a-payment?platform=ios#add-server-endpoint" + + case mismatchPublishableKey = + "The publishable key provided does not match the publishable key associated with the PaymentIntent/SetupIntent. This is most likley caused by using a different publishable key in `STPAPIClient.shared.publishableKey` than what your server is using." + + case noSuchPaymentIntent = + "No matching PaymentIntent could be found. Ensure you are creating a PaymentIntent server side and using the same publishable key on both client and server. You can find more information at https://stripe.com/docs/api/payment_intents/create" + + case noSuchSetupIntent = + "No matching SetupIntent could be found. Ensure you are creating a SetupIntent server side and using the same publishable key on both client and server. You can find more information at https://stripe.com/docs/api/setup_intents/create" +} + +/// Maps known server error message to mobile friendly versions. +struct ServerErrorMapper { + + /// Maps common server error messages to a mobile friendly equivalent if known, + /// otherwise defaults to the server error message. + /// + /// - Parameters: + /// - serverErrorMessage: the error message returned from the server. + /// - httpResponse: the http response for this error. + /// - Returns: a mobile friendly error message if known, + /// otherwise defaults the error message from the server. + static func mobileErrorMessage( + from serverErrorMessage: String, + httpResponse: HTTPURLResponse? + ) -> String? { + guard let httpResponse = httpResponse else { + return nil + } + + let serverError = ServerErrorPrefixes.allCases.first(where: { + serverErrorMessage.hasPrefix($0.rawValue) + }) + return serverError?.mobileError(from: serverErrorMessage, httpResponse: httpResponse) + .rawValue + } +} diff --git a/StripeCore/StripeCore/Source/Helpers/StripeCoreBundleLocator.swift b/StripeCore/StripeCore/Source/Helpers/StripeCoreBundleLocator.swift new file mode 100644 index 00000000..7ac36879 --- /dev/null +++ b/StripeCore/StripeCore/Source/Helpers/StripeCoreBundleLocator.swift @@ -0,0 +1,18 @@ +// +// StripeCoreBundleLocator.swift +// StripeCore +// +// Created by Mel Ludowise on 7/6/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +final class StripeCoreBundleLocator: BundleLocatorProtocol { + static let internalClass: AnyClass = StripeCoreBundleLocator.self + static let bundleName = "StripeCore" + #if SWIFT_PACKAGE + static let spmResourcesBundle = Bundle.module + #endif + static let resourcesBundle = StripeCoreBundleLocator.computeResourcesBundle() +} diff --git a/StripeCore/StripeCore/Source/Helpers/URLEncoder.swift b/StripeCore/StripeCore/Source/Helpers/URLEncoder.swift new file mode 100644 index 00000000..de33cab3 --- /dev/null +++ b/StripeCore/StripeCore/Source/Helpers/URLEncoder.swift @@ -0,0 +1,147 @@ +// +// URLEncoder.swift +// StripeCore +// +// Created by Mel Ludowise on 5/26/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) public final class URLEncoder { + public class func string(byURLEncoding string: String) -> String { + return escape(string) + } + + public class func convertToCamelCase(snakeCase input: String) -> String { + let parts: [String] = input.components(separatedBy: "_") + var camelCaseParam = "" + for (idx, part) in parts.enumerated() { + camelCaseParam += idx == 0 ? part : part.capitalized + } + + return camelCaseParam + } + + public class func convertToSnakeCase(camelCase input: String) -> String { + var newString = input + + while let range = newString.rangeOfCharacter(from: .uppercaseLetters) { + let character = newString[range] + newString = newString.replacingCharacters(in: range, with: character.lowercased()) + newString.insert("_", at: range.lowerBound) + } + + return newString + } + + @objc(queryStringFromParameters:) + public class func queryString(from parameters: [String: Any]) -> String { + return query(parameters) + } +} + +// MARK: - +// The code below is adapted from https://github.com/Alamofire/Alamofire +struct Key { + enum Part { + case normal(String) + case dontEscape(String) + } + let parts: [Part] +} + +/// Creates a percent-escaped, URL encoded query string components from the given key-value pair recursively. +/// +/// - Parameters: +/// - key: Key of the query component. +/// - value: Value of the query component. +/// +/// - Returns: The percent-escaped, URL encoded query string components. +private func queryComponents(fromKey key: String, value: Any) -> [(String, String)] { + func unwrap(_ any: T) -> Any { + let mirror = Mirror(reflecting: any) + guard mirror.displayStyle == .optional, let first = mirror.children.first else { + return any + } + return first.value + } + + var components: [(String, String)] = [] + switch value { + case let dictionary as [String: Any]: + for nestedKey in dictionary.keys.sorted() { + let value = dictionary[nestedKey]! + let escapedNestedKey = escape(nestedKey) + components += queryComponents(fromKey: "\(key)[\(escapedNestedKey)]", value: value) + } + case let array as [Any]: + for (index, value) in array.enumerated() { + components += queryComponents(fromKey: "\(key)[\(index)]", value: value) + } + case let number as NSNumber: + if number.isBool { + components.append((key, escape(number.boolValue ? "true" : "false"))) + } else { + components.append((key, escape("\(number)"))) + } + case let bool as Bool: + components.append((key, escape(bool ? "true" : "false"))) + case let set as Set: + for value in Array(set) { + components += queryComponents(fromKey: "\(key)", value: value) + } + default: + let unwrappedValue = unwrap(value) + components.append((key, escape("\(unwrappedValue)"))) + } + return components +} + +/// Creates a percent-escaped string following RFC 3986 for a query string key or value. +/// +/// - Parameter string: `String` to be percent-escaped. +/// +/// - Returns: The percent-escaped `String`. +private func escape(_ string: String) -> String { + string.addingPercentEncoding(withAllowedCharacters: URLQueryAllowed) ?? string +} + +private func query(_ parameters: [String: Any]) -> String { + var components: [(String, String)] = [] + + for key in parameters.keys.sorted(by: <) { + let value = parameters[key]! + components += queryComponents(fromKey: escape(key), value: value) + } + return components.map { "\($0)=\($1)" }.joined(separator: "&") +} + +/// Creates a CharacterSet from RFC 3986 allowed characters. +/// +/// RFC 3986 states that the following characters are "reserved" characters. +/// +/// - General Delimiters: ":", "#", "[", "]", "@", "?", "/" +/// - Sub-Delimiters: "!", "$", "&", "'", "(", ")", "*", "+", ",", ";", "=" +/// +/// In RFC 3986 - Section 3.4, it states that the "?" and "/" characters should not be escaped to allow +/// query strings to include a URL. Therefore, all "reserved" characters with the exception of "?" and "/" +/// should be percent-escaped in the query string. +private let URLQueryAllowed: CharacterSet = { + // does not include "?" or "/" due to RFC 3986 - Section 3.4. + let generalDelimitersToEncode = ":#[]@" + let subDelimitersToEncode = "!$&'()*+,;=" + let encodableDelimiters = CharacterSet( + charactersIn: "\(generalDelimitersToEncode)\(subDelimitersToEncode)" + ) + + return CharacterSet.urlQueryAllowed.subtracting(encodableDelimiters) +}() + +extension NSNumber { + fileprivate var isBool: Bool { + // Use Obj-C type encoding to check whether the underlying type is a `Bool`, as it's guaranteed as part of + // swift-corelibs-foundation, per [this discussion on the Swift forums](https://forums.swift.org/t/alamofire-on-linux-possible-but-not-release-ready/34553/22). + String(cString: objCType) == "c" + } +} diff --git a/StripeCore/StripeCore/Source/Helpers/URLSession+Retry.swift b/StripeCore/StripeCore/Source/Helpers/URLSession+Retry.swift new file mode 100644 index 00000000..5750a299 --- /dev/null +++ b/StripeCore/StripeCore/Source/Helpers/URLSession+Retry.swift @@ -0,0 +1,42 @@ +// +// URLSession+Retry.swift +// StripeCore +// +// Created by David Estes on 3/26/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +extension URLSession { + @_spi(STP) public func stp_performDataTask( + with request: URLRequest, + completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void, + retryCount: Int = StripeAPI.maxRetries + ) { + let task = dataTask(with: request) { (data, response, error) in + if let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 429, + retryCount > 0 + { + // Add some backoff time with a little bit of jitter: + let delayTime = TimeInterval( + pow(Double(1 + StripeAPI.maxRetries - retryCount), Double(2)) + + .random(in: 0..<0.5) + ) + + let fireDate = Date() + delayTime + self.delegateQueue.schedule(after: .init(fireDate)) { + self.stp_performDataTask( + with: request, + completionHandler: completionHandler, + retryCount: retryCount - 1 + ) + } + } else { + completionHandler(data, response, error) + } + } + task.resume() + } +} diff --git a/StripeCore/StripeCore/Source/Localization/STPLocalizationUtils.swift b/StripeCore/StripeCore/Source/Localization/STPLocalizationUtils.swift new file mode 100644 index 00000000..513ac36a --- /dev/null +++ b/StripeCore/StripeCore/Source/Localization/STPLocalizationUtils.swift @@ -0,0 +1,97 @@ +// +// STPLocalizationUtils.swift +// StripeCore +// +// Created by Brian Dorfman on 8/11/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) public final class STPLocalizationUtils { + /// Acts like NSLocalizedString but tries to find the string in the Stripe + /// bundle first if possible. + /// + /// If the main app has a localization that we do not support, we want to switch + /// to pulling strings from the main bundle instead of our own bundle so that + /// users can add translations for our strings without having to fork the sdk. + /// At launch, NSBundles' store what language(s) the user requests that they + /// actually have translations for in `preferredLocalizations`. + /// We compare our framework's resource bundle to the main app's bundle, and + /// if their language choice doesn't match up we switch to pulling strings + /// from the main bundle instead. + /// This also prevents language mismatches. E.g. the user lists portuguese and + /// then spanish as their preferred languages. The main app supports both so all its + /// strings are in pt, but we support spanish so our bundle marks es as our + /// preferred language and our strings are in es. + /// If the main bundle doesn't have the correct string, we'll always fall back to + /// using the Stripe bundle so we don't inadvertently show an untranslated string. + static func localizedStripeStringUseMainBundle( + bundleLocator: BundleLocatorProtocol.Type + ) -> Bool { + if bundleLocator.resourcesBundle.preferredLocalizations.first + != Bundle.main.preferredLocalizations.first + { + return true + } + return false + } + + static let UnknownString = "STPStringNotFound" + + public class func localizedStripeString( + forKey key: String, + bundleLocator: BundleLocatorProtocol.Type + ) -> String { + if languageOverride != nil { + return testing_localizedStripeString(forKey: key, bundleLocator: bundleLocator) + } + if localizedStripeStringUseMainBundle(bundleLocator: bundleLocator) { + // Per https://developer.apple.com/documentation/foundation/bundle/1417694-localizedstring, + // iOS will give us an empty string if a string isn't found for the specified key. + // Work around this by specifying an unknown sentinel string as the value. If we get that value back, + // we know that the string wasn't present in the bundle. + let userTranslation = Bundle.main.localizedString( + forKey: key, + value: UnknownString, + table: nil + ) + if userTranslation != UnknownString { + return userTranslation + } + } + + return bundleLocator.resourcesBundle.localizedString( + forKey: key, + value: nil, + table: nil + ) + } + + // MARK: - Testing + static var languageOverride: String? + static func overrideLanguage(to string: String?) { + STPLocalizationUtils.languageOverride = string + } + static func testing_localizedStripeString( + forKey key: String, + bundleLocator: BundleLocatorProtocol.Type + ) -> String { + var bundle = bundleLocator.resourcesBundle + + if let languageOverride = languageOverride { + + let lprojPath = bundle.path(forResource: languageOverride, ofType: "lproj") + if let lprojPath = lprojPath { + bundle = Bundle(path: lprojPath)! + } + } + return bundle.localizedString(forKey: key, value: nil, table: nil) + } +} + +/// Use to explicitly ignore static analyzer warning: +/// "User-facing text should use localized string macro". +@inline(__always) @_spi(STP) public func STPNonLocalizedString(_ string: String) -> String { + return string +} diff --git a/StripeCore/StripeCore/Source/Localization/STPLocalizedString.swift b/StripeCore/StripeCore/Source/Localization/STPLocalizedString.swift new file mode 100644 index 00000000..7e44fcfb --- /dev/null +++ b/StripeCore/StripeCore/Source/Localization/STPLocalizedString.swift @@ -0,0 +1,14 @@ +// +// STPLocalizedString.swift +// StripeCore +// +// Created by Mel Ludowise on 7/6/20. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@inline(__always) func STPLocalizedString(_ key: String, _ comment: String?) -> String { + return STPLocalizationUtils.localizedStripeString( + forKey: key, + bundleLocator: StripeCoreBundleLocator.self + ) +} diff --git a/StripeCore/StripeCore/Source/Localization/String+Localized.swift b/StripeCore/StripeCore/Source/Localization/String+Localized.swift new file mode 100644 index 00000000..1b292e42 --- /dev/null +++ b/StripeCore/StripeCore/Source/Localization/String+Localized.swift @@ -0,0 +1,51 @@ +// +// String+Localized.swift +// StripeCore +// +// Created by Mel Ludowise on 8/4/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) extension String { + public enum Localized { + public static var close: String { + return STPLocalizedString("Close", "Text for close button") + } + + public static var tryAgain: String { + return STPLocalizedString("Try again", "Text for a retry button") + } + + public static var scan_card_title_capitalization: String { + STPLocalizedString("Scan Card", "Text for button to scan a credit card") + } + + public static var scan_card: String { + STPLocalizedString("Scan card", "Button title to open camera to scan credit/debit card") + } + + public static var scan_card_privacy_link_text: String { + // THIS STRING SHOULD NOT BE MODIFIED + STPLocalizedString( + "We use Stripe to verify your card details. Stripe may use and store your data according its privacy policy. Learn more", + "Informational text informing the user that Stripe is used to process data and a link to Stripe's privacy policy" + ) + } + + public static func scanCardExpectedPrivacyLinkText() -> NSAttributedString? { + let stringData = Data(String.Localized.scan_card_privacy_link_text.utf8) + let stringOptions: [NSAttributedString.DocumentReadingOptionKey: Any] = [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue, + ] + + return try? NSAttributedString( + data: stringData, + options: stringOptions, + documentAttributes: nil + ) + } + } +} diff --git a/StripeCore/StripeCore/Source/Telemetry/FraudDetectionData.swift b/StripeCore/StripeCore/Source/Telemetry/FraudDetectionData.swift new file mode 100644 index 00000000..23e6fb91 --- /dev/null +++ b/StripeCore/StripeCore/Source/Telemetry/FraudDetectionData.swift @@ -0,0 +1,72 @@ +// +// FraudDetectionData.swift +// StripeCore +// +// Created by Yuki Tokuhiro on 5/20/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +private let SIDLifetime: TimeInterval = 30 * 60 // 30 minutes + +/// Contains encoded values returned from m.stripe.com. +/// +/// - Note: See `STPTelemetryClient`. +/// - Note: See `StripeAPI.advancedFraudSignalsEnabled`. +@_spi(STP) public final class FraudDetectionData: Codable { + @_spi(STP) public static let shared: FraudDetectionData = + // Load initial value from UserDefaults + UserDefaults.standard.fraudDetectionData ?? FraudDetectionData() + + @_spi(STP) public var muid: String? + @_spi(STP) public var guid: String? + @_spi(STP) public var sid: String? + + /// The approximate time that the sid was generated from m.stripe.com + /// Intended to be used to expire the sid after `SIDLifetime` seconds + /// - Note: This class is a dumb container; users must set this value appropriately. + var sidCreationDate: Date? + + init( + sid: String? = nil, + muid: String? = nil, + guid: String? = nil, + sidCreationDate: Date? = nil + ) { + self.sid = sid + self.muid = muid + self.guid = guid + self.sidCreationDate = sidCreationDate + } + + func resetSIDIfExpired() { + guard let sidCreationDate = sidCreationDate else { + return + } + let thirtyMinutesAgo = Date(timeIntervalSinceNow: -SIDLifetime) + if sidCreationDate < thirtyMinutesAgo { + sid = nil + } + } + + deinit { + // Write latest value to disk + UserDefaults.standard.fraudDetectionData = self + } + + func reset() { + self.sid = nil + self.muid = nil + self.guid = nil + self.sidCreationDate = nil + } +} + +extension FraudDetectionData: Equatable { + @_spi(STP) public static func == (lhs: FraudDetectionData, rhs: FraudDetectionData) -> Bool { + return + lhs.muid == rhs.muid && lhs.sid == rhs.sid && lhs.guid == rhs.guid + && lhs.sidCreationDate == rhs.sidCreationDate + } +} diff --git a/StripeCore/StripeCore/Source/Telemetry/STPTelemetryClient.swift b/StripeCore/StripeCore/Source/Telemetry/STPTelemetryClient.swift new file mode 100644 index 00000000..f3c18dfb --- /dev/null +++ b/StripeCore/StripeCore/Source/Telemetry/STPTelemetryClient.swift @@ -0,0 +1,217 @@ +// +// STPTelemetryClient.swift +// StripeCore +// +// Created by Ben Guo on 4/18/17. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +private let TelemetryURL = URL(string: "https://m.stripe.com/6")! + +@_spi(STP) public final class STPTelemetryClient: NSObject { + @_spi(STP) public static var shared: STPTelemetryClient = STPTelemetryClient( + sessionConfiguration: StripeAPIConfiguration.sharedUrlSessionConfiguration + ) + + @_spi(STP) public func addTelemetryFields(toParams params: inout [String: Any]) { + params["muid"] = fraudDetectionData.muid + params["guid"] = fraudDetectionData.guid + fraudDetectionData.resetSIDIfExpired() + params["sid"] = fraudDetectionData.sid + } + + @_spi(STP) public func paramsByAddingTelemetryFields( + toParams params: [String: Any] + ) -> [String: Any] { + var mutableParams = params + mutableParams["muid"] = fraudDetectionData.muid + mutableParams["guid"] = fraudDetectionData.guid + fraudDetectionData.resetSIDIfExpired() + mutableParams["sid"] = fraudDetectionData.sid + return mutableParams + } + + /// Sends a payload of telemetry to the Stripe telemetry service. + /// + /// - Parameters: + /// - forceSend: ⚠️ Always send the request. Only pass this for testing purposes. + /// - completion: Called with the result of the telemetry network request. + @_spi(STP) public func sendTelemetryData( + forceSend: Bool = false, + completion: ((Result<[String: Any], Error>) -> Void)? = nil + ) { + guard forceSend || STPTelemetryClient.shouldSendTelemetry() else { + completion?(.failure(NSError.stp_genericConnectionError())) + return + } + sendTelemetryRequest(jsonPayload: payload, completion: completion) + } + + @_spi(STP) public func updateFraudDetectionIfNecessary( + completion: @escaping ((Result) -> Void) + ) { + fraudDetectionData.resetSIDIfExpired() + if fraudDetectionData.muid == nil || fraudDetectionData.sid == nil { + sendTelemetryRequest( + jsonPayload: [ + "muid": fraudDetectionData.muid ?? "", + "guid": fraudDetectionData.guid ?? "", + "sid": fraudDetectionData.sid ?? "", + ]) { result in + switch result { + case .failure(let error): + completion(.failure(error)) + case .success: + completion(.success(self.fraudDetectionData)) + } + } + } else { + completion(.success(fraudDetectionData)) + } + } + + private let urlSession: URLSession + + @_spi(STP) public class func shouldSendTelemetry() -> Bool { + #if targetEnvironment(simulator) + return false + #else + return StripeAPI.advancedFraudSignalsEnabled && NSClassFromString("XCTest") == nil + #endif + } + + @_spi(STP) public init( + sessionConfiguration config: URLSessionConfiguration + ) { + urlSession = URLSession(configuration: config) + super.init() + } + + private var language = Locale.autoupdatingCurrent.identifier + private lazy var fraudDetectionData = { + return FraudDetectionData.shared + }() + lazy private var platform = [deviceModel, osVersion].joined(separator: " ") + + private var deviceModel: String = { + var systemInfo = utsname() + uname(&systemInfo) + let model = withUnsafePointer(to: &systemInfo.machine) { + $0.withMemoryRebound( + to: CChar.self, + capacity: 1 + ) { ptr in + String.init(validatingUTF8: ptr) + } + } + return model ?? "Unknown" + }() + + private var osVersion = UIDevice.current.systemVersion + + private var screenSize: String { + let screen = UIScreen.main + let screenRect = screen.bounds + let width = screenRect.size.width + let height = screenRect.size.height + let scale = screen.scale + return String(format: "%.0fw_%.0fh_%.0fr", width, height, scale) + } + + private var timeZoneOffset: String { + let timeZone = NSTimeZone.local as NSTimeZone + let hoursFromGMT = Double(timeZone.secondsFromGMT) / (60 * 60) + return String(format: "%.0f", hoursFromGMT) + } + + private func encodeValue(_ value: String?) -> [AnyHashable: Any]? { + if let value = value { + return [ + "v": value, + ] + } + return nil + } + + private var payload: [String: Any] { + var payload: [String: Any] = [:] + var data: [String: Any] = [:] + if let encode = encodeValue(language) { + data["c"] = encode + } + if let encode = encodeValue(platform) { + data["d"] = encode + } + if let encode = encodeValue(screenSize) { + data["f"] = encode + } + if let encode = encodeValue(timeZoneOffset) { + data["g"] = encode + } + payload["a"] = data + + // Don't pass expired SIDs to m.stripe.com + fraudDetectionData.resetSIDIfExpired() + + let otherData: [String: Any] = [ + "d": fraudDetectionData.muid ?? "", + "e": fraudDetectionData.sid ?? "", + "k": Bundle.stp_applicationName() ?? "", + "l": Bundle.stp_applicationVersion() ?? "", + "m": NSNumber(value: StripeAPI.deviceSupportsApplePay()), + "o": osVersion, + "s": deviceModel, + ] + payload["b"] = otherData + payload["tag"] = STPAPIClient.STPSDKVersion + payload["src"] = "ios-sdk" + payload["v2"] = NSNumber(value: 1) + return payload + } + + private func sendTelemetryRequest( + jsonPayload: [String: Any], + completion: ((Result<[String: Any], Error>) -> Void)? = nil + ) { + var request = URLRequest(url: TelemetryURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let data = try? JSONSerialization.data( + withJSONObject: jsonPayload, + options: [] + ) + request.httpBody = data + let task = urlSession.dataTask(with: request as URLRequest) { (data, response, error) in + guard + error == nil, + let response = response as? HTTPURLResponse, + response.statusCode == 200, + let data = data, + let responseDict = try? JSONSerialization.jsonObject(with: data, options: []) + as? [String: Any] + else { + completion?(.failure(error ?? NSError.stp_genericFailedToParseResponseError())) + return + } + + // Update fraudDetectionData + if let muid = responseDict["muid"] as? String { + self.fraudDetectionData.muid = muid + } + if let guid = responseDict["guid"] as? String { + self.fraudDetectionData.guid = guid + } + if self.fraudDetectionData.sid == nil, + let sid = responseDict["sid"] as? String + { + self.fraudDetectionData.sid = sid + self.fraudDetectionData.sidCreationDate = Date() + } + completion?(.success(responseDict)) + } + task.resume() + } +} diff --git a/StripeCore/StripeCore/Source/Telemetry/UserDefaults+PaymentsCore.swift b/StripeCore/StripeCore/Source/Telemetry/UserDefaults+PaymentsCore.swift new file mode 100644 index 00000000..98abd45d --- /dev/null +++ b/StripeCore/StripeCore/Source/Telemetry/UserDefaults+PaymentsCore.swift @@ -0,0 +1,42 @@ +// +// UserDefaults+PaymentsCore.swift +// StripeCore +// +// Created by David Estes on 11/16/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +extension UserDefaults { + /// Canonical list of all UserDefaults keys the SDK uses. + enum StripePaymentsCoreKeys: String { + /// The key for a dictionary FraudDetectionData dictionary. + case fraudDetectionData = "com.stripe.lib:FraudDetectionDataKey" + } + + var fraudDetectionData: FraudDetectionData? { + get { + let key = StripePaymentsCoreKeys.fraudDetectionData.rawValue + guard let data = data(forKey: key) else { + return nil + } + do { + return try JSONDecoder().decode(FraudDetectionData.self, from: data) + } catch let e { + assertionFailure("\(e)") + return nil + } + } + set { + let key = StripePaymentsCoreKeys.fraudDetectionData.rawValue + do { + let data = try JSONEncoder().encode(newValue) + setValue(data, forKey: key) + } catch let e { + assertionFailure("\(e)") + return + } + } + } +} diff --git a/StripeCore/StripeCore/Source/UI/UIActivityIndicatorView+Stripe.swift b/StripeCore/StripeCore/Source/UI/UIActivityIndicatorView+Stripe.swift new file mode 100644 index 00000000..afc18ef5 --- /dev/null +++ b/StripeCore/StripeCore/Source/UI/UIActivityIndicatorView+Stripe.swift @@ -0,0 +1,35 @@ +// +// UIActivityIndicatorView+Stripe.swift +// StripeCore +// +// Created by Mel Ludowise on 3/9/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) extension UIActivityIndicatorView { + #if DEBUG + /// Disables animation for `stp_startAnimatingAndShow`. + /// + /// This should be disabled in snapshot tests. + public static var stp_isAnimationEnabled = true + #endif + + /// This method should be used in place of `hidesWhenStopped` and `startAnimating()` + /// so we can ensure consistency in snapshot tests. + public func stp_startAnimatingAndShow() { + isHidden = false + #if DEBUG + guard UIActivityIndicatorView.stp_isAnimationEnabled else { return } + #endif + startAnimating() + } + + /// This method should be used in place of and `hidesWhenStopped` and `stopAnimating()` + /// so we can ensure consistency in snapshot tests. + public func stp_stopAnimatingAndHide() { + isHidden = true + stopAnimating() + } +} diff --git a/StripeCore/StripeCore/Source/UI/UIFont+Stripe.swift b/StripeCore/StripeCore/Source/UI/UIFont+Stripe.swift new file mode 100644 index 00000000..5775138f --- /dev/null +++ b/StripeCore/StripeCore/Source/UI/UIFont+Stripe.swift @@ -0,0 +1,88 @@ +// +// UIFont+Stripe.swift +// StripeCore +// +// Created by Yuki Tokuhiro on 11/11/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +@_spi(STP) extension UIFont { + /// The default size category used to compute font size prior to scaling it. + /// + /// - seealso: + /// https://developer.apple.com/documentation/uikit/uifont/scaling_fonts_automatically + private static let defaultSizeCategory: UIContentSizeCategory = .large + + public static func preferredFont( + forTextStyle style: TextStyle, + weight: Weight, + maximumPointSize: CGFloat? = nil + ) -> UIFont { + let metrics = UIFontMetrics(forTextStyle: style) + let desc = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style) + let font = UIFont.systemFont(ofSize: desc.pointSize, weight: weight) + + if let maximumPointSize = maximumPointSize { + return metrics.scaledFont(for: font, maximumPointSize: maximumPointSize) + } + return metrics.scaledFont(for: font) + } + + /// Creates a copy of this `UIFont` with a point size matching the specified style and weight. + /// + /// - Parameters: + /// - style: The style used to determine the font's size. + /// - weight: The weight to apply to the font. + public func withPreferredSize( + forTextStyle style: TextStyle, + weight: Weight? = nil + ) -> UIFont { + // Determine the font size for the system default font for this style + // at the default font scale, apply the size to this font, then return a + // scaled font using UIFontMetrics. + // + // Note: We must scale the font in this way rather than directly using the + // font size for the current scale, or UILabel won't adjust the font size + // if the size category dynamically changes. + + // Get font descriptor for the font system default font with this style + // using the unscaled size category + let systemDefaultFontDescriptor = UIFontDescriptor.preferredFontDescriptor( + withTextStyle: style, + compatibleWith: UITraitCollection( + preferredContentSizeCategory: UIFont.defaultSizeCategory + ) + ) + + // If no weight was specified, use the weight associated with the system + // default font for this TextStyle + var useWeight = weight + if weight == nil, + let traits = systemDefaultFontDescriptor.fontAttributes[.traits] + as? [UIFontDescriptor.TraitKey: Any], + let systemDefaultWeight = traits[.weight] as? Weight + { + useWeight = systemDefaultWeight + } + + // Create a descriptor that set's the font to the specified weight + let descriptor = fontDescriptor.addingAttributes([ + .traits: [ + UIFontDescriptor.TraitKey.weight: useWeight, + ], + ]) + + // Get the point size used by the system font for this style + let pointSize = systemDefaultFontDescriptor.pointSize + + // Apply the weight and size to the font + let font = UIFont(descriptor: descriptor, size: pointSize) + + // Scale the font for the current size category + let metrics = UIFontMetrics(forTextStyle: style) + return metrics.scaledFont(for: font) + } +} diff --git a/StripeCore/StripeCore/StripeCore.h b/StripeCore/StripeCore/StripeCore.h new file mode 100644 index 00000000..55d5d203 --- /dev/null +++ b/StripeCore/StripeCore/StripeCore.h @@ -0,0 +1,18 @@ +// +// StripeCore.h +// StripeCore +// +// Created by Mel Ludowise on 6/24/21. +// + +#import + +//! Project version number for StripeCore. +FOUNDATION_EXPORT double StripeCoreVersionNumber; + +//! Project version string for StripeCore. +FOUNDATION_EXPORT const unsigned char StripeCoreVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/StripeCore/StripeCoreTestUtils/APIStubbedTestCase.swift b/StripeCore/StripeCoreTestUtils/APIStubbedTestCase.swift new file mode 100644 index 00000000..ae6aed58 --- /dev/null +++ b/StripeCore/StripeCoreTestUtils/APIStubbedTestCase.swift @@ -0,0 +1,53 @@ +// +// APIStubbedTestCase.swift +// StripeCoreTestUtils +// +// Created by David Estes on 9/24/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import OHHTTPStubs +import OHHTTPStubsSwift +import XCTest + +@testable@_spi(STP) import StripeCore + +/// A test case offering a custom STPAPIClient with manual JSON stubbing. +open class APIStubbedTestCase: XCTestCase { + open override func setUp() { + super.setUp() + APIStubbedTestCase.stubAllOutgoingRequests() + } + public override func tearDown() { + super.tearDown() + HTTPStubs.removeAllStubs() + } + + public func stubbedAPIClient(configuration: URLSessionConfiguration? = nil) -> STPAPIClient { + return APIStubbedTestCase.stubbedAPIClient(configuration: configuration) + } + + static public func stubAllOutgoingRequests() { + // Stubs are evaluated in the reverse order that they are added, so if the network is hit and no other stub is matched, raise an exception + stub(condition: { _ in + return true + }) { request in + XCTFail("Attempted to hit the live network at \(request.url?.path ?? "")") + return HTTPStubsResponse() + } + } + + static public func stubbedAPIClient(configuration: URLSessionConfiguration? = nil) -> STPAPIClient { + let apiClient = STPAPIClient() + let config = configuration ?? APIStubbedTestCase.stubbedURLSessionConfig() + apiClient.urlSession = URLSession(configuration: config) + return apiClient + } + + static public func stubbedURLSessionConfig() -> URLSessionConfiguration { + let urlSessionConfig = URLSessionConfiguration.default + HTTPStubs.setEnabled(true, for: urlSessionConfig) + return urlSessionConfig + } +} diff --git a/StripeCore/StripeCoreTestUtils/Categories/UIImage+StripeCoreTestingUtils.swift b/StripeCore/StripeCoreTestUtils/Categories/UIImage+StripeCoreTestingUtils.swift new file mode 100644 index 00000000..3dbbcdbe --- /dev/null +++ b/StripeCore/StripeCoreTestUtils/Categories/UIImage+StripeCoreTestingUtils.swift @@ -0,0 +1,30 @@ +// +// UIImage+StripeCoreTestingUtils.swift +// StripeCoreTestUtils +// +// Created by Ramon Torres on 11/9/21. +// + +import UIKit + +extension UIImage { + + /// Returns a 24x24 icon for testing purposes. + /// - Returns: Plus sign icon. + public class func mockIcon() -> UIImage { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 24, height: 24)) + + let icon = renderer.image { context in + context.cgContext.move(to: CGPoint(x: 12, y: 4)) + context.cgContext.addLine(to: CGPoint(x: 12, y: 20)) + context.cgContext.move(to: CGPoint(x: 4, y: 12)) + context.cgContext.addLine(to: CGPoint(x: 20, y: 12)) + context.cgContext.setLineWidth(2) + context.cgContext.setLineCap(.round) + context.cgContext.strokePath() + } + + return icon.withRenderingMode(.alwaysTemplate) + } + +} diff --git a/StripeCore/StripeCoreTestUtils/Categories/UIView+StripeCoreTestingUtils.swift b/StripeCore/StripeCoreTestUtils/Categories/UIView+StripeCoreTestingUtils.swift new file mode 100644 index 00000000..b7c5e574 --- /dev/null +++ b/StripeCore/StripeCoreTestUtils/Categories/UIView+StripeCoreTestingUtils.swift @@ -0,0 +1,24 @@ +// +// UIView+StripeCoreTestingUtils.swift +// StripeCoreTestUtils +// +// Created by Mel Ludowise on 10/4/21. +// + +import UIKit + +extension UIView { + /// Constrains the view to the given width and autosizes its height. + /// + /// - Parameter width: Resizes the view to this width + public func autosizeHeight(width: CGFloat) { + translatesAutoresizingMaskIntoConstraints = false + widthAnchor.constraint(equalToConstant: width).isActive = true + setNeedsLayout() + layoutIfNeeded() + frame = .init( + origin: .zero, + size: systemLayoutSizeFitting(CGSize(width: width, height: UIView.noIntrinsicMetric)) + ) + } +} diff --git a/StripeCore/StripeCoreTestUtils/Info.plist b/StripeCore/StripeCoreTestUtils/Info.plist new file mode 100644 index 00000000..9bcb2444 --- /dev/null +++ b/StripeCore/StripeCoreTestUtils/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/StripeCore/StripeCoreTestUtils/KeyPathExpectation.swift b/StripeCore/StripeCoreTestUtils/KeyPathExpectation.swift new file mode 100644 index 00000000..9aa26de7 --- /dev/null +++ b/StripeCore/StripeCoreTestUtils/KeyPathExpectation.swift @@ -0,0 +1,70 @@ +// +// KeyPathExpectation.swift +// StripeCoreTestUtils +// +// Created by Ramon Torres on 1/21/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import XCTest + +public class KeyPathExpectation: XCTNSPredicateExpectation { + + public convenience init( + object: Object, + keyPath: KeyPath, + equalsToValue value: Value, + description: String? = nil + ) { + let description = + description + ?? "Expect predicate `\(keyPath)` == \(value) for \(String(describing: object))" + + self.init( + object: object, + keyPath: keyPath, + evaluatedWith: { $0 == value }, + description: description + ) + } + + public convenience init( + object: Object, + keyPath: KeyPath, + notEqualsToValue value: Value, + description: String? = nil + ) { + let description = + description + ?? "Expect predicate `\(keyPath)` != \(value) for \(String(describing: object))" + + self.init( + object: object, + keyPath: keyPath, + evaluatedWith: { $0 != value }, + description: description + ) + } + + init( + object: Object, + keyPath: KeyPath, + evaluatedWith block: @escaping (Value) -> Bool, + description: String? = nil + ) { + let predicate = NSPredicate { object, _ in + guard let unwrappedObject = object as? Object else { + return false + } + + return block(unwrappedObject[keyPath: keyPath]) + } + + super.init(predicate: predicate, object: object) + + expectationDescription = + description + ?? "Expect `\(keyPath)` to return `true` when evaluated with block." + } + +} diff --git a/StripeCore/StripeCoreTestUtils/Mock Files/File_IdentityDocument.json b/StripeCore/StripeCoreTestUtils/Mock Files/File_IdentityDocument.json new file mode 100644 index 00000000..bae03c32 --- /dev/null +++ b/StripeCore/StripeCoreTestUtils/Mock Files/File_IdentityDocument.json @@ -0,0 +1,7 @@ +{ + "created": 1636833390, + "id": "file_id", + "purpose": "identity_document", + "size": 100, + "type": "jpg" +} diff --git a/StripeCore/StripeCoreTestUtils/Mocks/MockAnalyticsClient.swift b/StripeCore/StripeCoreTestUtils/Mocks/MockAnalyticsClient.swift new file mode 100644 index 00000000..9ef02234 --- /dev/null +++ b/StripeCore/StripeCoreTestUtils/Mocks/MockAnalyticsClient.swift @@ -0,0 +1,31 @@ +// +// MockAnalyticsClient.swift +// StripeCoreTestUtils +// +// Created by Mel Ludowise on 3/12/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore + +@_spi(STP) public final class MockAnalyticsClient: STPAnalyticsClientProtocol { + + public private(set) var productUsage: Set = [] + public private(set) var loggedAnalytics: [Analytic] = [] + + public init() {} + + public func addClass(toProductUsageIfNecessary klass: T.Type) where T: STPAnalyticsProtocol { + productUsage.insert(klass.stp_analyticsIdentifier) + } + + public func log(analytic: Analytic, apiClient: STPAPIClient = .shared) { + loggedAnalytics.append(analytic) + } + + /// Clears `loggedAnalytics` and `productUsage`. + public func reset() { + productUsage = [] + loggedAnalytics = [] + } +} diff --git a/StripeCore/StripeCoreTestUtils/Mocks/MockAnalyticsClientV2.swift b/StripeCore/StripeCoreTestUtils/Mocks/MockAnalyticsClientV2.swift new file mode 100644 index 00000000..0d03aeeb --- /dev/null +++ b/StripeCore/StripeCoreTestUtils/Mocks/MockAnalyticsClientV2.swift @@ -0,0 +1,25 @@ +// +// MockAnalyticsClientV2.swift +// StripeCoreTestUtils +// +// Created by Mel Ludowise on 6/7/22. +// + +import Foundation +@_spi(STP) import StripeCore + +@_spi(STP) public final class MockAnalyticsClientV2: AnalyticsClientV2Protocol { + public let clientId: String = "MockAnalyticsClient" + + public private(set) var loggedAnalyticsPayloads: [[String: Any]] = [] + + public func loggedAnalyticPayloads(withEventName eventName: String) -> [[String: Any]] { + return loggedAnalyticsPayloads.filter { ($0["event_name"] as? String) == eventName } + } + + public init() {} + + public func log(eventName: String, parameters: [String: Any]) { + loggedAnalyticsPayloads.append(payload(withEventName: eventName, parameters: parameters)) + } +} diff --git a/StripeCore/StripeCoreTestUtils/Mocks/MockData.swift b/StripeCore/StripeCoreTestUtils/Mocks/MockData.swift new file mode 100644 index 00000000..1dea8e79 --- /dev/null +++ b/StripeCore/StripeCoreTestUtils/Mocks/MockData.swift @@ -0,0 +1,50 @@ +// +// MockData.swift +// StripeCoreTestUtils +// +// Created by Mel Ludowise on 10/27/21. +// + +import Foundation + +@testable@_spi(STP) import StripeCore + +/// Protocol for easily opening JSON mock files. +public protocol MockData: RawRepresentable where RawValue == String { + associatedtype ResponseType: Decodable + var bundle: Bundle { get } +} + +extension MockData { + public var url: URL { + return bundle.url(forResource: rawValue, withExtension: "json")! + } + + public func data() throws -> Data { + return try Data(contentsOf: url) + } + + public func make() throws -> ResponseType { + let result: Result = STPAPIClient.decodeResponse( + data: try data(), + error: nil, + response: nil + ) + switch result { + case .success(let response): + return response + case .failure(let error): + throw error + } + } +} + +// Dummy class to determine this bundle +private class ClassForBundle {} + +@_spi(STP) public enum FileMock: String, MockData { + public typealias ResponseType = StripeFile + public var bundle: Bundle { return Bundle(for: ClassForBundle.self) } + + case identityDocument = "File_IdentityDocument" +} diff --git a/StripeCore/StripeCoreTestUtils/STPSnapshotVerifyView.swift b/StripeCore/StripeCoreTestUtils/STPSnapshotVerifyView.swift new file mode 100644 index 00000000..292af455 --- /dev/null +++ b/StripeCore/StripeCoreTestUtils/STPSnapshotVerifyView.swift @@ -0,0 +1,32 @@ +// +// STPSnapshotVerifyView.swift +// StripeCoreTestUtils +// +// Created by David Estes on 4/13/22. +// + +import Foundation +import iOSSnapshotTestCase + +extension FBSnapshotTestCase { + // Calls FBSnapshotVerifyView with a default 2% per-pixel color differentiation, as M1 and Intel machines render shadows differently. + public func STPSnapshotVerifyView( + _ view: UIView, + identifier: String? = nil, + suffixes: NSOrderedSet = FBSnapshotTestCaseDefaultSuffixes(), + perPixelTolerance: CGFloat = 0.02, + overallTolerance: CGFloat = 0, + file: StaticString = #file, + line: UInt = #line + ) { + FBSnapshotVerifyView( + view, + identifier: identifier, + suffixes: suffixes, + perPixelTolerance: perPixelTolerance, + overallTolerance: overallTolerance, + file: file, + line: line + ) + } +} diff --git a/StripeCore/StripeCoreTestUtils/StripeCoreTestUtils.h b/StripeCore/StripeCoreTestUtils/StripeCoreTestUtils.h new file mode 100644 index 00000000..4a9c8d94 --- /dev/null +++ b/StripeCore/StripeCoreTestUtils/StripeCoreTestUtils.h @@ -0,0 +1,18 @@ +// +// StripeCoreTestUtils.h +// StripeCoreTestUtils +// +// Created by Mel Ludowise on 7/1/21. +// + +#import + +//! Project version number for StripeCoreTestUtils. +FOUNDATION_EXPORT double StripeCoreTestUtilsVersionNumber; + +//! Project version string for StripeCoreTestUtils. +FOUNDATION_EXPORT const unsigned char StripeCoreTestUtilsVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/StripeCore/StripeCoreTestUtils/TestConstants.swift b/StripeCore/StripeCoreTestUtils/TestConstants.swift new file mode 100644 index 00000000..90a80923 --- /dev/null +++ b/StripeCore/StripeCoreTestUtils/TestConstants.swift @@ -0,0 +1,16 @@ +// +// TestConstants.swift +// StripeCoreTestUtils +// +// Created by Mel Ludowise on 10/26/21. +// + +import Foundation + +public let STPTestingNetworkRequestTimeout: TimeInterval = 8 + +@objc(TestConstants) +public class _objc_Constants: NSObject { + @objc(STPTestingNetworkRequestTimeout) + public static let _objc_STPTestingNetworkRequestTimeout = STPTestingNetworkRequestTimeout +} diff --git a/StripeCore/StripeCoreTestUtils/URLRequest+StripeTest.swift b/StripeCore/StripeCoreTestUtils/URLRequest+StripeTest.swift new file mode 100644 index 00000000..5cb22cbe --- /dev/null +++ b/StripeCore/StripeCoreTestUtils/URLRequest+StripeTest.swift @@ -0,0 +1,50 @@ +// +// URLRequest+StripeTest.swift +// StripeiOS Tests +// +// Created by David Estes on 9/24/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +extension URLRequest { + /// A Data representation of the response's body, + /// fetched from the body Data or body InputStream. + public var httpBodyOrBodyStream: Data? { + if let httpBody = httpBody { + return httpBody + } + if let httpBodyStream = httpBodyStream { + let maxLength = 1024 + var data = Data() + var buffer = Data(count: maxLength) + httpBodyStream.open() + buffer.withUnsafeMutableBytes { bufferPtr in + let bufferTypedPtr = bufferPtr.bindMemory(to: UInt8.self) + while httpBodyStream.hasBytesAvailable { + let length = httpBodyStream.read( + bufferTypedPtr.baseAddress!, + maxLength: maxLength + ) + if length == 0 { + break + } else { + data.append(bufferTypedPtr.baseAddress!, count: length) + } + } + } + return data + } + return nil + } + + // Query items sent as part of this URLRequest + public var queryItems: [URLQueryItem]? { + let body = String(data: httpBodyOrBodyStream!, encoding: .utf8)! + // Create a combined URLComponents with the URL params from the body + var urlComponents = URLComponents(url: url!, resolvingAgainstBaseURL: false) + urlComponents?.query = body + return urlComponents?.queryItems + } +} diff --git a/StripeCore/StripeCoreTestUtils/XCTestCase+Stripe.swift b/StripeCore/StripeCoreTestUtils/XCTestCase+Stripe.swift new file mode 100644 index 00000000..2ebedd7d --- /dev/null +++ b/StripeCore/StripeCoreTestUtils/XCTestCase+Stripe.swift @@ -0,0 +1,64 @@ +// +// XCTestCase+Stripe.swift +// StripeCoreTestUtils +// +// Created by Mel Ludowise on 11/3/21. +// + +import XCTest + +extension XCTestCase { + + public func expectation( + for object: Object, + keyPath: KeyPath, + equalsToValue value: Value, + description: String? = nil + ) -> KeyPathExpectation { + return KeyPathExpectation( + object: object, + keyPath: keyPath, + equalsToValue: value, + description: description + ) + } + + public func expectation( + for object: Object, + keyPath: KeyPath, + notEqualsToValue value: Value, + description: String? = nil + ) -> KeyPathExpectation { + return KeyPathExpectation( + object: object, + keyPath: keyPath, + notEqualsToValue: value, + description: description + ) + } + + public func notNullExpectation( + for object: Object, + keyPath: KeyPath, + description: String? = nil + ) -> KeyPathExpectation { + let description = + description ?? "Expect predicate `\(keyPath)` != nil for \(String(describing: object))" + + return KeyPathExpectation( + object: object, + keyPath: keyPath, + evaluatedWith: { $0 != nil }, + description: description + ) + } +} + +public func XCTAssertIs( + _ item: Any, + _ t: T.Type, + file: StaticString = #filePath, + line: UInt = #line +) { + XCTAssert(item is T, "\(type(of: item)) is not type \(T.self)", file: file, line: line) +} diff --git a/StripeCore/StripeCoreTests/API Bindings/STPAPIClient+EmptyResponseTest.swift b/StripeCore/StripeCoreTests/API Bindings/STPAPIClient+EmptyResponseTest.swift new file mode 100644 index 00000000..f755f95b --- /dev/null +++ b/StripeCore/StripeCoreTests/API Bindings/STPAPIClient+EmptyResponseTest.swift @@ -0,0 +1,81 @@ +// +// STPAPIClient+EmptyResponseTest.swift +// StripeCoreTests +// +// Created by Jaime Park on 1/7/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +import XCTest + +class STPAPIClient_EmptyResponseTest: XCTestCase { + /// Response is an error; Error is nil. + /// + /// Should result in a failure.` + func testEmptyResponse_WithErrorResponse() throws { + let response = [ + "error": [ + "type": "api_error", + "message": "some message", + ], + ] + + let responseData = try JSONSerialization.data(withJSONObject: response, options: []) + let result: Result = STPAPIClient.decodeResponse( + data: responseData, + error: nil, + response: HTTPURLResponse( + url: URL(string: "https://www.stripe.com")!, + statusCode: 400, + httpVersion: nil, + headerFields: nil + ) + ) + + switch result { + case .success: + XCTFail("The request should not have succeeded") + case .failure(let error): + if let stripeError = error as? StripeError, case .apiError(let apiError) = stripeError { + XCTAssert(apiError.statusCode == 400, "expected status code to be set") + } else { + XCTFail("The error should have been an `.apiError`") + } + } + } + + /// Response is an empty response; Error is not nil. + /// + /// Should result in a failure. + func testEmptyResponse_WithError() throws { + let responseData = try JSONSerialization.data(withJSONObject: [:], options: []) + let result: Result = STPAPIClient.decodeResponse( + data: responseData, + error: NSError.stp_genericConnectionError(), + response: nil + ) + + guard case .failure = result else { + XCTFail("The request should not have succeeded") + return + } + } + + /// Response is an empty response; Error is nil. + /// + /// Should result in a success. + func testEmptyResponse_NoError() throws { + let responseData = try JSONSerialization.data(withJSONObject: [:], options: []) + let result: Result = STPAPIClient.decodeResponse( + data: responseData, + error: nil, + response: nil + ) + + guard case .success = result else { + XCTFail("The request should have succeeded") + return + } + } +} diff --git a/StripeCore/StripeCoreTests/API Bindings/STPAPIClient+ErrorResponseTest.swift b/StripeCore/StripeCoreTests/API Bindings/STPAPIClient+ErrorResponseTest.swift new file mode 100644 index 00000000..7869815f --- /dev/null +++ b/StripeCore/StripeCoreTests/API Bindings/STPAPIClient+ErrorResponseTest.swift @@ -0,0 +1,143 @@ +// +// STPAPIClient+ErrorResponseTest.swift +// StripeCoreTests +// +// Created by Eduardo Urias on 9/30/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +import XCTest + +class STPAPIClient_ErrorResponseTest: XCTestCase { + /// Response is an error, error code is a known error code, and no message is present, + /// `localizedDescription` is filled by a default error message. + func testResponse_WithKnownErrorCode_noMessage() throws { + let response = [ + "error": [ + "type": "card_error", + "code": "incorrect_number", + ], + ] + + let responseData = try JSONSerialization.data(withJSONObject: response, options: []) + let result: Result = STPAPIClient.decodeResponse( + data: responseData, + error: nil, + response: nil + ) + + switch result { + case .failure(let error): + XCTAssertEqual( + error.localizedDescription, + NSError.stp_cardErrorInvalidNumberUserMessage() + ) + case .success: + XCTFail("The request should not have succeeded") + } + } + + /// Response is an error, error code is a known error code, and message is present, + /// `localizedDescription` is filled with the provided message. + func testResponse_WithKnownErrorCode_messagePresent() throws { + let response = [ + "error": [ + "type": "card_error", + "message": "some message", + "code": "incorrect_number", + ], + ] + + let responseData = try JSONSerialization.data(withJSONObject: response, options: []) + let result: Result = STPAPIClient.decodeResponse( + data: responseData, + error: nil, + response: nil + ) + + switch result { + case .failure(let error): + XCTAssertEqual( + error.localizedDescription, + "some message" + ) + case .success: + XCTFail("The request should not have succeeded") + } + } + + /// Response is an error, error code is not present. + func testResponse_WithNoErrorCode() throws { + let response = [ + "error": [ + "type": "card_error", + "message": "some message", + ], + ] + + let responseData = try JSONSerialization.data(withJSONObject: response, options: []) + let result: Result = STPAPIClient.decodeResponse( + data: responseData, + error: nil, + response: nil + ) + + switch result { + case .failure(let error): + XCTAssertEqual(error.localizedDescription, "some message") + case .success: + XCTFail("The request should not have succeeded") + } + } + + /// Response is an error, error code is empty. + func testResponse_WithEmptyErrorCode() throws { + let response = [ + "error": [ + "type": "card_error", + "message": "some message", + "code": "", + ], + ] + + let responseData = try JSONSerialization.data(withJSONObject: response, options: []) + let result: Result = STPAPIClient.decodeResponse( + data: responseData, + error: nil, + response: nil + ) + + switch result { + case .failure(let error): + XCTAssertEqual(error.localizedDescription, "some message") + case .success: + XCTFail("The request should not have succeeded") + } + } + + /// Response is an error, error code is invalid. + func testResponse_WithInvalidErrorCode() throws { + let response = [ + "error": [ + "type": "card_error", + "message": "some message", + "code": "garbage", + ], + ] + + let responseData = try JSONSerialization.data(withJSONObject: response, options: []) + let result: Result = STPAPIClient.decodeResponse( + data: responseData, + error: nil, + response: nil + ) + + switch result { + case .failure(let error): + XCTAssertEqual(error.localizedDescription, "some message") + case .success: + XCTFail("The request should not have succeeded") + } + } +} diff --git a/StripeCore/StripeCoreTests/API Bindings/StripeCodableTest.swift b/StripeCore/StripeCoreTests/API Bindings/StripeCodableTest.swift new file mode 100644 index 00000000..b03b035f --- /dev/null +++ b/StripeCore/StripeCoreTests/API Bindings/StripeCodableTest.swift @@ -0,0 +1,212 @@ +// +// StripeCodableTest.swift +// StripeCoreTests +// +// Created by David Estes on 8/10/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import OHHTTPStubs +import OHHTTPStubsSwift +@_spi(STP)@testable import StripeCore +import StripeCoreTestUtils +import XCTest + +struct TestCodable: UnknownFieldsCodable { + struct Nested: UnknownFieldsCodable { + struct DeeplyNested: UnknownFieldsCodable { + var deeplyNestedProperty: String + + var _additionalParametersStorage: NonEncodableParameters? + var _allResponseFieldsStorage: NonEncodableParameters? + } + + var nestedProperty: String + + var deeplyNested: DeeplyNested? + + var _additionalParametersStorage: NonEncodableParameters? + var _allResponseFieldsStorage: NonEncodableParameters? + } + + var topProperty: String + + var arrayProperty: [Nested]? + + var nested: Nested? + + var testEnum: TestEnum? + var testEnums: [TestEnum]? + + var testEnumDict: [String: TestEnum]? + + enum TestEnum: String, SafeEnumCodable { + case hello + case hey + case unparsable + } + + var _additionalParametersStorage: NonEncodableParameters? + var _allResponseFieldsStorage: NonEncodableParameters? +} + +struct TestNonOptionalEnumCodable: UnknownFieldsCodable { + var testEnum: TestEnum + var testEnums: [TestEnum] + + enum TestEnum: String, SafeEnumCodable { + case hello + case hey + case unparsable + } + + var _additionalParametersStorage: NonEncodableParameters? + var _allResponseFieldsStorage: NonEncodableParameters? +} + +class StripeAPIRequestTest: APIStubbedTestCase { + func codableTest( + codable: T, + completion: @escaping ([String: Any], Result) -> Void + ) { + let e = expectation(description: "Request completed") + let encodedDict = try! codable.encodeJSONDictionary() + let encodedData = try? JSONSerialization.data(withJSONObject: encodedDict, options: []) + + let apiClient = stubbedAPIClient() + stub { _ in + return true + } response: { _ in + return HTTPStubsResponse(data: encodedData!, statusCode: 200, headers: nil) + } + + apiClient.post(resource: "anything", object: codable) { (result: Result) in + completion(encodedDict, result) + e.fulfill() + } + waitForExpectations(timeout: STPTestingNetworkRequestTimeout, handler: nil) + } + + func testValidEnum() { + var codable = TestCodable(topProperty: "hello1") + codable.additionalParameters = ["test_enum": "hey", "test_enums": ["hello", "hey"]] + codableTest(codable: codable) { _, result in + let resultObject = try! result.get() + XCTAssertEqual(resultObject.testEnum, .hey) + XCTAssertEqual(resultObject.testEnums, [.hello, .hey]) + } + } + + func testNonOptionalValidEnum() { + let codable = TestNonOptionalEnumCodable(testEnum: .hey, testEnums: [.hello, .hey]) + codableTest(codable: codable) { _, result in + let resultObject = try! result.get() + XCTAssertEqual(resultObject.testEnum, .hey) + XCTAssertEqual(resultObject.testEnums, [.hello, .hey]) + } + } + + func testUnknownEnum() { + var codable = TestCodable(topProperty: "hello1") + codable.additionalParameters = [ + "test_enum": "hellooo", "test_enums": ["hello", "helloooo"], + "test_enum_dict": ["item1": "hello", "item2": "this_will_not_parse"], + ] + codableTest(codable: codable) { _, result in + let resultObject = try! result.get() + XCTAssertEqual(resultObject.testEnum, .unparsable) + XCTAssertEqual(resultObject.testEnumDict!["item1"], .hello) + XCTAssertEqual(resultObject.testEnumDict!["item2"], .unparsable) + } + } + + func testEmptyEnum() { + let codable = TestCodable(topProperty: "hello1") + codableTest(codable: codable) { _, result in + let resultObject = try! result.get() + XCTAssertNil(resultObject.testEnum) + } + } + + func testUnpopulatedFieldsAreNil() { + let codable = TestCodable(topProperty: "hello1") + codableTest(codable: codable) { _, result in + let resultObject = try! result.get() + // wrappedValues will sometimes be populated but empty. We want them to be nil. + XCTAssertNil(resultObject.nested) + XCTAssertNil(resultObject.nested?.deeplyNested) + } + } + + func testArrays() { + var codable = TestCodable(topProperty: "hello1") + codable.arrayProperty = [ + TestCodable.Nested(nestedProperty: "hi"), TestCodable.Nested(nestedProperty: "there"), + ] + codableTest(codable: codable) { _, result in + let resultObject = try! result.get() + XCTAssert(resultObject.arrayProperty![0].nestedProperty == "hi") + } + } + + func testRoundtripKnownFields() { + var codable = TestCodable(topProperty: "hello1") + codable.topProperty = "hello1" + codable.nested = TestCodable.Nested(nestedProperty: "hello2") + codable.nested!.deeplyNested = TestCodable.Nested.DeeplyNested( + deeplyNestedProperty: "hello3" + ) + codableTest(codable: codable) { codableDict, result in + let resultObject = try! result.get() + XCTAssertEqual( + resultObject.nested!.deeplyNested!.deeplyNestedProperty, + codable.nested!.deeplyNested!.deeplyNestedProperty + ) + let newDictionary = try! resultObject.encodeJSONDictionary() as NSDictionary + XCTAssert(newDictionary.isEqual(to: codableDict)) + } + } + + func testRoundtripUnknownFields() { + var codable = TestCodable(topProperty: "hello1") + codable.topProperty = "hello1" + codable.nested = TestCodable.Nested(nestedProperty: "hello2") + codable.nested!.deeplyNested = TestCodable.Nested.DeeplyNested( + deeplyNestedProperty: "hello3" + ) + codable.nested?.additionalParameters = [ + "nested_property": "a_different_thing", + "deeply_nested": [ + "hello": "world", + "deepest": [ + "deep": + ["wow": "very deep"], + ], + ], + ] + codable.additionalParameters = ["boop": "beep"] + + codableTest(codable: codable) { codableDict, result in + let resultObject = try! result.get() + XCTAssertEqual( + resultObject.nested!.deeplyNested!.deeplyNestedProperty, + codable.nested!.deeplyNested!.deeplyNestedProperty + ) + let newDictionary = try! resultObject.encodeJSONDictionary() as NSDictionary + XCTAssert(newDictionary.isEqual(to: codableDict)) + XCTAssertEqual( + resultObject.nested!.nestedProperty, + "a_different_thing" + ) + XCTAssertEqual( + resultObject.allResponseFields["boop"] as! String, + "beep" + ) + XCTAssertEqual( + resultObject.nested!.deeplyNested!.allResponseFields["hello"] as! String, + "world" + ) + } + } +} diff --git a/StripeCore/StripeCoreTests/Analytics/AnalyticsClientV2Test.swift b/StripeCore/StripeCoreTests/Analytics/AnalyticsClientV2Test.swift new file mode 100644 index 00000000..59f312ef --- /dev/null +++ b/StripeCore/StripeCoreTests/Analytics/AnalyticsClientV2Test.swift @@ -0,0 +1,91 @@ +// +// AnalyticsClientV2Test.swift +// StripeCoreTests +// +// Created by Mel Ludowise on 6/7/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +import XCTest + +@testable@_spi(STP) import StripeCore + +final class AnalyticsClientV2Test: XCTestCase { + let client = AnalyticsClientV2( + clientId: "test_client_id", + origin: "test_origin" + ) + + func testShouldCollectAnalytics_alwaysFalseInTest() { + XCTAssertFalse(AnalyticsClientV2.shouldCollectAnalytics) + } + + func testRequestHeaders() { + let headers = client.requestHeaders + XCTAssertEqual( + headers["user-agent"]?.starts(with: "Stripe/v1 ios/"), + true, + String(describing: headers["user-agent"]) + ) + XCTAssertEqual(headers["origin"], "test_origin") + } + + func testSerializeError() { + let payload = AnalyticsClientV2.serialize( + error: NSError(domain: "my_domain", code: 125, userInfo: ["foo": "bar"]), + filePath: "/some/file/path/my_device/MyClass.swift", + line: 786 + ) + + XCTAssertEqual(payload.count, 4) + XCTAssertEqual(payload["domain"] as? String, "my_domain") + XCTAssertEqual(payload["code"] as? Int, 125) + XCTAssertEqual(payload["file"] as? String, "MyClass.swift") + XCTAssertEqual(payload["line"] as? UInt, 786) + } + + func testCommonPayload() { + let commonPayload = client.makeCommonPayload() + + XCTAssertEqual(commonPayload["client_id"] as? String, "test_client_id") + XCTAssertNotNil(commonPayload["event_id"] as? String) + XCTAssertNotNil(commonPayload["created"] as? Double) + XCTAssertNotNil(commonPayload["os_version"] as? String) + XCTAssertEqual(commonPayload["sdk_platform"] as? String, "ios") + XCTAssertNotNil(commonPayload["sdk_version"] as? String) + XCTAssertNotNil(commonPayload["device_type"] as? String) + XCTAssertNotNil(commonPayload["app_name"] as? String) + XCTAssertNotNil(commonPayload["app_version"] as? String) + + let platformInfo = commonPayload["platform_info"] as? [String: Any] + XCTAssertNotNil(platformInfo?["install"] as? String) + XCTAssertNotNil(platformInfo?["app_bundle_id"] as? String) + } + + func testPayloadFromAnalytic() { + + let payload = client.payload( + withEventName: "foo", + parameters: [ + "custom_property": "test_property", + "event_metadata": [ + "string_property": "test_string", + "int_property": 156, + ], + ] + ) + + // Ensure some common properties are present + XCTAssertNotNil(payload["client_id"] as? String) + XCTAssertNotNil(payload["event_id"] as? String) + + // Ensure encoded analytic is merged + XCTAssertEqual(payload["event_name"] as? String, "foo") + XCTAssertEqual(payload["custom_property"] as? String, "test_property") + + let metadata = payload["event_metadata"] as? [String: Any] + XCTAssertEqual(metadata?["string_property"] as? String, "test_string") + XCTAssertEqual(metadata?["int_property"] as? Int, 156) + } +} diff --git a/StripeCore/StripeCoreTests/Analytics/Error_SerializeForLoggingTest.swift b/StripeCore/StripeCoreTests/Analytics/Error_SerializeForLoggingTest.swift new file mode 100644 index 00000000..54808019 --- /dev/null +++ b/StripeCore/StripeCoreTests/Analytics/Error_SerializeForLoggingTest.swift @@ -0,0 +1,67 @@ +// +// Error_SerializeForLoggingTest.swift +// StripeCoreTests +// +// Created by Nick Porter on 9/8/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import XCTest + +@testable@_spi(STP) import StripeCore + +class Error_SerializeForLoggingTest: XCTestCase { + + struct CustomLoggableError: Error, AnalyticLoggableError { + func analyticLoggableSerializeForLogging() -> [String: Any] { + return [ + "foo": "value", + ] + } + } + + enum StringError: String, AnalyticLoggableStringError { + case foo + } + + func testNSErrorSerializedForLogging() throws { + let error = NSError( + domain: "test-domain", + code: 1, + userInfo: ["description": "test-description"] + ) + + let serializedError = error.serializeForLogging() + + XCTAssertEqual(serializedError.count, 2) + XCTAssertEqual("test-domain", serializedError["domain"] as? String) + XCTAssertEqual(serializedError["code"] as? Int, 1) + } + + /// Tests that casting an the error to `Error` still uses custom + /// serialization as opposed to the NSError default behavior. + func testAnalyticLoggableSerializedForLogging() { + let error: Error = CustomLoggableError() + + let serializedError = error.serializeForLogging() + + XCTAssertEqual(serializedError.count, 1) + XCTAssertEqual(serializedError["foo"] as? String, "value") + } + + func testStringErrorSerializeForLogging() { + let error: Error = StringError.foo + + let serializedError = error.serializeForLogging() + + print(serializedError) + + XCTAssertEqual(serializedError.count, 2) + XCTAssertEqual(serializedError["type"] as? String, "foo") + XCTAssertEqual( + serializedError["domain"] as? String, + "StripeCoreTests.Error_SerializeForLoggingTest.StringError" + ) + } +} diff --git a/StripeCore/StripeCoreTests/Analytics/STPAnalyticsClientTest.swift b/StripeCore/StripeCoreTests/Analytics/STPAnalyticsClientTest.swift new file mode 100644 index 00000000..134431ff --- /dev/null +++ b/StripeCore/StripeCoreTests/Analytics/STPAnalyticsClientTest.swift @@ -0,0 +1,44 @@ +// +// STPAnalyticsClientTest.swift +// StripeCoreTests +// +// Created by Yuki Tokuhiro on 12/15/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import Foundation +import XCTest + +@testable@_spi(STP) import StripeCore + +class STPAnalyticsClientTest: XCTestCase { + + func testShouldCollectAnalytics_alwaysFalseInTest() { + XCTAssertFalse(STPAnalyticsClient.shouldCollectAnalytics()) + } + + func testShouldRedactLiveKeyFromLog() { + let analyticsClient = STPAnalyticsClient() + + let payload = analyticsClient.commonPayload(STPAPIClient(publishableKey: "sk_live_foo")) + + XCTAssertEqual("[REDACTED_LIVE_KEY]", payload["publishable_key"] as? String) + } + + func testShouldRedactUserKeyFromLog() { + let analyticsClient = STPAnalyticsClient() + + let payload = analyticsClient.commonPayload(STPAPIClient(publishableKey: "uk_live_foo")) + + XCTAssertEqual("[REDACTED_LIVE_KEY]", payload["publishable_key"] as? String) + } + + func testShouldNotRedactLiveKeyFromLog() { + let analyticsClient = STPAnalyticsClient() + + let payload = analyticsClient.commonPayload(STPAPIClient(publishableKey: "pk_foo")) + + XCTAssertEqual("pk_foo", payload["publishable_key"] as? String) + } + +} diff --git a/StripeCore/StripeCoreTests/Categories/Dictionary+StripeTests.swift b/StripeCore/StripeCoreTests/Categories/Dictionary+StripeTests.swift new file mode 100644 index 00000000..a9e18d37 --- /dev/null +++ b/StripeCore/StripeCoreTests/Categories/Dictionary+StripeTests.swift @@ -0,0 +1,37 @@ +// +// Dictionary+StripeTests.swift +// StripeCoreTests +// +// Created by Mel Ludowise on 6/16/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP)@testable import StripeCore +import XCTest + +final class Dictionary_StripeTests: XCTestCase { + func testJsonEncodeNestedDicts() { + let input: [String: Any] = [ + "string": "some_string", + "int": 0, + "float": Float(0.1), + "nested_string_dict": [ + "string": "some_other_string", + "int": 1, + "float": Float(0.5), + ], + ] + + let output = input.jsonEncodeNestedDicts(options: .sortedKeys) + + XCTAssertEqual(output.count, 4) + XCTAssertEqual(output["string"] as? String, "some_string") + XCTAssertEqual(output["int"] as? Int, 0) + XCTAssertEqual(output["float"] as? Float, 0.1) + XCTAssertEqual( + output["nested_string_dict"] as? String, + "{\"float\":0.5,\"int\":1,\"string\":\"some_other_string\"}" + ) + } +} diff --git a/StripeCore/StripeCoreTests/Categories/NSArray+StripeCoreTest.swift b/StripeCore/StripeCoreTests/Categories/NSArray+StripeCoreTest.swift new file mode 100644 index 00000000..3523541d --- /dev/null +++ b/StripeCore/StripeCoreTests/Categories/NSArray+StripeCoreTest.swift @@ -0,0 +1,27 @@ +// +// NSArray+StripeCoreTest.swift +// StripeCoreTests +// +// Created by Jack Flintermann on 1/19/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +import XCTest + +class Array_StripeCoreTest: XCTestCase { + func test_boundSafeObjectAtIndex_emptyArray() { + let test: [Any] = [] + XCTAssertNil(test.stp_boundSafeObject(at: 5)) + } + + func test_boundSafeObjectAtIndex_tooHighIndex() { + let test = [NSNumber(value: 1), NSNumber(value: 2), NSNumber(value: 3)] + XCTAssertNil(test.stp_boundSafeObject(at: 5)) + } + + func test_boundSafeObjectAtIndex_withinBoundsIndex() { + let test = [NSNumber(value: 1), NSNumber(value: 2), NSNumber(value: 3)] + XCTAssertEqual(test.stp_boundSafeObject(at: 1), NSNumber(value: 2)) + } +} diff --git a/StripeCore/StripeCoreTests/Categories/NSMutableURLRequest+StripeTest.swift b/StripeCore/StripeCoreTests/Categories/NSMutableURLRequest+StripeTest.swift new file mode 100644 index 00000000..aa73470b --- /dev/null +++ b/StripeCore/StripeCoreTests/Categories/NSMutableURLRequest+StripeTest.swift @@ -0,0 +1,36 @@ +// +// NSMutableURLRequest+StripeTest.swift +// StripeCoreTests +// +// Created by Ben Guo on 4/22/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +import XCTest + +class NSMutableURLRequest_StripeTest: XCTestCase { + func testAddParametersToURL_noQuery() { + var request: URLRequest? + if let url = URL(string: "https://example.com") { + request = URLRequest(url: url) + } + request?.stp_addParameters(toURL: [ + "foo": "bar", + ]) + + XCTAssertEqual(request?.url?.absoluteString, "https://example.com?foo=bar") + } + + func testAddParametersToURL_hasQuery() { + var request: URLRequest? + if let url = URL(string: "https://example.com?a=b") { + request = URLRequest(url: url) + } + request?.stp_addParameters(toURL: [ + "foo": "bar", + ]) + + XCTAssertEqual(request?.url?.absoluteString, "https://example.com?a=b&foo=bar") + } +} diff --git a/StripeCore/StripeCoreTests/Categories/UIImage+StripeCoreTests.swift b/StripeCore/StripeCoreTests/Categories/UIImage+StripeCoreTests.swift new file mode 100644 index 00000000..86d31bbf --- /dev/null +++ b/StripeCore/StripeCoreTests/Categories/UIImage+StripeCoreTests.swift @@ -0,0 +1,85 @@ +// +// UIImage+StripeCoreTests.swift +// StripeCoreTests +// +// Created by Brian Dorfman on 4/25/17. +// Copyright © 2017 Stripe, Inc. All rights reserved. +// + +import XCTest + +// swift-format-ignore +@_spi(STP) @testable import StripeCore + +class UIImage_StripeTests: XCTestCase { + static let testJpegImageResizingKBiggerSize = 50000 + static let testJpegImageResizingKSmallerSize = 6000 + // Don't make this too low or test becomes somewhat meaningless, as jpegs can only get so small + static let testJpegImageResizingKMuchSmallerSize = 5000 + + func testJpegImageResizing() { + // Strategy is to grab an image from our bundle and pass to the resizer + // with maximums both larger and smaller than it already is + // then make sure we get what we expect + + guard + let testImage = UIImage( + named: "test_image", + in: Bundle(for: UIImage_StripeTests.self), + compatibleWith: nil + ) + else { + return XCTFail("Could not load test image") + } + + // Verify that before being passed to resizer it is within the + // correct size range for our tests to be meaningful + var data = testImage.jpegData(compressionQuality: 0.5)! + + XCTAssertLessThan(data.count, UIImage_StripeTests.testJpegImageResizingKBiggerSize) + XCTAssertGreaterThan(data.count, UIImage_StripeTests.testJpegImageResizingKSmallerSize) + XCTAssertGreaterThan(data.count, UIImage_StripeTests.testJpegImageResizingKMuchSmallerSize) + + // This is the size the data would be without scaling it less than maxBytes + let baselineSize = data.count + + // Test passing in a maxBytes larger than original image + data = + testImage.jpegDataAndDimensions( + maxBytes: UIImage_StripeTests.testJpegImageResizingKBiggerSize + ).imageData + var resultingImage = UIImage(data: data, scale: testImage.scale)! + XCTAssertLessThan(data.count, UIImage_StripeTests.testJpegImageResizingKBiggerSize) + // Image shouldn't have been shrunk at all + XCTAssertEqual(resultingImage.size, testImage.size) + + // Test passing in a maxBytes a bit smaller than the original image + data = + testImage.jpegDataAndDimensions( + maxBytes: UIImage_StripeTests.testJpegImageResizingKSmallerSize + ).imageData + resultingImage = UIImage(data: data, scale: testImage.scale)! + XCTAssertNotNil(data) + XCTAssertLessThan(data.count, UIImage_StripeTests.testJpegImageResizingKSmallerSize) + XCTAssertLessThan(resultingImage.size.width, testImage.size.width) + XCTAssertLessThan(resultingImage.size.height, testImage.size.height) + + // Test passing in a maxBytes a lot smaller than the original image + data = + testImage.jpegDataAndDimensions( + maxBytes: UIImage_StripeTests.testJpegImageResizingKMuchSmallerSize + ).imageData + resultingImage = UIImage(data: data, scale: testImage.scale)! + XCTAssertNotNil(data) + XCTAssertLessThan(data.count, UIImage_StripeTests.testJpegImageResizingKMuchSmallerSize) + XCTAssertLessThan(resultingImage.size.width, testImage.size.width) + XCTAssertLessThan(resultingImage.size.height, testImage.size.height) + + // Test passing in nil maxBytes + data = testImage.jpegDataAndDimensions(maxBytes: nil).imageData + resultingImage = UIImage(data: data, scale: testImage.scale)! + XCTAssertNotNil(data) + XCTAssertEqual(data.count, baselineSize) + XCTAssertEqual(resultingImage.size, testImage.size) + } +} diff --git a/StripeCore/StripeCoreTests/DownloadManager/DownloadManagerTest.swift b/StripeCore/StripeCoreTests/DownloadManager/DownloadManagerTest.swift new file mode 100644 index 00000000..f4327d6d --- /dev/null +++ b/StripeCore/StripeCoreTests/DownloadManager/DownloadManagerTest.swift @@ -0,0 +1,336 @@ +import OHHTTPStubs +import OHHTTPStubsSwift +@_spi(STP)@testable import StripeCore +import StripeCoreTestUtils +// +// DownloadManagerTest.swift +// StripeCoreTests +// +// +import XCTest + +class DownloadManagerTest: APIStubbedTestCase { + let validURL = URL(string: "https://js.stripe.com/validImage.png")! + let validURL2 = URL(string: "https://js.stripe.com/validImage2.png")! + let invalidURL = URL(string: "https://js.stripe.com/invalidImage.png")! + + let placeholderImageSize = CGSize(width: 1.0, height: 1.0) + let validImageSize = CGSize(width: 2.0, height: 3.0) + let validImageSize2 = CGSize(width: 4.0, height: 5.0) + + var urlSessionConfig: URLSessionConfiguration! + var rm: DownloadManager! + + override func setUp() { + super.setUp() + self.urlSessionConfig = APIStubbedTestCase.stubbedURLSessionConfig() + self.rm = DownloadManager(urlSessionConfiguration: urlSessionConfig) + + self.rm.resetDiskCache() + self.rm.resetMemoryCache() + } + + func testSynchronous_validImage() { + stub(condition: { request in + return request.url?.path.contains("/validImage.png") ?? false + }) { _ in + return HTTPStubsResponse(data: self.validImageData(), statusCode: 200, headers: nil) + } + + let image = rm.downloadImage(url: validURL, updateHandler: nil) + XCTAssertEqual(image.size, validImageSize) + } + + func testSynchronous_validImageIsCached() { + let expectedRequest = expectation(description: "Request is only called once") + + stub(condition: { request in + return request.url?.path.contains("/validImage.png") ?? false + }) { _ in + expectedRequest.fulfill() + return HTTPStubsResponse(data: self.validImageData(), statusCode: 200, headers: nil) + } + + let image = rm.downloadImage(url: validURL, updateHandler: nil) + XCTAssertEqual(image.size, validImageSize) + + wait(for: [expectedRequest], timeout: 1.0) + + let cachedImageWithoutNetworkCall = rm.downloadImage(url: validURL, updateHandler: nil) + XCTAssertEqual(cachedImageWithoutNetworkCall.size, validImageSize) + } + + func testSynchronous_validImageCacheIsCleared() { + var numTimesCalled = 0 + let expectedRequest1 = expectation(description: "First request") + let expectedRequest2 = expectation(description: "Second request") + + stub(condition: { request in + return request.url?.path.contains("/validImage.png") ?? false + }) { _ in + if numTimesCalled == 0 { + expectedRequest1.fulfill() + numTimesCalled += 1 + } else if numTimesCalled == 1 { + expectedRequest2.fulfill() + numTimesCalled += 1 + } + return HTTPStubsResponse(data: self.validImageData(), statusCode: 200, headers: nil) + } + + let image = rm.downloadImage(url: validURL, updateHandler: nil) + XCTAssertEqual(image.size, validImageSize) + wait(for: [expectedRequest1], timeout: 1.0) + + rm.resetDiskCache() + rm.resetMemoryCache() + + let cachedImageWithoutNetworkCall = rm.downloadImage(url: validURL, updateHandler: nil) + XCTAssertEqual(cachedImageWithoutNetworkCall.size, validImageSize) + wait(for: [expectedRequest2], timeout: 1.0) + } + + func testSynchronous_invalidImage() { + stub(condition: { request in + return request.url?.path.contains("/invalidImage.png") ?? false + }) { _ in + return HTTPStubsResponse(error: NotFoundError()) + } + + let image = rm.downloadImage(url: invalidURL, updateHandler: nil) + + XCTAssertEqual(image.size, placeholderImageSize) + } + + func testAsync_validImage() { + let expected_imageUpdaterCalled = expectation(description: "updateHandler is called") + + stub(condition: { request in + return request.url?.path.contains("/validImage.png") ?? false + }) { _ in + return HTTPStubsResponse(data: self.validImageData(), statusCode: 200, headers: nil) + } + + let image = rm.downloadImage( + url: validURL, + updateHandler: { image in + XCTAssertEqual(image.size, self.validImageSize) + expected_imageUpdaterCalled.fulfill() + } + ) + + XCTAssertEqual(image.size, placeholderImageSize) + wait(for: [expected_imageUpdaterCalled], timeout: 1.0) + } + + func testAsync_validImageIsCached() { + var stickyFlag = false + let expected1 = expectation(description: "updateHandler is called") + + stub(condition: { request in + return request.url?.path.contains("/validImage.png") ?? false + }) { _ in + if !stickyFlag { + stickyFlag = true + } else { + XCTFail("Request should not be called twice") + } + return HTTPStubsResponse(data: self.validImageData(), statusCode: 200, headers: nil) + } + + let image = rm.downloadImage( + url: validURL, + updateHandler: { image in + XCTAssertEqual(image.size, self.validImageSize) + expected1.fulfill() + } + ) + + XCTAssertEqual(image.size, placeholderImageSize) + wait(for: [expected1], timeout: 1.0) + + let expected2 = expectation( + description: "updateHandler will not be called when image is cached" + ) + expected2.isInverted = true + let imageCached = rm.downloadImage( + url: validURL, + updateHandler: { _ in + expected2.fulfill() + } + ) + + XCTAssertEqual(imageCached.size, validImageSize) + waitForExpectations(timeout: 0.5) + } + func testAsync_validImageCacheIsCleared() { + var numTimesCalled = 0 + let expected1 = expectation(description: "updateHandler is called") + + stub(condition: { request in + return request.url?.path.contains("/validImage.png") ?? false + }) { _ in + if numTimesCalled != 0 && numTimesCalled != 1 { + XCTFail("Request called more than 2 times") + } + numTimesCalled += 1 + return HTTPStubsResponse(data: self.validImageData(), statusCode: 200, headers: nil) + } + + let image = rm.downloadImage( + url: validURL, + updateHandler: { image in + XCTAssertEqual(image.size, self.validImageSize) + expected1.fulfill() + } + ) + + XCTAssertEqual(image.size, placeholderImageSize) + wait(for: [expected1], timeout: 1.0) + + rm.resetMemoryCache() + rm.resetDiskCache() + + let expected2 = expectation(description: "updateHandler us called a second time") + let image2 = rm.downloadImage( + url: validURL, + updateHandler: { image in + XCTAssertEqual(image.size, self.validImageSize) + expected2.fulfill() + } + ) + + XCTAssertEqual(image2.size, self.placeholderImageSize) + wait(for: [expected2], timeout: 1.0) + } + + func testAsync_invalidImage() { + let expected = expectation(description: "updateHandler should not be called") + expected.isInverted = true + stub(condition: { request in + return request.url?.path.contains("/invalidImage.png") ?? false + }) { _ in + return HTTPStubsResponse(error: NotFoundError()) + } + + let image = rm.downloadImage( + url: invalidURL, + updateHandler: { image in + XCTAssertEqual(image.size, self.validImageSize) + expected.fulfill() + } + ) + + XCTAssertEqual(image.size, placeholderImageSize) + waitForExpectations(timeout: 0.5) + } + + func testAsync_validImage_avoidDeadLockInCallback() { + let expected_imageUpdater1 = expectation(description: "updateHandler for first image") + let expected_imageUpdater2 = expectation(description: "updateHandler for second image") + + stub(condition: { request in + return request.url?.path.contains("/validImage.png") ?? false + }) { _ in + return HTTPStubsResponse(data: self.validImageData(), statusCode: 200, headers: nil) + } + + stub(condition: { request in + return request.url?.path.contains("/validImage2.png") ?? false + }) { _ in + return HTTPStubsResponse(data: self.validImageData2(), statusCode: 200, headers: nil) + } + + let image = rm.downloadImage( + url: validURL, + updateHandler: { cb_image1 in + XCTAssertEqual(cb_image1.size, self.validImageSize) + expected_imageUpdater1.fulfill() + let image2 = self.rm.downloadImage( + url: self.validURL2, + updateHandler: { cb_image2 in + XCTAssertEqual(cb_image2.size, self.validImageSize2) + expected_imageUpdater2.fulfill() + } + ) + XCTAssertEqual(image2.size, self.placeholderImageSize) + } + ) + + XCTAssertEqual(image.size, placeholderImageSize) + wait(for: [expected_imageUpdater1], timeout: 1.0) + wait(for: [expected_imageUpdater2], timeout: 1.0) + + } + + func testImageNamefromURL() { + let img0 = rm.imageNameFromURL(url: URL(string: "http://js.stripe.com/icon0.png")!) + XCTAssertEqual(img0, "icon0.png") + + let img1 = rm.imageNameFromURL( + url: URL(string: "http://js.stripe.com/icon1.png?key1=value1")! + ) + XCTAssertEqual(img1, "icon1.png") + } + + func test_nil_AddUpdateHandlerWithoutLocking_unableToAddEmptyBlock() { + rm.addUpdateHandlerWithoutLocking(nil, forImageName: "imgName1") + XCTAssert(rm.updateHandlers.isEmpty) + } + + func test_AddUpdateHandlerWithoutLocking_appends() { + let expect1 = expectation(description: "first") + let expect2 = expectation(description: "second") + let updateHandler1: DownloadManager.UpdateImageHandler = { _ in expect1.fulfill() } + let updateHandler2: DownloadManager.UpdateImageHandler = { _ in expect2.fulfill() } + + rm.addUpdateHandlerWithoutLocking(updateHandler1, forImageName: "imgName1") + rm.addUpdateHandlerWithoutLocking(updateHandler2, forImageName: "imgName1") + + guard let firstHandler = rm.updateHandlers["imgName1"]?.first, + let lastHandler = rm.updateHandlers["imgName1"]?.last + else { + XCTFail("Unable to get handlers") + return + } + + firstHandler(UIImage()) + lastHandler(UIImage()) + + wait(for: [expect1], timeout: 1.0) + wait(for: [expect2], timeout: 1.0) + } + + func test_persistToMemory() { + XCTAssertNil(rm.cachedImageNamed("imgName")) + + let validImageData = validImageData() + guard let image = rm.persistToMemory(validImageData, forImageName: "imgName") else { + XCTFail("Failed to persist to memory") + return + } + + XCTAssertEqual(image.size, validImageSize) + XCTAssertNotNil(rm.imageCache["imgName"]) + } + + // MARK: - Helper functions + private func validImageData() -> Data { + return generateUIImage(size: validImageSize).pngData()! + } + + private func validImageData2() -> Data { + return generateUIImage(size: validImageSize2).pngData()! + } + + private func generateUIImage(size: CGSize) -> UIImage { + let rect = CGRect(x: 0, y: 0, width: size.width, height: size.height) + UIGraphicsBeginImageContextWithOptions(size, false, 0.0) + UIColor.clear.set() + UIRectFill(rect) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return image! + } + struct NotFoundError: Error {} +} diff --git a/StripeCore/StripeCoreTests/External/TestJSONEncoder.swift b/StripeCore/StripeCoreTests/External/TestJSONEncoder.swift new file mode 100644 index 00000000..74d13345 --- /dev/null +++ b/StripeCore/StripeCoreTests/External/TestJSONEncoder.swift @@ -0,0 +1,1756 @@ +// Modifications copyright (c) 2022 Stripe, Inc. +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// + +import Foundation +import XCTest + +@testable@_spi(STP) import StripeCore + +struct TopLevelObjectWrapper: Codable, Equatable { + var value: T + + static func == (lhs: TopLevelObjectWrapper, rhs: TopLevelObjectWrapper) -> Bool { + return lhs.value == rhs.value + } + + init( + _ value: T + ) { + self.value = value + } +} + +class TestJSONEncoder: XCTestCase { + + // MARK: - Encoding Top-Level fragments + // JSON fragments are only supported by JSONDecoder in iOS 13 or later + + func test_encodingTopLevelFragments() { + + func _testFragment(value: T, fragment: String) { + let data: Data + let payload: String + + do { + data = try StripeJSONEncoder().encode(value) + payload = try XCTUnwrap(String.init(decoding: data, as: UTF8.self)) + XCTAssertEqual(fragment, payload) + } catch { + XCTFail("Failed to encode \(T.self) to JSON: \(error)") + return + } + do { + let decodedValue = try StripeJSONDecoder().decode(T.self, from: data) + XCTAssertEqual(value, decodedValue) + } catch { + XCTFail("Failed to decode \(payload) to \(T.self): \(error)") + } + } + _testFragment(value: 2, fragment: "2") + _testFragment(value: false, fragment: "false") + _testFragment(value: true, fragment: "true") + _testFragment(value: Float(1), fragment: "1") + _testFragment(value: Double(2), fragment: "2") + _testFragment( + value: Decimal(Double(Float.leastNormalMagnitude)), + fragment: "0.000000000000000000000000000000000000011754943508222875648" + ) + _testFragment(value: "test", fragment: "\"test\"") + let v: Int? = nil + _testFragment(value: v, fragment: "null") + } + + // MARK: - Encoding Top-Level Empty Types + func test_encodingTopLevelEmptyStruct() { + let empty = EmptyStruct() + _testRoundTrip(of: empty, expectedJSON: _jsonEmptyDictionary) + } + + func test_encodingTopLevelEmptyClass() { + let empty = EmptyClass() + _testRoundTrip(of: empty, expectedJSON: _jsonEmptyDictionary) + } + + // MARK: - Encoding Top-Level Single-Value Types + // JSON fragments are only supported by JSONDecoder in iOS 13 or later + + func test_encodingTopLevelSingleValueEnum() { + _testRoundTrip(of: Switch.off) + _testRoundTrip(of: Switch.on) + + _testRoundTrip(of: TopLevelArrayWrapper(Switch.off)) + _testRoundTrip(of: TopLevelArrayWrapper(Switch.on)) + } + + // JSON fragments are only supported by JSONDecoder in iOS 13 or later + + func test_encodingTopLevelSingleValueStruct() { + _testRoundTrip(of: Timestamp(3_141_592_653)) + _testRoundTrip(of: TopLevelArrayWrapper(Timestamp(3_141_592_653))) + } + + // JSON fragments are only supported by JSONDecoder in iOS 13 or later + + func test_encodingTopLevelSingleValueClass() { + _testRoundTrip(of: Counter()) + _testRoundTrip(of: TopLevelArrayWrapper(Counter())) + } + + // MARK: - Encoding Top-Level Structured Types + func test_encodingTopLevelStructuredStruct() { + // Address is a struct type with multiple fields. + let address = Address.testValue + _testRoundTrip(of: address) + } + + func test_encodingTopLevelStructuredClass() { + // Person is a class with multiple fields. + let expectedJSON = "{\"name\":\"Johnny Appleseed\",\"email\":\"appleseed@apple.com\"}".data( + using: .utf8 + )! + let person = Person.testValue + _testRoundTrip(of: person, expectedJSON: expectedJSON) + } + + func test_encodingTopLevelStructuredSingleStruct() { + // Numbers is a struct which encodes as an array through a single value container. + let numbers = Numbers.testValue + _testRoundTrip(of: numbers) + } + + func test_encodingTopLevelStructuredSingleClass() { + // Mapping is a class which encodes as a dictionary through a single value container. + let mapping = Mapping.testValue + _testRoundTrip(of: mapping) + } + + func test_encodingTopLevelDeepStructuredType() { + // Company is a type with fields which are Codable themselves. + let company = Company.testValue + _testRoundTrip(of: company) + } + + // MARK: - Output Formatting Tests + func test_encodingOutputFormattingDefault() { + let expectedJSON = "{\"name\":\"Johnny Appleseed\",\"email\":\"appleseed@apple.com\"}".data( + using: .utf8 + )! + let person = Person.testValue + _testRoundTrip(of: person, expectedJSON: expectedJSON) + } + + func test_encodingOutputFormattingPrettyPrinted() throws { + let expectedJSON = + "{\n \"name\" : \"Johnny Appleseed\",\n \"email\" : \"appleseed@apple.com\"\n}".data( + using: .utf8 + )! + let person = Person.testValue + _testRoundTrip(of: person, expectedJSON: expectedJSON, outputFormatting: [.prettyPrinted]) + + let encoder = StripeJSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + + let emptyArray: [Int] = [] + let arrayOutput = try encoder.encode(emptyArray) + XCTAssertEqual(String.init(decoding: arrayOutput, as: UTF8.self), "[\n\n]") + + let emptyDictionary: [String: Int] = [:] + let dictionaryOutput = try encoder.encode(emptyDictionary) + XCTAssertEqual(String.init(decoding: dictionaryOutput, as: UTF8.self), "{\n\n}") + + struct DataType: Encodable { + let array = [1, 2, 3] + let dictionary: [String: Int] = [:] + let emptyArray: [Int] = [] + let secondArray: [Int] = [4, 5, 6] + let secondDictionary: [String: Int] = ["one": 1, "two": 2, "three": 3] + let singleElement: [Int] = [1] + let subArray: [String: [Int]] = ["array": []] + let subDictionary: [String: [String: Int]] = ["dictionary": [:]] + } + + let dataOutput = try encoder.encode([DataType(), DataType()]) + XCTAssertEqual( + String.init(decoding: dataOutput, as: UTF8.self), + """ + [ + { + "array" : [ + 1, + 2, + 3 + ], + "dictionary" : { + + }, + "empty_array" : [ + + ], + "second_array" : [ + 4, + 5, + 6 + ], + "second_dictionary" : { + "one" : 1, + "three" : 3, + "two" : 2 + }, + "single_element" : [ + 1 + ], + "sub_array" : { + "array" : [ + + ] + }, + "sub_dictionary" : { + "dictionary" : { + + } + } + }, + { + "array" : [ + 1, + 2, + 3 + ], + "dictionary" : { + + }, + "empty_array" : [ + + ], + "second_array" : [ + 4, + 5, + 6 + ], + "second_dictionary" : { + "one" : 1, + "three" : 3, + "two" : 2 + }, + "single_element" : [ + 1 + ], + "sub_array" : { + "array" : [ + + ] + }, + "sub_dictionary" : { + "dictionary" : { + + } + } + } + ] + """ + ) + } + + func test_encodingOutputFormattingSortedKeys() { + let expectedJSON = "{\"email\":\"appleseed@apple.com\",\"name\":\"Johnny Appleseed\"}".data( + using: .utf8 + )! + let person = Person.testValue + #if os(macOS) || DARWIN_COMPATIBILITY_TESTS + if #available(macOS 10.13, iOS 11.0, watchOS 4.0, tvOS 11.0, *) { + _testRoundTrip( + of: person, + expectedJSON: expectedJSON, + outputFormatting: [.sortedKeys] + ) + } + #else + _testRoundTrip(of: person, expectedJSON: expectedJSON, outputFormatting: [.sortedKeys]) + #endif + } + + func test_encodingOutputFormattingPrettyPrintedSortedKeys() { + let expectedJSON = + "{\n \"email\" : \"appleseed@apple.com\",\n \"name\" : \"Johnny Appleseed\"\n}".data( + using: .utf8 + )! + let person = Person.testValue + #if os(macOS) || DARWIN_COMPATIBILITY_TESTS + if #available(macOS 10.13, iOS 11.0, watchOS 4.0, tvOS 11.0, *) { + _testRoundTrip( + of: person, + expectedJSON: expectedJSON, + outputFormatting: [.prettyPrinted, .sortedKeys] + ) + } + #else + _testRoundTrip( + of: person, + expectedJSON: expectedJSON, + outputFormatting: [.prettyPrinted, .sortedKeys] + ) + #endif + } + + // MARK: - Date Strategy Tests + func test_encodingDate() { + // We intentionally drop precision to seconds, so round the date to the nearest second. + let date = Date(timeIntervalSince1970: Date().timeIntervalSince1970.rounded()) + // We can't encode a top-level Date, so it'll be wrapped in an array. + _testRoundTrip(of: TopLevelArrayWrapper(date)) + } + + func test_encodingDateSecondsSince1970() { + // Cannot encode an arbitrary number of seconds since we've lost precision since 1970. + let seconds = 1000.0 + let expectedJSON = "[1000]".data(using: .utf8)! + + // We can't encode a top-level Date, so it'll be wrapped in an array. + _testRoundTrip( + of: TopLevelArrayWrapper(Date(timeIntervalSince1970: seconds)), + expectedJSON: expectedJSON, + dateEncodingStrategy: .secondsSince1970, + dateDecodingStrategy: .secondsSince1970 + ) + } + + // MARK: - Data Strategy Tests + func test_encodingBase64Data() { + let data = Data([0xDE, 0xAD, 0xBE, 0xEF]) + + // We can't encode a top-level Data, so it'll be wrapped in an array. + let expectedJSON = "[\"3q2+7w==\"]".data(using: .utf8)! + _testRoundTrip(of: TopLevelArrayWrapper(data), expectedJSON: expectedJSON) + } + + // MARK: - Non-Conforming Floating Point Strategy Tests + func test_encodingNonConformingFloatStrings() { + let encodingStrategy: JSONEncoder.NonConformingFloatEncodingStrategy = .convertToString( + positiveInfinity: "Inf", + negativeInfinity: "-Inf", + nan: "nan" + ) + let decodingStrategy: JSONDecoder.NonConformingFloatDecodingStrategy = .convertFromString( + positiveInfinity: "Inf", + negativeInfinity: "-Inf", + nan: "nan" + ) + + _testRoundTrip( + of: TopLevelArrayWrapper(Float.infinity), + expectedJSON: "[\"Inf\"]".data(using: .utf8)!, + nonConformingFloatEncodingStrategy: encodingStrategy, + nonConformingFloatDecodingStrategy: decodingStrategy + ) + _testRoundTrip( + of: TopLevelArrayWrapper(-Float.infinity), + expectedJSON: "[\"-Inf\"]".data(using: .utf8)!, + nonConformingFloatEncodingStrategy: encodingStrategy, + nonConformingFloatDecodingStrategy: decodingStrategy + ) + + // Since Float.nan != Float.nan, we have to use a placeholder that'll encode NaN but actually round-trip. + _testRoundTrip( + of: TopLevelArrayWrapper(FloatNaNPlaceholder()), + expectedJSON: "[\"nan\"]".data(using: .utf8)!, + nonConformingFloatEncodingStrategy: encodingStrategy, + nonConformingFloatDecodingStrategy: decodingStrategy + ) + + _testRoundTrip( + of: TopLevelArrayWrapper(Double.infinity), + expectedJSON: "[\"Inf\"]".data(using: .utf8)!, + nonConformingFloatEncodingStrategy: encodingStrategy, + nonConformingFloatDecodingStrategy: decodingStrategy + ) + _testRoundTrip( + of: TopLevelArrayWrapper(-Double.infinity), + expectedJSON: "[\"-Inf\"]".data(using: .utf8)!, + nonConformingFloatEncodingStrategy: encodingStrategy, + nonConformingFloatDecodingStrategy: decodingStrategy + ) + + // Since Double.nan != Double.nan, we have to use a placeholder that'll encode NaN but actually round-trip. + _testRoundTrip( + of: TopLevelArrayWrapper(DoubleNaNPlaceholder()), + expectedJSON: "[\"nan\"]".data(using: .utf8)!, + nonConformingFloatEncodingStrategy: encodingStrategy, + nonConformingFloatDecodingStrategy: decodingStrategy + ) + } + + // MARK: - Encoder Features + // Nested containers are not supported, see StripeJSONEncoder for details. + // func test_nestedContainerCodingPaths() { + // let encoder = StripeJSONEncoder() + // do { + // let _ = try encoder.encode(NestedContainersTestType()) + // } catch { + // XCTFail("Caught error during encoding nested container types: \(error)") + // } + // } + + // Superencoding isn't supported, see StripeJSONEncoder for details. + // func test_superEncoderCodingPaths() { + // let encoder = StripeJSONEncoder() + // do { + // let _ = try encoder.encode(NestedContainersTestType(testSuperEncoder: true)) + // } catch { + // XCTFail("Caught error during encoding nested container types: \(error)") + // } + // } + + // MARK: - Test encoding and decoding of built-in Codable types + func test_codingOfBool() { + test_codingOf(value: Bool(true), toAndFrom: "true") + test_codingOf(value: Bool(false), toAndFrom: "false") + + // Check that a Bool false or true isn't converted to 0 or 1 + struct Foo: Decodable { + var intValue: Int? + var int8Value: Int8? + var int16Value: Int16? + var int32Value: Int32? + var int64Value: Int64? + var uintValue: UInt? + var uint8Value: UInt8? + var uint16Value: UInt16? + var uint32Value: UInt32? + var uint64Value: UInt64? + var floatValue: Float? + var doubleValue: Double? + var decimalValue: Decimal? + let boolValue: Bool + } + + func testValue(_ valueName: String) { + do { + let jsonData = "{ \"\(valueName)\": false }".data(using: .utf8)! + _ = try StripeJSONDecoder().decode(Foo.self, from: jsonData) + XCTFail("Decoded 'false' as non Bool for \(valueName)") + } catch {} + do { + let jsonData = "{ \"\(valueName)\": true }".data(using: .utf8)! + _ = try StripeJSONDecoder().decode(Foo.self, from: jsonData) + XCTFail("Decoded 'true' as non Bool for \(valueName)") + } catch {} + } + + testValue("intValue") + testValue("int8Value") + testValue("int16Value") + testValue("int32Value") + testValue("int64Value") + testValue("uintValue") + testValue("uint8Value") + testValue("uint16Value") + testValue("uint32Value") + testValue("uint64Value") + testValue("floatValue") + testValue("doubleValue") + testValue("decimalValue") + let falseJsonData = "{ \"bool_value\": false }".data(using: .utf8)! + if let falseFoo = try? StripeJSONDecoder().decode(Foo.self, from: falseJsonData) { + XCTAssertFalse(falseFoo.boolValue) + } else { + XCTFail("Could not decode 'false' as a Bool") + } + + let trueJsonData = "{ \"bool_value\": true }".data(using: .utf8)! + if let trueFoo = try? StripeJSONDecoder().decode(Foo.self, from: trueJsonData) { + XCTAssertTrue(trueFoo.boolValue) + } else { + XCTFail("Could not decode 'true' as a Bool") + } + } + + func test_codingOfNil() { + let x: Int? = nil + test_codingOf(value: x, toAndFrom: "null") + } + + func test_codingOfInt8() { + test_codingOf(value: Int8(-42), toAndFrom: "-42") + } + + func test_codingOfUInt8() { + test_codingOf(value: UInt8(42), toAndFrom: "42") + } + + func test_codingOfInt16() { + test_codingOf(value: Int16(-30042), toAndFrom: "-30042") + } + + func test_codingOfUInt16() { + test_codingOf(value: UInt16(30042), toAndFrom: "30042") + } + + func test_codingOfInt32() { + test_codingOf(value: Int32(-2_000_000_042), toAndFrom: "-2000000042") + } + + func test_codingOfUInt32() { + test_codingOf(value: UInt32(2_000_000_042), toAndFrom: "2000000042") + } + + func test_codingOfInt64() { + #if !arch(arm) + test_codingOf( + value: Int64(-9_000_000_000_000_000_042), + toAndFrom: "-9000000000000000042" + ) + #endif + } + + func test_codingOfUInt64() { + #if !arch(arm) + test_codingOf( + value: UInt64(9_000_000_000_000_000_042), + toAndFrom: "9000000000000000042" + ) + #endif + } + + func test_codingOfInt() { + let intSize = MemoryLayout.size + switch intSize { + case 4: // 32-bit + test_codingOf(value: Int(-2_000_000_042), toAndFrom: "-2000000042") + case 8: // 64-bit + #if arch(arm) + break + #else + test_codingOf( + value: Int(-9_000_000_000_000_000_042), + toAndFrom: "-9000000000000000042" + ) + #endif + default: + XCTFail("Unexpected UInt size: \(intSize)") + } + } + + func test_codingOfUInt() { + let uintSize = MemoryLayout.size + switch uintSize { + case 4: // 32-bit + test_codingOf(value: UInt(2_000_000_042), toAndFrom: "2000000042") + case 8: // 64-bit + #if arch(arm) + break + #else + test_codingOf( + value: UInt(9_000_000_000_000_000_042), + toAndFrom: "9000000000000000042" + ) + #endif + default: + XCTFail("Unexpected UInt size: \(uintSize)") + } + } + + func test_codingOfFloat() { + test_codingOf(value: Float(1.5), toAndFrom: "1.5") + + // Check value too large fails to decode. + XCTAssertThrowsError( + try StripeJSONDecoder().decode(Float.self, from: "1e100".data(using: .utf8)!) + ) + } + + func test_codingOfDouble() { + test_codingOf(value: Double(1.5), toAndFrom: "1.5") + + // Check value too large fails to decode. + XCTAssertThrowsError( + try StripeJSONDecoder().decode(Double.self, from: "100e323".data(using: .utf8)!) + ) + } + + func test_codingOfDecimal() { + test_codingOf(value: Decimal.pi, toAndFrom: "3.14159265358979323846264338327950288419") + + // Check value too large fails to decode. + // TODO(davide): This doesn't pass on Darwin Foundation, and I'm not sure if it's necessary here + // XCTAssertThrowsError(try JSONDecoder().decode(Decimal.self, from: "100e200".data(using: .utf8)!)) + } + + func test_codingOfString() { + test_codingOf(value: "Hello, world!", toAndFrom: "\"Hello, world!\"") + } + + func test_codingOfURL() { + test_codingOf(value: URL(string: "https://swift.org")!, toAndFrom: "\"https://swift.org\"") + } + + // UInt and Int + func test_codingOfUIntMinMax() { + + struct MyValue: Encodable { + let int64Min = Int64.min + let int64Max = Int64.max + let uint64Min = UInt64.min + let uint64Max = UInt64.max + } + + func compareJSON(_ s1: String, _ s2: String) { + let ss1 = s1.trimmingCharacters(in: CharacterSet(charactersIn: "{}")).split( + separator: Character(",") + ).sorted() + let ss2 = s2.trimmingCharacters(in: CharacterSet(charactersIn: "{}")).split( + separator: Character(",") + ).sorted() + XCTAssertEqual(ss1, ss2) + } + + do { + let encoder = StripeJSONEncoder() + let myValue = MyValue() + let result = try encoder.encode(myValue) + let r = String(data: result, encoding: .utf8) ?? "nil" + compareJSON( + r, + "{\"uint64_min\":0,\"uint64_max\":18446744073709551615,\"int64_min\":-9223372036854775808,\"int64_max\":9223372036854775807}" + ) + } catch { + XCTFail(String(describing: error)) + } + } + + func test_numericLimits() { + struct DataStruct: Codable { + let int8Value: Int8? + let uint8Value: UInt8? + let int16Value: Int16? + let uint16Value: UInt16? + let int32Value: Int32? + let uint32Value: UInt32? + let int64Value: Int64? + let intValue: Int? + let uintValue: UInt? + let uint64Value: UInt64? + let floatValue: Float? + let doubleValue: Double? + let decimalValue: Decimal? + } + + func decode(_ type: String, _ value: String) throws { + var key = type.lowercased() + key.append("_value") + _ = try StripeJSONDecoder().decode( + DataStruct.self, + from: "{ \"\(key)\": \(value) }".data(using: .utf8)! + ) + } + + func testGoodValue(_ type: String, _ value: String) { + do { + try decode(type, value) + } catch { + XCTFail("Unexpected error: \(error) for parsing \(value) to \(type)") + } + } + + func testErrorThrown(_ type: String, _ value: String, errorMessage: String) { + do { + try decode(type, value) + XCTFail("Decode of \(value) to \(type) should not succeed") + } catch DecodingError.dataCorrupted(let context) { + XCTAssertEqual(context.debugDescription, errorMessage) + } catch { + XCTAssertEqual(String(describing: error), errorMessage) + } + } + + var goodValues = [ + ("Int8", "0"), ("Int8", "1"), ("Int8", "-1"), ("Int8", "-128"), ("Int8", "127"), + ("UInt8", "0"), ("UInt8", "1"), ("UInt8", "255"), ("UInt8", "-0"), + + ("Int16", "0"), ("Int16", "1"), ("Int16", "-1"), ("Int16", "-32768"), + ("Int16", "32767"), + ("UInt16", "0"), ("UInt16", "1"), ("UInt16", "65535"), ("UInt16", "34.0"), + + ("Int32", "0"), ("Int32", "1"), ("Int32", "-1"), ("Int32", "-2147483648"), + ("Int32", "2147483647"), + ("UInt32", "0"), ("UInt32", "1"), ("UInt32", "4294967295"), + + ("Int64", "0"), ("Int64", "1"), ("Int64", "-1"), ("Int64", "-9223372036854775808"), + ("Int64", "9223372036854775807"), + ("UInt64", "0"), ("UInt64", "1"), ("UInt64", "18446744073709551615"), + + ("Double", "0"), ("Double", "1"), ("Double", "-1"), + ("Double", "2.2250738585072014e-308"), ("Double", "1.7976931348623157e+308"), + ("Double", "5e-324"), ("Double", "3.141592653589793"), + + ("Decimal", "1.2"), ("Decimal", "3.14159265358979323846264338327950288419"), + ( + "Decimal", + "3402823669209384634633746074317682114550000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ), + ( + "Decimal", + "-3402823669209384634633746074317682114550000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ), + ] + + if Int.max == Int64.max { + goodValues += [ + ("Int", "0"), ("Int", "1"), ("Int", "-1"), ("Int", "-9223372036854775808"), + ("Int", "9223372036854775807"), + ("UInt", "0"), ("UInt", "1"), ("UInt", "18446744073709551615"), + ] + } else { + goodValues += [ + ("Int", "0"), ("Int", "1"), ("Int", "-1"), ("Int", "-2147483648"), + ("Int", "2147483647"), + ("UInt", "0"), ("UInt", "1"), ("UInt", "4294967295"), + ] + } + + let badValues = [ + ("Int8", "-129"), ("Int8", "128"), ("Int8", "1.2"), + ("UInt8", "-1"), ("UInt8", "256"), + + ("Int16", "-32769"), ("Int16", "32768"), + ("UInt16", "-1"), ("UInt16", "65536"), + + ("Int32", "-2147483649"), ("Int32", "2147483648"), + ("UInt32", "-1"), ("UInt32", "4294967296"), + + ("Int64", "9223372036854775808"), ("Int64", "9223372036854775808"), + ("Int64", "-100000000000000000000"), + ("UInt64", "-1"), ("UInt64", "18446744073709600000"), + ("Int64", "10000000000000000000000000000000000000"), + ] + + for value in goodValues { + testGoodValue(value.0, value.1) + } + + for (type, value) in badValues { + testErrorThrown( + type, + value, + errorMessage: "Parsed JSON number <\(value)> does not fit in \(type)." + ) + } + + // Invalid JSON number formats + testErrorThrown( + "Int8", + "0000000000000000000000000000001", + errorMessage: "The given data was not valid JSON." + ) + testErrorThrown("Double", "-.1", errorMessage: "The given data was not valid JSON.") + testErrorThrown("Int32", "+1", errorMessage: "The given data was not valid JSON.") + testErrorThrown("Int", ".012", errorMessage: "The given data was not valid JSON.") + } + + func test_snake_case_encoding() throws { + struct MyTestData: Codable, Equatable { + let thisIsAString: String + let thisIsABool: Bool + let thisIsAnInt: Int + let thisIsAnInt8: Int8 + let thisIsAnInt16: Int16 + let thisIsAnInt32: Int32 + let thisIsAnInt64: Int64 + let thisIsAUint: UInt + let thisIsAUint8: UInt8 + let thisIsAUint16: UInt16 + let thisIsAUint32: UInt32 + let thisIsAUint64: UInt64 + let thisIsAFloat: Float + let thisIsADouble: Double + let thisIsADate: Date + let thisIsAnArray: [Int] + let thisIsADictionary: [String: Bool] + } + + let data = MyTestData( + thisIsAString: "Hello", + thisIsABool: true, + thisIsAnInt: 1, + thisIsAnInt8: 2, + thisIsAnInt16: 3, + thisIsAnInt32: 4, + thisIsAnInt64: 5, + thisIsAUint: 6, + thisIsAUint8: 7, + thisIsAUint16: 8, + thisIsAUint32: 9, + thisIsAUint64: 10, + thisIsAFloat: 11, + thisIsADouble: 12, + thisIsADate: Date.init(timeIntervalSince1970: 0), + thisIsAnArray: [1, 2, 3], + thisIsADictionary: ["trueValue": true, "falseValue": false] + ) + + let encoder = StripeJSONEncoder() + let encodedData = try encoder.encode(data) + guard let jsonObject = try JSONSerialization.jsonObject(with: encodedData) as? [String: Any] + else { + XCTFail("Cant decode json object") + return + } + XCTAssertEqual(jsonObject["this_is_a_string"] as? String, "Hello") + XCTAssertEqual(jsonObject["this_is_a_bool"] as? Bool, true) + XCTAssertEqual(jsonObject["this_is_an_int"] as? Int, 1) + XCTAssertEqual(jsonObject["this_is_an_int8"] as? Int8, 2) + XCTAssertEqual(jsonObject["this_is_an_int16"] as? Int16, 3) + XCTAssertEqual(jsonObject["this_is_an_int32"] as? Int32, 4) + XCTAssertEqual(jsonObject["this_is_an_int64"] as? Int64, 5) + XCTAssertEqual(jsonObject["this_is_a_uint"] as? UInt, 6) + XCTAssertEqual(jsonObject["this_is_a_uint8"] as? UInt8, 7) + XCTAssertEqual(jsonObject["this_is_a_uint16"] as? UInt16, 8) + XCTAssertEqual(jsonObject["this_is_a_uint32"] as? UInt32, 9) + XCTAssertEqual(jsonObject["this_is_a_uint64"] as? UInt64, 10) + XCTAssertEqual(jsonObject["this_is_a_float"] as? Float, 11) + XCTAssertEqual(jsonObject["this_is_a_double"] as? Double, 12) + XCTAssertEqual(jsonObject["this_is_a_date"] as? Int, 0) + XCTAssertEqual(jsonObject["this_is_an_array"] as? [Int], [1, 2, 3]) + XCTAssertEqual( + jsonObject["this_is_a_dictionary"] as? [String: Bool], + ["trueValue": true, "falseValue": false] + ) + + let decoder = StripeJSONDecoder() + let decodedData = try decoder.decode(MyTestData.self, from: encodedData) + XCTAssertEqual(data, decodedData) + } + + func test_dictionary_snake_case_decoding() throws { + let decoder = StripeJSONDecoder() + let snakeCaseJSONData = """ + { + "snake_case_key": { + "nested_dictionary": 1 + } + } + """.data(using: .utf8)! + let decodedDictionary = try decoder.decode( + [String: [String: Int]].self, + from: snakeCaseJSONData + ) + let expectedDictionary = ["snake_case_key": ["nested_dictionary": 1]] + XCTAssertEqual(decodedDictionary, expectedDictionary) + } + + func test_dictionary_snake_case_encoding() throws { + let encoder = StripeJSONEncoder() + let camelCaseDictionary = ["camelCaseKey": ["nested_dictionary": 1]] + let encodedData = try encoder.encode(camelCaseDictionary) + guard + let jsonObject = try JSONSerialization.jsonObject(with: encodedData) + as? [String: [String: Int]] + else { + XCTFail("Cant decode json object") + return + } + XCTAssertEqual(jsonObject, camelCaseDictionary) + } + + func test_SR17581_codingEmptyDictionaryWithNonstringKeyDoesRoundtrip() throws { + struct Something: Codable { + struct Key: Codable, Hashable { + var x: String + } + + var dict: [Key: String] + + enum CodingKeys: String, CodingKey { + case dict + } + + init( + from decoder: Decoder + ) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.dict = try container.decode([Key: String].self, forKey: .dict) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(dict, forKey: .dict) + } + + init( + dict: [Key: String] + ) { + self.dict = dict + } + } + + let toEncode = Something(dict: [:]) + let data = try StripeJSONEncoder().encode(toEncode) + let result = try StripeJSONDecoder().decode(Something.self, from: data) + XCTAssertEqual(result.dict.count, 0) + } + + func testIncorrectArrayType() throws { + struct PaymentMethod: Decodable { + let type: String + } + + let json = """ + { + "type": "card" + } + """ + + let decoder = StripeJSONDecoder() + do { + _ = try decoder.decode(Array.self, from: json.data(using: .utf8)!) + } catch DecodingError.dataCorrupted(let context) { + XCTAssert(context.debugDescription.hasPrefix("Could not convert")) + } + } + + // MARK: - Helper Functions + private var _jsonEmptyDictionary: Data { + return "{}".data(using: .utf8)! + } + + private func _testEncodeFailure(of value: T) { + do { + _ = try StripeJSONEncoder().encode(value) + XCTFail("Encode of top-level \(T.self) was expected to fail.") + } catch {} + } + + private func _testRoundTrip( + of value: T, + expectedJSON json: Data? = nil, + outputFormatting: JSONSerialization.WritingOptions = [], + dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .secondsSince1970, + dateDecodingStrategy: JSONDecoder.DateDecodingStrategy = .secondsSince1970, + dataEncodingStrategy: JSONEncoder.DataEncodingStrategy = .base64, + dataDecodingStrategy: JSONDecoder.DataDecodingStrategy = .base64, + nonConformingFloatEncodingStrategy: JSONEncoder.NonConformingFloatEncodingStrategy = .throw, + nonConformingFloatDecodingStrategy: JSONDecoder.NonConformingFloatDecodingStrategy = .throw + ) where T: Codable, T: Equatable { + var payload: Data! = nil + do { + let encoder = StripeJSONEncoder() + encoder.outputFormatting = outputFormatting + payload = try encoder.encode(value) + } catch { + XCTFail("Failed to encode \(T.self) to JSON: \(error)") + } + + if let expectedJSON = json { + // We do not compare expectedJSON to payload directly, because they might have values like + // {"name": "Bob", "age": 22} + // and + // {"age": 22, "name": "Bob"} + // which if compared as Data would not be equal, but the contained JSON values are equal. + // So we wrap them in a JSON type, which compares data as if it were a json. + + let expectedJSONObject: JSON + let payloadJSONObject: JSON + + do { + expectedJSONObject = try JSON(data: expectedJSON) + } catch { + XCTFail("Invalid JSON data passed as expectedJSON: \(error)") + return + } + + do { + payloadJSONObject = try JSON(data: payload) + } catch { + XCTFail("Produced data is not a valid JSON: \(error)") + return + } + + XCTAssertEqual( + expectedJSONObject, + payloadJSONObject, + "Produced JSON not identical to expected JSON." + ) + } + + do { + let decoder = StripeJSONDecoder() + let decoded = try decoder.decode(T.self, from: payload) + XCTAssertEqual(decoded, value, "\(T.self) did not round-trip to an equal value.") + } catch { + XCTFail("Failed to decode \(T.self) from JSON: \(error)") + } + } + + func test_codingOf(value: T, toAndFrom stringValue: String) { + _testRoundTrip( + of: TopLevelObjectWrapper(value), + expectedJSON: "{\"value\":\(stringValue)}".data(using: .utf8)! + ) + + _testRoundTrip( + of: TopLevelArrayWrapper(value), + expectedJSON: "[\(stringValue)]".data(using: .utf8)! + ) + } + + enum Format: String, SafeEnumCodable { + case format1 + case format2 + case format3 + case unparsable + } + + struct FormatContainer: Decodable, Equatable { + private let container: [Format: String?] + + init( + _ container: [Format: String?] + ) { + self.container = container + } + + static public func == (lhs: FormatContainer, rhs: FormatContainer) -> Bool { + return NSDictionary(dictionary: lhs.container as [AnyHashable: Any]).isEqual( + rhs.container + ) + } + } + + /// This method tests a dictionary that has keys of a custom type + /// (types that are not exactly `String.Type` or `Int.Type`. + func test_encodingCustomKeysForDictionary() { + let formats = FormatContainer([ + .format1: "The first format", + .format2: "The second format", + .format3: "The third format", + ]) + + do { + let encodedData = + "{\"container\":[\"format3\",\"The third format\",\"format2\",\"The second format\",\"format1\",\"The first format\"]}" + .data(using: .utf8) + let decodedResult: FormatContainer = try StripeJSONDecoder.decode( + jsonData: encodedData! + ) + + XCTAssertEqual( + formats, + decodedResult, + "\(FormatContainer.self) did not round-trip to an equal value." + ) + } catch { + XCTFail(String(describing: error)) + } + } +} + +// MARK: - Helper Global Functions +func expectEqualPaths(_ lhs: [CodingKey?], _ rhs: [CodingKey?], _ prefix: String) { + if lhs.count != rhs.count { + XCTFail( + "\(prefix) [CodingKey?].count mismatch: \(lhs.count) != \(rhs.count). \(lhs) != \(rhs)" + ) + return + } + + for (k1, k2) in zip(lhs, rhs) { + switch (k1, k2) { + case (nil, nil): continue + case (let _k1?, nil): + XCTFail("\(prefix) CodingKey mismatch: \(type(of: _k1)) != nil") + return + case (nil, let _k2?): + XCTFail("\(prefix) CodingKey mismatch: nil != \(type(of: _k2))") + return + default: break + } + + let key1 = k1! + let key2 = k2! + + switch (key1.intValue, key2.intValue) { + case (nil, nil): break + case (let i1?, nil): + XCTFail("\(prefix) CodingKey.intValue mismatch: \(type(of: key1))(\(i1)) != nil") + return + case (nil, let i2?): + XCTFail("\(prefix) CodingKey.intValue mismatch: nil != \(type(of: key2))(\(i2))") + return + case (let i1?, let i2?): + guard i1 == i2 else { + XCTFail( + "\(prefix) CodingKey.intValue mismatch: \(type(of: key1))(\(i1)) != \(type(of: key2))(\(i2))" + ) + return + } + } + + XCTAssertEqual( + key1.stringValue, + key2.stringValue, + "\(prefix) CodingKey.stringValue mismatch: \(type(of: key1))('\(key1.stringValue)') != \(type(of: key2))('\(key2.stringValue)')" + ) + } +} + +// MARK: - Test Types +// FIXME: Import from %S/Inputs/Coding/SharedTypes.swift somehow. + +// MARK: - Empty Types +private struct EmptyStruct: Codable, Equatable { + static func == (_ lhs: EmptyStruct, _ rhs: EmptyStruct) -> Bool { + return true + } +} + +private class EmptyClass: Codable, Equatable { + static func == (_ lhs: EmptyClass, _ rhs: EmptyClass) -> Bool { + return true + } +} + +// MARK: - Single-Value Types +/// A simple on-off switch type that encodes as a single Bool value. +private enum Switch: Codable { + case off + case on + + init( + from decoder: Decoder + ) throws { + let container = try decoder.singleValueContainer() + switch try container.decode(Bool.self) { + case false: self = .off + case true: self = .on + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .off: try container.encode(false) + case .on: try container.encode(true) + } + } +} + +/// A simple timestamp type that encodes as a single Double value. +private struct Timestamp: Codable, Equatable { + let value: Double + + init( + _ value: Double + ) { + self.value = value + } + + init( + from decoder: Decoder + ) throws { + let container = try decoder.singleValueContainer() + value = try container.decode(Double.self) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.value) + } + + static func == (_ lhs: Timestamp, _ rhs: Timestamp) -> Bool { + return lhs.value == rhs.value + } +} + +/// A simple referential counter type that encodes as a single Int value. +private final class Counter: Codable, Equatable { + var count: Int = 0 + + init() {} + + init( + from decoder: Decoder + ) throws { + let container = try decoder.singleValueContainer() + count = try container.decode(Int.self) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.count) + } + + static func == (_ lhs: Counter, _ rhs: Counter) -> Bool { + return lhs === rhs || lhs.count == rhs.count + } +} + +// MARK: - Structured Types +/// A simple address type that encodes as a dictionary of values. +private struct Address: Codable, Equatable { + let street: String + let city: String + let state: String + let zipCode: Int + let country: String + + static func == (_ lhs: Address, _ rhs: Address) -> Bool { + return lhs.street == rhs.street && lhs.city == rhs.city && lhs.state == rhs.state + && lhs.zipCode == rhs.zipCode && lhs.country == rhs.country + } + + static var testValue: Address { + return Address( + street: "1 Infinite Loop", + city: "Cupertino", + state: "CA", + zipCode: 95014, + country: "United States" + ) + } +} + +/// A simple person class that encodes as a dictionary of values. +private class Person: Codable, Equatable { + let name: String + let email: String + + // FIXME: This property is present only in order to test the expected result of Codable synthesis in the compiler. + // We want to test against expected encoded output (to ensure this generates an encodeIfPresent call), but we need an output format for that. + // Once we have a VerifyingEncoder for compiler unit tests, we should move this test there. + let website: URL? + + init( + name: String, + email: String, + website: URL? = nil + ) { + self.name = name + self.email = email + self.website = website + } + + static func == (_ lhs: Person, _ rhs: Person) -> Bool { + return lhs.name == rhs.name && lhs.email == rhs.email && lhs.website == rhs.website + } + + static var testValue: Person { + return Person(name: "Johnny Appleseed", email: "appleseed@apple.com") + } +} + +/// A simple company struct which encodes as a dictionary of nested values. +private struct Company: Codable, Equatable { + let address: Address + var employees: [Person] + + static func == (_ lhs: Company, _ rhs: Company) -> Bool { + return lhs.address == rhs.address && lhs.employees == rhs.employees + } + + static var testValue: Company { + return Company(address: Address.testValue, employees: [Person.testValue]) + } +} + +// MARK: - Helper Types + +/// A key type which can take on any string or integer value. +/// +/// This needs to mirror `_JSONKey`. +private struct _TestKey: CodingKey { + var stringValue: String + var intValue: Int? + + init?( + stringValue: String + ) { + self.stringValue = stringValue + self.intValue = nil + } + + init?( + intValue: Int + ) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } + + init( + index: Int + ) { + self.stringValue = "Index \(index)" + self.intValue = index + } +} + +/// Wraps a type T so that it can be encoded at the top level of a payload. +private struct TopLevelArrayWrapper: Codable, Equatable where T: Codable, T: Equatable { + let value: T + + init( + _ value: T + ) { + self.value = value + } + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(value) + } + + init( + from decoder: Decoder + ) throws { + var container = try decoder.unkeyedContainer() + value = try container.decode(T.self) + assert(container.isAtEnd) + } + + static func == (_ lhs: TopLevelArrayWrapper, _ rhs: TopLevelArrayWrapper) -> Bool { + return lhs.value == rhs.value + } +} + +private struct FloatNaNPlaceholder: Codable, Equatable { + init() {} + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(Float.nan) + } + + init( + from decoder: Decoder + ) throws { + let container = try decoder.singleValueContainer() + let float = try container.decode(Float.self) + if !float.isNaN { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Couldn't decode NaN." + ) + ) + } + } + + static func == (_ lhs: FloatNaNPlaceholder, _ rhs: FloatNaNPlaceholder) -> Bool { + return true + } +} + +private struct DoubleNaNPlaceholder: Codable, Equatable { + init() {} + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(Double.nan) + } + + init( + from decoder: Decoder + ) throws { + let container = try decoder.singleValueContainer() + let double = try container.decode(Double.self) + if !double.isNaN { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Couldn't decode NaN." + ) + ) + } + } + + static func == (_ lhs: DoubleNaNPlaceholder, _ rhs: DoubleNaNPlaceholder) -> Bool { + return true + } +} + +/// A type which encodes as an array directly through a single value container. +struct Numbers: Codable, Equatable { + let values = [4, 8, 15, 16, 23, 42] + + init() {} + + init( + from decoder: Decoder + ) throws { + let container = try decoder.singleValueContainer() + let decodedValues = try container.decode([Int].self) + guard decodedValues == values else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: + "The Numbers are wrong! decoded \(decodedValues) but expected \(values)!" + ) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(values) + } + + static func == (_ lhs: Numbers, _ rhs: Numbers) -> Bool { + return lhs.values == rhs.values + } + + static var testValue: Numbers { + return Numbers() + } +} + +/// A type which encodes as a dictionary directly through a single value container. +private final class Mapping: Codable, Equatable { + let values: [String: URL] + + init( + values: [String: URL] + ) { + self.values = values + } + + init( + from decoder: Decoder + ) throws { + let container = try decoder.singleValueContainer() + values = try container.decode([String: URL].self) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(values) + } + + static func == (_ lhs: Mapping, _ rhs: Mapping) -> Bool { + return lhs === rhs || lhs.values == rhs.values + } + + static var testValue: Mapping { + return Mapping(values: [ + "Apple": URL(string: "http://apple.com")!, + "localhost": URL(string: "http://127.0.0.1")!, + ]) + } +} + +struct NestedContainersTestType: Encodable { + let testSuperEncoder: Bool + + init( + testSuperEncoder: Bool = false + ) { + self.testSuperEncoder = testSuperEncoder + } + + enum TopLevelCodingKeys: Int, CodingKey { + case a + case b + case c + } + + enum IntermediateCodingKeys: Int, CodingKey { + case one + case two + } + + func encode(to encoder: Encoder) throws { + if self.testSuperEncoder { + var topLevelContainer = encoder.container(keyedBy: TopLevelCodingKeys.self) + expectEqualPaths(encoder.codingPath, [], "Top-level Encoder's codingPath changed.") + expectEqualPaths( + topLevelContainer.codingPath, + [], + "New first-level keyed container has non-empty codingPath." + ) + + let superEncoder = topLevelContainer.superEncoder(forKey: .a) + expectEqualPaths(encoder.codingPath, [], "Top-level Encoder's codingPath changed.") + expectEqualPaths( + topLevelContainer.codingPath, + [], + "First-level keyed container's codingPath changed." + ) + expectEqualPaths( + superEncoder.codingPath, + [TopLevelCodingKeys.a], + "New superEncoder had unexpected codingPath." + ) + _testNestedContainers(in: superEncoder, baseCodingPath: [TopLevelCodingKeys.a]) + } else { + _testNestedContainers(in: encoder, baseCodingPath: []) + } + } + + func _testNestedContainers(in encoder: Encoder, baseCodingPath: [CodingKey?]) { + expectEqualPaths( + encoder.codingPath, + baseCodingPath, + "New encoder has non-empty codingPath." + ) + + // codingPath should not change upon fetching a non-nested container. + var firstLevelContainer = encoder.container(keyedBy: TopLevelCodingKeys.self) + expectEqualPaths( + encoder.codingPath, + baseCodingPath, + "Top-level Encoder's codingPath changed." + ) + expectEqualPaths( + firstLevelContainer.codingPath, + baseCodingPath, + "New first-level keyed container has non-empty codingPath." + ) + + // Nested Keyed Container + do { + // Nested container for key should have a new key pushed on. + var secondLevelContainer = firstLevelContainer.nestedContainer( + keyedBy: IntermediateCodingKeys.self, + forKey: .a + ) + expectEqualPaths( + encoder.codingPath, + baseCodingPath, + "Top-level Encoder's codingPath changed." + ) + expectEqualPaths( + firstLevelContainer.codingPath, + baseCodingPath, + "First-level keyed container's codingPath changed." + ) + expectEqualPaths( + secondLevelContainer.codingPath, + baseCodingPath + [TopLevelCodingKeys.a], + "New second-level keyed container had unexpected codingPath." + ) + + // Inserting a keyed container should not change existing coding paths. + let thirdLevelContainerKeyed = secondLevelContainer.nestedContainer( + keyedBy: IntermediateCodingKeys.self, + forKey: .one + ) + expectEqualPaths( + encoder.codingPath, + baseCodingPath, + "Top-level Encoder's codingPath changed." + ) + expectEqualPaths( + firstLevelContainer.codingPath, + baseCodingPath, + "First-level keyed container's codingPath changed." + ) + expectEqualPaths( + secondLevelContainer.codingPath, + baseCodingPath + [TopLevelCodingKeys.a], + "Second-level keyed container's codingPath changed." + ) + expectEqualPaths( + thirdLevelContainerKeyed.codingPath, + baseCodingPath + [TopLevelCodingKeys.a, IntermediateCodingKeys.one], + "New third-level keyed container had unexpected codingPath." + ) + + // Inserting an unkeyed container should not change existing coding paths. + let thirdLevelContainerUnkeyed = secondLevelContainer.nestedUnkeyedContainer( + forKey: .two + ) + expectEqualPaths( + encoder.codingPath, + baseCodingPath + [], + "Top-level Encoder's codingPath changed." + ) + expectEqualPaths( + firstLevelContainer.codingPath, + baseCodingPath + [], + "First-level keyed container's codingPath changed." + ) + expectEqualPaths( + secondLevelContainer.codingPath, + baseCodingPath + [TopLevelCodingKeys.a], + "Second-level keyed container's codingPath changed." + ) + expectEqualPaths( + thirdLevelContainerUnkeyed.codingPath, + baseCodingPath + [TopLevelCodingKeys.a, IntermediateCodingKeys.two], + "New third-level unkeyed container had unexpected codingPath." + ) + } + + // Nested Unkeyed Container + do { + // Nested container for key should have a new key pushed on. + var secondLevelContainer = firstLevelContainer.nestedUnkeyedContainer(forKey: .b) + expectEqualPaths( + encoder.codingPath, + baseCodingPath, + "Top-level Encoder's codingPath changed." + ) + expectEqualPaths( + firstLevelContainer.codingPath, + baseCodingPath, + "First-level keyed container's codingPath changed." + ) + expectEqualPaths( + secondLevelContainer.codingPath, + baseCodingPath + [TopLevelCodingKeys.b], + "New second-level keyed container had unexpected codingPath." + ) + + // Appending a keyed container should not change existing coding paths. + let thirdLevelContainerKeyed = secondLevelContainer.nestedContainer( + keyedBy: IntermediateCodingKeys.self + ) + expectEqualPaths( + encoder.codingPath, + baseCodingPath, + "Top-level Encoder's codingPath changed." + ) + expectEqualPaths( + firstLevelContainer.codingPath, + baseCodingPath, + "First-level keyed container's codingPath changed." + ) + expectEqualPaths( + secondLevelContainer.codingPath, + baseCodingPath + [TopLevelCodingKeys.b], + "Second-level unkeyed container's codingPath changed." + ) + expectEqualPaths( + thirdLevelContainerKeyed.codingPath, + baseCodingPath + [TopLevelCodingKeys.b, _TestKey(index: 0)], + "New third-level keyed container had unexpected codingPath." + ) + + // Appending an unkeyed container should not change existing coding paths. + let thirdLevelContainerUnkeyed = secondLevelContainer.nestedUnkeyedContainer() + expectEqualPaths( + encoder.codingPath, + baseCodingPath, + "Top-level Encoder's codingPath changed." + ) + expectEqualPaths( + firstLevelContainer.codingPath, + baseCodingPath, + "First-level keyed container's codingPath changed." + ) + expectEqualPaths( + secondLevelContainer.codingPath, + baseCodingPath + [TopLevelCodingKeys.b], + "Second-level unkeyed container's codingPath changed." + ) + expectEqualPaths( + thirdLevelContainerUnkeyed.codingPath, + baseCodingPath + [TopLevelCodingKeys.b, _TestKey(index: 1)], + "New third-level unkeyed container had unexpected codingPath." + ) + } + } +} + +// MARK: - Helpers + +private struct JSON: Equatable { + private var jsonObject: Any + + fileprivate init( + data: Data + ) throws { + self.jsonObject = try JSONSerialization.jsonObject(with: data, options: []) + } + + static func == (lhs: JSON, rhs: JSON) -> Bool { + switch (lhs.jsonObject, rhs.jsonObject) { + case let (lhs, rhs) as ([AnyHashable: Any], [AnyHashable: Any]): + return NSDictionary(dictionary: lhs) == NSDictionary(dictionary: rhs) + case let (lhs, rhs) as ([Any], [Any]): + return NSArray(array: lhs) == NSArray(array: rhs) + default: + return false + } + } +} + +// MARK: - Run Tests + +extension TestJSONEncoder { + static var allTests: [(String, (TestJSONEncoder) -> () throws -> Void)] { + return [ + ("test_encodingTopLevelFragments", test_encodingTopLevelFragments), + ("test_encodingTopLevelEmptyStruct", test_encodingTopLevelEmptyStruct), + ("test_encodingTopLevelEmptyClass", test_encodingTopLevelEmptyClass), + ("test_encodingTopLevelSingleValueEnum", test_encodingTopLevelSingleValueEnum), + ("test_encodingTopLevelSingleValueStruct", test_encodingTopLevelSingleValueStruct), + ("test_encodingTopLevelSingleValueClass", test_encodingTopLevelSingleValueClass), + ("test_encodingTopLevelStructuredStruct", test_encodingTopLevelStructuredStruct), + ("test_encodingTopLevelStructuredClass", test_encodingTopLevelStructuredClass), + ( + "test_encodingTopLevelStructuredSingleStruct", + test_encodingTopLevelStructuredSingleStruct + ), + ( + "test_encodingTopLevelStructuredSingleClass", + test_encodingTopLevelStructuredSingleClass + ), + ( + "test_encodingTopLevelDeepStructuredType", + test_encodingTopLevelDeepStructuredType + ), + ("test_encodingOutputFormattingDefault", test_encodingOutputFormattingDefault), + ( + "test_encodingOutputFormattingPrettyPrinted", + test_encodingOutputFormattingPrettyPrinted + ), + ( + "test_encodingOutputFormattingSortedKeys", + test_encodingOutputFormattingSortedKeys + ), + ( + "test_encodingOutputFormattingPrettyPrintedSortedKeys", + test_encodingOutputFormattingPrettyPrintedSortedKeys + ), + ("test_encodingDate", test_encodingDate), + ("test_encodingDateSecondsSince1970", test_encodingDateSecondsSince1970), + ("test_encodingBase64Data", test_encodingBase64Data), + ("test_encodingNonConformingFloatStrings", test_encodingNonConformingFloatStrings), + // ("test_nestedContainerCodingPaths", test_nestedContainerCodingPaths), + // ("test_superEncoderCodingPaths", test_superEncoderCodingPaths), + ("test_codingOfBool", test_codingOfBool), + ("test_codingOfNil", test_codingOfNil), + ("test_codingOfInt8", test_codingOfInt8), + ("test_codingOfUInt8", test_codingOfUInt8), + ("test_codingOfInt16", test_codingOfInt16), + ("test_codingOfUInt16", test_codingOfUInt16), + ("test_codingOfInt32", test_codingOfInt32), + ("test_codingOfUInt32", test_codingOfUInt32), + ("test_codingOfInt64", test_codingOfInt64), + ("test_codingOfUInt64", test_codingOfUInt64), + ("test_codingOfInt", test_codingOfInt), + ("test_codingOfUInt", test_codingOfUInt), + ("test_codingOfFloat", test_codingOfFloat), + ("test_codingOfDouble", test_codingOfDouble), + ("test_codingOfDecimal", test_codingOfDecimal), + ("test_codingOfString", test_codingOfString), + ("test_codingOfURL", test_codingOfURL), + ("test_codingOfUIntMinMax", test_codingOfUIntMinMax), + ("test_numericLimits", test_numericLimits), + ("test_snake_case_encoding", test_snake_case_encoding), + ("test_dictionary_snake_case_decoding", test_dictionary_snake_case_decoding), + ("test_dictionary_snake_case_encoding", test_dictionary_snake_case_encoding), + ( + "test_SR17581_codingEmptyDictionaryWithNonstringKeyDoesRoundtrip", + test_SR17581_codingEmptyDictionaryWithNonstringKeyDoesRoundtrip + ), + ] + } +} diff --git a/StripeCore/StripeCoreTests/Helpers/URLEncoderTest.swift b/StripeCore/StripeCoreTests/Helpers/URLEncoderTest.swift new file mode 100644 index 00000000..345a3e73 --- /dev/null +++ b/StripeCore/StripeCoreTests/Helpers/URLEncoderTest.swift @@ -0,0 +1,61 @@ +// +// URLEncoderTest.swift +// StripeCoreTests +// +// Created by Mel Ludowise on 5/26/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +import XCTest + +final class URLEncoderTest: XCTestCase { + func testStringByReplacingSnakeCaseWithCamelCase() { + let camelCase = URLEncoder.convertToCamelCase(snakeCase: "test_1_2_34_test") + XCTAssertEqual("test1234Test", camelCase) + } + + func testStringByReplacingCamelCaseWithSnakeCase() { + let snakeCase = URLEncoder.convertToSnakeCase(camelCase: "test1234Test") + XCTAssertEqual("test1234_test", snakeCase) + let snakeCase2 = URLEncoder.convertToSnakeCase(camelCase: "testUrlTest") + XCTAssertEqual("test_url_test", snakeCase2) + } + + func testQueryStringWithBadFields() { + let params = [ + "foo]": "bar", + "baz": "qux[", + "woo;": ";hoo", + ] + let result = URLEncoder.queryString(from: params) + XCTAssertEqual(result, "baz=qux%5B&foo%5D=bar&woo%3B=%3Bhoo") + } + + func testQueryStringFromParameters() { + let params = + [ + "foo": "bar", + "baz": [ + "qux": NSNumber(value: 1), + ], + ] as [String: AnyHashable] + let result = URLEncoder.queryString(from: params) + XCTAssertEqual(result, "baz[qux]=1&foo=bar") + } + + func testPushProvisioningQueryStringFromParameters() { + let params = [ + "ios": [ + "certificates": ["cert1", "cert2"], + "nonce": "123mynonce", + "nonce_signature": "sig", + ], + ] + let result = URLEncoder.queryString(from: params) + XCTAssertEqual( + result, + "ios[certificates][0]=cert1&ios[certificates][1]=cert2&ios[nonce]=123mynonce&ios[nonce_signature]=sig" + ) + } +} diff --git a/StripeCore/StripeCoreTests/Info.plist b/StripeCore/StripeCoreTests/Info.plist new file mode 100644 index 00000000..64d65ca4 --- /dev/null +++ b/StripeCore/StripeCoreTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/StripeCore/StripeCoreTests/Mock Files/test_image.png b/StripeCore/StripeCoreTests/Mock Files/test_image.png new file mode 100644 index 00000000..60ed2123 Binary files /dev/null and b/StripeCore/StripeCoreTests/Mock Files/test_image.png differ diff --git a/StripeFinancialConnections/Project.swift b/StripeFinancialConnections/Project.swift new file mode 100644 index 00000000..751bc4c6 --- /dev/null +++ b/StripeFinancialConnections/Project.swift @@ -0,0 +1,19 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.stripeFramework( + name: "StripeFinancialConnections", + resources: "StripeFinancialConnections/Resources/**", + dependencies: [ + .project(target: "StripeCore", path: "//StripeCore"), + .project(target: "StripeUICore", path: "//StripeUICore"), + ], + unitTestOptions: .testOptions( + resources: "StripeFinancialConnectionsTests/MockData/**", + dependencies: [ + .project(target: "StripeCore", path: "//StripeCore"), + .project(target: "StripeCoreTestUtils", path: "//StripeCore"), + .project(target: "StripeUICore", path: "//StripeUICore"), + ] + ) +) diff --git a/StripeFinancialConnections/README.md b/StripeFinancialConnections/README.md new file mode 100644 index 00000000..35d9e272 --- /dev/null +++ b/StripeFinancialConnections/README.md @@ -0,0 +1,50 @@ +# Stripe Financial Connections iOS SDK (Beta) + +Stripe Financial Connections iOS SDK lets your users securely share their financial data by linking their external financial accounts to your business in your iOS app. + +## Table of contents + + +* [Features](#features) +* [Requirements](#requirements) +* [Getting started](#getting-started) + * [Integration](#integration) + * [Example](#example) +* [Manual linking](#manual-linking) + + + +## Features + +**Prebuilt UI**: We provide [`FinancialConnectionsSheet`](https://stripe.dev/stripe-ios/stripe-financialconnections/Classes/FinancialConnectionsSheet.html), a prebuilt UI that combines all the steps required for your users to linking their external financial accounts to your business. + +Data retrieved through Financial Connections can help you unlock a variety of use cases, including: + +- Tokenized account and routing numbers let you instantly verify bank accounts for ACH Direct Debit payments. +- Real-time balance data helps you avoid fees from insufficient funds failures before initiating a bank-based payment or wallet transfer. +- Account ownership information, such as the name and address of the bank accountholder, helps you mitigate fraud when onboarding a customer or merchant. +- Transactions data that you can use to help users track expenses, handle bills, manage their finances, and take control of their financial well-being. +- Transactions and balance data helps you speed up underwriting and improve access to credit and other financial services. + + + +## Requirements + +The Stripe Financial Connections iOS SDK is compatible with apps targeting iOS 12.0 or above. + +## Getting started + +### Integration + +Get started with Stripe Financial Connections [📚 iOS integration guide](https://stripe.com/docs/financial-connections/other-data-powered-products?platform=ios) and [example project](../Example/FinancialConnections%20Example), or [📘 browse the SDK reference](https://stripe.dev/stripe-ios/stripe-financialconnections/index.html) for fine-grained documentation of all the classes and methods in the SDK. + +### Example + +[Financial Connections Example](../Example/FinancialConnections%20Example) – This example demonstrates how to let your user link their external financial accounts. + +## Manual linking + +If you link the Stripe Financial Connections library manually, use a version from our [releases](https://github.com/stripe/stripe-ios/releases) page and make sure to embed all of the following frameworks: +- `StripeFinancialConnections.xcframework` +- `StripeCore.xcframework` +- `StripeUICore.xcframework` diff --git a/StripeFinancialConnections/StripeFinancialConnections.xcodeproj/project.pbxproj b/StripeFinancialConnections/StripeFinancialConnections.xcodeproj/project.pbxproj new file mode 100644 index 00000000..39de8eaf --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections.xcodeproj/project.pbxproj @@ -0,0 +1,1580 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + 01C820ECDBFC041A741A5499 /* FlowRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1407DD9E95ADFE143FA046E4 /* FlowRouter.swift */; }; + 0375F8C6D79947C992C32362 /* NetworkingOTPDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0430E0E195DD128FA2D5F86 /* NetworkingOTPDataSource.swift */; }; + 04EB48BB849D29C7C8349DBC /* AccountPickerSelectionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F95EA3D421BE335087E05DB /* AccountPickerSelectionRowView.swift */; }; + 06445472B3008395FCA92FEC /* StripeCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C650E82A4195A7566AA54298 /* StripeCore.framework */; }; + 0692953D76599318535105EC /* arrow_right@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = A9F9ADD5550140F5D763596A /* arrow_right@3x.png */; }; + 07712610C7D2F484AAB96982 /* FinancialConnectionsInstitution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FE8BCF5A9FF2B9392A755EA /* FinancialConnectionsInstitution.swift */; }; + 07A86CEB6B4F6BEB524EFE37 /* ManualEntryValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F897F5AA6A684D0A370EA7BC /* ManualEntryValidator.swift */; }; + 07BFA34C5643A79E1E35A159 /* FinancialConnectionsSession_only_accounts.json in Resources */ = {isa = PBXBuildFile; fileRef = 4AFBF95DAE0783010A17EB58 /* FinancialConnectionsSession_only_accounts.json */; }; + 09DE22DB356EFB0739B3FE02 /* LinkEmailElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC2F211E0EBE8D6AF164F0FA /* LinkEmailElement.swift */; }; + 0AF88791C01102CDCC31F419 /* AccountFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77A71EBB1B98CD285DD17D5F /* AccountFetcherTests.swift */; }; + 0D56BD448019185656DF9310 /* FinancialConnectionsCustomManualEntryRequiredError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C7AE6509C80B6F30662AA /* FinancialConnectionsCustomManualEntryRequiredError.swift */; }; + 11782289208971CCAA1037A5 /* NetworkingLinkStepUpVerificationDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 925B7F2CBACCB2346CD0CDFC /* NetworkingLinkStepUpVerificationDataSource.swift */; }; + 11FB97AC840FEB5B5BF85BF9 /* FinancialConnectionsAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2EAD7059FF8358E674774A /* FinancialConnectionsAPIClient.swift */; }; + 136B704C025F6F69D55A625C /* PlaceholderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C163A50C423B593C7F620630 /* PlaceholderViewController.swift */; }; + 152B480EA85D541CC64451A1 /* RadioButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A4D5A45DB15DF6148F1C85A /* RadioButtonView.swift */; }; + 1599A235CE57409AA2F678E1 /* LinkingAccountsLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8F7A75976314427E8087F9 /* LinkingAccountsLoadingView.swift */; }; + 15EC9F36187C341800164428 /* FinancialConnectionsAnalyticsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBF5CEE2C9030B2D374BC76 /* FinancialConnectionsAnalyticsClient.swift */; }; + 163E387D567068E4A64A4C13 /* AccountPickerSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72DEDE8871A732D603E96E2B /* AccountPickerSelectionView.swift */; }; + 166ACB3BF53BDB4443E276E3 /* LinkAccountPickerFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A680CB323B9139F838643EC1 /* LinkAccountPickerFooterView.swift */; }; + 16F2968DC3B2FC4558821970 /* chevron_down@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = EC561AF0993C02AD68472D11 /* chevron_down@3x.png */; }; + 1889ECB24D40EF331974C288 /* AccountPickerNoAccountEligibleErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 912E3AA36B68492A69019AEA /* AccountPickerNoAccountEligibleErrorView.swift */; }; + 19D1548A5A4034D349DB0947 /* ManualEntryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8301F7BA1FF90D131AE96E10 /* ManualEntryViewController.swift */; }; + 1C043C73281C0856D2C979C6 /* FinancialConnectionsSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACEF0BAF1A5BBA3061C15A09 /* FinancialConnectionsSession.swift */; }; + 1C5D953684456368EEF4C622 /* ManualEntryTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6516BB12D029D1DDCBA1534D /* ManualEntryTextField.swift */; }; + 1CE588CD44D6591B95A9B281 /* AlwaysTemplateImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD2284A0556400DEAEB279BE /* AlwaysTemplateImageView.swift */; }; + 1E0C39EB65B8CB04F218D0BD /* NetworkingLinkLoginWarmupDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091D43608583C7BE5E444C2C /* NetworkingLinkLoginWarmupDataSource.swift */; }; + 1F5DD8E2E0FCED964D636D00 /* NetworkingSaveToLinkFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD137B9465B0DB96AED0AC98 /* NetworkingSaveToLinkFooterView.swift */; }; + 21C0B56B34EACFDAC06ED97D /* InstitutionSearchTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 735101D3516EE679A29AE6D0 /* InstitutionSearchTableViewCell.swift */; }; + 22426A37E01AE759BF93C422 /* AttributedLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE64D5A17913CF4AAD855A9A /* AttributedLabel.swift */; }; + 2343C58289259920DD81620D /* ManualEntryErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C88FD9148F64D2AA8989D361 /* ManualEntryErrorView.swift */; }; + 23DBA4240ED1727C47937A6B /* AccountPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97CE72E38D41E86E0A1FAE9F /* AccountPickerViewController.swift */; }; + 2554BE48E61032CCD4565E7E /* ManualEntrySuccessTransactionTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35882B0B77DB22CEEDDA8C3C /* ManualEntrySuccessTransactionTableView.swift */; }; + 2671241DE661B675E575C0AB /* NetworkingSaveToLinkVerificationDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BDFEB5860F73D4CD90907A /* NetworkingSaveToLinkVerificationDataSource.swift */; }; + 2AA0942F22A323B33CA6B7CA /* PartnerAuthDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC8B933341AF86D9AFF5979 /* PartnerAuthDataSource.swift */; }; + 2CE89100448F26DDA831F455 /* NativeFlowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F856808B78F4F1975959805 /* NativeFlowController.swift */; }; + 2D14461B27B3DEE2CC19B090 /* FinancialConnectionsAccountFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC1BC95816DAD5AE9680662 /* FinancialConnectionsAccountFetcher.swift */; }; + 2E29C0A30C137791D357F4C1 /* SuccessBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E53077F6CE2AF59B9BCB4EF /* SuccessBodyView.swift */; }; + 2FADCA33DEC08E6551D94811 /* AttributedTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97948A848C99ACD1A498F841 /* AttributedTextView.swift */; }; + 31A741F3BE54880AEA75A222 /* ellipsis@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 7BD84F72112C7434B1E25D09 /* ellipsis@3x.png */; }; + 333B9C3E3349F5369FBA7C32 /* NetworkingLinkVerificationDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDB75C69FE9322C745943B3 /* NetworkingLinkVerificationDataSource.swift */; }; + 33FA1684CE79F21271D14F23 /* HitTestStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00D518C15FF3FAED7C193C2 /* HitTestStackView.swift */; }; + 3446145FCA3278D51A9D4B80 /* AttachLinkedPaymentAccountDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 518F1F230FD4DF68E683C728 /* AttachLinkedPaymentAccountDataSource.swift */; }; + 34E12CB27B60F6A53D030765 /* FinancialConnectionsFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83E0A15A666F20DA97F128EA /* FinancialConnectionsFont.swift */; }; + 368DFF9D68F1F8D6A4353961 /* AuthFlowHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B31B3A45DD8AAFD6F08820 /* AuthFlowHelpers.swift */; }; + 39E5D4531961150E9CB3262F /* EmptyFinancialConnectionsAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A7B146AA6BF44921A249DB8 /* EmptyFinancialConnectionsAPIClient.swift */; }; + 3AC5CA5F5529B55026342A54 /* NetworkingLinkSignupDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688616FDC435586025D2023 /* NetworkingLinkSignupDataSource.swift */; }; + 3AE1C7A78FB5B220F5200F49 /* search@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = EF0111A8932418631FFA1663 /* search@3x.png */; }; + 3BF4BBF7E722B961E037286C /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 94869BACB486153419B30DE5 /* XCTest.framework */; }; + 3BFED24B6DF835A0F2FB4939 /* TerminalErrorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83A2749140B4E129CEF39C4 /* TerminalErrorViewController.swift */; }; + 3ECA346F75060BD954376EBF /* StripeFinancialConnectionsBundleLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C0F907D5B0AC58BE7A454BA /* StripeFinancialConnectionsBundleLocator.swift */; }; + 3F835D5A1C797C1C9BCF05D0 /* NetworkingLinkStepUpVerificationBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F9D6F0CC79B7949D037DE66 /* NetworkingLinkStepUpVerificationBodyView.swift */; }; + 3FE4DEFAD6FF77B8D9EE68D3 /* String+Localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = E05B47C10B77812660F7B01A /* String+Localized.swift */; }; + 40FF444E6CF20E9DA7D90448 /* NetworkingOTPView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A3C86CF29F533F87C7DFD6 /* NetworkingOTPView.swift */; }; + 432463EBF562CDDC6D3DC252 /* BankAccountToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = D890BD770F4E33D23ABA37EA /* BankAccountToken.swift */; }; + 43B6B0358EDD34A8AB39B53C /* bank_check@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 6D18A6D99669DFF1E91A0705 /* bank_check@2x.png */; }; + 44203505ED2F64D07632566B /* LinkAccountPickerBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DD5619FDD6C8D85FC352F99 /* LinkAccountPickerBodyView.swift */; }; + 444884F264D13FF654EA7471 /* PaneLayoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34452D5FDC1ED566A13427FE /* PaneLayoutView.swift */; }; + 460C7685096AA6C693309647 /* FinancialConnectionsAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C837C27C2577391B91FF0E5 /* FinancialConnectionsAuthSession.swift */; }; + 465AE8A58AD2183E1E2042FE /* ConsentDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81304AD5BE5CCA10D1A866E0 /* ConsentDataSource.swift */; }; + 486E50E6CB90208AB98C031E /* UIImageView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC3AC48492FAB61E5B66D94 /* UIImageView+Extensions.swift */; }; + 48E093CC2FB4A619A9E2C20E /* ManualEntryCheckView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A22DBD7D336381FEB3AB00 /* ManualEntryCheckView.swift */; }; + 4A0D015C978BD79BBFE6CE57 /* ManualEntryDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4C39F5F9AF440B13F51A81 /* ManualEntryDataSource.swift */; }; + 4A537AE0C50CAFF3889EFE28 /* UIViewController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF7E41313B709F87B549D85F /* UIViewController+Extensions.swift */; }; + 4AADAA347EF70ECB8BE28E84 /* SuccessIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7455C145AEAE3D5F87532187 /* SuccessIconView.swift */; }; + 4CEE364A07B91B51B3D00F05 /* ConsentBottomSheetModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A80CA4A87A90D2E19262220 /* ConsentBottomSheetModel.swift */; }; + 4DC8EB63806434ABF4C9CC43 /* add@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 782A419DCF59BE6AB6439D04 /* add@3x.png */; }; + 54B51EA1F75B9607D7C29B08 /* NetworkingLinkSignupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CDEF702710EEA29BA3DC653 /* NetworkingLinkSignupViewController.swift */; }; + 58CD76A6379A04566684D2AC /* InstitutionSearchTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71BC4222E1986338BCB9587 /* InstitutionSearchTableView.swift */; }; + 5F3C86F23B65CAC56FDDEC90 /* NetworkingLinkSignupBodyFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 041EFAE37D8F7E96DD4A4435 /* NetworkingLinkSignupBodyFormView.swift */; }; + 62F9AC317C04285853345A0C /* SpinnerIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 723996D53965EC2286267A01 /* SpinnerIconView.swift */; }; + 645D6FF67167263F9A1C2BB0 /* bullet@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 07BCA9D23511A3494C82B632 /* bullet@3x.png */; }; + 648FA50974B14CC861B08ECB /* APIPollingHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF54EDA6123C7E4E78D9D56B /* APIPollingHelper.swift */; }; + 6744CB1B182C5F7220B0B804 /* AuthFlowHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AFC0D3ED86914DC4216CCCA /* AuthFlowHelpersTests.swift */; }; + 685CBD26771250C40E60F7A0 /* NetworkingSaveToLinkBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BE553B86F5AC8B7D6190A04 /* NetworkingSaveToLinkBodyView.swift */; }; + 691619AE9A989548ABA36535 /* HitTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F669BB8F3DA862C425897705 /* HitTestView.swift */; }; + 6944E131D351784058C7D734 /* FinancialConnectionsPaymentMethodType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 191760EFAA9154C1F168E1D2 /* FinancialConnectionsPaymentMethodType.swift */; }; + 69508BFAF474855B05347CE4 /* ConsentBottomSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E337C061E4152E7814FC21E /* ConsentBottomSheetViewController.swift */; }; + 6BC6DB482984F9288944FE25 /* NetworkingSaveToLinkVerificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66D3CAB53EC9D33831C5A48B /* NetworkingSaveToLinkVerificationViewController.swift */; }; + 6D018BB3C1253ED4C1674E0B /* ManualEntryFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73C03F4DDC67B50C5E1993F6 /* ManualEntryFormView.swift */; }; + 6D29E55F6A3864ED52799169 /* InstitutionPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B3BF292FD82A198752A82EB /* InstitutionPickerViewController.swift */; }; + 6E6E30D01D4E9629DB07E97B /* FinancialConnectionsNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA6A57067FB5EF86FEBD5B3 /* FinancialConnectionsNavigationController.swift */; }; + 6FE9F171CF9A5D0EDB2035AA /* FinancialConnectionsNetworkingLinkSignup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF7CE16FE4D5E8B889BF5D1E /* FinancialConnectionsNetworkingLinkSignup.swift */; }; + 700B745FEF43088D9E34C0E4 /* AccountPickerHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED33E6BADC0893C3F6B22D2 /* AccountPickerHelpersTests.swift */; }; + 707C265C4179A8FEC98913FE /* ConsentBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FA941D5F7178E89DE70076F /* ConsentBodyView.swift */; }; + 716E12A9AC0B790F14FB72C6 /* AccountNumberRetrievalErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6224E799E667DF223757D493 /* AccountNumberRetrievalErrorView.swift */; }; + 72BB9389206F10DE9B18E542 /* LinkAccountPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1848547B588045C776236B3B /* LinkAccountPickerViewController.swift */; }; + 7386E1F9256B23CE29BF996D /* FinancialConnectionsInstitutionSearchResultResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 921686D9A3076749E1A9E549 /* FinancialConnectionsInstitutionSearchResultResource.swift */; }; + 74CC216C8A71AD357B8AA544 /* NativeFlowDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 780BC432329228B042DA97D8 /* NativeFlowDataManager.swift */; }; + 755140DEEE50DCD6E939E528 /* check@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 5B77DE6D7A86CC847977396A /* check@3x.png */; }; + 76C466DE26B6646D9B25E9B1 /* FeaturedInstitutionGridCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC4BABB6C871FE97868767E7 /* FeaturedInstitutionGridCell.swift */; }; + 76FB143918C5463B587091BB /* STPLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2D765AC793D89D26B74FC4 /* STPLocalizedString.swift */; }; + 779C729BB49FD4B99DCD517B /* MarkdownBoldAttributedStringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E7D318EE807701AB3FCA17D /* MarkdownBoldAttributedStringTests.swift */; }; + 77C7F9A1DD0461FA2B1B4328 /* FinancialConnectionsPartnerAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452989E2D269784006EFD18C /* FinancialConnectionsPartnerAccount.swift */; }; + 77D3B375B9DBF80BA209BC99 /* FinancialConnectionsSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E05C2C5CDAA55CE700662040 /* FinancialConnectionsSessionTests.swift */; }; + 7AC3A12D50FA995882473C8A /* MerchantDataAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13A97EEE12DA8FB77D13C527 /* MerchantDataAccessView.swift */; }; + 7AE7474B7AFF416B6072721C /* StripeCore+Import.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CF67A1F497E6CC73029CF0 /* StripeCore+Import.swift */; }; + 825C2182D13D7AC2DF67BB5E /* Locale+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AE2036F58EE5C098C1B25B /* Locale+Extensions.swift */; }; + 82FD3CEE526DE8B6519F666E /* FinancialConnectionsSessionFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = A872C2B500306F775622F904 /* FinancialConnectionsSessionFetcher.swift */; }; + 846D1D7429B9E414744DEC99 /* FinancialConnectionsSheetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20E7725EF317C3BD62ADF845 /* FinancialConnectionsSheetTests.swift */; }; + 864C5159C62C562C655B53F7 /* StripeFinancialConnections.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E37D8CE9CD73443A9AAF2AE8 /* StripeFinancialConnections.framework */; }; + 87198EFD873751CA4E4B5005 /* FinancialConnectionsOAuthPrepane.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC5894A5EB74F6157C7DE95 /* FinancialConnectionsOAuthPrepane.swift */; }; + 87E22AF1E35FB63C20AEE9DF /* warning_triangle@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 5D555DB0657A602274596428 /* warning_triangle@3x.png */; }; + 8927328EE28A0C94B5AB69DB /* ConsentLogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0281E0221BDE01D0845DC0F9 /* ConsentLogoView.swift */; }; + 8A424D8F321E80945AD42B1A /* ReusableInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F1C8781095363382A0F7BE5 /* ReusableInformationView.swift */; }; + 8C985491C17431278097D0FF /* Placeholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2710EA52082B4A983294567 /* Placeholder.swift */; }; + 8DC6C2A239456994091BF3EE /* NetworkingLinkSignupFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72A74B9F667A3BF48253045E /* NetworkingLinkSignupFooterView.swift */; }; + 8EDCAA40D5ACE4D4CCC67695 /* CloseConfirmationAlertHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB4BBD3A262039B34C2DDCCB /* CloseConfirmationAlertHandler.swift */; }; + 91A3583A0BDE0F8F0C4AD3E2 /* InstitutionIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939D10B20D3ECA7BF7021BF8 /* InstitutionIconView.swift */; }; + 933F9DFE970FAB4715369086 /* HostController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93C4ECB724BD75320A999C42 /* HostController.swift */; }; + 95B2A73AC5DA9FA64017B3CB /* NetworkingLinkSignupBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40ECB2B008FC082B4D38D2FE /* NetworkingLinkSignupBodyView.swift */; }; + 97032B101B54E6A98178FD73 /* stripe_logo@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = B4F56BF50DBF4A353D2526A6 /* stripe_logo@3x.png */; }; + 971E6F5E78BC3265CD80D0C6 /* StripeUICore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6652EBE38C47B36962AD370A /* StripeUICore.framework */; }; + 97C528CE821C6A55D58F68A4 /* ConsentFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E58AE51252DA4597DC82988 /* ConsentFooterView.swift */; }; + 99F41681B77ECB0090F34E31 /* SFSafariViewController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D38B3C816EAB38AD242B064 /* SFSafariViewController+Extensions.swift */; }; + 9AF6EC34D666BEB3C1397092 /* BulletPointLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591E8073D2AD30115ABDB60F /* BulletPointLabelView.swift */; }; + 9B2CAE99344C26D524EDCF26 /* ModalPresentationWrapperViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587CD174831344F15ADB538D /* ModalPresentationWrapperViewController.swift */; }; + 9C6CE8824A7AEA56A2F5E7F5 /* NetworkingLinkVerificationBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CBF53BDBB2CDC5908C374D7 /* NetworkingLinkVerificationBodyView.swift */; }; + 9CE29EA549C4BFA447AB82E0 /* StripeCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C650E82A4195A7566AA54298 /* StripeCore.framework */; }; + 9DEE4DE72A7FEDD9D3902C16 /* InstitutionSearchFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F560A66FD9A35762F884F2D4 /* InstitutionSearchFooterView.swift */; }; + 9E0044ABEC04E2A8C50E3658 /* FinancialConnectionsSessionManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429F985168AE9F9D700AE37B /* FinancialConnectionsSessionManifest.swift */; }; + A10B5A3E5E8AE8767CF09C15 /* AccountPickerSelectionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283469CD0298E3AFCFDAF10F /* AccountPickerSelectionListView.swift */; }; + A156FACA60231988F247F6F4 /* FinancialConnectionsSession_only_both_missing.json in Resources */ = {isa = PBXBuildFile; fileRef = 1DF07A1AAD6B39033F0B86FD /* FinancialConnectionsSession_only_both_missing.json */; }; + A1AEE72611F62550267C326C /* ManualEntryFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2922EAE205D76B40ED7FC92 /* ManualEntryFooterView.swift */; }; + A34AB3AC6D071605CABFFC9B /* UIViewController+KeyboardAvoiding.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB3C49A180D1697B03C79A59 /* UIViewController+KeyboardAvoiding.swift */; }; + A573468B2800DABF384CAB43 /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BCC4356AE3295B4A2F4A28 /* Image.swift */; }; + A6DB232AD8CB25B0C9F4C79C /* PaneWithHeaderLayoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD362DF2D937D22E803C1DD /* PaneWithHeaderLayoutView.swift */; }; + A79D6A26EE9FF96D24F4AC5C /* NSAttributedString+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267B3586136203186882F5CE /* NSAttributedString+Extensions.swift */; }; + A9F9E63FD6B72F5552A8A850 /* ResetFlowViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA81F9910A88A7DEB0F0BFD /* ResetFlowViewController.swift */; }; + AA80602323C28AFAC391358D /* TimeInterval+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E813BE6B34901E4E050FFE13 /* TimeInterval+Extensions.swift */; }; + AB5AFAC3C70D6195075DE5AE /* FinancialConnectionsBulletPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3FD6A7D1638E42AA00C88C4 /* FinancialConnectionsBulletPoint.swift */; }; + AB7C9A26484953762FFBB4A5 /* FinancialConnectionsWebFlowViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BD07CE6F99D7FAE83FC5CCC /* FinancialConnectionsWebFlowViewController.swift */; }; + ABB28C3F6604C2BA2FCA079D /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F3A660CB2E9651947FE6D0A /* String+Extensions.swift */; }; + ACD21F21C6E42706A882A1AE /* FinancialConnectionsSession_only_la.json in Resources */ = {isa = PBXBuildFile; fileRef = AA01BC4016BF8788633CCAD9 /* FinancialConnectionsSession_only_la.json */; }; + AD5B496425E2993C87F0B770 /* PrepaneImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54F545087F12B09FF416991 /* PrepaneImageView.swift */; }; + B271AAF41C9FE6AE392B88D3 /* FinancialConnectionsMixedOAuthParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BF4CD26CEAE792AC2A7313 /* FinancialConnectionsMixedOAuthParams.swift */; }; + B2970FE2753A4D79E428BA73 /* SuccessDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C15A30C40F34CE330F89C41 /* SuccessDataSource.swift */; }; + B45B8DC3DAACDD5F04B1B1BE /* SuccessViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB3F67BE6E46ED018EB8C3FD /* SuccessViewController.swift */; }; + B5EEF34D158C08A1745FA150 /* ContinueStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA55452373FD735983F3690B /* ContinueStateView.swift */; }; + B8F440921DB172F96F912CD0 /* PaneWithCustomHeaderLayoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263817C1DB5311A4E99C11CE /* PaneWithCustomHeaderLayoutView.swift */; }; + B9A24A47454134F2B869C969 /* FinancialConnectionsConsent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FD9739F1AA7CBA76DD3E1E2 /* FinancialConnectionsConsent.swift */; }; + BAA72FE13406CAF5FA4BBDC8 /* SuccessAccountListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4EDFBDA8D9C0CEA30B51F2 /* SuccessAccountListView.swift */; }; + BC991D917034CCF5149403CA /* FeaturedInstitutionGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947F7F2C282A37EC4AB119E0 /* FeaturedInstitutionGridView.swift */; }; + BCEA321423DF0E7674C2544C /* APIVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D95E5F34BDEE0237F52DA0A /* APIVersion.swift */; }; + BD3C87E03EB44F7D1C11664C /* spinner@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 57B289E803B7A53B000D7919 /* spinner@3x.png */; }; + BF5F964E1CA6312755D4161E /* SessionFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 965814B0C5F3D13158E610E3 /* SessionFetcherTests.swift */; }; + BFF222008EEEDC3FACE342D9 /* AccountPickerFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ACAB1B6DB88D74F5ECC1C6D /* AccountPickerFooterView.swift */; }; + C0831318A33A32BF2EAB641A /* AccountPickerHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1362591D12A04CA663A69A47 /* AccountPickerHelpers.swift */; }; + C11D48CC29E1A123D50A5094 /* prepane_phone_background@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1AD7571981F0FE2F10F68530 /* prepane_phone_background@3x.png */; }; + C128C1681E46F0F12EB4EB9F /* NetworkingLinkStepUpVerificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6D662AB9854F3BDB90D8FD /* NetworkingLinkStepUpVerificationViewController.swift */; }; + C19996D0AC7E046DA87B6B32 /* ManualEntryValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8C7FDF59D906EA5C6B7A514 /* ManualEntryValidatorTests.swift */; }; + C1A079E8E76A02EBCB2588DA /* AccountPickerDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D715516C6703A780913E66EB /* AccountPickerDataSource.swift */; }; + C23A55F7C98103222B159D73 /* bank@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = EA3F36F0BED21F607C546B6D /* bank@3x.png */; }; + C258E0D849083BCC8A9B5068 /* HostViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0863AF9E2F9BD7C026FE59E /* HostViewController.swift */; }; + C3338FA5019EC8E99E2BA62F /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 064D0E3A3AC71FAA60B54FC5 /* Helpers.swift */; }; + C38BEDD99477C83C91B105DD /* AccountPickerAccountLoadErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 463549CECD379484842033E3 /* AccountPickerAccountLoadErrorView.swift */; }; + C39214EA5995D85B847406BE /* SuccessFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD828AB80DE41DE11D38AF5C /* SuccessFooterView.swift */; }; + C55F79F4B85E1EB8730B02C6 /* LinkAccountPickerDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 524D3116FDA5A3AD68075AA4 /* LinkAccountPickerDataSource.swift */; }; + C59DBA5A86A3331113D6ED7E /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60B7CFC14964440E8AA670A9 /* LoadingView.swift */; }; + C5FEC806A31021B7D119A73C /* NetworkingLinkVerificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD861E4EB8BA294545B7651 /* NetworkingLinkVerificationViewController.swift */; }; + C61D5957D3276991795F7D16 /* FinancialConnectionsSheetAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6038978C79785C18257CD74 /* FinancialConnectionsSheetAnalytics.swift */; }; + C6B99A1C34886D3B5E1AF1A2 /* InstitutionSearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DA1C1B311E06C1165C6F6A2 /* InstitutionSearchBar.swift */; }; + C747113C75AC92643B283CBD /* close@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F1C7A2FE53419CB29CBB6C08 /* close@3x.png */; }; + C7D2763ACCE2CC71E788E18F /* FinancialConnectionsLegalDetailsNotice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07526E95D85120F6492E78AE /* FinancialConnectionsLegalDetailsNotice.swift */; }; + C906FC4DE38F16032B787607 /* ResetFlowDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1C78684DD0B2D168C86229 /* ResetFlowDataSource.swift */; }; + CA5825059866BD3416BF8240 /* ManualEntrySuccessViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A7913F446F111887F1FA01 /* ManualEntrySuccessViewController.swift */; }; + CB734C25A19D38A87876FB2B /* FinancialConnectionsAnalyticsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73AB8A480620B5C3567F453C /* FinancialConnectionsAnalyticsTest.swift */; }; + CBEAB081DD7353928F485071 /* APIPollingHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 710183EE587F6FDA077FC150 /* APIPollingHelperTests.swift */; }; + CBF7BE2271D309F2B1E794CC /* FinancialConnectionsDataAccessNotice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32ED8A7E94822F14AD94A698 /* FinancialConnectionsDataAccessNotice.swift */; }; + CF47070B2A4CA27FEE9AE5FA /* generic_error@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 6A764CF4DB5B5F6F488132A8 /* generic_error@3x.png */; }; + CF8152B40B6CA74FD15602BF /* warning_circle@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 956668158B27ACD34A6B2657 /* warning_circle@3x.png */; }; + D0C1EF46A418A8F8774B7418 /* FinancialConnectionsSession_both_accounts_la.json in Resources */ = {isa = PBXBuildFile; fileRef = F6CF7F1005B57D566E139DE3 /* FinancialConnectionsSession_both_accounts_la.json */; }; + D0C6D94867FA04B1BF80D56D /* StripeCoreTestUtils.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9AB787FE87EDD702B1BBF09 /* StripeCoreTestUtils.framework */; }; + D10FB0DAC5E452D4569CEA14 /* back_arrow@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = C4DA5D0EB4ED760B3F9818C5 /* back_arrow@3x.png */; }; + D3AB52D5AE87FE51642C50C1 /* ExperimentHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C2E9D1B7C962C46F7E0002A /* ExperimentHelper.swift */; }; + D50E771043434AD80EA28628 /* StripeUICore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6652EBE38C47B36962AD370A /* StripeUICore.framework */; }; + D926228B6C7601AE4C806C93 /* FinancialConnectionsPaymentAccountResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF9E2D4C9D7684B02AD6037A /* FinancialConnectionsPaymentAccountResource.swift */; }; + D936C8A9F6E018DB144A5B0A /* FinancialConnectionsSynchronize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4667E3861CDEC3A41B757714 /* FinancialConnectionsSynchronize.swift */; }; + D949AE695F3288F84258BACD /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF6810BAADB14ACB95216C2B /* Localizable.strings */; }; + D9D84D6FF624CF4363D87CEB /* InstitutionDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ED1CF95D441821773EA68EE /* InstitutionDataSource.swift */; }; + D9F35B3B31CA2E52055D6B1D /* StringExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF731140836AE438C7F4F6AB /* StringExtensionsTests.swift */; }; + DAA51ABB496551074DBA1A20 /* FinancialConnectionsNetworkedAccountsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3979E84D319D3ED1C3273D74 /* FinancialConnectionsNetworkedAccountsResponse.swift */; }; + DC4DFC847378AC9E9112B443 /* AuthenticationSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 596A401ABA089532A5006584 /* AuthenticationSessionManager.swift */; }; + E3F62D2F9C344A1178030E8E /* AttachLinkedPaymentAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA62F9B77C6A1D1B12F02CF5 /* AttachLinkedPaymentAccountViewController.swift */; }; + E4D00DB842047E595DD85BEF /* CheckboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97EE2BCD7B861ACA49DB56CD /* CheckboxView.swift */; }; + E54BF330E72BA091AEA1833A /* LinkAccountPickerRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 606B3546B822EE9F9F868B8C /* LinkAccountPickerRowView.swift */; }; + E637387728FA1597B1B51E5D /* UIImage+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2EA7801B85973F10F65DDB6 /* UIImage+Extensions.swift */; }; + E760C94B619A8934D1D5E1D0 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBC23DAC91962D0D8A713D37 /* UIColor+Extensions.swift */; }; + E85DCFCA61299EF27B3201CF /* FinancialConnectionsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D8397DB43DEC09BDF66E8A /* FinancialConnectionsSheet.swift */; }; + E9866D5CA186A242BBEA69E1 /* ConsentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13AAB10AEE7A8EC5C9C53FFA /* ConsentViewController.swift */; }; + E9E6775FE1FB4E13B284C8E3 /* AccountPickerLabelRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2323E76B1B9C295DC5EAD53E /* AccountPickerLabelRowView.swift */; }; + EC74B719F0FA1A977EF4708C /* FinancialConnectionsAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 359BF8ACFB35A16EBD96C4F0 /* FinancialConnectionsAccount.swift */; }; + ED818E10F37230678B9B73CC /* SoftLinkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90A06A97D714450321A5D76D /* SoftLinkTests.swift */; }; + EDB22A11E058269784090A51 /* ConsentBottomSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8199AF9CE999D21ED5871D96 /* ConsentBottomSheetView.swift */; }; + F0397F4E1D6A91416897F45E /* brandicon_default@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 7413C9D80DF7190CA6FB82EE /* brandicon_default@3x.png */; }; + F03F840B9E896F1B09742191 /* PartnerAuthViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25BDE722D8A827955C3182E8 /* PartnerAuthViewController.swift */; }; + F0495231F4C70E054149C03A /* LinkAccountPickerNewAccountRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFF070262170A321F5622CCF /* LinkAccountPickerNewAccountRowView.swift */; }; + F0FB346A0F86C3561CD3C048 /* UITableView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52F27D0FACAE9D4F4D15A73 /* UITableView+Extensions.swift */; }; + F10147CF75C2A09D66CB5C14 /* cancel_circle@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = A4CC00446B086B2987114099 /* cancel_circle@3x.png */; }; + F22DE4B785D51B318A1A3D08 /* FinancialConnectionsSheetError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0737DE86515E172909366F /* FinancialConnectionsSheetError.swift */; }; + F419E86FC441B6C58B3F0D00 /* PrepaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5309724F4B9531EBBB03FD54 /* PrepaneView.swift */; }; + F65E8D16DE691EB6C99C4521 /* Button+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4A84F0646AD673029CB6FC /* Button+Extensions.swift */; }; + F67624595BD2CD7B6793BFDA /* FinancialConnectionsImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C93F7139E9BFB044902962D0 /* FinancialConnectionsImage.swift */; }; + F7C10A1AB247D0F6E111DE36 /* NetworkingLinkLoginWarmupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 252DA1FE0574822605438AB4 /* NetworkingLinkLoginWarmupViewController.swift */; }; + FBF513C7F73002FA30CC7C21 /* ConsumerSessionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08C32F1447E47221DC0B7095 /* ConsumerSessionModels.swift */; }; + FCC5A360E0064887DB28F5C6 /* StripeFinancialConnections.h in Headers */ = {isa = PBXBuildFile; fileRef = F96A3BA5CCB8DCCFA3126974 /* StripeFinancialConnections.h */; settings = {ATTRIBUTES = (Public, ); }; }; + FE268512851E63E4E111DECD /* FinancialConnectionsSDKImplementation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 248D51F7AADE404E49957DDA /* FinancialConnectionsSDKImplementation.swift */; }; + FFD76E78070ECBB283D43D5E /* NetworkingLinkLoginWarmupBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13983D90462EB946B2A178C6 /* NetworkingLinkLoginWarmupBodyView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 99584176FCBCA6DC9B8E22E4 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3D00B888AF0B02587576A83F /* Project object */; + proxyType = 1; + remoteGlobalIDString = 44C90013B7C82C80A2F69956; + remoteInfo = StripeFinancialConnections; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 4E8F557BB03B30AB6BCE5DAC /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + 6EAD6F45EDAE7B645FDC823B /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0281E0221BDE01D0845DC0F9 /* ConsentLogoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentLogoView.swift; sourceTree = ""; }; + 041EFAE37D8F7E96DD4A4435 /* NetworkingLinkSignupBodyFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingLinkSignupBodyFormView.swift; sourceTree = ""; }; + 064D0E3A3AC71FAA60B54FC5 /* Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = ""; }; + 07526E95D85120F6492E78AE /* FinancialConnectionsLegalDetailsNotice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsLegalDetailsNotice.swift; sourceTree = ""; }; + 07BCA9D23511A3494C82B632 /* bullet@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "bullet@3x.png"; sourceTree = ""; }; + 08C32F1447E47221DC0B7095 /* ConsumerSessionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsumerSessionModels.swift; sourceTree = ""; }; + 091D43608583C7BE5E444C2C /* NetworkingLinkLoginWarmupDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingLinkLoginWarmupDataSource.swift; sourceTree = ""; }; + 0DA7868C9DD47582244B47C8 /* ca-ES */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ca-ES"; path = "ca-ES.lproj/Localizable.strings"; sourceTree = ""; }; + 0DD5619FDD6C8D85FC352F99 /* LinkAccountPickerBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkAccountPickerBodyView.swift; sourceTree = ""; }; + 0F4FC108D8C162EEE1EEA97E /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; + 106427315CD279EAAD7D1B74 /* en-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-GB"; path = "en-GB.lproj/Localizable.strings"; sourceTree = ""; }; + 1362591D12A04CA663A69A47 /* AccountPickerHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPickerHelpers.swift; sourceTree = ""; }; + 13983D90462EB946B2A178C6 /* NetworkingLinkLoginWarmupBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingLinkLoginWarmupBodyView.swift; sourceTree = ""; }; + 13A97EEE12DA8FB77D13C527 /* MerchantDataAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MerchantDataAccessView.swift; sourceTree = ""; }; + 13AAB10AEE7A8EC5C9C53FFA /* ConsentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentViewController.swift; sourceTree = ""; }; + 1407DD9E95ADFE143FA046E4 /* FlowRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowRouter.swift; sourceTree = ""; }; + 14CED33665ED3D8EE8D5D7B7 /* StripeiOS Tests-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS Tests-Release.xcconfig"; sourceTree = ""; }; + 1848547B588045C776236B3B /* LinkAccountPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkAccountPickerViewController.swift; sourceTree = ""; }; + 191760EFAA9154C1F168E1D2 /* FinancialConnectionsPaymentMethodType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsPaymentMethodType.swift; sourceTree = ""; }; + 1A80CA4A87A90D2E19262220 /* ConsentBottomSheetModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentBottomSheetModel.swift; sourceTree = ""; }; + 1AD7571981F0FE2F10F68530 /* prepane_phone_background@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "prepane_phone_background@3x.png"; sourceTree = ""; }; + 1CD19E0601599AE89976DB4D /* StripeiOS-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS-Release.xcconfig"; sourceTree = ""; }; + 1CE32B7E492EFD8143F687F2 /* zh-HK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-HK"; path = "zh-HK.lproj/Localizable.strings"; sourceTree = ""; }; + 1CFE14532C10471EC61BB05A /* sl-SI */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sl-SI"; path = "sl-SI.lproj/Localizable.strings"; sourceTree = ""; }; + 1D38B3C816EAB38AD242B064 /* SFSafariViewController+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SFSafariViewController+Extensions.swift"; sourceTree = ""; }; + 1DF07A1AAD6B39033F0B86FD /* FinancialConnectionsSession_only_both_missing.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = FinancialConnectionsSession_only_both_missing.json; sourceTree = ""; }; + 1E58AE51252DA4597DC82988 /* ConsentFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentFooterView.swift; sourceTree = ""; }; + 1E80DD2D042B327D9756E083 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 1F2C7AE6509C80B6F30662AA /* FinancialConnectionsCustomManualEntryRequiredError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsCustomManualEntryRequiredError.swift; sourceTree = ""; }; + 1F9D6F0CC79B7949D037DE66 /* NetworkingLinkStepUpVerificationBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingLinkStepUpVerificationBodyView.swift; sourceTree = ""; }; + 1FD362DF2D937D22E803C1DD /* PaneWithHeaderLayoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaneWithHeaderLayoutView.swift; sourceTree = ""; }; + 20E7725EF317C3BD62ADF845 /* FinancialConnectionsSheetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsSheetTests.swift; sourceTree = ""; }; + 2323E76B1B9C295DC5EAD53E /* AccountPickerLabelRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPickerLabelRowView.swift; sourceTree = ""; }; + 24701CABF53C21DD7BCF3E48 /* fr-CA */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "fr-CA"; path = "fr-CA.lproj/Localizable.strings"; sourceTree = ""; }; + 248D51F7AADE404E49957DDA /* FinancialConnectionsSDKImplementation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsSDKImplementation.swift; sourceTree = ""; }; + 24D4A72B4CCA677F45C29A5C /* el-GR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "el-GR"; path = "el-GR.lproj/Localizable.strings"; sourceTree = ""; }; + 252DA1FE0574822605438AB4 /* NetworkingLinkLoginWarmupViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingLinkLoginWarmupViewController.swift; sourceTree = ""; }; + 25BDE722D8A827955C3182E8 /* PartnerAuthViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PartnerAuthViewController.swift; sourceTree = ""; }; + 263817C1DB5311A4E99C11CE /* PaneWithCustomHeaderLayoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaneWithCustomHeaderLayoutView.swift; sourceTree = ""; }; + 267B3586136203186882F5CE /* NSAttributedString+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+Extensions.swift"; sourceTree = ""; }; + 283469CD0298E3AFCFDAF10F /* AccountPickerSelectionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPickerSelectionListView.swift; sourceTree = ""; }; + 2BE553B86F5AC8B7D6190A04 /* NetworkingSaveToLinkBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingSaveToLinkBodyView.swift; sourceTree = ""; }; + 2C0F907D5B0AC58BE7A454BA /* StripeFinancialConnectionsBundleLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeFinancialConnectionsBundleLocator.swift; sourceTree = ""; }; + 2C10E841FF9EBFEA8C2E30AF /* Project-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Debug.xcconfig"; sourceTree = ""; }; + 2D95E5F34BDEE0237F52DA0A /* APIVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIVersion.swift; sourceTree = ""; }; + 314462DF7856349FF9775598 /* StripeiOS-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS-Debug.xcconfig"; sourceTree = ""; }; + 32ED8A7E94822F14AD94A698 /* FinancialConnectionsDataAccessNotice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsDataAccessNotice.swift; sourceTree = ""; }; + 33B1E2861FA7CA86FF79236C /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; + 34452D5FDC1ED566A13427FE /* PaneLayoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaneLayoutView.swift; sourceTree = ""; }; + 35882B0B77DB22CEEDDA8C3C /* ManualEntrySuccessTransactionTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntrySuccessTransactionTableView.swift; sourceTree = ""; }; + 359BF8ACFB35A16EBD96C4F0 /* FinancialConnectionsAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsAccount.swift; sourceTree = ""; }; + 3979E84D319D3ED1C3273D74 /* FinancialConnectionsNetworkedAccountsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsNetworkedAccountsResponse.swift; sourceTree = ""; }; + 3BD07CE6F99D7FAE83FC5CCC /* FinancialConnectionsWebFlowViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsWebFlowViewController.swift; sourceTree = ""; }; + 3ED33E6BADC0893C3F6B22D2 /* AccountPickerHelpersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPickerHelpersTests.swift; sourceTree = ""; }; + 3FD9739F1AA7CBA76DD3E1E2 /* FinancialConnectionsConsent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsConsent.swift; sourceTree = ""; }; + 40ECB2B008FC082B4D38D2FE /* NetworkingLinkSignupBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingLinkSignupBodyView.swift; sourceTree = ""; }; + 429F985168AE9F9D700AE37B /* FinancialConnectionsSessionManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsSessionManifest.swift; sourceTree = ""; }; + 43A7913F446F111887F1FA01 /* ManualEntrySuccessViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntrySuccessViewController.swift; sourceTree = ""; }; + 452989E2D269784006EFD18C /* FinancialConnectionsPartnerAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsPartnerAccount.swift; sourceTree = ""; }; + 463549CECD379484842033E3 /* AccountPickerAccountLoadErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPickerAccountLoadErrorView.swift; sourceTree = ""; }; + 4667E3861CDEC3A41B757714 /* FinancialConnectionsSynchronize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsSynchronize.swift; sourceTree = ""; }; + 4A7B146AA6BF44921A249DB8 /* EmptyFinancialConnectionsAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyFinancialConnectionsAPIClient.swift; sourceTree = ""; }; + 4AFBF95DAE0783010A17EB58 /* FinancialConnectionsSession_only_accounts.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = FinancialConnectionsSession_only_accounts.json; sourceTree = ""; }; + 4BFCD9C339634B71FC8F85E9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 4C15A30C40F34CE330F89C41 /* SuccessDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuccessDataSource.swift; sourceTree = ""; }; + 4C4EDFBDA8D9C0CEA30B51F2 /* SuccessAccountListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuccessAccountListView.swift; sourceTree = ""; }; + 4DA1C1B311E06C1165C6F6A2 /* InstitutionSearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstitutionSearchBar.swift; sourceTree = ""; }; + 4E2EAD7059FF8358E674774A /* FinancialConnectionsAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsAPIClient.swift; sourceTree = ""; }; + 4E4A84F0646AD673029CB6FC /* Button+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Button+Extensions.swift"; sourceTree = ""; }; + 4E7D318EE807701AB3FCA17D /* MarkdownBoldAttributedStringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownBoldAttributedStringTests.swift; sourceTree = ""; }; + 4F95EA3D421BE335087E05DB /* AccountPickerSelectionRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPickerSelectionRowView.swift; sourceTree = ""; }; + 50B4E948868910ADA557F50D /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; + 518F1F230FD4DF68E683C728 /* AttachLinkedPaymentAccountDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachLinkedPaymentAccountDataSource.swift; sourceTree = ""; }; + 51D8397DB43DEC09BDF66E8A /* FinancialConnectionsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsSheet.swift; sourceTree = ""; }; + 51EEC3A9E3BC863ED054B1DC /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; + 524D3116FDA5A3AD68075AA4 /* LinkAccountPickerDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkAccountPickerDataSource.swift; sourceTree = ""; }; + 5309724F4B9531EBBB03FD54 /* PrepaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrepaneView.swift; sourceTree = ""; }; + 54CF67A1F497E6CC73029CF0 /* StripeCore+Import.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StripeCore+Import.swift"; sourceTree = ""; }; + 57B289E803B7A53B000D7919 /* spinner@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spinner@3x.png"; sourceTree = ""; }; + 587CD174831344F15ADB538D /* ModalPresentationWrapperViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalPresentationWrapperViewController.swift; sourceTree = ""; }; + 591E8073D2AD30115ABDB60F /* BulletPointLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BulletPointLabelView.swift; sourceTree = ""; }; + 596A401ABA089532A5006584 /* AuthenticationSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationSessionManager.swift; sourceTree = ""; }; + 5AC5D8EE52FE5D305F78E3A0 /* nn-NO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "nn-NO"; path = "nn-NO.lproj/Localizable.strings"; sourceTree = ""; }; + 5B65388786D25271A87D34CE /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; + 5B77DE6D7A86CC847977396A /* check@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "check@3x.png"; sourceTree = ""; }; + 5C0737DE86515E172909366F /* FinancialConnectionsSheetError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsSheetError.swift; sourceTree = ""; }; + 5C09425306344278C7B55089 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 5C837C27C2577391B91FF0E5 /* FinancialConnectionsAuthSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsAuthSession.swift; sourceTree = ""; }; + 5D1C78684DD0B2D168C86229 /* ResetFlowDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetFlowDataSource.swift; sourceTree = ""; }; + 5D555DB0657A602274596428 /* warning_triangle@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "warning_triangle@3x.png"; sourceTree = ""; }; + 5E48DB3155C1546B196DF97B /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; + 5E53077F6CE2AF59B9BCB4EF /* SuccessBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuccessBodyView.swift; sourceTree = ""; }; + 5F856808B78F4F1975959805 /* NativeFlowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeFlowController.swift; sourceTree = ""; }; + 606B3546B822EE9F9F868B8C /* LinkAccountPickerRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkAccountPickerRowView.swift; sourceTree = ""; }; + 60B7CFC14964440E8AA670A9 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; + 6224E799E667DF223757D493 /* AccountNumberRetrievalErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountNumberRetrievalErrorView.swift; sourceTree = ""; }; + 6516BB12D029D1DDCBA1534D /* ManualEntryTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryTextField.swift; sourceTree = ""; }; + 65BCC4356AE3295B4A2F4A28 /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = ""; }; + 6652EBE38C47B36962AD370A /* StripeUICore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeUICore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 66D2857E68EA69AC6F658BEA /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Localizable.strings"; sourceTree = ""; }; + 66D3CAB53EC9D33831C5A48B /* NetworkingSaveToLinkVerificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingSaveToLinkVerificationViewController.swift; sourceTree = ""; }; + 6A764CF4DB5B5F6F488132A8 /* generic_error@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "generic_error@3x.png"; sourceTree = ""; }; + 6B70A0C4DBFE46805549CF8B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + 6C81D547F6BAD96C62E1E4D3 /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/Localizable.strings; sourceTree = ""; }; + 6CDEF702710EEA29BA3DC653 /* NetworkingLinkSignupViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingLinkSignupViewController.swift; sourceTree = ""; }; + 6D18A6D99669DFF1E91A0705 /* bank_check@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "bank_check@2x.png"; sourceTree = ""; }; + 6DA6A57067FB5EF86FEBD5B3 /* FinancialConnectionsNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsNavigationController.swift; sourceTree = ""; }; + 6DDB75C69FE9322C745943B3 /* NetworkingLinkVerificationDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingLinkVerificationDataSource.swift; sourceTree = ""; }; + 6E2D765AC793D89D26B74FC4 /* STPLocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPLocalizedString.swift; sourceTree = ""; }; + 6F1C8781095363382A0F7BE5 /* ReusableInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableInformationView.swift; sourceTree = ""; }; + 6FA941D5F7178E89DE70076F /* ConsentBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentBodyView.swift; sourceTree = ""; }; + 710183EE587F6FDA077FC150 /* APIPollingHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIPollingHelperTests.swift; sourceTree = ""; }; + 723996D53965EC2286267A01 /* SpinnerIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinnerIconView.swift; sourceTree = ""; }; + 72A74B9F667A3BF48253045E /* NetworkingLinkSignupFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingLinkSignupFooterView.swift; sourceTree = ""; }; + 72DEDE8871A732D603E96E2B /* AccountPickerSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPickerSelectionView.swift; sourceTree = ""; }; + 735101D3516EE679A29AE6D0 /* InstitutionSearchTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstitutionSearchTableViewCell.swift; sourceTree = ""; }; + 73AB8A480620B5C3567F453C /* FinancialConnectionsAnalyticsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsAnalyticsTest.swift; sourceTree = ""; }; + 73C03F4DDC67B50C5E1993F6 /* ManualEntryFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryFormView.swift; sourceTree = ""; }; + 7413C9D80DF7190CA6FB82EE /* brandicon_default@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "brandicon_default@3x.png"; sourceTree = ""; }; + 742D94AC4B2D17F8282A6788 /* fil */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fil; path = fil.lproj/Localizable.strings; sourceTree = ""; }; + 7455C145AEAE3D5F87532187 /* SuccessIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuccessIconView.swift; sourceTree = ""; }; + 77A71EBB1B98CD285DD17D5F /* AccountFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFetcherTests.swift; sourceTree = ""; }; + 77AE2036F58EE5C098C1B25B /* Locale+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locale+Extensions.swift"; sourceTree = ""; }; + 780BC432329228B042DA97D8 /* NativeFlowDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeFlowDataManager.swift; sourceTree = ""; }; + 782A419DCF59BE6AB6439D04 /* add@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "add@3x.png"; sourceTree = ""; }; + 7A4D5A45DB15DF6148F1C85A /* RadioButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButtonView.swift; sourceTree = ""; }; + 7AFC0D3ED86914DC4216CCCA /* AuthFlowHelpersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthFlowHelpersTests.swift; sourceTree = ""; }; + 7BD84F72112C7434B1E25D09 /* ellipsis@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "ellipsis@3x.png"; sourceTree = ""; }; + 7C2E9D1B7C962C46F7E0002A /* ExperimentHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentHelper.swift; sourceTree = ""; }; + 7C402C24A15DC6167E2C593F /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; + 7F3A660CB2E9651947FE6D0A /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; + 81304AD5BE5CCA10D1A866E0 /* ConsentDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentDataSource.swift; sourceTree = ""; }; + 8199AF9CE999D21ED5871D96 /* ConsentBottomSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentBottomSheetView.swift; sourceTree = ""; }; + 8301F7BA1FF90D131AE96E10 /* ManualEntryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryViewController.swift; sourceTree = ""; }; + 83E0A15A666F20DA97F128EA /* FinancialConnectionsFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsFont.swift; sourceTree = ""; }; + 846D9EF58B02C69F9629AE79 /* ms-MY */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ms-MY"; path = "ms-MY.lproj/Localizable.strings"; sourceTree = ""; }; + 88F7731972F5FB12FD4FA48B /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + 8B3BF292FD82A198752A82EB /* InstitutionPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstitutionPickerViewController.swift; sourceTree = ""; }; + 8FE8BCF5A9FF2B9392A755EA /* FinancialConnectionsInstitution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsInstitution.swift; sourceTree = ""; }; + 90A06A97D714450321A5D76D /* SoftLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLinkTests.swift; sourceTree = ""; }; + 912E3AA36B68492A69019AEA /* AccountPickerNoAccountEligibleErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPickerNoAccountEligibleErrorView.swift; sourceTree = ""; }; + 921686D9A3076749E1A9E549 /* FinancialConnectionsInstitutionSearchResultResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsInstitutionSearchResultResource.swift; sourceTree = ""; }; + 925B7F2CBACCB2346CD0CDFC /* NetworkingLinkStepUpVerificationDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingLinkStepUpVerificationDataSource.swift; sourceTree = ""; }; + 9312AAE1BFF1D9BBEA44E8AA /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; + 939D10B20D3ECA7BF7021BF8 /* InstitutionIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstitutionIconView.swift; sourceTree = ""; }; + 93C4ECB724BD75320A999C42 /* HostController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostController.swift; sourceTree = ""; }; + 947F7F2C282A37EC4AB119E0 /* FeaturedInstitutionGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedInstitutionGridView.swift; sourceTree = ""; }; + 94869BACB486153419B30DE5 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; + 956668158B27ACD34A6B2657 /* warning_circle@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "warning_circle@3x.png"; sourceTree = ""; }; + 965814B0C5F3D13158E610E3 /* SessionFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionFetcherTests.swift; sourceTree = ""; }; + 97948A848C99ACD1A498F841 /* AttributedTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedTextView.swift; sourceTree = ""; }; + 97CE72E38D41E86E0A1FAE9F /* AccountPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPickerViewController.swift; sourceTree = ""; }; + 97EE2BCD7B861ACA49DB56CD /* CheckboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxView.swift; sourceTree = ""; }; + 9ACAB1B6DB88D74F5ECC1C6D /* AccountPickerFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPickerFooterView.swift; sourceTree = ""; }; + 9CBF53BDBB2CDC5908C374D7 /* NetworkingLinkVerificationBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingLinkVerificationBodyView.swift; sourceTree = ""; }; + 9D8F7A75976314427E8087F9 /* LinkingAccountsLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkingAccountsLoadingView.swift; sourceTree = ""; }; + 9E337C061E4152E7814FC21E /* ConsentBottomSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentBottomSheetViewController.swift; sourceTree = ""; }; + 9EA0AA05BC9FC60A06AC1B5E /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; + 9ED1CF95D441821773EA68EE /* InstitutionDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstitutionDataSource.swift; sourceTree = ""; }; + A00D518C15FF3FAED7C193C2 /* HitTestStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTestStackView.swift; sourceTree = ""; }; + A0863AF9E2F9BD7C026FE59E /* HostViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostViewController.swift; sourceTree = ""; }; + A37D7E687494FAE048945144 /* sk-SK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sk-SK"; path = "sk-SK.lproj/Localizable.strings"; sourceTree = ""; }; + A3A2815DF2EE9447CE7A3826 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; + A4CC00446B086B2987114099 /* cancel_circle@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "cancel_circle@3x.png"; sourceTree = ""; }; + A54F545087F12B09FF416991 /* PrepaneImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrepaneImageView.swift; sourceTree = ""; }; + A6038978C79785C18257CD74 /* FinancialConnectionsSheetAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsSheetAnalytics.swift; sourceTree = ""; }; + A680CB323B9139F838643EC1 /* LinkAccountPickerFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkAccountPickerFooterView.swift; sourceTree = ""; }; + A872C2B500306F775622F904 /* FinancialConnectionsSessionFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsSessionFetcher.swift; sourceTree = ""; }; + A9B31B3A45DD8AAFD6F08820 /* AuthFlowHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthFlowHelpers.swift; sourceTree = ""; }; + A9F9ADD5550140F5D763596A /* arrow_right@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "arrow_right@3x.png"; sourceTree = ""; }; + AA01BC4016BF8788633CCAD9 /* FinancialConnectionsSession_only_la.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = FinancialConnectionsSession_only_la.json; sourceTree = ""; }; + AC4BABB6C871FE97868767E7 /* FeaturedInstitutionGridCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedInstitutionGridCell.swift; sourceTree = ""; }; + AC7FED22D9EAC568EA6B35EB /* cs-CZ */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "cs-CZ"; path = "cs-CZ.lproj/Localizable.strings"; sourceTree = ""; }; + ACEF0BAF1A5BBA3061C15A09 /* FinancialConnectionsSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsSession.swift; sourceTree = ""; }; + AFF070262170A321F5622CCF /* LinkAccountPickerNewAccountRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkAccountPickerNewAccountRowView.swift; sourceTree = ""; }; + B2B74140FCD8F5871F42C881 /* ro-RO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ro-RO"; path = "ro-RO.lproj/Localizable.strings"; sourceTree = ""; }; + B3FD6A7D1638E42AA00C88C4 /* FinancialConnectionsBulletPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsBulletPoint.swift; sourceTree = ""; }; + B4F56BF50DBF4A353D2526A6 /* stripe_logo@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "stripe_logo@3x.png"; sourceTree = ""; }; + B52F27D0FACAE9D4F4D15A73 /* UITableView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+Extensions.swift"; sourceTree = ""; }; + B5FFA1B806BC6AD3500B0567 /* Project-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Release.xcconfig"; sourceTree = ""; }; + B83A2749140B4E129CEF39C4 /* TerminalErrorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalErrorViewController.swift; sourceTree = ""; }; + BC4D2368AC577A5233DEC72C /* lv-LV */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "lv-LV"; path = "lv-LV.lproj/Localizable.strings"; sourceTree = ""; }; + BD2284A0556400DEAEB279BE /* AlwaysTemplateImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlwaysTemplateImageView.swift; sourceTree = ""; }; + BD4C39F5F9AF440B13F51A81 /* ManualEntryDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryDataSource.swift; sourceTree = ""; }; + BF7E41313B709F87B549D85F /* UIViewController+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Extensions.swift"; sourceTree = ""; }; + C0430E0E195DD128FA2D5F86 /* NetworkingOTPDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingOTPDataSource.swift; sourceTree = ""; }; + C0467CE507A92557C72885DF /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; + C163A50C423B593C7F620630 /* PlaceholderViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderViewController.swift; sourceTree = ""; }; + C2710EA52082B4A983294567 /* Placeholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Placeholder.swift; sourceTree = ""; }; + C2922EAE205D76B40ED7FC92 /* ManualEntryFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryFooterView.swift; sourceTree = ""; }; + C3BDFEB5860F73D4CD90907A /* NetworkingSaveToLinkVerificationDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingSaveToLinkVerificationDataSource.swift; sourceTree = ""; }; + C4DA5D0EB4ED760B3F9818C5 /* back_arrow@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "back_arrow@3x.png"; sourceTree = ""; }; + C650E82A4195A7566AA54298 /* StripeCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C71BC4222E1986338BCB9587 /* InstitutionSearchTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstitutionSearchTableView.swift; sourceTree = ""; }; + C88FD9148F64D2AA8989D361 /* ManualEntryErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryErrorView.swift; sourceTree = ""; }; + C8AFA09E86048B4325C36CC8 /* lt-LT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "lt-LT"; path = "lt-LT.lproj/Localizable.strings"; sourceTree = ""; }; + C93F7139E9BFB044902962D0 /* FinancialConnectionsImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsImage.swift; sourceTree = ""; }; + C9A22DBD7D336381FEB3AB00 /* ManualEntryCheckView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryCheckView.swift; sourceTree = ""; }; + CA2DA47ECE153F888FA675CE /* StripeiOS Tests-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS Tests-Debug.xcconfig"; sourceTree = ""; }; + CB3C49A180D1697B03C79A59 /* UIViewController+KeyboardAvoiding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+KeyboardAvoiding.swift"; sourceTree = ""; }; + CDD861E4EB8BA294545B7651 /* NetworkingLinkVerificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingLinkVerificationViewController.swift; sourceTree = ""; }; + CE10909F3FC7D60E13B65226 /* et-EE */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "et-EE"; path = "et-EE.lproj/Localizable.strings"; sourceTree = ""; }; + CEC1BC95816DAD5AE9680662 /* FinancialConnectionsAccountFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsAccountFetcher.swift; sourceTree = ""; }; + CF731140836AE438C7F4F6AB /* StringExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtensionsTests.swift; sourceTree = ""; }; + CF7CE16FE4D5E8B889BF5D1E /* FinancialConnectionsNetworkingLinkSignup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsNetworkingLinkSignup.swift; sourceTree = ""; }; + CF80A9614EB3ADA9E81397F8 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/Localizable.strings"; sourceTree = ""; }; + CFEEBA73EBBCE02A50B2DB7A /* StripeFinancialConnectionsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StripeFinancialConnectionsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + D2C62B6AA6891A4214E0754E /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; + D2EA7801B85973F10F65DDB6 /* UIImage+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Extensions.swift"; sourceTree = ""; }; + D3BF4CD26CEAE792AC2A7313 /* FinancialConnectionsMixedOAuthParams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsMixedOAuthParams.swift; sourceTree = ""; }; + D688616FDC435586025D2023 /* NetworkingLinkSignupDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingLinkSignupDataSource.swift; sourceTree = ""; }; + D715516C6703A780913E66EB /* AccountPickerDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPickerDataSource.swift; sourceTree = ""; }; + D890BD770F4E33D23ABA37EA /* BankAccountToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BankAccountToken.swift; sourceTree = ""; }; + D8A3C86CF29F533F87C7DFD6 /* NetworkingOTPView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingOTPView.swift; sourceTree = ""; }; + DBBF5CEE2C9030B2D374BC76 /* FinancialConnectionsAnalyticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsAnalyticsClient.swift; sourceTree = ""; }; + DC2F211E0EBE8D6AF164F0FA /* LinkEmailElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkEmailElement.swift; sourceTree = ""; }; + DCC5894A5EB74F6157C7DE95 /* FinancialConnectionsOAuthPrepane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsOAuthPrepane.swift; sourceTree = ""; }; + DD828AB80DE41DE11D38AF5C /* SuccessFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuccessFooterView.swift; sourceTree = ""; }; + DD9E2537472B2ED4AA3ED6A2 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; + DDC3AC48492FAB61E5B66D94 /* UIImageView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImageView+Extensions.swift"; sourceTree = ""; }; + DFA81F9910A88A7DEB0F0BFD /* ResetFlowViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetFlowViewController.swift; sourceTree = ""; }; + E05B47C10B77812660F7B01A /* String+Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Localized.swift"; sourceTree = ""; }; + E05C2C5CDAA55CE700662040 /* FinancialConnectionsSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsSessionTests.swift; sourceTree = ""; }; + E37D8CE9CD73443A9AAF2AE8 /* StripeFinancialConnections.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeFinancialConnections.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + E813BE6B34901E4E050FFE13 /* TimeInterval+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Extensions.swift"; sourceTree = ""; }; + E898E7D173685669E31FC58F /* bg-BG */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "bg-BG"; path = "bg-BG.lproj/Localizable.strings"; sourceTree = ""; }; + E90CF6AD88E530CE63D57269 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; + EA3F36F0BED21F607C546B6D /* bank@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "bank@3x.png"; sourceTree = ""; }; + EA55452373FD735983F3690B /* ContinueStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueStateView.swift; sourceTree = ""; }; + EAC8B933341AF86D9AFF5979 /* PartnerAuthDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PartnerAuthDataSource.swift; sourceTree = ""; }; + EB3F67BE6E46ED018EB8C3FD /* SuccessViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuccessViewController.swift; sourceTree = ""; }; + EC561AF0993C02AD68472D11 /* chevron_down@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "chevron_down@3x.png"; sourceTree = ""; }; + EE64D5A17913CF4AAD855A9A /* AttributedLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedLabel.swift; sourceTree = ""; }; + EE6D662AB9854F3BDB90D8FD /* NetworkingLinkStepUpVerificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingLinkStepUpVerificationViewController.swift; sourceTree = ""; }; + EF0111A8932418631FFA1663 /* search@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "search@3x.png"; sourceTree = ""; }; + EF9E2D4C9D7684B02AD6037A /* FinancialConnectionsPaymentAccountResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsPaymentAccountResource.swift; sourceTree = ""; }; + EFB09DF9C9434032F387E081 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; + F1C7A2FE53419CB29CBB6C08 /* close@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "close@3x.png"; sourceTree = ""; }; + F25B2AB87C9548245C28D14C /* mt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mt; path = mt.lproj/Localizable.strings; sourceTree = ""; }; + F2B7ECC6F6A4DA1F5F376467 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; + F2BA0F04A5A7D3B1DBF34AEE /* pl-PL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pl-PL"; path = "pl-PL.lproj/Localizable.strings"; sourceTree = ""; }; + F560A66FD9A35762F884F2D4 /* InstitutionSearchFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstitutionSearchFooterView.swift; sourceTree = ""; }; + F669BB8F3DA862C425897705 /* HitTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTestView.swift; sourceTree = ""; }; + F6CF7F1005B57D566E139DE3 /* FinancialConnectionsSession_both_accounts_la.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = FinancialConnectionsSession_both_accounts_la.json; sourceTree = ""; }; + F897F5AA6A684D0A370EA7BC /* ManualEntryValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryValidator.swift; sourceTree = ""; }; + F8C7FDF59D906EA5C6B7A514 /* ManualEntryValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryValidatorTests.swift; sourceTree = ""; }; + F96A3BA5CCB8DCCFA3126974 /* StripeFinancialConnections.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StripeFinancialConnections.h; sourceTree = ""; }; + F9A847D2AAA7271F507DC9F3 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; + F9AB787FE87EDD702B1BBF09 /* StripeCoreTestUtils.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCoreTestUtils.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + FA62F9B77C6A1D1B12F02CF5 /* AttachLinkedPaymentAccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachLinkedPaymentAccountViewController.swift; sourceTree = ""; }; + FB4BBD3A262039B34C2DDCCB /* CloseConfirmationAlertHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseConfirmationAlertHandler.swift; sourceTree = ""; }; + FBC23DAC91962D0D8A713D37 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = ""; }; + FD137B9465B0DB96AED0AC98 /* NetworkingSaveToLinkFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingSaveToLinkFooterView.swift; sourceTree = ""; }; + FF54EDA6123C7E4E78D9D56B /* APIPollingHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIPollingHelper.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13EF25670CFA2AE22BD37D31 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3BF4BBF7E722B961E037286C /* XCTest.framework in Frameworks */, + 9CE29EA549C4BFA447AB82E0 /* StripeCore.framework in Frameworks */, + D0C6D94867FA04B1BF80D56D /* StripeCoreTestUtils.framework in Frameworks */, + 864C5159C62C562C655B53F7 /* StripeFinancialConnections.framework in Frameworks */, + 971E6F5E78BC3265CD80D0C6 /* StripeUICore.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 618850E963EE4CF3E0EFE1FD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 06445472B3008395FCA92FEC /* StripeCore.framework in Frameworks */, + D50E771043434AD80EA28628 /* StripeUICore.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0436E20F0A78D3A243CF7761 /* Images */ = { + isa = PBXGroup; + children = ( + 782A419DCF59BE6AB6439D04 /* add@3x.png */, + A9F9ADD5550140F5D763596A /* arrow_right@3x.png */, + C4DA5D0EB4ED760B3F9818C5 /* back_arrow@3x.png */, + 6D18A6D99669DFF1E91A0705 /* bank_check@2x.png */, + EA3F36F0BED21F607C546B6D /* bank@3x.png */, + 7413C9D80DF7190CA6FB82EE /* brandicon_default@3x.png */, + 07BCA9D23511A3494C82B632 /* bullet@3x.png */, + A4CC00446B086B2987114099 /* cancel_circle@3x.png */, + 5B77DE6D7A86CC847977396A /* check@3x.png */, + EC561AF0993C02AD68472D11 /* chevron_down@3x.png */, + F1C7A2FE53419CB29CBB6C08 /* close@3x.png */, + 7BD84F72112C7434B1E25D09 /* ellipsis@3x.png */, + 6A764CF4DB5B5F6F488132A8 /* generic_error@3x.png */, + 1AD7571981F0FE2F10F68530 /* prepane_phone_background@3x.png */, + EF0111A8932418631FFA1663 /* search@3x.png */, + 57B289E803B7A53B000D7919 /* spinner@3x.png */, + B4F56BF50DBF4A353D2526A6 /* stripe_logo@3x.png */, + 956668158B27ACD34A6B2657 /* warning_circle@3x.png */, + 5D555DB0657A602274596428 /* warning_triangle@3x.png */, + ); + path = Images; + sourceTree = ""; + }; + 132A42D1B9A52681405D214A /* BuildConfigurations */ = { + isa = PBXGroup; + children = ( + 2C10E841FF9EBFEA8C2E30AF /* Project-Debug.xcconfig */, + B5FFA1B806BC6AD3500B0567 /* Project-Release.xcconfig */, + CA2DA47ECE153F888FA675CE /* StripeiOS Tests-Debug.xcconfig */, + 14CED33665ED3D8EE8D5D7B7 /* StripeiOS Tests-Release.xcconfig */, + 314462DF7856349FF9775598 /* StripeiOS-Debug.xcconfig */, + 1CD19E0601599AE89976DB4D /* StripeiOS-Release.xcconfig */, + ); + name = BuildConfigurations; + path = ../BuildConfigurations; + sourceTree = ""; + }; + 24D2905794F8E635EEDEC0D8 /* NetworkingLinkVerification */ = { + isa = PBXGroup; + children = ( + 9CBF53BDBB2CDC5908C374D7 /* NetworkingLinkVerificationBodyView.swift */, + 6DDB75C69FE9322C745943B3 /* NetworkingLinkVerificationDataSource.swift */, + CDD861E4EB8BA294545B7651 /* NetworkingLinkVerificationViewController.swift */, + ); + path = NetworkingLinkVerification; + sourceTree = ""; + }; + 2530BFFBB1E845D2AA698BFC /* Placeholder */ = { + isa = PBXGroup; + children = ( + C163A50C423B593C7F620630 /* PlaceholderViewController.swift */, + ); + path = Placeholder; + sourceTree = ""; + }; + 266BB00CA59B6EBADFAD798F /* NetworkingLinkStepUpVerification */ = { + isa = PBXGroup; + children = ( + 1F9D6F0CC79B7949D037DE66 /* NetworkingLinkStepUpVerificationBodyView.swift */, + 925B7F2CBACCB2346CD0CDFC /* NetworkingLinkStepUpVerificationDataSource.swift */, + EE6D662AB9854F3BDB90D8FD /* NetworkingLinkStepUpVerificationViewController.swift */, + ); + path = NetworkingLinkStepUpVerification; + sourceTree = ""; + }; + 298B4CECCC54664B4997B9D7 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 94869BACB486153419B30DE5 /* XCTest.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 32249762D11692D5B34BBF38 /* ConsumerSession */ = { + isa = PBXGroup; + children = ( + 08C32F1447E47221DC0B7095 /* ConsumerSessionModels.swift */, + ); + path = ConsumerSession; + sourceTree = ""; + }; + 328390D72E3911449BB9FD0B /* Analytics */ = { + isa = PBXGroup; + children = ( + DBBF5CEE2C9030B2D374BC76 /* FinancialConnectionsAnalyticsClient.swift */, + A6038978C79785C18257CD74 /* FinancialConnectionsSheetAnalytics.swift */, + ); + path = Analytics; + sourceTree = ""; + }; + 35190A546A00D11AB281556E /* API Bindings */ = { + isa = PBXGroup; + children = ( + 637114D9B91F9206B6F6709B /* Models */, + FF54EDA6123C7E4E78D9D56B /* APIPollingHelper.swift */, + 2D95E5F34BDEE0237F52DA0A /* APIVersion.swift */, + 4E2EAD7059FF8358E674774A /* FinancialConnectionsAPIClient.swift */, + ); + path = "API Bindings"; + sourceTree = ""; + }; + 3ADD22C8436381E94908DA82 /* Success */ = { + isa = PBXGroup; + children = ( + 4C4EDFBDA8D9C0CEA30B51F2 /* SuccessAccountListView.swift */, + 5E53077F6CE2AF59B9BCB4EF /* SuccessBodyView.swift */, + 4C15A30C40F34CE330F89C41 /* SuccessDataSource.swift */, + DD828AB80DE41DE11D38AF5C /* SuccessFooterView.swift */, + EB3F67BE6E46ED018EB8C3FD /* SuccessViewController.swift */, + ); + path = Success; + sourceTree = ""; + }; + 45F5EA9A9A1DEBC1EC05937F /* FinancialConnectionsSDK */ = { + isa = PBXGroup; + children = ( + 248D51F7AADE404E49957DDA /* FinancialConnectionsSDKImplementation.swift */, + ); + path = FinancialConnectionsSDK; + sourceTree = ""; + }; + 53C3F536D69FCBC77C3E7D5F /* Common */ = { + isa = PBXGroup; + children = ( + 7C2E9D1B7C962C46F7E0002A /* ExperimentHelper.swift */, + 1F2C7AE6509C80B6F30662AA /* FinancialConnectionsCustomManualEntryRequiredError.swift */, + 6DA6A57067FB5EF86FEBD5B3 /* FinancialConnectionsNavigationController.swift */, + 1407DD9E95ADFE143FA046E4 /* FlowRouter.swift */, + 93C4ECB724BD75320A999C42 /* HostController.swift */, + A0863AF9E2F9BD7C026FE59E /* HostViewController.swift */, + 60B7CFC14964440E8AA670A9 /* LoadingView.swift */, + 587CD174831344F15ADB538D /* ModalPresentationWrapperViewController.swift */, + ); + path = Common; + sourceTree = ""; + }; + 541D32C4B0F1635A97F4FE11 /* Web */ = { + isa = PBXGroup; + children = ( + 596A401ABA089532A5006584 /* AuthenticationSessionManager.swift */, + EA55452373FD735983F3690B /* ContinueStateView.swift */, + CEC1BC95816DAD5AE9680662 /* FinancialConnectionsAccountFetcher.swift */, + A872C2B500306F775622F904 /* FinancialConnectionsSessionFetcher.swift */, + 3BD07CE6F99D7FAE83FC5CCC /* FinancialConnectionsWebFlowViewController.swift */, + ); + path = Web; + sourceTree = ""; + }; + 5A844A24E4E9F4B7E3802DA9 /* AccountPicker */ = { + isa = PBXGroup; + children = ( + 463549CECD379484842033E3 /* AccountPickerAccountLoadErrorView.swift */, + D715516C6703A780913E66EB /* AccountPickerDataSource.swift */, + 9ACAB1B6DB88D74F5ECC1C6D /* AccountPickerFooterView.swift */, + 1362591D12A04CA663A69A47 /* AccountPickerHelpers.swift */, + 2323E76B1B9C295DC5EAD53E /* AccountPickerLabelRowView.swift */, + 912E3AA36B68492A69019AEA /* AccountPickerNoAccountEligibleErrorView.swift */, + 283469CD0298E3AFCFDAF10F /* AccountPickerSelectionListView.swift */, + 4F95EA3D421BE335087E05DB /* AccountPickerSelectionRowView.swift */, + 72DEDE8871A732D603E96E2B /* AccountPickerSelectionView.swift */, + 97CE72E38D41E86E0A1FAE9F /* AccountPickerViewController.swift */, + 97EE2BCD7B861ACA49DB56CD /* CheckboxView.swift */, + 9D8F7A75976314427E8087F9 /* LinkingAccountsLoadingView.swift */, + 7A4D5A45DB15DF6148F1C85A /* RadioButtonView.swift */, + ); + path = AccountPicker; + sourceTree = ""; + }; + 605E7A0FA65A081265FA6F54 /* InstitutionPicker */ = { + isa = PBXGroup; + children = ( + AC4BABB6C871FE97868767E7 /* FeaturedInstitutionGridCell.swift */, + 947F7F2C282A37EC4AB119E0 /* FeaturedInstitutionGridView.swift */, + 9ED1CF95D441821773EA68EE /* InstitutionDataSource.swift */, + 8B3BF292FD82A198752A82EB /* InstitutionPickerViewController.swift */, + 4DA1C1B311E06C1165C6F6A2 /* InstitutionSearchBar.swift */, + F560A66FD9A35762F884F2D4 /* InstitutionSearchFooterView.swift */, + C71BC4222E1986338BCB9587 /* InstitutionSearchTableView.swift */, + 735101D3516EE679A29AE6D0 /* InstitutionSearchTableViewCell.swift */, + ); + path = InstitutionPicker; + sourceTree = ""; + }; + 637114D9B91F9206B6F6709B /* Models */ = { + isa = PBXGroup; + children = ( + 32249762D11692D5B34BBF38 /* ConsumerSession */, + D890BD770F4E33D23ABA37EA /* BankAccountToken.swift */, + 359BF8ACFB35A16EBD96C4F0 /* FinancialConnectionsAccount.swift */, + 5C837C27C2577391B91FF0E5 /* FinancialConnectionsAuthSession.swift */, + B3FD6A7D1638E42AA00C88C4 /* FinancialConnectionsBulletPoint.swift */, + 3FD9739F1AA7CBA76DD3E1E2 /* FinancialConnectionsConsent.swift */, + 32ED8A7E94822F14AD94A698 /* FinancialConnectionsDataAccessNotice.swift */, + C93F7139E9BFB044902962D0 /* FinancialConnectionsImage.swift */, + 8FE8BCF5A9FF2B9392A755EA /* FinancialConnectionsInstitution.swift */, + 921686D9A3076749E1A9E549 /* FinancialConnectionsInstitutionSearchResultResource.swift */, + 07526E95D85120F6492E78AE /* FinancialConnectionsLegalDetailsNotice.swift */, + D3BF4CD26CEAE792AC2A7313 /* FinancialConnectionsMixedOAuthParams.swift */, + 3979E84D319D3ED1C3273D74 /* FinancialConnectionsNetworkedAccountsResponse.swift */, + CF7CE16FE4D5E8B889BF5D1E /* FinancialConnectionsNetworkingLinkSignup.swift */, + DCC5894A5EB74F6157C7DE95 /* FinancialConnectionsOAuthPrepane.swift */, + 452989E2D269784006EFD18C /* FinancialConnectionsPartnerAccount.swift */, + EF9E2D4C9D7684B02AD6037A /* FinancialConnectionsPaymentAccountResource.swift */, + 191760EFAA9154C1F168E1D2 /* FinancialConnectionsPaymentMethodType.swift */, + ACEF0BAF1A5BBA3061C15A09 /* FinancialConnectionsSession.swift */, + 429F985168AE9F9D700AE37B /* FinancialConnectionsSessionManifest.swift */, + 4667E3861CDEC3A41B757714 /* FinancialConnectionsSynchronize.swift */, + ); + path = Models; + sourceTree = ""; + }; + 65C5C08A61EC14B62EA32D0B /* Resources */ = { + isa = PBXGroup; + children = ( + 0436E20F0A78D3A243CF7761 /* Images */, + F2CF7FBAD6C8773D6E954126 /* Localizations */, + ); + path = Resources; + sourceTree = ""; + }; + 67E2F19935317C74CE2CEB8D /* LinkAccountPicker */ = { + isa = PBXGroup; + children = ( + 0DD5619FDD6C8D85FC352F99 /* LinkAccountPickerBodyView.swift */, + 524D3116FDA5A3AD68075AA4 /* LinkAccountPickerDataSource.swift */, + A680CB323B9139F838643EC1 /* LinkAccountPickerFooterView.swift */, + AFF070262170A321F5622CCF /* LinkAccountPickerNewAccountRowView.swift */, + 606B3546B822EE9F9F868B8C /* LinkAccountPickerRowView.swift */, + 1848547B588045C776236B3B /* LinkAccountPickerViewController.swift */, + ); + path = LinkAccountPicker; + sourceTree = ""; + }; + 7879CBA341D7E807714A831B = { + isa = PBXGroup; + children = ( + 7F765C8B9BCB2FA4A3334063 /* Project */, + 298B4CECCC54664B4997B9D7 /* Frameworks */, + 820BF9CF057CF92872BC3C15 /* Products */, + ); + sourceTree = ""; + }; + 78A335C396F4FDF349C7BD59 /* Source */ = { + isa = PBXGroup; + children = ( + 328390D72E3911449BB9FD0B /* Analytics */, + 35190A546A00D11AB281556E /* API Bindings */, + 53C3F536D69FCBC77C3E7D5F /* Common */, + 45F5EA9A9A1DEBC1EC05937F /* FinancialConnectionsSDK */, + DEC2827C937247DAA010F3D2 /* Helpers */, + BF0B937496C645C3ED6E265E /* Native */, + 541D32C4B0F1635A97F4FE11 /* Web */, + 51D8397DB43DEC09BDF66E8A /* FinancialConnectionsSheet.swift */, + 5C0737DE86515E172909366F /* FinancialConnectionsSheetError.swift */, + C2710EA52082B4A983294567 /* Placeholder.swift */, + 54CF67A1F497E6CC73029CF0 /* StripeCore+Import.swift */, + ); + path = Source; + sourceTree = ""; + }; + 7BFDCD3B61A38A4BA3466780 /* AttachLinkedPaymentAccount */ = { + isa = PBXGroup; + children = ( + 6224E799E667DF223757D493 /* AccountNumberRetrievalErrorView.swift */, + 518F1F230FD4DF68E683C728 /* AttachLinkedPaymentAccountDataSource.swift */, + FA62F9B77C6A1D1B12F02CF5 /* AttachLinkedPaymentAccountViewController.swift */, + ); + path = AttachLinkedPaymentAccount; + sourceTree = ""; + }; + 7E5623CA300ADBCBE8B33E69 /* NetworkingOTPView */ = { + isa = PBXGroup; + children = ( + C0430E0E195DD128FA2D5F86 /* NetworkingOTPDataSource.swift */, + D8A3C86CF29F533F87C7DFD6 /* NetworkingOTPView.swift */, + ); + path = NetworkingOTPView; + sourceTree = ""; + }; + 7F765C8B9BCB2FA4A3334063 /* Project */ = { + isa = PBXGroup; + children = ( + 132A42D1B9A52681405D214A /* BuildConfigurations */, + B099AE2E516197735F31B3D9 /* StripeFinancialConnections */, + EA8A954B6F8275294AD1D76F /* StripeFinancialConnectionsTests */, + ); + name = Project; + sourceTree = ""; + }; + 820BF9CF057CF92872BC3C15 /* Products */ = { + isa = PBXGroup; + children = ( + C650E82A4195A7566AA54298 /* StripeCore.framework */, + F9AB787FE87EDD702B1BBF09 /* StripeCoreTestUtils.framework */, + E37D8CE9CD73443A9AAF2AE8 /* StripeFinancialConnections.framework */, + CFEEBA73EBBCE02A50B2DB7A /* StripeFinancialConnectionsTests.xctest */, + 6652EBE38C47B36962AD370A /* StripeUICore.framework */, + ); + name = Products; + sourceTree = ""; + }; + 92A9D27306891BBCBC4DBF49 /* ManualEntry */ = { + isa = PBXGroup; + children = ( + C9A22DBD7D336381FEB3AB00 /* ManualEntryCheckView.swift */, + BD4C39F5F9AF440B13F51A81 /* ManualEntryDataSource.swift */, + C88FD9148F64D2AA8989D361 /* ManualEntryErrorView.swift */, + C2922EAE205D76B40ED7FC92 /* ManualEntryFooterView.swift */, + 73C03F4DDC67B50C5E1993F6 /* ManualEntryFormView.swift */, + 6516BB12D029D1DDCBA1534D /* ManualEntryTextField.swift */, + F897F5AA6A684D0A370EA7BC /* ManualEntryValidator.swift */, + 8301F7BA1FF90D131AE96E10 /* ManualEntryViewController.swift */, + ); + path = ManualEntry; + sourceTree = ""; + }; + 9D434FE45EB09749E475FED6 /* MockData */ = { + isa = PBXGroup; + children = ( + F6CF7F1005B57D566E139DE3 /* FinancialConnectionsSession_both_accounts_la.json */, + 4AFBF95DAE0783010A17EB58 /* FinancialConnectionsSession_only_accounts.json */, + 1DF07A1AAD6B39033F0B86FD /* FinancialConnectionsSession_only_both_missing.json */, + AA01BC4016BF8788633CCAD9 /* FinancialConnectionsSession_only_la.json */, + ); + path = MockData; + sourceTree = ""; + }; + A7767444AAEB7AB6E887A54D /* PartnerAuth */ = { + isa = PBXGroup; + children = ( + EAC8B933341AF86D9AFF5979 /* PartnerAuthDataSource.swift */, + 25BDE722D8A827955C3182E8 /* PartnerAuthViewController.swift */, + A54F545087F12B09FF416991 /* PrepaneImageView.swift */, + 5309724F4B9531EBBB03FD54 /* PrepaneView.swift */, + ); + path = PartnerAuth; + sourceTree = ""; + }; + ACC10582EEBD4F7003A8EA2E /* ManualEntrySuccess */ = { + isa = PBXGroup; + children = ( + 35882B0B77DB22CEEDDA8C3C /* ManualEntrySuccessTransactionTableView.swift */, + 43A7913F446F111887F1FA01 /* ManualEntrySuccessViewController.swift */, + ); + path = ManualEntrySuccess; + sourceTree = ""; + }; + B099AE2E516197735F31B3D9 /* StripeFinancialConnections */ = { + isa = PBXGroup; + children = ( + 65C5C08A61EC14B62EA32D0B /* Resources */, + 78A335C396F4FDF349C7BD59 /* Source */, + 1E80DD2D042B327D9756E083 /* Info.plist */, + F96A3BA5CCB8DCCFA3126974 /* StripeFinancialConnections.h */, + ); + path = StripeFinancialConnections; + sourceTree = ""; + }; + B5A3DB0705F83912097C39C9 /* NetworkingLinkLoginWarmup */ = { + isa = PBXGroup; + children = ( + 13983D90462EB946B2A178C6 /* NetworkingLinkLoginWarmupBodyView.swift */, + 091D43608583C7BE5E444C2C /* NetworkingLinkLoginWarmupDataSource.swift */, + 252DA1FE0574822605438AB4 /* NetworkingLinkLoginWarmupViewController.swift */, + ); + path = NetworkingLinkLoginWarmup; + sourceTree = ""; + }; + B71852C5A10D21D52A586721 /* NetworkingSaveToLinkVerification */ = { + isa = PBXGroup; + children = ( + 2BE553B86F5AC8B7D6190A04 /* NetworkingSaveToLinkBodyView.swift */, + FD137B9465B0DB96AED0AC98 /* NetworkingSaveToLinkFooterView.swift */, + C3BDFEB5860F73D4CD90907A /* NetworkingSaveToLinkVerificationDataSource.swift */, + 66D3CAB53EC9D33831C5A48B /* NetworkingSaveToLinkVerificationViewController.swift */, + ); + path = NetworkingSaveToLinkVerification; + sourceTree = ""; + }; + BD42562786EC2FC703C1B28E /* TerminalError */ = { + isa = PBXGroup; + children = ( + B83A2749140B4E129CEF39C4 /* TerminalErrorViewController.swift */, + ); + path = TerminalError; + sourceTree = ""; + }; + BF0B937496C645C3ED6E265E /* Native */ = { + isa = PBXGroup; + children = ( + 5A844A24E4E9F4B7E3802DA9 /* AccountPicker */, + 7BFDCD3B61A38A4BA3466780 /* AttachLinkedPaymentAccount */, + EB6632ED2E696DA6381326C0 /* Consent */, + 605E7A0FA65A081265FA6F54 /* InstitutionPicker */, + 67E2F19935317C74CE2CEB8D /* LinkAccountPicker */, + 92A9D27306891BBCBC4DBF49 /* ManualEntry */, + ACC10582EEBD4F7003A8EA2E /* ManualEntrySuccess */, + B5A3DB0705F83912097C39C9 /* NetworkingLinkLoginWarmup */, + F4F0179DDFE793873C22E918 /* NetworkingLinkSignupPane */, + 266BB00CA59B6EBADFAD798F /* NetworkingLinkStepUpVerification */, + 24D2905794F8E635EEDEC0D8 /* NetworkingLinkVerification */, + B71852C5A10D21D52A586721 /* NetworkingSaveToLinkVerification */, + A7767444AAEB7AB6E887A54D /* PartnerAuth */, + 2530BFFBB1E845D2AA698BFC /* Placeholder */, + C76F2B3F6D5AB3E54CE1C206 /* ResetFlow */, + C0C2FC181FFA71CFAA6F3148 /* Shared */, + 3ADD22C8436381E94908DA82 /* Success */, + BD42562786EC2FC703C1B28E /* TerminalError */, + 5F856808B78F4F1975959805 /* NativeFlowController.swift */, + 780BC432329228B042DA97D8 /* NativeFlowDataManager.swift */, + ); + path = Native; + sourceTree = ""; + }; + C0C2FC181FFA71CFAA6F3148 /* Shared */ = { + isa = PBXGroup; + children = ( + 7E5623CA300ADBCBE8B33E69 /* NetworkingOTPView */, + BD2284A0556400DEAEB279BE /* AlwaysTemplateImageView.swift */, + EE64D5A17913CF4AAD855A9A /* AttributedLabel.swift */, + 97948A848C99ACD1A498F841 /* AttributedTextView.swift */, + A9B31B3A45DD8AAFD6F08820 /* AuthFlowHelpers.swift */, + 591E8073D2AD30115ABDB60F /* BulletPointLabelView.swift */, + 4E4A84F0646AD673029CB6FC /* Button+Extensions.swift */, + FB4BBD3A262039B34C2DDCCB /* CloseConfirmationAlertHandler.swift */, + 1A80CA4A87A90D2E19262220 /* ConsentBottomSheetModel.swift */, + 8199AF9CE999D21ED5871D96 /* ConsentBottomSheetView.swift */, + 9E337C061E4152E7814FC21E /* ConsentBottomSheetViewController.swift */, + A00D518C15FF3FAED7C193C2 /* HitTestStackView.swift */, + F669BB8F3DA862C425897705 /* HitTestView.swift */, + 939D10B20D3ECA7BF7021BF8 /* InstitutionIconView.swift */, + 13A97EEE12DA8FB77D13C527 /* MerchantDataAccessView.swift */, + 34452D5FDC1ED566A13427FE /* PaneLayoutView.swift */, + 263817C1DB5311A4E99C11CE /* PaneWithCustomHeaderLayoutView.swift */, + 1FD362DF2D937D22E803C1DD /* PaneWithHeaderLayoutView.swift */, + 6F1C8781095363382A0F7BE5 /* ReusableInformationView.swift */, + 1D38B3C816EAB38AD242B064 /* SFSafariViewController+Extensions.swift */, + 723996D53965EC2286267A01 /* SpinnerIconView.swift */, + 7455C145AEAE3D5F87532187 /* SuccessIconView.swift */, + E813BE6B34901E4E050FFE13 /* TimeInterval+Extensions.swift */, + D2EA7801B85973F10F65DDB6 /* UIImage+Extensions.swift */, + DDC3AC48492FAB61E5B66D94 /* UIImageView+Extensions.swift */, + B52F27D0FACAE9D4F4D15A73 /* UITableView+Extensions.swift */, + CB3C49A180D1697B03C79A59 /* UIViewController+KeyboardAvoiding.swift */, + ); + path = Shared; + sourceTree = ""; + }; + C76F2B3F6D5AB3E54CE1C206 /* ResetFlow */ = { + isa = PBXGroup; + children = ( + 5D1C78684DD0B2D168C86229 /* ResetFlowDataSource.swift */, + DFA81F9910A88A7DEB0F0BFD /* ResetFlowViewController.swift */, + ); + path = ResetFlow; + sourceTree = ""; + }; + DEC2827C937247DAA010F3D2 /* Helpers */ = { + isa = PBXGroup; + children = ( + 83E0A15A666F20DA97F128EA /* FinancialConnectionsFont.swift */, + 064D0E3A3AC71FAA60B54FC5 /* Helpers.swift */, + 65BCC4356AE3295B4A2F4A28 /* Image.swift */, + 77AE2036F58EE5C098C1B25B /* Locale+Extensions.swift */, + 267B3586136203186882F5CE /* NSAttributedString+Extensions.swift */, + 6E2D765AC793D89D26B74FC4 /* STPLocalizedString.swift */, + 7F3A660CB2E9651947FE6D0A /* String+Extensions.swift */, + E05B47C10B77812660F7B01A /* String+Localized.swift */, + 2C0F907D5B0AC58BE7A454BA /* StripeFinancialConnectionsBundleLocator.swift */, + FBC23DAC91962D0D8A713D37 /* UIColor+Extensions.swift */, + BF7E41313B709F87B549D85F /* UIViewController+Extensions.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + EA8A954B6F8275294AD1D76F /* StripeFinancialConnectionsTests */ = { + isa = PBXGroup; + children = ( + 9D434FE45EB09749E475FED6 /* MockData */, + 77A71EBB1B98CD285DD17D5F /* AccountFetcherTests.swift */, + 3ED33E6BADC0893C3F6B22D2 /* AccountPickerHelpersTests.swift */, + 710183EE587F6FDA077FC150 /* APIPollingHelperTests.swift */, + 7AFC0D3ED86914DC4216CCCA /* AuthFlowHelpersTests.swift */, + 4A7B146AA6BF44921A249DB8 /* EmptyFinancialConnectionsAPIClient.swift */, + 73AB8A480620B5C3567F453C /* FinancialConnectionsAnalyticsTest.swift */, + E05C2C5CDAA55CE700662040 /* FinancialConnectionsSessionTests.swift */, + 20E7725EF317C3BD62ADF845 /* FinancialConnectionsSheetTests.swift */, + 4BFCD9C339634B71FC8F85E9 /* Info.plist */, + F8C7FDF59D906EA5C6B7A514 /* ManualEntryValidatorTests.swift */, + 4E7D318EE807701AB3FCA17D /* MarkdownBoldAttributedStringTests.swift */, + 965814B0C5F3D13158E610E3 /* SessionFetcherTests.swift */, + 90A06A97D714450321A5D76D /* SoftLinkTests.swift */, + CF731140836AE438C7F4F6AB /* StringExtensionsTests.swift */, + ); + path = StripeFinancialConnectionsTests; + sourceTree = ""; + }; + EB6632ED2E696DA6381326C0 /* Consent */ = { + isa = PBXGroup; + children = ( + 6FA941D5F7178E89DE70076F /* ConsentBodyView.swift */, + 81304AD5BE5CCA10D1A866E0 /* ConsentDataSource.swift */, + 1E58AE51252DA4597DC82988 /* ConsentFooterView.swift */, + 0281E0221BDE01D0845DC0F9 /* ConsentLogoView.swift */, + 13AAB10AEE7A8EC5C9C53FFA /* ConsentViewController.swift */, + ); + path = Consent; + sourceTree = ""; + }; + F2CF7FBAD6C8773D6E954126 /* Localizations */ = { + isa = PBXGroup; + children = ( + BF6810BAADB14ACB95216C2B /* Localizable.strings */, + ); + path = Localizations; + sourceTree = ""; + }; + F4F0179DDFE793873C22E918 /* NetworkingLinkSignupPane */ = { + isa = PBXGroup; + children = ( + DC2F211E0EBE8D6AF164F0FA /* LinkEmailElement.swift */, + 041EFAE37D8F7E96DD4A4435 /* NetworkingLinkSignupBodyFormView.swift */, + 40ECB2B008FC082B4D38D2FE /* NetworkingLinkSignupBodyView.swift */, + D688616FDC435586025D2023 /* NetworkingLinkSignupDataSource.swift */, + 72A74B9F667A3BF48253045E /* NetworkingLinkSignupFooterView.swift */, + 6CDEF702710EEA29BA3DC653 /* NetworkingLinkSignupViewController.swift */, + ); + path = NetworkingLinkSignupPane; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + BD02401A40A42372CFD642EE /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + FCC5A360E0064887DB28F5C6 /* StripeFinancialConnections.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 44C90013B7C82C80A2F69956 /* StripeFinancialConnections */ = { + isa = PBXNativeTarget; + buildConfigurationList = DE1BF3F953C39B1173504C4A /* Build configuration list for PBXNativeTarget "StripeFinancialConnections" */; + buildPhases = ( + BD02401A40A42372CFD642EE /* Headers */, + 019D2A9648EC14A85E873494 /* Sources */, + CC61EC7C016C47747A2D7AB0 /* Resources */, + 6EAD6F45EDAE7B645FDC823B /* Embed Frameworks */, + 618850E963EE4CF3E0EFE1FD /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = StripeFinancialConnections; + productName = StripeFinancialConnections; + productReference = E37D8CE9CD73443A9AAF2AE8 /* StripeFinancialConnections.framework */; + productType = "com.apple.product-type.framework"; + }; + DF72D31B68363878FC1604CF /* StripeFinancialConnectionsTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8C428C73E0383F9203731DCB /* Build configuration list for PBXNativeTarget "StripeFinancialConnectionsTests" */; + buildPhases = ( + 0EF6A0BEC1B066774A0D985E /* Sources */, + 704BBAB23732F215CEEAD39C /* Resources */, + 4E8F557BB03B30AB6BCE5DAC /* Embed Frameworks */, + 13EF25670CFA2AE22BD37D31 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 3257A97C053FFC03C4806278 /* PBXTargetDependency */, + ); + name = StripeFinancialConnectionsTests; + productName = StripeFinancialConnectionsTests; + productReference = CFEEBA73EBBCE02A50B2DB7A /* StripeFinancialConnectionsTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 3D00B888AF0B02587576A83F /* Project object */ = { + isa = PBXProject; + attributes = { + TargetAttributes = { + }; + }; + buildConfigurationList = F7F4B17FFDBC5691F1A51423 /* Build configuration list for PBXProject "StripeFinancialConnections" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + "bg-BG", + "ca-ES", + "cs-CZ", + da, + de, + "el-GR", + en, + "en-GB", + es, + "es-419", + "et-EE", + fi, + fil, + fr, + "fr-CA", + hr, + hu, + id, + it, + ja, + ko, + "lt-LT", + "lv-LV", + "ms-MY", + mt, + nb, + nl, + "nn-NO", + "pl-PL", + "pt-BR", + "pt-PT", + "ro-RO", + ru, + "sk-SK", + "sl-SI", + sv, + tr, + vi, + "zh-HK", + "zh-Hans", + "zh-Hant", + ); + mainGroup = 7879CBA341D7E807714A831B; + productRefGroup = 820BF9CF057CF92872BC3C15 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 44C90013B7C82C80A2F69956 /* StripeFinancialConnections */, + DF72D31B68363878FC1604CF /* StripeFinancialConnectionsTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 704BBAB23732F215CEEAD39C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D0C1EF46A418A8F8774B7418 /* FinancialConnectionsSession_both_accounts_la.json in Resources */, + 07BFA34C5643A79E1E35A159 /* FinancialConnectionsSession_only_accounts.json in Resources */, + A156FACA60231988F247F6F4 /* FinancialConnectionsSession_only_both_missing.json in Resources */, + ACD21F21C6E42706A882A1AE /* FinancialConnectionsSession_only_la.json in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CC61EC7C016C47747A2D7AB0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4DC8EB63806434ABF4C9CC43 /* add@3x.png in Resources */, + 0692953D76599318535105EC /* arrow_right@3x.png in Resources */, + D10FB0DAC5E452D4569CEA14 /* back_arrow@3x.png in Resources */, + C23A55F7C98103222B159D73 /* bank@3x.png in Resources */, + 43B6B0358EDD34A8AB39B53C /* bank_check@2x.png in Resources */, + F0397F4E1D6A91416897F45E /* brandicon_default@3x.png in Resources */, + 645D6FF67167263F9A1C2BB0 /* bullet@3x.png in Resources */, + F10147CF75C2A09D66CB5C14 /* cancel_circle@3x.png in Resources */, + 755140DEEE50DCD6E939E528 /* check@3x.png in Resources */, + 16F2968DC3B2FC4558821970 /* chevron_down@3x.png in Resources */, + C747113C75AC92643B283CBD /* close@3x.png in Resources */, + 31A741F3BE54880AEA75A222 /* ellipsis@3x.png in Resources */, + CF47070B2A4CA27FEE9AE5FA /* generic_error@3x.png in Resources */, + C11D48CC29E1A123D50A5094 /* prepane_phone_background@3x.png in Resources */, + 3AE1C7A78FB5B220F5200F49 /* search@3x.png in Resources */, + BD3C87E03EB44F7D1C11664C /* spinner@3x.png in Resources */, + 97032B101B54E6A98178FD73 /* stripe_logo@3x.png in Resources */, + CF8152B40B6CA74FD15602BF /* warning_circle@3x.png in Resources */, + 87E22AF1E35FB63C20AEE9DF /* warning_triangle@3x.png in Resources */, + D949AE695F3288F84258BACD /* Localizable.strings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 019D2A9648EC14A85E873494 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 648FA50974B14CC861B08ECB /* APIPollingHelper.swift in Sources */, + BCEA321423DF0E7674C2544C /* APIVersion.swift in Sources */, + 11FB97AC840FEB5B5BF85BF9 /* FinancialConnectionsAPIClient.swift in Sources */, + 432463EBF562CDDC6D3DC252 /* BankAccountToken.swift in Sources */, + FBF513C7F73002FA30CC7C21 /* ConsumerSessionModels.swift in Sources */, + EC74B719F0FA1A977EF4708C /* FinancialConnectionsAccount.swift in Sources */, + 460C7685096AA6C693309647 /* FinancialConnectionsAuthSession.swift in Sources */, + AB5AFAC3C70D6195075DE5AE /* FinancialConnectionsBulletPoint.swift in Sources */, + B9A24A47454134F2B869C969 /* FinancialConnectionsConsent.swift in Sources */, + CBF7BE2271D309F2B1E794CC /* FinancialConnectionsDataAccessNotice.swift in Sources */, + F67624595BD2CD7B6793BFDA /* FinancialConnectionsImage.swift in Sources */, + 07712610C7D2F484AAB96982 /* FinancialConnectionsInstitution.swift in Sources */, + 7386E1F9256B23CE29BF996D /* FinancialConnectionsInstitutionSearchResultResource.swift in Sources */, + C7D2763ACCE2CC71E788E18F /* FinancialConnectionsLegalDetailsNotice.swift in Sources */, + B271AAF41C9FE6AE392B88D3 /* FinancialConnectionsMixedOAuthParams.swift in Sources */, + DAA51ABB496551074DBA1A20 /* FinancialConnectionsNetworkedAccountsResponse.swift in Sources */, + 6FE9F171CF9A5D0EDB2035AA /* FinancialConnectionsNetworkingLinkSignup.swift in Sources */, + 87198EFD873751CA4E4B5005 /* FinancialConnectionsOAuthPrepane.swift in Sources */, + 77C7F9A1DD0461FA2B1B4328 /* FinancialConnectionsPartnerAccount.swift in Sources */, + D926228B6C7601AE4C806C93 /* FinancialConnectionsPaymentAccountResource.swift in Sources */, + 6944E131D351784058C7D734 /* FinancialConnectionsPaymentMethodType.swift in Sources */, + 1C043C73281C0856D2C979C6 /* FinancialConnectionsSession.swift in Sources */, + 9E0044ABEC04E2A8C50E3658 /* FinancialConnectionsSessionManifest.swift in Sources */, + D936C8A9F6E018DB144A5B0A /* FinancialConnectionsSynchronize.swift in Sources */, + 15EC9F36187C341800164428 /* FinancialConnectionsAnalyticsClient.swift in Sources */, + C61D5957D3276991795F7D16 /* FinancialConnectionsSheetAnalytics.swift in Sources */, + D3AB52D5AE87FE51642C50C1 /* ExperimentHelper.swift in Sources */, + 0D56BD448019185656DF9310 /* FinancialConnectionsCustomManualEntryRequiredError.swift in Sources */, + 6E6E30D01D4E9629DB07E97B /* FinancialConnectionsNavigationController.swift in Sources */, + 01C820ECDBFC041A741A5499 /* FlowRouter.swift in Sources */, + 933F9DFE970FAB4715369086 /* HostController.swift in Sources */, + C258E0D849083BCC8A9B5068 /* HostViewController.swift in Sources */, + C59DBA5A86A3331113D6ED7E /* LoadingView.swift in Sources */, + 9B2CAE99344C26D524EDCF26 /* ModalPresentationWrapperViewController.swift in Sources */, + FE268512851E63E4E111DECD /* FinancialConnectionsSDKImplementation.swift in Sources */, + E85DCFCA61299EF27B3201CF /* FinancialConnectionsSheet.swift in Sources */, + F22DE4B785D51B318A1A3D08 /* FinancialConnectionsSheetError.swift in Sources */, + 34E12CB27B60F6A53D030765 /* FinancialConnectionsFont.swift in Sources */, + C3338FA5019EC8E99E2BA62F /* Helpers.swift in Sources */, + A573468B2800DABF384CAB43 /* Image.swift in Sources */, + 825C2182D13D7AC2DF67BB5E /* Locale+Extensions.swift in Sources */, + A79D6A26EE9FF96D24F4AC5C /* NSAttributedString+Extensions.swift in Sources */, + 76FB143918C5463B587091BB /* STPLocalizedString.swift in Sources */, + ABB28C3F6604C2BA2FCA079D /* String+Extensions.swift in Sources */, + 3FE4DEFAD6FF77B8D9EE68D3 /* String+Localized.swift in Sources */, + 3ECA346F75060BD954376EBF /* StripeFinancialConnectionsBundleLocator.swift in Sources */, + E760C94B619A8934D1D5E1D0 /* UIColor+Extensions.swift in Sources */, + 4A537AE0C50CAFF3889EFE28 /* UIViewController+Extensions.swift in Sources */, + C38BEDD99477C83C91B105DD /* AccountPickerAccountLoadErrorView.swift in Sources */, + C1A079E8E76A02EBCB2588DA /* AccountPickerDataSource.swift in Sources */, + BFF222008EEEDC3FACE342D9 /* AccountPickerFooterView.swift in Sources */, + C0831318A33A32BF2EAB641A /* AccountPickerHelpers.swift in Sources */, + E9E6775FE1FB4E13B284C8E3 /* AccountPickerLabelRowView.swift in Sources */, + 1889ECB24D40EF331974C288 /* AccountPickerNoAccountEligibleErrorView.swift in Sources */, + A10B5A3E5E8AE8767CF09C15 /* AccountPickerSelectionListView.swift in Sources */, + 04EB48BB849D29C7C8349DBC /* AccountPickerSelectionRowView.swift in Sources */, + 163E387D567068E4A64A4C13 /* AccountPickerSelectionView.swift in Sources */, + 23DBA4240ED1727C47937A6B /* AccountPickerViewController.swift in Sources */, + E4D00DB842047E595DD85BEF /* CheckboxView.swift in Sources */, + 1599A235CE57409AA2F678E1 /* LinkingAccountsLoadingView.swift in Sources */, + 152B480EA85D541CC64451A1 /* RadioButtonView.swift in Sources */, + 716E12A9AC0B790F14FB72C6 /* AccountNumberRetrievalErrorView.swift in Sources */, + 3446145FCA3278D51A9D4B80 /* AttachLinkedPaymentAccountDataSource.swift in Sources */, + E3F62D2F9C344A1178030E8E /* AttachLinkedPaymentAccountViewController.swift in Sources */, + 707C265C4179A8FEC98913FE /* ConsentBodyView.swift in Sources */, + 465AE8A58AD2183E1E2042FE /* ConsentDataSource.swift in Sources */, + 97C528CE821C6A55D58F68A4 /* ConsentFooterView.swift in Sources */, + 8927328EE28A0C94B5AB69DB /* ConsentLogoView.swift in Sources */, + E9866D5CA186A242BBEA69E1 /* ConsentViewController.swift in Sources */, + 76C466DE26B6646D9B25E9B1 /* FeaturedInstitutionGridCell.swift in Sources */, + BC991D917034CCF5149403CA /* FeaturedInstitutionGridView.swift in Sources */, + D9D84D6FF624CF4363D87CEB /* InstitutionDataSource.swift in Sources */, + 6D29E55F6A3864ED52799169 /* InstitutionPickerViewController.swift in Sources */, + C6B99A1C34886D3B5E1AF1A2 /* InstitutionSearchBar.swift in Sources */, + 9DEE4DE72A7FEDD9D3902C16 /* InstitutionSearchFooterView.swift in Sources */, + 58CD76A6379A04566684D2AC /* InstitutionSearchTableView.swift in Sources */, + 21C0B56B34EACFDAC06ED97D /* InstitutionSearchTableViewCell.swift in Sources */, + 44203505ED2F64D07632566B /* LinkAccountPickerBodyView.swift in Sources */, + C55F79F4B85E1EB8730B02C6 /* LinkAccountPickerDataSource.swift in Sources */, + 166ACB3BF53BDB4443E276E3 /* LinkAccountPickerFooterView.swift in Sources */, + F0495231F4C70E054149C03A /* LinkAccountPickerNewAccountRowView.swift in Sources */, + E54BF330E72BA091AEA1833A /* LinkAccountPickerRowView.swift in Sources */, + 72BB9389206F10DE9B18E542 /* LinkAccountPickerViewController.swift in Sources */, + 48E093CC2FB4A619A9E2C20E /* ManualEntryCheckView.swift in Sources */, + 4A0D015C978BD79BBFE6CE57 /* ManualEntryDataSource.swift in Sources */, + 2343C58289259920DD81620D /* ManualEntryErrorView.swift in Sources */, + A1AEE72611F62550267C326C /* ManualEntryFooterView.swift in Sources */, + 6D018BB3C1253ED4C1674E0B /* ManualEntryFormView.swift in Sources */, + 1C5D953684456368EEF4C622 /* ManualEntryTextField.swift in Sources */, + 07A86CEB6B4F6BEB524EFE37 /* ManualEntryValidator.swift in Sources */, + 19D1548A5A4034D349DB0947 /* ManualEntryViewController.swift in Sources */, + 2554BE48E61032CCD4565E7E /* ManualEntrySuccessTransactionTableView.swift in Sources */, + CA5825059866BD3416BF8240 /* ManualEntrySuccessViewController.swift in Sources */, + 2CE89100448F26DDA831F455 /* NativeFlowController.swift in Sources */, + 74CC216C8A71AD357B8AA544 /* NativeFlowDataManager.swift in Sources */, + FFD76E78070ECBB283D43D5E /* NetworkingLinkLoginWarmupBodyView.swift in Sources */, + 1E0C39EB65B8CB04F218D0BD /* NetworkingLinkLoginWarmupDataSource.swift in Sources */, + F7C10A1AB247D0F6E111DE36 /* NetworkingLinkLoginWarmupViewController.swift in Sources */, + 09DE22DB356EFB0739B3FE02 /* LinkEmailElement.swift in Sources */, + 5F3C86F23B65CAC56FDDEC90 /* NetworkingLinkSignupBodyFormView.swift in Sources */, + 95B2A73AC5DA9FA64017B3CB /* NetworkingLinkSignupBodyView.swift in Sources */, + 3AC5CA5F5529B55026342A54 /* NetworkingLinkSignupDataSource.swift in Sources */, + 8DC6C2A239456994091BF3EE /* NetworkingLinkSignupFooterView.swift in Sources */, + 54B51EA1F75B9607D7C29B08 /* NetworkingLinkSignupViewController.swift in Sources */, + 3F835D5A1C797C1C9BCF05D0 /* NetworkingLinkStepUpVerificationBodyView.swift in Sources */, + 11782289208971CCAA1037A5 /* NetworkingLinkStepUpVerificationDataSource.swift in Sources */, + C128C1681E46F0F12EB4EB9F /* NetworkingLinkStepUpVerificationViewController.swift in Sources */, + 9C6CE8824A7AEA56A2F5E7F5 /* NetworkingLinkVerificationBodyView.swift in Sources */, + 333B9C3E3349F5369FBA7C32 /* NetworkingLinkVerificationDataSource.swift in Sources */, + C5FEC806A31021B7D119A73C /* NetworkingLinkVerificationViewController.swift in Sources */, + 685CBD26771250C40E60F7A0 /* NetworkingSaveToLinkBodyView.swift in Sources */, + 1F5DD8E2E0FCED964D636D00 /* NetworkingSaveToLinkFooterView.swift in Sources */, + 2671241DE661B675E575C0AB /* NetworkingSaveToLinkVerificationDataSource.swift in Sources */, + 6BC6DB482984F9288944FE25 /* NetworkingSaveToLinkVerificationViewController.swift in Sources */, + 2AA0942F22A323B33CA6B7CA /* PartnerAuthDataSource.swift in Sources */, + F03F840B9E896F1B09742191 /* PartnerAuthViewController.swift in Sources */, + AD5B496425E2993C87F0B770 /* PrepaneImageView.swift in Sources */, + F419E86FC441B6C58B3F0D00 /* PrepaneView.swift in Sources */, + 136B704C025F6F69D55A625C /* PlaceholderViewController.swift in Sources */, + C906FC4DE38F16032B787607 /* ResetFlowDataSource.swift in Sources */, + A9F9E63FD6B72F5552A8A850 /* ResetFlowViewController.swift in Sources */, + 1CE588CD44D6591B95A9B281 /* AlwaysTemplateImageView.swift in Sources */, + 22426A37E01AE759BF93C422 /* AttributedLabel.swift in Sources */, + 2FADCA33DEC08E6551D94811 /* AttributedTextView.swift in Sources */, + 368DFF9D68F1F8D6A4353961 /* AuthFlowHelpers.swift in Sources */, + 9AF6EC34D666BEB3C1397092 /* BulletPointLabelView.swift in Sources */, + F65E8D16DE691EB6C99C4521 /* Button+Extensions.swift in Sources */, + 8EDCAA40D5ACE4D4CCC67695 /* CloseConfirmationAlertHandler.swift in Sources */, + 4CEE364A07B91B51B3D00F05 /* ConsentBottomSheetModel.swift in Sources */, + EDB22A11E058269784090A51 /* ConsentBottomSheetView.swift in Sources */, + 69508BFAF474855B05347CE4 /* ConsentBottomSheetViewController.swift in Sources */, + 33FA1684CE79F21271D14F23 /* HitTestStackView.swift in Sources */, + 691619AE9A989548ABA36535 /* HitTestView.swift in Sources */, + 91A3583A0BDE0F8F0C4AD3E2 /* InstitutionIconView.swift in Sources */, + 7AC3A12D50FA995882473C8A /* MerchantDataAccessView.swift in Sources */, + 0375F8C6D79947C992C32362 /* NetworkingOTPDataSource.swift in Sources */, + 40FF444E6CF20E9DA7D90448 /* NetworkingOTPView.swift in Sources */, + 444884F264D13FF654EA7471 /* PaneLayoutView.swift in Sources */, + B8F440921DB172F96F912CD0 /* PaneWithCustomHeaderLayoutView.swift in Sources */, + A6DB232AD8CB25B0C9F4C79C /* PaneWithHeaderLayoutView.swift in Sources */, + 8A424D8F321E80945AD42B1A /* ReusableInformationView.swift in Sources */, + 99F41681B77ECB0090F34E31 /* SFSafariViewController+Extensions.swift in Sources */, + 62F9AC317C04285853345A0C /* SpinnerIconView.swift in Sources */, + 4AADAA347EF70ECB8BE28E84 /* SuccessIconView.swift in Sources */, + AA80602323C28AFAC391358D /* TimeInterval+Extensions.swift in Sources */, + E637387728FA1597B1B51E5D /* UIImage+Extensions.swift in Sources */, + 486E50E6CB90208AB98C031E /* UIImageView+Extensions.swift in Sources */, + F0FB346A0F86C3561CD3C048 /* UITableView+Extensions.swift in Sources */, + A34AB3AC6D071605CABFFC9B /* UIViewController+KeyboardAvoiding.swift in Sources */, + BAA72FE13406CAF5FA4BBDC8 /* SuccessAccountListView.swift in Sources */, + 2E29C0A30C137791D357F4C1 /* SuccessBodyView.swift in Sources */, + B2970FE2753A4D79E428BA73 /* SuccessDataSource.swift in Sources */, + C39214EA5995D85B847406BE /* SuccessFooterView.swift in Sources */, + B45B8DC3DAACDD5F04B1B1BE /* SuccessViewController.swift in Sources */, + 3BFED24B6DF835A0F2FB4939 /* TerminalErrorViewController.swift in Sources */, + 8C985491C17431278097D0FF /* Placeholder.swift in Sources */, + 7AE7474B7AFF416B6072721C /* StripeCore+Import.swift in Sources */, + DC4DFC847378AC9E9112B443 /* AuthenticationSessionManager.swift in Sources */, + B5EEF34D158C08A1745FA150 /* ContinueStateView.swift in Sources */, + 2D14461B27B3DEE2CC19B090 /* FinancialConnectionsAccountFetcher.swift in Sources */, + 82FD3CEE526DE8B6519F666E /* FinancialConnectionsSessionFetcher.swift in Sources */, + AB7C9A26484953762FFBB4A5 /* FinancialConnectionsWebFlowViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0EF6A0BEC1B066774A0D985E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CBEAB081DD7353928F485071 /* APIPollingHelperTests.swift in Sources */, + 0AF88791C01102CDCC31F419 /* AccountFetcherTests.swift in Sources */, + 700B745FEF43088D9E34C0E4 /* AccountPickerHelpersTests.swift in Sources */, + 6744CB1B182C5F7220B0B804 /* AuthFlowHelpersTests.swift in Sources */, + 39E5D4531961150E9CB3262F /* EmptyFinancialConnectionsAPIClient.swift in Sources */, + CB734C25A19D38A87876FB2B /* FinancialConnectionsAnalyticsTest.swift in Sources */, + 77D3B375B9DBF80BA209BC99 /* FinancialConnectionsSessionTests.swift in Sources */, + 846D1D7429B9E414744DEC99 /* FinancialConnectionsSheetTests.swift in Sources */, + C19996D0AC7E046DA87B6B32 /* ManualEntryValidatorTests.swift in Sources */, + 779C729BB49FD4B99DCD517B /* MarkdownBoldAttributedStringTests.swift in Sources */, + BF5F964E1CA6312755D4161E /* SessionFetcherTests.swift in Sources */, + ED818E10F37230678B9B73CC /* SoftLinkTests.swift in Sources */, + D9F35B3B31CA2E52055D6B1D /* StringExtensionsTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 3257A97C053FFC03C4806278 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = StripeFinancialConnections; + target = 44C90013B7C82C80A2F69956 /* StripeFinancialConnections */; + targetProxy = 99584176FCBCA6DC9B8E22E4 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + BF6810BAADB14ACB95216C2B /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + E898E7D173685669E31FC58F /* bg-BG */, + 0DA7868C9DD47582244B47C8 /* ca-ES */, + AC7FED22D9EAC568EA6B35EB /* cs-CZ */, + 7C402C24A15DC6167E2C593F /* da */, + 0F4FC108D8C162EEE1EEA97E /* de */, + 24D4A72B4CCA677F45C29A5C /* el-GR */, + 5C09425306344278C7B55089 /* en */, + 106427315CD279EAAD7D1B74 /* en-GB */, + 6B70A0C4DBFE46805549CF8B /* es */, + CF80A9614EB3ADA9E81397F8 /* es-419 */, + CE10909F3FC7D60E13B65226 /* et-EE */, + 9312AAE1BFF1D9BBEA44E8AA /* fi */, + 742D94AC4B2D17F8282A6788 /* fil */, + E90CF6AD88E530CE63D57269 /* fr */, + 24701CABF53C21DD7BCF3E48 /* fr-CA */, + 6C81D547F6BAD96C62E1E4D3 /* hr */, + A3A2815DF2EE9447CE7A3826 /* hu */, + 51EEC3A9E3BC863ED054B1DC /* id */, + 5B65388786D25271A87D34CE /* it */, + F9A847D2AAA7271F507DC9F3 /* ja */, + 5E48DB3155C1546B196DF97B /* ko */, + C8AFA09E86048B4325C36CC8 /* lt-LT */, + BC4D2368AC577A5233DEC72C /* lv-LV */, + 846D9EF58B02C69F9629AE79 /* ms-MY */, + F25B2AB87C9548245C28D14C /* mt */, + EFB09DF9C9434032F387E081 /* nb */, + C0467CE507A92557C72885DF /* nl */, + 5AC5D8EE52FE5D305F78E3A0 /* nn-NO */, + F2BA0F04A5A7D3B1DBF34AEE /* pl-PL */, + 9EA0AA05BC9FC60A06AC1B5E /* pt-BR */, + 66D2857E68EA69AC6F658BEA /* pt-PT */, + B2B74140FCD8F5871F42C881 /* ro-RO */, + 88F7731972F5FB12FD4FA48B /* ru */, + A37D7E687494FAE048945144 /* sk-SK */, + 1CFE14532C10471EC61BB05A /* sl-SI */, + D2C62B6AA6891A4214E0754E /* sv */, + 33B1E2861FA7CA86FF79236C /* tr */, + DD9E2537472B2ED4AA3ED6A2 /* vi */, + F2B7ECC6F6A4DA1F5F376467 /* zh-Hans */, + 50B4E948868910ADA557F50D /* zh-Hant */, + 1CE32B7E492EFD8143F687F2 /* zh-HK */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 00E780C5BEA516D21120ACE2 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 14CED33665ED3D8EE8D5D7B7 /* StripeiOS Tests-Release.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeFinancialConnectionsTests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeFinancialConnectionsTests; + PRODUCT_NAME = StripeFinancialConnectionsTests; + SDKROOT = iphoneos; + }; + name = Release; + }; + 0F5472F7DE76FFA97369CE47 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2C10E841FF9EBFEA8C2E30AF /* Project-Debug.xcconfig */; + buildSettings = { + }; + name = Debug; + }; + 11E0E3D8EFAC643C1CF22071 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1CD19E0601599AE89976DB4D /* StripeiOS-Release.xcconfig */; + buildSettings = { + INFOPLIST_FILE = StripeFinancialConnections/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "com.stripe.stripe-financial-connections"; + PRODUCT_NAME = StripeFinancialConnections; + SDKROOT = iphoneos; + }; + name = Release; + }; + 7D2EBF6B1293E586F89B7BA4 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 314462DF7856349FF9775598 /* StripeiOS-Debug.xcconfig */; + buildSettings = { + INFOPLIST_FILE = StripeFinancialConnections/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "com.stripe.stripe-financial-connections"; + PRODUCT_NAME = StripeFinancialConnections; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 929F106FFD819D993A187A71 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = CA2DA47ECE153F888FA675CE /* StripeiOS Tests-Debug.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeFinancialConnectionsTests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeFinancialConnectionsTests; + PRODUCT_NAME = StripeFinancialConnectionsTests; + SDKROOT = iphoneos; + }; + name = Debug; + }; + F7799318348B9FA5263B14D1 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B5FFA1B806BC6AD3500B0567 /* Project-Release.xcconfig */; + buildSettings = { + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 8C428C73E0383F9203731DCB /* Build configuration list for PBXNativeTarget "StripeFinancialConnectionsTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 929F106FFD819D993A187A71 /* Debug */, + 00E780C5BEA516D21120ACE2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DE1BF3F953C39B1173504C4A /* Build configuration list for PBXNativeTarget "StripeFinancialConnections" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7D2EBF6B1293E586F89B7BA4 /* Debug */, + 11E0E3D8EFAC643C1CF22071 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F7F4B17FFDBC5691F1A51423 /* Build configuration list for PBXProject "StripeFinancialConnections" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0F5472F7DE76FFA97369CE47 /* Debug */, + F7799318348B9FA5263B14D1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 3D00B888AF0B02587576A83F /* Project object */; +} diff --git a/StripeFinancialConnections/StripeFinancialConnections.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/StripeFinancialConnections/StripeFinancialConnections.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/StripeFinancialConnections/StripeFinancialConnections.xcodeproj/xcshareddata/xcschemes/StripeFinancialConnections.xcscheme b/StripeFinancialConnections/StripeFinancialConnections.xcodeproj/xcshareddata/xcschemes/StripeFinancialConnections.xcscheme new file mode 100644 index 00000000..d58bf207 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections.xcodeproj/xcshareddata/xcschemes/StripeFinancialConnections.xcscheme @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StripeFinancialConnections/StripeFinancialConnections/Info.plist b/StripeFinancialConnections/StripeFinancialConnections/Info.plist new file mode 100644 index 00000000..cd4a496b --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(CURRENT_PROJECT_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/add@3x.png b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/add@3x.png new file mode 100644 index 00000000..a649b170 Binary files /dev/null and b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/add@3x.png differ diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/arrow_right@3x.png b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/arrow_right@3x.png new file mode 100644 index 00000000..7d29c32a Binary files /dev/null and b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/arrow_right@3x.png differ diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/back_arrow@3x.png b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/back_arrow@3x.png new file mode 100644 index 00000000..d605eaf0 Binary files /dev/null and b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/back_arrow@3x.png differ diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/bank@3x.png b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/bank@3x.png new file mode 100644 index 00000000..a454e4d3 Binary files /dev/null and b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/bank@3x.png differ diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/bank_check@2x.png b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/bank_check@2x.png new file mode 100644 index 00000000..336dc917 Binary files /dev/null and b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/bank_check@2x.png differ diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/brandicon_default@3x.png b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/brandicon_default@3x.png new file mode 100644 index 00000000..cd440493 Binary files /dev/null and b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/brandicon_default@3x.png differ diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/bullet@3x.png b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/bullet@3x.png new file mode 100644 index 00000000..95a877d7 Binary files /dev/null and b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/bullet@3x.png differ diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/cancel_circle@3x.png b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/cancel_circle@3x.png new file mode 100644 index 00000000..028a7809 Binary files /dev/null and b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/cancel_circle@3x.png differ diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/check@3x.png b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/check@3x.png new file mode 100644 index 00000000..d6cabf50 Binary files /dev/null and b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/check@3x.png differ diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/chevron_down@3x.png b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/chevron_down@3x.png new file mode 100644 index 00000000..3215a75a Binary files /dev/null and b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/chevron_down@3x.png differ diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/close@3x.png b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/close@3x.png new file mode 100644 index 00000000..0ad3bea7 Binary files /dev/null and b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/close@3x.png differ diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/ellipsis@3x.png b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/ellipsis@3x.png new file mode 100644 index 00000000..47792a7f Binary files /dev/null and b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/ellipsis@3x.png differ diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/generic_error@3x.png b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/generic_error@3x.png new file mode 100644 index 00000000..f1847118 Binary files /dev/null and b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/generic_error@3x.png differ diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/prepane_phone_background@3x.png b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/prepane_phone_background@3x.png new file mode 100644 index 00000000..5412cd19 Binary files /dev/null and b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/prepane_phone_background@3x.png differ diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/search@3x.png b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/search@3x.png new file mode 100644 index 00000000..ae7d53c5 Binary files /dev/null and b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/search@3x.png differ diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/spinner@3x.png b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/spinner@3x.png new file mode 100644 index 00000000..9a0420b7 Binary files /dev/null and b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/spinner@3x.png differ diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/stripe_logo@3x.png b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/stripe_logo@3x.png new file mode 100644 index 00000000..1e5feb8d Binary files /dev/null and b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/stripe_logo@3x.png differ diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/warning_circle@3x.png b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/warning_circle@3x.png new file mode 100644 index 00000000..07fabca6 Binary files /dev/null and b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/warning_circle@3x.png differ diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/warning_triangle@3x.png b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/warning_triangle@3x.png new file mode 100644 index 00000000..3e1a2799 Binary files /dev/null and b/StripeFinancialConnections/StripeFinancialConnections/Resources/Images/warning_triangle@3x.png differ diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/bg-BG.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/bg-BG.lproj/Localizable.strings new file mode 100644 index 00000000..e5bc2dde --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/bg-BG.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Неуспешно свързване"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/ca-ES.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/ca-ES.lproj/Localizable.strings new file mode 100644 index 00000000..9439cb30 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/ca-ES.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "No s'ha pogut connectar"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/cs-CZ.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/cs-CZ.lproj/Localizable.strings new file mode 100644 index 00000000..f3a5cdb9 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/cs-CZ.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Nepodařilo se připojit"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/da.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/da.lproj/Localizable.strings new file mode 100644 index 00000000..80d08acc --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/da.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Kunne ikke oprette forbindelse"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/de.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/de.lproj/Localizable.strings new file mode 100644 index 00000000..a1259e27 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/de.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Verbindung fehlgeschlagen"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/el-GR.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/el-GR.lproj/Localizable.strings new file mode 100644 index 00000000..f48c4cd8 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/el-GR.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Δεν ήταν δυνατή η σύνδεση"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/en-GB.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/en-GB.lproj/Localizable.strings new file mode 100644 index 00000000..abb1b524 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/en-GB.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Failed to connect"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/en.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/en.lproj/Localizable.strings new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/en.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/es-419.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/es-419.lproj/Localizable.strings new file mode 100644 index 00000000..6202c93e --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/es-419.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Error al establecer conexión"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/es.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/es.lproj/Localizable.strings new file mode 100644 index 00000000..6202c93e --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/es.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Error al establecer conexión"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/et-EE.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/et-EE.lproj/Localizable.strings new file mode 100644 index 00000000..9398af76 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/et-EE.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Ühendamine ebaõnnestus"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/fi.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/fi.lproj/Localizable.strings new file mode 100644 index 00000000..7983b67d --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/fi.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Yhteyden muodostaminen epäonnistui"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/fil.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/fil.lproj/Localizable.strings new file mode 100644 index 00000000..7f7ae7ed --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/fil.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Nabigong kumonekta"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/fr-CA.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/fr-CA.lproj/Localizable.strings new file mode 100644 index 00000000..d2681a7a --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/fr-CA.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Échec de la connexion"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/fr.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/fr.lproj/Localizable.strings new file mode 100644 index 00000000..d2681a7a --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/fr.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Échec de la connexion"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/hr.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/hr.lproj/Localizable.strings new file mode 100644 index 00000000..f83d2471 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/hr.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Povezivanje nije uspjelo"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/hu.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/hu.lproj/Localizable.strings new file mode 100644 index 00000000..c06a443e --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/hu.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Nem sikerült csatlakozni"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/id.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/id.lproj/Localizable.strings new file mode 100644 index 00000000..e36462c2 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/id.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Gagal menghubungkan"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/it.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/it.lproj/Localizable.strings new file mode 100644 index 00000000..dc0b24c8 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/it.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Connessione non riuscita"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/ja.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/ja.lproj/Localizable.strings new file mode 100644 index 00000000..e44fb008 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/ja.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "接続できませんでした"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/ko.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/ko.lproj/Localizable.strings new file mode 100644 index 00000000..727a2712 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/ko.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "연결 실패"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/lt-LT.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/lt-LT.lproj/Localizable.strings new file mode 100644 index 00000000..57a96610 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/lt-LT.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Nepavyko prisijungti"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/lv-LV.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/lv-LV.lproj/Localizable.strings new file mode 100644 index 00000000..29e846ce --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/lv-LV.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Neizdevās izveidot savienojumu"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/ms-MY.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/ms-MY.lproj/Localizable.strings new file mode 100644 index 00000000..079c9cf4 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/ms-MY.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Gagal disambungkan"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/mt.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/mt.lproj/Localizable.strings new file mode 100644 index 00000000..33a45ec3 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/mt.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Ma nistgħux naqbdu"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/nb.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/nb.lproj/Localizable.strings new file mode 100644 index 00000000..b553a8ae --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/nb.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Kunne ikke koble til"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/nl.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/nl.lproj/Localizable.strings new file mode 100644 index 00000000..53931e49 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/nl.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Kan geen verbinding maken"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/nn-NO.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/nn-NO.lproj/Localizable.strings new file mode 100644 index 00000000..61f27cd8 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/nn-NO.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Kunne ikkje kople til"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/pl-PL.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/pl-PL.lproj/Localizable.strings new file mode 100644 index 00000000..335453a1 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/pl-PL.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Nie udało się połączyć"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/pt-BR.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/pt-BR.lproj/Localizable.strings new file mode 100644 index 00000000..64267766 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/pt-BR.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Falha ao conectar"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/pt-PT.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/pt-PT.lproj/Localizable.strings new file mode 100644 index 00000000..05f1c3a3 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/pt-PT.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Impossível ligar"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/ro-RO.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/ro-RO.lproj/Localizable.strings new file mode 100644 index 00000000..2e3b60b6 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/ro-RO.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Eroare de conexiune"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/ru.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/ru.lproj/Localizable.strings new file mode 100644 index 00000000..65fa4f39 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/ru.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Не удалось подключиться"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/sk-SK.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/sk-SK.lproj/Localizable.strings new file mode 100644 index 00000000..6e1c8cfb --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/sk-SK.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Pripojenie sa nepodarilo"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/sl-SI.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/sl-SI.lproj/Localizable.strings new file mode 100644 index 00000000..a225e8d3 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/sl-SI.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Povezave ni bilo mogoče vzpostaviti"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/sv.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/sv.lproj/Localizable.strings new file mode 100644 index 00000000..e9207cf7 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/sv.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Det gick inte att ansluta"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/tr.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/tr.lproj/Localizable.strings new file mode 100644 index 00000000..e5895e8c --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/tr.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Bağlantı başarısız."; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/vi.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/vi.lproj/Localizable.strings new file mode 100644 index 00000000..b0ec7065 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/vi.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "Không thể kết nối"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/zh-HK.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/zh-HK.lproj/Localizable.strings new file mode 100644 index 00000000..bf70575c --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/zh-HK.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "連接失敗"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/zh-Hans.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/zh-Hans.lproj/Localizable.strings new file mode 100644 index 00000000..308068f8 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "连接失败"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/zh-Hant.lproj/Localizable.strings b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/zh-Hant.lproj/Localizable.strings new file mode 100644 index 00000000..bf70575c --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Resources/Localizations/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Error message that displays when we're unable to connect to the server. */ +"Failed to connect" = "連接失敗"; \ No newline at end of file diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/APIPollingHelper.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/APIPollingHelper.swift new file mode 100644 index 00000000..0c301f54 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/APIPollingHelper.swift @@ -0,0 +1,108 @@ +// +// APIPollingHelper.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 10/2/22. +// + +import Foundation +@_spi(STP) import StripeCore + +final class APIPollingHelper { + + struct PollTimingOptions { + let initialPollDelay: TimeInterval + let maxNumberOfRetries: Int + let retryInterval: TimeInterval + + init( + initialPollDelay: TimeInterval = 1.75, + maxNumberOfRetries: Int = 180, + retryInterval: TimeInterval = 0.25 + ) { + self.initialPollDelay = initialPollDelay + self.maxNumberOfRetries = maxNumberOfRetries + self.retryInterval = retryInterval + } + } + + private let apiCall: () -> Future + private let originalPromise: Promise + private let pollTimingOptions: PollTimingOptions + + private var strongSelfReference: APIPollingHelper? + private var currentApiCallTimer: Timer? + private var numberOfRetriesLeft: Int + + init( + apiCall: @escaping () -> Future, + pollTimingOptions: PollTimingOptions = PollTimingOptions() + ) { + self.apiCall = apiCall + self.pollTimingOptions = pollTimingOptions + self.numberOfRetriesLeft = pollTimingOptions.maxNumberOfRetries + self.originalPromise = Promise() + } + + deinit { + invalidateTimer() + } + + func startPollingApiCall() -> Future { + assertMainQueue() + // polling helper will keep a strong reference to itself + // until `originalPromise` is fulfilled + self.strongSelfReference = self + originalPromise + .observe(on: .main) { [weak self] _ in + // clear the strong reference once the original + // promise is fulfilled... + self?.strongSelfReference = nil + } + + callApi(afterDelay: pollTimingOptions.initialPollDelay) + return originalPromise + } + + private func callApi(afterDelay delay: TimeInterval) { + assertMainQueue() + self.currentApiCallTimer = Timer.scheduledTimer( + withTimeInterval: delay, + repeats: false, + block: { [weak self] _ in + guard let self = self else { return } + self.invalidateTimer() + self.callApi() + } + ) + } + + private func callApi() { + assertMainQueue() + apiCall() + .observe(on: .main) { [weak self] result in + guard let self = self else { return } + switch result { + case .success: + self.originalPromise.fullfill(with: result) + case .failure(let error): + if self.numberOfRetriesLeft > 0, + let error = error as? StripeError, + case .apiError(let apiError) = error, + // we want to retry in the case of a 202 + apiError.statusCode == 202 + { + self.numberOfRetriesLeft -= 1 + self.callApi(afterDelay: self.pollTimingOptions.retryInterval) + } else { + self.originalPromise.fullfill(with: result) + } + } + } + } + + private func invalidateTimer() { + currentApiCallTimer?.invalidate() + currentApiCallTimer = nil + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/APIVersion.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/APIVersion.swift new file mode 100644 index 00000000..f3c3dd75 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/APIVersion.swift @@ -0,0 +1,26 @@ +// +// APIVersion.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 9/13/22. +// + +import Foundation +@_spi(STP) import StripeCore + +struct APIVersion { + /** + The latest production-ready version of the Financial Connections API that the + SDK is capable of using. + + - Note: Update this value when a new API version is ready for use in production. + */ + private static let apiVersion: Int = 1 // WARNING: this is also referenced in other places, so double check changes! + private static let header = "financial_connections_client_api_beta=v\(apiVersion)" + + static func configureFinancialConnectionsAPIVersion(apiClient: STPAPIClient) { + var betas = apiClient.betas + betas.insert(header) + apiClient.betas = betas + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/FinancialConnectionsAPIClient.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/FinancialConnectionsAPIClient.swift new file mode 100644 index 00000000..4d5963a9 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/FinancialConnectionsAPIClient.swift @@ -0,0 +1,633 @@ +// +// FinancialConnectionsAPIClient.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 12/1/21. +// + +import Foundation +@_spi(STP) import StripeCore + +protocol FinancialConnectionsAPIClient { + + func synchronize(clientSecret: String, returnURL: String?) -> Promise + + func fetchFinancialConnectionsAccounts( + clientSecret: String, + startingAfterAccountId: String? + ) -> Promise + + func fetchFinancialConnectionsSession(clientSecret: String) -> Promise + + func markConsentAcquired(clientSecret: String) -> Promise + + func fetchFeaturedInstitutions(clientSecret: String) -> Promise + + func fetchInstitutions(clientSecret: String, query: String) -> Future + + func createAuthSession(clientSecret: String, institutionId: String) -> Promise + + func cancelAuthSession(clientSecret: String, authSessionId: String) -> Promise + + func retrieveAuthSession( + clientSecret: String, + authSessionId: String + ) -> Future + + func fetchAuthSessionOAuthResults(clientSecret: String, authSessionId: String) -> Future< + FinancialConnectionsMixedOAuthParams + > + + func authorizeAuthSession( + clientSecret: String, + authSessionId: String, + publicToken: String? + ) -> Promise + + func fetchAuthSessionAccounts( + clientSecret: String, + authSessionId: String, + initialPollDelay: TimeInterval + ) -> Future + + func selectAuthSessionAccounts( + clientSecret: String, + authSessionId: String, + selectedAccountIds: [String] + ) -> Promise + + func markLinkingMoreAccounts(clientSecret: String) -> Promise + + func completeFinancialConnectionsSession( + clientSecret: String, + terminalError: String? + ) -> Future + + func attachBankAccountToLinkAccountSession( + clientSecret: String, + accountNumber: String, + routingNumber: String + ) -> Future + + func attachLinkedAccountIdToLinkAccountSession( + clientSecret: String, + linkedAccountId: String, + consumerSessionClientSecret: String? + ) -> Future + + func recordAuthSessionEvent( + clientSecret: String, + authSessionId: String, + eventNamespace: String, + eventName: String + ) -> Future + + // MARK: - Networking + + func saveAccountsToLink( + emailAddress: String?, + phoneNumber: String?, + country: String?, + selectedAccountIds: [String], + consumerSessionClientSecret: String?, + clientSecret: String + ) -> Future + + func disableNetworking( + disabledReason: String?, + clientSecret: String + ) -> Future + + func fetchNetworkedAccounts( + clientSecret: String, + consumerSessionClientSecret: String + ) -> Future + + func selectNetworkedAccounts( + selectedAccountIds: [String], + clientSecret: String, + consumerSessionClientSecret: String + ) -> Future + + func markLinkStepUpAuthenticationVerified( + clientSecret: String + ) -> Future + + func consumerSessionLookup( + emailAddress: String, + clientSecret: String + ) -> Future + + // MARK: - Link API's + + func consumerSessionStartVerification( + otpType: String, + customEmailType: String?, + connectionsMerchantName: String?, + consumerSessionClientSecret: String + ) -> Future + + func consumerSessionConfirmVerification( + otpCode: String, + otpType: String, + consumerSessionClientSecret: String + ) -> Future + + func markLinkVerified( + clientSecret: String + ) -> Future +} + +extension STPAPIClient: FinancialConnectionsAPIClient { + + func fetchFinancialConnectionsAccounts( + clientSecret: String, + startingAfterAccountId: String? + ) -> Promise { + var parameters = ["client_secret": clientSecret] + if let startingAfterAccountId = startingAfterAccountId { + parameters["starting_after"] = startingAfterAccountId + } + return self.get( + resource: APIEndpointListAccounts, + parameters: parameters + ) + } + + func fetchFinancialConnectionsSession(clientSecret: String) -> Promise { + return self.get( + resource: APIEndpointSessionReceipt, + parameters: ["client_secret": clientSecret] + ) + } + + func synchronize(clientSecret: String, returnURL: String?) -> Promise { + let parameters: [String: Any] = [ + "expand": ["manifest.active_auth_session"], + "client_secret": clientSecret, + "mobile": { + var mobileParameters: [String: Any] = [ + "fullscreen": true, + "hide_close_button": true, + ] + mobileParameters["app_return_url"] = returnURL + return mobileParameters + }(), + "locale": Locale.current.toLanguageTag(), + ] + return self.post( + resource: "financial_connections/sessions/synchronize", + parameters: parameters + ) + } + + func markConsentAcquired(clientSecret: String) -> Promise { + let parameters = [ + "client_secret": clientSecret + ] + return self.post( + resource: APIEndpointConsentAcquired, + parameters: parameters + ) + } + + func fetchFeaturedInstitutions(clientSecret: String) -> Promise { + let parameters = [ + "client_secret": clientSecret, + "limit": "10", + ] + return self.get( + resource: APIEndpointFeaturedInstitutions, + parameters: parameters + ) + } + + func fetchInstitutions(clientSecret: String, query: String) -> Future { + let parameters = [ + "client_secret": clientSecret, + "query": query, + "limit": "20", + ] + return self.get( + resource: APIEndpointSearchInstitutions, + parameters: parameters + ) + } + + func createAuthSession(clientSecret: String, institutionId: String) -> Promise { + let body: [String: Any] = [ + "client_secret": clientSecret, + "institution": institutionId, + "use_mobile_handoff": "false", + "use_abstract_flow": true, + "return_url": "ios", + ] + return self.post(resource: APIEndpointAuthSessions, parameters: body) + } + + func cancelAuthSession(clientSecret: String, authSessionId: String) -> Promise { + let body = [ + "client_secret": clientSecret, + "id": authSessionId, + ] + return self.post(resource: APIEndpointAuthSessionsCancel, object: body) + } + + func retrieveAuthSession( + clientSecret: String, + authSessionId: String + ) -> Future { + let body = [ + "client_secret": clientSecret, + "id": authSessionId, + ] + return self.post(resource: APIEndpointAuthSessionsRetrieve, object: body) + } + + func fetchAuthSessionOAuthResults(clientSecret: String, authSessionId: String) -> Future< + FinancialConnectionsMixedOAuthParams + > { + let body = [ + "client_secret": clientSecret, + "id": authSessionId, + ] + let pollingHelper = APIPollingHelper( + apiCall: { [weak self] in + guard let self = self else { + return Promise( + error: FinancialConnectionsSheetError.unknown(debugDescription: "STPAPIClient deallocated.") + ) + } + return self.post(resource: APIEndpointAuthSessionsOAuthResults, object: body) + }, + pollTimingOptions: APIPollingHelper.PollTimingOptions( + initialPollDelay: 0, + maxNumberOfRetries: 300, // Stripe.js has 600 second timeout, 600 / 2 = 300 retries + retryInterval: 2.0 + ) + ) + return pollingHelper.startPollingApiCall() + } + + func authorizeAuthSession( + clientSecret: String, + authSessionId: String, + publicToken: String? = nil + ) -> Promise { + var body = [ + "client_secret": clientSecret, + "id": authSessionId, + ] + body["public_token"] = publicToken // not all integrations require public_token + return self.post(resource: APIEndpointAuthSessionsAuthorized, object: body) + } + + func fetchAuthSessionAccounts( + clientSecret: String, + authSessionId: String, + initialPollDelay: TimeInterval + ) -> Future { + let body: [String: Any] = [ + "client_secret": clientSecret, + "id": authSessionId, + "expand": ["data.institution"], + ] + let pollingHelper = APIPollingHelper( + apiCall: { [weak self] in + guard let self = self else { + return Promise( + error: FinancialConnectionsSheetError.unknown(debugDescription: "STPAPIClient deallocated.") + ) + } + return self.post(resource: APIEndpointAuthSessionsAccounts, parameters: body) + }, + pollTimingOptions: APIPollingHelper.PollTimingOptions( + initialPollDelay: initialPollDelay + ) + ) + return pollingHelper.startPollingApiCall() + } + + func selectAuthSessionAccounts( + clientSecret: String, + authSessionId: String, + selectedAccountIds: [String] + ) -> Promise { + let body: [String: Any] = [ + "client_secret": clientSecret, + "id": authSessionId, + "selected_accounts": selectedAccountIds, + "expand": ["data.institution"], + ] + return self.post(resource: APIEndpointAuthSessionsSelectedAccounts, parameters: body) + } + + func markLinkingMoreAccounts(clientSecret: String) -> Promise { + let body = [ + "client_secret": clientSecret + ] + return self.post(resource: APIEndpointLinkMoreAccounts, object: body) + } + + func completeFinancialConnectionsSession( + clientSecret: String, + terminalError: String? + ) -> Future { + var body: [String: Any] = [ + "client_secret": clientSecret + ] + body["terminal_error"] = terminalError + return self.post(resource: APIEndpointComplete, parameters: body) + .chained { (session: StripeAPI.FinancialConnectionsSession) in + if session.accounts.hasMore { + // de-paginate the accounts we get from the session because + // we want to give the clients a full picture of the number + // of accounts that were linked + let accountAPIFetcher = FinancialConnectionsAccountAPIFetcher( + api: self, + clientSecret: clientSecret + ) + return + accountAPIFetcher + .fetchAccounts(initial: session.accounts.data) + .chained { [accountAPIFetcher] accounts in + _ = accountAPIFetcher // retain `accountAPIFetcher` for the duration of the network call + return Promise( + value: StripeAPI.FinancialConnectionsSession( + clientSecret: session.clientSecret, + id: session.id, + accounts: StripeAPI.FinancialConnectionsSession.AccountList( + data: accounts, + hasMore: false + ), + livemode: session.livemode, + paymentAccount: session.paymentAccount, + bankAccountToken: session.bankAccountToken, + status: session.status, + statusDetails: session.statusDetails + ) + ) + } + } else { + return Promise(value: session) + } + } + } + + func attachBankAccountToLinkAccountSession( + clientSecret: String, + accountNumber: String, + routingNumber: String + ) -> Future { + return attachPaymentAccountToLinkAccountSession( + clientSecret: clientSecret, + accountNumber: accountNumber, + routingNumber: routingNumber + ) + } + + func attachLinkedAccountIdToLinkAccountSession( + clientSecret: String, + linkedAccountId: String, + consumerSessionClientSecret: String? + ) -> Future { + return attachPaymentAccountToLinkAccountSession( + clientSecret: clientSecret, + linkedAccountId: linkedAccountId, + consumerSessionClientSecret: consumerSessionClientSecret + ) + } + + private func attachPaymentAccountToLinkAccountSession( + clientSecret: String, + accountNumber: String? = nil, + routingNumber: String? = nil, + linkedAccountId: String? = nil, + consumerSessionClientSecret: String? = nil + ) -> Future { + var body: [String: Any] = [ + "client_secret": clientSecret + ] + if let accountNumber = accountNumber, let routingNumber = routingNumber { + body["type"] = "bank_account" + body["bank_account"] = [ + "routing_number": routingNumber, + "account_number": accountNumber, + ] + } else if let linkedAccountId = linkedAccountId { + body["type"] = "linked_account" + body["linked_account"] = [ + "id": linkedAccountId + ] + body["consumer_session_client_secret"] = consumerSessionClientSecret // optional for Link + } else { + assertionFailure() + return Promise( + error: + FinancialConnectionsSheetError + .unknown(debugDescription: "Invalid usage of \(#function).") + ) + } + + let pollingHelper = APIPollingHelper( + apiCall: { [weak self] in + guard let self = self else { + return Promise( + error: FinancialConnectionsSheetError.unknown(debugDescription: "STPAPIClient deallocated.") + ) + } + return self.post(resource: APIEndpointAttachPaymentAccount, parameters: body) + }, + pollTimingOptions: APIPollingHelper.PollTimingOptions( + initialPollDelay: 1.0 + ) + ) + return pollingHelper.startPollingApiCall() + } + + func recordAuthSessionEvent( + clientSecret: String, + authSessionId: String, + eventNamespace: String, + eventName: String + ) -> Future { + let clientTimestamp = Date().timeIntervalSince1970.milliseconds + var body: [String: Any] = [ + "id": authSessionId, + "client_secret": clientSecret, + "client_timestamp": clientTimestamp, + "frontend_events": [ + [ + "event_namespace": eventNamespace, + "event_name": eventName, + "client_timestamp": clientTimestamp, + "raw_event_details": "{}", + ] as [String: Any], + ], + ] + body["key"] = publishableKey + return self.post( + resource: APIEndpointAuthSessionsEvents, + parameters: body + ) + } + + // MARK: - Networking + + func saveAccountsToLink( + emailAddress: String?, + phoneNumber: String?, + country: String?, + selectedAccountIds: [String], + consumerSessionClientSecret: String?, + clientSecret: String + ) -> Future { + var body: [String: Any] = [ + "client_secret": clientSecret, + "selected_accounts": selectedAccountIds, + "expand": ["active_auth_session"], + ] + body["email_address"] = emailAddress? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + body["phone_number"] = phoneNumber + body["country"] = country + body["locale"] = (phoneNumber != nil) ? Locale.current.toLanguageTag() : nil + body["consumer_session_client_secret"] = consumerSessionClientSecret + return post(resource: APIEndpointSaveAccountsToLink, parameters: body) + } + + func disableNetworking( + disabledReason: String?, + clientSecret: String + ) -> Future { + var body: [String: Any] = [ + "client_secret": clientSecret, + "expand": ["active_auth_session"], + ] + body["disabled_reason"] = disabledReason + return post(resource: APIEndpointDisableNetworking, parameters: body) + } + + func markLinkVerified( + clientSecret: String + ) -> Future { + let parameters: [String: Any] = [ + "client_secret": clientSecret, + "expand": ["active_auth_session"], + ] + return post(resource: APIEndpointLinkVerified, parameters: parameters) + } + + func fetchNetworkedAccounts( + clientSecret: String, + consumerSessionClientSecret: String + ) -> Future { + let parameters: [String: Any] = [ + "client_secret": clientSecret, + "consumer_session_client_secret": consumerSessionClientSecret, + "expand": ["data.institution"], + ] + return get(resource: APIEndpointNetworkedAccounts, parameters: parameters) + } + + func selectNetworkedAccounts( + selectedAccountIds: [String], + clientSecret: String, + consumerSessionClientSecret: String + ) -> Future { + let parameters: [String: Any] = [ + "selected_accounts": selectedAccountIds, + "client_secret": clientSecret, + "consumer_session_client_secret": consumerSessionClientSecret, + ] + return post(resource: APIEndpointShareNetworkedAccount, parameters: parameters) + } + + func markLinkStepUpAuthenticationVerified( + clientSecret: String + ) -> Future { + let parameters: [String: Any] = [ + "client_secret": clientSecret, + "expand": ["active_auth_session"], + ] + return post(resource: APIEndpointLinkStepUpAuthenticationVerified, parameters: parameters) + } + + func consumerSessionLookup( + emailAddress: String, + clientSecret: String + ) -> Future { + let parameters: [String: Any] = [ + "email_address": + emailAddress + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + "client_secret": clientSecret, + ] + return post(resource: APIEndpointConsumerSessions, parameters: parameters) + } + + // MARK: - Link API's + + func consumerSessionStartVerification( + otpType: String, + customEmailType: String?, + connectionsMerchantName: String?, + consumerSessionClientSecret: String + ) -> Future { + var parameters: [String: Any] = [ + "request_surface": "ios_connections", + "type": otpType, + "credentials": [ + "consumer_session_client_secret": consumerSessionClientSecret, + ], + "locale": Locale.current.toLanguageTag(), + ] + parameters["custom_email_type"] = customEmailType + parameters["connections_merchant_name"] = connectionsMerchantName + return post(resource: "consumers/sessions/start_verification", parameters: parameters) + } + + func consumerSessionConfirmVerification( + otpCode: String, + otpType: String, + consumerSessionClientSecret: String + ) -> Future { + let parameters: [String: Any] = [ + "type": otpType, + "code": otpCode, + "credentials": [ + "consumer_session_client_secret": consumerSessionClientSecret, + ], + "request_surface": "ios_connections", + ] + return post(resource: "consumers/sessions/confirm_verification", parameters: parameters) + } +} + +private let APIEndpointListAccounts = "link_account_sessions/list_accounts" +private let APIEndpointAttachPaymentAccount = "link_account_sessions/attach_payment_account" +private let APIEndpointSessionReceipt = "link_account_sessions/session_receipt" +private let APIEndpointGenerateHostedURL = "link_account_sessions/generate_hosted_url" +private let APIEndpointConsentAcquired = "link_account_sessions/consent_acquired" +private let APIEndpointLinkMoreAccounts = "link_account_sessions/link_more_accounts" +private let APIEndpointComplete = "link_account_sessions/complete" +private let APIEndpointFeaturedInstitutions = "connections/featured_institutions" +private let APIEndpointSearchInstitutions = "connections/institutions" +private let APIEndpointAuthSessions = "connections/auth_sessions" +private let APIEndpointAuthSessionsCancel = "connections/auth_sessions/cancel" +private let APIEndpointAuthSessionsRetrieve = "connections/auth_sessions/retrieve" +private let APIEndpointAuthSessionsOAuthResults = "connections/auth_sessions/oauth_results" +private let APIEndpointAuthSessionsAuthorized = "connections/auth_sessions/authorized" +private let APIEndpointAuthSessionsAccounts = "connections/auth_sessions/accounts" +private let APIEndpointAuthSessionsSelectedAccounts = "connections/auth_sessions/selected_accounts" +private let APIEndpointAuthSessionsEvents = "connections/auth_sessions/events" +// Networking +private let APIEndpointDisableNetworking = "link_account_sessions/disable_networking" +private let APIEndpointLinkStepUpAuthenticationVerified = "link_account_sessions/link_step_up_authentication_verified" +private let APIEndpointLinkVerified = "link_account_sessions/link_verified" +private let APIEndpointNetworkedAccounts = "link_account_sessions/networked_accounts" +private let APIEndpointSaveAccountsToLink = "link_account_sessions/save_accounts_to_link" +private let APIEndpointShareNetworkedAccount = "link_account_sessions/share_networked_account" +private let APIEndpointConsumerSessions = "connections/link_account_sessions/consumer_sessions" diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/BankAccountToken.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/BankAccountToken.swift new file mode 100644 index 00000000..d6faae49 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/BankAccountToken.swift @@ -0,0 +1,40 @@ +// +// BankAccountToken.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 4/8/22. +// + +import Foundation +@_spi(STP) import StripeCore + +public extension StripeAPI { + + struct BankAccountToken { + + // MARK: - Types + + public struct BankAccount { + public let id: String + public let accountHolderName: String? + public let bankName: String? + public let country: String + public let currency: String + public let fingerprint: String? + public let last4: String + public let routingNumber: String? + public let status: String + } + + public let id: String + public let bankAccount: BankAccountToken.BankAccount? + public let clientIp: String? + public let livemode: Bool + public let used: Bool + } +} + +// MARK: - Decodable + +@_spi(STP) extension StripeAPI.BankAccountToken: Decodable {} +@_spi(STP) extension StripeAPI.BankAccountToken.BankAccount: Decodable {} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/ConsumerSession/ConsumerSessionModels.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/ConsumerSession/ConsumerSessionModels.swift new file mode 100644 index 00000000..f2e364f3 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/ConsumerSession/ConsumerSessionModels.swift @@ -0,0 +1,25 @@ +// +// LookupConsumerSessionResponse.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 1/25/23. +// + +import Foundation + +struct ConsumerSessionData: Decodable { + let clientSecret: String + let emailAddress: String + let redactedPhoneNumber: String +} + +struct LookupConsumerSessionResponse: Decodable { + let consumerSession: ConsumerSessionData? + let exists: Bool + let accountId: String? +} + +struct ConsumerSessionResponse: Decodable { + let consumerSession: ConsumerSessionData + let authSessionClientSecret: String? +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsAccount.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsAccount.swift new file mode 100644 index 00000000..d9f4b545 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsAccount.swift @@ -0,0 +1,138 @@ +// +// FinancialConnectionsAccount.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 11/17/21. +// + +import Foundation +@_spi(STP) import StripeCore + +public extension StripeAPI { + + /// A Financial Connections Account represents an account that exists outside of Stripe, to which you have been granted some degree of access. + /// - seealso: https://stripe.com/docs/api/financial_connections/accounts/object + struct FinancialConnectionsAccount { + + // MARK: - Types + + public struct BalanceRefresh { + @frozen public enum Status: String, SafeEnumCodable, Equatable { + case failed = "failed" + case pending = "pending" + case succeeded = "succeeded" + case unparsable + } + /** The time at which the last refresh attempt was initiated. Measured in seconds since the Unix epoch. */ + public let lastAttemptedAt: Int + public let status: Status + } + + public struct CashBalance { + /** The funds available to the account holder. Typically this is the current balance less any holds. Each key is a three-letter [ISO currency code](https://www.iso.org/iso-4217-currency-codes.html), in lowercase. Each value is an integer amount. A positive amount indicates money owed to the account holder. A negative amount indicates money owed by the account holder. */ + public let available: [String: Int]? + } + + public struct CreditBalance { + /** The credit that has been used by the account holder. Each key is a three-letter [ISO currency code](https://www.iso.org/iso-4217-currency-codes.html), in lowercase. Each value is a integer amount. A positive amount indicates money owed to the account holder. A negative amount indicates money owed by the account holder. */ + public let used: [String: Int]? + } + + public struct Balance { + @frozen public enum ModelType: String, SafeEnumCodable, Equatable { + case cash = "cash" + case credit = "credit" + case unparsable + } + /** The time that the external institution calculated this balance. Measured in seconds since the Unix epoch. */ + public let asOf: Int + public let cash: CashBalance? + public let credit: CreditBalance? + /** The balances owed to (or by) the account holder. Each key is a three-letter [ISO currency code](https://www.iso.org/iso-4217-currency-codes.html), in lowercase. Each value is a integer amount. A positive amount indicates money owed to the account holder. A negative amount indicates money owed by the account holder. */ + public let current: [String: Int] + public let type: ModelType + } + + public struct OwnershipRefresh: Codable, Equatable { + @frozen public enum Status: String, SafeEnumCodable, Equatable { + case failed = "failed" + case pending = "pending" + case succeeded = "succeeded" + case unparsable + } + /// The time at which the last refresh attempt was initiated. Measured in seconds since the Unix epoch. + public let lastAttemptedAt: Int + /// The status of the last refresh attempt. + public let status: OwnershipRefresh.Status + } + + @frozen public enum Category: String, SafeEnumCodable, Equatable { + case cash = "cash" + case credit = "credit" + case investment = "investment" + case other = "other" + case unparsable + } + + @frozen public enum Permissions: String, SafeEnumCodable, Equatable { + case balances = "balances" + case ownership = "ownership" + case paymentMethod = "payment_method" + case transactions = "transactions" + case accountNumbers = "account_numbers" + case unparsable + } + + @frozen public enum Status: String, SafeEnumCodable, Equatable { + case active = "active" + case disconnected = "disconnected" + case inactive = "inactive" + case unparsable + } + + @frozen public enum Subcategory: String, SafeEnumCodable, Equatable { + case checking = "checking" + case creditCard = "credit_card" + case lineOfCredit = "line_of_credit" + case mortgage = "mortgage" + case other = "other" + case savings = "savings" + case unparsable + } + + @frozen public enum SupportedPaymentMethodTypes: String, SafeEnumCodable, Equatable { + case link = "link" + case usBankAccount = "us_bank_account" + case unparsable + } + + // MARK: - Public Fields + + public let balance: Balance? + public let balanceRefresh: BalanceRefresh? + public let ownership: String? + /// The state of the most recent attempt to refresh the account owners. + public let ownershipRefresh: OwnershipRefresh? + public let displayName: String? + public let institutionName: String + public let last4: String? + public let category: Category + public let created: Int + public let id: String + public let livemode: Bool + public let permissions: [Permissions]? + public let status: Status + public let subcategory: Subcategory + /** The [PaymentMethod type](https://stripe.com/docs/api/payment_methods/object#payment_method_object-type)(s) that can be created from this FinancialConnectionsAccount. */ + public let supportedPaymentMethodTypes: [SupportedPaymentMethodTypes] + } + +} + +// MARK: - Decodable + +@_spi(STP) extension StripeAPI.FinancialConnectionsAccount: Decodable {} +@_spi(STP) extension StripeAPI.FinancialConnectionsAccount.BalanceRefresh: Decodable {} +@_spi(STP) extension StripeAPI.FinancialConnectionsAccount.CashBalance: Decodable {} +@_spi(STP) extension StripeAPI.FinancialConnectionsAccount.CreditBalance: Decodable {} +@_spi(STP) extension StripeAPI.FinancialConnectionsAccount.Balance: Decodable {} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsAuthSession.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsAuthSession.swift new file mode 100644 index 00000000..14425a55 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsAuthSession.swift @@ -0,0 +1,58 @@ +// +// FinancialConnectionsPartner.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 10/27/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +struct FinancialConnectionsAuthSession: Decodable { + enum Flow: String, SafeEnumCodable, Equatable { + case directWebview = "direct_webview" + case finicityConnectV2Lite = "finicity_connect_v2_lite" + case finicityConnectV2Oauth = "finicity_connect_v2_oauth" + case finicityConnectV2OauthWebview = "finicity_connect_v2_oauth_webview" + case finicityConnectV2OauthRedirect = "finicity_connect_v2_oauth_redirect" + case mxConnect = "mx_connect" + case mxOauth = "mx_oauth" + case mxOauthWebview = "mx_oauth_webview" + case mxOauthAppToApp = "mx_oauth_app_to_app" + case testmode = "testmode" + case testmodeOauth = "testmode_oauth" + case testmodeOauthWebview = "testmode_oauth_webview" + case truelayerEmbedded = "truelayer_embedded" + case truelayerOauth = "truelayer_oauth" + case wellsFargo = "wells_fargo" + case unparsable + } + + let id: String + let flow: Flow? + let institutionSkipAccountSelection: Bool? + let nextPane: FinancialConnectionsSessionManifest.NextPane + let showPartnerDisclosure: Bool? + let skipAccountSelection: Bool? + let url: String? + let isOauth: Bool? + let display: Display? + + var isOauthNonOptional: Bool { + return isOauth ?? false + } + + var requiresNativeRedirect: Bool { + return url?.hasNativeRedirectPrefix ?? false + } + + struct Display: Decodable { + let text: Text? + + struct Text: Decodable { + let oauthPrepane: FinancialConnectionsOAuthPrepane? + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsBulletPoint.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsBulletPoint.swift new file mode 100644 index 00000000..038c8a9c --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsBulletPoint.swift @@ -0,0 +1,20 @@ +// +// FinancialConnectionsBulletPoint.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 1/19/23. +// + +import Foundation + +struct FinancialConnectionsBulletPoint: Decodable { + let icon: FinancialConnectionsImage? + let title: String? + let content: String? + + init(icon: FinancialConnectionsImage, title: String? = nil, content: String? = nil) { + self.icon = icon + self.title = title + self.content = content + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsConsent.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsConsent.swift new file mode 100644 index 00000000..bf036687 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsConsent.swift @@ -0,0 +1,23 @@ +// +// FinancialConnectionsConsent.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 1/19/23. +// + +import Foundation + +struct FinancialConnectionsConsent: Decodable { + let title: String + let body: Body + let aboveCta: String + let cta: String + let belowCta: String? + + let dataAccessNotice: FinancialConnectionsDataAccessNotice + let legalDetailsNotice: FinancialConnectionsLegalDetailsNotice + + struct Body: Decodable { + let bullets: [FinancialConnectionsBulletPoint] + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsDataAccessNotice.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsDataAccessNotice.swift new file mode 100644 index 00000000..d532906f --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsDataAccessNotice.swift @@ -0,0 +1,21 @@ +// +// FinancialConnectionsDataAccessNotice.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 1/19/23. +// + +import Foundation + +struct FinancialConnectionsDataAccessNotice: Decodable { + let title: String + let subtitle: String? + let body: Body + let connectedAccountNotice: String? + let learnMore: String + let cta: String + + struct Body: Decodable { + let bullets: [FinancialConnectionsBulletPoint] + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsImage.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsImage.swift new file mode 100644 index 00000000..a3283bad --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsImage.swift @@ -0,0 +1,13 @@ +// +// FinancialConnectionsImage.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 10/31/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +struct FinancialConnectionsImage: Decodable, Equatable, Hashable { + let `default`: String? +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsInstitution.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsInstitution.swift new file mode 100644 index 00000000..397ac051 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsInstitution.swift @@ -0,0 +1,24 @@ +// +// FinancialConnectionsInstitution.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 6/6/22. +// + +import Foundation +@_spi(STP) import StripeCore + +struct FinancialConnectionsInstitution: Decodable, Hashable, Equatable { + + let id: String + let name: String + let url: String? + let icon: FinancialConnectionsImage? + let logo: FinancialConnectionsImage? +} + +// MARK: - Institution List + +struct FinancialConnectionsInstitutionList: Decodable { + let data: [FinancialConnectionsInstitution] +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsInstitutionSearchResultResource.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsInstitutionSearchResultResource.swift new file mode 100644 index 00000000..cab05833 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsInstitutionSearchResultResource.swift @@ -0,0 +1,13 @@ +// +// FinancialConnectionsInstitutionSearchResultResource.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 3/23/23. +// + +import Foundation + +struct FinancialConnectionsInstitutionSearchResultResource: Decodable { + let data: [FinancialConnectionsInstitution] + let showManualEntry: Bool +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsLegalDetailsNotice.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsLegalDetailsNotice.swift new file mode 100644 index 00000000..9ed8a125 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsLegalDetailsNotice.swift @@ -0,0 +1,19 @@ +// +// FinancialConnectionsLegalDetailsNotice.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 1/19/23. +// + +import Foundation + +struct FinancialConnectionsLegalDetailsNotice: Decodable { + let title: String + let body: Body + let learnMore: String + let cta: String + + struct Body: Decodable { + let bullets: [FinancialConnectionsBulletPoint] + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsMixedOAuthParams.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsMixedOAuthParams.swift new file mode 100644 index 00000000..e36c5c8e --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsMixedOAuthParams.swift @@ -0,0 +1,13 @@ +// +// FinancialConnectionsMixedOAuthParams.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 10/27/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +struct FinancialConnectionsMixedOAuthParams: Decodable { + let publicToken: String? +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsNetworkedAccountsResponse.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsNetworkedAccountsResponse.swift new file mode 100644 index 00000000..de3a3d2e --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsNetworkedAccountsResponse.swift @@ -0,0 +1,46 @@ +// +// FinancialConnectionsNetworkedAccountsResponse.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 6/16/23. +// + +import Foundation + +struct FinancialConnectionsNetworkedAccountsResponse: Decodable { + let data: [FinancialConnectionsPartnerAccount] + let display: Display? + let nextPaneOnAddAccount: FinancialConnectionsSessionManifest.NextPane? + + struct Display: Decodable { + let text: Text? + + struct Text: Decodable { + let returningNetworkingUserAccountPicker: FinancialConnectionsNetworkingAccountPicker? + } + } +} + +struct FinancialConnectionsNetworkingAccountPicker: Decodable { + let title: String + let defaultCta: String + let addNewAccount: AddNewAccount + let accounts: [FinancialConnectionsNetworkingAccountPicker.Account] + + struct AddNewAccount: Decodable { + let body: String + let icon: FinancialConnectionsImage? + } + + struct Account: Decodable { + let id: String + let allowSelection: Bool + // ex. "Select to repair and connect" + let caption: String? + // ex. "Repair and connect account" + let selectionCta: String? + // trailing icon on the account row + let icon: FinancialConnectionsImage? + let selectionCtaIcon: FinancialConnectionsImage? + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsNetworkingLinkSignup.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsNetworkingLinkSignup.swift new file mode 100644 index 00000000..657d5303 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsNetworkingLinkSignup.swift @@ -0,0 +1,20 @@ +// +// FinancialConnectionsNetworkingLinkSignup.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 3/28/23. +// + +import Foundation + +struct FinancialConnectionsNetworkingLinkSignup: Decodable { + let title: String + let body: Body + let aboveCta: String + let cta: String + let skipCta: String + + struct Body: Decodable { + let bullets: [FinancialConnectionsBulletPoint] + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsOAuthPrepane.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsOAuthPrepane.swift new file mode 100644 index 00000000..544473e4 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsOAuthPrepane.swift @@ -0,0 +1,75 @@ +// +// FinancialConnectionsOAuthPrepane.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 1/6/23. +// + +import Foundation +@_spi(STP) import StripeUICore + +struct FinancialConnectionsOAuthPrepane: Decodable { + + let institutionIcon: FinancialConnectionsImage? + let title: String + let body: OauthPrepaneBody + let partnerNotice: OauthPrepanePartnerNotice? + let cta: OauthPrepaneCTA + let dataAccessNotice: FinancialConnectionsDataAccessNotice + + struct OauthPrepaneBody: Decodable { + let entries: [OauthPrepaneBodyEntry]? + + struct OauthPrepaneBodyEntry: Decodable { + + enum Content { + case text(String) + case image(FinancialConnectionsImage) + case unparsable + } + + let content: Content + + init(content: Content) { + self.content = content + } + + enum CodingKeys: String, CodingKey { + case type + case content + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + + // check the `type` before we unwrap `content` because we + // want to avoid cases where an unknown/new `type` has + // the same underlying data-type (ex. String) as a known `type` + guard let type = try? values.decode(String.self, forKey: .type) else { + self.content = .unparsable + return + } + + if type == "text", let text = try? values.decode(String.self, forKey: .content) { + self.content = .text(text) + } else if type == "image", + let image = try? values.decode(FinancialConnectionsImage.self, forKey: .content) + { + self.content = .image(image) + } else { + self.content = .unparsable + } + } + } + } + + struct OauthPrepanePartnerNotice: Decodable { + let partnerIcon: FinancialConnectionsImage? + let text: String + } + + struct OauthPrepaneCTA: Decodable { + let text: String + let icon: FinancialConnectionsImage? + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsPartnerAccount.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsPartnerAccount.swift new file mode 100644 index 00000000..06989387 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsPartnerAccount.swift @@ -0,0 +1,41 @@ +// +// FinancialConnectionsPartnerAccount.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 10/27/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +struct FinancialConnectionsPartnerAccount: Decodable { + let id: String + let name: String + let displayableAccountNumbers: String? + let linkedAccountId: String? // determines whether we show a "Linked" label + let balanceAmount: Int? + let currency: String? + let supportedPaymentMethodTypes: [FinancialConnectionsPaymentMethodType] + let allowSelection: Bool? + let allowSelectionMessage: String? + let status: String? + let institution: FinancialConnectionsInstitution? + let nextPaneOnSelection: FinancialConnectionsSessionManifest.NextPane? + + var allowSelectionNonOptional: Bool { + return allowSelection ?? true + } + var balanceInfo: (balanceAmount: Int, currency: String)? { + if let balanceAmount = balanceAmount, let currency = currency { + return (balanceAmount, currency) + } else { + return nil + } + } +} + +struct FinancialConnectionsAuthSessionAccounts: Decodable { + let data: [FinancialConnectionsPartnerAccount] + let nextPane: FinancialConnectionsSessionManifest.NextPane + let skipAccountSelection: Bool? +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsPaymentAccountResource.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsPaymentAccountResource.swift new file mode 100644 index 00000000..46c0b8b6 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsPaymentAccountResource.swift @@ -0,0 +1,25 @@ +// +// FinancialConnectionsPaymentAccountResource.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 10/27/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +typealias MicrodepositVerificationMethod = FinancialConnectionsPaymentAccountResource.MicrodepositVerificationMethod +struct FinancialConnectionsPaymentAccountResource: Decodable { + + enum MicrodepositVerificationMethod: String, SafeEnumCodable, Equatable { + case descriptorCode = "descriptor_code" + case amounts = "amounts" + case unparsable + } + + let id: String + let nextPane: FinancialConnectionsSessionManifest.NextPane? + let microdepositVerificationMethod: MicrodepositVerificationMethod? + let eligibleForNetworking: Bool? + let networkingSuccessful: Bool? +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsPaymentMethodType.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsPaymentMethodType.swift new file mode 100644 index 00000000..00e5eb24 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsPaymentMethodType.swift @@ -0,0 +1,15 @@ +// +// FinancialConnectionsPaymentMethodType.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 10/27/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +enum FinancialConnectionsPaymentMethodType: String, SafeEnumCodable, Equatable { + case usBankAccount = "us_bank_account" + case link = "link" + case unparsable +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsSession.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsSession.swift new file mode 100644 index 00000000..b3800821 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsSession.swift @@ -0,0 +1,166 @@ +// +// FinancialConnectionsSession.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 1/19/22. +// + +import Foundation +@_spi(STP) import StripeCore + +public extension StripeAPI { + + /** + Financial Connections Session is the programatic representation of the session for connecting financial accounts. + - seealso: https://stripe.com/docs/api/financial_connections/session + */ + struct FinancialConnectionsSession { + + // MARK: - Types + + /// An object representing a list of FinancialConnectionsAccounts. + public struct AccountList { + public let data: [StripeAPI.FinancialConnectionsAccount] + /** True if this list has another page of items after this one that can be fetched. */ + public let hasMore: Bool + + // MARK: - Internal Init + + internal init( + data: [StripeAPI.FinancialConnectionsAccount], + hasMore: Bool + ) { + self.data = data + self.hasMore = hasMore + } + } + + @_spi(STP) public enum PaymentAccount: Decodable { + + // MARK: - Types + + @_spi(STP) public struct BankAccount: Decodable { + public let bankName: String? + public let id: String + public let last4: String + public let routingNumber: String? + } + + case linkedAccount(StripeAPI.FinancialConnectionsAccount) + case bankAccount(StripeAPI.FinancialConnectionsSession.PaymentAccount.BankAccount) + case unparsable + + // MARK: - Decodable + + /** + Per API specification paymentAccount is a polymorphic field denoted by openAPI anyOf modifier. + We are translating it to an enum with associated types. + */ + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(FinancialConnectionsAccount.self) { + self = .linkedAccount(value) + } else if let value = try? container.decode(FinancialConnectionsSession.PaymentAccount.BankAccount.self) + { + self = .bankAccount(value) + } else { + self = .unparsable + } + } + } + + enum Status: String, SafeEnumCodable, Equatable { + case pending + case succeeded + case failed + case cancelled + case unparsable + } + + struct StatusDetails: Decodable { + struct CancelledStatusDetails: Decodable { + enum TerminalStateReason: String, SafeEnumCodable, Equatable { + case other + case customManualEntry = "custom_manual_entry" + case unparsable + } + + let reason: TerminalStateReason + } + + let cancelled: CancelledStatusDetails? + } + + // MARK: - Properties + + public let clientSecret: String + public let id: String + public let accounts: FinancialConnectionsSession.AccountList + public let livemode: Bool + @_spi(STP) public let paymentAccount: PaymentAccount? + @_spi(STP) public let bankAccountToken: BankAccountToken? + let status: Status? + let statusDetails: StatusDetails? + + // MARK: - Internal Init + + internal init( + clientSecret: String, + id: String, + accounts: FinancialConnectionsSession.AccountList, + livemode: Bool, + paymentAccount: PaymentAccount?, + bankAccountToken: BankAccountToken?, + status: Status?, + statusDetails: StatusDetails? + ) { + self.clientSecret = clientSecret + self.id = id + self.accounts = accounts + self.livemode = livemode + self.paymentAccount = paymentAccount + self.bankAccountToken = bankAccountToken + self.status = status + self.statusDetails = statusDetails + } + + // MARK: - Decodable + + enum CodingKeys: String, CodingKey { + case clientSecret = "client_secret" + case id = "id" + case accounts = "accounts" + case linkedAccounts = "linked_accounts" + case livemode = "livemode" + case paymentAccount = "payment_account" + case bankAccountToken = "bank_account_token" + case status = "status" + case statusDetails = "status_details" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let accounts: FinancialConnectionsSession.AccountList + do { + accounts = try container.decode(FinancialConnectionsSession.AccountList.self, forKey: .accounts) + } catch { + accounts = try container.decode(FinancialConnectionsSession.AccountList.self, forKey: .linkedAccounts) + } + self.init( + clientSecret: try container.decode(String.self, forKey: .clientSecret), + id: try container.decode(String.self, forKey: .id), + accounts: accounts, + livemode: try container.decode(Bool.self, forKey: .livemode), + paymentAccount: try? container.decode(PaymentAccount.self, forKey: .paymentAccount), + bankAccountToken: try? container.decode(BankAccountToken.self, forKey: .bankAccountToken), + status: try? container.decode(Status.self, forKey: .status), + statusDetails: try? container.decode(StatusDetails.self, forKey: .statusDetails) + ) + } + } +} + +// MARK: - Decodable + +@_spi(STP) extension StripeAPI.FinancialConnectionsSession: Decodable {} +@_spi(STP) extension StripeAPI.FinancialConnectionsSession.AccountList: Decodable {} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsSessionManifest.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsSessionManifest.swift new file mode 100644 index 00000000..82aa6b5e --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsSessionManifest.swift @@ -0,0 +1,95 @@ +// +// FinancialConnectionsSessionManifest.swift +// +// Generated by swagger-codegen +// https://github.com/swagger-api/swagger-codegen +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +struct FinancialConnectionsSessionManifest: Decodable { + + // MARK: - Types + + enum NextPane: String, SafeEnumCodable, Equatable { + case accountPicker = "account_picker" + case attachLinkedPaymentAccount = "attach_linked_payment_account" + case authOptions = "auth_options" + case bankAuthRepair = "bank_auth_repair" + case consent = "consent" + case institutionPicker = "institution_picker" + case linkAccountPicker = "link_account_picker" + case linkConsent = "link_consent" + case linkLogin = "link_login" + case manualEntry = "manual_entry" + case manualEntrySuccess = "manual_entry_success" + case networkingLinkLoginWarmup = "networking_link_login_warmup" + case networkingLinkSignupPane = "networking_link_signup_pane" + case networkingLinkStepUpVerification = "networking_link_step_up_verification" + case networkingLinkVerification = "networking_link_verification" + case networkingSaveToLinkVerification = "networking_save_to_link_verification" + case partnerAuth = "partner_auth" + case success = "success" + case unexpectedError = "unexpected_error" + case unparsable + + // client-side only panes + case resetFlow = "reset_flow" + case terminalError = "terminal_error" + } + + enum AccountDisconnectionMethod: String, SafeEnumCodable, Equatable { + case dashboard + case support + case email + case link + case unparsable + } + + enum ManualEntryMode: String, SafeEnumCodable, Equatable { + case automatic + case custom + case unparsable + } + + // MARK: - Properties + + let accountholderIsLinkConsumer: Bool? + let activeInstitution: FinancialConnectionsInstitution? + let allowManualEntry: Bool + let businessName: String? + let consentRequired: Bool + let customManualEntryHandling: Bool + let disableLinkMoreAccounts: Bool + let hostedAuthUrl: String? + let successUrl: String? + let cancelUrl: String? + let activeAuthSession: FinancialConnectionsAuthSession? + let initialInstitution: FinancialConnectionsInstitution? + let instantVerificationDisabled: Bool + let institutionSearchDisabled: Bool + let isLinkWithStripe: Bool? + let isNetworkingUserFlow: Bool? + let isStripeDirect: Bool? + let livemode: Bool + let manualEntryUsesMicrodeposits: Bool + let nextPane: NextPane + let permissions: [StripeAPI.FinancialConnectionsAccount.Permissions] + let singleAccount: Bool + let paymentMethodType: FinancialConnectionsPaymentMethodType? + let accountDisconnectionMethod: AccountDisconnectionMethod? + let isEndUserFacing: Bool? + let product: String + let accountholderToken: String? + let features: [String: Bool]? + let experimentAssignments: [String: String]? + let assignmentEventId: String? + let skipSuccessPane: Bool? + let manualEntryMode: ManualEntryMode + let accountholderCustomerEmailAddress: String? + let accountholderPhoneNumber: String? + let stepUpAuthenticationRequired: Bool? +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsSynchronize.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsSynchronize.swift new file mode 100644 index 00000000..4d6a4b70 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/Models/FinancialConnectionsSynchronize.swift @@ -0,0 +1,25 @@ +// +// FinancialConnectionsSynchronize.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 10/20/22. +// + +import Foundation + +struct FinancialConnectionsSynchronize: Decodable { + let manifest: FinancialConnectionsSessionManifest + let text: Text? + let visual: VisualUpdate + + struct Text: Decodable { + let consentPane: FinancialConnectionsConsent? + let networkingLinkSignupPane: FinancialConnectionsNetworkingLinkSignup? + } + + struct VisualUpdate: Decodable { + let reducedBranding: Bool + let merchantLogo: [String] + let reduceManualEntryProminenceInErrors: Bool + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Analytics/FinancialConnectionsAnalyticsClient.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Analytics/FinancialConnectionsAnalyticsClient.swift new file mode 100644 index 00000000..93c37efb --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Analytics/FinancialConnectionsAnalyticsClient.swift @@ -0,0 +1,204 @@ +// +// FinancialConnectionsAnalyticsClient.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 10/13/22. +// + +import Foundation +@_spi(STP) import StripeCore +import UIKit + +final class FinancialConnectionsAnalyticsClient { + + private let analyticsClient: AnalyticsClientV2 + private var additionalParameters: [String: Any] = [:] + + init( + analyticsClient: AnalyticsClientV2 = AnalyticsClientV2( + clientId: "mobile-clients-linked-accounts", + origin: "stripe-linked-accounts-ios" + ) + ) { + self.analyticsClient = analyticsClient + additionalParameters["is_webview"] = false + additionalParameters["navigator_language"] = Locale.current.toLanguageTag() + } + + public func log( + eventName: String, + parameters: [String: Any] = [:], + pane: FinancialConnectionsSessionManifest.NextPane + ) { + let eventName = "linked_accounts.\(eventName)" + + var parameters = parameters + // !!! BE CAREFUL MODIFYING "PANE" ANALYTICS CODE + // ITS CRITICAL FOR PANE CONVERSION !!! + assert(parameters["pane"] == nil, "Unexpected logic: will override 'pane' parameter.") + parameters["pane"] = pane.rawValue + parameters = parameters.merging( + additionalParameters, + uniquingKeysWith: { eventParameter, _ in + // prioritize event `parameters` over `additionalParameters` + return eventParameter + } + ) + + assert( + !parameters.contains(where: { $0.key == "duration" && type(of: $0.value) == TimeInterval.self }), + "Duration is expected to be sent as an Int (miliseconds)." + ) + assert( + !parameters.contains(where: { type(of: $0.value) == FinancialConnectionsSessionManifest.NextPane.self }), + "Do not pass NextPane enum. Use the raw value." + ) + assert((parameters["pane"] as? String) != nil, "We expect pane to be set as a String for all analytics events.") + analyticsClient.log(eventName: eventName, parameters: parameters) + } + + public func logExposure( + experimentName: String, + assignmentEventId: String, + accountholderToken: String + ) { + var parameters = additionalParameters + parameters["experiment_retrieved"] = experimentName + parameters["arb_id"] = assignmentEventId + parameters["account_holder_id"] = accountholderToken + analyticsClient.log(eventName: "preloaded_experiment_retrieved", parameters: parameters) + } +} + +// MARK: - Helpers + +extension FinancialConnectionsAnalyticsClient { + + func logPaneLoaded(pane: FinancialConnectionsSessionManifest.NextPane) { + log(eventName: "pane.loaded", pane: pane) + } + + func logExpectedError( + _ error: Error, + errorName: String, + pane: FinancialConnectionsSessionManifest.NextPane + ) { + log( + error: error, + errorName: errorName, + eventName: "error.expected", + pane: pane + ) + } + + func logUnexpectedError( + _ error: Error, + errorName: String, + pane: FinancialConnectionsSessionManifest.NextPane + ) { + log( + error: error, + errorName: errorName, + eventName: "error.unexpected", + pane: pane + ) + } + + private func log( + error: Error, + errorName: String, + eventName: String, + pane: FinancialConnectionsSessionManifest.NextPane + ) { + var parameters: [String: Any] = [:] + parameters["error"] = errorName + if let stripeError = error as? StripeError, + case .apiError(let apiError) = stripeError + { + parameters["error_type"] = apiError.type.rawValue + parameters["error_message"] = apiError.message + parameters["code"] = apiError.code + } else { + parameters["error_type"] = (error as NSError).domain + parameters["error_message"] = { + if let sheetError = error as? FinancialConnectionsSheetError { + switch sheetError { + case .unknown(let debugDescription): + return debugDescription + } + } else { + return (error as NSError).localizedDescription + } + }() as String + parameters["code"] = (error as NSError).code + } + log(eventName: eventName, parameters: parameters, pane: pane) + } + + func logMerchantDataAccessLearnMore(pane: FinancialConnectionsSessionManifest.NextPane) { + log( + eventName: "click.data_access.learn_more", + pane: pane + ) + } + + func setAdditionalParameters( + linkAccountSessionClientSecret: String, + publishableKey: String?, + stripeAccount: String? + ) { + additionalParameters["las_client_secret"] = linkAccountSessionClientSecret + additionalParameters["key"] = publishableKey + additionalParameters["stripe_account"] = stripeAccount + } + + func setAdditionalParameters(fromManifest manifest: FinancialConnectionsSessionManifest) { + additionalParameters["livemode"] = manifest.livemode + additionalParameters["product"] = manifest.product + additionalParameters["is_stripe_direct"] = manifest.isStripeDirect + additionalParameters["single_account"] = manifest.singleAccount + additionalParameters["allow_manual_entry"] = manifest.allowManualEntry + additionalParameters["account_holder_id"] = manifest.accountholderToken + } + + static func paneFromViewController( + _ viewController: UIViewController? + ) -> FinancialConnectionsSessionManifest.NextPane { + switch viewController { + case is ConsentViewController: + return .consent + case is InstitutionPickerViewController: + return .institutionPicker + case is PartnerAuthViewController: + return .partnerAuth + case is AccountPickerViewController: + return .accountPicker + case is AttachLinkedPaymentAccountViewController: + return .attachLinkedPaymentAccount + case is SuccessViewController: + return .success + case is ManualEntryViewController: + return .manualEntry + case is ManualEntrySuccessViewController: + return .manualEntrySuccess + case is ResetFlowViewController: + return .resetFlow + case is TerminalErrorViewController: + return .terminalError + case is NetworkingLinkSignupViewController: + return .networkingLinkSignupPane + case is NetworkingLinkLoginWarmupViewController: + return .networkingLinkLoginWarmup + case is NetworkingLinkVerificationViewController: + return .networkingLinkVerification + case is NetworkingLinkStepUpVerificationViewController: + return .networkingLinkStepUpVerification + case is NetworkingSaveToLinkVerificationViewController: + return .networkingSaveToLinkVerification + case is LinkAccountPickerViewController: + return .linkAccountPicker + default: + return .unparsable + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Analytics/FinancialConnectionsSheetAnalytics.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Analytics/FinancialConnectionsSheetAnalytics.swift new file mode 100644 index 00000000..90f132d6 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Analytics/FinancialConnectionsSheetAnalytics.swift @@ -0,0 +1,78 @@ +// +// FinancialConnectionsSheetAnalytics.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 12/9/21. +// + +import Foundation +@_spi(STP) import StripeCore + +/// Analytic that contains a `financial connections session clientSecret` payload param +protocol FinancialConnectionsSheetAnalytic: Analytic { + var clientSecret: String { get } + var additionalParams: [String: Any] { get } +} + +extension FinancialConnectionsSheetAnalytic { + var params: [String: Any] { + var params = additionalParams + params["las_client_secret"] = clientSecret + return params + } +} + +/// Logged when the sheet is presented +struct FinancialConnectionsSheetPresentedAnalytic: FinancialConnectionsSheetAnalytic { + let event = STPAnalyticEvent.financialConnectionsSheetPresented + let clientSecret: String + let additionalParams: [String: Any] = [:] +} + +/// Logged when the sheet is closed by the end-user +struct FinancialConnectionsSheetClosedAnalytic: FinancialConnectionsSheetAnalytic { + let event = STPAnalyticEvent.financialConnectionsSheetClosed + let clientSecret: String + let result: String + + var additionalParams: [String: Any] { + return [ + "session_result": result + ] + } +} + +/// Logged if there's an error presenting the sheet +struct FinancialConnectionsSheetFailedAnalytic: FinancialConnectionsSheetAnalytic, ErrorAnalytic { + let event = STPAnalyticEvent.financialConnectionsSheetFailed + let clientSecret: String + let additionalParams: [String: Any] = [:] + let error: Error +} + +/// Helper to determine if we should log a failed analytic or closed analytic from the sheet's completion block +struct FinancialConnectionsSheetCompletionAnalytic { + /// Returns either a `FinancialConnectionsSheetClosedAnalytic` or `FinancialConnectionsSheetFailedAnalytic` depending on the result + static func make( + clientSecret: String, + result: FinancialConnectionsSheet.Result + ) -> FinancialConnectionsSheetAnalytic { + switch result { + case .completed: + return FinancialConnectionsSheetClosedAnalytic( + clientSecret: clientSecret, + result: "completed" + ) + case .canceled: + return FinancialConnectionsSheetClosedAnalytic( + clientSecret: clientSecret, + result: "cancelled" + ) + case .failed(let error): + return FinancialConnectionsSheetFailedAnalytic( + clientSecret: clientSecret, + error: error + ) + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Common/ExperimentHelper.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Common/ExperimentHelper.swift new file mode 100644 index 00000000..a7d5d607 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Common/ExperimentHelper.swift @@ -0,0 +1,69 @@ +// +// ExperimentHelper.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 12/22/22. +// + +import Foundation + +/// Abstracts experimentation logic for a specific experiment. +final class ExperimentHelper { + + private let experimentName: String + private let manifest: FinancialConnectionsSessionManifest + private let analyticsClient: FinancialConnectionsAnalyticsClient + private var didLogExposure = false + + private var isExperimentValid: Bool { + return experimentVariant != nil && manifest.assignmentEventId != nil && manifest.accountholderToken != nil + } + private var experimentVariant: String? { + return manifest.experimentAssignments?[experimentName] + } + + init( + experimentName: String, + manifest: FinancialConnectionsSessionManifest, + analyticsClient: FinancialConnectionsAnalyticsClient + ) { + self.experimentName = experimentName + self.manifest = manifest + self.analyticsClient = analyticsClient + } + + // Helper where we assume that we have two groups: "control" and "treatment." + // If user is in "treatment" group, we return `true`. + func isEnabled(logExposure: Bool) -> Bool { + guard isExperimentValid else { + return false + } + if logExposure { + logExposureIfNeeded() + } + return experimentVariant == "treatment" + } + + private func logExposureIfNeeded() { + guard isExperimentValid else { + return + } + guard let assignmentEventId = manifest.assignmentEventId else { + assertionFailure("`isExperimentValid` should ensure `assignmentEventId` is non-null") + return + } + guard let accountholderToken = manifest.accountholderToken else { + assertionFailure("`isExperimentValid` should ensure `accountholderToken` is non-null") + return + } + + if !didLogExposure { + didLogExposure = true + analyticsClient.logExposure( + experimentName: experimentName, + assignmentEventId: assignmentEventId, + accountholderToken: accountholderToken + ) + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Common/FinancialConnectionsCustomManualEntryRequiredError.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Common/FinancialConnectionsCustomManualEntryRequiredError.swift new file mode 100644 index 00000000..9c8052f6 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Common/FinancialConnectionsCustomManualEntryRequiredError.swift @@ -0,0 +1,10 @@ +// +// FinancialConnectionsCustomManualEntryRequiredError.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/3/23. +// + +import Foundation + +public struct FinancialConnectionsCustomManualEntryRequiredError: Error {} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Common/FinancialConnectionsNavigationController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Common/FinancialConnectionsNavigationController.swift new file mode 100644 index 00000000..4c9ea46f --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Common/FinancialConnectionsNavigationController.swift @@ -0,0 +1,198 @@ +// +// FinancialConnectionsNavigationController.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 6/6/22. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +class FinancialConnectionsNavigationController: UINavigationController { + + // Swift 5.8 requires us to manually mark inits as unavailable as well: + override public init(navigationBarClass: AnyClass?, toolbarClass: AnyClass?) { + super.init(navigationBarClass: navigationBarClass, toolbarClass: toolbarClass) + } + + override public init(rootViewController: UIViewController) { + super.init(rootViewController: rootViewController) + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + // only currently set for native flow + weak var analyticsClient: FinancialConnectionsAnalyticsClient? + private var lastInteractivePopGestureRecognizerEndedDate: Date? + private weak var lastShownViewController: UIViewController? + + // MARK: - UIViewController + + override func viewDidLoad() { + super.viewDidLoad() + // disable the ability for a user to swipe down to dismiss + // because we want to make a network call (and wait for it) + // before a user can fully dismiss + isModalInPresentation = true + listenToInteractivePopGestureRecognizer() + navigationBar.accessibilityIdentifier = "fc_navigation_bar" + } + + private func logNavigationBackEvent(fromViewController: UIViewController, source: String) { + guard let analyticsClient = analyticsClient else { + assertionFailure("Expected `analyticsClient` (\(FinancialConnectionsAnalyticsClient.self)) to be set.") + return + } + analyticsClient + .log( + // we use the same event name for both clicks and swipes to + // simplify analytics logging (same event, different parameters) + eventName: "click.nav_bar.back", + parameters: [ + "source": source, + ], + pane: FinancialConnectionsAnalyticsClient.paneFromViewController(fromViewController) + ) + } +} + +// MARK: - Track Swipe Back Analytics Events + +extension FinancialConnectionsNavigationController: UINavigationControllerDelegate { + + private func listenToInteractivePopGestureRecognizer() { + delegate = self + assert(interactivePopGestureRecognizer != nil) + interactivePopGestureRecognizer?.addTarget(self, action: #selector(interactivePopGestureRecognizerDidChange)) + } + + @objc private func interactivePopGestureRecognizerDidChange() { + if interactivePopGestureRecognizer?.state == .ended { + // As soon as user releases the "interactive pop" the gesture will + // move to the `.ended` state. Note that this does NOT mean + // that the user actually finished the pop gesture and + // popped the view controller. + lastInteractivePopGestureRecognizerEndedDate = Date() + } + } + + func navigationController( + _ navigationController: UINavigationController, + didShow viewController: UIViewController, + animated: Bool + ) { + if let lastInteractivePopGestureRecognizerEndedDate = lastInteractivePopGestureRecognizerEndedDate, + Date().timeIntervalSince(lastInteractivePopGestureRecognizerEndedDate) < 0.7, + let lastShownViewController = lastShownViewController + { + // If user _recently_ ended the interactive pop gesture + // AND navigation controller presented a new view controller + // it's extremely likely that user popped a view controller + // by using the swipe gesture. + logNavigationBackEvent(fromViewController: lastShownViewController, source: "interactive_pop_gesture") + } + lastInteractivePopGestureRecognizerEndedDate = nil + lastShownViewController = viewController + } +} + +// MARK: - Track Back Button Press Analytics Events + +extension FinancialConnectionsNavigationController: UINavigationBarDelegate { + + // `UINavigationBarDelegate` methods "just work" on `UINavigationController` + // without having to set any delegates + func navigationBar( + _ navigationBar: UINavigationBar, + shouldPop item: UINavigationItem + ) -> Bool { + if let topViewController = topViewController { + logNavigationBackEvent(fromViewController: topViewController, source: "navigation_bar_button") + } else { + assertionFailure( + "Expected a `topViewConroller` to exist for \(FinancialConnectionsNavigationController.self)" + ) + } + return true + } +} + +// MARK: - `UINavigationController` Modifications + +// The purpose of this extension is to consolidate in one place +// all the common changes to `UINavigationController` +extension FinancialConnectionsNavigationController { + + func configureAppearanceForNative() { + let backButtonImage = Image + .back_arrow + .makeImage(template: false) + .withAlignmentRectInsets(UIEdgeInsets(top: 0, left: -13, bottom: -2, right: 0)) + let appearance = UINavigationBarAppearance() + appearance.setBackIndicatorImage(backButtonImage, transitionMaskImage: backButtonImage) + appearance.backgroundColor = .customBackgroundColor + appearance.shadowColor = .clear // remove border + navigationBar.standardAppearance = appearance + navigationBar.scrollEdgeAppearance = appearance + navigationBar.compactAppearance = appearance + + // change the back button color + navigationBar.tintColor = UIColor.textDisabled + navigationBar.isTranslucent = false + } + + static func configureNavigationItemForNative( + _ navigationItem: UINavigationItem?, + closeItem: UIBarButtonItem, + shouldHideStripeLogo: Bool, + shouldLeftAlignStripeLogo: Bool + ) { + if !shouldHideStripeLogo { + let stripeLogoView: UIView = { + let stripeLogoImageView = UIImageView( + image: { + if shouldLeftAlignStripeLogo { + return Image + .stripe_logo + .makeImage(template: true) + .withInsets(UIEdgeInsets(top: 0, left: 3, bottom: 0, right: 0)) + } else { + return Image + .stripe_logo + .makeImage(template: true) + } + }() + ) + stripeLogoImageView.tintColor = UIColor.textBrand + stripeLogoImageView.contentMode = .scaleAspectFit + stripeLogoImageView.sizeToFit() + stripeLogoImageView.frame = CGRect( + x: 0, + y: 0, + width: stripeLogoImageView.bounds.width * (20 / max(1, stripeLogoImageView.bounds.height)), + height: 20 + ) + // If `titleView` is directly set to the `UIImageView` + // we can't control the sizing...so we create a `containerView` + // so we can control `UIImageView` sizing. + let containerView = UIView() + containerView.frame = stripeLogoImageView.bounds + containerView.addSubview(stripeLogoImageView) + + stripeLogoImageView.center = containerView.center + return containerView + }() + + if shouldLeftAlignStripeLogo { + navigationItem?.leftBarButtonItem = UIBarButtonItem(customView: stripeLogoView) + } else { + navigationItem?.titleView = stripeLogoView + } + } + navigationItem?.backButtonTitle = "" + navigationItem?.rightBarButtonItem = closeItem + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Common/FlowRouter.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Common/FlowRouter.swift new file mode 100644 index 00000000..ff68888a --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Common/FlowRouter.swift @@ -0,0 +1,91 @@ +// +// FlowRouter.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 11/1/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +class FlowRouter { + + private let synchronizePayload: FinancialConnectionsSynchronize + private let analyticsClient: FinancialConnectionsAnalyticsClient + + // MARK: - Init + + init( + synchronizePayload: FinancialConnectionsSynchronize, + analyticsClient: FinancialConnectionsAnalyticsClient + ) { + self.synchronizePayload = synchronizePayload + self.analyticsClient = analyticsClient + } + + // MARK: - Private + + private var killswitchActive: Bool { + // If the manifest is missing features map, fallback to webview. + guard let features = synchronizePayload.manifest.features else { return true } + + // If native version killswitch feature is missing, fallback to webview. + guard let killswitchValue = features[Constants.killswitchFeature] else { return true } + + return killswitchValue + } + + private var experimentVariant: String? { + return synchronizePayload.manifest.experimentAssignments?[Constants.nativeExperiment] + } + + // MARK: - Public + + var shouldUseNative: Bool { + if let isNativeEnabled = UserDefaults.standard.value( + forKey: "FINANCIAL_CONNECTIONS_EXAMPLE_APP_ENABLE_NATIVE" + ) as? Bool { + return isNativeEnabled + } + + // if this version is killswitched by server, fallback to webview. + if killswitchActive { return false } + + // If native experiment is missing, fallback to webview. + guard let experimentVariant = experimentVariant else { return false } + + return experimentVariant == Constants.nativeExperimentTreatment + } + + func logExposureIfNeeded() { + + // if this version is killswitched by server, don't log exposure. + if killswitchActive { return } + + // If native experiment is missing, don't log exposure. + if experimentVariant == nil { return } + + // If assignmentIdIsMissing, don't log exposure. + guard let assignmentEventId = synchronizePayload.manifest.assignmentEventId else { return } + + // If account holder is unknown, don't log exposure. + guard let accountHolder = synchronizePayload.manifest.accountholderToken else { return } + + analyticsClient.logExposure( + experimentName: Constants.nativeExperiment, + assignmentEventId: assignmentEventId, + accountholderToken: accountHolder + ) + + } +} + +// MARK: - Constants + +private extension FlowRouter { + enum Constants { + static let killswitchFeature = "bank_connections_mobile_native_version_killswitch" + static let nativeExperiment = "connections_mobile_native" + static let nativeExperimentTreatment = "treatment" + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Common/HostController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Common/HostController.swift new file mode 100644 index 00000000..2f28f799 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Common/HostController.swift @@ -0,0 +1,161 @@ +// +// HostController.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 6/3/22. +// + +@_spi(STP) import StripeCore +import UIKit + +protocol HostControllerDelegate: AnyObject { + + func hostController( + _ hostController: HostController, + viewController: UIViewController, + didFinish result: FinancialConnectionsSheet.Result + ) +} + +class HostController { + + // MARK: - Properties + + private let api: FinancialConnectionsAPIClient + private let clientSecret: String + private let returnURL: String? + private let analyticsClient: FinancialConnectionsAnalyticsClient + + private var nativeFlowController: NativeFlowController? + lazy var hostViewController = HostViewController( + clientSecret: clientSecret, + returnURL: returnURL, + apiClient: api, + delegate: self + ) + lazy var navigationController = FinancialConnectionsNavigationController(rootViewController: hostViewController) + + weak var delegate: HostControllerDelegate? + + // MARK: - Init + + init( + api: FinancialConnectionsAPIClient, + clientSecret: String, + returnURL: String?, + publishableKey: String?, + stripeAccount: String? + ) { + self.api = api + self.clientSecret = clientSecret + self.returnURL = returnURL + self.analyticsClient = FinancialConnectionsAnalyticsClient() + analyticsClient.setAdditionalParameters( + linkAccountSessionClientSecret: clientSecret, + publishableKey: publishableKey, + stripeAccount: stripeAccount + ) + } +} + +// MARK: - HostViewControllerDelegate + +extension HostController: HostViewControllerDelegate { + func hostViewControllerDidFinish(_ viewController: HostViewController, lastError: Error?) { + guard let error = lastError else { + delegate?.hostController(self, viewController: viewController, didFinish: .canceled) + return + } + + delegate?.hostController(self, viewController: viewController, didFinish: .failed(error: error)) + } + + func hostViewController( + _ viewController: HostViewController, + didFetch synchronizePayload: FinancialConnectionsSynchronize + ) { + guard + let consentPaneModel = synchronizePayload.text?.consentPane + else { + continueWithWebFlow(synchronizePayload.manifest) + return + } + + let flowRouter = FlowRouter( + synchronizePayload: synchronizePayload, + analyticsClient: analyticsClient + ) + defer { + // no matter how we exit this function + // log exposure to one of the variants if appropriate. + flowRouter.logExposureIfNeeded() + } + + guard flowRouter.shouldUseNative else { + continueWithWebFlow(synchronizePayload.manifest) + return + } + + navigationController.configureAppearanceForNative() + + let dataManager = NativeFlowAPIDataManager( + manifest: synchronizePayload.manifest, + visualUpdate: synchronizePayload.visual, + returnURL: returnURL, + consentPaneModel: consentPaneModel, + apiClient: api, + clientSecret: clientSecret, + analyticsClient: analyticsClient + ) + nativeFlowController = NativeFlowController( + dataManager: dataManager, + navigationController: navigationController + ) + nativeFlowController?.delegate = self + nativeFlowController?.startFlow() + } +} + +// MARK: - Helpers + +private extension HostController { + + func continueWithWebFlow(_ manifest: FinancialConnectionsSessionManifest) { + let accountFetcher = FinancialConnectionsAccountAPIFetcher(api: api, clientSecret: clientSecret) + let sessionFetcher = FinancialConnectionsSessionAPIFetcher( + api: api, + clientSecret: clientSecret, + accountFetcher: accountFetcher + ) + let webFlowViewController = FinancialConnectionsWebFlowViewController( + clientSecret: clientSecret, + apiClient: api, + manifest: manifest, + sessionFetcher: sessionFetcher, + returnURL: returnURL + ) + webFlowViewController.delegate = self + navigationController.setViewControllers([webFlowViewController], animated: true) + } +} + +// MARK: - ConnectionsWebFlowViewControllerDelegate + +extension HostController: FinancialConnectionsWebFlowViewControllerDelegate { + func financialConnectionsWebFlow( + viewController: FinancialConnectionsWebFlowViewController, + didFinish result: FinancialConnectionsSheet.Result + ) { + delegate?.hostController(self, viewController: viewController, didFinish: result) + } +} + +extension HostController: NativeFlowControllerDelegate { + func authFlow(controller: NativeFlowController, didFinish result: FinancialConnectionsSheet.Result) { + guard let viewController = navigationController.topViewController else { + assertionFailure("Navigation stack is empty") + return + } + delegate?.hostController(self, viewController: viewController, didFinish: result) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Common/HostViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Common/HostViewController.swift new file mode 100644 index 00000000..16950025 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Common/HostViewController.swift @@ -0,0 +1,126 @@ +// +// HostViewController.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 6/3/22. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol HostViewControllerDelegate: AnyObject { + + func hostViewControllerDidFinish( + _ viewController: HostViewController, + lastError: Error? + ) + + func hostViewController( + _ viewController: HostViewController, + didFetch synchronizePayload: FinancialConnectionsSynchronize + ) +} + +final class HostViewController: UIViewController { + + // MARK: - UI + + private lazy var closeItem: UIBarButtonItem = { + let item = UIBarButtonItem( + image: Image.close.makeImage(template: false), + style: .plain, + target: self, + action: #selector(didTapClose) + ) + item.tintColor = .textDisabled + return item + }() + + private let loadingView = LoadingView(frame: .zero) + + // MARK: - Properties + + weak var delegate: HostViewControllerDelegate? + + private let clientSecret: String + private let apiClient: FinancialConnectionsAPIClient + private let returnURL: String? + + private var lastError: Error? + + // MARK: - Init + + init( + clientSecret: String, + returnURL: String?, + apiClient: FinancialConnectionsAPIClient, + delegate: HostViewControllerDelegate? + ) { + self.clientSecret = clientSecret + self.returnURL = returnURL + self.apiClient = apiClient + self.delegate = delegate + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - UIViewController + + override func viewDidLoad() { + super.viewDidLoad() + + view.addSubview(loadingView) + view.backgroundColor = .customBackgroundColor + navigationItem.rightBarButtonItem = closeItem + loadingView.tryAgainButton.addTarget(self, action: #selector(didTapTryAgainButton), for: .touchUpInside) + getManifest() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + loadingView.frame = view.bounds.inset(by: view.safeAreaInsets) + } +} + +// MARK: - Helpers + +extension HostViewController { + private func getManifest() { + loadingView.errorView.isHidden = true + loadingView.activityIndicatorView.stp_startAnimatingAndShow() + apiClient + .synchronize(clientSecret: clientSecret, returnURL: returnURL) + .observe { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let synchronizePayload): + self.lastError = nil + self.delegate?.hostViewController(self, didFetch: synchronizePayload) + case .failure(let error): + self.loadingView.activityIndicatorView.stp_stopAnimatingAndHide() + self.loadingView.errorView.isHidden = false + self.lastError = error + } + } + } +} + +// MARK: - UI Helpers + +private extension HostViewController { + + @objc + func didTapTryAgainButton() { + getManifest() + } + + @objc + func didTapClose() { + delegate?.hostViewControllerDidFinish(self, lastError: lastError) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Common/LoadingView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Common/LoadingView.swift new file mode 100644 index 00000000..4512dd94 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Common/LoadingView.swift @@ -0,0 +1,97 @@ +// +// LoadingView.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 6/3/22. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +class LoadingView: UIView { + + // MARK: - Subview Properties + + private lazy var errorLabel: UILabel = { + let label = UILabel() + label.text = STPLocalizedString( + "Failed to connect", + "Error message that displays when we're unable to connect to the server." + ) + label.textAlignment = .center + label.numberOfLines = 0 + label.font = Styling.errorLabelFont + label.textColor = .textPrimary + return label + }() + + private(set) lazy var tryAgainButton: StripeUICore.Button = { + + let button = StripeUICore.Button( + configuration: .primary(), + title: String.Localized.tryAgain + ) + return button + }() + + internal let errorView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.distribution = .fill + stackView.alignment = .center + stackView.spacing = Styling.errorViewSpacing + return stackView + }() + + internal let activityIndicatorView: UIActivityIndicatorView = { + let activityIndicatorView = UIActivityIndicatorView() + activityIndicatorView.style = .large + return activityIndicatorView + }() + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + + errorView.addArrangedSubview(errorLabel) + errorView.addArrangedSubview(tryAgainButton) + addSubview(errorView) + addSubview(activityIndicatorView) + + // Add constraints + errorView.translatesAutoresizingMaskIntoConstraints = false + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + + tryAgainButton.setContentHuggingPriority(.required, for: .vertical) + tryAgainButton.setContentCompressionResistancePriority(.required, for: .vertical) + errorLabel.setContentHuggingPriority(.required, for: .vertical) + errorLabel.setContentCompressionResistancePriority(.required, for: .vertical) + + NSLayoutConstraint.activate([ + // Center activity indicator + activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor), + activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor), + + // Pin error view to top + errorView.centerYAnchor.constraint(equalTo: centerYAnchor), + errorView.centerXAnchor.constraint(equalTo: centerXAnchor), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Styling + +private extension LoadingView { + enum Styling { + static let errorViewSpacing: CGFloat = 16 + static var errorLabelFont: UIFont { + UIFont.preferredFont(forTextStyle: .body, weight: .medium) + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Common/ModalPresentationWrapperViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Common/ModalPresentationWrapperViewController.swift new file mode 100644 index 00000000..81645af8 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Common/ModalPresentationWrapperViewController.swift @@ -0,0 +1,50 @@ +// +// ModalPresentationWrapperViewController.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 12/22/22. +// + +import UIKit + +class ModalPresentationWrapperViewController: UIViewController { + + private weak var vc: UIViewController? + + // MARK: - Init + + init(vc: UIViewController) { + self.vc = vc + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - UIViewController + + override func viewDidLoad() { + super.viewDidLoad() + + view.alpha = 0.3 + view.backgroundColor = .black + view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTap))) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if let vc = vc, presentedViewController == nil { + self.present(vc, animated: true) + } + } + + // MARK: - Touch Handler + + @objc + private func didTap() { + dismiss(animated: false) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/FinancialConnectionsSDK/FinancialConnectionsSDKImplementation.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/FinancialConnectionsSDK/FinancialConnectionsSDKImplementation.swift new file mode 100644 index 00000000..e0799b4f --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/FinancialConnectionsSDK/FinancialConnectionsSDKImplementation.swift @@ -0,0 +1,122 @@ +// +// FinancialConnectionsSDKImplementation.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 2/24/22. +// + +@_spi(STP) import StripeCore +import UIKit + +/** + NOTE: If you change the name of this class, make sure to also change it FinancialConnectionsSDKAvailability file + */ +@_spi(STP) +public class FinancialConnectionsSDKImplementation: FinancialConnectionsSDKInterface { + required public init() {} + + public func presentFinancialConnectionsSheet( + apiClient: STPAPIClient, + clientSecret: String, + returnURL: String?, + from presentingViewController: UIViewController, + completion: @escaping (FinancialConnectionsSDKResult) -> Void + ) { + let financialConnectionsSheet = FinancialConnectionsSheet( + financialConnectionsSessionClientSecret: clientSecret, + returnURL: returnURL + ) + financialConnectionsSheet.apiClient = apiClient + // Captures self explicitly until the callback is invoked + financialConnectionsSheet.present( + from: presentingViewController, + completion: { result in + switch result { + case .completed(let session): + guard let paymentAccount = session.paymentAccount else { + completion( + .failed( + error: FinancialConnectionsSheetError.unknown( + debugDescription: "PaymentAccount is not set on FinancialConnectionsSession" + ) + ) + ) + return + } + if let linkedBank = self.linkedBankFor(paymentAccount: paymentAccount, session: session) { + completion(.completed(linkedBank: linkedBank)) + } else { + completion( + .failed( + error: FinancialConnectionsSheetError.unknown( + debugDescription: "Unknown PaymentAccount is set on FinancialConnectionsSession" + ) + ) + ) + } + case .canceled: + completion(.cancelled) + case .failed(let error): + completion(.failed(error: error)) + } + } + ) + } + + // MARK: - Helpers + + private func linkedBankFor( + paymentAccount: StripeAPI.FinancialConnectionsSession.PaymentAccount, + session: StripeAPI.FinancialConnectionsSession + ) -> LinkedBank? { + switch paymentAccount { + case .linkedAccount(let linkedAccount): + return LinkedBankImplementation( + with: session.id, + accountId: linkedAccount.id, + displayName: linkedAccount.displayName, + bankName: linkedAccount.institutionName, + last4: linkedAccount.last4, + instantlyVerified: true + ) + case .bankAccount(let bankAccount): + return LinkedBankImplementation( + with: session.id, + accountId: bankAccount.id, + displayName: bankAccount.bankName, + bankName: bankAccount.bankName, + last4: bankAccount.last4, + instantlyVerified: false + ) + case .unparsable: + return nil + } + } + +} + +// MARK: - LinkedBank Implementation +struct LinkedBankImplementation: LinkedBank { + public let sessionId: String + public let accountId: String + public let displayName: String? + public let bankName: String? + public let last4: String? + public let instantlyVerified: Bool + + public init( + with sessionId: String, + accountId: String, + displayName: String?, + bankName: String?, + last4: String?, + instantlyVerified: Bool + ) { + self.sessionId = sessionId + self.accountId = accountId + self.displayName = displayName + self.bankName = bankName + self.last4 = last4 + self.instantlyVerified = instantlyVerified + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/FinancialConnectionsSheet.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/FinancialConnectionsSheet.swift new file mode 100644 index 00000000..50011d23 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/FinancialConnectionsSheet.swift @@ -0,0 +1,236 @@ +// +// FinancialConnectionsSheet.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 11/10/21. +// + +@_spi(STP) import StripeCore +import UIKit + +/** + A drop-in class that presents a sheet for a user to connect their financial accounts. + This class is in beta; see https://stripe.com/docs/financial-connections for access + */ +final public class FinancialConnectionsSheet { + + // MARK: - Types + + /// The result of financial account connection flow + @frozen public enum Result { + /// User completed the financialConnections session + case completed(session: StripeAPI.FinancialConnectionsSession) + /// Failed with error + case failed(error: Error) + /// User canceled out of the financialConnections session + case canceled + } + + @frozen public enum TokenResult { + // User completed the financialConnections session + case completed( + result: ( + session: StripeAPI.FinancialConnectionsSession, + token: StripeAPI.BankAccountToken? + ) + ) + // Failed with error + case failed(error: Error) + // User canceled out of the financialConnections session + case canceled + } + + // MARK: - Properties + + /** + The client secret of the Stripe FinancialConnectionsSession object. + See https://stripe.com/docs/api/financial_connections/sessions/object#financial_connections_session_object-client_secret + */ + public let financialConnectionsSessionClientSecret: String + + /// A URL that redirects back to your app that FinancialConnectionsSheet can use + /// get back to your app after completing authentication in another app (such as bank app or Safari). + public let returnURL: String? + + /// The APIClient instance used to make requests to Stripe + public var apiClient: STPAPIClient = STPAPIClient.shared { + didSet { + APIVersion.configureFinancialConnectionsAPIVersion(apiClient: apiClient) + } + } + + /// Completion block called when the sheet is closed or fails to open + private var completion: ((Result) -> Void)? + + private var hostController: HostController? + + private var wrapperViewController: ModalPresentationWrapperViewController? + + // Analytics client to use for logging analytics + @_spi(STP) public let analyticsClient: STPAnalyticsClientProtocol + + // MARK: - Init + + /** + Initializes a `FinancialConnectionsSheet`. + + - Parameters: + - financialConnectionsSessionClientSecret: The [client secret](https://stripe.com/docs/api/financial_connections/sessions/object#financial_connections_session_object-client_secret) of a Stripe FinancialConnectionsSession object. + - returnURL: A URL that redirects back to your application. FinancialConnectionsSheet uses it after completing authentication in another application (such as a bank application or Safari). + */ + public convenience init(financialConnectionsSessionClientSecret: String, returnURL: String? = nil) { + self.init( + financialConnectionsSessionClientSecret: financialConnectionsSessionClientSecret, + returnURL: returnURL, + analyticsClient: STPAnalyticsClient.sharedClient + ) + } + + init( + financialConnectionsSessionClientSecret: String, + returnURL: String?, + analyticsClient: STPAnalyticsClientProtocol + ) { + self.financialConnectionsSessionClientSecret = financialConnectionsSessionClientSecret + self.returnURL = returnURL + self.analyticsClient = analyticsClient + + analyticsClient.addClass(toProductUsageIfNecessary: FinancialConnectionsSheet.self) + APIVersion.configureFinancialConnectionsAPIVersion(apiClient: apiClient) + } + + // MARK: - Public + + public func presentForToken( + from presentingViewController: UIViewController, + completion: @escaping (TokenResult) -> Void + ) { + present(from: presentingViewController) { result in + switch result { + case .completed(let session): + completion(.completed(result: (session: session, token: session.bankAccountToken))) + case .failed(let error): + completion(.failed(error: error)) + case .canceled: + completion(.canceled) + } + } + } + + /** + Presents a sheet for a customer to connect their financial account. + - Parameters: + - presentingViewController: The view controller to present the financial connections sheet. + - completion: Called with the result of the financial connections session after the financial connections sheet is dismissed. + */ + public func present( + from presentingViewController: UIViewController, + completion: @escaping (Result) -> Void + ) { + // Overwrite completion closure to retain self until called + let completion: (Result) -> Void = { result in + self.analyticsClient.log( + analytic: FinancialConnectionsSheetCompletionAnalytic.make( + clientSecret: self.financialConnectionsSessionClientSecret, + result: result + ), + apiClient: self.apiClient + ) + completion(result) + self.completion = nil + } + self.completion = completion + + // Guard against basic user error + guard presentingViewController.presentedViewController == nil else { + assertionFailure("presentingViewController is already presenting a view controller") + let error = FinancialConnectionsSheetError.unknown( + debugDescription: "presentingViewController is already presenting a view controller" + ) + completion(.failed(error: error)) + return + } + + if let urlString = returnURL { + guard URL(string: urlString) != nil else { + assertionFailure( + "invalid returnURL: \(urlString) parameter passed in when creating FinancialConnectionsSheet" + ) + let error = FinancialConnectionsSheetError.unknown( + debugDescription: + "invalid returnURL: \(urlString) parameter passed in when creating FinancialConnectionsSheet" + ) + completion(.failed(error: error)) + return + } + } + + hostController = HostController( + api: apiClient, + clientSecret: financialConnectionsSessionClientSecret, + returnURL: returnURL, + publishableKey: apiClient.publishableKey, + stripeAccount: apiClient.stripeAccount + ) + hostController?.delegate = self + + analyticsClient.log( + analytic: FinancialConnectionsSheetPresentedAnalytic( + clientSecret: self.financialConnectionsSessionClientSecret + ), + apiClient: apiClient + ) + let navigationController = hostController!.navigationController + present(navigationController, presentingViewController) + } + + private func present( + _ navigationController: FinancialConnectionsNavigationController, + _ presentingViewController: UIViewController + ) { + let toPresent: UIViewController + let animated: Bool + if UIDevice.current.userInterfaceIdiom == .pad { + navigationController.modalPresentationStyle = .formSheet + toPresent = navigationController + animated = true + } else { + wrapperViewController = ModalPresentationWrapperViewController(vc: navigationController) + toPresent = wrapperViewController! + animated = false + } + presentingViewController.present(toPresent, animated: animated, completion: nil) + } +} + +// MARK: - HostControllerDelegate + +/// :nodoc: +extension FinancialConnectionsSheet: HostControllerDelegate { + func hostController(_ hostController: HostController, viewController: UIViewController, didFinish result: Result) { + viewController.dismiss( + animated: true, + completion: { + if let wrapperViewController = self.wrapperViewController { + wrapperViewController.dismiss( + animated: false, + completion: { + self.completion?(result) + } + ) + self.wrapperViewController = nil + } else { + self.completion?(result) + } + } + ) + } +} + +// MARK: - STPAnalyticsProtocol + +/// :nodoc: +@_spi(STP) +extension FinancialConnectionsSheet: STPAnalyticsProtocol { + @_spi(STP) public static var stp_analyticsIdentifier = "FinancialConnectionsSheet" +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/FinancialConnectionsSheetError.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/FinancialConnectionsSheetError.swift new file mode 100644 index 00000000..bdb1a924 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/FinancialConnectionsSheetError.swift @@ -0,0 +1,43 @@ +// +// FinancialConnectionsSheetError.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 11/17/21. +// + +import Foundation +@_spi(STP) import StripeCore + +/** + Errors specific to the `FinancialConnectionsSheet`. + */ +public enum FinancialConnectionsSheetError: Error, LocalizedError { + /// An unknown error. + case unknown(debugDescription: String) + + /// Localized description of the error + public var localizedDescription: String { + return NSError.stp_unexpectedErrorMessage() + } +} + +/// :nodoc: +@_spi(STP) extension FinancialConnectionsSheetError: AnalyticLoggableError { + + /// The error code + public var errorCode: Int { + switch self { + case .unknown: + return 0 + } + } + + /// Serializes this error + /// - Returns: an error with a domain and code + public func analyticLoggableSerializeForLogging() -> [String: Any] { + return [ + "domain": "Stripe.\(FinancialConnectionsSheetError.self)", + "code": errorCode, + ] + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/FinancialConnectionsFont.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/FinancialConnectionsFont.swift new file mode 100644 index 00000000..ecdcbe90 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/FinancialConnectionsFont.swift @@ -0,0 +1,163 @@ +// +// FinancialConnectionsFont.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 5/2/23. +// + +import Foundation +import UIKit + +// A wrapper around `UIFont` that allows us to specify a `lineHeight`. +// `UIFont` does not support modifying `lineHeight` so this struct +// helps us to easily pass around font + line height. +struct FinancialConnectionsFont { + + let uiFont: UIFont + let lineHeight: CGFloat + + // An estimated "top padding of the font character" + var topPadding: CGFloat { + return max(0, ((lineHeight - uiFont.lineHeight) / 2)) + (uiFont.ascender - uiFont.capHeight) + } + + enum HeadingToken { + /// 20 size / 28 line height / 700 weight + case medium + /// 24 size / 32 line height / 700 weight + case large + } + static func heading(_ token: HeadingToken) -> FinancialConnectionsFont { + let font: UIFont + let lineHeight: CGFloat + let appleTextStyle: UIFont.TextStyle + switch token { + case .medium: + font = UIFont.systemFont(ofSize: 20, weight: .bold) + lineHeight = 28 + appleTextStyle = .title3 + case .large: + font = UIFont.systemFont(ofSize: 24, weight: .bold) + lineHeight = 32 + appleTextStyle = .title2 + } + return .create(font: font, lineHeight: lineHeight, appleTextStyle: appleTextStyle) + } + + enum BodyToken { + /// 14 size / 20 line height / 400 weight + case small + /// 14 size / 20 line height / 600 weight + case smallEmphasized + /// 16 size / 24 line height / 400 weight + case medium + /// 16 size / 24 line height / 600 weight + case mediumEmphasized + } + static func body(_ token: BodyToken) -> FinancialConnectionsFont { + let font: UIFont + let lineHeight: CGFloat + let appleTextStyle: UIFont.TextStyle + switch token { + case .small: + font = UIFont.systemFont(ofSize: 14, weight: .regular) + lineHeight = 20 + appleTextStyle = .footnote + case .smallEmphasized: + font = UIFont.systemFont(ofSize: 14, weight: .semibold) + lineHeight = 20 + appleTextStyle = .footnote + case .medium: + font = UIFont.systemFont(ofSize: 16, weight: .regular) + lineHeight = 24 + appleTextStyle = .callout + case .mediumEmphasized: + font = UIFont.systemFont(ofSize: 16, weight: .semibold) + lineHeight = 24 + appleTextStyle = .callout + } + return .create(font: font, lineHeight: lineHeight, appleTextStyle: appleTextStyle) + } + + enum LabelToken { + /// 12 size / 16 line height / 400 weight + case small + /// 12 size / 16 line height / 600 weight + case smallEmphasized + /// 14 size / 20 line height / 400 weight + case medium + /// 14 size / 20 line height / 600 weight + case mediumEmphasized + /// 16 size / 24 line height / 400 weight + case large + /// 16 size / 24 line height / 600 weight + case largeEmphasized + } + static func label(_ token: LabelToken) -> FinancialConnectionsFont { + let font: UIFont + let lineHeight: CGFloat + let appleTextStyle: UIFont.TextStyle + switch token { + case .small: + font = UIFont.systemFont(ofSize: 12, weight: .regular) + lineHeight = 16 + appleTextStyle = .caption1 + case .smallEmphasized: + font = UIFont.systemFont(ofSize: 12, weight: .semibold) + lineHeight = 16 + appleTextStyle = .caption1 + case .medium: + font = UIFont.systemFont(ofSize: 14, weight: .regular) + lineHeight = 20 + appleTextStyle = .footnote + case .mediumEmphasized: + font = UIFont.systemFont(ofSize: 14, weight: .semibold) + lineHeight = 20 + appleTextStyle = .footnote + case .large: + font = UIFont.systemFont(ofSize: 16, weight: .regular) + lineHeight = 24 + appleTextStyle = .callout + case .largeEmphasized: + font = UIFont.systemFont(ofSize: 16, weight: .semibold) + lineHeight = 24 + appleTextStyle = .callout + } + return .create(font: font, lineHeight: lineHeight, appleTextStyle: appleTextStyle) + } + + enum CodeToken { + /// 16 size / 24 line height / 600 weight + case largeEmphasized + } + static func code(_ token: CodeToken) -> FinancialConnectionsFont { + let font: UIFont + let lineHeight: CGFloat + let appleTextStyle: UIFont.TextStyle + switch token { + case .largeEmphasized: + font = UIFont.monospacedSystemFont(ofSize: 16, weight: .semibold) + lineHeight = 24 + appleTextStyle = .body + } + return .create(font: font, lineHeight: lineHeight, appleTextStyle: appleTextStyle) + } + + private static func create(font: UIFont, lineHeight: CGFloat, appleTextStyle: UIFont.TextStyle) -> FinancialConnectionsFont { + let scaledFont = scaleFont(font, appleTextStyle: appleTextStyle) + return FinancialConnectionsFont( + uiFont: scaledFont, + lineHeight: scaleLineHeight(lineHeight, font: font, scaledFont: scaledFont) + ) + } + + private static func scaleFont(_ font: UIFont, appleTextStyle: UIFont.TextStyle) -> UIFont { + let metrics = UIFontMetrics(forTextStyle: appleTextStyle) + let scaledFont = metrics.scaledFont(for: font) + return scaledFont + } + + private static func scaleLineHeight(_ lineHeight: CGFloat, font: UIFont, scaledFont: UIFont) -> CGFloat { + return lineHeight * (scaledFont.pointSize / max(1, font.pointSize)) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/Helpers.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/Helpers.swift new file mode 100644 index 00000000..28597118 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/Helpers.swift @@ -0,0 +1,15 @@ +// +// Foundation.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 10/27/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +func assertMainQueue() { + #if DEBUG + dispatchPrecondition(condition: .onQueue(DispatchQueue.main)) + #endif +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/Image.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/Image.swift new file mode 100644 index 00000000..961119e8 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/Image.swift @@ -0,0 +1,35 @@ +// +// Image.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 11/22/21. +// + +import Foundation +@_spi(STP) import StripeUICore + +/// The canonical set of all image files in the `StripeFinancialConnections` module. +/// This helps us avoid duplicates and automatically test that all images load properly +enum Image: String, ImageMaker { + typealias BundleLocator = StripeFinancialConnectionsBundleLocator + + case add + case arrow_right + case back_arrow + case bank + case bank_check + case brandicon_default + case cancel_circle + case check + case chevron_down + case close + case ellipsis + case generic_error + case prepane_phone_background + case search + case stripe_logo + case spinner + case warning_circle + case warning_triangle + case bullet +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/Locale+Extensions.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/Locale+Extensions.swift new file mode 100644 index 00000000..2c8c7051 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/Locale+Extensions.swift @@ -0,0 +1,33 @@ +// +// Locale+Extensions.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 6/20/23. +// + +import Foundation + +extension Locale { + + /// Returns the BCP 47(-ish) language tag representing the locale. + /// + /// The language tag is expected to be well-formed as log as the locale identifier contains a + /// valid language code. For example: + /// + /// ``` + /// let locale = Locale(identifier: "fr_CA") + /// locale.toLanguageTag() // -> "fr-CA" + /// ``` + /// + /// The following example returns `"-ES"`, even though `"und-ES"` will be the appropriate BCP 47 tag: + /// + /// ``` + /// let locale = Locale(identifier: "_ES") + /// locale.toLanguageTag() // -> "-ES" + /// ``` + /// + /// All system iOS and macOS locales are expected to contain valid language codes. + func toLanguageTag() -> String { + return Locale.canonicalLanguageIdentifier(from: self.identifier) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/NSAttributedString+Extensions.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/NSAttributedString+Extensions.swift new file mode 100644 index 00000000..c3eed32e --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/NSAttributedString+Extensions.swift @@ -0,0 +1,78 @@ +// +// NSAttributedString+Extensions.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/22/22. +// + +import Foundation +import UIKit + +// MARK: - Markdown Bold + +extension NSMutableAttributedString { + + /// Adds `boldFont` as an attribute in all the places that are surrounded by asterisks (ex. `**bold string here**). + /// + /// For example, `Click **here**` returns `Click here` with "here" being applied the `boldFont` as attribute. + func addBoldFontAttributesByMarkdownRules(boldFont: UIFont) { + guard + // The regex will find all occurrances of tokens formatted as: `**bold string here**` + let regularExpression = try? NSRegularExpression( + pattern: #"\*\*[^\*\n]+\*\*"#, + options: NSRegularExpression.Options(rawValue: 0) + ) + else { + return + } + + while let textCheckingResult = regularExpression.firstMatch( + in: string, + range: NSRange(location: 0, length: string.count) + ) { + // range where `**bold string here**` token is + let markdownBoldRange = textCheckingResult.range + // the string `**bold string here**` + let markdownBoldString = attributedSubstring(from: markdownBoldRange) + + // the string `bold string here` + let nonmarkdownBoldString = markdownBoldString.extractStringInAsterisks() + + if let nonmarkdownBoldString = nonmarkdownBoldString?.mutableCopy() as? NSMutableAttributedString { + // apply a "bold font attribute to the string `bold string here` + nonmarkdownBoldString + .addAttribute( + .font, + value: boldFont, + range: NSRange(location: 0, length: nonmarkdownBoldString.length) + ) + + replaceCharacters(in: markdownBoldRange, with: nonmarkdownBoldString) + } + } + } +} + +extension NSAttributedString { + + /// Extracts a substring out of the first set of asterisks. + /// + /// For example, `Bold Text` out of `**Bold Text**`. + fileprivate func extractStringInAsterisks() -> NSAttributedString? { + guard + let regularExpression = try? NSRegularExpression( + pattern: #"(?<=\*\*)[^\*\n]*(?=\*\*)"#, + options: NSRegularExpression.Options(rawValue: 0) + ) + else { + return nil + } + guard + let range = regularExpression.firstMatch(in: string, range: NSRange(location: 0, length: string.count))? + .range + else { + return nil + } + return attributedSubstring(from: range) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/STPLocalizedString.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/STPLocalizedString.swift new file mode 100644 index 00000000..cb6a7f7d --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/STPLocalizedString.swift @@ -0,0 +1,16 @@ +// +// STPLocalizedString.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 11/11/21. +// + +import Foundation +@_spi(STP) import StripeCore + +@inline(__always) func STPLocalizedString(_ key: String, _ comment: String?) -> String { + return STPLocalizationUtils.localizedStripeString( + forKey: key, + bundleLocator: StripeFinancialConnectionsBundleLocator.self + ) +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/String+Extensions.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/String+Extensions.swift new file mode 100644 index 00000000..0816f5e3 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/String+Extensions.swift @@ -0,0 +1,123 @@ +// +// String+Extensions.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 7/11/22. +// + +import Foundation + +// MARK: - Native Redirect Helpers + +private let nativeRedirectPrefix = "stripe-auth://native-redirect/" + +extension String { + + func dropPrefix(_ prefix: String) -> String { + guard self.hasPrefix(prefix) else { return self } + return String(self.dropFirst(prefix.count)) + } + + func droppingNativeRedirectPrefix() -> String { + return dropPrefix(nativeRedirectPrefix) + } + + var hasNativeRedirectPrefix: Bool { + return self.hasPrefix(nativeRedirectPrefix) + } +} + +// MARK: - Markdown Links + +extension String { + + struct Link: Equatable { + let range: NSRange + let urlString: String + } + + /// Extracts markdown links from a string. + /// + /// For example, `You can [visit](https://stripe.com/) the website` returns + /// "You can visit the website" with a `Link` of "https://stripe.com/". + func extractLinks() -> (linklessString: String, links: [Link]) { + + let originalString = self + guard + // Matches markdown links. For example, the regex will find all + // occurrances of tokens like: `[Stripe Link Here](https://stripe.com/)` + let regularExpression = try? NSRegularExpression( + pattern: #"\[[^\[]*]*\]\([^\)]*\)"#, + options: NSRegularExpression.Options(rawValue: 0) + ) + else { + return (originalString, []) + } + + var modifiedString = originalString + var links: [Link] = [] + while let textCheckingResult = regularExpression.firstMatch( + in: modifiedString, + range: NSRange(location: 0, length: modifiedString.count) + ) { + let markdownLinkRange = textCheckingResult.range + // Ex. [Terms](https://stripe.com/legal/end-users#linked-financial-account-terms) + let markdownLinkString = (modifiedString as NSString).substring(with: markdownLinkRange) + + var replacementString = "" + if let substring = markdownLinkString.extractStringInBrackets(), + let urlString = markdownLinkString.extractStringInParentheses() + { + replacementString = substring + let linkRange = NSRange(location: markdownLinkRange.location, length: substring.count) + let link = Link(range: linkRange, urlString: urlString) + links.append(link) + } + + modifiedString = (modifiedString as NSString).replacingCharacters( + in: markdownLinkRange, + with: replacementString + ) + } + + return (modifiedString, links) + } + + /// Extracts a substring out of the first bracket. + /// + /// For example, `Terms` out of `[Terms]`. + private func extractStringInBrackets() -> String? { + guard + let regularExpression = try? NSRegularExpression( + pattern: #"(?<=\[)[^\[\n]*(?=\])"#, + options: NSRegularExpression.Options(rawValue: 0) + ) + else { + return nil + } + guard let range = regularExpression.firstMatch(in: self, range: NSRange(location: 0, length: count))?.range + else { + return nil + } + return (self as NSString).substring(with: range) + } + + /// Extracts a substring out of the first parantheses. + /// + /// For example, `https://stripe.com/` out of `(https://stripe.com/)`. + private func extractStringInParentheses() -> String? { + guard + let regularExpression = try? NSRegularExpression( + pattern: #"(?<=\()[^\)\(\n]*(?=\))"#, + options: NSRegularExpression.Options(rawValue: 0) + ) + else { + return nil + } + guard let range = regularExpression.firstMatch(in: self, range: NSRange(location: 0, length: count))?.range + else { + return nil + } + return (self as NSString).substring(with: range) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/String+Localized.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/String+Localized.swift new file mode 100644 index 00000000..d04c799e --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/String+Localized.swift @@ -0,0 +1,42 @@ +// +// String+Localized.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/22/22. +// + +import Foundation +@_spi(STP) import StripeCore + +// Localized strings that are used in multiple contexts. Collected here to avoid re-translation +// We use snake case to make long names easier to read. +extension String.Localized { + + static var learn_more: String { + return STPLocalizedString( + "Learn more", + "Represents the text of a button that can be clicked to learn more about some topic. Once clicked, a web-browser will be opened to give users more info." + ) + } + + static var select_another_bank: String { + return STPLocalizedString( + "Select another bank", + "The title of a button. The button presents the user an option to select another bank. For example, we may show this button after user failed to link their primary bank, but maybe the user can try to link their secondary bank!" + ) + } + + static var enter_bank_details_manually: String { + return STPLocalizedString( + "Enter bank details manually", + "The title of a button. The button presents the user an option to enter their bank details (account number, routing number) manually. For example, we may show this button after user failed to link their bank 'automatically' with Stripe, so we offer them the option manually link it." + ) + } + + static var link_another_account: String { + return STPLocalizedString( + "Link another account", + "The title of a button that, once clicked, allows the user to connect (or link) an additional bank account. Once the bank accounts are connected (or linked), the user will be able to use those bank accounts for payments." + ) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/StripeFinancialConnectionsBundleLocator.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/StripeFinancialConnectionsBundleLocator.swift new file mode 100644 index 00000000..1b275bf1 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/StripeFinancialConnectionsBundleLocator.swift @@ -0,0 +1,18 @@ +// +// StripeFinancialConnectionsBundleLocator.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 11/11/21. +// + +import Foundation +@_spi(STP) import StripeCore + +final class StripeFinancialConnectionsBundleLocator: BundleLocatorProtocol { + static let internalClass: AnyClass = StripeFinancialConnectionsBundleLocator.self + static let bundleName = "StripeFinancialConnections" + #if SWIFT_PACKAGE + static let spmResourcesBundle = Bundle.module + #endif + static let resourcesBundle = StripeFinancialConnectionsBundleLocator.computeResourcesBundle() +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/UIColor+Extensions.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/UIColor+Extensions.swift new file mode 100644 index 00000000..8194a8fd --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/UIColor+Extensions.swift @@ -0,0 +1,110 @@ +// +// Extensions.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 11/26/21. +// + +import UIKit + +extension UIColor { + + // The background color we use across across many screens. + // Added for future support around dark mode. + static var customBackgroundColor: UIColor { + return .white + } + + static var textPrimary: UIColor { + return neutral800 + } + + static var textSecondary: UIColor { + return neutral500 + } + + static var textBrand: UIColor { + return brand500 + } + + static var textDisabled: UIColor { + return neutral300 + } + + static var textCritical: UIColor { + return critical500 + } + + static var textSuccess: UIColor { + return success500 + } + + static var borderNeutral: UIColor { + return neutral150 + } + + static var borderCritical: UIColor { + return critical500 + } + + static var backgroundContainer: UIColor { + return neutral50 + } + + static var attention50: UIColor { + return UIColor(red: 254 / 255.0, green: 249 / 255.0, blue: 218 / 255.0, alpha: 1) // #fef9da + } + + private static var neutral50: UIColor { + return UIColor(red: 246 / 255.0, green: 248 / 255.0, blue: 250 / 255.0, alpha: 1) // #f6f8fa + } + + private static var neutral150: UIColor { + return UIColor(red: 224 / 255.0, green: 230 / 255.0, blue: 235 / 255.0, alpha: 1) // #e0e6eb + } + + private static var neutral300: UIColor { + return UIColor(red: 163 / 255.0, green: 172 / 255.0, blue: 186 / 255.0, alpha: 1) // #a3acba + } + + private static var neutral500: UIColor { + return UIColor(red: 106 / 255.0, green: 115 / 255.0, blue: 131 / 255.0, alpha: 1) // #6a7383 + } + + private static var neutral800: UIColor { + return UIColor(red: 48 / 255.0, green: 49 / 255.0, blue: 61 / 255.0, alpha: 1) // #30313d + } + + static var brand100: UIColor { + return UIColor(red: 242 / 255.0, green: 235 / 255.0, blue: 255 / 255.0, alpha: 1) // #f2ebff + } + + private static var brand500: UIColor { + return UIColor(red: 99 / 255.0, green: 91 / 255.0, blue: 255 / 255.0, alpha: 1) // #635bff + } + + private static var critical500: UIColor { + return UIColor(red: 223 / 255.0, green: 27 / 255.0, blue: 65 / 255.0, alpha: 1) // #df1b41 + } + + static var success100: UIColor { + return UIColor(red: 215 / 255.0, green: 247 / 255.0, blue: 194 / 255.0, alpha: 1) // #d7f7c2 + } + + private static var success500: UIColor { + return UIColor(red: 34 / 255.0, green: 132 / 255.0, blue: 3 / 255.0, alpha: 1) // #228403 + } + + static func dynamic(light: UIColor, dark: UIColor) -> UIColor { + return UIColor(dynamicProvider: { + switch $0.userInterfaceStyle { + case .light, .unspecified: + return light + case .dark: + return dark + @unknown default: + return light + } + }) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/UIViewController+Extensions.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/UIViewController+Extensions.swift new file mode 100644 index 00000000..327e058c --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Helpers/UIViewController+Extensions.swift @@ -0,0 +1,47 @@ +// +// UIViewController+Extensions.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 7/15/22. +// + +import Foundation +import UIKit + +extension UIViewController { + + static func topMostViewController() -> UIViewController? { + guard let window = UIApplication.shared.customKeyWindow else { + return nil + } + var topMostViewController = window.rootViewController + while let presentedViewController = topMostViewController?.presentedViewController { + topMostViewController = presentedViewController + } + return topMostViewController + } +} + +extension UIApplication { + + fileprivate var customKeyWindow: UIWindow? { + let foregroundActiveWindow = + connectedScenes + .filter { $0.activationState == .foregroundActive } + .first(where: { $0 is UIWindowScene }) + .flatMap({ ($0 as? UIWindowScene) })?.windows + .first(where: \.isKeyWindow) + + if let foregroundActiveWindow = foregroundActiveWindow { + return foregroundActiveWindow + } + + // There are scenarios (ex. presenting from a notification) when + // no scenes are `foregroundActive` so here we ignore the parameter + return + connectedScenes + .first(where: { $0 is UIWindowScene }) + .flatMap({ ($0 as? UIWindowScene) })?.windows + .first(where: \.isKeyWindow) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerAccountLoadErrorView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerAccountLoadErrorView.swift new file mode 100644 index 00000000..d65de500 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerAccountLoadErrorView.swift @@ -0,0 +1,146 @@ +// +// AccountPickerAccountLoadFailureView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/23/22. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +final class AccountPickerAccountLoadErrorView: UIView { + + init( + institution: FinancialConnectionsInstitution, + didSelectAnotherBank: @escaping () -> Void, + didSelectTryAgain: (() -> Void)?, // if nil, don't show button + didSelectEnterBankDetailsManually: (() -> Void)? // if nil, don't show button + ) { + super.init(frame: .zero) + + let subtitle: String + let secondaryButtonConfiguration: ReusableInformationView.ButtonConfiguration? + let primaryButtonConfiguration: ReusableInformationView.ButtonConfiguration + + if let didSelectTryAgain = didSelectTryAgain { + subtitle = STPLocalizedString( + "Please select another bank or try again.", + "The subtitle/description of a screen that shows an error. The error appears after we failed to load users bank accounts. Here we instruct the user to select another bank or to try loading their bank accounts again." + ) + secondaryButtonConfiguration = ReusableInformationView.ButtonConfiguration( + title: String.Localized.select_another_bank, + action: didSelectAnotherBank + ) + primaryButtonConfiguration = ReusableInformationView.ButtonConfiguration( + title: "Try again", // TODO: once we localize, pull in the string from StripeCore `String.Localized.tryAgain` + action: didSelectTryAgain + ) + } else if let didSelectEnterBankDetailsManually = didSelectEnterBankDetailsManually { + subtitle = STPLocalizedString( + "Please enter your bank details manually or select another bank.", + "The subtitle/description of a screen that shows an error. The error appears after we failed to load users bank accounts. Here we instruct the user to enter their bank details manually or to try selecting another bank." + ) + secondaryButtonConfiguration = ReusableInformationView.ButtonConfiguration( + title: String.Localized.enter_bank_details_manually, + action: didSelectEnterBankDetailsManually + ) + primaryButtonConfiguration = ReusableInformationView.ButtonConfiguration( + title: String.Localized.select_another_bank, + action: didSelectAnotherBank + ) + } else { + subtitle = STPLocalizedString( + "Please select another bank.", + "The subtitle/description of a screen that shows an error. The error appears after we failed to load users bank accounts. Here we instruct the user to try selecting another bank." + ) + secondaryButtonConfiguration = nil + primaryButtonConfiguration = ReusableInformationView.ButtonConfiguration( + title: String.Localized.select_another_bank, + action: didSelectAnotherBank + ) + } + let institutionIconView = InstitutionIconView( + size: .large, + showWarning: true + ) + institutionIconView.setImageUrl(institution.icon?.default) + let reusableInformationView = ReusableInformationView( + iconType: .view(institutionIconView), + title: String( + format: STPLocalizedString( + "There was a problem accessing your %@ account", + "The title of a screen that shows an error. The error appears after we failed to load users bank accounts. Here we describe to the user that we had issues with the bank. '%@' gets replaced by the name of the bank." + ), + institution.name + ), + subtitle: subtitle, + primaryButtonConfiguration: primaryButtonConfiguration, + secondaryButtonConfiguration: secondaryButtonConfiguration + ) + addAndPinSubview(reusableInformationView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +#if DEBUG + +import SwiftUI + +private struct AccountPickerAccountLoadErrorViewUIViewRepresentable: UIViewRepresentable { + + let institutionName: String + let didSelectTryAgain: (() -> Void)? + let didSelectEnterBankDetailsManually: (() -> Void)? + + func makeUIView(context: Context) -> AccountPickerAccountLoadErrorView { + AccountPickerAccountLoadErrorView( + institution: FinancialConnectionsInstitution( + id: "123", + name: institutionName, + url: nil, + icon: nil, + logo: nil + ), + didSelectAnotherBank: {}, + didSelectTryAgain: didSelectTryAgain, + didSelectEnterBankDetailsManually: didSelectEnterBankDetailsManually + ) + } + + func updateUIView(_ uiView: AccountPickerAccountLoadErrorView, context: Context) {} +} + +struct AccountPickerAccountLoadErrorView_Previews: PreviewProvider { + static var previews: some View { + AccountPickerAccountLoadErrorViewUIViewRepresentable( + institutionName: "Chase", + didSelectTryAgain: {}, + didSelectEnterBankDetailsManually: {} + ) + + AccountPickerAccountLoadErrorViewUIViewRepresentable( + institutionName: "Ally", + didSelectTryAgain: nil, + didSelectEnterBankDetailsManually: {} + ) + + AccountPickerAccountLoadErrorViewUIViewRepresentable( + institutionName: "Chase", + didSelectTryAgain: {}, + didSelectEnterBankDetailsManually: nil + ) + + AccountPickerAccountLoadErrorViewUIViewRepresentable( + institutionName: "Chase", + didSelectTryAgain: nil, + didSelectEnterBankDetailsManually: nil + ) + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerDataSource.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerDataSource.swift new file mode 100644 index 00000000..a2da9393 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerDataSource.swift @@ -0,0 +1,112 @@ +// +// AccountPickerDataSource.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/5/22. +// + +import Foundation +@_spi(STP) import StripeCore + +protocol AccountPickerDataSourceDelegate: AnyObject { + func accountPickerDataSource( + _ dataSource: AccountPickerDataSource, + didSelectAccounts selectedAccounts: [FinancialConnectionsPartnerAccount] + ) +} + +protocol AccountPickerDataSource: AnyObject { + + var delegate: AccountPickerDataSourceDelegate? { get set } + var manifest: FinancialConnectionsSessionManifest { get } + var authSession: FinancialConnectionsAuthSession { get } + var institution: FinancialConnectionsInstitution { get } + var selectedAccounts: [FinancialConnectionsPartnerAccount] { get } + var analyticsClient: FinancialConnectionsAnalyticsClient { get } + var reduceManualEntryProminenceInErrors: Bool { get } + + func pollAuthSessionAccounts() -> Future + func updateSelectedAccounts(_ selectedAccounts: [FinancialConnectionsPartnerAccount]) + func selectAuthSessionAccounts() -> Promise +} + +final class AccountPickerDataSourceImplementation: AccountPickerDataSource { + + private let apiClient: FinancialConnectionsAPIClient + private let clientSecret: String + let authSession: FinancialConnectionsAuthSession + let manifest: FinancialConnectionsSessionManifest + let institution: FinancialConnectionsInstitution + let analyticsClient: FinancialConnectionsAnalyticsClient + let reduceManualEntryProminenceInErrors: Bool + + private(set) var selectedAccounts: [FinancialConnectionsPartnerAccount] = [] { + didSet { + delegate?.accountPickerDataSource(self, didSelectAccounts: selectedAccounts) + } + } + weak var delegate: AccountPickerDataSourceDelegate? + + init( + apiClient: FinancialConnectionsAPIClient, + clientSecret: String, + authSession: FinancialConnectionsAuthSession, + manifest: FinancialConnectionsSessionManifest, + institution: FinancialConnectionsInstitution, + analyticsClient: FinancialConnectionsAnalyticsClient, + reduceManualEntryProminenceInErrors: Bool + ) { + self.apiClient = apiClient + self.clientSecret = clientSecret + self.authSession = authSession + self.manifest = manifest + self.institution = institution + self.analyticsClient = analyticsClient + self.reduceManualEntryProminenceInErrors = reduceManualEntryProminenceInErrors + } + + func pollAuthSessionAccounts() -> Future { + return apiClient.fetchAuthSessionAccounts( + clientSecret: clientSecret, + authSessionId: authSession.id, + initialPollDelay: AuthSessionAccountsInitialPollDelay(forFlow: authSession.flow) + ) + } + + func updateSelectedAccounts(_ selectedAccounts: [FinancialConnectionsPartnerAccount]) { + self.selectedAccounts = selectedAccounts + } + + func selectAuthSessionAccounts() -> Promise { + return apiClient.selectAuthSessionAccounts( + clientSecret: clientSecret, + authSessionId: authSession.id, + selectedAccountIds: selectedAccounts.map({ $0.id }) + ) + } +} + +private func AuthSessionAccountsInitialPollDelay( + forFlow flow: FinancialConnectionsAuthSession.Flow? +) -> TimeInterval { + let defaultInitialPollDelay: TimeInterval = 1.75 + guard let flow = flow else { + return defaultInitialPollDelay + } + switch flow { + case .testmode: + fallthrough + case .testmodeOauth: + fallthrough + case .testmodeOauthWebview: + fallthrough + case .finicityConnectV2Lite: + // Post auth flow, Finicity non-OAuth account retrieval latency is extremely quick - p90 < 1sec. + return 0 + case .mxConnect: + // 10 account retrieval latency on MX non-OAuth sessions is currently 460 ms + return 0.5 + default: + return defaultInitialPollDelay + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerFooterView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerFooterView.swift new file mode 100644 index 00000000..6bf4c6fc --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerFooterView.swift @@ -0,0 +1,105 @@ +// +// AccountPickerFooterView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/10/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +final class AccountPickerFooterView: UIView { + + private let singleAccount: Bool + private let institutionHasAccountPicker: Bool + private let didSelectLinkAccounts: () -> Void + + private lazy var linkAccountsButton: Button = { + let linkAccountsButton = Button(configuration: .financialConnectionsPrimary) + linkAccountsButton.addTarget(self, action: #selector(didSelectLinkAccountsButton), for: .touchUpInside) + linkAccountsButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + linkAccountsButton.heightAnchor.constraint(equalToConstant: 56) + ]) + linkAccountsButton.accessibilityIdentifier = "account_picker_link_accounts_button" + return linkAccountsButton + }() + + init( + isStripeDirect: Bool, + businessName: String?, + permissions: [StripeAPI.FinancialConnectionsAccount.Permissions], + singleAccount: Bool, + institutionHasAccountPicker: Bool, + didSelectLinkAccounts: @escaping () -> Void, + didSelectMerchantDataAccessLearnMore: @escaping () -> Void + ) { + self.singleAccount = singleAccount + self.institutionHasAccountPicker = institutionHasAccountPicker + self.didSelectLinkAccounts = didSelectLinkAccounts + super.init(frame: .zero) + + let verticalStackView = HitTestStackView( + arrangedSubviews: [ + MerchantDataAccessView( + isStripeDirect: isStripeDirect, + businessName: businessName, + permissions: permissions, + isNetworking: false, + font: .body(.small), + boldFont: .body(.smallEmphasized), + alignCenter: true, + didSelectLearnMore: didSelectMerchantDataAccessLearnMore + ), + linkAccountsButton, + ] + ) + verticalStackView.axis = .vertical + verticalStackView.spacing = 24 + addSubview(verticalStackView) + addAndPinSubviewToSafeArea(verticalStackView) + + didSelectAccounts(count: 0) // set the button title + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func didSelectLinkAccountsButton() { + didSelectLinkAccounts() + } + + func didSelectAccounts(count numberOfAccountsSelected: Int) { + linkAccountsButton.isEnabled = (numberOfAccountsSelected > 0) + + if institutionHasAccountPicker { + linkAccountsButton.title = STPLocalizedString( + "Confirm", + "A button that allows users to confirm the process of saving their bank accounts for future payments. This button appears in a screen that allows users to select which bank accounts they want to use to pay for something." + ) + } else { + let singleAccountButtonTitle = STPLocalizedString( + "Link account", + "A button that allows users to confirm the process of saving their bank account for future payments. This button appears in a screen that allows users to select which bank accounts they want to use to pay for something." + ) + let multipleAccountButtonTitle = STPLocalizedString( + "Link accounts", + "A button that allows users to confirm the process of saving their bank accounts for future payments. This button appears in a screen that allows users to select which bank accounts they want to use to pay for something." + ) + + if numberOfAccountsSelected == 0 { + if singleAccount { + linkAccountsButton.title = singleAccountButtonTitle + } else { + linkAccountsButton.title = multipleAccountButtonTitle + } + } else if numberOfAccountsSelected == 1 { + linkAccountsButton.title = singleAccountButtonTitle + } else { // numberOfAccountsSelected > 1 + linkAccountsButton.title = multipleAccountButtonTitle + } + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerHelpers.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerHelpers.swift new file mode 100644 index 00000000..a2602793 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerHelpers.swift @@ -0,0 +1,110 @@ +// +// AccountPickerHeleprs.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/8/22. +// + +import UIKit + +final class AccountPickerHelpers { + static func rowTitles( + forAccount account: FinancialConnectionsPartnerAccount, + // caption, for networked accounts, will hide account numbers, so we should show account numbers in the title + captionWillHideAccountNumbers: Bool + ) -> ( + leadingTitle: String, trailingTitle: String? + ) { + // balance info in subtitle will hide account numbers, so we should show account numbers in the title + let balanceWillHideAccountNumbersInSubtitle = account.balanceInfo != nil + if balanceWillHideAccountNumbersInSubtitle || captionWillHideAccountNumbers { + return (account.name, "••••\(account.displayableAccountNumbers ?? "")") + } else { + return (account.name, nil) + } + } + + static func rowSubtitle(forAccount account: FinancialConnectionsPartnerAccount) -> String? { + if let balanceInfo = account.balanceInfo { + return currencyString(currency: balanceInfo.currency, balanceAmount: balanceInfo.balanceAmount) + } else { + if let displayableAccountNumbers = account.displayableAccountNumbers { + return "••••••••\(displayableAccountNumbers)" + } else { + return nil + } + } + } + + static func currencyString(currency: String, balanceAmount: Int) -> String? { + let numberFormatter = NumberFormatter() + numberFormatter.currencyCode = currency + numberFormatter.numberStyle = .currency + return numberFormatter.string( + for: NSDecimalNumber.stp_fn_decimalNumber(withAmount: balanceAmount, currency: currency) + ) + } +} + +// TODO(kgaidis): move this to StripeCore + +extension NSDecimalNumber { + @objc class func stp_fn_decimalNumber( + withAmount amount: Int, + currency: String? + ) -> NSDecimalNumber { + let isAmountNegative = amount < 0 + let amount = abs(amount) + + let noDecimalCurrencies = self.stp_fn_currenciesWithNoDecimal() + let number = self.init(mantissa: UInt64(amount), exponent: 0, isNegative: isAmountNegative) + if noDecimalCurrencies.contains(currency?.lowercased() ?? "") { + return number + } + return number.multiplying(byPowerOf10: -2) + } + + @objc func stp_fn_amount(withCurrency currency: String?) -> Int { + let noDecimalCurrencies = NSDecimalNumber.stp_fn_currenciesWithNoDecimal() + + var ourNumber = self + if !(noDecimalCurrencies.contains(currency?.lowercased() ?? "")) { + ourNumber = multiplying(byPowerOf10: 2) + } + return Int(ourNumber.doubleValue) + } + + class func stp_fn_currenciesWithNoDecimal() -> [String] { + return [ + "bif", + "clp", + "djf", + "gnf", + "jpy", + "kmf", + "krw", + "mga", + "pyg", + "rwf", + "vnd", + "vuv", + "xaf", + "xof", + "xpf", + ] + } +} + +func buildRetrievingAccountsView() -> UIView { + return ReusableInformationView( + iconType: .loading, + title: STPLocalizedString( + "Connecting your bank", + "The title of the loading screen that appears when a user just logged into their bank account, and now is waiting for their bank accounts to load. Once the bank accounts are loaded, user will be able to pick the bank account they want to to use for things like payments." + ), + subtitle: STPLocalizedString( + "You're almost done.", + "The subtitle/description of the loading screen that appears when a user just logged into their bank account, and now is waiting for their bank accounts to load. Once the bank accounts are loaded, user will be able to pick the bank account they want to to use for things like payments." + ) + ) +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerLabelRowView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerLabelRowView.swift new file mode 100644 index 00000000..7fc016d6 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerLabelRowView.swift @@ -0,0 +1,90 @@ +// +// AccountPickerLabelRowView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/8/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +final class AccountPickerLabelRowView: UIView { + + private lazy var verticalLabelStackView: UIStackView = { + let labelStackView = UIStackView() + labelStackView.axis = .vertical + labelStackView.spacing = 0 + labelStackView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return labelStackView + }() + + private lazy var leadingTitleLabel: AttributedLabel = { + let leadingTitleLabel = AttributedLabel( + font: .label(.largeEmphasized), + textColor: .textPrimary + ) + leadingTitleLabel.lineBreakMode = .byCharWrapping + leadingTitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + leadingTitleLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + return leadingTitleLabel + }() + + private lazy var trailingTitleLabel: AttributedLabel = { + let trailingTitleLabel = AttributedLabel( + font: .label(.largeEmphasized), + textColor: .textPrimary + ) + trailingTitleLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + return trailingTitleLabel + }() + + private lazy var subtitleLabel: AttributedLabel = { + let subtitleLabel = AttributedLabel( + font: .label(.small), + textColor: .textSecondary + ) + return subtitleLabel + }() + + init() { + super.init(frame: .zero) + verticalLabelStackView.addArrangedSubview( + { + let horizontalStackView = UIStackView( + arrangedSubviews: [ + // we need a leading and a trailing + // title label because we want to + // prioritize the `trailingTitleLabel` + // when there's a need for truncation + leadingTitleLabel, + trailingTitleLabel, + ] + ) + horizontalStackView.axis = .horizontal + horizontalStackView.spacing = 4 + return horizontalStackView + }() + ) + addAndPinSubview(verticalLabelStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setLeadingTitle( + _ leadingTitle: String, + trailingTitle: String?, + subtitle: String? + ) { + leadingTitleLabel.text = leadingTitle + trailingTitleLabel.text = trailingTitle + + subtitleLabel.removeFromSuperview() + if let subtitle = subtitle { + subtitleLabel.text = subtitle + verticalLabelStackView.addArrangedSubview(subtitleLabel) + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerNoAccountEligibleErrorView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerNoAccountEligibleErrorView.swift new file mode 100644 index 00000000..bc0e9df2 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerNoAccountEligibleErrorView.swift @@ -0,0 +1,223 @@ +// +// AccountPickerNoAccountAvailableErrorView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/23/22. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +// Same as Stripe.js `AccountNoneEligibleForPaymentMethodFailure` +final class AccountPickerNoAccountEligibleErrorView: UIView { + + init( + institution: FinancialConnectionsInstitution, + bussinessName: String?, + institutionSkipAccountSelection: Bool, + numberOfIneligibleAccounts: Int, + paymentMethodType: FinancialConnectionsPaymentMethodType, + didSelectAnotherBank: @escaping () -> Void + ) { + super.init(frame: .zero) + assert( + numberOfIneligibleAccounts >= 1, + "this error should never be displayed if 0 accounts were selected by the user" + ) + + // Financial Connections support credit cards, but not in all flows + // (ex. ACH only supports checking/savings). + let supportedAccountTypes: String = { + if paymentMethodType == .link { + return STPLocalizedString( + "US checking", + "A type of payment account. We will insert this string into other messages to explain users what payment accounts are eligible for payments. For example, we may display a message that says 'The accounts you selected aren't US checking accounts.'" + ) + } else { + return STPLocalizedString( + "checking or savings", + "A type of payment account. We will insert this string into other messages to explain users what payment accounts are eligible for payments. For example, we may display a message that says 'The accounts you selected aren't checking or savings accounts.'" + ) + } + }() + let subtitleFirstSentence: String = { + if let bussinessName = bussinessName { + if numberOfIneligibleAccounts == 1 { + let localizedString = STPLocalizedString( + "We found 1 %@ account but you can only link %@ accounts to %@.", + "A description/subtitle that instructs the user that the bank account they selected is not eligible. For example, maybe the user selected a credit card, but we only accept debit cards. The first '%@' is replaced by the name of the bank. The second '%@' is replaced by the supported payment accounts (ex. US checking). The third '%@' is replaced by the business name (Ex. Coca-Cola Inc). For example, it may read 'We found 1 Chase account but you can only link checking or savings to Coca-Cola Inc.'" + ) + return String(format: localizedString, institution.name, supportedAccountTypes, bussinessName) + } else { + let localizedString = STPLocalizedString( + "We found %d %@ accounts but you can only link %@ accounts to %@.", + "A description/subtitle that instructs the user that the bank accounts they selected are not eligible. For example, maybe the user selected credit cards, but we only accept debit cards. The '%d' is replaced by the number of ineligible accounts. The first '%@' is replaced by the name of the bank. The second '%@' is replaced by the supported payment accounts (ex. US checking). The third '%@' is replaced by the business name (Ex. Coca-Cola Inc). For example, it may read 'We found 2 Chase accounts but you can only link checking or savings to Coca-Cola Inc.'" + ) + return String( + format: localizedString, + numberOfIneligibleAccounts, + institution.name, + supportedAccountTypes, + bussinessName + ) + } + } else { + if numberOfIneligibleAccounts == 1 { + let localizedString = STPLocalizedString( + "We found 1 %@ account but you can only link %@ accounts.", + "A description/subtitle that instructs the user that the bank account they selected is not eligible. For example, maybe the user selected a credit card, but we only accept debit cards. The first '%@' is replaced by the name of the bank. The second '%@' is replaced by the supported payment accounts (ex. US checking). For example, it may read 'We found 1 Chase account but you can only link checking or savings.'" + ) + return String(format: localizedString, institution.name, supportedAccountTypes) + } else { + let localizedString = STPLocalizedString( + "We found %d %@ accounts but you can only link %@ accounts.", + "A description/subtitle that instructs the user that the bank accounts they selected are not eligible. For example, maybe the user selected credit cards, but we only accept debit cards. The '%d' is replaced by the number of ineligible accounts. The first '%@' is replaced by the name of the bank. The second '%@' is replaced by the supported payment accounts (ex. US checking). For example, it may read 'We found 2 Chase accounts but you can only link checking or savings.'" + ) + return String( + format: localizedString, + numberOfIneligibleAccounts, + institution.name, + supportedAccountTypes + ) + } + } + }() + let subtitleSecondSentence: String = { + if institutionSkipAccountSelection { + return STPLocalizedString( + "Please try selecting another bank account.", + "The subtitle/description of a screen that shows an error. The error appears after user selected bank accounts, but we found that none of them are eligible to be linked. Here we instruct the user to try selecting another bank account at the same bank." + ) + } else { + return STPLocalizedString( + "Please try selecting another bank.", + "The subtitle/description of a screen that shows an error. The error appears after user selected bank accounts, but we found that none of them are eligible to be linked. Here we instruct the user to try selecting another bank account at a different bank." + ) + } + }() + + let reusableInformationView = ReusableInformationView( + iconType: .view( + { + let institutionIconView = InstitutionIconView( + size: .large, + showWarning: true + ) + institutionIconView.setImageUrl(institution.icon?.default) + return institutionIconView + }() + ), + title: { + if institutionSkipAccountSelection { + if numberOfIneligibleAccounts == 1 { + return STPLocalizedString( + "The account you selected isn't available for payments", + "The title of a screen that shows an error. The error appears after we failed to load users bank accounts. Here we describe to the user that the account they selected isn't eligible." + ) + } else { + return STPLocalizedString( + "The accounts you selected aren't available for payments", + "The title of a screen that shows an error. The error appears after we failed to load users bank accounts. Here we describe to the user that the accounts they selected aren't eligible. '%@' gets replaced by the eligible type of bank accounts, i.e. checking or savings. For example, maybe user selected a credit card, but we only support debit cards." + ) + } + } else { + return STPLocalizedString( + "No payment accounts available", + "The title of a screen that shows an error. The error appears after we failed to load users bank accounts. Here we describe to the user that the accounts they selected aren't eligible. '%@' gets replaced by the eligible type of bank accounts, i.e. checking or savings. For example, maybe user selected a credit card, but we only support debit cards." + ) + } + }(), + subtitle: subtitleFirstSentence + " " + subtitleSecondSentence, + primaryButtonConfiguration: ReusableInformationView.ButtonConfiguration( + title: { + if institutionSkipAccountSelection { + return String.Localized.link_another_account + } else { + return String.Localized.select_another_bank + } + }(), + action: didSelectAnotherBank + ), + secondaryButtonConfiguration: { + return nil + }() + ) + addAndPinSubview(reusableInformationView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +#if DEBUG + +import SwiftUI + +private struct AccountPickerNoAccountEligibleErrorViewUIViewRepresentable: UIViewRepresentable { + + let institutionName: String + let businessName: String? + let institutionSkipAccountSelection: Bool + let numberOfIneligibleAccounts: Int + let paymentMethodType: FinancialConnectionsPaymentMethodType + + func makeUIView(context: Context) -> AccountPickerNoAccountEligibleErrorView { + AccountPickerNoAccountEligibleErrorView( + institution: FinancialConnectionsInstitution( + id: "123", + name: institutionName, + url: nil, + icon: nil, + logo: nil + ), + bussinessName: businessName, + institutionSkipAccountSelection: institutionSkipAccountSelection, + numberOfIneligibleAccounts: numberOfIneligibleAccounts, + paymentMethodType: paymentMethodType, + didSelectAnotherBank: {} + ) + } + + func updateUIView(_ uiView: AccountPickerNoAccountEligibleErrorView, context: Context) {} +} + +struct AccountPickerNoAccountEligibleErrorView_Previews: PreviewProvider { + static var previews: some View { + AccountPickerNoAccountEligibleErrorViewUIViewRepresentable( + institutionName: "Chase", + businessName: "The Coca-Cola Company", + institutionSkipAccountSelection: false, + numberOfIneligibleAccounts: 1, + paymentMethodType: .link + ) + + AccountPickerNoAccountEligibleErrorViewUIViewRepresentable( + institutionName: "Chase", + businessName: "The Coca-Cola Company", + institutionSkipAccountSelection: false, + numberOfIneligibleAccounts: 3, + paymentMethodType: .usBankAccount + ) + + AccountPickerNoAccountEligibleErrorViewUIViewRepresentable( + institutionName: "Chase", + businessName: nil, + institutionSkipAccountSelection: false, + numberOfIneligibleAccounts: 1, + paymentMethodType: .link + ) + + AccountPickerNoAccountEligibleErrorViewUIViewRepresentable( + institutionName: "Chase", + businessName: nil, + institutionSkipAccountSelection: true, + numberOfIneligibleAccounts: 3, + paymentMethodType: .unparsable + ) + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerSelectionListView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerSelectionListView.swift new file mode 100644 index 00000000..9f77eefc --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerSelectionListView.swift @@ -0,0 +1,132 @@ +// +// AccountPickerSelectionListView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/22/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +protocol AccountPickerSelectionListViewDelegate: AnyObject { + func accountPickerSelectionListView( + _ view: AccountPickerSelectionListView, + didSelectAccounts selectedAccounts: [FinancialConnectionsPartnerAccount] + ) +} + +final class AccountPickerSelectionListView: UIView { + + private let selectionType: AccountPickerSelectionRowView.SelectionType + private let enabledAccounts: [FinancialConnectionsPartnerAccount] + private let disabledAccounts: [FinancialConnectionsPartnerAccount] + weak var delegate: AccountPickerSelectionListViewDelegate? + + private lazy var verticalStackView: UIStackView = { + let verticalStackView = UIStackView() + verticalStackView.spacing = 12 + verticalStackView.axis = .vertical + return verticalStackView + }() + + init( + selectionType: AccountPickerSelectionRowView.SelectionType, + enabledAccounts: [FinancialConnectionsPartnerAccount], + disabledAccounts: [FinancialConnectionsPartnerAccount] + ) { + self.selectionType = selectionType + self.enabledAccounts = enabledAccounts + self.disabledAccounts = disabledAccounts + super.init(frame: .zero) + addAndPinSubviewToSafeArea(verticalStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func selectAccounts(_ selectedAccounts: [FinancialConnectionsPartnerAccount]) { + // clear all previous state + verticalStackView.arrangedSubviews.forEach { arrangedSubview in + arrangedSubview.removeFromSuperview() + } + + if selectionType == .checkbox { + // show a "all accounts" cell + let allAccountsCellView = AccountPickerSelectionRowView( + selectionType: .checkbox, + isDisabled: false, + didSelect: { [weak self] in + guard let self = self else { return } + let isAllAccountsSelected = (self.enabledAccounts.count == selectedAccounts.count) + var selectedAccounts = selectedAccounts + if isAllAccountsSelected { + selectedAccounts.removeAll() + } else { + selectedAccounts = self.enabledAccounts + } + self.delegate?.accountPickerSelectionListView(self, didSelectAccounts: selectedAccounts) + } + ) + allAccountsCellView.setLeadingTitle( + STPLocalizedString( + "All accounts", + "A button that allows users to select all their bank accounts. This button appears in a screen that allows users to select which bank accounts they want to use to pay for something." + ), + trailingTitle: nil, + subtitle: nil, + isSelected: (enabledAccounts.count == selectedAccounts.count) + ) + verticalStackView.addArrangedSubview(allAccountsCellView) + } + + // list enabled accounts + enabledAccounts.forEach { account in + let accountCellView = AccountPickerSelectionRowView( + selectionType: selectionType, + isDisabled: false, + didSelect: { [weak self] in + guard let self = self else { return } + var selectedAccounts = selectedAccounts + if let index = selectedAccounts.firstIndex(where: { $0.id == account.id }) { + selectedAccounts.remove(at: index) + } else { + if self.selectionType == .checkbox { + selectedAccounts.append(account) + } else { // radiobutton + selectedAccounts = [account] // select only one account + } + } + self.delegate?.accountPickerSelectionListView(self, didSelectAccounts: selectedAccounts) + } + ) + let rowTitles = AccountPickerHelpers.rowTitles(forAccount: account, captionWillHideAccountNumbers: false) + accountCellView.setLeadingTitle( + rowTitles.leadingTitle, + trailingTitle: rowTitles.trailingTitle, + subtitle: AccountPickerHelpers.rowSubtitle(forAccount: account), + isSelected: selectedAccounts.contains(where: { $0.id == account.id }) + ) + verticalStackView.addArrangedSubview(accountCellView) + } + + // list disabled accounts + disabledAccounts.forEach { disabledAccount in + let accountCellView = AccountPickerSelectionRowView( + selectionType: selectionType, + isDisabled: true, + didSelect: { + // can't select disabled accounts + } + ) + accountCellView.setLeadingTitle( + AccountPickerHelpers.rowTitles(forAccount: disabledAccount, captionWillHideAccountNumbers: false).leadingTitle, + trailingTitle: "••••\(disabledAccount.displayableAccountNumbers ?? "")", + subtitle: disabledAccount.allowSelectionMessage, + isSelected: false + ) + verticalStackView.addArrangedSubview(accountCellView) + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerSelectionRowView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerSelectionRowView.swift new file mode 100644 index 00000000..c9888b58 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerSelectionRowView.swift @@ -0,0 +1,238 @@ +// +// AccountPickerSelectionRowView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/10/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +final class AccountPickerSelectionRowView: UIView { + + enum SelectionType { + case checkbox + case radioButton + } + + private let selectionType: SelectionType + private let didSelect: () -> Void + + private var isSelected: Bool = false { + didSet { + layer.cornerRadius = 8 + if isSelected { + layer.borderColor = UIColor.textBrand.cgColor + layer.borderWidth = 2 + } else { + layer.borderColor = UIColor.borderNeutral.cgColor + layer.borderWidth = 1 + } + selectionView.isSelected = isSelected + } + } + + private lazy var selectionView: SelectionView = { + let selectionView: SelectionView + switch selectionType { + case .checkbox: + selectionView = CheckboxView() + case .radioButton: + selectionView = RadioButtonView() + } + selectionView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + selectionView.widthAnchor.constraint(equalToConstant: 20), + selectionView.heightAnchor.constraint(equalToConstant: 20), + ]) + return selectionView + }() + + private lazy var labelRowView: AccountPickerLabelRowView = { + return AccountPickerLabelRowView() + }() + + init( + selectionType: SelectionType, + isDisabled: Bool, + didSelect: @escaping () -> Void + ) { + self.selectionType = selectionType + self.didSelect = didSelect + super.init(frame: .zero) + + let horizontalStackView = CreateHorizontalStackView( + arrangedSubviews: [ + selectionView, + labelRowView, + ] + ) + if isDisabled { + horizontalStackView.alpha = 0.25 + } + addAndPinSubviewToSafeArea(horizontalStackView) + + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didTapView)) + addGestureRecognizer(tapGestureRecognizer) + + isSelected = false // activate the setter to draw border + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setLeadingTitle( + _ leadingTitle: String, + trailingTitle: String?, + subtitle: String?, + isSelected: Bool + ) { + labelRowView.setLeadingTitle( + leadingTitle, + trailingTitle: trailingTitle, + subtitle: subtitle + ) + self.isSelected = isSelected + } + + @objc private func didTapView() { + self.didSelect() + } +} + +private func CreateHorizontalStackView(arrangedSubviews: [UIView]) -> UIStackView { + let horizontalStackView = UIStackView(arrangedSubviews: arrangedSubviews) + horizontalStackView.axis = .horizontal + horizontalStackView.spacing = 12 + horizontalStackView.alignment = .center + horizontalStackView.isLayoutMarginsRelativeArrangement = true + horizontalStackView.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: 12, + leading: 12, + bottom: 12, + trailing: 12 + ) + return horizontalStackView +} + +// MARK: - Helpers + +private protocol SelectionView: UIView { + var isSelected: Bool { get set } +} +extension CheckboxView: SelectionView {} +extension RadioButtonView: SelectionView {} + +#if DEBUG + +import SwiftUI + +private struct AccountPickerSelectionRowViewUIViewRepresentable: UIViewRepresentable { + + let type: AccountPickerSelectionRowView.SelectionType + let leadingTitle: String + let trailingTitle: String? + let subtitle: String? + let isSelected: Bool + let isDisabled: Bool + let isLinked: Bool + + func makeUIView(context: Context) -> AccountPickerSelectionRowView { + let view = AccountPickerSelectionRowView( + selectionType: type, + isDisabled: isDisabled, + didSelect: {} + ) + view.setLeadingTitle( + leadingTitle, + trailingTitle: trailingTitle, + subtitle: subtitle, + isSelected: isSelected + ) + return view + } + + func updateUIView(_ uiView: AccountPickerSelectionRowView, context: Context) { + uiView.setLeadingTitle( + leadingTitle, + trailingTitle: trailingTitle, + subtitle: subtitle, + isSelected: isSelected + ) + } +} + +struct AccountPickerSelectionRowView_Previews: PreviewProvider { + static var previews: some View { + if #available(iOS 14.0, *) { + ScrollView { + VStack(spacing: 10) { + VStack(spacing: 2) { + Text("Checkmark") + AccountPickerSelectionRowViewUIViewRepresentable( + type: .checkbox, + leadingTitle: "Joint Checking Very Long Name To Truncate", + trailingTitle: "••••6789", + subtitle: "$2,000", + isSelected: true, + isDisabled: false, + isLinked: true + ).frame(height: 60) + AccountPickerSelectionRowViewUIViewRepresentable( + type: .checkbox, + leadingTitle: "Joint Checking", + trailingTitle: nil, + subtitle: nil, + isSelected: false, + isDisabled: false, + isLinked: false + ).frame(height: 60) + AccountPickerSelectionRowViewUIViewRepresentable( + type: .checkbox, + leadingTitle: "Joint Checking", + trailingTitle: nil, + subtitle: "Must be US checking account", + isSelected: false, + isDisabled: true, + isLinked: false + ).frame(height: 60) + } + VStack(spacing: 2) { + Text("Radiobutton") + AccountPickerSelectionRowViewUIViewRepresentable( + type: .radioButton, + leadingTitle: "Student Savings", + trailingTitle: "••••6789", + subtitle: "$2,000.32", + isSelected: true, + isDisabled: false, + isLinked: false + ).frame(height: 60) + AccountPickerSelectionRowViewUIViewRepresentable( + type: .radioButton, + leadingTitle: "Student Savings", + trailingTitle: nil, + subtitle: "••••••••4321", + isSelected: false, + isDisabled: false, + isLinked: true + ).frame(height: 60) + AccountPickerSelectionRowViewUIViewRepresentable( + type: .radioButton, + leadingTitle: "Student Savings", + trailingTitle: nil, + subtitle: "Must be checking or savings account", + isSelected: false, + isDisabled: true, + isLinked: true + ).frame(height: 60) + } + }.padding() + } + } + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerSelectionView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerSelectionView.swift new file mode 100644 index 00000000..7c411162 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerSelectionView.swift @@ -0,0 +1,61 @@ +// +// AccountPickerSelectionView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/10/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +protocol AccountPickerSelectionViewDelegate: AnyObject { + func accountPickerSelectionView( + _ view: AccountPickerSelectionView, + didSelectAccounts selectedAccounts: [FinancialConnectionsPartnerAccount] + ) +} + +final class AccountPickerSelectionView: UIView { + + private weak var delegate: AccountPickerSelectionViewDelegate? + private let listView: AccountPickerSelectionListView + + init( + accountPickerType: AccountPickerType, + enabledAccounts: [FinancialConnectionsPartnerAccount], + disabledAccounts: [FinancialConnectionsPartnerAccount], + institution: FinancialConnectionsInstitution, + delegate: AccountPickerSelectionViewDelegate + ) { + self.delegate = delegate + self.listView = AccountPickerSelectionListView( + selectionType: accountPickerType == .checkbox ? .checkbox : .radioButton, + enabledAccounts: enabledAccounts, + disabledAccounts: disabledAccounts + ) + super.init(frame: .zero) + listView.delegate = self + addAndPinSubviewToSafeArea(listView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func selectAccounts(_ selectedAccounts: [FinancialConnectionsPartnerAccount]) { + listView.selectAccounts(selectedAccounts) + } +} + +// MARK: - AccountPickerSelectionListViewDelegate + +extension AccountPickerSelectionView: AccountPickerSelectionListViewDelegate { + + func accountPickerSelectionListView( + _ view: AccountPickerSelectionListView, + didSelectAccounts selectedAccounts: [FinancialConnectionsPartnerAccount] + ) { + delegate?.accountPickerSelectionView(self, didSelectAccounts: selectedAccounts) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerViewController.swift new file mode 100644 index 00000000..5bd95097 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/AccountPickerViewController.swift @@ -0,0 +1,438 @@ +// +// AccountPickerViewController.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/5/22. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol AccountPickerViewControllerDelegate: AnyObject { + func accountPickerViewController( + _ viewController: AccountPickerViewController, + didSelectAccounts selectedAccounts: [FinancialConnectionsPartnerAccount] + ) + func accountPickerViewControllerDidSelectAnotherBank(_ viewController: AccountPickerViewController) + func accountPickerViewControllerDidSelectManualEntry(_ viewController: AccountPickerViewController) + func accountPickerViewController( + _ viewController: AccountPickerViewController, + didReceiveTerminalError error: Error + ) +} + +enum AccountPickerType { + case checkbox + case radioButton +} + +final class AccountPickerViewController: UIViewController { + + private let dataSource: AccountPickerDataSource + private let accountPickerType: AccountPickerType + // a special case where some institutions have an + // account picker as part of their bank auth + // flow, so we give special treatment + private let institutionHasAccountPicker: Bool + weak var delegate: AccountPickerViewControllerDelegate? + private weak var accountPickerSelectionView: AccountPickerSelectionView? + private var businessName: String? { + return dataSource.manifest.businessName + } + private var didSelectAnotherBank: () -> Void { + return { [weak self] in + guard let self = self else { return } + self.delegate?.accountPickerViewControllerDidSelectAnotherBank(self) + } + } + // we only allow to retry account polling once + private var allowAccountPollingRetry = true + private var didSelectTryAgain: (() -> Void)? { + return allowAccountPollingRetry + ? { [weak self] in + guard let self = self else { return } + self.allowAccountPollingRetry = false + self.showErrorView(nil) + self.pollAuthSessionAccounts() + } : nil + } + private var didSelectManualEntry: (() -> Void)? { + return (dataSource.manifest.allowManualEntry && !dataSource.reduceManualEntryProminenceInErrors) + ? { [weak self] in + guard let self = self else { return } + self.delegate?.accountPickerViewControllerDidSelectManualEntry(self) + } : nil + } + private var errorView: UIView? + + private lazy var footerView: AccountPickerFooterView = { + return AccountPickerFooterView( + isStripeDirect: dataSource.manifest.isStripeDirect ?? false, + businessName: businessName, + permissions: dataSource.manifest.permissions, + singleAccount: dataSource.manifest.singleAccount, + institutionHasAccountPicker: institutionHasAccountPicker, + didSelectLinkAccounts: { [weak self] in + guard let self = self else { + return + } + self.dataSource + .analyticsClient + .log( + eventName: "click.link_accounts", + pane: .accountPicker + ) + + self.didSelectLinkAccounts() + }, + didSelectMerchantDataAccessLearnMore: { [weak self] in + guard let self = self else { return } + self.dataSource + .analyticsClient + .logMerchantDataAccessLearnMore(pane: .accountPicker) + } + ) + }() + + init(dataSource: AccountPickerDataSource) { + self.dataSource = dataSource + self.accountPickerType = dataSource.manifest.singleAccount ? .radioButton : .checkbox + self.institutionHasAccountPicker = + (dataSource.authSession.institutionSkipAccountSelection == true && dataSource.manifest.singleAccount + && dataSource.authSession.isOauthNonOptional) + super.init(nibName: nil, bundle: nil) + dataSource.delegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + // account picker ALWAYS hides the back button + navigationItem.hidesBackButton = true + view.backgroundColor = .customBackgroundColor + pollAuthSessionAccounts() + } + + private func pollAuthSessionAccounts() { + // Load accounts + let retreivingAccountsLoadingView = buildRetrievingAccountsView() + view.addAndPinSubviewToSafeArea(retreivingAccountsLoadingView) + + let pollingStartDate = Date() + dataSource + .pollAuthSessionAccounts() + .observe(on: .main) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let accountsPayload): + let accounts = accountsPayload.data + let shouldSkipAccountSelection = accountsPayload.skipAccountSelection ?? self.dataSource.authSession.skipAccountSelection ?? false + if !accounts.isEmpty { + // the API is expected to never return 0 accounts + self.dataSource + .analyticsClient + .log( + eventName: "polling.accounts.success", + parameters: [ + "duration": Date().timeIntervalSince(pollingStartDate).milliseconds, + "authSessionId": self.dataSource.authSession.id, + ], + pane: .accountPicker + ) + } + self.dataSource + .analyticsClient + .logPaneLoaded(pane: .accountPicker) + + if accounts.isEmpty { + // if there were no accounts returned, API should have thrown an error + // ...handle it here since API did not throw error + self.showAccountLoadErrorView( + error: FinancialConnectionsSheetError.unknown( + debugDescription: "API returned an empty list of accounts" + ) + ) + } else if shouldSkipAccountSelection { + self.delegate?.accountPickerViewController( + self, + didSelectAccounts: accounts + ) + } else if self.dataSource.manifest.singleAccount, + self.dataSource.authSession.institutionSkipAccountSelection ?? false, + accounts.count == 1 + { + // the user saw an OAuth account selection screen and selected + // just one to send back in a single-account context. treat these as if + // we had done account selection, and submit. + self.dataSource.updateSelectedAccounts(accounts) + self.didSelectLinkAccounts() + } else { + let (enabledAccounts, disabledAccounts) = + accounts + .reduce( + ([FinancialConnectionsPartnerAccount](), [FinancialConnectionsPartnerAccount]()) + ) { accountsTuple, account in + if !account.allowSelectionNonOptional { + return ( + accountsTuple.0, + accountsTuple.1 + [account] + ) + } else { + return ( + accountsTuple.0 + [account], + accountsTuple.1 + ) + } + } + self.displayAccounts(enabledAccounts, disabledAccounts) + } + case .failure(let error): + if let error = error as? StripeError, + case .apiError(let apiError) = error, + let extraFields = apiError.allResponseFields["extra_fields"] as? [String: Any], + let reason = extraFields["reason"] as? String, + reason == "no_supported_payment_method_type_accounts_found", + let numberOfIneligibleAccounts = extraFields["total_accounts_count"] as? Int, + // it should never happen, but if numberOfIneligibleAccounts is < 1, we should + // show "AccountLoadErrorView." + numberOfIneligibleAccounts > 0 + { + let errorView = AccountPickerNoAccountEligibleErrorView( + institution: self.dataSource.institution, + bussinessName: self.businessName, + institutionSkipAccountSelection: self.dataSource.authSession.institutionSkipAccountSelection + ?? false, + numberOfIneligibleAccounts: numberOfIneligibleAccounts, + paymentMethodType: self.dataSource.manifest.paymentMethodType ?? .usBankAccount, + didSelectAnotherBank: self.didSelectAnotherBank + ) + // the user will never enter this instance of `AccountPickerViewController` + // again...they can only choose manual entry or go through "ResetFlow" + self.showErrorView(errorView) + self.dataSource + .analyticsClient + .logExpectedError( + error, + errorName: "AccountNoneEligibleForPaymentMethodError", + pane: .accountPicker + ) + } else { + // if we didn't get that specific error back, we don't know what's wrong. could the be + // aggregator, could be Stripe. + self.showAccountLoadErrorView(error: error) + } + } + retreivingAccountsLoadingView.removeFromSuperview() + } + } + + private func displayAccounts( + _ enabledAccounts: [FinancialConnectionsPartnerAccount], + _ disabledAccounts: [FinancialConnectionsPartnerAccount] + ) { + let accountPickerSelectionView = AccountPickerSelectionView( + accountPickerType: accountPickerType, + enabledAccounts: enabledAccounts, + disabledAccounts: disabledAccounts, + institution: dataSource.institution, + delegate: self + ) + self.accountPickerSelectionView = accountPickerSelectionView + let paneLayoutView = PaneWithHeaderLayoutView( + title: { + if self.institutionHasAccountPicker { + return STPLocalizedString( + "Confirm your account", + "The title of a screen that allows users to select which bank accounts they want to use to pay for something." + ) + } else { + if dataSource.manifest.singleAccount { + return STPLocalizedString( + "Select an account", + "The title of a screen that allows users to select which bank accounts they want to use to pay for something." + ) + } else { + return STPLocalizedString( + "Select accounts", + "The title of a screen that allows users to select which bank accounts they want to use to pay for something." + ) + } + } + }(), + subtitle: { + if institutionHasAccountPicker { + return STPLocalizedString( + "Select the account you'd like to link.", + "A subtitle/description of a screen that allows users to select which bank accounts they want to use to pay for something. This text tries to portray that they only need to select one bank account." + ) + } else { + return nil // no subtitle + } + }(), + contentView: accountPickerSelectionView, + footerView: footerView + ) + paneLayoutView.addTo(view: view) + + switch accountPickerType { + case .checkbox: + // select all accounts + dataSource.updateSelectedAccounts(enabledAccounts) + case .radioButton: + if let firstAccount = enabledAccounts.first { + dataSource.updateSelectedAccounts([firstAccount]) + } else { + // defensive programming; it should never happen that we have 0 accounts + dataSource.updateSelectedAccounts([]) + } + } + } + + private func showAccountLoadErrorView(error: Error) { + let errorView = AccountPickerAccountLoadErrorView( + institution: dataSource.institution, + didSelectAnotherBank: didSelectAnotherBank, + didSelectTryAgain: didSelectTryAgain, + didSelectEnterBankDetailsManually: didSelectManualEntry + ) + showErrorView(errorView) + dataSource + .analyticsClient + .logExpectedError( + error, + errorName: "AccountLoadError", + pane: .accountPicker + ) + } + + private func showErrorView(_ errorView: UIView?) { + if let errorView = errorView { + view.addAndPinSubview(errorView) + } else { + // clear last error + self.errorView?.removeFromSuperview() + } + self.errorView = errorView + } + + private func didSelectLinkAccounts() { + let numberOfSelectedAccounts = dataSource.selectedAccounts.count + let linkingAccountsLoadingView = LinkingAccountsLoadingView( + numberOfSelectedAccounts: numberOfSelectedAccounts, + businessName: businessName + ) + view.addAndPinSubviewToSafeArea(linkingAccountsLoadingView) + + dataSource + .selectAuthSessionAccounts() + .observe(on: .main) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let linkedAccounts): + self.delegate?.accountPickerViewController( + self, + didSelectAccounts: linkedAccounts.data + ) + case .failure(let error): + self.dataSource + .analyticsClient + .logUnexpectedError( + error, + errorName: "SelectAuthSessionAccountsError", + pane: .accountPicker + ) + self.delegate?.accountPickerViewController(self, didReceiveTerminalError: error) + } + } + } + + private func logAccountSelectOrDeselect(selectedAccounts: [FinancialConnectionsPartnerAccount]) { + let isSelect: Bool? // false when deselected, and nil when event shouldn't be sent + let accountId: String? + switch accountPickerType { + case .radioButton: + // user deselected an account by selecting the same row + if selectedAccounts.isEmpty { + isSelect = false + accountId = dataSource.selectedAccounts.first?.id + } + // user selected a new account + else { + isSelect = true + accountId = selectedAccounts.first?.id + } + case .checkbox: + // if user selects or deselects more than two accounts at the same time, we assume user pressed + // "All accounts" which we have decided to exclude due to V3 changes + let pressedAllAccountsButton = (abs(selectedAccounts.count - dataSource.selectedAccounts.count) >= 2) + if !pressedAllAccountsButton { + if selectedAccounts.count > dataSource.selectedAccounts.count { + // selected a new, additional account + isSelect = true + accountId = selectedAccounts + .filter({ newSelectedAccount in + return !dataSource.selectedAccounts.contains(where: { $0.id == newSelectedAccount.id }) + }) + .first? + .id + } + // selectedAccounts.count < dataSource.selectedAccounts.count + else { + // deselected an account + isSelect = false + accountId = dataSource.selectedAccounts + .filter({ oldSelectedAccount in + return !selectedAccounts.contains(where: { $0.id == oldSelectedAccount.id }) + }) + .first? + .id + } + } else { + isSelect = nil + accountId = nil + } + } + if let isSelect = isSelect, let accountId = accountId { + dataSource + .analyticsClient + .log( + eventName: isSelect ? "click.account_picker.account_selected" : "click.account_picker.account_unselected", + parameters: [ + "account": accountId, + "is_single_account": dataSource.manifest.singleAccount, + ], + pane: .accountPicker + ) + } + } +} + +// MARK: - AccountPickerSelectionViewDelegate + +extension AccountPickerViewController: AccountPickerSelectionViewDelegate { + + func accountPickerSelectionView( + _ view: AccountPickerSelectionView, + didSelectAccounts selectedAccounts: [FinancialConnectionsPartnerAccount] + ) { + logAccountSelectOrDeselect(selectedAccounts: selectedAccounts) + dataSource.updateSelectedAccounts(selectedAccounts) + } +} + +// MARK: - AccountPickerDataSourceDelegate + +extension AccountPickerViewController: AccountPickerDataSourceDelegate { + func accountPickerDataSource( + _ dataSource: AccountPickerDataSource, + didSelectAccounts selectedAccounts: [FinancialConnectionsPartnerAccount] + ) { + footerView.didSelectAccounts(count: selectedAccounts.count) + accountPickerSelectionView?.selectAccounts(selectedAccounts) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/CheckboxView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/CheckboxView.swift new file mode 100644 index 00000000..175d8154 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/CheckboxView.swift @@ -0,0 +1,57 @@ +// +// CheckboxView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/10/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +final class CheckboxView: UIView { + + private let checkboxImageView: UIImageView = { + let checkboxImageView = UIImageView() + checkboxImageView.contentMode = .scaleAspectFit + checkboxImageView.image = Image.check.makeImage() + .withTintColor(.customBackgroundColor, renderingMode: .alwaysOriginal) + return checkboxImageView + }() + + var isSelected: Bool = false { + didSet { + checkboxImageView.isHidden = !isSelected + layer.cornerRadius = 6 + if isSelected { + backgroundColor = .textBrand + layer.borderWidth = 0 + layer.borderColor = UIColor.clear.cgColor + } else { + backgroundColor = .clear + layer.borderWidth = 1 + layer.borderColor = UIColor.borderNeutral.cgColor + } + } + } + + init() { + super.init(frame: .zero) + isSelected = false // fire off setter to draw + addSubview(checkboxImageView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + let checkmarkSize = CGSize(width: 12, height: 12) + checkboxImageView.frame = CGRect( + x: bounds.midX - checkmarkSize.width / 2, + y: bounds.midY - checkmarkSize.height / 2, + width: checkmarkSize.width, + height: checkmarkSize.height + ) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/LinkingAccountsLoadingView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/LinkingAccountsLoadingView.swift new file mode 100644 index 00000000..46814ae6 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/LinkingAccountsLoadingView.swift @@ -0,0 +1,74 @@ +// +// LinkingAccountsLoadingView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/28/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +final class LinkingAccountsLoadingView: UIView { + + init( + numberOfSelectedAccounts: Int, + businessName: String? + ) { + super.init(frame: .zero) + let linkingAccountsLoadingView = ReusableInformationView( + iconType: .loading, + title: { + if numberOfSelectedAccounts == 1 { + return STPLocalizedString( + "Linking account", + "The title of the loading screen that appears when a user is in process of connecting their bank account to an application. Once the bank account is connected (or linked), the user will be able to use that bank account for payments." + ) + } else { + return STPLocalizedString( + "Linking accounts", + "The title of the loading screen that appears when a user is in process of connecting their bank accounts to an application. Once the bank accounts are connected (or linked), the user will be able to use those bank accounts for payments." + ) + } + }(), + subtitle: { + if numberOfSelectedAccounts == 1 { + if let businessName = businessName { + return String( + format: STPLocalizedString( + "Please wait while your account is connected to %@.", + "The subtitle/description of the loading screen that appears when a user is in process of connecting their bank account to an application. Once the bank account is connected (or linked), the user will be able to use the bank account for payments. %@ will be replaced by the business name, for example, The Coca-Cola Company." + ), + businessName + ) + } else { + return STPLocalizedString( + "Please wait while your account is connected to Stripe.", + "The subtitle/description of the loading screen that appears when a user is in process of connecting their bank account to an application. Once the bank account is connected (or linked), the user will be able to use the bank account for payments." + ) + } + } else { // multiple bank accounts (numberOfSelectedAccounts > 1) + if let businessName = businessName { + return String( + format: STPLocalizedString( + "Please wait while your accounts are connected to %@.", + "The subtitle/description of the loading screen that appears when a user is in process of connecting their bank accounts to an application. Once the bank accounts are connected (or linked), the user will be able to use those bank accounts for payments. %@ will be replaced by the business name, for example, The Coca-Cola Company." + ), + businessName + ) + } else { + return STPLocalizedString( + "Please wait while your accounts are connected to Stripe.", + "The subtitle/description of the loading screen that appears when a user is in process of connecting their bank accounts to an application. Once the bank accounts are connected (or linked), the user will be able to use those bank accounts for payments." + ) + } + } + }() + ) + addAndPinSubviewToSafeArea(linkingAccountsLoadingView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/RadioButtonView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/RadioButtonView.swift new file mode 100644 index 00000000..8323a71d --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AccountPicker/RadioButtonView.swift @@ -0,0 +1,82 @@ +// +// RadioButtonView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/18/22. +// + +import Foundation +import UIKit + +final class RadioButtonView: UIView { + private struct Constants { + static let diameter: CGFloat = 20 + static let innerDiameter: CGFloat = 8 + static let borderWidth: CGFloat = 1 + } + + var isSelected: Bool = false { + didSet { + updateViewBasedOffSelectionState() + } + } + + private let unselectedStateLayer: CALayer = { + let unselectedStateLayer = CALayer() + unselectedStateLayer.bounds = CGRect(x: 0, y: 0, width: Constants.diameter, height: Constants.diameter) + unselectedStateLayer.cornerRadius = Constants.diameter / 2 + unselectedStateLayer.borderWidth = Constants.borderWidth + unselectedStateLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5) + return unselectedStateLayer + }() + + private let selectedStateLayer: CALayer = { + let selectedStateLayer = CALayer() + selectedStateLayer.bounds = CGRect(x: 0, y: 0, width: Constants.diameter, height: Constants.diameter) + selectedStateLayer.cornerRadius = Constants.diameter / 2 + selectedStateLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5) + + let innerCircleLayer = CALayer() + innerCircleLayer.backgroundColor = UIColor.white.cgColor + innerCircleLayer.cornerRadius = Constants.innerDiameter / 2 + innerCircleLayer.bounds = CGRect(x: 0, y: 0, width: Constants.innerDiameter, height: Constants.innerDiameter) + innerCircleLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5) + + // Add and center inner circle + selectedStateLayer.addSublayer(innerCircleLayer) + innerCircleLayer.position = CGPoint(x: selectedStateLayer.bounds.midX, y: selectedStateLayer.bounds.midY) + + return selectedStateLayer + }() + + override var intrinsicContentSize: CGSize { + return CGSize(width: Constants.diameter, height: Constants.diameter) + } + + init() { + super.init(frame: .zero) + layer.addSublayer(unselectedStateLayer) + layer.addSublayer(selectedStateLayer) + unselectedStateLayer.borderColor = UIColor.borderNeutral.cgColor + selectedStateLayer.backgroundColor = UIColor.textBrand.cgColor + + updateViewBasedOffSelectionState() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + override func layoutSubviews() { + unselectedStateLayer.position = CGPoint(x: bounds.midX, y: bounds.midY) + selectedStateLayer.position = CGPoint(x: bounds.midX, y: bounds.midY) + } + + private func updateViewBasedOffSelectionState() { + CATransaction.begin() + CATransaction.setDisableActions(true) + unselectedStateLayer.isHidden = isSelected + selectedStateLayer.isHidden = !unselectedStateLayer.isHidden + CATransaction.commit() + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AttachLinkedPaymentAccount/AccountNumberRetrievalErrorView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AttachLinkedPaymentAccount/AccountNumberRetrievalErrorView.swift new file mode 100644 index 00000000..70da89c0 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AttachLinkedPaymentAccount/AccountNumberRetrievalErrorView.swift @@ -0,0 +1,114 @@ +// +// AccountNumberRetrievalErrorView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/29/22. +// + +import Foundation + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +final class AccountNumberRetrievalErrorView: UIView { + + init( + institution: FinancialConnectionsInstitution, + didSelectAnotherBank: @escaping () -> Void, + didSelectEnterBankDetailsManually: (() -> Void)? // if nil, don't show button + ) { + super.init(frame: .zero) + let reusableInformationView = ReusableInformationView( + iconType: .view( + { + let institutionIconView = InstitutionIconView( + size: .large, + showWarning: true + ) + institutionIconView.setImageUrl(institution.icon?.default) + return institutionIconView + }() + ), + title: STPLocalizedString( + "Your account number couldn’t be accessed at this time", + "The title of a screen that shows an error. The error appears after we failed to access users bank account." + ), + subtitle: { + let isManualEntryEnabled = didSelectEnterBankDetailsManually != nil + if isManualEntryEnabled { + return STPLocalizedString( + "Please enter your bank details manually or select another bank.", + "The subtitle/description of a screen that shows an error. The error appears after we failed to access users bank account. Here we instruct the user to enter their bank details manually or to try selecting another bank." + ) + } else { + return STPLocalizedString( + "Please select another bank.", + "The subtitle/description of a screen that shows an error. The error appears after we failed to access users bank account. Here we instruct the user to try selecting another bank." + ) + } + }(), + primaryButtonConfiguration: ReusableInformationView.ButtonConfiguration( + title: String.Localized.select_another_bank, + action: didSelectAnotherBank + ), + secondaryButtonConfiguration: { + if let didSelectEnterBankDetailsManually = didSelectEnterBankDetailsManually { + return ReusableInformationView.ButtonConfiguration( + title: String.Localized.enter_bank_details_manually, + action: didSelectEnterBankDetailsManually + ) + } else { + return nil + } + }() + ) + addAndPinSubview(reusableInformationView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +#if DEBUG + +import SwiftUI + +private struct AccountNumberRetrievalErrorViewUIViewRepresentable: UIViewRepresentable { + + let institutionName: String + let didSelectEnterBankDetailsManually: (() -> Void)? + + func makeUIView(context: Context) -> AccountNumberRetrievalErrorView { + AccountNumberRetrievalErrorView( + institution: FinancialConnectionsInstitution( + id: "123", + name: institutionName, + url: nil, + icon: nil, + logo: nil + ), + didSelectAnotherBank: {}, + didSelectEnterBankDetailsManually: didSelectEnterBankDetailsManually + ) + } + + func updateUIView(_ uiView: AccountNumberRetrievalErrorView, context: Context) {} +} + +struct AccountNumberRetrievalErrorView_Previews: PreviewProvider { + static var previews: some View { + AccountNumberRetrievalErrorViewUIViewRepresentable( + institutionName: "Chase", + didSelectEnterBankDetailsManually: {} + ) + + AccountNumberRetrievalErrorViewUIViewRepresentable( + institutionName: "Bank of America", + didSelectEnterBankDetailsManually: nil + ) + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AttachLinkedPaymentAccount/AttachLinkedPaymentAccountDataSource.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AttachLinkedPaymentAccount/AttachLinkedPaymentAccountDataSource.swift new file mode 100644 index 00000000..ca23642c --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AttachLinkedPaymentAccount/AttachLinkedPaymentAccountDataSource.swift @@ -0,0 +1,63 @@ +// +// AttachLinkedPaymentAccountDataSource.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/28/22. +// + +import Foundation +@_spi(STP) import StripeCore + +protocol AttachLinkedPaymentAccountDataSource: AnyObject { + + var manifest: FinancialConnectionsSessionManifest { get } + var institution: FinancialConnectionsInstitution { get } + var analyticsClient: FinancialConnectionsAnalyticsClient { get } + var authSessionId: String? { get } + var reduceManualEntryProminenceInErrors: Bool { get } + + func attachLinkedAccountIdToLinkAccountSession() -> Future +} + +final class AttachLinkedPaymentAccountDataSourceImplementation: AttachLinkedPaymentAccountDataSource { + + private let apiClient: FinancialConnectionsAPIClient + private let clientSecret: String + let manifest: FinancialConnectionsSessionManifest + let institution: FinancialConnectionsInstitution + private let linkedAccountId: String + let analyticsClient: FinancialConnectionsAnalyticsClient + let authSessionId: String? + private let consumerSessionClientSecret: String? + let reduceManualEntryProminenceInErrors: Bool + + init( + apiClient: FinancialConnectionsAPIClient, + clientSecret: String, + manifest: FinancialConnectionsSessionManifest, + institution: FinancialConnectionsInstitution, + linkedAccountId: String, + analyticsClient: FinancialConnectionsAnalyticsClient, + authSessionId: String?, + consumerSessionClientSecret: String?, + reduceManualEntryProminenceInErrors: Bool + ) { + self.apiClient = apiClient + self.clientSecret = clientSecret + self.manifest = manifest + self.institution = institution + self.linkedAccountId = linkedAccountId + self.analyticsClient = analyticsClient + self.authSessionId = authSessionId + self.consumerSessionClientSecret = consumerSessionClientSecret + self.reduceManualEntryProminenceInErrors = reduceManualEntryProminenceInErrors + } + + func attachLinkedAccountIdToLinkAccountSession() -> Future { + return apiClient.attachLinkedAccountIdToLinkAccountSession( + clientSecret: clientSecret, + linkedAccountId: linkedAccountId, + consumerSessionClientSecret: consumerSessionClientSecret // used for Link + ) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AttachLinkedPaymentAccount/AttachLinkedPaymentAccountViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AttachLinkedPaymentAccount/AttachLinkedPaymentAccountViewController.swift new file mode 100644 index 00000000..3de11347 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/AttachLinkedPaymentAccount/AttachLinkedPaymentAccountViewController.swift @@ -0,0 +1,172 @@ +// +// AttachLinkedPaymentAccountViewController.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/28/22. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol AttachLinkedPaymentAccountViewControllerDelegate: AnyObject { + func attachLinkedPaymentAccountViewController( + _ viewController: AttachLinkedPaymentAccountViewController, + didFinishWithPaymentAccountResource paymentAccountResource: FinancialConnectionsPaymentAccountResource, + saveToLinkWithStripeSucceeded: Bool? + ) + func attachLinkedPaymentAccountViewControllerDidSelectAnotherBank( + _ viewController: AttachLinkedPaymentAccountViewController + ) + func attachLinkedPaymentAccountViewControllerDidSelectManualEntry( + _ viewController: AttachLinkedPaymentAccountViewController + ) +} + +final class AttachLinkedPaymentAccountViewController: UIViewController { + + private let dataSource: AttachLinkedPaymentAccountDataSource + weak var delegate: AttachLinkedPaymentAccountViewControllerDelegate? + + private var didSelectAnotherBank: () -> Void { + return { [weak self] in + guard let self = self else { return } + self.delegate?.attachLinkedPaymentAccountViewControllerDidSelectAnotherBank(self) + } + } + // we only allow to retry once + private var allowRetry = true + private var didSelectTryAgain: (() -> Void)? { + return allowRetry + ? { [weak self] in + guard let self = self else { return } + self.allowRetry = false + self.showErrorView(nil) + self.attachLinkedAccountIdToLinkAccountSession() + } : nil + } + private var didSelectManualEntry: (() -> Void)? { + return (dataSource.manifest.allowManualEntry && !dataSource.reduceManualEntryProminenceInErrors) + ? { [weak self] in + guard let self = self else { return } + self.delegate?.attachLinkedPaymentAccountViewControllerDidSelectManualEntry(self) + } : nil + } + private var errorView: UIView? + + init(dataSource: AttachLinkedPaymentAccountDataSource) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .customBackgroundColor + navigationItem.hidesBackButton = true + + dataSource + .analyticsClient + .logPaneLoaded(pane: .attachLinkedPaymentAccount) + + attachLinkedAccountIdToLinkAccountSession() + } + + private func attachLinkedAccountIdToLinkAccountSession() { + let linkingAccountsLoadingView = LinkingAccountsLoadingView( + // the `AttachLinkedPaymentAccount` flow will only ever + // have one account + numberOfSelectedAccounts: 1, + businessName: dataSource.manifest.businessName + ) + view.addAndPinSubviewToSafeArea(linkingAccountsLoadingView) + + let pollingStartDate = Date() + dataSource.attachLinkedAccountIdToLinkAccountSession() + .observe { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let paymentAccountResource): + var saveToLinkWithStripeSucceeded: Bool? + if self.dataSource.manifest.isNetworkingUserFlow == true { + if self.dataSource.manifest.accountholderIsLinkConsumer == true { + saveToLinkWithStripeSucceeded = paymentAccountResource.networkingSuccessful + } + } + + self.dataSource + .analyticsClient + .log( + eventName: "polling.attachPayment.success", + parameters: [ + "duration": Date().timeIntervalSince(pollingStartDate).milliseconds, + "authSessionId": self.dataSource.authSessionId ?? "unknown", + ], + pane: .attachLinkedPaymentAccount + ) + + self.delegate?.attachLinkedPaymentAccountViewController( + self, + didFinishWithPaymentAccountResource: paymentAccountResource, + saveToLinkWithStripeSucceeded: saveToLinkWithStripeSucceeded + ) + // we don't remove `linkingAccountsLoadingView` on success + // because this is the last time the user will see this + // screen, and we don't want to show a blank background + // while we transition to the next pane + case .failure(let error): + linkingAccountsLoadingView.removeFromSuperview() + if let error = error as? StripeError, + case .apiError(let apiError) = error, + let extraFields = apiError.allResponseFields["extra_fields"] as? [String: Any], + let reason = extraFields["reason"] as? String, + reason == "account_number_retrieval_failed" + { + let errorView = AccountNumberRetrievalErrorView( + institution: self.dataSource.institution, + didSelectAnotherBank: self.didSelectAnotherBank, + didSelectEnterBankDetailsManually: self.didSelectManualEntry + ) + self.showErrorView(errorView) + self.dataSource + .analyticsClient + .logExpectedError( + error, + errorName: "AccountNumberRetrievalError", + pane: .attachLinkedPaymentAccount + ) + } else { + // something unknown happened here, allow a retry + let errorView = AccountPickerAccountLoadErrorView( + institution: self.dataSource.institution, + didSelectAnotherBank: self.didSelectAnotherBank, + didSelectTryAgain: self.didSelectTryAgain, + didSelectEnterBankDetailsManually: self.didSelectManualEntry + ) + self.showErrorView(errorView) + self.dataSource + .analyticsClient + .logUnexpectedError( + error, + errorName: "AttachLinkedPaymentAccountError", + pane: .attachLinkedPaymentAccount + ) + } + } + } + } + + private func showErrorView(_ errorView: UIView?) { + if let errorView = errorView { + view.addAndPinSubview(errorView) + } else { + // clear last error + self.errorView?.removeFromSuperview() + } + self.errorView = errorView + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Consent/ConsentBodyView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Consent/ConsentBodyView.swift new file mode 100644 index 00000000..7f40be1d --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Consent/ConsentBodyView.swift @@ -0,0 +1,142 @@ +// +// ConsentBodyView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 6/15/22. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +class ConsentBodyView: UIView { + + init( + bulletItems: [FinancialConnectionsBulletPoint], + didSelectURL: @escaping (URL) -> Void + ) { + super.init(frame: .zero) + backgroundColor = .customBackgroundColor + + let verticalStackView = HitTestStackView() + verticalStackView.axis = .vertical + verticalStackView.spacing = 16 + bulletItems.forEach { bulletItem in + verticalStackView.addArrangedSubview( + CreateLabelView( + title: bulletItem.title, + content: bulletItem.content, + iconUrl: bulletItem.icon?.default, + action: didSelectURL + ) + ) + } + addAndPinSubview(verticalStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private func CreateLabelView( + title: String?, + content: String?, + iconUrl: String?, + action: @escaping (URL) -> Void +) -> UIView { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.setImage(with: iconUrl) + imageView.translatesAutoresizingMaskIntoConstraints = false + let imageDiameter: CGFloat = 16 + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: imageDiameter), + imageView.heightAnchor.constraint(equalToConstant: imageDiameter), + ]) + + let labelView = BulletPointLabelView( + title: title, + content: content, + didSelectURL: action + ) + + let horizontalStackView = HitTestStackView( + arrangedSubviews: [ + { + // add padding to the `imageView` so the + // image is aligned with the label + let paddingStackView = UIStackView( + arrangedSubviews: [imageView] + ) + paddingStackView.isLayoutMarginsRelativeArrangement = true + paddingStackView.directionalLayoutMargins = NSDirectionalEdgeInsets( + // center the image in the middle of the first line height + top: max(0, (labelView.topLineHeight - imageDiameter) / 2), + leading: 0, + bottom: 0, + trailing: 0 + ) + return paddingStackView + }(), + labelView, + ] + ) + horizontalStackView.axis = .horizontal + horizontalStackView.spacing = 12 + horizontalStackView.alignment = .top + return horizontalStackView +} + +#if DEBUG + +import SwiftUI + +private struct ConsentBodyViewUIViewRepresentable: UIViewRepresentable { + + func makeUIView(context: Context) -> ConsentBodyView { + ConsentBodyView( + bulletItems: [ + FinancialConnectionsBulletPoint( + icon: FinancialConnectionsImage( + default: + "https://b.stripecdn.com/connections-statics-srv/assets/SailIcon--reserve-primary-3x.png" + ), + content: + "Stripe will allow Goldilocks to access only the [data requested](https://www.stripe.com). We never share your login details with them." + ), + FinancialConnectionsBulletPoint( + icon: FinancialConnectionsImage( + default: + "https://b.stripecdn.com/connections-statics-srv/assets/SailIcon--reserve-primary-3x.png" + ), + content: "Your data is encrypted for your protection." + ), + FinancialConnectionsBulletPoint( + icon: FinancialConnectionsImage( + default: + "https://b.stripecdn.com/connections-statics-srv/assets/SailIcon--reserve-primary-3x.png" + ), + content: "You can [disconnect](https://www.stripe.com) your accounts at any time." + ), + ], + didSelectURL: { _ in } + ) + } + + func updateUIView(_ uiView: ConsentBodyView, context: Context) {} +} + +struct ConsentBodyView_Previews: PreviewProvider { + static var previews: some View { + VStack(alignment: .leading) { + ConsentBodyViewUIViewRepresentable() + .frame(maxHeight: 200) + .padding() + Spacer() + } + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Consent/ConsentDataSource.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Consent/ConsentDataSource.swift new file mode 100644 index 00000000..892836a2 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Consent/ConsentDataSource.swift @@ -0,0 +1,48 @@ +// +// ConsentDataSource.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/13/22. +// + +import Foundation +@_spi(STP) import StripeCore + +protocol ConsentDataSource: AnyObject { + var manifest: FinancialConnectionsSessionManifest { get } + var consent: FinancialConnectionsConsent { get } + var merchantLogo: [String]? { get } + var analyticsClient: FinancialConnectionsAnalyticsClient { get } + + func markConsentAcquired() -> Promise +} + +final class ConsentDataSourceImplementation: ConsentDataSource { + + let manifest: FinancialConnectionsSessionManifest + let consent: FinancialConnectionsConsent + let merchantLogo: [String]? + private let apiClient: FinancialConnectionsAPIClient + private let clientSecret: String + let analyticsClient: FinancialConnectionsAnalyticsClient + + init( + manifest: FinancialConnectionsSessionManifest, + consent: FinancialConnectionsConsent, + merchantLogo: [String]?, + apiClient: FinancialConnectionsAPIClient, + clientSecret: String, + analyticsClient: FinancialConnectionsAnalyticsClient + ) { + self.manifest = manifest + self.consent = consent + self.merchantLogo = merchantLogo + self.apiClient = apiClient + self.clientSecret = clientSecret + self.analyticsClient = analyticsClient + } + + func markConsentAcquired() -> Promise { + return apiClient.markConsentAcquired(clientSecret: clientSecret) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Consent/ConsentFooterView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Consent/ConsentFooterView.swift new file mode 100644 index 00000000..8ea8a4d7 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Consent/ConsentFooterView.swift @@ -0,0 +1,131 @@ +// +// ConsentFooterView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 6/14/22. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +class ConsentFooterView: HitTestView { + + private let agreeButtonText: String + private let didSelectAgree: () -> Void + + private lazy var agreeButton: StripeUICore.Button = { + let agreeButton = Button(configuration: .financialConnectionsPrimary) + agreeButton.title = agreeButtonText + agreeButton.addTarget(self, action: #selector(didSelectAgreeButton), for: .touchUpInside) + agreeButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + agreeButton.heightAnchor.constraint(equalToConstant: 56) + ]) + agreeButton.accessibilityIdentifier = "consent_agree_button" + return agreeButton + }() + + init( + aboveCtaText: String, + ctaText: String, + belowCtaText: String?, + didSelectAgree: @escaping () -> Void, + didSelectURL: @escaping (URL) -> Void + ) { + self.agreeButtonText = ctaText + self.didSelectAgree = didSelectAgree + super.init(frame: .zero) + backgroundColor = .customBackgroundColor + + let termsAndPrivacyPolicyLabel = AttributedTextView( + font: .body(.small), + boldFont: .body(.smallEmphasized), + linkFont: .body(.smallEmphasized), + textColor: .textSecondary, + alignCenter: true + ) + termsAndPrivacyPolicyLabel.setText( + aboveCtaText, + action: didSelectURL + ) + + let verticalStackView = HitTestStackView( + arrangedSubviews: [ + termsAndPrivacyPolicyLabel, + agreeButton, + ] + ) + verticalStackView.axis = .vertical + verticalStackView.spacing = 24 + + if let belowCtaText = belowCtaText { + let manuallyVerifyLabel = AttributedTextView( + font: .body(.small), + boldFont: .body(.smallEmphasized), + linkFont: .body(.smallEmphasized), + textColor: .textSecondary, + alignCenter: true + ) + manuallyVerifyLabel.setText( + belowCtaText, + action: didSelectURL + ) + manuallyVerifyLabel.accessibilityIdentifier = "consent_manually_verify_label" + verticalStackView.addArrangedSubview(manuallyVerifyLabel) + verticalStackView.setCustomSpacing(24, after: agreeButton) + } + + addAndPinSubview(verticalStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func didSelectAgreeButton() { + didSelectAgree() + } + + func setIsLoading(_ isLoading: Bool) { + agreeButton.isLoading = isLoading + } +} + +#if DEBUG + +import SwiftUI + +private struct ConsentFooterViewUIViewRepresentable: UIViewRepresentable { + + func makeUIView(context: Context) -> ConsentFooterView { + ConsentFooterView( + aboveCtaText: + "You agree to Stripe's [Terms](https://stripe.com/legal/end-users#linked-financial-account-terms) and [Privacy Policy](https://stripe.com/privacy). [Learn more](https://stripe.com/privacy-center/legal#linking-financial-accounts)", + ctaText: "Agree", + belowCtaText: "[Manually verify instead](https://www.stripe.com) (takes 1-2 business days)", + didSelectAgree: {}, + didSelectURL: { _ in } + ) + } + + func updateUIView(_ uiView: ConsentFooterView, context: Context) { + uiView.sizeToFit() + } +} + +struct ConsentFooterView_Previews: PreviewProvider { + static var previews: some View { + if #available(iOS 14.0, *) { + VStack { + ConsentFooterViewUIViewRepresentable() + .frame(maxHeight: 200) + Spacer() + } + .padding() + } + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Consent/ConsentLogoView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Consent/ConsentLogoView.swift new file mode 100644 index 00000000..867ad83a --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Consent/ConsentLogoView.swift @@ -0,0 +1,76 @@ +// +// ConsentLogoView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 12/22/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +final class ConsentLogoView: UIView { + + init(merchantLogo: [String]) { + super.init(frame: .zero) + let horizontalStackView = UIStackView() + horizontalStackView.axis = .horizontal + horizontalStackView.spacing = 16.0 + horizontalStackView.alignment = .center + // display one logo + if merchantLogo.isEmpty { + let imageView = UIImageView(image: Image.stripe_logo.makeImage(template: true)) + imageView.tintColor = .textBrand + imageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: 60), + imageView.heightAnchor.constraint(equalToConstant: 25), + ]) + horizontalStackView.addArrangedSubview(imageView) + } + // display multiple logos + else { + for i in 0.. UIView { + let radius: CGFloat = 40.0 + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.cornerRadius = radius / 2.0 + imageView.setImage(with: urlString) + imageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: radius), + imageView.heightAnchor.constraint(equalToConstant: radius), + ]) + return imageView +} + +private func CreateEllipsisView() -> UIView { + let imageView = UIImageView(image: Image.ellipsis.makeImage()) + imageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: 16), + imageView.heightAnchor.constraint(equalToConstant: 4), + ]) + return imageView +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Consent/ConsentViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Consent/ConsentViewController.swift new file mode 100644 index 00000000..433f89c0 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Consent/ConsentViewController.swift @@ -0,0 +1,180 @@ +// +// ConsentViewController.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 6/14/22. +// + +import Foundation +import SafariServices +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol ConsentViewControllerDelegate: AnyObject { + func consentViewControllerDidSelectManuallyVerify(_ viewController: ConsentViewController) + func consentViewController( + _ viewController: ConsentViewController, + didConsentWithManifest manifest: FinancialConnectionsSessionManifest + ) +} + +class ConsentViewController: UIViewController { + + private let dataSource: ConsentDataSource + weak var delegate: ConsentViewControllerDelegate? + + private lazy var titleLabel: AttributedTextView = { + let titleLabel = AttributedTextView( + font: .heading(.large), + boldFont: .heading(.large), + linkFont: .heading(.large), + textColor: .textPrimary, + alignCenter: dataSource.merchantLogo != nil + ) + titleLabel.setText( + dataSource.consent.title, + action: { [weak self] url in + // there are no known cases where we add a link to the title + // but we add this handling regardless in case this changes + // in the future + self?.didSelectURLInTextFromBackend(url) + } + ) + return titleLabel + }() + private lazy var footerView: ConsentFooterView = { + return ConsentFooterView( + aboveCtaText: dataSource.consent.aboveCta, + ctaText: dataSource.consent.cta, + belowCtaText: dataSource.consent.belowCta, + didSelectAgree: { [weak self] in + self?.didSelectAgree() + }, + didSelectURL: { [weak self] url in + self?.didSelectURLInTextFromBackend(url) + } + ) + }() + + init(dataSource: ConsentDataSource) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .customBackgroundColor + + let paneLayoutView = PaneWithCustomHeaderLayoutView( + headerView: { + if let merchantLogo = dataSource.merchantLogo { + let stackView = UIStackView( + arrangedSubviews: [ + ConsentLogoView(merchantLogo: merchantLogo), + titleLabel, + ] + ) + stackView.axis = .vertical + stackView.spacing = 24 + stackView.alignment = .center + return stackView + } else { + return titleLabel + } + }(), + headerTopMargin: 16, + contentView: ConsentBodyView( + bulletItems: dataSource.consent.body.bullets, + didSelectURL: { [weak self] url in + self?.didSelectURLInTextFromBackend(url) + } + ), + headerAndContentSpacing: 24.0, + footerView: footerView + ) + paneLayoutView.addTo(view: view) + + dataSource.analyticsClient.logPaneLoaded(pane: .consent) + } + + private func didSelectAgree() { + dataSource.analyticsClient.log( + eventName: "click.agree", + pane: .consent + ) + + footerView.setIsLoading(true) + dataSource.markConsentAcquired() + .observe(on: .main) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let manifest): + self.delegate?.consentViewController(self, didConsentWithManifest: manifest) + case .failure(let error): + // we display no errors on failure + self.dataSource + .analyticsClient + .logUnexpectedError( + error, + errorName: "ConsentAcquiredError", + pane: .consent + ) + } + self.footerView.setIsLoading(false) + } + } + + private func didSelectURLInTextFromBackend(_ url: URL) { + AuthFlowHelpers.handleURLInTextFromBackend( + url: url, + pane: .consent, + analyticsClient: dataSource.analyticsClient, + handleStripeScheme: { urlHost in + if urlHost == "manual-entry" { + delegate?.consentViewControllerDidSelectManuallyVerify(self) + } else if urlHost == "data-access-notice" { + let dataAccessNoticeModel = dataSource.consent.dataAccessNotice + let consentBottomSheetModel = ConsentBottomSheetModel( + title: dataAccessNoticeModel.title, + subtitle: dataAccessNoticeModel.subtitle, + body: ConsentBottomSheetModel.Body( + bullets: dataAccessNoticeModel.body.bullets + ), + extraNotice: dataAccessNoticeModel.connectedAccountNotice, + learnMore: dataAccessNoticeModel.learnMore, + cta: dataAccessNoticeModel.cta + ) + ConsentBottomSheetViewController.present( + withModel: consentBottomSheetModel, + didSelectUrl: { [weak self] url in + self?.didSelectURLInTextFromBackend(url) + } + ) + } else if urlHost == "legal-details-notice" { + let legalDetailsNoticeModel = dataSource.consent.legalDetailsNotice + let consentBottomSheetModel = ConsentBottomSheetModel( + title: legalDetailsNoticeModel.title, + subtitle: nil, + body: ConsentBottomSheetModel.Body( + bullets: legalDetailsNoticeModel.body.bullets + ), + extraNotice: nil, + learnMore: legalDetailsNoticeModel.learnMore, + cta: legalDetailsNoticeModel.cta + ) + ConsentBottomSheetViewController.present( + withModel: consentBottomSheetModel, + didSelectUrl: { [weak self] url in + self?.didSelectURLInTextFromBackend(url) + } + ) + } + } + ) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/FeaturedInstitutionGridCell.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/FeaturedInstitutionGridCell.swift new file mode 100644 index 00000000..f6c15e27 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/FeaturedInstitutionGridCell.swift @@ -0,0 +1,103 @@ +// +// FeaturedInstitutionGridCellView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 7/19/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +class FeaturedInstitutionGridCell: UICollectionViewCell { + + private lazy var logoImageView: UIImageView = { + let imageView = UIImageView() + imageView.backgroundColor = .clear + return imageView + }() + + // only shown if logo fails loading + private lazy var optionalTitleLabel: UILabel = { + let label = AttributedLabel( + font: .body(.small), + textColor: .textPrimary + ) + label.numberOfLines = 0 + label.textAlignment = .center + return label + }() + + override var isHighlighted: Bool { + didSet { + if isHighlighted { + contentView.layer.borderColor = UIColor.textDisabled.cgColor + + contentView.layer.shadowColor = UIColor.textDisabled.cgColor + contentView.layer.shadowOffset = .zero + contentView.layer.shadowOpacity = 0.8 + contentView.layer.shadowRadius = 2 + } else { + contentView.layer.borderColor = UIColor.borderNeutral.cgColor + + contentView.layer.shadowOpacity = 0 // hide shadow + } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.backgroundColor = .customBackgroundColor + contentView.layer.cornerRadius = 8 + contentView.layer.borderWidth = 1 + + contentView.addAndPinSubview( + optionalTitleLabel, + insets: NSDirectionalEdgeInsets( + top: 12, + leading: 12, + bottom: 12, + trailing: 12 + ) + ) + optionalTitleLabel.isHidden = true + + contentView.addSubview(logoImageView) + logoImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + logoImageView.widthAnchor.constraint(equalToConstant: 88), + logoImageView.heightAnchor.constraint(equalToConstant: 40), + logoImageView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + logoImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + ]) + + // toggle setter so the coloring applies + isHighlighted = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Customize + +extension FeaturedInstitutionGridCell { + + func customize(with institution: FinancialConnectionsInstitution) { + optionalTitleLabel.isHidden = true + logoImageView.isHidden = false + optionalTitleLabel.text = institution.name + + logoImageView.setImage( + with: institution.logo?.default, + completionHandler: { [weak self] didDownloadLogo in + guard let self = self else { + return + } + self.logoImageView.isHidden = !didDownloadLogo + self.optionalTitleLabel.isHidden = !self.logoImageView.isHidden + } + ) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/FeaturedInstitutionGridView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/FeaturedInstitutionGridView.swift new file mode 100644 index 00000000..bbd20f06 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/FeaturedInstitutionGridView.swift @@ -0,0 +1,142 @@ +// +// FeaturedInstitutionGridViewController.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 7/19/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +protocol FeaturedInstitutionGridViewDelegate: AnyObject { + func featuredInstitutionGridView( + _ view: FeaturedInstitutionGridView, + didSelectInstitution institution: FinancialConnectionsInstitution + ) +} + +private enum Section { + case main +} + +class FeaturedInstitutionGridView: UIView { + + private let horizontalPadding: CGFloat = 24.0 + private let flowLayout: UICollectionViewFlowLayout + // necessary to retain a reference to `dataSource` + private let dataSource: UICollectionViewDiffableDataSource + weak var delegate: FeaturedInstitutionGridViewDelegate? + + init() { + let flowLayout = UICollectionViewFlowLayout() + self.flowLayout = flowLayout + + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout) + collectionView.backgroundColor = .clear + collectionView.contentInset = UIEdgeInsets( + top: 0, + left: horizontalPadding, + bottom: 16, + right: horizontalPadding + ) + collectionView.keyboardDismissMode = .onDrag + let cellIdentifier = "\(FeaturedInstitutionGridCell.self)" + collectionView.register(FeaturedInstitutionGridCell.self, forCellWithReuseIdentifier: cellIdentifier) + + let dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { collectionView, indexPath, institution in + guard + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier, for: indexPath) + as? FeaturedInstitutionGridCell + else { + fatalError("Couldn't find cell with reuseIdentifier \(cellIdentifier)") + } + cell.customize(with: institution) + cell.accessibilityLabel = institution.name // used for UI tests + return cell + } + self.dataSource = dataSource + + super.init(frame: .zero) + + collectionView.delegate = self + addAndPinSubview(collectionView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func loadInstitutions(_ institutions: [FinancialConnectionsInstitution]) { + assertMainQueue() + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(institutions, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false) + } + + override func layoutSubviews() { + super.layoutSubviews() + let itemSpacing: CGFloat = 8 + flowLayout.minimumLineSpacing = itemSpacing + flowLayout.minimumInteritemSpacing = itemSpacing + flowLayout.itemSize = CGSize( + width: (bounds.width - itemSpacing - (2 * horizontalPadding)) / 2, + height: 80 + ) + } +} + +// MARK: - + +extension FeaturedInstitutionGridView: UICollectionViewDelegate { + + func collectionView( + _ collectionView: UICollectionView, + didSelectItemAt indexPath: IndexPath + ) { + if let institution = dataSource.itemIdentifier(for: indexPath) { + delegate?.featuredInstitutionGridView(self, didSelectInstitution: institution) + } + } +} + +#if DEBUG + +import SwiftUI + +private struct FeaturedInstitutionGridViewUIViewRepresentable: UIViewRepresentable { + + func makeUIView(context: Context) -> FeaturedInstitutionGridView { + FeaturedInstitutionGridView() + } + + func updateUIView(_ uiView: FeaturedInstitutionGridView, context: Context) { + let institutions = (1...10).map { i in + FinancialConnectionsInstitution( + id: "\(i)", + name: "\(i)", + url: nil, + icon: nil, + logo: nil + ) + } + uiView.loadInstitutions(institutions) + } +} + +struct FeaturedInstitutionGridView_Previews: PreviewProvider { + + static var previews: some View { + VStack { + FeaturedInstitutionGridViewUIViewRepresentable() + } + .frame(width: 320) + .frame(maxHeight: .infinity) + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/InstitutionDataSource.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/InstitutionDataSource.swift new file mode 100644 index 00000000..13211f49 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/InstitutionDataSource.swift @@ -0,0 +1,58 @@ +// +// InstitutionDataSource.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 6/8/22. +// + +import Foundation +@_spi(STP) import StripeCore + +protocol InstitutionDataSource: AnyObject { + + var manifest: FinancialConnectionsSessionManifest { get } + var analyticsClient: FinancialConnectionsAnalyticsClient { get } + + func fetchInstitutions(searchQuery: String) -> Future + func fetchFeaturedInstitutions() -> Future<[FinancialConnectionsInstitution]> +} + +class InstitutionAPIDataSource: InstitutionDataSource { + + // MARK: - Properties + + let manifest: FinancialConnectionsSessionManifest + private let apiClient: FinancialConnectionsAPIClient + private let clientSecret: String + let analyticsClient: FinancialConnectionsAnalyticsClient + + // MARK: - Init + + init( + manifest: FinancialConnectionsSessionManifest, + apiClient: FinancialConnectionsAPIClient, + clientSecret: String, + analyticsClient: FinancialConnectionsAnalyticsClient + ) { + self.manifest = manifest + self.apiClient = apiClient + self.clientSecret = clientSecret + self.analyticsClient = analyticsClient + } + + // MARK: - InstitutionDataSource + + func fetchInstitutions(searchQuery: String) -> Future { + return apiClient.fetchInstitutions( + clientSecret: clientSecret, + query: searchQuery + ) + } + + func fetchFeaturedInstitutions() -> Future<[FinancialConnectionsInstitution]> { + return apiClient.fetchFeaturedInstitutions(clientSecret: clientSecret) + .chained { list in + return Promise(value: list.data) + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/InstitutionPickerViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/InstitutionPickerViewController.swift new file mode 100644 index 00000000..910ae10c --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/InstitutionPickerViewController.swift @@ -0,0 +1,398 @@ +// +// InstitutionPickerViewController.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 6/7/22. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol InstitutionPickerViewControllerDelegate: AnyObject { + func institutionPickerViewController( + _ viewController: InstitutionPickerViewController, + didSelect institution: FinancialConnectionsInstitution + ) + func institutionPickerViewControllerDidSelectManuallyAddYourAccount( + _ viewController: InstitutionPickerViewController + ) +} + +class InstitutionPickerViewController: UIViewController { + + // MARK: - Properties + + private let dataSource: InstitutionDataSource + weak var delegate: InstitutionPickerViewControllerDelegate? + + private lazy var loadingView: ActivityIndicator = { + let activityIndicator = ActivityIndicator(size: .large) + activityIndicator.color = .textDisabled + activityIndicator.backgroundColor = .customBackgroundColor + return activityIndicator + }() + private lazy var searchBar: InstitutionSearchBar = { + let searchBar = InstitutionSearchBar() + searchBar.delegate = self + return searchBar + }() + private lazy var contentContainerView: UIView = { + let contentContainerView = UIView() + contentContainerView.backgroundColor = .clear + return contentContainerView + }() + private lazy var featuredInstitutionGridView: FeaturedInstitutionGridView = { + let featuredInstitutionGridView = FeaturedInstitutionGridView() + featuredInstitutionGridView.delegate = self + return featuredInstitutionGridView + }() + private lazy var institutionSearchTableView: InstitutionSearchTableView = { + let institutionSearchTableView = InstitutionSearchTableView( + frame: view.bounds, + allowManualEntry: dataSource.manifest.allowManualEntry + ) + institutionSearchTableView.delegate = self + return institutionSearchTableView + }() + + // MARK: - Debouncing Support + + private var fetchInstitutionsDispatchWorkItem: DispatchWorkItem? + private var lastInstitutionSearchFetchDate = Date() + + // MARK: - Init + + init(dataSource: InstitutionDataSource) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - UIViewController + + override func viewDidLoad() { + super.viewDidLoad() + setupView() + + showLoadingView(true) + fetchFeaturedInstitutions { [weak self] in + self?.showLoadingView(false) + } + } + + private func setupView() { + view.backgroundColor = UIColor.customBackgroundColor + + view.addAndPinSubview(loadingView) + view.addAndPinSubviewToSafeArea( + CreateMainView( + searchBar: (dataSource.manifest.institutionSearchDisabled == true) ? nil : searchBar, + contentContainerView: contentContainerView + ) + ) + contentContainerView.addAndPinSubview(featuredInstitutionGridView) + contentContainerView.addAndPinSubview(institutionSearchTableView) + + toggleContentContainerViewVisbility() + + let dismissSearchBarTapGestureRecognizer = UITapGestureRecognizer( + target: self, + action: #selector(didTapOutsideOfSearchBar) + ) + dismissSearchBarTapGestureRecognizer.delegate = self + view.addGestureRecognizer(dismissSearchBarTapGestureRecognizer) + } + + private func toggleContentContainerViewVisbility() { + let isUserCurrentlySearching = !searchBar.text.isEmpty + featuredInstitutionGridView.isHidden = isUserCurrentlySearching + institutionSearchTableView.isHidden = !featuredInstitutionGridView.isHidden + } + + @IBAction private func didTapOutsideOfSearchBar() { + searchBar.resignFirstResponder() + } + + private func didSelectInstitution(_ institution: FinancialConnectionsInstitution) { + searchBar.resignFirstResponder() + // clear search results + searchBar.text = "" + institutionSearchTableView.loadInstitutions([]) + toggleContentContainerViewVisbility() + delegate?.institutionPickerViewController(self, didSelect: institution) + } + + private func showLoadingView(_ show: Bool) { + loadingView.isHidden = !show + if show { + loadingView.startAnimating() + } else { + loadingView.stopAnimating() + } + view.bringSubviewToFront(loadingView) // defensive programming to avoid loadingView being hiddden + } +} + +// MARK: - Data + +extension InstitutionPickerViewController { + + private func fetchFeaturedInstitutions(completionHandler: @escaping () -> Void) { + assertMainQueue() + let fetchStartDate = Date() + dataSource + .fetchFeaturedInstitutions() + .observe(on: .main) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let institutions): + self.dataSource + .analyticsClient + .log( + eventName: "search.feature_institutions_loaded", + parameters: [ + "institutions": institutions.map({ $0.id }), + "result_count": institutions.count, + "duration": Date().timeIntervalSince(fetchStartDate).milliseconds, + ], + pane: .institutionPicker + ) + self.featuredInstitutionGridView.loadInstitutions(institutions) + self.dataSource + .analyticsClient + .logPaneLoaded(pane: .institutionPicker) + case .failure(let error): + self.dataSource + .analyticsClient + .logUnexpectedError( + error, + errorName: "FeaturedInstitutionsError", + pane: .institutionPicker + ) + } + completionHandler() + } + } + + private func fetchInstitutions(searchQuery: String) { + fetchInstitutionsDispatchWorkItem?.cancel() + institutionSearchTableView.showError(false) + + guard !searchQuery.isEmpty else { + searchBar.updateSearchingIndicator(false) + // clear data because search query is empty + institutionSearchTableView.loadInstitutions([]) + return + } + + searchBar.updateSearchingIndicator(true) + let newFetchInstitutionsDispatchWorkItem = DispatchWorkItem(block: { [weak self] in + guard let self = self else { return } + + let lastInstitutionSearchFetchDate = Date() + self.lastInstitutionSearchFetchDate = lastInstitutionSearchFetchDate + self.dataSource + .fetchInstitutions(searchQuery: searchQuery) + .observe(on: DispatchQueue.main) { [weak self] result in + guard let self = self else { return } + guard lastInstitutionSearchFetchDate == self.lastInstitutionSearchFetchDate else { + // ignore any search result that came before + // the lastest search attempt + return + } + switch result { + case .success(let institutionList): + self.institutionSearchTableView.loadInstitutions( + institutionList.data, + showManualEntry: institutionList.showManualEntry + ) + self.dataSource + .analyticsClient + .log( + eventName: "search.succeeded", + parameters: [ + "query": searchQuery, + "duration": Date().timeIntervalSince(lastInstitutionSearchFetchDate).milliseconds, + "result_count": institutionList.data.count, + ], + pane: .institutionPicker + ) + case .failure(let error): + self.institutionSearchTableView.loadInstitutions([]) + self.institutionSearchTableView.showError(true) + self.dataSource + .analyticsClient + .logUnexpectedError( + error, + errorName: "SearchInstitutionsError", + pane: .institutionPicker + ) + } + self.searchBar.updateSearchingIndicator(false) + } + }) + self.fetchInstitutionsDispatchWorkItem = newFetchInstitutionsDispatchWorkItem + DispatchQueue.main.asyncAfter( + deadline: .now() + Constants.queryDelay, + execute: newFetchInstitutionsDispatchWorkItem + ) + } +} + +// MARK: - InstitutioNSearchBarDelegate + +extension InstitutionPickerViewController: InstitutionSearchBarDelegate { + + func institutionSearchBar(_ searchBar: InstitutionSearchBar, didChangeText text: String) { + toggleContentContainerViewVisbility() + fetchInstitutions(searchQuery: text) + } +} + +// MARK: - UIGestureRecognizerDelegate + +extension InstitutionPickerViewController: UIGestureRecognizerDelegate { + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + let touchPoint = touch.location(in: view) + return !searchBar.frame.contains(touchPoint) && !contentContainerView.frame.contains(touchPoint) + } +} + +// MARK: - FeaturedInstitutionGridViewDelegate + +extension InstitutionPickerViewController: FeaturedInstitutionGridViewDelegate { + + func featuredInstitutionGridView( + _ view: FeaturedInstitutionGridView, + didSelectInstitution institution: FinancialConnectionsInstitution + ) { + dataSource.analyticsClient.log( + eventName: "search.featured_institution_selected", + parameters: [ + "institution_id": institution.id, + ], + pane: .institutionPicker + ) + didSelectInstitution(institution) + } +} + +// MARK: - InstitutionSearchTableViewDelegate + +extension InstitutionPickerViewController: InstitutionSearchTableViewDelegate { + + func institutionSearchTableView( + _ tableView: InstitutionSearchTableView, + didSelectInstitution institution: FinancialConnectionsInstitution + ) { + dataSource.analyticsClient.log( + eventName: "search.search_result_selected", + parameters: [ + "institution_id": institution.id, + ], + pane: .institutionPicker + ) + didSelectInstitution(institution) + } + + func institutionSearchTableView( + _ tableView: InstitutionSearchTableView, + didSelectManuallyAddYourAccountWithInstitutions institutions: [FinancialConnectionsInstitution] + ) { + dataSource + .analyticsClient + .log( + eventName: "click.manual_entry", + parameters: [ + "institution_ids": institutions.map({ $0.id }), + ], + pane: .institutionPicker + ) + delegate?.institutionPickerViewControllerDidSelectManuallyAddYourAccount(self) + } + + func institutionSearchTableView( + _ tableView: InstitutionSearchTableView, + didScrollInstitutions institutions: [FinancialConnectionsInstitution] + ) { + dataSource + .analyticsClient + .log( + eventName: "search.scroll", + parameters: [ + "institution_ids": institutions.map({ $0.id }) + ], + pane: .institutionPicker + ) + } +} + +// MARK: - Constants + +extension InstitutionPickerViewController { + enum Constants { + static let queryDelay = TimeInterval(0.2) + } +} + +// MARK: - Helpers + +private func CreateMainView( + searchBar: UIView?, + contentContainerView: UIView +) -> UIView { + let verticalStackView = UIStackView( + arrangedSubviews: [ + CreateHeaderView( + searchBar: searchBar + ), + contentContainerView, + ] + ) + verticalStackView.axis = .vertical + verticalStackView.spacing = (searchBar == nil) ? 24 : 16 + return verticalStackView +} + +private func CreateHeaderView( + searchBar: UIView? +) -> UIView { + let verticalStackView = UIStackView( + arrangedSubviews: [ + CreateHeaderTitleLabel() + ] + ) + if let searchBar = searchBar { + verticalStackView.addArrangedSubview(searchBar) + } + verticalStackView.axis = .vertical + verticalStackView.spacing = 24 + verticalStackView.isLayoutMarginsRelativeArrangement = true + verticalStackView.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: 8, + leading: 24, + bottom: 0, + trailing: 24 + ) + return verticalStackView +} + +private func CreateHeaderTitleLabel() -> UIView { + let headerTitleLabel = AttributedLabel( + font: .heading(.large), + textColor: .textPrimary + ) + headerTitleLabel.setText( + STPLocalizedString( + "Select your bank", + "The title of the 'Institution Picker' screen where users get to select an institution (ex. a bank like Bank of America)." + ) + ) + return headerTitleLabel +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/InstitutionSearchBar.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/InstitutionSearchBar.swift new file mode 100644 index 00000000..c1ee9190 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/InstitutionSearchBar.swift @@ -0,0 +1,248 @@ +// +// InstitutionSearchBar.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/30/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +protocol InstitutionSearchBarDelegate: AnyObject { + func institutionSearchBar( + _ searchBar: InstitutionSearchBar, + didChangeText text: String + ) +} + +final class InstitutionSearchBar: UIView { + + weak var delegate: InstitutionSearchBarDelegate? + var text: String { + get { + return textField.text ?? "" + } + set { + textField.text = newValue + // manual changes to `text` do not call `textFieldTextDidChange` + // so here we do it ourselves + textFieldTextDidChange() + } + } + + private lazy var textField: UITextField = { + let textField = IncreasedHitTestTextField() + textField.textColor = .textPrimary + textField.font = FinancialConnectionsFont.label(.large).uiFont + // this removes the `searchTextField` background color. + // for an unknown reason, setting the `backgroundColor` to + // a white color is a no-op + textField.borderStyle = .none + // use `NSAttributedString` to be able to change the placeholder color + textField.attributedPlaceholder = NSAttributedString( + string: STPLocalizedString( + "Search", + "The placeholder message that appears in a search bar. The placeholder appears before a user enters a search term. It instructs user that this is a search bar." + ), + attributes: [ + .foregroundColor: UIColor.textSecondary, + .font: FinancialConnectionsFont.label(.large).uiFont, + ] + ) + textField.returnKeyType = .search + textField.delegate = self + textField.addTarget( + self, + action: #selector(textFieldTextDidChange), + for: .editingChanged + ) + textField.accessibilityIdentifier = "search_bar_text_field" + return textField + }() + private lazy var textFieldClearButton: UIButton = { + let imageView = UIImageView() + let textFieldClearButton = TextFieldClearButton() + let cancelImage = Image.cancel_circle.makeImage() + .withTintColor(.textDisabled) + textFieldClearButton.setImage(cancelImage, for: .normal) + textFieldClearButton.addTarget( + self, + action: #selector(didSelectClearButton), + for: .touchUpInside + ) + textFieldClearButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + textFieldClearButton.widthAnchor.constraint(equalToConstant: 16), + textFieldClearButton.heightAnchor.constraint(equalToConstant: 16), + ]) + return textFieldClearButton + }() + private lazy var searchIconView: UIView = { + let searchIconImageView = UIImageView() + searchIconImageView.image = Image.search.makeImage() + .withTintColor(.textPrimary) + searchIconImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + searchIconImageView.widthAnchor.constraint(equalToConstant: 16), + searchIconImageView.heightAnchor.constraint(equalToConstant: 16), + ]) + return searchIconImageView + }() + + init() { + super.init(frame: .zero) + layer.cornerRadius = 8 + + let horizontalStackView = UIStackView( + arrangedSubviews: [ + searchIconView, + textField, + textFieldClearButton, + ] + ) + horizontalStackView.axis = .horizontal + horizontalStackView.alignment = .center + horizontalStackView.spacing = 10 + horizontalStackView.isLayoutMarginsRelativeArrangement = true + horizontalStackView.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: 16, + leading: 16, + bottom: 16, + trailing: 16 + ) + addAndPinSubview(horizontalStackView) + + highlightBorder(false) + adjustClearButtonVisibility() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @discardableResult override func resignFirstResponder() -> Bool { + return textField.resignFirstResponder() + } + + @objc private func textFieldTextDidChange() { + adjustClearButtonVisibility() + delegate?.institutionSearchBar(self, didChangeText: text) + } + + @objc private func didSelectClearButton() { + text = "" + } + + private func adjustClearButtonVisibility() { + textFieldClearButton.isHidden = text.isEmpty + } + + private func highlightBorder(_ shouldHighlightBorder: Bool) { + let searchBarBorderColor: UIColor + let searchBarBorderWidth: CGFloat + if shouldHighlightBorder { + searchBarBorderColor = .textBrand + searchBarBorderWidth = 2 + } else { + searchBarBorderColor = .borderNeutral + searchBarBorderWidth = 1 + } + layer.borderColor = searchBarBorderColor.cgColor + layer.borderWidth = searchBarBorderWidth + } + + func updateSearchingIndicator(_ isSearching: Bool) { + guard isSearching else { + searchIconView.layer.removeAnimation(forKey: "pulseAnimation") + return + } + guard searchIconView.layer.animation(forKey: "pulseAnimation") == nil else { + return + } + + let opacityAnimation = CABasicAnimation(keyPath: "opacity") + opacityAnimation.fromValue = 0.6 + opacityAnimation.toValue = 0.3 + opacityAnimation.repeatCount = .infinity + opacityAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut) + opacityAnimation.duration = 0.3 + opacityAnimation.autoreverses = true + searchIconView.layer.add(opacityAnimation, forKey: "pulseAnimation") + } +} + +// MARK: - UITextFieldDelegate + +extension InstitutionSearchBar: UITextFieldDelegate { + + func textFieldDidBeginEditing(_ textField: UITextField) { + highlightBorder(true) + } + + func textFieldDidEndEditing(_ textField: UITextField) { + highlightBorder(false) + } + + // called when user presses "Search" button in the keyboard + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} + +private class IncreasedHitTestTextField: UITextField { + // increase the area of TextField taps + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + let largerBounds = bounds.insetBy(dx: 0, dy: -16) + return largerBounds.contains(point) + } +} + +#if DEBUG + +import SwiftUI + +private struct InstitutionSearchBarUIViewRepresentable: UIViewRepresentable { + + let text: String + + func makeUIView(context: Context) -> InstitutionSearchBar { + InstitutionSearchBar() + } + + func updateUIView(_ searchBar: InstitutionSearchBar, context: Context) { + searchBar.text = text + } +} + +struct InstitutionSearchBar_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 20) { + InstitutionSearchBarUIViewRepresentable(text: "") + .frame(width: 327) + .frame(height: 56) + + InstitutionSearchBarUIViewRepresentable(text: "Chase") + .frame(width: 327) + .frame(height: 56) + + Spacer() + } + .frame(maxWidth: .infinity) + } +} + +#endif + +private class TextFieldClearButton: UIButton { + + // increase hit-test area of the clear button + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + let largerBounds = bounds.insetBy( + dx: -(50 - bounds.width) / 2, + dy: -(50 - bounds.height) / 2 + ) + return largerBounds.contains(point) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/InstitutionSearchFooterView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/InstitutionSearchFooterView.swift new file mode 100644 index 00000000..d584b3e5 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/InstitutionSearchFooterView.swift @@ -0,0 +1,227 @@ +// +// InstitutionSearchFooterView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/19/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +final class InstitutionSearchFooterView: UIView { + + private static let constantTopPadding: CGFloat = 10.0 + + private let didSelect: (() -> Void)? + private let topSeparatorView: UIView + private let paddingStackView: UIStackView + var showTopSeparator: Bool { + get { + return !topSeparatorView.isHidden + } + set { + topSeparatorView.isHidden = !newValue + paddingStackView.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: (showTopSeparator ? 20 : 0) + Self.constantTopPadding, + leading: 24, + bottom: 20, + trailing: 24 + ) + } + } + + init( + title: String, + subtitle: String, + showIcon: Bool, + didSelect: (() -> Void)? + ) { + self.didSelect = didSelect + let topSeparatorView = UIView() + topSeparatorView.backgroundColor = .borderNeutral + self.topSeparatorView = topSeparatorView + let paddingStackView = UIStackView( + arrangedSubviews: [ + CreateRowView( + image: showIcon ? .add : nil, + title: title, + subtitle: subtitle + ), + ] + ) + self.paddingStackView = paddingStackView + super.init(frame: .zero) + paddingStackView.isLayoutMarginsRelativeArrangement = true + addAndPinSubview(paddingStackView) + + addSubview(topSeparatorView) + topSeparatorView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + topSeparatorView.topAnchor.constraint(equalTo: topAnchor, constant: Self.constantTopPadding), + topSeparatorView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24), + topSeparatorView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 24), + topSeparatorView.heightAnchor.constraint(equalToConstant: 1.0 / UIScreen.main.nativeScale), + ]) + + if didSelect != nil { + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didTapView)) + tapGestureRecognizer.delegate = self + addGestureRecognizer(tapGestureRecognizer) + } + + self.showTopSeparator = true + accessibilityIdentifier = "institution_search_footer_view" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func didTapView() { + self.didSelect?() + } +} + +// MARK: - UITapGestureRecognizer + +extension InstitutionSearchFooterView: UIGestureRecognizerDelegate { + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + // if user taps on the footer, we always want it to be recognized + // + // if the keyboard is on screen, then NOT having this method + // implemented will block the first tap in order to + // dismiss the keyboard + return true + } +} + +// MARK: - Helpers + +private func CreateRowView( + image: Image?, + title: String, + subtitle: String +) -> UIView { + let horizontalStackView = UIStackView() + horizontalStackView.axis = .horizontal + horizontalStackView.spacing = 12 + horizontalStackView.alignment = .center + if let image = image { + horizontalStackView.addArrangedSubview( + CreateRowIconView(image: image) + ) + } + horizontalStackView.addArrangedSubview( + CreateRowLabelView( + title: title, + subtitle: subtitle + ) + ) + return horizontalStackView +} + +private func CreateRowIconView(image: Image) -> UIView { + let iconImageView = UIImageView() + iconImageView.contentMode = .scaleAspectFit + iconImageView.image = image.makeImage() + .withTintColor(.textBrand) + + let iconContainerView = UIView() + iconContainerView.backgroundColor = .brand100 + iconContainerView.layer.cornerRadius = 4 + iconContainerView.addSubview(iconImageView) + + iconContainerView.translatesAutoresizingMaskIntoConstraints = false + iconImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + iconContainerView.widthAnchor.constraint(equalToConstant: 36), + iconContainerView.heightAnchor.constraint(equalToConstant: 36), + + iconImageView.heightAnchor.constraint(equalToConstant: 20), + iconImageView.widthAnchor.constraint(equalToConstant: 20), + iconImageView.centerXAnchor.constraint(equalTo: iconContainerView.centerXAnchor), + iconImageView.centerYAnchor.constraint(equalTo: iconContainerView.centerYAnchor), + ]) + return iconContainerView +} + +private func CreateRowLabelView( + title: String, + subtitle: String +) -> UIView { + let titleLabel = AttributedTextView( + font: .label(.largeEmphasized), + boldFont: .label(.largeEmphasized), + linkFont: .label(.largeEmphasized), + textColor: .textPrimary + ) + titleLabel.setText(title) + + let subtitleLabel = AttributedTextView( + font: .label(.small), + boldFont: .label(.smallEmphasized), + linkFont: .label(.smallEmphasized), + textColor: .textSecondary + ) + subtitleLabel.setText(subtitle) + + let verticalStackView = UIStackView( + arrangedSubviews: [ + titleLabel, + subtitleLabel, + ] + ) + verticalStackView.axis = .vertical + return verticalStackView +} + +#if DEBUG + +import SwiftUI + +private struct InstitutionSearchFooterViewUIViewRepresentable: UIViewRepresentable { + + let title: String + let subtitle: String + let showIcon: Bool + + func makeUIView(context: Context) -> InstitutionSearchFooterView { + InstitutionSearchFooterView( + title: title, + subtitle: subtitle, + showIcon: showIcon, + didSelect: {} + ) + } + + func updateUIView(_ uiView: InstitutionSearchFooterView, context: Context) { + uiView.sizeToFit() + } +} + +struct InstitutionSearchFooterView_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 20) { + InstitutionSearchFooterViewUIViewRepresentable( + title: "Don't see your bank?", + subtitle: "Enter your bank account and routing numbers", + showIcon: true + ) + .frame(maxHeight: 100) + InstitutionSearchFooterViewUIViewRepresentable( + title: "No results", + subtitle: "Double check your spelling and search terms", + showIcon: false + ) + .frame(maxHeight: 100) + Spacer() + } + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/InstitutionSearchTableView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/InstitutionSearchTableView.swift new file mode 100644 index 00000000..bacb3abf --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/InstitutionSearchTableView.swift @@ -0,0 +1,261 @@ +// +// InstitutionSearchTableView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 7/20/22. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +private enum Section { + case main +} + +protocol InstitutionSearchTableViewDelegate: AnyObject { + func institutionSearchTableView( + _ tableView: InstitutionSearchTableView, + didSelectInstitution institution: FinancialConnectionsInstitution + ) + func institutionSearchTableView( + _ tableView: InstitutionSearchTableView, + didSelectManuallyAddYourAccountWithInstitutions institutions: [FinancialConnectionsInstitution] + ) + func institutionSearchTableView( + _ tableView: InstitutionSearchTableView, + didScrollInstitutions institutions: [FinancialConnectionsInstitution] + ) +} + +final class InstitutionSearchTableView: UIView { + + private let allowManualEntry: Bool + private let tableView: UITableView + private let dataSource: UITableViewDiffableDataSource + private lazy var didSelectManualEntry: (() -> Void)? = { + return allowManualEntry + ? { [weak self] in + guard let self = self else { return } + self.delegate?.institutionSearchTableView( + self, + didSelectManuallyAddYourAccountWithInstitutions: self.institutions + ) + } : nil + }() + weak var delegate: InstitutionSearchTableViewDelegate? + private var institutions: [FinancialConnectionsInstitution] = [] + private var shouldLogScroll = true + + private lazy var tableFooterView: InstitutionSearchFooterView = { + let title: String + let subtitle: String + let showIcon: Bool + let didSelect: (() -> Void)? + if allowManualEntry { + title = STPLocalizedString( + "Don't see your bank?", + "The title of a button that appears at the bottom of search results. It appears when a user is searching for their bank. The purpose of the button is to give users the option to enter their bank account numbers manually (ex. routing and account number)." + ) + subtitle = STPLocalizedString( + "Enter your account and routing numbers", + "The subtitle of a button that appears at the bottom of search results. It appears when a user is searching for their bank. The purpose of the button is to give users the option to enter their bank account numbers manually (ex. routing and account number)." + ) + showIcon = true + didSelect = didSelectManualEntry + } else { + title = STPLocalizedString( + "No results", + "The title of a notice that appears at the bottom of search results. It appears when a user is searching for their bank, but no results are returned." + ) + subtitle = STPLocalizedString( + "Double check your spelling and search terms", + "The subtitle of a notice that appears at the bottom of search results. It appears when a user is searching for their bank, but no results are returned." + ) + showIcon = false + didSelect = nil + } + let footerView = InstitutionSearchFooterView( + title: title, + subtitle: subtitle, + showIcon: showIcon, + didSelect: didSelect + ) + return footerView + }() + private lazy var loadingContainerView: UIView = { + let loadingContainerView = UIView() + loadingContainerView.backgroundColor = .customBackgroundColor + loadingContainerView.isHidden = true + return loadingContainerView + }() + private lazy var loadingView: ActivityIndicator = { + let activityIndicator = ActivityIndicator(size: .large) + activityIndicator.color = .textDisabled + activityIndicator.backgroundColor = .customBackgroundColor + return activityIndicator + }() + + init(frame: CGRect, allowManualEntry: Bool) { + self.allowManualEntry = allowManualEntry + let cellIdentifier = "\(InstitutionSearchTableViewCell.self)" + tableView = UITableView(frame: frame) + dataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, _, institution in + guard + let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) + as? InstitutionSearchTableViewCell + else { + fatalError( + "Unable to dequeue cell \(InstitutionSearchTableViewCell.self) with cell identifier \(cellIdentifier)" + ) + } + cell.customize(with: institution) + return cell + } + dataSource.defaultRowAnimation = .fade + + super.init(frame: frame) + tableView.backgroundColor = .customBackgroundColor + tableView.separatorInset = .zero + tableView.separatorStyle = .none + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 54 + tableView.contentInset = UIEdgeInsets( + // add extra inset at the top/bottom to show the cell-selected-state separators + top: 1.0 / UIScreen.main.nativeScale, + left: 0, + bottom: 1.0 / UIScreen.main.nativeScale, + right: 0 + ) + tableView.keyboardDismissMode = .onDrag + tableView.register(InstitutionSearchTableViewCell.self, forCellReuseIdentifier: cellIdentifier) + tableView.delegate = self + addAndPinSubview(tableView) + + addAndPinSubview(loadingContainerView) + loadingContainerView.addSubview(loadingView) + loadingView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + // pin loading view to the top so it doesn't get blocked by keyboard + loadingView.topAnchor.constraint(equalTo: loadingContainerView.topAnchor), + loadingView.leadingAnchor.constraint(equalTo: loadingContainerView.leadingAnchor), + loadingView.trailingAnchor.constraint(equalTo: loadingContainerView.trailingAnchor), + ]) + + showTableFooterView(false) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + // `UITableView` does not automatically resize `tableHeaderView` + // so here we do it manually + if let tableHeaderView = tableView.tableHeaderView { + let tableHeaderViewSize = tableHeaderView.systemLayoutSizeFitting( + CGSize( + width: tableView.bounds.size.width, + height: UIView.layoutFittingCompressedSize.height + ) + ) + if tableHeaderView.frame.size.height != tableHeaderViewSize.height { + tableHeaderView.frame.size.height = tableHeaderViewSize.height + tableView.tableHeaderView = tableHeaderView + } + } + + // `UITableView` does not automatically resize `tableFooterView` + // so here we do it manually + if let tableFooterView = tableView.tableFooterView { + let tableFooterViewSize = tableFooterView.systemLayoutSizeFitting( + CGSize( + width: tableView.bounds.size.width, + height: UIView.layoutFittingCompressedSize.height + ) + ) + if tableFooterView.frame.size.height != tableFooterViewSize.height { + tableFooterView.frame.size.height = tableFooterViewSize.height + tableView.tableFooterView = tableFooterView + } + } + } + + func loadInstitutions( + _ institutions: [FinancialConnectionsInstitution], + showManualEntry: Bool? = nil + ) { + assertMainQueue() + self.institutions = institutions + shouldLogScroll = true + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([Section.main]) + snapshot.appendItems(institutions, toSection: Section.main) + dataSource.apply(snapshot, animatingDifferences: true, completion: nil) + + // clear state (some of this is defensive programming) + showError(false) + + if allowManualEntry { + showTableFooterView( + institutions.isEmpty || (showManualEntry == true), + showTopSeparator: !institutions.isEmpty + ) + } else { + showTableFooterView(institutions.isEmpty, showTopSeparator: false) + } + } + + func showLoadingView(_ show: Bool) { + loadingContainerView.isHidden = !show + if show { + // do not call `startAnimating` if already animating because + // it will cause an animation glitch otherwise + if !loadingView.isAnimating { + loadingView.startAnimating() + } + } else { + loadingView.stopAnimating() + } + bringSubviewToFront(loadingContainerView) // defensive programming to avoid loadingView being hiddden + } + + func showError(_ show: Bool) { + showTableFooterView(show, showTopSeparator: false) + } + + // the footer is always shown, except for when there is an error searching + private func showTableFooterView(_ show: Bool, showTopSeparator: Bool = true) { + tableFooterView.showTopSeparator = showTopSeparator + if show { + tableView.setTableFooterViewWithCompressedFrameSize(tableFooterView) + } else { + tableView.tableFooterView = nil + } + } +} + +// MARK: - UITableViewDelegate + +extension InstitutionSearchTableView: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if let institution = dataSource.itemIdentifier(for: indexPath) { + delegate?.institutionSearchTableView(self, didSelectInstitution: institution) + } + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + // Every time the institutions change, we are open to sending the event again + if shouldLogScroll { + shouldLogScroll = false + + delegate?.institutionSearchTableView( + self, + didScrollInstitutions: institutions + ) + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/InstitutionSearchTableViewCell.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/InstitutionSearchTableViewCell.swift new file mode 100644 index 00000000..664a4c16 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/InstitutionPicker/InstitutionSearchTableViewCell.swift @@ -0,0 +1,109 @@ +// +// InstitutionSearchTableViewCell.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 7/21/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +final class InstitutionSearchTableViewCell: UITableViewCell { + + private lazy var institutionIconView: InstitutionIconView = { + return InstitutionIconView(size: .medium) + }() + private lazy var titleLabel: AttributedLabel = { + let titleLabel = AttributedLabel( + font: .label(.largeEmphasized), + textColor: .textPrimary + ) + return titleLabel + }() + private lazy var subtitleLabel: AttributedLabel = { + let subtitleLabel = AttributedLabel( + font: .label(.small), + textColor: .textSecondary + ) + return subtitleLabel + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.backgroundColor = .customBackgroundColor + + let labelStackView = UIStackView( + arrangedSubviews: [ + titleLabel, + subtitleLabel, + ] + ) + labelStackView.axis = .vertical + labelStackView.spacing = 0 + + let cellStackView = UIStackView( + arrangedSubviews: [ + institutionIconView, + labelStackView, + ] + ) + cellStackView.axis = .horizontal + cellStackView.spacing = 12 + cellStackView.alignment = .center + cellStackView.isLayoutMarginsRelativeArrangement = true + cellStackView.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: 10, + leading: 24, + bottom: 10, + trailing: 24 + ) + contentView.addAndPinSubview(cellStackView) + + self.selectedBackgroundView = CreateSelectedBackgroundView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private func CreateSelectedBackgroundView() -> UIView { + let selectedBackgroundView = UIView() + selectedBackgroundView.backgroundColor = .backgroundContainer + + let topSeparatorView = UIView() + topSeparatorView.backgroundColor = .borderNeutral + let bottomSeparatorView = UIView() + bottomSeparatorView.backgroundColor = .borderNeutral + selectedBackgroundView.addSubview(topSeparatorView) + selectedBackgroundView.addSubview(bottomSeparatorView) + + topSeparatorView.translatesAutoresizingMaskIntoConstraints = false + bottomSeparatorView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + topSeparatorView.topAnchor.constraint(equalTo: selectedBackgroundView.topAnchor), + topSeparatorView.leadingAnchor.constraint(equalTo: selectedBackgroundView.leadingAnchor), + topSeparatorView.trailingAnchor.constraint(equalTo: selectedBackgroundView.trailingAnchor), + topSeparatorView.heightAnchor.constraint(equalToConstant: 1.0 / UIScreen.main.nativeScale), + + bottomSeparatorView.bottomAnchor.constraint(equalTo: selectedBackgroundView.bottomAnchor), + bottomSeparatorView.leadingAnchor.constraint(equalTo: selectedBackgroundView.leadingAnchor), + bottomSeparatorView.trailingAnchor.constraint(equalTo: selectedBackgroundView.trailingAnchor), + bottomSeparatorView.heightAnchor.constraint(equalToConstant: 1.0 / UIScreen.main.nativeScale), + ]) + + return selectedBackgroundView +} + +// MARK: - Customize + +extension InstitutionSearchTableViewCell { + + func customize(with institution: FinancialConnectionsInstitution) { + institutionIconView.setImageUrl(institution.icon?.default) + titleLabel.setText(institution.name) + subtitleLabel.setText(AuthFlowHelpers.formatUrlString(institution.url) ?? "") + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/LinkAccountPicker/LinkAccountPickerBodyView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/LinkAccountPicker/LinkAccountPickerBodyView.swift new file mode 100644 index 00000000..dab1d1ae --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/LinkAccountPicker/LinkAccountPickerBodyView.swift @@ -0,0 +1,217 @@ +// +// LinkAccountPickerBodyView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/13/23. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol LinkAccountPickerBodyViewDelegate: AnyObject { + func linkAccountPickerBodyView( + _ view: LinkAccountPickerBodyView, + didSelectAccount selectedAccountTuple: FinancialConnectionsAccountTuple + ) + func linkAccountPickerBodyViewSelectedNewBankAccount(_ view: LinkAccountPickerBodyView) +} + +final class LinkAccountPickerBodyView: UIView { + + private let accountTuples: [FinancialConnectionsAccountTuple] + private let addNewAccount: FinancialConnectionsNetworkingAccountPicker.AddNewAccount + weak var delegate: LinkAccountPickerBodyViewDelegate? + + private lazy var verticalStackView: UIStackView = { + let verticalStackView = UIStackView() + verticalStackView.axis = .vertical + verticalStackView.spacing = 12 + return verticalStackView + }() + + init( + accountTuples: [FinancialConnectionsAccountTuple], + addNewAccount: FinancialConnectionsNetworkingAccountPicker.AddNewAccount + ) { + self.accountTuples = accountTuples + self.addNewAccount = addNewAccount + super.init(frame: .zero) + addAndPinSubview(verticalStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func selectAccount(_ selectedAccountTuple: FinancialConnectionsAccountTuple?) { + // clear all previous state + verticalStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + + accountTuples.forEach { accountTuple in + let accountRowView = LinkAccountPickerRowView( + isDisabled: !accountTuple.accountPickerAccount.allowSelection, + didSelect: { [weak self] in + guard let self = self else { return } + self.delegate?.linkAccountPickerBodyView( + self, + didSelectAccount: accountTuple + ) + } + ) + let rowTitles = AccountPickerHelpers.rowTitles( + forAccount: accountTuple.partnerAccount, + captionWillHideAccountNumbers: accountTuple.accountPickerAccount.caption != nil + ) + accountRowView.configure( + institutionImageUrl: accountTuple.partnerAccount.institution?.icon?.default, + leadingTitle: rowTitles.leadingTitle, + trailingTitle: rowTitles.trailingTitle, + subtitle: { + if let caption = accountTuple.accountPickerAccount.caption { + return caption + } else { + return AccountPickerHelpers.rowSubtitle( + forAccount: accountTuple.partnerAccount + ) + } + }(), + trailingIconImageUrl: accountTuple.accountPickerAccount.icon?.default, + isSelected: selectedAccountTuple?.partnerAccount.id == accountTuple.partnerAccount.id + ) + verticalStackView.addArrangedSubview(accountRowView) + } + + // add a 'new bank account' button row + let newAccountRowView = LinkAccountPickerNewAccountRowView( + title: addNewAccount.body, + imageUrl: addNewAccount.icon?.default, + didSelect: { [weak self] in + guard let self = self else { return } + self.delegate?.linkAccountPickerBodyViewSelectedNewBankAccount(self) + } + ) + verticalStackView.addArrangedSubview(newAccountRowView) + } +} + +#if DEBUG + +import SwiftUI + +private struct LinkAccountPickerBodyViewUIViewRepresentable: UIViewRepresentable { + + func makeUIView(context: Context) -> LinkAccountPickerBodyView { + LinkAccountPickerBodyView( + accountTuples: [ + ( + accountPickerAccount: FinancialConnectionsNetworkingAccountPicker.Account( + id: "123", + allowSelection: true, + caption: nil, + selectionCta: nil, + icon: nil, + selectionCtaIcon: nil + ), + partnerAccount: FinancialConnectionsPartnerAccount( + id: "abc", + name: "Advantage Plus Checking With Extra Words", + displayableAccountNumbers: "1324", + linkedAccountId: nil, + balanceAmount: 100000, + currency: "USD", + supportedPaymentMethodTypes: [.usBankAccount], + allowSelection: true, + allowSelectionMessage: nil, + status: "active", + institution: FinancialConnectionsInstitution( + id: "abc", + name: "N/A", + url: nil, + icon: FinancialConnectionsImage( + default: "https://b.stripecdn.com/connections-statics-srv/assets/BrandIcon--stripe-4x.png" + ), + logo: nil + ), + nextPaneOnSelection: .success + ) + ), + ( + accountPickerAccount: FinancialConnectionsNetworkingAccountPicker.Account( + id: "123", + allowSelection: true, + caption: "Repair and connect account", + selectionCta: nil, + icon: FinancialConnectionsImage( + default: "https://b.stripecdn.com/connections-statics-srv/assets/SailIcon--warning-orange-3x.png" + ), + selectionCtaIcon: nil + ), + partnerAccount: FinancialConnectionsPartnerAccount( + id: "abc", + name: "Advantage Plus Checking", + displayableAccountNumbers: "1324", + linkedAccountId: nil, + balanceAmount: 100000, + currency: "USD", + supportedPaymentMethodTypes: [.usBankAccount], + allowSelection: true, + allowSelectionMessage: nil, + status: "disabled", + institution: nil, + nextPaneOnSelection: .success + ) + ), + ( + accountPickerAccount: FinancialConnectionsNetworkingAccountPicker.Account( + id: "123", + allowSelection: false, + caption: nil, + selectionCta: nil, + icon: nil, + selectionCtaIcon: nil + ), + partnerAccount: FinancialConnectionsPartnerAccount( + id: "abc", + name: "Advantage Plus Checking", + displayableAccountNumbers: "1324", + linkedAccountId: nil, + balanceAmount: 100000, + currency: "USD", + supportedPaymentMethodTypes: [.usBankAccount], + allowSelection: true, + allowSelectionMessage: nil, + status: "disabled", + institution: nil, + nextPaneOnSelection: .success + ) + ), + ], + addNewAccount: FinancialConnectionsNetworkingAccountPicker.AddNewAccount( + body: "New bank account", + icon: FinancialConnectionsImage( + default: "https://b.stripecdn.com/connections-statics-srv/assets/SailIcon--add-purple-3x.png" + ) + ) + ) + } + + func updateUIView(_ uiView: LinkAccountPickerBodyView, context: Context) { + uiView.selectAccount(nil) + } +} + +struct LinkAccountPickerBodyView_Previews: PreviewProvider { + static var previews: some View { + VStack(alignment: .leading) { + Spacer() + LinkAccountPickerBodyViewUIViewRepresentable() + .frame(maxHeight: 300) + .padding() + Spacer() + } + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/LinkAccountPicker/LinkAccountPickerDataSource.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/LinkAccountPicker/LinkAccountPickerDataSource.swift new file mode 100644 index 00000000..dd487f8f --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/LinkAccountPicker/LinkAccountPickerDataSource.swift @@ -0,0 +1,83 @@ +// +// LinkLinkAccountPickerDataSource.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/13/23. +// + +import Foundation +@_spi(STP) import StripeCore + +protocol LinkAccountPickerDataSourceDelegate: AnyObject { + func linkAccountPickerDataSource( + _ dataSource: LinkAccountPickerDataSource, + didSelectAccount selectedAccountTuple: FinancialConnectionsAccountTuple? + ) +} + +protocol LinkAccountPickerDataSource: AnyObject { + + var delegate: LinkAccountPickerDataSourceDelegate? { get set } + var manifest: FinancialConnectionsSessionManifest { get } + var selectedAccountTuple: FinancialConnectionsAccountTuple? { get } + var nextPaneOnAddAccount: FinancialConnectionsSessionManifest.NextPane? { get set } + var analyticsClient: FinancialConnectionsAnalyticsClient { get } + + func fetchNetworkedAccounts() -> Future + func selectNetworkedAccount(_ selectedAccount: FinancialConnectionsPartnerAccount) -> Future + func updateSelectedAccount(_ selectedAccountTuple: FinancialConnectionsAccountTuple) +} + +final class LinkAccountPickerDataSourceImplementation: LinkAccountPickerDataSource { + + let manifest: FinancialConnectionsSessionManifest + var nextPaneOnAddAccount: FinancialConnectionsSessionManifest.NextPane? + let analyticsClient: FinancialConnectionsAnalyticsClient + private let apiClient: FinancialConnectionsAPIClient + private let clientSecret: String + private let consumerSession: ConsumerSessionData + + private(set) var selectedAccountTuple: FinancialConnectionsAccountTuple? { + didSet { + delegate?.linkAccountPickerDataSource(self, didSelectAccount: selectedAccountTuple) + } + } + weak var delegate: LinkAccountPickerDataSourceDelegate? + + init( + manifest: FinancialConnectionsSessionManifest, + apiClient: FinancialConnectionsAPIClient, + analyticsClient: FinancialConnectionsAnalyticsClient, + clientSecret: String, + consumerSession: ConsumerSessionData + ) { + self.manifest = manifest + self.apiClient = apiClient + self.analyticsClient = analyticsClient + self.clientSecret = clientSecret + self.consumerSession = consumerSession + } + + func fetchNetworkedAccounts() -> Future { + return apiClient.fetchNetworkedAccounts( + clientSecret: clientSecret, + consumerSessionClientSecret: consumerSession.clientSecret + ) + .chained { [weak self] response in + self?.nextPaneOnAddAccount = response.nextPaneOnAddAccount + return Promise(value: response) + } + } + + func updateSelectedAccount(_ selectedAccountTuple: FinancialConnectionsAccountTuple) { + self.selectedAccountTuple = selectedAccountTuple + } + + func selectNetworkedAccount(_ selectedAccount: FinancialConnectionsPartnerAccount) -> Future { + return apiClient.selectNetworkedAccounts( + selectedAccountIds: [selectedAccount.id], + clientSecret: clientSecret, + consumerSessionClientSecret: consumerSession.clientSecret + ) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/LinkAccountPicker/LinkAccountPickerFooterView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/LinkAccountPicker/LinkAccountPickerFooterView.swift new file mode 100644 index 00000000..54c26c30 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/LinkAccountPicker/LinkAccountPickerFooterView.swift @@ -0,0 +1,81 @@ +// +// LinkAccountPickerFooterView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/13/23. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +final class LinkAccountPickerFooterView: UIView { + + private let defaultCta: String + private let singleAccount: Bool + private let didSelectConnectAccount: () -> Void + + private lazy var connectAccountButton: Button = { + let connectAccountButton = Button(configuration: .financialConnectionsPrimary) + connectAccountButton.title = defaultCta + connectAccountButton.isEnabled = false // disable by default + connectAccountButton.addTarget(self, action: #selector(didSelectLinkAccountsButton), for: .touchUpInside) + connectAccountButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + connectAccountButton.heightAnchor.constraint(equalToConstant: 56) + ]) + return connectAccountButton + }() + + init( + defaultCta: String, + isStripeDirect: Bool, + businessName: String?, + permissions: [StripeAPI.FinancialConnectionsAccount.Permissions], + singleAccount: Bool, + didSelectConnectAccount: @escaping () -> Void, + didSelectMerchantDataAccessLearnMore: @escaping () -> Void + ) { + self.defaultCta = defaultCta + self.singleAccount = singleAccount + self.didSelectConnectAccount = didSelectConnectAccount + super.init(frame: .zero) + + let verticalStackView = HitTestStackView( + arrangedSubviews: [ + MerchantDataAccessView( + isStripeDirect: isStripeDirect, + businessName: businessName, + permissions: permissions, + isNetworking: true, + font: .body(.small), + boldFont: .body(.smallEmphasized), + alignCenter: true, + didSelectLearnMore: didSelectMerchantDataAccessLearnMore + ), + connectAccountButton, + ] + ) + verticalStackView.axis = .vertical + verticalStackView.spacing = 24 + addAndPinSubview(verticalStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func didSelectLinkAccountsButton() { + didSelectConnectAccount() + } + + func didSelectedAccount(_ selectedAccountTuple: FinancialConnectionsAccountTuple?) { + if let selectionCta = selectedAccountTuple?.accountPickerAccount.selectionCta { + connectAccountButton.title = selectionCta + } else { + connectAccountButton.title = defaultCta + } + + connectAccountButton.isEnabled = selectedAccountTuple != nil + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/LinkAccountPicker/LinkAccountPickerNewAccountRowView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/LinkAccountPicker/LinkAccountPickerNewAccountRowView.swift new file mode 100644 index 00000000..76bdc12c --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/LinkAccountPicker/LinkAccountPickerNewAccountRowView.swift @@ -0,0 +1,144 @@ +// +// LinkAccountPickerNewAccountRowView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/14/23. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +final class LinkAccountPickerNewAccountRowView: UIView { + + private let didSelect: () -> Void + + init( + title: String, + imageUrl: String?, + didSelect: @escaping () -> Void + ) { + self.didSelect = didSelect + super.init(frame: .zero) + + let horizontalStackView = CreateHorizontalStackView() + if let imageUrl = imageUrl { + horizontalStackView.addArrangedSubview( + CreateIconView(imageUrl: imageUrl) + ) + } + horizontalStackView.addArrangedSubview( + CreateTitleLabelView( + title: title + ) + ) + addAndPinSubviewToSafeArea(horizontalStackView) + + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didTapView)) + addGestureRecognizer(tapGestureRecognizer) + + layer.cornerRadius = 8 + layer.borderColor = UIColor.borderNeutral.cgColor + layer.borderWidth = 1 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func didTapView() { + self.didSelect() + } +} + +private func CreateIconView(imageUrl: String) -> UIView { + let diameter: CGFloat = 24 + let iconImageView = UIImageView() + iconImageView.contentMode = .scaleAspectFit + iconImageView.setImage(with: imageUrl) + let paddedView = UIStackView(arrangedSubviews: [iconImageView]) + paddedView.backgroundColor = .textBrand.withAlphaComponent(0.1) + paddedView.layer.cornerRadius = 6 + paddedView.isLayoutMarginsRelativeArrangement = true + paddedView.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: 6, + leading: 6, + bottom: 6, + trailing: 6 + ) + NSLayoutConstraint.activate([ + paddedView.widthAnchor.constraint(equalToConstant: diameter), + paddedView.heightAnchor.constraint(equalToConstant: diameter), + ]) + return paddedView +} + +private func CreateTitleLabelView(title: String) -> UIView { + let titleLabel = AttributedLabel( + font: .label(.largeEmphasized), + textColor: .textBrand + ) + titleLabel.text = title + titleLabel.lineBreakMode = .byCharWrapping + return titleLabel +} + +private func CreateHorizontalStackView() -> UIStackView { + let horizontalStackView = UIStackView() + horizontalStackView.axis = .horizontal + horizontalStackView.spacing = 12 + horizontalStackView.alignment = .center + horizontalStackView.isLayoutMarginsRelativeArrangement = true + horizontalStackView.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: 12, + leading: 12, + bottom: 12, + trailing: 12 + ) + return horizontalStackView +} + +#if DEBUG + +import SwiftUI + +private struct LinkAccountPickerNewAccountRowViewUIViewRepresentable: UIViewRepresentable { + + let title: String + let imageUrl: String? + + func makeUIView(context: Context) -> LinkAccountPickerNewAccountRowView { + return LinkAccountPickerNewAccountRowView( + title: title, + imageUrl: imageUrl, + didSelect: {} + ) + } + + func updateUIView(_ uiView: LinkAccountPickerNewAccountRowView, context: Context) {} +} + +struct LinkAccountPickerNewAccountRowView_Previews: PreviewProvider { + static var previews: some View { + if #available(iOS 14.0, *) { + ScrollView { + VStack(spacing: 10) { + LinkAccountPickerNewAccountRowViewUIViewRepresentable( + title: "New bank account", + imageUrl: "https://b.stripecdn.com/connections-statics-srv/assets/SailIcon--add-purple-3x.png" + ) + .frame(height: 48) + + LinkAccountPickerNewAccountRowViewUIViewRepresentable( + title: "New bank account", + imageUrl: nil + ) + .frame(height: 48) + } + .padding() + } + } + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/LinkAccountPicker/LinkAccountPickerRowView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/LinkAccountPicker/LinkAccountPickerRowView.swift new file mode 100644 index 00000000..001e13d6 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/LinkAccountPicker/LinkAccountPickerRowView.swift @@ -0,0 +1,218 @@ +// +// LinkAccountPickerRowView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/13/23. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +final class LinkAccountPickerRowView: UIView { + + private let didSelect: () -> Void + private var isSelected: Bool = false { + didSet { + layer.cornerRadius = 8 + if isSelected { + layer.borderColor = UIColor.textBrand.cgColor + layer.borderWidth = 2 + } else { + layer.borderColor = UIColor.borderNeutral.cgColor + layer.borderWidth = 1 + } + } + } + + private lazy var horizontalStackView: UIStackView = { + return CreateHorizontalStackView( + arrangedSubviews: [ + institutionIconView, + labelRowView, + ] + ) + }() + private lazy var institutionIconView: InstitutionIconView = { + let institutionIconView = InstitutionIconView(size: .small) + return institutionIconView + }() + private lazy var labelRowView: AccountPickerLabelRowView = { + return AccountPickerLabelRowView() + }() + private var trailingIconImageView: UIImageView? + + init( + isDisabled: Bool, + didSelect: @escaping () -> Void + ) { + self.didSelect = didSelect + super.init(frame: .zero) + + if isDisabled { + horizontalStackView.alpha = 0.25 + } + addAndPinSubviewToSafeArea(horizontalStackView) + + if !isDisabled { + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didTapView)) + addGestureRecognizer(tapGestureRecognizer) + } + + isSelected = false // activate the setter to draw border + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure( + institutionImageUrl: String?, + leadingTitle: String, + trailingTitle: String?, + subtitle: String?, + trailingIconImageUrl: String?, + isSelected: Bool + ) { + institutionIconView.setImageUrl(institutionImageUrl) + labelRowView.setLeadingTitle( + leadingTitle, + trailingTitle: trailingTitle, + subtitle: subtitle + ) + self.isSelected = isSelected + + trailingIconImageView?.removeFromSuperview() + trailingIconImageView = nil + if let trailingIconImageUrl = trailingIconImageUrl { + let trailingIconImageView = UIImageView() + trailingIconImageView.contentMode = .scaleAspectFit + NSLayoutConstraint.activate([ + trailingIconImageView.widthAnchor.constraint(equalToConstant: 16), + trailingIconImageView.heightAnchor.constraint(equalToConstant: 16), + ]) + trailingIconImageView.setImage(with: trailingIconImageUrl) + self.trailingIconImageView = trailingIconImageView + horizontalStackView.addArrangedSubview(trailingIconImageView) + } + } + + @objc private func didTapView() { + self.didSelect() + } +} + +private func CreateInsitutionImageView(imageUrl: String) -> InstitutionIconView { + let institutionIconView = InstitutionIconView(size: .small) + institutionIconView.setImageUrl(imageUrl) + return institutionIconView +} + +private func CreateHorizontalStackView(arrangedSubviews: [UIView]) -> UIStackView { + let horizontalStackView = UIStackView(arrangedSubviews: arrangedSubviews) + horizontalStackView.axis = .horizontal + horizontalStackView.spacing = 12 + horizontalStackView.alignment = .center + horizontalStackView.isLayoutMarginsRelativeArrangement = true + horizontalStackView.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: 12, + leading: 12, + bottom: 12, + trailing: 12 + ) + return horizontalStackView +} + +#if DEBUG + +import SwiftUI + +private struct LinkAccountPickerRowViewUIViewRepresentable: UIViewRepresentable { + + let institutionImageUrl: String? + let leadingTitle: String + let trailingTitle: String? + let subtitle: String? + let trailingIconImageUrl: String? + let isSelected: Bool + let isDisabled: Bool + + func makeUIView(context: Context) -> LinkAccountPickerRowView { + let view = LinkAccountPickerRowView( + isDisabled: isDisabled, + didSelect: {} + ) + view.configure( + institutionImageUrl: institutionImageUrl, + leadingTitle: leadingTitle, + trailingTitle: trailingTitle, + subtitle: subtitle, + trailingIconImageUrl: trailingIconImageUrl, + isSelected: isSelected + ) + return view + } + + func updateUIView(_ uiView: LinkAccountPickerRowView, context: Context) { + uiView.configure( + institutionImageUrl: institutionImageUrl, + leadingTitle: leadingTitle, + trailingTitle: trailingTitle, + subtitle: subtitle, + trailingIconImageUrl: trailingIconImageUrl, + isSelected: isSelected + ) + } +} + +struct LinkAccountPickerRowView_Previews: PreviewProvider { + static var previews: some View { + if #available(iOS 14.0, *) { + ScrollView { + VStack(spacing: 10) { + VStack(spacing: 2) { + Text("Active Accounts") + LinkAccountPickerRowViewUIViewRepresentable( + institutionImageUrl: nil, + leadingTitle: "Joint Checking Very Long Name To Truncate", + trailingTitle: "••••6789", + subtitle: "$2,000", + trailingIconImageUrl: nil, + isSelected: true, + isDisabled: false + ).frame(height: 60) + LinkAccountPickerRowViewUIViewRepresentable( + institutionImageUrl: nil, + leadingTitle: "Joint Checking", + trailingTitle: nil, + subtitle: nil, + trailingIconImageUrl: nil, + isSelected: false, + isDisabled: false + ).frame(height: 60) + LinkAccountPickerRowViewUIViewRepresentable( + institutionImageUrl: nil, + leadingTitle: "Joint Checking Very Long Name To Truncate", + trailingTitle: "••••6789", + subtitle: "Select to repair and connect", + trailingIconImageUrl: "https://b.stripecdn.com/connections-statics-srv/assets/SailIcon--warning-orange-3x.png", + isSelected: false, + isDisabled: false + ).frame(height: 60) + LinkAccountPickerRowViewUIViewRepresentable( + institutionImageUrl: nil, + leadingTitle: "Joint Checking", + trailingTitle: nil, + subtitle: "Must be US checking account", + trailingIconImageUrl: nil, + isSelected: false, + isDisabled: true + ).frame(height: 60) + } + }.padding() + } + } + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/LinkAccountPicker/LinkAccountPickerViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/LinkAccountPicker/LinkAccountPickerViewController.swift new file mode 100644 index 00000000..7658b6df --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/LinkAccountPicker/LinkAccountPickerViewController.swift @@ -0,0 +1,332 @@ +// +// LinkLinkAccountPickerViewController.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/13/23. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol LinkAccountPickerViewControllerDelegate: AnyObject { + func linkAccountPickerViewController( + _ viewController: LinkAccountPickerViewController, + didRequestNextPane nextPane: FinancialConnectionsSessionManifest.NextPane + ) + + func linkAccountPickerViewController( + _ viewController: LinkAccountPickerViewController, + didSelectAccount selectedAccount: FinancialConnectionsPartnerAccount + ) + + func linkAccountPickerViewController( + _ viewController: LinkAccountPickerViewController, + didRequestSuccessPaneWithInstitution institution: FinancialConnectionsInstitution + ) + + func linkAccountPickerViewController( + _ viewController: LinkAccountPickerViewController, + requestedPartnerAuthWithInstitution institution: FinancialConnectionsInstitution + ) + + func linkAccountPickerViewController( + _ viewController: LinkAccountPickerViewController, + didReceiveTerminalError error: Error + ) +} + +final class LinkAccountPickerViewController: UIViewController { + + private let dataSource: LinkAccountPickerDataSource + weak var delegate: LinkAccountPickerViewControllerDelegate? + private var businessName: String? { + return dataSource.manifest.businessName + } + private weak var bodyView: LinkAccountPickerBodyView? + private weak var footerView: LinkAccountPickerFooterView? + + init(dataSource: LinkAccountPickerDataSource) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + dataSource.delegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + // link account picker ALWAYS hides the back button + navigationItem.hidesBackButton = true + view.backgroundColor = .customBackgroundColor + + fetchNetworkedAccounts() + } + + private func fetchNetworkedAccounts() { + let retreivingAccountsLoadingView = buildRetrievingAccountsView() + view.addAndPinSubviewToSafeArea(retreivingAccountsLoadingView) + dataSource + .fetchNetworkedAccounts() + .observe { [weak self] result in + guard let self = self else { return } + retreivingAccountsLoadingView.removeFromSuperview() + switch result { + case .success(let networkedAccountsResponse): + if let returningNetworkingUserAccountPicker = networkedAccountsResponse.display?.text?.returningNetworkingUserAccountPicker { + self.display( + partnerAccounts: networkedAccountsResponse.data, + networkingAccountPicker: returningNetworkingUserAccountPicker + ) + } else { + self.delegate?.linkAccountPickerViewController( + self, + didReceiveTerminalError: FinancialConnectionsSheetError.unknown( + debugDescription: "Tried fetching networked accounts but received no display parameter." + ) + ) + } + case .failure(let error): + self.dataSource + .analyticsClient + .logUnexpectedError( + error, + errorName: "FetchNetworkedAccountsError", + pane: .linkAccountPicker + ) + self.delegate?.linkAccountPickerViewController(self, didRequestNextPane: .institutionPicker) + } + } + } + + private func display( + partnerAccounts: [FinancialConnectionsPartnerAccount], + networkingAccountPicker: FinancialConnectionsNetworkingAccountPicker + ) { + let accountTuples: [FinancialConnectionsAccountTuple] = ZipAccounts( + partnerAccounts: partnerAccounts, + accountPickerAccounts: networkingAccountPicker.accounts + ) + let bodyView = LinkAccountPickerBodyView( + accountTuples: accountTuples, + addNewAccount: networkingAccountPicker.addNewAccount + ) + bodyView.delegate = self + self.bodyView = bodyView + + let footerView = LinkAccountPickerFooterView( + defaultCta: networkingAccountPicker.defaultCta, + isStripeDirect: false, + businessName: businessName, + permissions: dataSource.manifest.permissions, + singleAccount: dataSource.manifest.singleAccount, + didSelectConnectAccount: { [weak self] in + guard let self = self else { + return + } + self.didSelectConectAccount() + }, + didSelectMerchantDataAccessLearnMore: { [weak self] in + guard let self = self else { return } + self.dataSource + .analyticsClient + .logMerchantDataAccessLearnMore(pane: .linkAccountPicker) + } + ) + self.footerView = footerView + + let paneLayoutView = PaneWithHeaderLayoutView( + title: networkingAccountPicker.title, + contentView: bodyView, + footerView: footerView + ) + paneLayoutView.addTo(view: view) + + bodyView.selectAccount(nil) // activate the logic to list all accounts + } + + private func didSelectConectAccount() { + guard let selectedAccountTuple = dataSource.selectedAccountTuple else { + assertionFailure("user shouldn't be able to press the connect account button without an account") + dataSource + .analyticsClient + .logUnexpectedError( + FinancialConnectionsSheetError + .unknown( + debugDescription: "Selected to connect an account, but no account is selected." + ), + errorName: "ConnectUnselectedAccountError", + pane: .linkAccountPicker + ) + delegate?.linkAccountPickerViewController(self, didRequestNextPane: .institutionPicker) + return + } + + let nextPane = selectedAccountTuple + .partnerAccount + .nextPaneOnSelection + + // update data model with selected account + delegate?.linkAccountPickerViewController( + self, + didSelectAccount: selectedAccountTuple.partnerAccount + ) + + if nextPane == .success { + let linkingAccountsLoadingView = LinkingAccountsLoadingView( + numberOfSelectedAccounts: 1, + businessName: businessName + ) + view.addAndPinSubviewToSafeArea(linkingAccountsLoadingView) + + dataSource + .selectNetworkedAccount(selectedAccountTuple.partnerAccount) + .observe { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let institutionList): + self.dataSource + .analyticsClient + .log( + eventName: "click.link_accounts", + pane: .linkAccountPicker + ) + if let institution = institutionList.data.first { + self.delegate?.linkAccountPickerViewController( + self, + didRequestSuccessPaneWithInstitution: institution + ) + } else { + // this should never happen, but in case it does we want to force a + // a terminal error so user can start again with a fresh state + let error = FinancialConnectionsSheetError.unknown( + debugDescription: "Successfully selected an networked account but no institution was returned." + ) + self.dataSource + .analyticsClient + .logUnexpectedError( + error, + errorName: "SelectNetworkedAccountNoInstitutionError", + pane: .linkAccountPicker + ) + self.delegate?.linkAccountPickerViewController( + self, + didReceiveTerminalError: error + ) + } + case .failure(let error): + self.dataSource + .analyticsClient + .logUnexpectedError( + error, + errorName: "SelectNetworkedAccountError", + pane: .linkAccountPicker + ) + self.delegate?.linkAccountPickerViewController(self, didReceiveTerminalError: error) + } + } + } else if nextPane == .partnerAuth { + if let institution = selectedAccountTuple.partnerAccount.institution { + delegate?.linkAccountPickerViewController( + self, + requestedPartnerAuthWithInstitution: institution + ) + } else { + delegate?.linkAccountPickerViewController( + self, + didReceiveTerminalError: FinancialConnectionsSheetError.unknown( + debugDescription: "LinkAccountPicker wanted to go to partner_auth but there is no institution." + ) + ) + } + } else if let nextPane = nextPane { + if nextPane == .bankAuthRepair { + dataSource + .analyticsClient + .log( + eventName: "click.repair_accounts", + pane: .linkAccountPicker + ) + } + delegate?.linkAccountPickerViewController(self, didRequestNextPane: nextPane) + } else { + delegate?.linkAccountPickerViewController( + self, + didReceiveTerminalError: FinancialConnectionsSheetError.unknown( + debugDescription: "LinkAccountPicker pressed account but no nextPane returned." + ) + ) + } + } +} + +// MARK: - LinkAccountPickerBodyViewDelegate + +extension LinkAccountPickerViewController: LinkAccountPickerBodyViewDelegate { + func linkAccountPickerBodyView( + _ view: LinkAccountPickerBodyView, + didSelectAccount selectedAccountTuple: FinancialConnectionsAccountTuple + ) { + dataSource + .analyticsClient + .log( + eventName: "click.account_picker.account_selected", + parameters: [ + "account": selectedAccountTuple.partnerAccount.id, + "is_single_account": true, + ], + pane: .linkAccountPicker + ) + dataSource.updateSelectedAccount(selectedAccountTuple) + } + + func linkAccountPickerBodyViewSelectedNewBankAccount(_ view: LinkAccountPickerBodyView) { + dataSource + .analyticsClient + .log( + eventName: "click.new_account", + pane: .linkAccountPicker + ) + delegate?.linkAccountPickerViewController( + self, + didRequestNextPane: dataSource.nextPaneOnAddAccount ?? .institutionPicker + ) + } +} + +// MARK: - LinkAccountPickerDataSourceDelegate + +extension LinkAccountPickerViewController: LinkAccountPickerDataSourceDelegate { + + func linkAccountPickerDataSource( + _ dataSource: LinkAccountPickerDataSource, + didSelectAccount selectedAccountTuple: FinancialConnectionsAccountTuple? + ) { + bodyView?.selectAccount(selectedAccountTuple) + footerView?.didSelectedAccount(selectedAccountTuple) + } +} + +/// Combines two different `account` types into one type +typealias FinancialConnectionsAccountTuple = ( + accountPickerAccount: FinancialConnectionsNetworkingAccountPicker.Account, + partnerAccount: FinancialConnectionsPartnerAccount +) +private func ZipAccounts( + partnerAccounts: [FinancialConnectionsPartnerAccount], + accountPickerAccounts: [FinancialConnectionsNetworkingAccountPicker.Account] +) -> [FinancialConnectionsAccountTuple] { + var accountTuples: [FinancialConnectionsAccountTuple] = [] + let idToPartnerAccount = Dictionary(uniqueKeysWithValues: partnerAccounts.map({ ($0.id, $0) })) + // use `accountPickerAccounts` to determine the order as its + // used for defining how we display the account + for accountPickerAccount in accountPickerAccounts { + if let partnerAccount = idToPartnerAccount[accountPickerAccount.id] { + accountTuples.append((accountPickerAccount, partnerAccount)) + } + } + return accountTuples +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryCheckView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryCheckView.swift new file mode 100644 index 00000000..e3b5bf68 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryCheckView.swift @@ -0,0 +1,54 @@ +// +// ManualEntryCheckView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/24/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +final class ManualEntryCheckView: UIView { + + static let height: CGFloat = 96.0 + + enum HighlightState: Int { + case none = 0 + case routingNumber = 1 + case accountNumber = 2 + } + + var highlightState: HighlightState = .none { + didSet { + setNeedsLayout() + layoutIfNeeded() + } + } + private lazy var imageView: UIImageView = { + let imageView = UIImageView() + imageView.image = Image.bank_check.makeImage() + return imageView + }() + + init() { + super.init(frame: .zero) + addSubview(imageView) + clipsToBounds = true // we shift `imageView` in the `bounds` of this view, so clip it to bounds + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + imageView.sizeToFit() + imageView.frame = CGRect( + x: (bounds.width - imageView.bounds.width) / 2, + y: -1 * CGFloat(highlightState.rawValue) * Self.height, + width: imageView.bounds.width, + height: imageView.bounds.height + ) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryDataSource.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryDataSource.swift new file mode 100644 index 00000000..17f4feaa --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryDataSource.swift @@ -0,0 +1,50 @@ +// +// ManualEntryDataSource.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/24/22. +// + +import Foundation +@_spi(STP) import StripeCore + +protocol ManualEntryDataSource: AnyObject { + + var manifest: FinancialConnectionsSessionManifest { get } + var analyticsClient: FinancialConnectionsAnalyticsClient { get } + + func attachBankAccountToLinkAccountSession(routingNumber: String, accountNumber: String) -> Future< + FinancialConnectionsPaymentAccountResource + > +} + +final class ManualEntryDataSourceImplementation: ManualEntryDataSource { + + private let apiClient: FinancialConnectionsAPIClient + private let clientSecret: String + let manifest: FinancialConnectionsSessionManifest + let analyticsClient: FinancialConnectionsAnalyticsClient + + init( + apiClient: FinancialConnectionsAPIClient, + clientSecret: String, + manifest: FinancialConnectionsSessionManifest, + analyticsClient: FinancialConnectionsAnalyticsClient + ) { + self.apiClient = apiClient + self.clientSecret = clientSecret + self.manifest = manifest + self.analyticsClient = analyticsClient + } + + func attachBankAccountToLinkAccountSession( + routingNumber: String, + accountNumber: String + ) -> Future { + return apiClient.attachBankAccountToLinkAccountSession( + clientSecret: clientSecret, + accountNumber: accountNumber, + routingNumber: routingNumber + ) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryErrorView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryErrorView.swift new file mode 100644 index 00000000..3462c24d --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryErrorView.swift @@ -0,0 +1,61 @@ +// +// ManualEntryErrorView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/31/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +final class ManualEntryErrorView: UIView { + + init(text: String) { + super.init(frame: .zero) + let errorLabelFont = FinancialConnectionsFont.label(.large) + let warningIconWidthAndHeight: CGFloat = 14 + let warningIconInsets = errorLabelFont.topPadding + let warningIconImageView = UIImageView() + warningIconImageView.image = Image.warning_triangle.makeImage() + .withTintColor(.textCritical) + // Align the icon to the center of the first line. + // + // UIStackView does not do a great job of doing this + // automatically. + .withAlignmentRectInsets( + UIEdgeInsets(top: -warningIconInsets, left: 0, bottom: warningIconInsets, right: 0) + ) + warningIconImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + warningIconImageView.widthAnchor.constraint(equalToConstant: warningIconWidthAndHeight), + warningIconImageView.heightAnchor.constraint(equalToConstant: warningIconWidthAndHeight), + ]) + + let errorLabel = AttributedTextView( + font: errorLabelFont, + boldFont: .label(.largeEmphasized), + linkFont: .label(.largeEmphasized), + textColor: .textCritical, + linkColor: .textCritical + ) + errorLabel.setText(text) + errorLabel.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + + let horizontalStackView = UIStackView( + arrangedSubviews: [ + warningIconImageView, + errorLabel, + ] + ) + horizontalStackView.axis = .horizontal + horizontalStackView.spacing = 6 + // align icon + text to the top + horizontalStackView.alignment = .top + addAndPinSubview(horizontalStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryFooterView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryFooterView.swift new file mode 100644 index 00000000..65acbfd7 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryFooterView.swift @@ -0,0 +1,52 @@ +// +// ManualEntryFooterView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/25/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +final class ManualEntryFooterView: UIView { + + private let didSelectContinue: () -> Void + + private(set) lazy var continueButton: Button = { + let continueButton = Button(configuration: .financialConnectionsPrimary) + continueButton.title = "Continue" // TODO: replace with String.Localized.continue when we localize + continueButton.addTarget(self, action: #selector(didSelectContinueButton), for: .touchUpInside) + continueButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + continueButton.heightAnchor.constraint(equalToConstant: 56) + ]) + continueButton.accessibilityIdentifier = "manual_entry_continue_button" + return continueButton + }() + + init(didSelectContinue: @escaping () -> Void) { + self.didSelectContinue = didSelectContinue + super.init(frame: .zero) + + let verticalStackView = UIStackView( + arrangedSubviews: [ + continueButton + ] + ) + verticalStackView.axis = .vertical + addAndPinSubview(verticalStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func didSelectContinueButton() { + didSelectContinue() + } + + func setIsLoading(_ isLoading: Bool) { + continueButton.isLoading = isLoading + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryFormView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryFormView.swift new file mode 100644 index 00000000..cca542bb --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryFormView.swift @@ -0,0 +1,227 @@ +// +// ManualEntryFormView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/24/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import SwiftUI +import UIKit + +protocol ManualEntryFormViewDelegate: AnyObject { + func manualEntryFormViewTextDidChange(_ view: ManualEntryFormView) +} + +final class ManualEntryFormView: UIView { + + weak var delegate: ManualEntryFormViewDelegate? + + private lazy var checkView: ManualEntryCheckView = { + let checkView = ManualEntryCheckView() + checkView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + checkView.heightAnchor.constraint(equalToConstant: ManualEntryCheckView.height) + ]) + return checkView + }() + private lazy var textFieldStackView: UIStackView = { + let spacerView = UIView() + spacerView.setContentHuggingPriority(.defaultLow, for: .vertical) + let textFieldVerticalStackView = UIStackView( + arrangedSubviews: [ + routingNumberTextField, + accountNumberTextField, + accountNumberConfirmationTextField, + spacerView, + ] + ) + textFieldVerticalStackView.axis = .vertical + textFieldVerticalStackView.spacing = 16 + return textFieldVerticalStackView + }() + private var errorView: ManualEntryErrorView? + private lazy var routingNumberTextField: ManualEntryTextField = { + let routingNumberTextField = ManualEntryTextField( + title: STPLocalizedString( + "Routing number", + "The title of a user-input-field that appears when a user is manually entering their bank account information. It instructs user to type the routing number." + ), + placeholder: "123456789" + ) + routingNumberTextField.delegate = self + routingNumberTextField.textField.addTarget( + self, + action: #selector(textFieldTextDidChange), + for: .editingChanged + ) + routingNumberTextField.textField.accessibilityIdentifier = "manual_entry_routing_number_text_field" + return routingNumberTextField + }() + private lazy var accountNumberTextField: ManualEntryTextField = { + let accountNumberTextField = ManualEntryTextField( + // STPLocalizedString_("Account number", "The title of a user-input-field that appears when a user is manually entering their bank account information. It instructs user to type the account number."), + title: "Account number", // TODO: replace with String.Localized.accountNumber + placeholder: "000123456789", + footerText: STPLocalizedString( + "Please enter a checking account.", + "A description under a user-input-field that appears when a user is manually entering their bank account information. It the user that the bank account number can be either checkings or savings." + ) + ) + accountNumberTextField.textField.addTarget( + self, + action: #selector(textFieldTextDidChange), + for: .editingChanged + ) + accountNumberTextField.delegate = self + accountNumberTextField.textField.accessibilityIdentifier = "manual_entry_account_number_text_field" + return accountNumberTextField + }() + private lazy var accountNumberConfirmationTextField: ManualEntryTextField = { + let accountNumberConfirmationTextField = ManualEntryTextField( + title: STPLocalizedString( + "Confirm account number", + "The title of a user-input-field that appears when a user is manually entering their bank account information. It instructs user to re-type the account number to confirm it." + ), + placeholder: "000123456789" + ) + accountNumberConfirmationTextField.textField.addTarget( + self, + action: #selector(textFieldTextDidChange), + for: .editingChanged + ) + accountNumberConfirmationTextField.delegate = self + accountNumberConfirmationTextField.textField.accessibilityIdentifier = "manual_entry_account_number_confirmation_text_field" + return accountNumberConfirmationTextField + }() + + private var didEndEditingOnceRoutingNumberTextField = false + private var didEndEditingOnceAccountNumberTextField = false + private var didEndEditingOnceAccountNumberConfirmationTextField = false + + var routingAndAccountNumber: (routingNumber: String, accountNumber: String)? { + guard + ManualEntryValidator.validateRoutingNumber(routingNumberTextField.text) == nil + && ManualEntryValidator.validateAccountNumber(accountNumberTextField.text) == nil + && ManualEntryValidator.validateAccountNumberConfirmation( + accountNumberConfirmationTextField.text, + accountNumber: accountNumberTextField.text + ) == nil + else { + return nil + } + return (routingNumberTextField.text, accountNumberTextField.text) + } + + init() { + super.init(frame: .zero) + let contentVerticalStackView = UIStackView( + arrangedSubviews: [ + checkView, + textFieldStackView, + ] + ) + contentVerticalStackView.axis = .vertical + contentVerticalStackView.spacing = 2 + addAndPinSubview(contentVerticalStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func textFieldTextDidChange() { + delegate?.manualEntryFormViewTextDidChange(self) + updateTextFieldErrorStates() + } + + private func updateCheckViewState() { + checkView.highlightState = .none + if routingNumberTextField.textField.isFirstResponder { + checkView.highlightState = .routingNumber + } else if accountNumberTextField.textField.isFirstResponder + || accountNumberConfirmationTextField.textField.isFirstResponder + { + checkView.highlightState = .accountNumber + } + } + + private func updateTextFieldErrorStates() { + // we only show errors if user has previously ended editing the field + + if didEndEditingOnceRoutingNumberTextField { + routingNumberTextField.errorText = ManualEntryValidator.validateRoutingNumber(routingNumberTextField.text) + } + + if didEndEditingOnceAccountNumberTextField { + accountNumberTextField.errorText = ManualEntryValidator.validateAccountNumber(accountNumberTextField.text) + } + + if didEndEditingOnceAccountNumberConfirmationTextField { + accountNumberConfirmationTextField.errorText = ManualEntryValidator.validateAccountNumberConfirmation( + accountNumberConfirmationTextField.text, + accountNumber: accountNumberTextField.text + ) + } + } + + func setError(text: String?) { + if let text = text { + let errorView = ManualEntryErrorView(text: text) + self.errorView = errorView + textFieldStackView.insertArrangedSubview(errorView, at: 0) + } else { + errorView?.removeFromSuperview() + errorView = nil + } + } +} + +// MARK: - ManualEntryTextFieldDelegate + +extension ManualEntryFormView: ManualEntryTextFieldDelegate { + + func manualEntryTextField( + _ manualEntryTextField: ManualEntryTextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + let currentText = manualEntryTextField.textField.text ?? "" + guard let currentTextChangeRange = Range(range, in: currentText) else { + return false + } + let updatedText = currentText.replacingCharacters(in: currentTextChangeRange, with: string) + + // don't allow the user to type more characters than possible + if manualEntryTextField === routingNumberTextField { + return updatedText.count <= ManualEntryValidator.routingNumberLength + } else if manualEntryTextField === accountNumberTextField + || manualEntryTextField === accountNumberConfirmationTextField + { + return updatedText.count <= ManualEntryValidator.accountNumberMaxLength + } + + assertionFailure("we should never have an unhandled case") + return true + } + + func manualEntryTextFieldDidBeginEditing(_ textField: ManualEntryTextField) { + updateCheckViewState() + } + + func manualEntryTextFieldDidEndEditing(_ manualEntryTextField: ManualEntryTextField) { + if manualEntryTextField === routingNumberTextField { + didEndEditingOnceRoutingNumberTextField = true + } else if manualEntryTextField === accountNumberTextField { + didEndEditingOnceAccountNumberTextField = true + } else if manualEntryTextField === accountNumberConfirmationTextField { + didEndEditingOnceAccountNumberConfirmationTextField = true + } else { + assertionFailure("we should always be able to reference a textfield") + } + updateTextFieldErrorStates() + + updateCheckViewState() + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryTextField.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryTextField.swift new file mode 100644 index 00000000..306d9f6e --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryTextField.swift @@ -0,0 +1,256 @@ +// +// ManualEntryTextField.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/23/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +protocol ManualEntryTextFieldDelegate: AnyObject { + func manualEntryTextField( + _ textField: ManualEntryTextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool + func manualEntryTextFieldDidBeginEditing(_ textField: ManualEntryTextField) + func manualEntryTextFieldDidEndEditing(_ textField: ManualEntryTextField) +} + +final class ManualEntryTextField: UIView { + + private lazy var verticalStackView: UIStackView = { + let verticalStackView = UIStackView( + arrangedSubviews: [ + titleLabel, + textFieldContainerView, + ] + ) + verticalStackView.axis = .vertical + verticalStackView.spacing = 6 + return verticalStackView + }() + private lazy var titleLabel: AttributedLabel = { + let titleLabel = AttributedLabel( + font: .label(.largeEmphasized), + textColor: .textPrimary + ) + titleLabel.setContentHuggingPriority(.defaultHigh, for: .vertical) + return titleLabel + }() + private lazy var textFieldContainerView: UIView = { + let textFieldStackView = UIStackView( + arrangedSubviews: [ + textField + ] + ) + textFieldStackView.isLayoutMarginsRelativeArrangement = true + textFieldStackView.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: 16, + leading: 16, + bottom: 16, + trailing: 16 + ) + textFieldStackView.layer.cornerRadius = 8 + return textFieldStackView + }() + private(set) lazy var textField: UITextField = { + let textField = IncreasedHitTestTextField() + textField.font = FinancialConnectionsFont.label(.large).uiFont + textField.textColor = .textPrimary + textField.keyboardType = .numberPad + textField.delegate = self + return textField + }() + private var currentFooterView: UIView? + + var text: String { + get { + return textField.text ?? "" + } + set { + textField.text = newValue + } + } + private var footerText: String? { + didSet { + didUpdateFooterText() + } + } + var errorText: String? { + didSet { + didUpdateFooterText() + } + } + weak var delegate: ManualEntryTextFieldDelegate? + + init(title: String, placeholder: String, footerText: String? = nil) { + super.init(frame: .zero) + addAndPinSubview(verticalStackView) + titleLabel.text = title + textField.attributedPlaceholder = NSAttributedString( + string: placeholder, + attributes: [ + .font: FinancialConnectionsFont.label(.large).uiFont, + .foregroundColor: UIColor.textDisabled, + ] + ) + self.footerText = footerText + didUpdateFooterText() // simulate `didSet`. it not get called in `init` + updateBorder(highlighted: false) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func didUpdateFooterText() { + currentFooterView?.removeFromSuperview() + currentFooterView = nil + + let footerTextLabel: UIView? + if let errorText = errorText, footerText != nil { + footerTextLabel = ManualEntryErrorView(text: errorText) + } else if let errorText = errorText { + footerTextLabel = ManualEntryErrorView(text: errorText) + } else if let footerText = footerText { + let footerLabel = AttributedLabel( + font: .label(.large), + textColor: .textPrimary + ) + footerLabel.text = footerText + footerTextLabel = footerLabel + } else { // no text + footerTextLabel = nil + } + if let footerTextLabel = footerTextLabel { + verticalStackView.addArrangedSubview(footerTextLabel) + currentFooterView = footerTextLabel + } + + updateBorder(highlighted: textField.isFirstResponder) + } + + private func updateBorder(highlighted: Bool) { + let highlighted = textField.isFirstResponder + + if errorText != nil && !highlighted { + textFieldContainerView.layer.borderColor = UIColor.borderCritical.cgColor + textFieldContainerView.layer.borderWidth = 1.0 + } else { + if highlighted { + textFieldContainerView.layer.borderColor = UIColor.textBrand.cgColor + textFieldContainerView.layer.borderWidth = 2.0 + } else { + textFieldContainerView.layer.borderColor = UIColor.borderNeutral.cgColor + textFieldContainerView.layer.borderWidth = 1.0 + } + } + } +} + +// MARK: - UITextFieldDelegate + +extension ManualEntryTextField: UITextFieldDelegate { + + func textField( + _ textField: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + return delegate?.manualEntryTextField( + self, + shouldChangeCharactersIn: range, + replacementString: string + ) ?? true + } + + func textFieldDidBeginEditing(_ textField: UITextField) { + updateBorder(highlighted: true) + delegate?.manualEntryTextFieldDidBeginEditing(self) + } + + func textFieldDidEndEditing(_ textField: UITextField) { + updateBorder(highlighted: false) + delegate?.manualEntryTextFieldDidEndEditing(self) + } +} + +private class IncreasedHitTestTextField: UITextField { + // increase the area of TextField taps + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + let largerBounds = bounds.insetBy(dx: -16, dy: -16) + return largerBounds.contains(point) + } +} + +#if DEBUG + +import SwiftUI + +private struct ManualEntryTextFieldUIViewRepresentable: UIViewRepresentable { + + let title: String + let placeholder: String + let footerText: String? + let errorText: String? + + func makeUIView(context: Context) -> ManualEntryTextField { + ManualEntryTextField( + title: title, + placeholder: placeholder, + footerText: footerText + ) + } + + func updateUIView(_ uiView: ManualEntryTextField, context: Context) { + uiView.errorText = errorText + } +} + +struct ManualEntryTextField_Previews: PreviewProvider { + static var previews: some View { + if #available(iOS 14.0, *) { + VStack(spacing: 16) { + ManualEntryTextFieldUIViewRepresentable( + title: "Routing number", + placeholder: "123456789", + footerText: nil, + errorText: nil + ) + ManualEntryTextFieldUIViewRepresentable( + title: "Account number", + placeholder: "000123456789", + footerText: "Your account can be checkings or savings.", + errorText: nil + ) + ManualEntryTextFieldUIViewRepresentable( + title: "Confirm account number", + placeholder: "000123456789", + footerText: nil, + errorText: nil + ) + ManualEntryTextFieldUIViewRepresentable( + title: "Routing number", + placeholder: "123456789", + footerText: nil, + errorText: "Routing number is required." + ) + ManualEntryTextFieldUIViewRepresentable( + title: "Account number", + placeholder: "000123456789", + footerText: "Your account can be checkings or savings.", + errorText: "Account number is required." + ) + Spacer() + } + .frame(maxHeight: 500) + .padding() + .background(Color(UIColor.customBackgroundColor)) + } + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryValidator.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryValidator.swift new file mode 100644 index 00000000..642d4fa8 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryValidator.swift @@ -0,0 +1,121 @@ +// +// ManualEntryValidator.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/25/22. +// + +import Foundation + +final class ManualEntryValidator { + + static let routingNumberLength = 9 + static let accountNumberMaxLength = 17 + + static func validateRoutingNumber(_ routingNumber: String) -> String? { + if routingNumber.isEmpty { + return STPLocalizedString( + "Routing number is required.", + "An error message that appears when a user is manually entering their bank account information. This error message appears when the user left the 'Routing number' field blank." + ) + } else if !isStringDigits(routingNumber, withExactLength: routingNumberLength) { + return String( + format: STPLocalizedString( + "Please enter %d digits for your routing number.", + "An error message that appears when a user is manually entering their bank account information. %d is replaced with the routing number length (usually 9)." + ), + routingNumberLength + ) + } else if !isUSRoutingNumber(routingNumber) { + return STPLocalizedString( + "Invalid routing number.", + "An error message that appears when a user is manually entering their bank account information." + ) + } else { + return nil + } + } + + static func validateAccountNumber(_ accountNumber: String) -> String? { + if accountNumber.isEmpty { + return STPLocalizedString( + "Account number is required.", + "An error message that appears when a user is manually entering their bank account information. This error message appears when the user left the 'Account number' field blank." + ) + } else if !isStringDigits(accountNumber, withMaxLength: accountNumberMaxLength) { + return String( + format: STPLocalizedString( + "Invalid bank account number: must be at most %d digits long.", + "An error message that appears when a user is manually entering their bank account information. %d is replaced with the account number length (usually 17)." + ), + accountNumberMaxLength + ) + } else { + return nil + } + } + + static func validateAccountNumberConfirmation( + _ accountNumberConfirmation: String, + accountNumber: String + ) -> String? { + if accountNumberConfirmation.isEmpty { + return STPLocalizedString( + "Confirm the account number.", + "An error message that appears when a user is manually entering their bank account information. This error message appears when the user left the 'Confirm account number' field blank." + ) + } else if accountNumberConfirmation != accountNumber { + return STPLocalizedString( + "Your account numbers don't match.", + "An error message that appears when a user is manually entering their bank account information. This error message tells the user that the account number they typed doesn't match a previously typed account number." + ) + } else { + return nil + } + } + + private static func isStringDigits( + _ string: String, + withMaxLength maxLength: Int + ) -> Bool { + let regex = "^\\d{1,\(maxLength)}$" + return string.range(of: regex, options: [.regularExpression]) != nil + } + + private static func isStringDigits( + _ string: String, + withExactLength exactLength: Int + ) -> Bool { + let regex = "^\\d{\(exactLength)}$" + return string.range(of: regex, options: [.regularExpression]) != nil + } + + private static func isUSRoutingNumber(_ routingNumber: String) -> Bool { + func usRoutingFactor(_ index: Int) -> Int { + let mod3 = index % 3 + if mod3 == 0 { + return 3 + } else if mod3 == 1 { + return 7 + } else { + return 1 + } + } + + if routingNumber.range(of: #"^\d{9}$"#, options: [.regularExpression]) != nil { + let total = routingNumber.enumerated().reduce(0) { partialResult, indexAndCharacter in + let index = indexAndCharacter.offset + let character = String(indexAndCharacter.element) + + // the character cast can't fail because we ensure that + // all characters are digits with the regex + assert(Int(character) != nil) + + return partialResult + (Int(character) ?? 1) * usRoutingFactor(index) + } + return total % 10 == 0 + } else { + return false + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryViewController.swift new file mode 100644 index 00000000..f537605a --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntry/ManualEntryViewController.swift @@ -0,0 +1,131 @@ +// +// ManualEntryViewController.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/23/22. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol ManualEntryViewControllerDelegate: AnyObject { + func manualEntryViewController( + _ viewController: ManualEntryViewController, + didRequestToContinueWithPaymentAccountResource paymentAccountResource: + FinancialConnectionsPaymentAccountResource, + accountNumberLast4: String + ) +} + +final class ManualEntryViewController: UIViewController { + + private let dataSource: ManualEntryDataSource + weak var delegate: ManualEntryViewControllerDelegate? + + private lazy var manualEntryFormView: ManualEntryFormView = { + let manualEntryFormView = ManualEntryFormView() + manualEntryFormView.delegate = self + return manualEntryFormView + }() + private lazy var footerView: ManualEntryFooterView = { + let manualEntryFooterView = ManualEntryFooterView( + didSelectContinue: { [weak self] in + self?.didSelectContinue() + } + ) + return manualEntryFooterView + }() + + init(dataSource: ManualEntryDataSource) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .customBackgroundColor + + let paneWithHeaderLayoutView = PaneWithHeaderLayoutView( + title: STPLocalizedString( + "Enter bank account details", + "The title of a screen that allows a user to manually enter their bank account information." + ), + subtitle: dataSource.manifest.manualEntryUsesMicrodeposits + ? STPLocalizedString( + "Your bank information will be verified with micro-deposits to your account", + "The subtitle/description in a screen that allows a user to manually enter their bank account information. It informs the user that their bank account information will have to be verified." + ) : nil, + contentView: manualEntryFormView, + footerView: footerView + ) + paneWithHeaderLayoutView.addTo(view: view) + paneWithHeaderLayoutView.scrollView.keyboardDismissMode = .onDrag + stp_beginObservingKeyboardAndInsettingScrollView(paneWithHeaderLayoutView.scrollView, onChange: nil) + + adjustContinueButtonStateIfNeeded() + + dataSource + .analyticsClient + .logPaneLoaded(pane: .manualEntry) + } + + private func didSelectContinue() { + guard let routingAndAccountNumber = manualEntryFormView.routingAndAccountNumber else { + assertionFailure("user should never be able to press continue if we have no routing/account number") + return + } + manualEntryFormView.setError(text: nil) // clear previous error + + footerView.setIsLoading(true) + dataSource.attachBankAccountToLinkAccountSession( + routingNumber: routingAndAccountNumber.routingNumber, + accountNumber: routingAndAccountNumber.accountNumber + ).observe(on: .main) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let resource): + self.delegate? + .manualEntryViewController( + self, + didRequestToContinueWithPaymentAccountResource: resource, + accountNumberLast4: String(routingAndAccountNumber.accountNumber.suffix(4)) + ) + case .failure(let error): + let errorText: String + if let stripeError = error as? StripeError, case .apiError(let apiError) = stripeError { + errorText = apiError.message ?? stripeError.localizedDescription + } else { + errorText = error.localizedDescription + } + self.manualEntryFormView.setError(text: errorText) + self.dataSource + .analyticsClient + .logUnexpectedError( + error, + errorName: "ManualEntryAttachBankAccountToLinkAccountSessionError", + pane: .manualEntry + ) + } + self.footerView.setIsLoading(false) + } + } + + private func adjustContinueButtonStateIfNeeded() { + footerView.continueButton.isEnabled = (manualEntryFormView.routingAndAccountNumber != nil) + } +} + +// MARK: - ManualEntryFormViewDelegate + +extension ManualEntryViewController: ManualEntryFormViewDelegate { + + func manualEntryFormViewTextDidChange(_ view: ManualEntryFormView) { + adjustContinueButtonStateIfNeeded() + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntrySuccess/ManualEntrySuccessTransactionTableView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntrySuccess/ManualEntrySuccessTransactionTableView.swift new file mode 100644 index 00000000..e1b29407 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntrySuccess/ManualEntrySuccessTransactionTableView.swift @@ -0,0 +1,294 @@ +// +// ManualEntrySuccessTableView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/29/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +private struct Label { + let title: String + let isHighlighted: Bool + + init(title: String, isHighlighted: Bool = false) { + self.title = title + self.isHighlighted = isHighlighted + } +} + +final class ManualEntrySuccessTransactionTableView: UIView { + + init( + microdepositVerificationMethod: MicrodepositVerificationMethod?, + accountNumberLast4: String + ) { + super.init(frame: .zero) + let verticalStackView = UIStackView( + arrangedSubviews: [ + CreateTableTitleView( + title: String( + format: STPLocalizedString( + "••••%@ BANK STATEMENT", + "The title of a table. The table shows a list of bank transactions, or, in other words, a list of payments made for purchases. The '%@' is replaced by the last 4 digits of a bank account number. For example, it could form '••••6489 BANK STATEMENT'." + ), + accountNumberLast4 + ) + ), + CreateTableView( + rows: CreateRows( + microdepositVerificationMethod: microdepositVerificationMethod + ) + ), + ] + ) + verticalStackView.axis = .vertical + verticalStackView.spacing = 7 + verticalStackView.isLayoutMarginsRelativeArrangement = true + verticalStackView.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: 14, + leading: 20, + bottom: 14, + trailing: 20 + ) + verticalStackView.backgroundColor = .backgroundContainer + verticalStackView.layer.cornerRadius = 5 + verticalStackView.layer.borderColor = UIColor.borderNeutral.cgColor + verticalStackView.layer.borderWidth = 1.0 / UIScreen.main.nativeScale + addAndPinSubview(verticalStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Helpers + +private func CreateRows( + microdepositVerificationMethod: MicrodepositVerificationMethod? +) -> [[Label]] { + var rows: [[Label]] = [] + if microdepositVerificationMethod == .descriptorCode { + rows.append([ + Label(title: "SMXXXX", isHighlighted: true), + Label(title: "$0.01"), + Label(title: "ACH CREDIT"), + ]) + } else { + for _ in 0..<2 { + rows.append([ + Label(title: "AMTS"), + Label(title: "$0.XX", isHighlighted: true), + Label(title: "ACH CREDIT"), + ]) + } + } + rows.append([ + Label(title: "GROCERIES"), + Label(title: "$56.12"), + Label(title: "VISA"), + ]) + + return rows +} + +private func CreateTableTitleView(title: String) -> UIView { + let iconImageView = UIImageView() + iconImageView.image = Image.bank.makeImage() + .withTintColor(.textSecondary) + NSLayoutConstraint.activate([ + iconImageView.widthAnchor.constraint(equalToConstant: 16), + iconImageView.heightAnchor.constraint(equalToConstant: 16), + ]) + + let titleLabel = AttributedLabel( + font: .code(.largeEmphasized), + textColor: .textSecondary + ) + titleLabel.numberOfLines = 0 + titleLabel.text = title + + let horizontalStackView = UIStackView( + arrangedSubviews: [ + iconImageView, + titleLabel, + ] + ) + horizontalStackView.axis = .horizontal + horizontalStackView.spacing = 5 + return horizontalStackView +} + +private func CreateTableView(rows: [[Label]]) -> UIView { + let transactionColumnTuple = CreateColumnView( + title: STPLocalizedString( + "Transaction", + "The title of a column of a table. The table shows a list of bank transactions, or, in other words, a list of payments made for purchases. The 'Transaction' column displays the title of the transaction, for example, 'Groceries.'" + ), + rowLabels: rows.compactMap { $0[0] } + ) + let amountColumnTuple = CreateColumnView( + title: STPLocalizedString( + "Amount", + "The title of a column of a table. The table shows a list of bank transactions, or, in other words, a list of payments made for purchases. The 'Amount' column displays the currency value for a transaction, for example, '$56.12.'" + ), + alignment: .trailing, + rowLabels: rows.compactMap { $0[1] } + ) + let typeColumnTuple = CreateColumnView( + title: STPLocalizedString( + "Type", + "The title of a column of a table. The table shows a list of bank transactions, or, in other words, a list of payments made for purchases. The 'Type' column displays the type of transaction, for example, 'VISA' or 'ACH CREDIT'" + ), + rowLabels: rows.compactMap { $0[2] } + ) + + let columnHorizontalStackView = UIStackView( + arrangedSubviews: [ + transactionColumnTuple.stackView, + amountColumnTuple.stackView, + typeColumnTuple.stackView, + ] + ) + columnHorizontalStackView.axis = .horizontal + columnHorizontalStackView.distribution = .fillProportionally + + // Add spacing between columns. + // + // "Amount" column is `.trailing` aligned, so + // it needs extra spacing to avoid interferring + // with "Type" column. + columnHorizontalStackView.setCustomSpacing(10, after: amountColumnTuple.stackView) + columnHorizontalStackView.spacing = 1 // otherwise..have "1" spacing + + // Add separator to each column. + // + // The sparator needs to be the width of `UIStackView`, + // so we first need to create the `UIStackView`, + // and then we use its `widthAnchor` to set the separator + // width. + for columnTuple in [transactionColumnTuple, amountColumnTuple, typeColumnTuple] { + let separatorView = UIView() + separatorView.backgroundColor = .borderNeutral + + separatorView.setContentHuggingPriority(.defaultHigh, for: .vertical) + separatorView.translatesAutoresizingMaskIntoConstraints = false + columnTuple.stackView.insertArrangedSubview(separatorView, at: 1) + columnTuple.stackView.setCustomSpacing(10, after: separatorView) + NSLayoutConstraint.activate([ + separatorView.heightAnchor.constraint(equalToConstant: 1.0 / UIScreen.main.nativeScale), + separatorView.widthAnchor.constraint(equalTo: columnTuple.stackView.widthAnchor), + ]) + } + + // Make all rows equal height. + // + // UIStackView can't align content across multiple + // independent UIStackView's. As a result, here we + // align the row height across all the UIStackView's. + let numberOfRows = min( + transactionColumnTuple.rowViews.count, + amountColumnTuple.rowViews.count, + typeColumnTuple.rowViews.count + ) + for i in 0.. (stackView: UIStackView, rowViews: [UIView]) { + let verticalStackView = UIStackView() + verticalStackView.axis = .vertical + verticalStackView.spacing = 4 // spacing for rows + verticalStackView.alignment = alignment + + // Title + let titleLabel = AttributedLabel( + font: .code(.largeEmphasized), + textColor: .textSecondary + ) + titleLabel.text = title + titleLabel.setContentHuggingPriority(.defaultHigh, for: .vertical) + verticalStackView.addArrangedSubview(titleLabel) + verticalStackView.setCustomSpacing(5, after: titleLabel) + + // Rows + var rowViews: [UIView] = [] + for label in rowLabels { + let rowLabel = AttributedLabel( + font: .code(.largeEmphasized), + textColor: label.isHighlighted ? .textBrand : .textPrimary + ) + rowLabel.numberOfLines = 0 + rowLabel.text = label.title + rowLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + rowLabel.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + verticalStackView.addArrangedSubview(rowLabel) + rowViews.append(rowLabel) + } + + // Spacer + verticalStackView.addArrangedSubview(UIView()) + + return (verticalStackView, rowViews) +} + +#if DEBUG + +import SwiftUI + +private struct ManualEntrySuccessTransactionTableViewUIViewRepresentable: UIViewRepresentable { + + let microdepositVerificationMethod: MicrodepositVerificationMethod + let accountNumberLast4: String + + func makeUIView(context: Context) -> ManualEntrySuccessTransactionTableView { + ManualEntrySuccessTransactionTableView( + microdepositVerificationMethod: microdepositVerificationMethod, + accountNumberLast4: accountNumberLast4 + ) + } + + func updateUIView(_ uiView: ManualEntrySuccessTransactionTableView, context: Context) {} +} + +struct ManualEntrySuccessTransactionTableView_Previews: PreviewProvider { + static var previews: some View { + if #available(iOS 14.0, *) { + VStack(spacing: 16) { + ManualEntrySuccessTransactionTableViewUIViewRepresentable( + microdepositVerificationMethod: .amounts, + accountNumberLast4: "6789" + ) + .frame(maxHeight: 200) + .frame(maxWidth: 320) + ManualEntrySuccessTransactionTableViewUIViewRepresentable( + microdepositVerificationMethod: .descriptorCode, + accountNumberLast4: "6789" + ) + .frame(maxHeight: 200) + .frame(maxWidth: 320) + } + .padding() + .background(Color(UIColor.customBackgroundColor)) + } + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntrySuccess/ManualEntrySuccessViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntrySuccess/ManualEntrySuccessViewController.swift new file mode 100644 index 00000000..d16349c6 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ManualEntrySuccess/ManualEntrySuccessViewController.swift @@ -0,0 +1,109 @@ +// +// ManualEntrySuccessViewController.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/29/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +protocol ManualEntrySuccessViewControllerDelegate: AnyObject { + func manualEntrySuccessViewControllerDidFinish(_ viewController: ManualEntrySuccessViewController) +} + +final class ManualEntrySuccessViewController: UIViewController { + + private let microdepositVerificationMethod: MicrodepositVerificationMethod? + private let accountNumberLast4: String + private let analyticsClient: FinancialConnectionsAnalyticsClient + + weak var delegate: ManualEntrySuccessViewControllerDelegate? + + init( + microdepositVerificationMethod: MicrodepositVerificationMethod?, + accountNumberLast4: String, + analyticsClient: FinancialConnectionsAnalyticsClient + ) { + self.microdepositVerificationMethod = microdepositVerificationMethod + self.accountNumberLast4 = accountNumberLast4 + self.analyticsClient = analyticsClient + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .customBackgroundColor + navigationItem.hidesBackButton = true + + let paneWithHeaderLayoutView = PaneWithHeaderLayoutView( + icon: .view(SuccessIconView()), + title: STPLocalizedString( + "Micro-deposits initiated", + "The title of a screen that instructs user that they will receive micro-deposists (small payments like '$0.01') in their bank account." + ), + subtitle: { + let subtitle: String + if microdepositVerificationMethod == .descriptorCode { + subtitle = String( + format: STPLocalizedString( + "Expect a $0.01 deposit to the account ending in ****%@ in 1-2 business days and an email with additional instructions to verify your bank account.", + "The subtitle of a screen that instructs user that they will receive micro-deposists (small payments like '$0.01') in their bank account. '%@' is replaced by the last 4 digits of a bank account number, ex. 6489." + ), + accountNumberLast4 + ) + } else { + subtitle = String( + format: STPLocalizedString( + "Expect two small deposits to the account ending in ••••%@ in 1-2 business days and an email with additional instructions to verify your bank account.", + "The subtitle of a screen that instructs user that they will receive micro-deposists (small payments like '$0.01') in their bank account. '%@' is replaced by the last 4 digits of a bank account number, ex. 6489." + ), + accountNumberLast4 + ) + } + return subtitle + }(), + contentView: ManualEntrySuccessTransactionTableView( + microdepositVerificationMethod: microdepositVerificationMethod, + accountNumberLast4: accountNumberLast4 + ), + footerView: CreateFooterView(self) + ) + paneWithHeaderLayoutView.addTo(view: view) + + analyticsClient.logPaneLoaded(pane: .manualEntrySuccess) + } + + @objc fileprivate func didSelectDone() { + delegate?.manualEntrySuccessViewControllerDidFinish(self) + } +} + +// MARK: - Helpers + +private func CreateFooterView(_ buttonTarget: ManualEntrySuccessViewController) -> UIView { + let doneButton = Button(configuration: .financialConnectionsPrimary) + doneButton.title = "Done" // TODO: replace with UIButton.doneButtonTitle once the SDK is localized + doneButton.addTarget( + buttonTarget, + action: #selector(ManualEntrySuccessViewController.didSelectDone), + for: .touchUpInside + ) + doneButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + doneButton.heightAnchor.constraint(equalToConstant: 56) + ]) + doneButton.accessibilityIdentifier = "manual_entry_success_done_button" + let verticalStackView = UIStackView( + arrangedSubviews: [ + doneButton + ] + ) + verticalStackView.axis = .vertical + return verticalStackView +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NativeFlowController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NativeFlowController.swift new file mode 100644 index 00000000..7ef419c3 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NativeFlowController.swift @@ -0,0 +1,1124 @@ +// +// NativeFlowController.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 6/6/22. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol NativeFlowControllerDelegate: AnyObject { + + func authFlow( + controller: NativeFlowController, + didFinish result: FinancialConnectionsSheet.Result + ) +} + +class NativeFlowController { + + private let dataManager: NativeFlowDataManager + private let navigationController: FinancialConnectionsNavigationController + weak var delegate: NativeFlowControllerDelegate? + + private lazy var navigationBarCloseBarButtonItem: UIBarButtonItem = { + let item = UIBarButtonItem( + image: Image.close.makeImage(template: false), + style: .plain, + target: self, + action: #selector(didSelectNavigationBarCloseButton) + ) + item.tintColor = .textDisabled + item.imageInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 5) + return item + }() + + init( + dataManager: NativeFlowDataManager, + navigationController: FinancialConnectionsNavigationController + ) { + self.dataManager = dataManager + self.navigationController = navigationController + navigationController.analyticsClient = dataManager.analyticsClient + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + } + + func startFlow() { + assert(navigationController.analyticsClient != nil) + guard + let viewController = CreatePaneViewController( + pane: dataManager.manifest.nextPane, + nativeFlowController: self, + dataManager: dataManager + ) + else { + assertionFailure( + "We should always get a view controller for the first pane: \(dataManager.manifest.nextPane)" + ) + showTerminalError() + return + } + setNavigationControllerViewControllers([viewController], animated: false) + } + + @objc private func didSelectNavigationBarCloseButton() { + dataManager.analyticsClient.log( + eventName: "click.nav_bar.close", + pane: FinancialConnectionsAnalyticsClient + .paneFromViewController(navigationController.topViewController) + ) + + let showConfirmationAlert = + (navigationController.topViewController is AccountPickerViewController + || navigationController.topViewController is PartnerAuthViewController + || navigationController.topViewController is AttachLinkedPaymentAccountViewController + || navigationController.topViewController is NetworkingLinkSignupViewController + || navigationController.topViewController is NetworkingLinkStepUpVerificationViewController + || navigationController.topViewController is NetworkingLinkVerificationViewController + || navigationController.topViewController is NetworkingSaveToLinkVerificationViewController + || navigationController.topViewController is LinkAccountPickerViewController) + + let finishClosingAuthFlow = { [weak self] in + self?.closeAuthFlow() + } + if showConfirmationAlert { + CloseConfirmationAlertHandler.present( + businessName: dataManager.manifest.businessName, + showNetworkingLanguageInConfirmationAlert: (dataManager.manifest.isNetworkingUserFlow == true && navigationController.topViewController is NetworkingLinkSignupViewController), + didSelectOK: { + finishClosingAuthFlow() + } + ) + } else { + finishClosingAuthFlow() + } + } + + @objc private func applicationWillEnterForeground() { + dataManager + .analyticsClient + .log( + eventName: "mobile.app_entered_foreground", + pane: FinancialConnectionsAnalyticsClient + .paneFromViewController(navigationController.topViewController) + ) + } + + @objc private func applicationDidEnterBackground() { + dataManager + .analyticsClient + .log( + eventName: "mobile.app_entered_background", + pane: FinancialConnectionsAnalyticsClient + .paneFromViewController(navigationController.topViewController) + ) + } +} + +// MARK: - Core Navigation Helpers + +extension NativeFlowController { + + private func setNavigationControllerViewControllers(_ viewControllers: [UIViewController], animated: Bool = true) { + viewControllers.forEach { viewController in + FinancialConnectionsNavigationController.configureNavigationItemForNative( + viewController.navigationItem, + closeItem: navigationBarCloseBarButtonItem, + shouldHideStripeLogo: ShouldHideStripeLogoInNavigationBar( + forViewController: viewController, + reducedBranding: dataManager.reducedBranding, + merchantLogo: dataManager.merchantLogo + ), + shouldLeftAlignStripeLogo: viewControllers.first == viewController + && viewController is ConsentViewController + ) + } + navigationController.setViewControllers(viewControllers, animated: animated) + } + + private func pushPane( + _ pane: FinancialConnectionsSessionManifest.NextPane, + animated: Bool, + // useful for cases where we want to prevent the user from navigating back + // + // keeping this logic in `pushPane` is helpful because we want to + // reuse `skipSuccessPane` and `manualEntryMode == .custom` logic + clearNavigationStack: Bool = false + ) { + if pane == .success && dataManager.manifest.skipSuccessPane == true { + closeAuthFlow(error: nil) + } else if pane == .manualEntry && dataManager.manifest.manualEntryMode == .custom { + closeAuthFlow(customManualEntry: true) + } else { + let paneViewController = CreatePaneViewController( + pane: pane, + nativeFlowController: self, + dataManager: dataManager + ) + if clearNavigationStack, let paneViewController = paneViewController { + setNavigationControllerViewControllers([paneViewController], animated: animated) + } else { + pushViewController(paneViewController, animated: animated) + } + } + } + + private func pushViewController(_ viewController: UIViewController?, animated: Bool) { + if let viewController = viewController { + FinancialConnectionsNavigationController.configureNavigationItemForNative( + viewController.navigationItem, + closeItem: navigationBarCloseBarButtonItem, + shouldHideStripeLogo: ShouldHideStripeLogoInNavigationBar( + forViewController: viewController, + reducedBranding: dataManager.reducedBranding, + merchantLogo: dataManager.merchantLogo + ), + shouldLeftAlignStripeLogo: false // if we `push`, this is not the first VC + ) + navigationController.pushViewController(viewController, animated: animated) + } else { + // when we can't find a view controller to present, + // show a terminal error + showTerminalError() + } + } +} + +// MARK: - Other Helpers + +extension NativeFlowController { + + private func didSelectAnotherBank() { + if dataManager.manifest.disableLinkMoreAccounts { + closeAuthFlow(error: nil) + } else { + startResetFlow() + } + } + + private func startResetFlow() { + guard + let resetFlowViewController = CreatePaneViewController( + pane: .resetFlow, + nativeFlowController: self, + dataManager: dataManager + ) + else { + assertionFailure( + "We should always get a view controller for \(FinancialConnectionsSessionManifest.NextPane.resetFlow)" + ) + showTerminalError() + return + } + + var viewControllers: [UIViewController] = [] + if let consentViewController = navigationController.viewControllers.first as? ConsentViewController { + viewControllers.append(consentViewController) + } + viewControllers.append(resetFlowViewController) + + setNavigationControllerViewControllers(viewControllers, animated: true) + } + + private func showTerminalError(_ error: Error? = nil) { + let terminalError: Error + if let error = error { + terminalError = error + } else { + terminalError = FinancialConnectionsSheetError.unknown( + debugDescription: + "Unknown terminal error. It is likely that we couldn't find a view controller for a specific pane." + ) + } + dataManager.terminalError = terminalError // needs to be set to create `terminalError` pane + + guard + let terminalErrorViewController = CreatePaneViewController( + pane: .terminalError, + nativeFlowController: self, + dataManager: dataManager + ) + else { + assertionFailure( + "We should always get a view controller for \(FinancialConnectionsSessionManifest.NextPane.terminalError)" + ) + closeAuthFlow(error: terminalError) + return + } + setNavigationControllerViewControllers([terminalErrorViewController], animated: false) + } + + // There's at least four types of close cases: + // 1. User closes, and accounts are returned (or `paymentAccount` or `bankAccountToken`). That's a success. + // 2. User closes, no accounts are returned, and there's an error. That's a failure. + // 3. User closes, no accounts are returned, and there's no error. That's a cancel. + // 4. User closes, and fetching accounts returns an error. That's a failure. + private func closeAuthFlow( + customManualEntry: Bool = false, + error closeAuthFlowError: Error? = nil // user can also close AuthFlow while looking at an error screen + ) { + let finishAuthSession: (FinancialConnectionsSheet.Result) -> Void = { [weak self] result in + guard let self = self else { return } + self.delegate?.authFlow(controller: self, didFinish: result) + } + + dataManager + .completeFinancialConnectionsSession( + terminalError: customManualEntry ? "user_initiated_with_custom_manual_entry" : nil + ) + .observe(on: .main) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let session): + let eventType = "object" + if session.status == .cancelled + && session.statusDetails?.cancelled?.reason == .customManualEntry + { + self.logCompleteEvent( + type: eventType, + status: "custom_manual_entry" + ) + finishAuthSession(.failed(error: FinancialConnectionsCustomManualEntryRequiredError())) + } else { + if !session.accounts.data.isEmpty || session.paymentAccount != nil + || session.bankAccountToken != nil + { + self.logCompleteEvent( + type: eventType, + status: "completed", + numberOfLinkedAccounts: session.accounts.data.count + ) + finishAuthSession(.completed(session: session)) + } else if let closeAuthFlowError = closeAuthFlowError { + self.logCompleteEvent( + type: eventType, + status: "failed", + error: closeAuthFlowError + ) + finishAuthSession(.failed(error: closeAuthFlowError)) + } else { + if let terminalError = self.dataManager.terminalError { + self.logCompleteEvent( + type: eventType, + status: "failed", + error: terminalError + ) + finishAuthSession(.failed(error: terminalError)) + } else { + self.logCompleteEvent( + type: eventType, + status: "canceled" + ) + finishAuthSession(.canceled) + } + } + } + case .failure(let completeFinancialConnectionsSessionError): + self.logCompleteEvent( + type: "error", + status: "failed", + error: completeFinancialConnectionsSessionError + ) + + if let closeAuthFlowError = closeAuthFlowError { + finishAuthSession(.failed(error: closeAuthFlowError)) + } else { + finishAuthSession(.failed(error: completeFinancialConnectionsSessionError)) + } + } + } + } + + private func logCompleteEvent( + type: String, + status: String, + numberOfLinkedAccounts: Int? = nil, + error: Error? = nil + ) { + var parameters: [String: Any] = [ + "type": type, + "status": status, + ] + parameters["num_linked_accounts"] = numberOfLinkedAccounts + if let error = error { + if let stripeError = error as? StripeError, + case .apiError(let apiError) = stripeError + { + parameters["error_type"] = apiError.type.rawValue + parameters["error_message"] = apiError.message + parameters["code"] = apiError.code + } else { + parameters["error_type"] = (error as NSError).domain + parameters["error_message"] = (error as NSError).localizedDescription + parameters["code"] = (error as NSError).code + } + } + dataManager + .analyticsClient + .log( + eventName: "complete", + parameters: parameters, + pane: FinancialConnectionsAnalyticsClient + .paneFromViewController(navigationController.topViewController) + ) + } +} + +// MARK: - ConsentViewControllerDelegate + +extension NativeFlowController: ConsentViewControllerDelegate { + + func consentViewController( + _ viewController: ConsentViewController, + didConsentWithManifest manifest: FinancialConnectionsSessionManifest + ) { + dataManager.manifest = manifest + + pushPane(manifest.nextPane, animated: true) + } + + func consentViewControllerDidSelectManuallyVerify(_ viewController: ConsentViewController) { + pushPane(.manualEntry, animated: true) + } +} + +// MARK: - InstitutionPickerViewControllerDelegate + +extension NativeFlowController: InstitutionPickerViewControllerDelegate { + + func institutionPickerViewController( + _ viewController: InstitutionPickerViewController, + didSelect institution: FinancialConnectionsInstitution + ) { + dataManager.institution = institution + + pushPane(.partnerAuth, animated: true) + } + + func institutionPickerViewControllerDidSelectManuallyAddYourAccount( + _ viewController: InstitutionPickerViewController + ) { + pushPane(.manualEntry, animated: true) + } +} + +// MARK: - PartnerAuthViewControllerDelegate + +extension NativeFlowController: PartnerAuthViewControllerDelegate { + + func partnerAuthViewControllerUserDidSelectAnotherBank(_ viewController: PartnerAuthViewController) { + didSelectAnotherBank() + } + + func partnerAuthViewControllerDidRequestToGoBack(_ viewController: PartnerAuthViewController) { + navigationController.popViewController(animated: true) + } + + func partnerAuthViewControllerUserDidSelectEnterBankDetailsManually(_ viewController: PartnerAuthViewController) { + pushPane(.manualEntry, animated: true) + } + + func partnerAuthViewController( + _ viewController: PartnerAuthViewController, + didCompleteWithAuthSession authSession: FinancialConnectionsAuthSession + ) { + dataManager.authSession = authSession + + // This is a weird thing to do, but effectively we don't want to + // animate for OAuth since we make the authorize call in that case + // and already have the same loading screen. + let shouldAnimate = !authSession.isOauthNonOptional + pushPane(.accountPicker, animated: shouldAnimate) + } + + func partnerAuthViewController( + _ viewController: PartnerAuthViewController, + didReceiveTerminalError error: Error + ) { + showTerminalError(error) + } +} + +// MARK: - AccountPickerViewControllerDelegate + +extension NativeFlowController: AccountPickerViewControllerDelegate { + + func accountPickerViewController( + _ viewController: AccountPickerViewController, + didSelectAccounts selectedAccounts: [FinancialConnectionsPartnerAccount] + ) { + dataManager.linkedAccounts = selectedAccounts + + let shouldAttachLinkedPaymentAccount = (dataManager.manifest.paymentMethodType != nil) + if shouldAttachLinkedPaymentAccount { + // this prevents an unnecessary push transition when presenting `attachLinkedPaymentAccount` + // + // `attachLinkedPaymentAccount` looks the same as the last step of `accountPicker` + // so navigating to a "Linking account" loading screen can look buggy to the user + pushPane(.attachLinkedPaymentAccount, animated: false) + } else { + pushPane(.success, animated: true) + } + } + + func accountPickerViewControllerDidSelectAnotherBank(_ viewController: AccountPickerViewController) { + didSelectAnotherBank() + } + + func accountPickerViewControllerDidSelectManualEntry(_ viewController: AccountPickerViewController) { + pushPane(.manualEntry, animated: true) + } + + func accountPickerViewController( + _ viewController: AccountPickerViewController, + didReceiveTerminalError error: Error + ) { + showTerminalError(error) + } +} + +// MARK: - SuccessViewControllerDelegate + +extension NativeFlowController: SuccessViewControllerDelegate { + + func successViewControllerDidSelectDone(_ viewController: SuccessViewController) { + closeAuthFlow(error: nil) + } +} + +// MARK: - ManualEntryViewControllerDelegate + +extension NativeFlowController: ManualEntryViewControllerDelegate { + + func manualEntryViewController( + _ viewController: ManualEntryViewController, + didRequestToContinueWithPaymentAccountResource paymentAccountResource: + FinancialConnectionsPaymentAccountResource, + accountNumberLast4: String + ) { + dataManager.paymentAccountResource = paymentAccountResource + dataManager.accountNumberLast4 = accountNumberLast4 + + if dataManager.manifest.manualEntryUsesMicrodeposits { + pushPane(.manualEntrySuccess, animated: true) + } else { + closeAuthFlow(error: nil) + } + } +} + +// MARK: - ManualEntrySuccessViewControllerDelegate + +extension NativeFlowController: ManualEntrySuccessViewControllerDelegate { + + func manualEntrySuccessViewControllerDidFinish(_ viewController: ManualEntrySuccessViewController) { + closeAuthFlow(error: nil) + } +} + +// MARK: - ResetFlowViewControllerDelegate + +extension NativeFlowController: ResetFlowViewControllerDelegate { + + func resetFlowViewController( + _ viewController: ResetFlowViewController, + didSucceedWithManifest manifest: FinancialConnectionsSessionManifest + ) { + assert(navigationController.topViewController is ResetFlowViewController) + if navigationController.topViewController is ResetFlowViewController { + // remove ResetFlowViewController from the navigation stack + navigationController.popViewController(animated: false) + } + + // reset all the state because we are starting + // a new auth session + dataManager.resetState(withNewManifest: manifest) + + // go to the next pane (likely `institutionPicker`) + pushPane(manifest.nextPane, animated: false) + } + + func resetFlowViewController( + _ viewController: ResetFlowViewController, + didFailWithError error: Error + ) { + closeAuthFlow(error: error) + } +} + +// MARK: - NetworkingLinkSignupViewControllerDelegate + +extension NativeFlowController: NetworkingLinkSignupViewControllerDelegate { + + func networkingLinkSignupViewController( + _ viewController: NetworkingLinkSignupViewController, + foundReturningConsumerWithSession consumerSession: ConsumerSessionData + ) { + dataManager.consumerSession = consumerSession + pushPane(.networkingSaveToLinkVerification, animated: true) + } + + func networkingLinkSignupViewControllerDidFinish( + _ viewController: NetworkingLinkSignupViewController, + saveToLinkWithStripeSucceeded: Bool?, + withError error: Error? + ) { + if saveToLinkWithStripeSucceeded != nil { + dataManager.saveToLinkWithStripeSucceeded = saveToLinkWithStripeSucceeded + } + pushPane(.success, animated: true) + } + + func networkingLinkSignupViewController( + _ viewController: NetworkingLinkSignupViewController, + didReceiveTerminalError error: Error + ) { + showTerminalError(error) + } +} + +// MARK: - NetworkingLinkLoginWarmupViewControllerDelegate + +extension NativeFlowController: NetworkingLinkLoginWarmupViewControllerDelegate { + + func networkingLinkLoginWarmupViewControllerDidSelectContinue( + _ viewController: NetworkingLinkLoginWarmupViewController + ) { + pushPane(.networkingLinkVerification, animated: true) + } + + func networkingLinkLoginWarmupViewController( + _ viewController: NetworkingLinkLoginWarmupViewController, + didSelectSkipWithManifest manifest: FinancialConnectionsSessionManifest + ) { + dataManager.manifest = manifest + pushPane( + manifest.nextPane, + animated: true, + // skipping disables networking, which means + // we don't want the user to navigate back to + // the warm-up pane + clearNavigationStack: true + ) + } + + func networkingLinkLoginWarmupViewController( + _ viewController: NetworkingLinkLoginWarmupViewController, + didReceiveTerminalError error: Error + ) { + showTerminalError(error) + } +} + +// MARK: - TerminalErrorViewControllerDelegate + +extension NativeFlowController: TerminalErrorViewControllerDelegate { + + func terminalErrorViewController( + _ viewController: TerminalErrorViewController, + didCloseWithError error: Error + ) { + closeAuthFlow(error: error) + } + + func terminalErrorViewControllerDidSelectManualEntry(_ viewController: TerminalErrorViewController) { + pushPane(.manualEntry, animated: true) + } +} + +// MARK: - AttachLinkedPaymentAccountViewControllerDelegate + +extension NativeFlowController: AttachLinkedPaymentAccountViewControllerDelegate { + + func attachLinkedPaymentAccountViewController( + _ viewController: AttachLinkedPaymentAccountViewController, + didFinishWithPaymentAccountResource paymentAccountResource: FinancialConnectionsPaymentAccountResource, + saveToLinkWithStripeSucceeded: Bool? + ) { + if saveToLinkWithStripeSucceeded != nil { + dataManager.saveToLinkWithStripeSucceeded = saveToLinkWithStripeSucceeded + } + pushPane(paymentAccountResource.nextPane ?? .success, animated: true) + } + + func attachLinkedPaymentAccountViewControllerDidSelectAnotherBank( + _ viewController: AttachLinkedPaymentAccountViewController + ) { + didSelectAnotherBank() + } + + func attachLinkedPaymentAccountViewControllerDidSelectManualEntry( + _ viewController: AttachLinkedPaymentAccountViewController + ) { + pushPane(.manualEntry, animated: true) + } +} + +// MARK: - NetworkingLinkVerificationViewControllerDelegate + +extension NativeFlowController: NetworkingLinkVerificationViewControllerDelegate { + + func networkingLinkVerificationViewController( + _ viewController: NetworkingLinkVerificationViewController, + didRequestNextPane nextPane: FinancialConnectionsSessionManifest.NextPane, + consumerSession: ConsumerSessionData? + ) { + if let consumerSession = consumerSession { + dataManager.consumerSession = consumerSession + } + pushPane(nextPane, animated: true) + } + + func networkingLinkVerificationViewController( + _ viewController: NetworkingLinkVerificationViewController, + didReceiveTerminalError error: Error + ) { + showTerminalError(error) + } +} + +// MARK: - LinkAccountPickerViewControllerDelegate + +extension NativeFlowController: LinkAccountPickerViewControllerDelegate { + + func linkAccountPickerViewController( + _ viewController: LinkAccountPickerViewController, + didSelectAccount selectedAccount: FinancialConnectionsPartnerAccount + ) { + dataManager.linkedAccounts = [selectedAccount] + } + + func linkAccountPickerViewController( + _ viewController: LinkAccountPickerViewController, + didRequestSuccessPaneWithInstitution institution: FinancialConnectionsInstitution + ) { + assert(dataManager.linkedAccounts?.count == 1, "expected a selected account to be set") + dataManager.institution = institution + pushPane(.success, animated: true) + } + + func linkAccountPickerViewController( + _ viewController: LinkAccountPickerViewController, + requestedPartnerAuthWithInstitution institution: FinancialConnectionsInstitution + ) { + dataManager.institution = institution + pushPane(.partnerAuth, animated: true) + } + + func linkAccountPickerViewController( + _ viewController: LinkAccountPickerViewController, + didRequestNextPane nextPane: FinancialConnectionsSessionManifest.NextPane + ) { + pushPane(nextPane, animated: true) + } + + func linkAccountPickerViewController( + _ viewController: LinkAccountPickerViewController, + didReceiveTerminalError error: Error + ) { + showTerminalError(error) + } +} + +// MARK: - NetworkingSaveToLinkVerificationDelegate + +extension NativeFlowController: NetworkingSaveToLinkVerificationViewControllerDelegate { + func networkingSaveToLinkVerificationViewControllerDidFinish( + _ viewController: NetworkingSaveToLinkVerificationViewController, + saveToLinkWithStripeSucceeded: Bool?, + error: Error? + ) { + if saveToLinkWithStripeSucceeded != nil { + dataManager.saveToLinkWithStripeSucceeded = saveToLinkWithStripeSucceeded + } + pushPane(.success, animated: true) + } + + func networkingSaveToLinkVerificationViewController( + _ viewController: NetworkingSaveToLinkVerificationViewController, + didReceiveTerminalError error: Error + ) { + showTerminalError(error) + } +} + +// MARK: - NetworkingLinkStepUpVerificationViewControllerDelegate + +extension NativeFlowController: NetworkingLinkStepUpVerificationViewControllerDelegate { + + func networkingLinkStepUpVerificationViewController( + _ viewController: NetworkingLinkStepUpVerificationViewController, + didCompleteVerificationWithInstitution institution: FinancialConnectionsInstitution + ) { + dataManager.institution = institution + pushPane(.success, animated: true) + } + + func networkingLinkStepUpVerificationViewController( + _ viewController: NetworkingLinkStepUpVerificationViewController, + didReceiveTerminalError error: Error + ) { + showTerminalError(error) + } + + func networkingLinkStepUpVerificationViewControllerEncounteredSoftError( + _ viewController: NetworkingLinkStepUpVerificationViewController + ) { + pushPane(.institutionPicker, animated: true) + } +} + +// MARK: - Static Helpers + +private func CreatePaneViewController( + pane: FinancialConnectionsSessionManifest.NextPane, + nativeFlowController: NativeFlowController, + dataManager: NativeFlowDataManager +) -> UIViewController? { + let viewController: UIViewController? + switch pane { + case .accountPicker: + if let authSession = dataManager.authSession, let institution = dataManager.institution { + let accountPickerDataSource = AccountPickerDataSourceImplementation( + apiClient: dataManager.apiClient, + clientSecret: dataManager.clientSecret, + authSession: authSession, + manifest: dataManager.manifest, + institution: institution, + analyticsClient: dataManager.analyticsClient, + reduceManualEntryProminenceInErrors: dataManager.reduceManualEntryProminenceInErrors + ) + let accountPickerViewController = AccountPickerViewController(dataSource: accountPickerDataSource) + accountPickerViewController.delegate = nativeFlowController + viewController = accountPickerViewController + } else { + assertionFailure("Code logic error. Missing parameters for \(pane).") + viewController = nil + } + case .attachLinkedPaymentAccount: + if let institution = dataManager.institution, + let linkedAccountId = dataManager.linkedAccounts?.first?.linkedAccountId + { + let dataSource = AttachLinkedPaymentAccountDataSourceImplementation( + apiClient: dataManager.apiClient, + clientSecret: dataManager.clientSecret, + manifest: dataManager.manifest, + institution: institution, + linkedAccountId: linkedAccountId, + analyticsClient: dataManager.analyticsClient, + authSessionId: dataManager.authSession?.id, + consumerSessionClientSecret: dataManager.consumerSession?.clientSecret, + reduceManualEntryProminenceInErrors: dataManager.reduceManualEntryProminenceInErrors + ) + let attachedLinkedPaymentAccountViewController = AttachLinkedPaymentAccountViewController( + dataSource: dataSource + ) + attachedLinkedPaymentAccountViewController.delegate = nativeFlowController + viewController = attachedLinkedPaymentAccountViewController + } else { + assertionFailure("Code logic error. Missing parameters for \(pane).") + viewController = nil + } + case .bankAuthRepair: + assertionFailure("Not supported") + viewController = nil + case .consent: + let consentDataSource = ConsentDataSourceImplementation( + manifest: dataManager.manifest, + consent: dataManager.consentPaneModel, + merchantLogo: dataManager.merchantLogo, + apiClient: dataManager.apiClient, + clientSecret: dataManager.clientSecret, + analyticsClient: dataManager.analyticsClient + ) + let consentViewController = ConsentViewController(dataSource: consentDataSource) + consentViewController.delegate = nativeFlowController + viewController = consentViewController + case .institutionPicker: + let dataSource = InstitutionAPIDataSource( + manifest: dataManager.manifest, + apiClient: dataManager.apiClient, + clientSecret: dataManager.clientSecret, + analyticsClient: dataManager.analyticsClient + ) + let picker = InstitutionPickerViewController(dataSource: dataSource) + picker.delegate = nativeFlowController + viewController = picker + case .linkAccountPicker: + if let consumerSession = dataManager.consumerSession { + let linkAccountPickerDataSource = LinkAccountPickerDataSourceImplementation( + manifest: dataManager.manifest, + apiClient: dataManager.apiClient, + analyticsClient: dataManager.analyticsClient, + clientSecret: dataManager.clientSecret, + consumerSession: consumerSession + ) + let linkAccountPickerViewController = LinkAccountPickerViewController( + dataSource: linkAccountPickerDataSource + ) + linkAccountPickerViewController.delegate = nativeFlowController + viewController = linkAccountPickerViewController + } else { + assertionFailure("Code logic error. Missing parameters for \(pane).") + viewController = nil + } + case .linkConsent: + assertionFailure("Not supported") + viewController = nil + case .linkLogin: + assertionFailure("Not supported") + viewController = nil + case .manualEntry: + let dataSource = ManualEntryDataSourceImplementation( + apiClient: dataManager.apiClient, + clientSecret: dataManager.clientSecret, + manifest: dataManager.manifest, + analyticsClient: dataManager.analyticsClient + ) + let manualEntryViewController = ManualEntryViewController(dataSource: dataSource) + manualEntryViewController.delegate = nativeFlowController + viewController = manualEntryViewController + case .manualEntrySuccess: + if let paymentAccountResource = dataManager.paymentAccountResource, + let accountNumberLast4 = dataManager.accountNumberLast4 + { + let manualEntrySuccessViewController = ManualEntrySuccessViewController( + microdepositVerificationMethod: paymentAccountResource.microdepositVerificationMethod, + accountNumberLast4: accountNumberLast4, + analyticsClient: dataManager.analyticsClient + ) + manualEntrySuccessViewController.delegate = nativeFlowController + viewController = manualEntrySuccessViewController + } else { + assertionFailure("Code logic error. Missing parameters for \(pane).") + viewController = nil + } + case .networkingLinkSignupPane: + if let linkedAccountIds = dataManager.linkedAccounts?.map({ $0.id }) { + let networkingLinkSignupDataSource = NetworkingLinkSignupDataSourceImplementation( + manifest: dataManager.manifest, + selectedAccountIds: linkedAccountIds, + returnURL: dataManager.returnURL, + apiClient: dataManager.apiClient, + clientSecret: dataManager.clientSecret, + analyticsClient: dataManager.analyticsClient + ) + let networkingLinkSignupViewController = NetworkingLinkSignupViewController( + dataSource: networkingLinkSignupDataSource + ) + networkingLinkSignupViewController.delegate = nativeFlowController + viewController = networkingLinkSignupViewController + } else { + assertionFailure("Code logic error. Missing parameters for \(pane).") + viewController = nil + } + case .networkingLinkVerification: + if let accountholderCustomerEmailAddress = dataManager.manifest.accountholderCustomerEmailAddress { + let networkingLinkVerificationDataSource = NetworkingLinkVerificationDataSourceImplementation( + accountholderCustomerEmailAddress: accountholderCustomerEmailAddress, + manifest: dataManager.manifest, + apiClient: dataManager.apiClient, + clientSecret: dataManager.clientSecret, + analyticsClient: dataManager.analyticsClient + ) + let networkingLinkVerificationViewController = NetworkingLinkVerificationViewController(dataSource: networkingLinkVerificationDataSource) + networkingLinkVerificationViewController.delegate = nativeFlowController + viewController = networkingLinkVerificationViewController + } else { + assertionFailure("Code logic error. Missing parameters for \(pane).") + viewController = nil + } + case .networkingSaveToLinkVerification: + if + let consumerSession = dataManager.consumerSession, + let selectedAccountId = dataManager.linkedAccounts?.map({ $0.id }).first + { + let networkingSaveToLinkVerificationDataSource = NetworkingSaveToLinkVerificationDataSourceImplementation( + consumerSession: consumerSession, + selectedAccountId: selectedAccountId, + apiClient: dataManager.apiClient, + clientSecret: dataManager.clientSecret, + analyticsClient: dataManager.analyticsClient + ) + let networkingSaveToLinkVerificationViewController = NetworkingSaveToLinkVerificationViewController( + dataSource: networkingSaveToLinkVerificationDataSource + ) + networkingSaveToLinkVerificationViewController.delegate = nativeFlowController + viewController = networkingSaveToLinkVerificationViewController + } else { + assertionFailure("Code logic error. Missing parameters for \(pane).") + viewController = nil + } + case .networkingLinkStepUpVerification: + if + let consumerSession = dataManager.consumerSession, + let selectedAccountId = dataManager.linkedAccounts?.map({ $0.id }).first + { + let networkingLinkStepUpVerificationDataSource = NetworkingLinkStepUpVerificationDataSourceImplementation( + consumerSession: consumerSession, + selectedAccountId: selectedAccountId, + manifest: dataManager.manifest, + apiClient: dataManager.apiClient, + clientSecret: dataManager.clientSecret, + analyticsClient: dataManager.analyticsClient + ) + let networkingLinkStepUpVerificationViewController = NetworkingLinkStepUpVerificationViewController( + dataSource: networkingLinkStepUpVerificationDataSource + ) + networkingLinkStepUpVerificationViewController.delegate = nativeFlowController + viewController = networkingLinkStepUpVerificationViewController + } else { + assertionFailure("Code logic error. Missing parameters for \(pane).") + viewController = nil + } + case .partnerAuth: + if let institution = dataManager.institution { + let partnerAuthDataSource = PartnerAuthDataSourceImplementation( + institution: institution, + manifest: dataManager.manifest, + returnURL: dataManager.returnURL, + apiClient: dataManager.apiClient, + clientSecret: dataManager.clientSecret, + analyticsClient: dataManager.analyticsClient, + reduceManualEntryProminenceInErrors: dataManager.reduceManualEntryProminenceInErrors + ) + let partnerAuthViewController = PartnerAuthViewController(dataSource: partnerAuthDataSource) + partnerAuthViewController.delegate = nativeFlowController + viewController = partnerAuthViewController + } else { + assertionFailure("Code logic error. Missing parameters for \(pane).") + viewController = nil + } + case .success: + if let linkedAccounts = dataManager.linkedAccounts, let institution = dataManager.institution { + let successDataSource = SuccessDataSourceImplementation( + manifest: dataManager.manifest, + linkedAccounts: linkedAccounts, + institution: institution, + saveToLinkWithStripeSucceeded: dataManager.saveToLinkWithStripeSucceeded, + apiClient: dataManager.apiClient, + clientSecret: dataManager.clientSecret, + analyticsClient: dataManager.analyticsClient + ) + let successViewController = SuccessViewController(dataSource: successDataSource) + successViewController.delegate = nativeFlowController + viewController = successViewController + } else { + assertionFailure("Code logic error. Missing parameters for \(pane).") + viewController = nil + } + case .unexpectedError: + viewController = nil + case .authOptions: + assertionFailure("Not supported") + viewController = nil + case .networkingLinkLoginWarmup: + let networkingLinkWarmupDataSource = NetworkingLinkLoginWarmupDataSourceImplementation( + manifest: dataManager.manifest, + apiClient: dataManager.apiClient, + clientSecret: dataManager.clientSecret, + analyticsClient: dataManager.analyticsClient + ) + let networkingLinkWarmupViewController = NetworkingLinkLoginWarmupViewController( + dataSource: networkingLinkWarmupDataSource + ) + networkingLinkWarmupViewController.delegate = nativeFlowController + viewController = networkingLinkWarmupViewController + + // client-side only panes below + case .resetFlow: + let resetFlowDataSource = ResetFlowDataSourceImplementation( + apiClient: dataManager.apiClient, + clientSecret: dataManager.clientSecret, + analyticsClient: dataManager.analyticsClient + ) + let resetFlowViewController = ResetFlowViewController( + dataSource: resetFlowDataSource + ) + resetFlowViewController.delegate = nativeFlowController + viewController = resetFlowViewController + case .terminalError: + if let terminalError = dataManager.terminalError { + let terminalErrorViewController = TerminalErrorViewController( + error: terminalError, + allowManualEntry: dataManager.manifest.allowManualEntry + ) + terminalErrorViewController.delegate = nativeFlowController + viewController = terminalErrorViewController + } else { + assertionFailure("Code logic error. Missing parameters for \(pane).") + viewController = nil + } + case .unparsable: + viewController = nil + } + + if let viewController = viewController { + // this assert should ensure that it's nearly impossible to miss + // adding new cases to `paneFromViewController` + assert( + FinancialConnectionsAnalyticsClient.paneFromViewController(viewController) == pane, + "Found a new view controller (\(viewController.self)) that needs to be added to `paneFromViewController`." + ) + + // this logging isn't perfect because one could call `CreatePaneViewController` + // and never use the view controller, but that is not the case today + // and it is difficult to imagine when that would be the case in the future + dataManager + .analyticsClient + .log( + eventName: "pane.launched", + parameters: { + var parameters: [String: Any] = [:] + parameters["referrer_pane"] = dataManager.lastPaneLaunched?.rawValue + return parameters + }(), + pane: pane + ) + dataManager.lastPaneLaunched = pane + } else { + dataManager + .analyticsClient + .logUnexpectedError( + FinancialConnectionsSheetError.unknown( + debugDescription: "Pane Not Found: either app state is invalid, or an unsupported pane was requested." + ), + errorName: "PaneNotFound", + pane: pane + ) + } + + return viewController +} + +private func ShouldHideStripeLogoInNavigationBar( + forViewController viewController: UIViewController, + reducedBranding: Bool, + merchantLogo: [String]? +) -> Bool { + if viewController is ConsentViewController { + let willShowMerchantLogoInConsentScreen = (merchantLogo != nil) + if willShowMerchantLogoInConsentScreen { + // if we are going to show merchant logo in consent screen, + // do not show the logo in the navigation bar + return true + } else { + return reducedBranding + } + } else { + return reducedBranding + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NativeFlowDataManager.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NativeFlowDataManager.swift new file mode 100644 index 00000000..e44d3303 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NativeFlowDataManager.swift @@ -0,0 +1,136 @@ +// +// NativeFlowDataManager.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 6/7/22. +// + +import Foundation +@_spi(STP) import StripeCore + +protocol NativeFlowDataManager: AnyObject { + var manifest: FinancialConnectionsSessionManifest { get set } + var reducedBranding: Bool { get } + var merchantLogo: [String]? { get } + var returnURL: String? { get } + var consentPaneModel: FinancialConnectionsConsent { get } + var apiClient: FinancialConnectionsAPIClient { get } + var clientSecret: String { get } + var analyticsClient: FinancialConnectionsAnalyticsClient { get } + var reduceManualEntryProminenceInErrors: Bool { get } + + var institution: FinancialConnectionsInstitution? { get set } + var authSession: FinancialConnectionsAuthSession? { get set } + var linkedAccounts: [FinancialConnectionsPartnerAccount]? { get set } + var terminalError: Error? { get set } + var paymentAccountResource: FinancialConnectionsPaymentAccountResource? { get set } + var accountNumberLast4: String? { get set } + var consumerSession: ConsumerSessionData? { get set } + var saveToLinkWithStripeSucceeded: Bool? { get set } + var lastPaneLaunched: FinancialConnectionsSessionManifest.NextPane? { get set } + + func resetState(withNewManifest newManifest: FinancialConnectionsSessionManifest) + func completeFinancialConnectionsSession(terminalError: String?) -> Future +} + +class NativeFlowAPIDataManager: NativeFlowDataManager { + + private lazy var consentCombinedLogoExperiment: ExperimentHelper = { + return ExperimentHelper( + experimentName: "connections_consent_combined_logo", + manifest: manifest, + analyticsClient: analyticsClient + ) + }() + var manifest: FinancialConnectionsSessionManifest { + didSet { + didUpdateManifest() + } + } + // don't expose `visualUpdate` because we don't want anyone to directly + // access `visualUpdate.merchantLogo`; we have custom logic for `merchantLogo` + private let visualUpdate: FinancialConnectionsSynchronize.VisualUpdate + var reducedBranding: Bool { + return visualUpdate.reducedBranding + } + var merchantLogo: [String]? { + if consentCombinedLogoExperiment.isEnabled(logExposure: true) { + let merchantLogo = visualUpdate.merchantLogo + if merchantLogo.isEmpty || merchantLogo.count == 2 || merchantLogo.count == 3 { + // show merchant logo inside of consent pane + return visualUpdate.merchantLogo + } else { + // if `merchantLogo.count > 3`, that is an invalid case + // + // we want to log experiment exposure regardless because + // if experiment is not working fine (ex. returns 1 or 4 logos) + // then the "cost" of those bugs should show up in the `treatment` data + return nil + } + } else { + // show the "control" experience of showing logo in the nav bar + return nil + } + } + var reduceManualEntryProminenceInErrors: Bool { + return visualUpdate.reduceManualEntryProminenceInErrors + } + let returnURL: String? + let consentPaneModel: FinancialConnectionsConsent + let apiClient: FinancialConnectionsAPIClient + let clientSecret: String + let analyticsClient: FinancialConnectionsAnalyticsClient + + var institution: FinancialConnectionsInstitution? + var authSession: FinancialConnectionsAuthSession? + var linkedAccounts: [FinancialConnectionsPartnerAccount]? + var terminalError: Error? + var paymentAccountResource: FinancialConnectionsPaymentAccountResource? + var accountNumberLast4: String? + var consumerSession: ConsumerSessionData? + var saveToLinkWithStripeSucceeded: Bool? + var lastPaneLaunched: FinancialConnectionsSessionManifest.NextPane? + + init( + manifest: FinancialConnectionsSessionManifest, + visualUpdate: FinancialConnectionsSynchronize.VisualUpdate, + returnURL: String?, + consentPaneModel: FinancialConnectionsConsent, + apiClient: FinancialConnectionsAPIClient, + clientSecret: String, + analyticsClient: FinancialConnectionsAnalyticsClient + ) { + self.manifest = manifest + self.visualUpdate = visualUpdate + self.returnURL = returnURL + self.consentPaneModel = consentPaneModel + self.apiClient = apiClient + self.clientSecret = clientSecret + self.analyticsClient = analyticsClient + // Use server provided active AuthSession. + self.authSession = manifest.activeAuthSession + // If the server returns active institution use that, otherwise resort to initial institution. + self.institution = manifest.activeInstitution ?? manifest.initialInstitution + didUpdateManifest() + } + + func completeFinancialConnectionsSession(terminalError: String?) -> Future { + return apiClient.completeFinancialConnectionsSession( + clientSecret: clientSecret, + terminalError: terminalError + ) + } + + func resetState(withNewManifest newManifest: FinancialConnectionsSessionManifest) { + authSession = nil + institution = nil + paymentAccountResource = nil + accountNumberLast4 = nil + linkedAccounts = nil + manifest = newManifest + } + + private func didUpdateManifest() { + analyticsClient.setAdditionalParameters(fromManifest: manifest) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkLoginWarmup/NetworkingLinkLoginWarmupBodyView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkLoginWarmup/NetworkingLinkLoginWarmupBodyView.swift new file mode 100644 index 00000000..c3c1b557 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkLoginWarmup/NetworkingLinkLoginWarmupBodyView.swift @@ -0,0 +1,175 @@ +// +// NetworkingLinkLoginWarmupBodyView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/6/23. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +final class NetworkingLinkLoginWarmupBodyView: HitTestView { + + private let didSelectContinue: () -> Void + + init( + email: String, + didSelectContinue: @escaping (() -> Void), + didSelectSkip: @escaping (() -> Void) + ) { + self.didSelectContinue = didSelectContinue + super.init(frame: .zero) + let verticalStackView = HitTestStackView( + arrangedSubviews: [ + CreateContinueButton( + email: email, + didSelectContinue: didSelectContinue, + target: self + ), + CreateSkipButton(didSelectSkip: didSelectSkip), + ] + ) + verticalStackView.axis = .vertical + verticalStackView.spacing = 20 + addAndPinSubview(verticalStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc fileprivate func didSelectContinueButton() { + didSelectContinue() + } +} + +private func CreateContinueButton( + email: String, + didSelectContinue: @escaping () -> Void, + target: UIView +) -> UIView { + let horizontalStack = UIStackView( + arrangedSubviews: [ + CreateContinueButtonLabelView(email: email), + CreateArrowIconView(), + ] + ) + horizontalStack.axis = .horizontal + horizontalStack.alignment = .center + horizontalStack.spacing = 12 + horizontalStack.isLayoutMarginsRelativeArrangement = true + horizontalStack.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: 12, + leading: 16, + bottom: 12, + trailing: 16 + ) + horizontalStack.layer.borderColor = UIColor.borderNeutral.cgColor + horizontalStack.layer.borderWidth = 1 + horizontalStack.layer.cornerRadius = 4 + + let tapGestureRecognizer = UITapGestureRecognizer( + target: target, + action: #selector(NetworkingLinkLoginWarmupBodyView.didSelectContinueButton) + ) + horizontalStack.addGestureRecognizer(tapGestureRecognizer) + + return horizontalStack +} + +private func CreateContinueButtonLabelView(email: String) -> UIView { + let continueLabel = AttributedLabel( + font: .label(.small), + textColor: .textSecondary + ) + continueLabel.text = STPLocalizedString( + "Continue as", + "Leading text that comes before an e-mail. For example, it might say 'Continue as username@gmail.com'. This text will be combined together to form a button which, when pressed, will automatically log-in the user with their e-mail." + ) + + let emailLabel = AttributedLabel( + font: .label(.largeEmphasized), + textColor: .textPrimary + ) + emailLabel.text = email + + let verticalStackView = UIStackView( + arrangedSubviews: [ + continueLabel, + emailLabel, + ] + ) + verticalStackView.axis = .vertical + verticalStackView.spacing = 0 + return verticalStackView +} + +private func CreateArrowIconView() -> UIView { + let imageView = UIImageView(image: Image.arrow_right.makeImage(template: true)) + imageView.tintColor = .textBrand + imageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: 16), + imageView.heightAnchor.constraint(equalToConstant: 16), + ]) + return imageView +} + +private func CreateSkipButton( + didSelectSkip: @escaping () -> Void +) -> UIView { + let leadingText = STPLocalizedString( + "Not you?", + "Leading text that comes before a button. For example, it will say 'Not you? Continue without signing in'. Pressing 'Continue without signing in' will allow the user to continue through the Bank Authentication Flow." + ) + let continueButtonText = STPLocalizedString( + "Continue without signing in", + "Text for a butoon. Pressing it will allow the user to continue through the Bank Authentication Flow." + ) + let skipLabel = AttributedTextView( + font: .label(.medium), + boldFont: .label(.mediumEmphasized), + linkFont: .label(.mediumEmphasized), + textColor: .textSecondary + ) + skipLabel.setText( + "\(leadingText) [\(continueButtonText)](stripe://no-url-action-handler-will-be-used)", + action: { _ in + didSelectSkip() + } + ) + return skipLabel +} + +#if DEBUG + +import SwiftUI + +private struct NetworkingLinkLoginWarmupBodyViewUIViewRepresentable: UIViewRepresentable { + + func makeUIView(context: Context) -> NetworkingLinkLoginWarmupBodyView { + NetworkingLinkLoginWarmupBodyView( + email: "test@stripe.com", + didSelectContinue: {}, + didSelectSkip: {} + ) + } + + func updateUIView(_ uiView: NetworkingLinkLoginWarmupBodyView, context: Context) {} +} + +struct NetworkingLinkLoginWarmupBodyView_Previews: PreviewProvider { + static var previews: some View { + VStack(alignment: .leading) { + Spacer() + NetworkingLinkLoginWarmupBodyViewUIViewRepresentable() + .frame(maxHeight: 200) + .padding() + Spacer() + } + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkLoginWarmup/NetworkingLinkLoginWarmupDataSource.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkLoginWarmup/NetworkingLinkLoginWarmupDataSource.swift new file mode 100644 index 00000000..d5d714f6 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkLoginWarmup/NetworkingLinkLoginWarmupDataSource.swift @@ -0,0 +1,43 @@ +// +// NetworkingLinkLoginWarmupDataSource.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/6/23. +// + +import Foundation +@_spi(STP) import StripeCore + +protocol NetworkingLinkLoginWarmupDataSource: AnyObject { + var manifest: FinancialConnectionsSessionManifest { get } + var analyticsClient: FinancialConnectionsAnalyticsClient { get } + + func disableNetworking() -> Future +} + +final class NetworkingLinkLoginWarmupDataSourceImplementation: NetworkingLinkLoginWarmupDataSource { + + let manifest: FinancialConnectionsSessionManifest + private let apiClient: FinancialConnectionsAPIClient + private let clientSecret: String + let analyticsClient: FinancialConnectionsAnalyticsClient + + init( + manifest: FinancialConnectionsSessionManifest, + apiClient: FinancialConnectionsAPIClient, + clientSecret: String, + analyticsClient: FinancialConnectionsAnalyticsClient + ) { + self.manifest = manifest + self.apiClient = apiClient + self.clientSecret = clientSecret + self.analyticsClient = analyticsClient + } + + func disableNetworking() -> Future { + return apiClient.disableNetworking( + disabledReason: nil, + clientSecret: clientSecret + ) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkLoginWarmup/NetworkingLinkLoginWarmupViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkLoginWarmup/NetworkingLinkLoginWarmupViewController.swift new file mode 100644 index 00000000..7d96647d --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkLoginWarmup/NetworkingLinkLoginWarmupViewController.swift @@ -0,0 +1,94 @@ +// +// NetworkingLinkLoginWarmupViewController.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/6/23. +// + +import Foundation +@_spi(STP) import StripeCore +import UIKit + +protocol NetworkingLinkLoginWarmupViewControllerDelegate: AnyObject { + func networkingLinkLoginWarmupViewControllerDidSelectContinue( + _ viewController: NetworkingLinkLoginWarmupViewController + ) + func networkingLinkLoginWarmupViewController( + _ viewController: NetworkingLinkLoginWarmupViewController, + didSelectSkipWithManifest manifest: FinancialConnectionsSessionManifest + ) + func networkingLinkLoginWarmupViewController(_ viewController: NetworkingLinkLoginWarmupViewController, didReceiveTerminalError error: Error) +} + +final class NetworkingLinkLoginWarmupViewController: UIViewController { + + private let dataSource: NetworkingLinkLoginWarmupDataSource + weak var delegate: NetworkingLinkLoginWarmupViewControllerDelegate? + + init(dataSource: NetworkingLinkLoginWarmupDataSource) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .customBackgroundColor + + let pane = PaneWithHeaderLayoutView( + title: STPLocalizedString( + "Sign in to Link", + "The title of a screen where users are informed that they can sign-in-to Link." + ), + subtitle: STPLocalizedString( + "It looks like you have a Link account. Signing in will let you quickly access your saved bank accounts.", + "The subtitle/description of a screen where users are informed that they can sign-in-to Link." + ), + contentView: NetworkingLinkLoginWarmupBodyView( + // `accountholderCustomerEmailAddress` should always be non-null, and + // since the email is only used as a visual, it's not worth to throw an error + // if it is null + email: dataSource.manifest.accountholderCustomerEmailAddress ?? "you", + didSelectContinue: { [weak self] in + self?.didSelectContinue() + }, + didSelectSkip: { [weak self] in + self?.didSelectSkip() + } + ), + footerView: nil + ) + pane.addTo(view: view) + } + + private func didSelectContinue() { + dataSource.analyticsClient.log( + eventName: "click.continue", + pane: .networkingLinkLoginWarmup + ) + delegate?.networkingLinkLoginWarmupViewControllerDidSelectContinue(self) + } + + private func didSelectSkip() { + dataSource.analyticsClient.log( + eventName: "click.skip_sign_in", + pane: .networkingLinkLoginWarmup + ) + dataSource.disableNetworking() + .observe { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let manifest): + self.delegate?.networkingLinkLoginWarmupViewController( + self, + didSelectSkipWithManifest: manifest + ) + case .failure(let error): + self.delegate?.networkingLinkLoginWarmupViewController(self, didReceiveTerminalError: error) + } + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkSignupPane/LinkEmailElement.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkSignupPane/LinkEmailElement.swift new file mode 100644 index 00000000..3d1cd032 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkSignupPane/LinkEmailElement.swift @@ -0,0 +1,94 @@ +// +// LinkEmailElement.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/21/23. +// + +@_spi(STP) import StripeUICore +import UIKit + +class LinkEmailElement: Element { + weak var delegate: ElementDelegate? + + let emailAddressElement: TextFieldElement + + private let activityIndicator: ActivityIndicator = { + let activityIndicator = ActivityIndicator(size: .medium) + activityIndicator.setContentCompressionResistancePriority(.required, for: .horizontal) + return activityIndicator + }() + + private lazy var stackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [emailAddressElement.view, activityIndicator]) + stackView.spacing = 0 + stackView.axis = .horizontal + stackView.alignment = .center + stackView.isLayoutMarginsRelativeArrangement = true + stackView.directionalLayoutMargins = .insets( + top: 0, + leading: 0, + bottom: 0, + trailing: ElementsUI.contentViewInsets.trailing + ) + return stackView + }() + + var view: UIView { + return stackView + } + + public var emailAddressString: String? { + return emailAddressElement.text + } + + public var validationState: ElementValidationState { + return emailAddressElement.validationState + } + + public var indicatorTintColor: UIColor { + get { + return activityIndicator.color + } + + set { + activityIndicator.color = newValue + } + } + + public func startAnimating() { + UIView.performWithoutAnimation { + activityIndicator.startAnimating() + stackView.setNeedsLayout() + stackView.layoutSubviews() + } + } + + public func stopAnimating() { + UIView.performWithoutAnimation { + activityIndicator.stopAnimating() + stackView.setNeedsLayout() + stackView.layoutSubviews() + } + } + + public init(defaultValue: String? = nil, theme: ElementsUITheme = .default) { + emailAddressElement = TextFieldElement.makeEmail(defaultValue: defaultValue, theme: theme) + emailAddressElement.delegate = self + } + + @discardableResult + func beginEditing() -> Bool { + return emailAddressElement.beginEditing() + } +} + +extension LinkEmailElement: ElementDelegate { + func didUpdate(element: Element) { + delegate?.didUpdate(element: self) + } + + func continueToNextField(element: Element) { + delegate?.continueToNextField(element: self) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkSignupPane/NetworkingLinkSignupBodyFormView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkSignupPane/NetworkingLinkSignupBodyFormView.swift new file mode 100644 index 00000000..5ad4ef85 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkSignupPane/NetworkingLinkSignupBodyFormView.swift @@ -0,0 +1,195 @@ +// +// NetworkingLinkSignupBodyFormView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 1/24/23. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol NetworkingLinkSignupBodyFormViewDelegate: AnyObject { + func networkingLinkSignupBodyFormView( + _ view: NetworkingLinkSignupBodyFormView, + didEnterValidEmailAddress emailAddress: String + ) + func networkingLinkSignupBodyFormViewDidUpdateFields( + _ view: NetworkingLinkSignupBodyFormView + ) +} + +final class NetworkingLinkSignupBodyFormView: UIView { + + private let accountholderPhoneNumber: String? + weak var delegate: NetworkingLinkSignupBodyFormViewDelegate? + + private lazy var formElement = FormElement( + elements: [ + emailSection, + phoneNumberSection, + ], + theme: theme + ) + private lazy var emailSection = SectionElement(elements: [emailElement], theme: theme) + private (set) lazy var emailElement: LinkEmailElement = { + let emailElement = LinkEmailElement(theme: theme) + emailElement.indicatorTintColor = .textPrimary + emailElement.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + emailElement.view.heightAnchor.constraint(greaterThanOrEqualToConstant: 56) + ]) + return emailElement + }() + private lazy var phoneNumberSection = SectionElement( + elements: [phoneNumberElement], + theme: theme + ) + private(set) lazy var phoneNumberElement: PhoneNumberElement = { + let phoneNumberElement = PhoneNumberElement( + // TODO(kgaidis): Stripe.js selects country via Stripe.js library + defaultCountryCode: nil, // the component automatically selects this based off locale + defaultPhoneNumber: accountholderPhoneNumber, + theme: theme + ) + phoneNumberElement.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + phoneNumberElement.view.heightAnchor.constraint(greaterThanOrEqualToConstant: 56) + ]) + return phoneNumberElement + }() + private lazy var theme: ElementsUITheme = { + var theme: ElementsUITheme = .default + theme.borderWidth = 1 + theme.cornerRadius = 8 + theme.shadow = nil + theme.fonts = { + var fonts = ElementsUITheme.Font() + fonts.subheadline = FinancialConnectionsFont.label(.large).uiFont + return fonts + }() + theme.colors = { + var colors = ElementsUITheme.Color() + colors.border = .borderNeutral + colors.danger = .textCritical + colors.placeholderText = .textSecondary + colors.textFieldText = .textPrimary + colors.parentBackground = .customBackgroundColor + colors.background = .customBackgroundColor + return colors + }() + return theme + }() + private var debounceEmailTimer: Timer? + private var lastValidEmail: String? + + init(accountholderPhoneNumber: String?) { + self.accountholderPhoneNumber = accountholderPhoneNumber + super.init(frame: .zero) + addAndPinSubview(formElement.view) + formElement.delegate = self + phoneNumberSection.view.isHidden = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // returns `true` if the phone number field was shown for the first time + func showPhoneNumberFieldIfNeeded() -> Bool { + let isPhoneNumberFieldHidden = phoneNumberSection.view.isHidden + guard isPhoneNumberFieldHidden else { + return false // phone number field is already shown + } + formElement.setElements( + [emailSection, phoneNumberSection], + hidden: false, + animated: true + ) + return true // phone number is shown for the first time + } + + func prefillEmailAddress(_ emailAddress: String?) { + guard let emailAddress = emailAddress, !emailAddress.isEmpty else { + return + } + emailElement.emailAddressElement.setText(emailAddress) + } + + func endEditingEmailAddressField() { + emailElement.view.endEditing(true) + } + + func beginEditingPhoneNumberField() { + _ = phoneNumberElement.beginEditing() + } +} + +extension NetworkingLinkSignupBodyFormView: ElementDelegate { + func didUpdate(element: StripeUICore.Element) { + delegate?.networkingLinkSignupBodyFormViewDidUpdateFields(self) + + switch emailElement.validationState { + case .valid: + if let emailAddress = emailElement.emailAddressString { + debounceEmailTimer?.invalidate() + debounceEmailTimer = Timer.scheduledTimer( + // TODO(kgaidis): discuss this logic w/ team; Stripe.js is constant 0.3 + // + // a valid e-mail will transition the user to the phone number + // field (sometimes prematurely), so we increase debounce if + // if there's a high chance the e-mail is not yet finished + // being typed (high chance of not finishing == not .com suffix) + withTimeInterval: emailAddress.hasSuffix(".com") ? 0.3 : 1.0, + repeats: false + ) { [weak self] _ in + guard let self = self else { return } + if + self.emailElement.validationState.isValid, + // `lastValidEmail` ensures that we only + // fire the delegate ONCE per unique valid email + emailAddress != self.lastValidEmail + { + self.lastValidEmail = emailAddress + self.delegate?.networkingLinkSignupBodyFormView( + self, + didEnterValidEmailAddress: emailAddress + ) + } + } + } + case .invalid: + // errors are displayed automatically by the component + lastValidEmail = nil + } + } + + func continueToNextField(element: StripeUICore.Element) {} +} + +#if DEBUG + +import SwiftUI + +private struct NetworkingLinkSignupBodyFormViewUIViewRepresentable: UIViewRepresentable { + + func makeUIView(context: Context) -> NetworkingLinkSignupBodyFormView { + NetworkingLinkSignupBodyFormView(accountholderPhoneNumber: nil) + } + + func updateUIView(_ uiView: NetworkingLinkSignupBodyFormView, context: Context) {} +} + +struct NetworkingLinkSignupBodyFormView_Previews: PreviewProvider { + static var previews: some View { + VStack(alignment: .leading) { + NetworkingLinkSignupBodyFormViewUIViewRepresentable() + .frame(maxHeight: 200) + .padding() + Spacer() + } + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkSignupPane/NetworkingLinkSignupBodyView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkSignupPane/NetworkingLinkSignupBodyView.swift new file mode 100644 index 00000000..11aac3ee --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkSignupPane/NetworkingLinkSignupBodyView.swift @@ -0,0 +1,150 @@ +// +// NetworkingLinkSignupContentView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 1/24/23. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +final class NetworkingLinkSignupBodyView: UIView { + + init( + bulletPoints: [FinancialConnectionsBulletPoint], + formView: UIView, + didSelectURL: @escaping (URL) -> Void + ) { + super.init(frame: .zero) + let verticalStackView = UIStackView( + arrangedSubviews: [ + CreateMultipleBulletPointView( + bulletPoints: bulletPoints, + didSelectURL: didSelectURL + ), + formView, + ] + ) + verticalStackView.axis = .vertical + verticalStackView.spacing = 24 + addAndPinSubview(verticalStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private func CreateMultipleBulletPointView( + bulletPoints: [FinancialConnectionsBulletPoint], + didSelectURL: @escaping (URL) -> Void +) -> UIView { + let verticalStackView = HitTestStackView() + verticalStackView.axis = .vertical + verticalStackView.spacing = 12 + bulletPoints.forEach { bulletPoint in + let bulletPointView = CreateBulletPointView( + title: bulletPoint.title, + content: bulletPoint.content, + iconUrl: bulletPoint.icon?.default, + action: didSelectURL + ) + verticalStackView.addArrangedSubview(bulletPointView) + } + return verticalStackView +} + +private func CreateBulletPointView( + title: String?, + content: String?, + iconUrl: String?, + action: @escaping (URL) -> Void +) -> UIView { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.setImage(with: iconUrl) + imageView.translatesAutoresizingMaskIntoConstraints = false + let imageDiameter: CGFloat = 16 + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: imageDiameter), + imageView.heightAnchor.constraint(equalToConstant: imageDiameter), + ]) + + let labelView = BulletPointLabelView( + title: title, + content: content, + didSelectURL: action + ) + + let horizontalStackView = HitTestStackView( + arrangedSubviews: [ + { + // add extra padding to `imageView` to align + // the text + image better + let extraPaddingView = UIStackView(arrangedSubviews: [imageView]) + extraPaddingView.isLayoutMarginsRelativeArrangement = true + extraPaddingView.directionalLayoutMargins = NSDirectionalEdgeInsets( + // center the image in the middle of the first line height + top: max(0, (labelView.topLineHeight - imageDiameter) / 2), + leading: 0, + bottom: 0, + trailing: 0 + ) + return extraPaddingView + }(), + labelView, + ] + ) + horizontalStackView.axis = .horizontal + horizontalStackView.spacing = 10 + horizontalStackView.alignment = .top + return horizontalStackView +} + +#if DEBUG + +import SwiftUI + +private struct NetworkingLinkSignupBodyViewUIViewRepresentable: UIViewRepresentable { + + func makeUIView(context: Context) -> NetworkingLinkSignupBodyView { + NetworkingLinkSignupBodyView( + bulletPoints: [ + FinancialConnectionsBulletPoint( + icon: FinancialConnectionsImage( + default: + "https://b.stripecdn.com/connections-statics-srv/assets/SailIcon--reserve-primary-3x.png" + ), + content: + "Connect your account faster on [Merchant] and thousands of sites." + ), + FinancialConnectionsBulletPoint( + icon: FinancialConnectionsImage( + default: + "https://b.stripecdn.com/connections-statics-srv/assets/SailIcon--reserve-primary-3x.png" + ), + content: "Link with Stripe encrypts your data and never shares your login details." + ), + ], + formView: UIView(), + didSelectURL: { _ in } + ) + } + + func updateUIView(_ uiView: NetworkingLinkSignupBodyView, context: Context) {} +} + +struct NetworkingLinkSignupBodyView_Previews: PreviewProvider { + static var previews: some View { + VStack(alignment: .leading) { + NetworkingLinkSignupBodyViewUIViewRepresentable() + .frame(maxHeight: 200) + .padding() + Spacer() + } + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkSignupPane/NetworkingLinkSignupDataSource.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkSignupPane/NetworkingLinkSignupDataSource.swift new file mode 100644 index 00000000..743c7195 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkSignupPane/NetworkingLinkSignupDataSource.swift @@ -0,0 +1,76 @@ +// +// NetworkingLinkSignupDataSource.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 1/17/23. +// + +import Foundation +@_spi(STP) import StripeCore + +protocol NetworkingLinkSignupDataSource: AnyObject { + var manifest: FinancialConnectionsSessionManifest { get } + var analyticsClient: FinancialConnectionsAnalyticsClient { get } + + func synchronize() -> Future + func lookup(emailAddress: String) -> Future + func saveToLink(emailAddress: String, phoneNumber: String, countryCode: String) -> Future +} + +final class NetworkingLinkSignupDataSourceImplementation: NetworkingLinkSignupDataSource { + + let manifest: FinancialConnectionsSessionManifest + private let selectedAccountIds: [String] + private let returnURL: String? + private let apiClient: FinancialConnectionsAPIClient + private let clientSecret: String + let analyticsClient: FinancialConnectionsAnalyticsClient + + init( + manifest: FinancialConnectionsSessionManifest, + selectedAccountIds: [String], + returnURL: String?, + apiClient: FinancialConnectionsAPIClient, + clientSecret: String, + analyticsClient: FinancialConnectionsAnalyticsClient + ) { + self.manifest = manifest + self.selectedAccountIds = selectedAccountIds + self.returnURL = returnURL + self.apiClient = apiClient + self.clientSecret = clientSecret + self.analyticsClient = analyticsClient + } + + func synchronize() -> Future { + return apiClient.synchronize( + clientSecret: clientSecret, + returnURL: returnURL + ) + .chained { synchronize in + if let networkingLinkSignup = synchronize.text?.networkingLinkSignupPane { + return Promise(value: networkingLinkSignup) + } else { + return Promise(error: FinancialConnectionsSheetError.unknown(debugDescription: "no networkingLinkSignup data attached")) + } + } + } + + func lookup(emailAddress: String) -> Future { + return apiClient.consumerSessionLookup(emailAddress: emailAddress, clientSecret: clientSecret) + } + + func saveToLink(emailAddress: String, phoneNumber: String, countryCode: String) -> Future { + return apiClient.saveAccountsToLink( + emailAddress: emailAddress, + phoneNumber: phoneNumber, + country: countryCode, // ex. "US" + selectedAccountIds: selectedAccountIds, + consumerSessionClientSecret: nil, + clientSecret: clientSecret + ) + .chained { _ in + return Promise(value: ()) + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkSignupPane/NetworkingLinkSignupFooterView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkSignupPane/NetworkingLinkSignupFooterView.swift new file mode 100644 index 00000000..ec33ec25 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkSignupPane/NetworkingLinkSignupFooterView.swift @@ -0,0 +1,160 @@ +// +// NetworkingLinkSignupFooterView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 1/17/23. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +class NetworkingLinkSignupFooterView: HitTestView { + + private let aboveCtaText: String + private let saveToLinkButtonText: String + private let notNowButtonText: String + private let didSelectSaveToLink: () -> Void + private let didSelectNotNow: () -> Void + private let didSelectURL: (URL) -> Void + + private lazy var footerVerticalStackView: UIStackView = { + let verticalStackView = UIStackView() + verticalStackView.axis = .vertical + verticalStackView.spacing = 24 + verticalStackView.addArrangedSubview(aboveCtaLabel) + verticalStackView.addArrangedSubview(buttonVerticalStack) + return verticalStackView + }() + + private lazy var aboveCtaLabel: AttributedTextView = { + let termsAndPrivacyPolicyLabel = AttributedTextView( + font: .body(.small), + boldFont: .body(.smallEmphasized), + linkFont: .body(.smallEmphasized), + textColor: .textSecondary, + alignCenter: true + ) + termsAndPrivacyPolicyLabel.setText( + aboveCtaText, + action: didSelectURL + ) + return termsAndPrivacyPolicyLabel + }() + + private lazy var buttonVerticalStack: UIStackView = { + let verticalStackView = UIStackView() + verticalStackView.axis = .vertical + verticalStackView.spacing = 12 + verticalStackView.addArrangedSubview(notNowButton) + return verticalStackView + }() + + private lazy var saveToLinkButton: StripeUICore.Button = { + let saveToLinkButton = Button(configuration: .financialConnectionsPrimary) + saveToLinkButton.title = saveToLinkButtonText + saveToLinkButton.addTarget(self, action: #selector(didSelectSaveToLinkButton), for: .touchUpInside) + saveToLinkButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + saveToLinkButton.heightAnchor.constraint(equalToConstant: 56) + ]) + return saveToLinkButton + }() + + private lazy var notNowButton: StripeUICore.Button = { + let saveToLinkButton = Button(configuration: .financialConnectionsSecondary) + saveToLinkButton.title = notNowButtonText + saveToLinkButton.addTarget(self, action: #selector(didSelectNotNowButton), for: .touchUpInside) + saveToLinkButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + saveToLinkButton.heightAnchor.constraint(equalToConstant: 56) + ]) + return saveToLinkButton + }() + + init( + aboveCtaText: String, + saveToLinkButtonText: String, + notNowButtonText: String, + didSelectSaveToLink: @escaping () -> Void, + didSelectNotNow: @escaping () -> Void, + didSelectURL: @escaping (URL) -> Void + ) { + self.aboveCtaText = aboveCtaText + self.saveToLinkButtonText = saveToLinkButtonText + self.notNowButtonText = notNowButtonText + self.didSelectSaveToLink = didSelectSaveToLink + self.didSelectNotNow = didSelectNotNow + self.didSelectURL = didSelectURL + super.init(frame: .zero) + backgroundColor = .customBackgroundColor + addAndPinSubview(footerVerticalStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func showSaveToLinkButtonIfNeeded() { + guard saveToLinkButton.superview == nil else { + return // already added + } + notNowButton.removeFromSuperview() + buttonVerticalStack.addArrangedSubview(saveToLinkButton) + buttonVerticalStack.addArrangedSubview(notNowButton) + } + + func enableSaveToLinkButton(_ enable: Bool) { + saveToLinkButton.isEnabled = enable + } + + @objc private func didSelectSaveToLinkButton() { + didSelectSaveToLink() + } + + @objc private func didSelectNotNowButton() { + didSelectNotNow() + } + + func setIsLoading(_ isLoading: Bool) { + saveToLinkButton.isLoading = isLoading + } +} + +#if DEBUG + +import SwiftUI + +private struct NetworkingLinkSignupFooterViewUIViewRepresentable: UIViewRepresentable { + + func makeUIView(context: Context) -> NetworkingLinkSignupFooterView { + NetworkingLinkSignupFooterView( + aboveCtaText: "By saving your account to Link, you agree to Link’s [Terms](https://link.co/terms) and [Privacy Policy](https://link.co/privacy)", + saveToLinkButtonText: "Save to Link", + notNowButtonText: "Not now", + didSelectSaveToLink: {}, + didSelectNotNow: {}, + didSelectURL: { _ in } + ) + } + + func updateUIView(_ uiView: NetworkingLinkSignupFooterView, context: Context) { + uiView.sizeToFit() + } +} + +@available(iOS 14.0, *) +struct NetworkingLinkSignupFooterView_Previews: PreviewProvider { + static var previews: some View { + VStack { + NetworkingLinkSignupFooterViewUIViewRepresentable() + .frame(maxHeight: 200) + Spacer() + } + .padding() + .frame(maxWidth: .infinity) + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkSignupPane/NetworkingLinkSignupViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkSignupPane/NetworkingLinkSignupViewController.swift new file mode 100644 index 00000000..1d9341fc --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkSignupPane/NetworkingLinkSignupViewController.swift @@ -0,0 +1,331 @@ +// +// NetworkingLinkSignupViewController.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 1/17/23. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol NetworkingLinkSignupViewControllerDelegate: AnyObject { + func networkingLinkSignupViewController( + _ viewController: NetworkingLinkSignupViewController, + foundReturningConsumerWithSession consumerSession: ConsumerSessionData + ) + func networkingLinkSignupViewControllerDidFinish( + _ viewController: NetworkingLinkSignupViewController, + // nil == we did not perform saveToLink + saveToLinkWithStripeSucceeded: Bool?, + withError error: Error? + ) + func networkingLinkSignupViewController( + _ viewController: NetworkingLinkSignupViewController, + didReceiveTerminalError error: Error + ) +} + +final class NetworkingLinkSignupViewController: UIViewController { + + private let dataSource: NetworkingLinkSignupDataSource + weak var delegate: NetworkingLinkSignupViewControllerDelegate? + + private lazy var loadingView: ActivityIndicator = { + let activityIndicator = ActivityIndicator(size: .large) + activityIndicator.color = .textDisabled + activityIndicator.backgroundColor = .customBackgroundColor + return activityIndicator + }() + private lazy var formView: NetworkingLinkSignupBodyFormView = { + let formView = NetworkingLinkSignupBodyFormView( + accountholderPhoneNumber: dataSource.manifest.accountholderPhoneNumber + ) + formView.delegate = self + return formView + }() + private var footerView: NetworkingLinkSignupFooterView? + private var viewDidAppear: Bool = false + private var willNavigateToReturningConsumer = false + + init(dataSource: NetworkingLinkSignupDataSource) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + navigationItem.hidesBackButton = true + view.backgroundColor = .customBackgroundColor + + showLoadingView(true) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + // delay executing logic until `viewDidAppear` because + // of janky keyboard animations + if !viewDidAppear { + viewDidAppear = true + dataSource.synchronize() + .observe { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let networkingLinkSignup): + self.showContent(networkingLinkSignup: networkingLinkSignup) + case .failure(let error): + self.dataSource + .analyticsClient + .logUnexpectedError( + error, + errorName: "NetworkingLinkSignupSynchronizeError", + pane: .networkingLinkSignupPane + ) + self.delegate?.networkingLinkSignupViewControllerDidFinish( + self, + saveToLinkWithStripeSucceeded: nil, + withError: error + ) + } + self.showLoadingView(false) // first set to `true` from `viewDidLoad` + } + } + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if willNavigateToReturningConsumer { + willNavigateToReturningConsumer = false + // in case a user decides to go back from verification pane, + // we clear the email so they can re-enter + formView.emailElement.emailAddressElement.setText("") + } + } + + private func showContent(networkingLinkSignup: FinancialConnectionsNetworkingLinkSignup) { + let footerView = NetworkingLinkSignupFooterView( + aboveCtaText: networkingLinkSignup.aboveCta, + saveToLinkButtonText: networkingLinkSignup.cta, + notNowButtonText: networkingLinkSignup.skipCta, + didSelectSaveToLink: { [weak self] in + self?.didSelectSaveToLink() + }, + didSelectNotNow: { [weak self] in + guard let self = self else { + return + } + self.dataSource.analyticsClient + .log( + eventName: "click.not_now", + pane: .networkingLinkSignupPane + ) + self.delegate?.networkingLinkSignupViewControllerDidFinish( + self, + saveToLinkWithStripeSucceeded: nil, + withError: nil + ) + }, + didSelectURL: { [weak self] url in + self?.didSelectURLInTextFromBackend(url) + } + ) + self.footerView = footerView + let pane = PaneWithHeaderLayoutView( + title: networkingLinkSignup.title, + contentView: NetworkingLinkSignupBodyView( + bulletPoints: networkingLinkSignup.body.bullets, + formView: formView, + didSelectURL: { [weak self] url in + self?.didSelectURLInTextFromBackend(url) + } + ), + footerView: footerView + ) + pane.addTo(view: view) + + // if user drags, dismiss keyboard so the CTA buttons can be shown + pane.scrollView.keyboardDismissMode = .onDrag + + let emailAddress = dataSource.manifest.accountholderCustomerEmailAddress + if let emailAddress = emailAddress, !emailAddress.isEmpty { + formView.prefillEmailAddress(dataSource.manifest.accountholderCustomerEmailAddress) + } + + assert(self.footerView != nil, "footer view should be initialized as part of displaying content") + } + + private func showLoadingView(_ show: Bool) { + if show && loadingView.superview == nil { + // first-time we are showing this, so add the view to hierarchy + view.addAndPinSubview(loadingView) + } + + loadingView.isHidden = !show + if show { + loadingView.startAnimating() + } else { + loadingView.stopAnimating() + } + view.bringSubviewToFront(loadingView) // defensive programming to avoid loadingView being hiddden + } + + private func didSelectSaveToLink() { + footerView?.setIsLoading(true) + dataSource + .analyticsClient + .log( + eventName: "click.save_to_link", + pane: .networkingLinkSignupPane + ) + + dataSource.saveToLink( + emailAddress: formView.emailElement.emailAddressString ?? "", + phoneNumber: formView.phoneNumberElement.phoneNumber?.string(as: .e164) ?? "", + countryCode: formView.phoneNumberElement.phoneNumber?.countryCode ?? "US" + ) + .observe { [weak self] result in + guard let self = self else { return } + switch result { + case .success: + self.delegate?.networkingLinkSignupViewControllerDidFinish( + self, + saveToLinkWithStripeSucceeded: true, + withError: nil + ) + case .failure(let error): + // on error, we still go to success pane, but show a small error + // notice above the done button of the success pane + self.delegate?.networkingLinkSignupViewControllerDidFinish( + self, + saveToLinkWithStripeSucceeded: false, + withError: error + ) + self.dataSource.analyticsClient.logUnexpectedError( + error, + errorName: "SaveToLinkError", + pane: .networkingLinkSignupPane + ) + } + self.footerView?.setIsLoading(false) + } + } + + private func didSelectURLInTextFromBackend(_ url: URL) { + AuthFlowHelpers.handleURLInTextFromBackend( + url: url, + pane: .networkingLinkSignupPane, + analyticsClient: dataSource.analyticsClient, + handleStripeScheme: { _ in + // no custom stripe scheme is handled + } + ) + } + + private func adjustSaveToLinkButtonDisabledState() { + let isEmailValid = formView.emailElement.validationState.isValid + let isPhoneNumberValid = formView.phoneNumberElement.validationState.isValid + footerView?.enableSaveToLinkButton(isEmailValid && isPhoneNumberValid) + } + + private func foundReturningConsumer(withSession consumerSession: ConsumerSessionData) { + willNavigateToReturningConsumer = true + delegate?.networkingLinkSignupViewController( + self, + foundReturningConsumerWithSession: consumerSession + ) + } +} + +extension NetworkingLinkSignupViewController: NetworkingLinkSignupBodyFormViewDelegate { + + func networkingLinkSignupBodyFormView( + _ bodyFormView: NetworkingLinkSignupBodyFormView, + didEnterValidEmailAddress emailAddress: String + ) { + bodyFormView.emailElement.startAnimating() + dataSource + .lookup(emailAddress: emailAddress) + .observe { [weak self, weak bodyFormView] result in + guard let self = self else { return } + switch result { + case .success(let response): + if response.exists { + self.dataSource.analyticsClient.log( + eventName: "networking.returning_consumer", + pane: .networkingLinkSignupPane + ) + if let consumerSession = response.consumerSession { + // TODO(kgaidis): check whether its fair to assume that we will always have a consumer sesion here + self.foundReturningConsumer(withSession: consumerSession) + } else { + self.delegate?.networkingLinkSignupViewControllerDidFinish( + self, + saveToLinkWithStripeSucceeded: nil, + withError: FinancialConnectionsSheetError.unknown( + debugDescription: "No consumer session returned from lookupConsumerSession for emailAddress: \(emailAddress)" + ) + ) + } + } else { + self.dataSource.analyticsClient.log( + eventName: "networking.new_consumer", + pane: .networkingLinkSignupPane + ) + + let didShowPhoneNumberFieldForTheFirstTime = self.formView.showPhoneNumberFieldIfNeeded() + // in case user needs to slowly re-type the e-mail, + // we want to only jump to the phone number the + // first time they enter the e-mail + if didShowPhoneNumberFieldForTheFirstTime { + let didPrefillPhoneNumber = (self.formView.phoneNumberElement.phoneNumber?.number ?? "").count > 1 + // if the phone number is pre-filled, we don't focus on the phone number field + if !didPrefillPhoneNumber { + let didPrefillEmailAddress = { + if + let accountholderCustomerEmailAddress = self.dataSource.manifest.accountholderCustomerEmailAddress, + !accountholderCustomerEmailAddress.isEmpty + { + return true + } else { + return false + } + }() + // we don't want to auto-focus the phone number field if we pre-filled the email + if !didPrefillEmailAddress { + // this disables the "Phone" label animating (we don't want that animation here) + UIView.performWithoutAnimation { + self.formView.beginEditingPhoneNumberField() + } + } + } else { + // user is done with e-mail AND phone number, so dismiss the keyboard + // so they can see the "Save to Link" button + self.formView.endEditingEmailAddressField() + } + } + self.footerView?.showSaveToLinkButtonIfNeeded() + } + case .failure(let error): + self.dataSource.analyticsClient.logUnexpectedError( + error, + errorName: "LookupConsumerSessionError", + pane: .networkingLinkSignupPane + ) + self.delegate?.networkingLinkSignupViewController( + self, + didReceiveTerminalError: error + ) + } + bodyFormView?.emailElement.stopAnimating() + } + } + + func networkingLinkSignupBodyFormViewDidUpdateFields(_ view: NetworkingLinkSignupBodyFormView) { + adjustSaveToLinkButtonDisabledState() + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkStepUpVerification/NetworkingLinkStepUpVerificationBodyView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkStepUpVerification/NetworkingLinkStepUpVerificationBodyView.swift new file mode 100644 index 00000000..84b259fd --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkStepUpVerification/NetworkingLinkStepUpVerificationBodyView.swift @@ -0,0 +1,171 @@ +// +// NetworkingLinkStepUpVerificationBodyView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/16/23. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +final class NetworkingLinkStepUpVerificationBodyView: UIView { + + private let email: String + private let didSelectResendCode: () -> Void + + private lazy var footnoteHorizontalStackView: UIStackView = { + let footnoteHorizontalStackView = UIStackView() + footnoteHorizontalStackView.axis = .horizontal + footnoteHorizontalStackView.spacing = 8 + footnoteHorizontalStackView.alignment = .center + return footnoteHorizontalStackView + }() + + init( + email: String, + otpView: UIView, + didSelectResendCode: @escaping () -> Void + ) { + self.email = email + self.didSelectResendCode = didSelectResendCode + super.init(frame: .zero) + let verticalStackView = UIStackView( + arrangedSubviews: [ + otpView, + footnoteHorizontalStackView, + ] + ) + verticalStackView.axis = .vertical + verticalStackView.spacing = 20 + addAndPinSubview(verticalStackView) + + setupFootnoteView(isResendingCode: false) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func isResendingCode(_ isResendingCode: Bool) { + setupFootnoteView(isResendingCode: isResendingCode) + } + + private func setupFootnoteView(isResendingCode: Bool) { + // clear all previous state + footnoteHorizontalStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + + footnoteHorizontalStackView.addArrangedSubview( + CreateEmailLabel(email: email) + ) + footnoteHorizontalStackView.addArrangedSubview( + CreateCreateDotLabel() + ) + footnoteHorizontalStackView.addArrangedSubview( + CreateResendCodeLabel( + isEnabled: !isResendingCode, + didSelect: didSelectResendCode + ) + ) + if isResendingCode { + footnoteHorizontalStackView.addArrangedSubview( + CreateResendCodeLoadingView() + ) + let spacerView = UIView() + footnoteHorizontalStackView.addArrangedSubview(spacerView) + } + } +} + +private func CreateEmailLabel(email: String) -> UIView { + let emailLabel = AttributedLabel( + font: .label(.medium), + textColor: .textSecondary + ) + emailLabel.text = "\(email)" + return emailLabel +} + +private func CreateCreateDotLabel() -> UIView { + let dotLabel = AttributedLabel( + font: .label(.medium), + textColor: .textDisabled + ) + dotLabel.text = "•" + return dotLabel +} + +private func CreateResendCodeLabel(isEnabled: Bool, didSelect: @escaping () -> Void) -> UIView { + let resendCodeLabel = AttributedTextView( + font: .label(.medium), + boldFont: .label(.mediumEmphasized), + linkFont: .label(.mediumEmphasized), + textColor: .textDisabled, + alignCenter: false + ) + let text = STPLocalizedString( + "Resend code", + "The title of a button that allows a user to request a one-time-password (OTP) again in case they did not receive it." + ) + if isEnabled { + resendCodeLabel.setText( + "[\(text)](https://www.just-fire-action.com)", + action: { _ in + didSelect() + } + ) + } else { + resendCodeLabel.setText(text) + } + return resendCodeLabel +} + +private func CreateResendCodeLoadingView() -> UIView { + let activityIndicator = ActivityIndicator(size: .medium) + activityIndicator.color = .textDisabled + activityIndicator.startAnimating() + + // `ActivityIndicator` is hard-coded to have specific sizes, so here we scale it to our needs + let mediumIconDiameter: CGFloat = 20 + let desiredIconDiameter: CGFloat = 12 + let transform = CGAffineTransform(scaleX: desiredIconDiameter / mediumIconDiameter, y: desiredIconDiameter / mediumIconDiameter) + activityIndicator.transform = transform + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + activityIndicator.widthAnchor.constraint(equalToConstant: desiredIconDiameter), + activityIndicator.heightAnchor.constraint(equalToConstant: desiredIconDiameter), + ]) + return activityIndicator +} + +#if DEBUG + +import SwiftUI + +private struct NetworkingLinkStepUpVerificationBodyViewUIViewRepresentable: UIViewRepresentable { + + func makeUIView(context: Context) -> NetworkingLinkStepUpVerificationBodyView { + NetworkingLinkStepUpVerificationBodyView( + email: "test@stripe.com", + otpView: UIView(), + didSelectResendCode: {} + ) + } + + func updateUIView(_ uiView: NetworkingLinkStepUpVerificationBodyView, context: Context) {} +} + +struct NetworkingLinkStepUpVerificationBodyView_Previews: PreviewProvider { + static var previews: some View { + VStack(alignment: .leading) { + Spacer() + NetworkingLinkStepUpVerificationBodyViewUIViewRepresentable() + .frame(maxHeight: 100) + .padding() + Spacer() + } + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkStepUpVerification/NetworkingLinkStepUpVerificationDataSource.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkStepUpVerification/NetworkingLinkStepUpVerificationDataSource.swift new file mode 100644 index 00000000..bb63cf18 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkStepUpVerification/NetworkingLinkStepUpVerificationDataSource.swift @@ -0,0 +1,82 @@ +// +// NetworkingLinkStepUpVerificationDataSource.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/16/23. +// + +import Foundation +@_spi(STP) import StripeCore + +protocol NetworkingLinkStepUpVerificationDataSource: AnyObject { + var consumerSession: ConsumerSessionData { get } + var analyticsClient: FinancialConnectionsAnalyticsClient { get } + var networkingOTPDataSource: NetworkingOTPDataSource { get } + + func markLinkStepUpAuthenticationVerified() -> Future + func selectNetworkedAccount() -> Future +} + +final class NetworkingLinkStepUpVerificationDataSourceImplementation: NetworkingLinkStepUpVerificationDataSource { + + private(set) var consumerSession: ConsumerSessionData + private let selectedAccountId: String + private let manifest: FinancialConnectionsSessionManifest + private let apiClient: FinancialConnectionsAPIClient + private let clientSecret: String + let analyticsClient: FinancialConnectionsAnalyticsClient + let networkingOTPDataSource: NetworkingOTPDataSource + + init( + consumerSession: ConsumerSessionData, + selectedAccountId: String, + manifest: FinancialConnectionsSessionManifest, + apiClient: FinancialConnectionsAPIClient, + clientSecret: String, + analyticsClient: FinancialConnectionsAnalyticsClient + ) { + self.consumerSession = consumerSession + self.selectedAccountId = selectedAccountId + self.manifest = manifest + self.apiClient = apiClient + self.clientSecret = clientSecret + self.analyticsClient = analyticsClient + let networkingOTPDataSource = NetworkingOTPDataSourceImplementation( + otpType: "EMAIL", + emailAddress: consumerSession.emailAddress, + customEmailType: "NETWORKED_CONNECTIONS_OTP_EMAIL", + connectionsMerchantName: manifest.businessName, + pane: .networkingLinkStepUpVerification, + consumerSession: nil, + apiClient: apiClient, + clientSecret: clientSecret, + analyticsClient: analyticsClient + ) + self.networkingOTPDataSource = networkingOTPDataSource + networkingOTPDataSource.delegate = self + } + + func markLinkStepUpAuthenticationVerified() -> Future { + return apiClient.markLinkStepUpAuthenticationVerified(clientSecret: clientSecret) + } + + func selectNetworkedAccount() -> Future { + return apiClient.selectNetworkedAccounts( + selectedAccountIds: [selectedAccountId], + clientSecret: clientSecret, + consumerSessionClientSecret: consumerSession.clientSecret + ) + } +} + +// MARK: - NetworkingOTPDataSourceDelegate + +extension NetworkingLinkStepUpVerificationDataSourceImplementation: NetworkingOTPDataSourceDelegate { + + func networkingOTPDataSource( + _ dataSource: NetworkingOTPDataSource, + didUpdateConsumerSession consumerSession: ConsumerSessionData + ) { + self.consumerSession = consumerSession + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkStepUpVerification/NetworkingLinkStepUpVerificationViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkStepUpVerification/NetworkingLinkStepUpVerificationViewController.swift new file mode 100644 index 00000000..6ba4769c --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkStepUpVerification/NetworkingLinkStepUpVerificationViewController.swift @@ -0,0 +1,259 @@ +// +// NetworkingLinkStepUpVerificationViewController.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/16/23. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol NetworkingLinkStepUpVerificationViewControllerDelegate: AnyObject { + func networkingLinkStepUpVerificationViewController( + _ viewController: NetworkingLinkStepUpVerificationViewController, + didCompleteVerificationWithInstitution institution: FinancialConnectionsInstitution + ) + func networkingLinkStepUpVerificationViewController( + _ viewController: NetworkingLinkStepUpVerificationViewController, + didReceiveTerminalError error: Error + ) + func networkingLinkStepUpVerificationViewControllerEncounteredSoftError( + _ viewController: NetworkingLinkStepUpVerificationViewController + ) +} + +final class NetworkingLinkStepUpVerificationViewController: UIViewController { + + private let dataSource: NetworkingLinkStepUpVerificationDataSource + weak var delegate: NetworkingLinkStepUpVerificationViewControllerDelegate? + + private lazy var loadingView: ActivityIndicator = { + let activityIndicator = ActivityIndicator(size: .large) + activityIndicator.color = .textDisabled + activityIndicator.backgroundColor = .customBackgroundColor + return activityIndicator + }() + private lazy var bodyView: NetworkingLinkStepUpVerificationBodyView = { + let bodyView = NetworkingLinkStepUpVerificationBodyView( + email: dataSource.consumerSession.emailAddress, + otpView: otpView, + didSelectResendCode: { [weak self] in + self?.didSelectResendCode() + } + ) + return bodyView + }() + private lazy var otpView: NetworkingOTPView = { + let otpView = NetworkingOTPView(dataSource: dataSource.networkingOTPDataSource) + otpView.delegate = self + return otpView + }() + // used to track whether we show loading view when calling `lookupConsumerAndStartVerification` + private var didShowContent: Bool = false + + init(dataSource: NetworkingLinkStepUpVerificationDataSource) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .customBackgroundColor + + otpView.lookupConsumerAndStartVerification() + } + + private func handleFailure(error: Error, errorName: String) { + dataSource.analyticsClient.log( + eventName: "networking.verification.step_up.error", + parameters: [ + "error": errorName + ], + pane: .networkingLinkStepUpVerification + ) + dataSource.analyticsClient.logUnexpectedError( + error, + errorName: errorName, + pane: .networkingLinkStepUpVerification + ) + delegate?.networkingLinkStepUpVerificationViewController( + self, + didReceiveTerminalError: error + ) + } + + private func showContent() { + didShowContent = true + + let pane = PaneWithHeaderLayoutView( + title: STPLocalizedString( + "Check your email to confirm your identity", + "The title of a screen where users are asked to enter a one-time-password (OTP) that they received in their email." + ), + subtitle: String( + format: STPLocalizedString( + "To keep your Link account safe, we periodically need to confirm you're you. Enter the code sent to your email %@.", + "The subtitle/description of a screen where users are asked to enter a one-time-password (OTP) that they received in their email. '%@' is replaced with an email, for example, 'test@test.com'." + ), "**\(dataSource.consumerSession.emailAddress)**" // asterisks make the e-mail bold + ), + contentView: bodyView, + footerView: nil + ) + pane.addTo(view: view) + } + + private func showLoadingView(_ show: Bool) { + if show && loadingView.superview == nil { + // first-time we are showing this, so add the view to hierarchy + view.addAndPinSubview(loadingView) + } + + loadingView.isHidden = !show + if show { + loadingView.startAnimating() + } else { + loadingView.stopAnimating() + } + view.bringSubviewToFront(loadingView) // defensive programming to avoid loadingView being hiddden + } + + private func didSelectResendCode() { + otpView.lookupConsumerAndStartVerification() + } +} + +// MARK: - NetworkingOTPViewDelegate + +extension NetworkingLinkStepUpVerificationViewController: NetworkingOTPViewDelegate { + + func networkingOTPViewWillStartConsumerLookup(_ view: NetworkingOTPView) { + if !didShowContent { + showLoadingView(true) + } else { + bodyView.isResendingCode(true) + } + } + + func networkingOTPViewConsumerNotFound(_ view: NetworkingOTPView) { + // side-note: it is redundant to call `showLoadingView` & `isResendingCode` because + // usually only one needs to be hidden, but this keeps the code simple + showLoadingView(false) + bodyView.isResendingCode(false) + + dataSource.analyticsClient.log( + eventName: "networking.verification.step_up.error", + parameters: [ + "error": "ConsumerNotFoundError", + ], + pane: .networkingLinkStepUpVerification + ) + delegate?.networkingLinkStepUpVerificationViewControllerEncounteredSoftError(self) + } + + func networkingOTPView(_ view: NetworkingOTPView, didFailConsumerLookup error: Error) { + // side-note: it is redundant to call both (`showLoadingView` & `isResendingCode`) because + // only one needs to be hidden (depends on the state), but this keeps the code simple + showLoadingView(false) + bodyView.isResendingCode(false) + + handleFailure(error: error, errorName: "LookupConsumerSessionError") + } + + func networkingOTPViewWillStartVerification(_ view: NetworkingOTPView) { + // no-op + } + + func networkingOTPView(_ view: NetworkingOTPView, didStartVerification consumerSession: ConsumerSessionData) { + // it's important to call this BEFORE we call `showContent` because of `didShowContent` + if !didShowContent { + showLoadingView(false) + } else { + bodyView.isResendingCode(false) + } + + showContent() + } + + func networkingOTPView(_ view: NetworkingOTPView, didFailToStartVerification error: Error) { + // side-note: it is redundant to call `showLoadingView` & `isResendingCode` because + // usually only one needs to be hidden, but this keeps the code simple + showLoadingView(false) + bodyView.isResendingCode(false) + + handleFailure(error: error, errorName: "StartVerificationSessionError") + } + + func networkingOTPViewDidConfirmVerification(_ view: NetworkingOTPView) { + dataSource.markLinkStepUpAuthenticationVerified() + .observe { [weak self] result in + guard let self = self else { return } + switch result { + case .success: + self.dataSource + .analyticsClient + .log( + eventName: "networking.verification.step_up.success", + pane: .networkingLinkStepUpVerification + ) + self.dataSource.selectNetworkedAccount() + .observe { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let institutionList): + self.dataSource + .analyticsClient + .log( + eventName: "click.link_accounts", + pane: .networkingLinkStepUpVerification + ) + + if let institution = institutionList.data.first { + self.delegate?.networkingLinkStepUpVerificationViewController( + self, + didCompleteVerificationWithInstitution: institution + ) + } else { + // this shouldn't happen, but in case it does, we navigate to `institutionPicker` so user + // could still have a chance at successfully connecting their account + self.delegate?.networkingLinkStepUpVerificationViewControllerEncounteredSoftError(self) + } + case .failure(let error): + self.dataSource + .analyticsClient + .logUnexpectedError( + error, + errorName: "SelectNetworkedAccountError", + pane: .networkingLinkStepUpVerification + ) + self.delegate?.networkingLinkStepUpVerificationViewController( + self, + didReceiveTerminalError: error + ) + } + } + case .failure(let error): + self.dataSource + .analyticsClient + .logUnexpectedError( + error, + errorName: "MarkLinkStepUpAuthenticationVerifiedError", + pane: .networkingLinkStepUpVerification + ) + self.delegate?.networkingLinkStepUpVerificationViewController( + self, + didReceiveTerminalError: error + ) + } + } + } + + func networkingOTPView(_ view: NetworkingOTPView, didTerminallyFailToConfirmVerification error: Error) { + delegate?.networkingLinkStepUpVerificationViewController(self, didReceiveTerminalError: error) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkVerification/NetworkingLinkVerificationBodyView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkVerification/NetworkingLinkVerificationBodyView.swift new file mode 100644 index 00000000..fe57fc12 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkVerification/NetworkingLinkVerificationBodyView.swift @@ -0,0 +1,70 @@ +// +// NetworkingLinkVerificationBodyView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/9/23. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +final class NetworkingLinkVerificationBodyView: UIView { + + init(email: String, otpView: UIView) { + super.init(frame: .zero) + let verticalStackView = UIStackView( + arrangedSubviews: [ + otpView, + CreateEmailLabel(email: email), + ] + ) + verticalStackView.axis = .vertical + verticalStackView.spacing = 20 + addAndPinSubview(verticalStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private func CreateEmailLabel(email: String) -> UIView { + let emailLabel = AttributedLabel( + font: .label(.medium), + textColor: .textSecondary + ) + emailLabel.text = String(format: STPLocalizedString("Signing in as %@", "A footnote that explains the user that when they enter an one-time-password code (OTP), they will be signing in as the email in this footnote. '%@' is replaced with an email, for examle: 'Signing in as user@gmail.com'."), email) + return emailLabel +} + +#if DEBUG + +import SwiftUI + +private struct NetworkingLinkVerificationBodyViewUIViewRepresentable: UIViewRepresentable { + + func makeUIView(context: Context) -> NetworkingLinkVerificationBodyView { + NetworkingLinkVerificationBodyView( + email: "test@stripe.com", + otpView: UIView() + ) + } + + func updateUIView(_ uiView: NetworkingLinkVerificationBodyView, context: Context) {} +} + +struct NetworkingLinkVerificationBodyView_Previews: PreviewProvider { + static var previews: some View { + VStack(alignment: .leading) { + Spacer() + NetworkingLinkVerificationBodyViewUIViewRepresentable() + .frame(maxHeight: 100) + .padding() + Spacer() + } + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkVerification/NetworkingLinkVerificationDataSource.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkVerification/NetworkingLinkVerificationDataSource.swift new file mode 100644 index 00000000..35f93890 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkVerification/NetworkingLinkVerificationDataSource.swift @@ -0,0 +1,82 @@ +// +// NetworkingLinkVerificationDataSource.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/7/23. +// + +import Foundation +@_spi(STP) import StripeCore + +protocol NetworkingLinkVerificationDataSource: AnyObject { + var accountholderCustomerEmailAddress: String { get } + var manifest: FinancialConnectionsSessionManifest { get } + var analyticsClient: FinancialConnectionsAnalyticsClient { get } + var consumerSession: ConsumerSessionData? { get } + var networkingOTPDataSource: NetworkingOTPDataSource { get } + + func markLinkVerified() -> Future + func fetchNetworkedAccounts() -> Future +} + +final class NetworkingLinkVerificationDataSourceImplementation: NetworkingLinkVerificationDataSource { + + let accountholderCustomerEmailAddress: String + let manifest: FinancialConnectionsSessionManifest + private let apiClient: FinancialConnectionsAPIClient + private let clientSecret: String + let analyticsClient: FinancialConnectionsAnalyticsClient + let networkingOTPDataSource: NetworkingOTPDataSource + + private(set) var consumerSession: ConsumerSessionData? + + init( + accountholderCustomerEmailAddress: String, + manifest: FinancialConnectionsSessionManifest, + apiClient: FinancialConnectionsAPIClient, + clientSecret: String, + analyticsClient: FinancialConnectionsAnalyticsClient + ) { + self.accountholderCustomerEmailAddress = accountholderCustomerEmailAddress + self.manifest = manifest + self.apiClient = apiClient + self.clientSecret = clientSecret + self.analyticsClient = analyticsClient + let networkingOTPDataSource = NetworkingOTPDataSourceImplementation( + otpType: "SMS", + emailAddress: accountholderCustomerEmailAddress, + customEmailType: nil, + connectionsMerchantName: nil, + pane: .networkingLinkVerification, + consumerSession: nil, + apiClient: apiClient, + clientSecret: clientSecret, + analyticsClient: analyticsClient + ) + self.networkingOTPDataSource = networkingOTPDataSource + networkingOTPDataSource.delegate = self + } + + func markLinkVerified() -> Future { + return apiClient.markLinkVerified(clientSecret: clientSecret) + } + + func fetchNetworkedAccounts() -> Future { + guard let consumerSessionClientSecret = consumerSession?.clientSecret else { + return Promise(error: FinancialConnectionsSheetError.unknown(debugDescription: "invalid confirmVerificationSession state: no consumerSessionClientSecret")) + } + return apiClient.fetchNetworkedAccounts( + clientSecret: clientSecret, + consumerSessionClientSecret: consumerSessionClientSecret + ) + } +} + +// MARK: - NetworkingOTPDataSourceDelegate + +extension NetworkingLinkVerificationDataSourceImplementation: NetworkingOTPDataSourceDelegate { + + func networkingOTPDataSource(_ dataSource: NetworkingOTPDataSource, didUpdateConsumerSession consumerSession: ConsumerSessionData) { + self.consumerSession = consumerSession + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkVerification/NetworkingLinkVerificationViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkVerification/NetworkingLinkVerificationViewController.swift new file mode 100644 index 00000000..0918b785 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingLinkVerification/NetworkingLinkVerificationViewController.swift @@ -0,0 +1,234 @@ +// +// NetworkingLinkVerificationViewController.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/7/23. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol NetworkingLinkVerificationViewControllerDelegate: AnyObject { + func networkingLinkVerificationViewController( + _ viewController: NetworkingLinkVerificationViewController, + didRequestNextPane nextPane: FinancialConnectionsSessionManifest.NextPane, + consumerSession: ConsumerSessionData? + ) + func networkingLinkVerificationViewController( + _ viewController: NetworkingLinkVerificationViewController, + didReceiveTerminalError error: Error + ) +} + +final class NetworkingLinkVerificationViewController: UIViewController { + + private let dataSource: NetworkingLinkVerificationDataSource + weak var delegate: NetworkingLinkVerificationViewControllerDelegate? + + private lazy var loadingView: ActivityIndicator = { + let activityIndicator = ActivityIndicator(size: .large) + activityIndicator.color = .textDisabled + activityIndicator.backgroundColor = .customBackgroundColor + return activityIndicator + }() + private lazy var bodyView: NetworkingLinkVerificationBodyView = { + let bodyView = NetworkingLinkVerificationBodyView( + email: dataSource.accountholderCustomerEmailAddress, + otpView: otpView + ) + return bodyView + }() + private lazy var otpView: NetworkingOTPView = { + let otpView = NetworkingOTPView(dataSource: dataSource.networkingOTPDataSource) + otpView.delegate = self + return otpView + }() + + init(dataSource: NetworkingLinkVerificationDataSource) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .customBackgroundColor + otpView.lookupConsumerAndStartVerification() + } + + private func showContent(redactedPhoneNumber: String) { + let pane = PaneWithHeaderLayoutView( + title: STPLocalizedString( + "Sign in to Link", + "The title of a screen where users are informed that they can sign-in-to Link." + ), + subtitle: String(format: STPLocalizedString( + "Enter the code sent to %@.", + "The subtitle/description of a screen where users are informed that they have received a One-Type-Password (OTP) to their phone. '%@' gets replaced by a redacted phone number." + ), redactedPhoneNumber), + contentView: bodyView, + footerView: nil + ) + pane.addTo(view: view) + } + + private func showLoadingView(_ show: Bool) { + if show && loadingView.superview == nil { + // first-time we are showing this, so add the view to hierarchy + view.addAndPinSubview(loadingView) + } + + loadingView.isHidden = !show + if show { + loadingView.startAnimating() + } else { + loadingView.stopAnimating() + } + view.bringSubviewToFront(loadingView) // defensive programming to avoid loadingView being hiddden + } + + private func requestNextPane(_ pane: FinancialConnectionsSessionManifest.NextPane) { + if let consumerSession = dataSource.consumerSession { + delegate?.networkingLinkVerificationViewController( + self, + didRequestNextPane: pane, + consumerSession: consumerSession + ) + } else { + assertionFailure("logic error: did not have consumerSession") + delegate?.networkingLinkVerificationViewController(self, didReceiveTerminalError: FinancialConnectionsSheetError.unknown(debugDescription: "logic error: did not have consumerSession")) + } + } +} + +// MARK: - NetworkingOTPViewDelegate + +extension NetworkingLinkVerificationViewController: NetworkingOTPViewDelegate { + + func networkingOTPViewWillStartConsumerLookup(_ view: NetworkingOTPView) { + showLoadingView(true) + } + + func networkingOTPViewConsumerNotFound(_ view: NetworkingOTPView) { + dataSource.analyticsClient.log( + eventName: "networking.verification.error", + parameters: [ + "error": "ConsumerNotFoundError" + ], + pane: .networkingLinkVerification + ) + delegate?.networkingLinkVerificationViewController(self, didRequestNextPane: .institutionPicker, consumerSession: nil) + showLoadingView(false) // started in networkingOTPViewWillStartConsumerLookup + } + + func networkingOTPView(_ view: NetworkingOTPView, didFailConsumerLookup error: Error) { + dataSource.analyticsClient.logUnexpectedError( + error, + errorName: "LookupConsumerSessionError", + pane: .networkingLinkVerification + ) + dataSource.analyticsClient.log( + eventName: "networking.verification.error", + parameters: [ + "error": "LookupConsumerSession" + ], + pane: .networkingLinkVerification + ) + delegate?.networkingLinkVerificationViewController(self, didReceiveTerminalError: error) + showLoadingView(false) // started in networkingOTPViewWillStartConsumerLookup + } + + func networkingOTPViewWillStartVerification(_ view: NetworkingOTPView) { + // no-op + } + + func networkingOTPView(_ view: NetworkingOTPView, didStartVerification consumerSession: ConsumerSessionData) { + showLoadingView(false) // started in networkingOTPViewWillStartConsumerLookup + showContent(redactedPhoneNumber: consumerSession.redactedPhoneNumber) + } + + func networkingOTPView(_ view: NetworkingOTPView, didFailToStartVerification error: Error) { + showLoadingView(false) // started in networkingOTPViewWillStartConsumerLookup + + dataSource.analyticsClient.logUnexpectedError( + error, + errorName: "StartVerificationSessionError", + pane: .networkingLinkVerification + ) + dataSource.analyticsClient.log( + eventName: "networking.verification.error", + parameters: [ + "error": "StartVerificationSession" + ], + pane: .networkingLinkVerification + ) + delegate?.networkingLinkVerificationViewController(self, didReceiveTerminalError: error) + } + + func networkingOTPViewDidConfirmVerification(_ view: NetworkingOTPView) { + dataSource.markLinkVerified() + .observe { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let manifest): + self.dataSource.fetchNetworkedAccounts() + .observe { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let networkedAccountsResponse): + let networkedAccounts = networkedAccountsResponse.data + if networkedAccounts.isEmpty { + self.dataSource.analyticsClient.log( + eventName: "networking.verification.success_no_accounts", + pane: .networkingLinkVerification + ) + self.requestNextPane(manifest.nextPane) + } else { + self.dataSource.analyticsClient.log( + eventName: "networking.verification.success", + pane: .networkingLinkVerification + ) + self.requestNextPane(.linkAccountPicker) + } + case .failure(let error): + self.dataSource + .analyticsClient + .logUnexpectedError( + error, + errorName: "FetchNetworkedAccountsError", + pane: .networkingLinkVerification + ) + self.dataSource + .analyticsClient + .log( + eventName: "networking.verification.error", + parameters: [ + "error": "NetworkedAccountsRetrieveMethodError", + ], + pane: .networkingLinkVerification + ) + self.requestNextPane(manifest.nextPane) + } + } + case .failure(let error): + self.dataSource + .analyticsClient + .logUnexpectedError( + error, + errorName: "MarkLinkVerifiedError", + pane: .networkingLinkVerification + ) + self.delegate?.networkingLinkVerificationViewController(self, didReceiveTerminalError: error) + } + } + } + + func networkingOTPView(_ view: NetworkingOTPView, didTerminallyFailToConfirmVerification error: Error) { + delegate?.networkingLinkVerificationViewController(self, didReceiveTerminalError: error) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingSaveToLinkVerification/NetworkingSaveToLinkBodyView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingSaveToLinkVerification/NetworkingSaveToLinkBodyView.swift new file mode 100644 index 00000000..29ba4b1a --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingSaveToLinkVerification/NetworkingSaveToLinkBodyView.swift @@ -0,0 +1,70 @@ +// +// NetworkingSaveToLinkBodyView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/14/23. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +final class NetworkingSaveToLinkVerificationBodyView: UIView { + + init(email: String, otpView: UIView) { + super.init(frame: .zero) + let verticalStackView = UIStackView( + arrangedSubviews: [ + otpView, + CreateEmailLabel(email: email), + ] + ) + verticalStackView.axis = .vertical + verticalStackView.spacing = 20 + addAndPinSubview(verticalStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private func CreateEmailLabel(email: String) -> UIView { + let emailLabel = AttributedLabel( + font: .label(.medium), + textColor: .textSecondary + ) + emailLabel.text = String(format: STPLocalizedString("Signing in as %@", "A footnote that explains to the user that they are signing in as a user with a specific e-mail. '%@' is replaced with an e-mail, for example, 'Signing in as test@test.com'"), email) + return emailLabel +} + +#if DEBUG + +import SwiftUI + +private struct NetworkingSaveToLinkVerificationBodyViewUIViewRepresentable: UIViewRepresentable { + + func makeUIView(context: Context) -> NetworkingSaveToLinkVerificationBodyView { + NetworkingSaveToLinkVerificationBodyView( + email: "test@stripe.com", + otpView: UIView() + ) + } + + func updateUIView(_ uiView: NetworkingSaveToLinkVerificationBodyView, context: Context) {} +} + +struct NetworkingSaveToLinkVerificationBodyView_Previews: PreviewProvider { + static var previews: some View { + VStack(alignment: .leading) { + Spacer() + NetworkingSaveToLinkVerificationBodyViewUIViewRepresentable() + .frame(maxHeight: 100) + .padding() + Spacer() + } + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingSaveToLinkVerification/NetworkingSaveToLinkFooterView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingSaveToLinkVerification/NetworkingSaveToLinkFooterView.swift new file mode 100644 index 00000000..cae4e822 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingSaveToLinkVerification/NetworkingSaveToLinkFooterView.swift @@ -0,0 +1,82 @@ +// +// NetworkingSaveToLinkFooterView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/15/23. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +class NetworkingSaveToLinkFooterView: HitTestView { + + private let didSelectNotNow: () -> Void + + private lazy var buttonVerticalStack: UIStackView = { + let verticalStackView = UIStackView() + verticalStackView.axis = .vertical + verticalStackView.spacing = 12 + verticalStackView.addArrangedSubview(notNowButton) + return verticalStackView + }() + + private lazy var notNowButton: StripeUICore.Button = { + let saveToLinkButton = Button(configuration: .financialConnectionsSecondary) + saveToLinkButton.title = STPLocalizedString("Not now", "Title of a button that allows users to skip the current screen.") + saveToLinkButton.addTarget(self, action: #selector(didSelectNotNowButton), for: .touchUpInside) + saveToLinkButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + saveToLinkButton.heightAnchor.constraint(equalToConstant: 56) + ]) + return saveToLinkButton + }() + + init(didSelectNotNow: @escaping () -> Void) { + self.didSelectNotNow = didSelectNotNow + super.init(frame: .zero) + backgroundColor = .customBackgroundColor + addAndPinSubview(buttonVerticalStack) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func didSelectNotNowButton() { + didSelectNotNow() + } +} + +#if DEBUG + +import SwiftUI + +private struct NetworkingSaveToLinkFooterViewUIViewRepresentable: UIViewRepresentable { + + func makeUIView(context: Context) -> NetworkingSaveToLinkFooterView { + NetworkingSaveToLinkFooterView( + didSelectNotNow: {} + ) + } + + func updateUIView(_ uiView: NetworkingSaveToLinkFooterView, context: Context) { + uiView.sizeToFit() + } +} + +@available(iOS 14.0, *) +struct NetworkingSaveToLinkFooterView_Previews: PreviewProvider { + static var previews: some View { + VStack { + NetworkingSaveToLinkFooterViewUIViewRepresentable() + .frame(maxHeight: 200) + Spacer() + } + .padding() + .frame(maxWidth: .infinity) + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingSaveToLinkVerification/NetworkingSaveToLinkVerificationDataSource.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingSaveToLinkVerification/NetworkingSaveToLinkVerificationDataSource.swift new file mode 100644 index 00000000..093e55fb --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingSaveToLinkVerification/NetworkingSaveToLinkVerificationDataSource.swift @@ -0,0 +1,116 @@ +// +// NetworkingSaveToLinkVerificationDataSource.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/14/23. +// + +import Foundation +@_spi(STP) import StripeCore + +protocol NetworkingSaveToLinkVerificationDataSource: AnyObject { + var consumerSession: ConsumerSessionData { get } + var analyticsClient: FinancialConnectionsAnalyticsClient { get } + var networkingOTPDataSource: NetworkingOTPDataSource { get } + + func startVerificationSession() -> Future + func confirmVerificationSession(otpCode: String) -> Future + func markLinkVerified() -> Future + func saveToLink() -> Future +} + +final class NetworkingSaveToLinkVerificationDataSourceImplementation: NetworkingSaveToLinkVerificationDataSource { + + private(set) var consumerSession: ConsumerSessionData + private let selectedAccountId: String + private let apiClient: FinancialConnectionsAPIClient + private let clientSecret: String + let analyticsClient: FinancialConnectionsAnalyticsClient + let networkingOTPDataSource: NetworkingOTPDataSource + + init( + consumerSession: ConsumerSessionData, + selectedAccountId: String, + apiClient: FinancialConnectionsAPIClient, + clientSecret: String, + analyticsClient: FinancialConnectionsAnalyticsClient + ) { + self.consumerSession = consumerSession + self.selectedAccountId = selectedAccountId + self.apiClient = apiClient + self.clientSecret = clientSecret + self.analyticsClient = analyticsClient + let networkingOTPDataSource = NetworkingOTPDataSourceImplementation( + otpType: "SMS", + emailAddress: consumerSession.emailAddress, + customEmailType: nil, + connectionsMerchantName: nil, + pane: .networkingSaveToLinkVerification, + consumerSession: consumerSession, + apiClient: apiClient, + clientSecret: clientSecret, + analyticsClient: analyticsClient + ) + self.networkingOTPDataSource = networkingOTPDataSource + networkingOTPDataSource.delegate = self + } + + func startVerificationSession() -> Future { + apiClient + .consumerSessionLookup( + emailAddress: consumerSession.emailAddress, + clientSecret: clientSecret + ) + .chained { [weak self] (lookupConsumerSessionResponse: LookupConsumerSessionResponse) in + guard let self = self else { + return Promise(error: FinancialConnectionsSheetError.unknown(debugDescription: "data source deallocated")) + } + if let consumerSession = lookupConsumerSessionResponse.consumerSession { + self.consumerSession = consumerSession + return self.apiClient.consumerSessionStartVerification( + otpType: "SMS", + customEmailType: nil, + connectionsMerchantName: nil, + consumerSessionClientSecret: consumerSession.clientSecret + ) + } else { + return Promise(error: FinancialConnectionsSheetError.unknown(debugDescription: "invalid consumerSessionLookup response: no consumerSession.clientSecret")) + } + } + } + + func confirmVerificationSession(otpCode: String) -> Future { + return apiClient.consumerSessionConfirmVerification( + otpCode: otpCode, + otpType: "SMS", + consumerSessionClientSecret: consumerSession.clientSecret + ) + } + + func markLinkVerified() -> Future { + return apiClient.markLinkVerified(clientSecret: clientSecret) + } + + func saveToLink() -> Future { + return apiClient.saveAccountsToLink( + emailAddress: nil, + phoneNumber: nil, + country: nil, + selectedAccountIds: [selectedAccountId], + consumerSessionClientSecret: consumerSession.clientSecret, + clientSecret: clientSecret + ) + .chained { _ in + return Promise(value: ()) + } + } +} + +// MARK: - NetworkingOTPDataSourceDelegate + +extension NetworkingSaveToLinkVerificationDataSourceImplementation: NetworkingOTPDataSourceDelegate { + + func networkingOTPDataSource(_ dataSource: NetworkingOTPDataSource, didUpdateConsumerSession consumerSession: ConsumerSessionData) { + self.consumerSession = consumerSession + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingSaveToLinkVerification/NetworkingSaveToLinkVerificationViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingSaveToLinkVerification/NetworkingSaveToLinkVerificationViewController.swift new file mode 100644 index 00000000..60389f5c --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NetworkingSaveToLinkVerification/NetworkingSaveToLinkVerificationViewController.swift @@ -0,0 +1,198 @@ +// +// NetworkingSaveToLinkVerification.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/14/23. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol NetworkingSaveToLinkVerificationViewControllerDelegate: AnyObject { + func networkingSaveToLinkVerificationViewControllerDidFinish( + _ viewController: NetworkingSaveToLinkVerificationViewController, + saveToLinkWithStripeSucceeded: Bool?, + error: Error? + ) + func networkingSaveToLinkVerificationViewController( + _ viewController: NetworkingSaveToLinkVerificationViewController, + didReceiveTerminalError error: Error + ) +} + +final class NetworkingSaveToLinkVerificationViewController: UIViewController { + + private let dataSource: NetworkingSaveToLinkVerificationDataSource + weak var delegate: NetworkingSaveToLinkVerificationViewControllerDelegate? + + private lazy var loadingView: ActivityIndicator = { + let activityIndicator = ActivityIndicator(size: .large) + activityIndicator.color = .textDisabled + activityIndicator.backgroundColor = .customBackgroundColor + return activityIndicator + }() + private lazy var bodyView: NetworkingSaveToLinkVerificationBodyView = { + let bodyView = NetworkingSaveToLinkVerificationBodyView( + email: dataSource.consumerSession.emailAddress, + otpView: otpView + ) + return bodyView + }() + private lazy var otpView: NetworkingOTPView = { + let otpView = NetworkingOTPView(dataSource: dataSource.networkingOTPDataSource) + otpView.delegate = self + return otpView + }() + + init(dataSource: NetworkingSaveToLinkVerificationDataSource) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .customBackgroundColor + + otpView.startVerification() + } + + private func showContent(redactedPhoneNumber: String) { + let pane = PaneWithHeaderLayoutView( + title: STPLocalizedString( + "Sign in to Link", + "The title of a screen where users are informed that they can sign-in-to Link." + ), + subtitle: String(format: STPLocalizedString( + "Enter the code sent to %@.", + "The subtitle/description of a screen where users are informed that they have received a One-Type-Password (OTP) to their phone. '%@' gets replaced by a redacted phone number." + ), redactedPhoneNumber), + contentView: bodyView, + footerView: NetworkingSaveToLinkFooterView( + didSelectNotNow: { [weak self] in + guard let self = self else { return } + self.dataSource + .analyticsClient + .log(eventName: "click.not_now", pane: .networkingSaveToLinkVerification) + self.delegate?.networkingSaveToLinkVerificationViewControllerDidFinish( + self, + saveToLinkWithStripeSucceeded: nil, + error: nil + ) + } + ) + ) + pane.addTo(view: view) + } + + private func showLoadingView(_ show: Bool) { + if show && loadingView.superview == nil { + // first-time we are showing this, so add the view to hierarchy + view.addAndPinSubview(loadingView) + } + + loadingView.isHidden = !show + if show { + loadingView.startAnimating() + } else { + loadingView.stopAnimating() + } + view.bringSubviewToFront(loadingView) // defensive programming to avoid loadingView being hiddden + } +} + +// MARK: - NetworkingOTPViewDelegate + +extension NetworkingSaveToLinkVerificationViewController: NetworkingOTPViewDelegate { + + func networkingOTPViewWillStartVerification(_ view: NetworkingOTPView) { + showLoadingView(true) + } + + func networkingOTPView(_ view: NetworkingOTPView, didStartVerification consumerSession: ConsumerSessionData) { + showLoadingView(false) + showContent(redactedPhoneNumber: consumerSession.redactedPhoneNumber) + } + + func networkingOTPView(_ view: NetworkingOTPView, didFailToStartVerification error: Error) { + showLoadingView(false) + dataSource.analyticsClient.log( + eventName: "networking.verification.error", + parameters: [ + "error": "StartVerificationSessionError" + ], + pane: .networkingSaveToLinkVerification + ) + dataSource.analyticsClient.logUnexpectedError( + error, + errorName: "StartVerificationSessionError", + pane: .networkingSaveToLinkVerification + ) + delegate?.networkingSaveToLinkVerificationViewController(self, didReceiveTerminalError: error) + } + + func networkingOTPViewDidConfirmVerification(_ view: NetworkingOTPView) { + dataSource.saveToLink() + .observe { [weak self] result in + guard let self = self else { return } + switch result { + case .success: + self.dataSource + .analyticsClient + .log( + eventName: "networking.verification.success", + pane: .networkingSaveToLinkVerification + ) + self.delegate?.networkingSaveToLinkVerificationViewControllerDidFinish( + self, + saveToLinkWithStripeSucceeded: true, + error: nil + ) + case .failure(let error): + self.dataSource + .analyticsClient + .log( + eventName: "networking.verification.error", + pane: .networkingSaveToLinkVerification + ) + self.dataSource + .analyticsClient + .logUnexpectedError( + error, errorName: "SaveToLinkError", + pane: .networkingSaveToLinkVerification + ) + self.delegate?.networkingSaveToLinkVerificationViewControllerDidFinish( + self, + saveToLinkWithStripeSucceeded: false, + error: error + ) + } + } + + dataSource.markLinkVerified() + .observe { _ in + // we ignore result + } + } + + func networkingOTPView(_ view: NetworkingOTPView, didTerminallyFailToConfirmVerification error: Error) { + delegate?.networkingSaveToLinkVerificationViewController(self, didReceiveTerminalError: error) + } + + func networkingOTPViewWillStartConsumerLookup(_ view: NetworkingOTPView) { + assertionFailure("we shouldn't call `lookup` for NetworkingSaveToLink") + } + + func networkingOTPViewConsumerNotFound(_ view: NetworkingOTPView) { + assertionFailure("we shouldn't call `lookup` for NetworkingSaveToLink") + } + + func networkingOTPView(_ view: NetworkingOTPView, didFailConsumerLookup error: Error) { + assertionFailure("we shouldn't call `lookup` for NetworkingSaveToLink") + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/PartnerAuth/PartnerAuthDataSource.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/PartnerAuth/PartnerAuthDataSource.swift new file mode 100644 index 00000000..b03bcbcf --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/PartnerAuth/PartnerAuthDataSource.swift @@ -0,0 +1,190 @@ +// +// PartnerAuthDataSource.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/8/22. +// + +import Foundation +@_spi(STP) import StripeCore + +protocol PartnerAuthDataSource: AnyObject { + var institution: FinancialConnectionsInstitution { get } + var manifest: FinancialConnectionsSessionManifest { get } + var returnURL: String? { get } + var analyticsClient: FinancialConnectionsAnalyticsClient { get } + var pendingAuthSession: FinancialConnectionsAuthSession? { get } + var reduceManualEntryProminenceInErrors: Bool { get } + var disableAuthSessionRetrieval: Bool { get } + + func createAuthSession() -> Future + func authorizeAuthSession(_ authSession: FinancialConnectionsAuthSession) -> Future + func cancelPendingAuthSessionIfNeeded() + func recordAuthSessionEvent(eventName: String, authSessionId: String) + func clearReturnURL(authSession: FinancialConnectionsAuthSession, authURL: String) -> Future + func retrieveAuthSession(_ authSession: FinancialConnectionsAuthSession) -> Future +} + +final class PartnerAuthDataSourceImplementation: PartnerAuthDataSource { + + let institution: FinancialConnectionsInstitution + let manifest: FinancialConnectionsSessionManifest + let returnURL: String? + private let apiClient: FinancialConnectionsAPIClient + private let clientSecret: String + let analyticsClient: FinancialConnectionsAnalyticsClient + let reduceManualEntryProminenceInErrors: Bool + var disableAuthSessionRetrieval: Bool { + return manifest.features?["bank_connections_disable_defensive_auth_session_retrieval_on_complete"] == true + } + + // a "pending" auth session is a session which has started + // BUT the session is still yet-to-be authorized + // + // in other words, a `pendingAuthSession` is up for being + // cancelled unless the user successfully authorizes + private(set) var pendingAuthSession: FinancialConnectionsAuthSession? + + init( + institution: FinancialConnectionsInstitution, + manifest: FinancialConnectionsSessionManifest, + returnURL: String?, + apiClient: FinancialConnectionsAPIClient, + clientSecret: String, + analyticsClient: FinancialConnectionsAnalyticsClient, + reduceManualEntryProminenceInErrors: Bool + ) { + self.institution = institution + self.manifest = manifest + self.returnURL = returnURL + self.apiClient = apiClient + self.clientSecret = clientSecret + self.analyticsClient = analyticsClient + self.reduceManualEntryProminenceInErrors = reduceManualEntryProminenceInErrors + } + + func createAuthSession() -> Future { + return apiClient.createAuthSession( + clientSecret: clientSecret, + institutionId: institution.id + ).chained { [weak self] (authSession: FinancialConnectionsAuthSession) in + self?.pendingAuthSession = authSession + return Promise(value: authSession) + } + } + + func clearReturnURL(authSession: FinancialConnectionsAuthSession, authURL: String) -> Future { + let promise = Promise() + + apiClient + .synchronize(clientSecret: clientSecret, returnURL: nil) + .observe { [weak self] result in + guard let self = self else { return } + switch result { + case .success: + let copiedSession = FinancialConnectionsAuthSession(id: authSession.id, + flow: authSession.flow, + institutionSkipAccountSelection: authSession.institutionSkipAccountSelection, + nextPane: authSession.nextPane, + showPartnerDisclosure: authSession.showPartnerDisclosure, + skipAccountSelection: authSession.skipAccountSelection, + url: authURL, + isOauth: authSession.isOauth, + display: authSession.display) + self.pendingAuthSession = copiedSession + promise.fullfill(with: .success(copiedSession)) + case .failure(let error): + promise.reject(with: error) + } + } + + return promise + } + + func cancelPendingAuthSessionIfNeeded() { + guard let pendingAuthSession = pendingAuthSession else { + return + } + self.pendingAuthSession = nil + cancelAuthSession(pendingAuthSession) + .observe { _ in + // we ignore the result because its not important + } + } + + private func cancelAuthSession(_ authSession: FinancialConnectionsAuthSession) -> Future< + FinancialConnectionsAuthSession + > { + return apiClient.cancelAuthSession( + clientSecret: clientSecret, + authSessionId: authSession.id + ) + } + + func authorizeAuthSession(_ authSession: FinancialConnectionsAuthSession) -> Future + { + return apiClient.fetchAuthSessionOAuthResults( + clientSecret: clientSecret, + authSessionId: authSession.id + ) + .chained( + on: DispatchQueue.main, + using: { [weak self] mixedOAuthParameters in + guard let self = self else { + return Promise( + error: FinancialConnectionsSheetError.unknown( + debugDescription: "\(PartnerAuthDataSourceImplementation.self) deallocated." + ) + ) + } + return self.apiClient.authorizeAuthSession( + clientSecret: self.clientSecret, + authSessionId: authSession.id, + publicToken: mixedOAuthParameters.publicToken + ) + } + ) + } + + func recordAuthSessionEvent( + eventName: String, + authSessionId: String + ) { + guard ShouldRecordAuthSessionEvent() else { + // on Stripe SDK Core analytics client we don't send events + // for simulator or tests, so don't send these either... + return + } + + apiClient.recordAuthSessionEvent( + clientSecret: clientSecret, + authSessionId: authSessionId, + eventNamespace: "partner-auth-lifecycle", + eventName: eventName + ) + .observe { _ in + // we don't do anything with the event response + } + } + + func retrieveAuthSession( + _ authSession: FinancialConnectionsAuthSession + ) -> Future { + return apiClient.retrieveAuthSession( + clientSecret: clientSecret, + authSessionId: authSession.id + ).chained { [weak self] (authSession: FinancialConnectionsAuthSession) in + // update the `pendingAuthSession` with the latest from the server + self?.pendingAuthSession = authSession + return Promise(value: authSession) + } + } +} + +private func ShouldRecordAuthSessionEvent() -> Bool { + #if targetEnvironment(simulator) + return false + #else + return NSClassFromString("XCTest") == nil + #endif +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/PartnerAuth/PartnerAuthViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/PartnerAuth/PartnerAuthViewController.swift new file mode 100644 index 00000000..78e10414 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/PartnerAuth/PartnerAuthViewController.swift @@ -0,0 +1,902 @@ +// +// PartnerAuthViewController.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 7/25/22. +// + +import AuthenticationServices +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol PartnerAuthViewControllerDelegate: AnyObject { + func partnerAuthViewControllerUserDidSelectAnotherBank(_ viewController: PartnerAuthViewController) + func partnerAuthViewControllerDidRequestToGoBack(_ viewController: PartnerAuthViewController) + func partnerAuthViewControllerUserDidSelectEnterBankDetailsManually(_ viewController: PartnerAuthViewController) + func partnerAuthViewController(_ viewController: PartnerAuthViewController, didReceiveTerminalError error: Error) + func partnerAuthViewController( + _ viewController: PartnerAuthViewController, + didCompleteWithAuthSession authSession: FinancialConnectionsAuthSession + ) +} + +final class PartnerAuthViewController: UIViewController { + + /** + Unfortunately there is a need for this state-full parameter. When we get url callback the app might not be in foreground state. + If we then authorize the auth session will fail as you can't do background networking without special permission. + */ + private var unprocessedReturnURL: URL? + private var subscribedToURLNotifications = false + private var subscribedToAppActiveNotifications = false + private var continueStateView: ContinueStateView? + + private let dataSource: PartnerAuthDataSource + private var institution: FinancialConnectionsInstitution { + return dataSource.institution + } + private var webAuthenticationSession: ASWebAuthenticationSession? + private var lastHandledAuthenticationSessionReturnUrl: URL? + weak var delegate: PartnerAuthViewControllerDelegate? + + private lazy var establishingConnectionLoadingView: UIView = { + let establishingConnectionLoadingView = ReusableInformationView( + iconType: .loading, + title: STPLocalizedString( + "Establishing connection", + "The title of the loading screen that appears after a user selected a bank. The user is waiting for Stripe to establish a bank connection with the bank." + ), + subtitle: STPLocalizedString( + "Please wait while we connect to your bank.", + "The subtitle of the loading screen that appears after a user selected a bank. The user is waiting for Stripe to establish a bank connection with the bank." + ) + ) + establishingConnectionLoadingView.isHidden = true + return establishingConnectionLoadingView + }() + + private lazy var retrievingAccountsView: UIView = { + return buildRetrievingAccountsView() + }() + + init(dataSource: PartnerAuthDataSource) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .customBackgroundColor + dataSource + .analyticsClient + .logPaneLoaded(pane: .partnerAuth) + createAuthSession() + } + + private func createAuthSession() { + assertMainQueue() + + showEstablishingConnectionLoadingView(true) + dataSource + .createAuthSession() + .observe(on: .main) { [weak self] result in + guard let self = self else { return } + // order is important so be careful of moving + self.showEstablishingConnectionLoadingView(false) + switch result { + case .success(let authSession): + self.createdAuthSession(authSession) + case .failure(let error): + self.showErrorView(error) + } + } + } + + private func createdAuthSession(_ authSession: FinancialConnectionsAuthSession) { + dataSource.recordAuthSessionEvent( + eventName: "launched", + authSessionId: authSession.id + ) + + if authSession.isOauthNonOptional, let prepaneModel = authSession.display?.text?.oauthPrepane { + let prepaneView = PrepaneView( + prepaneModel: prepaneModel, + didSelectURL: { [weak self] url in + self?.didSelectURLInTextFromBackend(url) + }, + didSelectContinue: { [weak self] in + guard let self = self else { return } + self.dataSource.analyticsClient.log( + eventName: "click.prepane.continue", + parameters: [ + "requires_native_redirect": authSession.requiresNativeRedirect + ], + pane: .partnerAuth + ) + + if authSession.requiresNativeRedirect { + self.openInstitutionAuthenticationNativeRedirect(authSession: authSession) + } else { + self.openInstitutionAuthenticationWebView(authSession: authSession) + } + } + ) + view.addAndPinSubview(prepaneView) + + dataSource.recordAuthSessionEvent( + eventName: "loaded", + authSessionId: authSession.id + ) + } else { + // a legacy (non-oauth) institution will have a blank background + // during presenting + dismissing of the Web View, so + // add a loading spinner to fill some of the blank space + let activityIndicator = ActivityIndicator(size: .large) + activityIndicator.color = .textDisabled + activityIndicator.backgroundColor = .customBackgroundColor + activityIndicator.startAnimating() + view.addAndPinSubview(activityIndicator) + + openInstitutionAuthenticationWebView(authSession: authSession) + } + } + + private func showErrorView(_ error: Error) { + // all Partner Auth errors hide the back button + // and all errors end up in user having to exit + // PartnerAuth to try again + navigationItem.hidesBackButton = true + + let allowManualEntryInErrors = (dataSource.manifest.allowManualEntry && !dataSource.reduceManualEntryProminenceInErrors) + let errorView: UIView? + if let error = error as? StripeError, + case .apiError(let apiError) = error, + let extraFields = apiError.allResponseFields["extra_fields"] as? [String: Any], + let institutionUnavailable = extraFields["institution_unavailable"] as? Bool, + institutionUnavailable + { + let institutionIconView = InstitutionIconView(size: .large, showWarning: true) + institutionIconView.setImageUrl(institution.icon?.default) + let primaryButtonConfiguration = ReusableInformationView.ButtonConfiguration( + title: String.Localized.select_another_bank, + action: { [weak self] in + guard let self = self else { return } + self.delegate?.partnerAuthViewControllerUserDidSelectAnotherBank(self) + } + ) + if let expectedToBeAvailableAt = extraFields["expected_to_be_available_at"] as? TimeInterval { + let expectedToBeAvailableDate = Date(timeIntervalSince1970: expectedToBeAvailableAt) + let dateFormatter = DateFormatter() + dateFormatter.timeStyle = .short + let expectedToBeAvailableTimeString = dateFormatter.string(from: expectedToBeAvailableDate) + errorView = ReusableInformationView( + iconType: .view(institutionIconView), + title: String( + format: STPLocalizedString( + "%@ is undergoing maintenance", + "Title of a screen that shows an error. The error indicates that the bank user selected is currently under maintenance." + ), + institution.name + ), + subtitle: { + let beginningOfSubtitle: String = { + if IsToday(expectedToBeAvailableDate) { + return String( + format: STPLocalizedString( + "Maintenance is scheduled to end at %@.", + "The first part of a subtitle/description of a screen that shows an error. The error indicates that the bank user selected is currently under maintenance." + ), + expectedToBeAvailableTimeString + ) + } else { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .short + let expectedToBeAvailableDateString = dateFormatter.string( + from: expectedToBeAvailableDate + ) + return String( + format: STPLocalizedString( + "Maintenance is scheduled to end on %@ at %@.", + "The first part of a subtitle/description of a screen that shows an error. The error indicates that the bank user selected is currently under maintenance." + ), + expectedToBeAvailableDateString, + expectedToBeAvailableTimeString + ) + } + }() + let endOfSubtitle: String = { + if allowManualEntryInErrors { + return STPLocalizedString( + "Please enter your bank details manually or select another bank.", + "The second part of a subtitle/description of a screen that shows an error. The error indicates that the bank user selected is currently under maintenance." + ) + } else { + return STPLocalizedString( + "Please select another bank or try again later.", + "The second part of a subtitle/description of a screen that shows an error. The error indicates that the bank user selected is currently under maintenance." + ) + } + }() + return beginningOfSubtitle + " " + endOfSubtitle + }(), + primaryButtonConfiguration: primaryButtonConfiguration, + secondaryButtonConfiguration: allowManualEntryInErrors + ? ReusableInformationView.ButtonConfiguration( + title: String.Localized.enter_bank_details_manually, + action: { [weak self] in + guard let self = self else { return } + self.delegate?.partnerAuthViewControllerUserDidSelectEnterBankDetailsManually(self) + } + ) : nil + ) + dataSource.analyticsClient.logExpectedError( + error, + errorName: "InstitutionPlannedDowntimeError", + pane: .partnerAuth + ) + } else { + errorView = ReusableInformationView( + iconType: .view(institutionIconView), + title: String( + format: STPLocalizedString( + "%@ is currently unavailable", + "Title of a screen that shows an error. The error indicates that the bank user selected is currently under maintenance." + ), + institution.name + ), + subtitle: { + if allowManualEntryInErrors { + return STPLocalizedString( + "Please enter your bank details manually or select another bank.", + "The subtitle/description of a screen that shows an error. The error indicates that the bank user selected is currently under maintenance." + ) + } else { + return STPLocalizedString( + "Please select another bank or try again later.", + "The subtitle/description of a screen that shows an error. The error indicates that the bank user selected is currently under maintenance." + ) + } + }(), + primaryButtonConfiguration: primaryButtonConfiguration, + secondaryButtonConfiguration: allowManualEntryInErrors + ? ReusableInformationView.ButtonConfiguration( + title: String.Localized.enter_bank_details_manually, + action: { [weak self] in + guard let self = self else { return } + self.delegate?.partnerAuthViewControllerUserDidSelectEnterBankDetailsManually(self) + } + ) : nil + ) + dataSource.analyticsClient.logExpectedError( + error, + errorName: "InstitutionUnplannedDowntimeError", + pane: .partnerAuth + ) + } + } else { + dataSource.analyticsClient.logUnexpectedError( + error, + errorName: "PartnerAuthError", + pane: .partnerAuth + ) + + // if we didn't get specific errors back, we don't know + // what's wrong, so show a generic error + delegate?.partnerAuthViewController(self, didReceiveTerminalError: error) + errorView = nil + + // keep showing the loading view while we transition to + // terminal error + showEstablishingConnectionLoadingView(true) + } + + if let errorView = errorView { + view.addAndPinSubviewToSafeArea(errorView) + } + } + + private func handleAuthSessionCompletionWithStatus( + _ status: String, + _ authSession: FinancialConnectionsAuthSession + ) { + if status == "success" { + self.dataSource.recordAuthSessionEvent( + eventName: "success", + authSessionId: authSession.id + ) + + if authSession.isOauthNonOptional { + // for OAuth flows, we need to fetch OAuth results + self.authorizeAuthSession(authSession) + } else { + // for legacy flows (non-OAuth), we do not need to fetch OAuth results, or call authorize + self.delegate?.partnerAuthViewController(self, didCompleteWithAuthSession: authSession) + } + } else if status == "failure" { + self.dataSource.recordAuthSessionEvent( + eventName: "failure", + authSessionId: authSession.id + ) + + // cancel current auth session + self.dataSource.cancelPendingAuthSessionIfNeeded() + + // show a terminal error + self.showErrorView( + FinancialConnectionsSheetError.unknown( + debugDescription: "Shim returned a failure." + ) + ) + } else { // assume `status == cancel` + self.checkIfAuthSessionWasSuccessful( + authSession: authSession, + completionHandler: { [weak self] isSuccess in + guard let self = self else { return } + if !isSuccess { + self.dataSource.recordAuthSessionEvent( + eventName: "cancel", + authSessionId: authSession.id + ) + + // cancel current auth session + self.dataSource.cancelPendingAuthSessionIfNeeded() + + // whether legacy or OAuth, we always go back + // if we got an explicit cancel from backend + self.navigateBack() + } + } + ) + } + } + + private func handleAuthSessionCompletionWithNoStatus( + _ authSession: FinancialConnectionsAuthSession, + _ error: Error? + ) { + if authSession.isOauthNonOptional { + // on "manual cancels" (for OAuth) we log retry event: + dataSource.recordAuthSessionEvent( + eventName: "retry", + authSessionId: authSession.id + ) + } else { + // on "manual cancels" (for Legacy) we log cancel event: + dataSource.recordAuthSessionEvent( + eventName: "cancel", + authSessionId: authSession.id + ) + } + + // cancel current auth session because something went wrong + dataSource.cancelPendingAuthSessionIfNeeded() + + if authSession.isOauthNonOptional { + // for OAuth institutions, we remain on the pre-pane, + // but create a brand new auth session + createAuthSession() + } else { + // for legacy (non-OAuth) institutions, we navigate back to InstitutionPickerViewController + navigateBack() + } + } + + private func openInstitutionAuthenticationNativeRedirect(authSession: FinancialConnectionsAuthSession) { + guard + let urlString = authSession.url?.droppingNativeRedirectPrefix(), + let url = URL(string: urlString) + else { + self.showErrorView( + FinancialConnectionsSheetError.unknown( + debugDescription: "Malformed auth session url." + ) + ) + return + } + self.continueStateView = ContinueStateView( + institutionImageUrl: self.institution.icon?.default, + didSelectContinue: { [weak self] in + guard let self = self else { return } + self.dataSource.analyticsClient.log( + eventName: "click.apptoapp.continue", + pane: .partnerAuth + ) + self.continueStateView?.removeFromSuperview() + self.continueStateView = nil + self.openInstitutionAuthenticationNativeRedirect(authSession: authSession) + } + ) + self.view.addAndPinSubview(self.continueStateView!) + + self.subscribeToURLAndAppActiveNotifications() + UIApplication.shared.open(url, options: [UIApplication.OpenExternalURLOptionsKey.universalLinksOnly: true]) { (success) in + if success { return } + // This means banking app is not installed + self.clearStateAndUnsubscribeFromNotifications() + + self.showEstablishingConnectionLoadingView(true) + self.dataSource + .clearReturnURL(authSession: authSession, authURL: urlString) + .observe(on: .main) { [weak self] result in + guard let self = self else { return } + // order is important so be careful of moving + self.showEstablishingConnectionLoadingView(false) + switch result { + case .success(let authSession): + self.openInstitutionAuthenticationWebView(authSession: authSession) + case .failure(let error): + self.showErrorView(error) + } + } + } + } + + private func openInstitutionAuthenticationWebView(authSession: FinancialConnectionsAuthSession) { + guard let urlString = authSession.url, let url = URL(string: urlString) else { + assertionFailure("Expected to get a URL back from authorization session.") + dataSource + .analyticsClient + .logUnexpectedError( + FinancialConnectionsSheetError.unknown( + debugDescription: "Invalid or NULL URL returned from auth session" + ), + errorName: "InvalidAuthSessionURL", + pane: .partnerAuth + ) + // navigate back to institution picker so user can try again + navigateBack() + return + } + + lastHandledAuthenticationSessionReturnUrl = nil + let webAuthenticationSession = ASWebAuthenticationSession( + url: url, + callbackURLScheme: "stripe", + // note that `error` is NOT related to our backend + // sending errors, it's only related to `ASWebAuthenticationSession` + completionHandler: { [weak self] returnUrl, error in + guard let self = self else { return } + if self.lastHandledAuthenticationSessionReturnUrl != nil + && self.lastHandledAuthenticationSessionReturnUrl == returnUrl + { + // for unknown reason, `ASWebAuthenticationSession` can _sometimes_ + // call the `completionHandler` twice + // + // we use `returnUrl`, instead of a `Bool`, in the case that + // this completion handler can sometimes return different URL's + self.dataSource.recordAuthSessionEvent( + eventName: "ios_double_return", + authSessionId: authSession.id + ) + return + } + self.lastHandledAuthenticationSessionReturnUrl = returnUrl + + if let returnUrl = returnUrl, + returnUrl.scheme == "stripe", + let urlComponsents = URLComponents(url: returnUrl, resolvingAgainstBaseURL: true), + let status = urlComponsents.queryItems?.first(where: { $0.name == "status" })?.value + { + self.logUrlReceived(returnUrl, status: status, authSessionId: authSession.id) + self.handleAuthSessionCompletionWithStatus(status, authSession) + } + // we did NOT get a `status` back from the backend, + // so assume a "cancel" + else { + self.logUrlReceived(returnUrl, status: nil, authSessionId: authSession.id) + + if let error = error { + if + (error as NSError).domain == ASWebAuthenticationSessionErrorDomain, + (error as NSError).code == ASWebAuthenticationSessionError.canceledLogin.rawValue + { + self.dataSource + .analyticsClient + .log( + eventName: "secure_webview_cancel", + pane: .partnerAuth + ) + } else { + self.dataSource + .analyticsClient + .logUnexpectedError( + error, + errorName: "ASWebAuthenticationSessionError", + pane: .partnerAuth + ) + } + } + + self.checkIfAuthSessionWasSuccessful( + authSession: authSession, + completionHandler: { [weak self] isSuccess in + guard let self = self else { return } + if !isSuccess { + self.handleAuthSessionCompletionWithNoStatus(authSession, error) + } + } + ) + } + + self.webAuthenticationSession = nil + } + ) + self.webAuthenticationSession = webAuthenticationSession + + webAuthenticationSession.presentationContextProvider = self + webAuthenticationSession.prefersEphemeralWebBrowserSession = true + + if #available(iOS 13.4, *) { + if !webAuthenticationSession.canStart { + dataSource.recordAuthSessionEvent( + eventName: "ios-browser-cant-start", + authSessionId: authSession.id + ) + // navigate back to bank picker so user can try again + // + // this may be an odd way to handle an issue, but trying again + // is potentially better than forcing user to close the whole + // auth session + navigateBack() + return // skip starting + } + } + + if !webAuthenticationSession.start() { + dataSource.recordAuthSessionEvent( + eventName: "ios-browser-did-not-start", + authSessionId: authSession.id + ) + // navigate back to bank picker so user can try again + // + // this may be an odd way to handle an issue, but trying again + // is potentially better than forcing user to close the whole + // auth session + navigateBack() + } else { + // we successfully launched the secure web browser + dataSource + .analyticsClient + .log( + eventName: "auth_session.opened", + parameters: [ + "browser": "ASWebAuthenticationSession", + "auth_session_id": authSession.id, + "flow": authSession.flow?.rawValue ?? "null", + ], + pane: .partnerAuth + ) + + if authSession.isOauthNonOptional { + dataSource.recordAuthSessionEvent( + eventName: "oauth-launched", + authSessionId: authSession.id + ) + } else { + dataSource.recordAuthSessionEvent( + eventName: "legacy-launched", + authSessionId: authSession.id + ) + } + } + } + + private func authorizeAuthSession(_ authSession: FinancialConnectionsAuthSession) { + showRetrievingAccountsView(true) + dataSource + .authorizeAuthSession(authSession) + .observe(on: .main) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let authSession): + self.delegate?.partnerAuthViewController(self, didCompleteWithAuthSession: authSession) + + // hide the loading view after a delay to prevent + // the screen from flashing _while_ the transition + // to the next screen takes place + // + // note that it should be impossible to view this screen + // after a successful `authorizeAuthSession`, so + // calling `showEstablishingConnectionLoadingView(false)` is + // defensive programming anyway + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.showRetrievingAccountsView(false) + } + case .failure(let error): + self.showRetrievingAccountsView(false) // important to come BEFORE showing error view so we avoid showing back button + self.showErrorView(error) + assert(self.navigationItem.hidesBackButton) + } + } + } + + private func navigateBack() { + delegate?.partnerAuthViewControllerDidRequestToGoBack(self) + } + + private func showEstablishingConnectionLoadingView(_ show: Bool) { + showView(loadingView: establishingConnectionLoadingView, show: show) + } + + private func showRetrievingAccountsView(_ show: Bool) { + showView(loadingView: retrievingAccountsView, show: show) + } + + private func showView(loadingView: UIView, show: Bool) { + if loadingView.superview == nil { + view.addAndPinSubviewToSafeArea(loadingView) + } + view.bringSubviewToFront(loadingView) // bring to front in-case something else is covering it + + navigationItem.hidesBackButton = show + loadingView.isHidden = !show + } + + private func didSelectURLInTextFromBackend(_ url: URL) { + AuthFlowHelpers.handleURLInTextFromBackend( + url: url, + pane: .partnerAuth, + analyticsClient: dataSource.analyticsClient, + handleStripeScheme: { urlHost in + if urlHost == "data-access-notice" { + if let dataAccessNoticeModel = dataSource.pendingAuthSession?.display?.text?.oauthPrepane? + .dataAccessNotice + { + let consentBottomSheetModel = ConsentBottomSheetModel( + title: dataAccessNoticeModel.title, + subtitle: dataAccessNoticeModel.subtitle, + body: ConsentBottomSheetModel.Body( + bullets: dataAccessNoticeModel.body.bullets + ), + extraNotice: dataAccessNoticeModel.connectedAccountNotice, + learnMore: dataAccessNoticeModel.learnMore, + cta: dataAccessNoticeModel.cta + ) + ConsentBottomSheetViewController.present( + withModel: consentBottomSheetModel, + didSelectUrl: { [weak self] url in + self?.didSelectURLInTextFromBackend(url) + } + ) + } + } + } + ) + } + + // There are edge-cases where redirect links don't work properly. + // Check the auth session in-case the auth session was successful. + private func checkIfAuthSessionWasSuccessful( + authSession: FinancialConnectionsAuthSession, + completionHandler: @escaping (_ isSuccess: Bool) -> Void + ) { + guard !dataSource.disableAuthSessionRetrieval else { + // if auth session retrieval is disabled, go to the default case + completionHandler(false) + return + } + + showEstablishingConnectionLoadingView(true) + dataSource + .retrieveAuthSession(authSession) + .observe { [weak self] result in + guard let self = self else { return } + self.showEstablishingConnectionLoadingView(false) + + self.dataSource + .analyticsClient + .log( + eventName: "auth_session.retrieved", + parameters: [ + "auth_session_id": authSession.id, + "next_pane": (try? result.get())?.nextPane.rawValue ?? "null", + ], + pane: .partnerAuth + ) + + switch result { + case .success(let authSession): + if authSession.nextPane != .partnerAuth { + completionHandler(true) + self.dataSource.recordAuthSessionEvent( + eventName: "success", + authSessionId: authSession.id + ) + // abstract auth handles calling `authorize` + self.delegate?.partnerAuthViewController( + self, + didCompleteWithAuthSession: authSession + ) + } else { + completionHandler(false) + } + case .failure(let error): + self.dataSource + .analyticsClient + .logUnexpectedError( + error, + errorName: "RetrieveAuthSessionError", + pane: .partnerAuth + ) + completionHandler(false) + } + } + } + + private func logUrlReceived( + _ url: URL?, + status: String?, + authSessionId: String + ) { + dataSource + .analyticsClient + .log( + eventName: "auth_session.url_received", + parameters: [ + "status": status ?? "null", + "url": url?.absoluteString ?? "null", + "auth_session_id": authSessionId, + ], + pane: .partnerAuth + ) + } +} + +// MARK: - STPURLCallbackListener + +extension PartnerAuthViewController: STPURLCallbackListener { + + private func handleAuthSessionCompletionFromNativeRedirect(_ url: URL) { + assertMainQueue() + + guard let authSession = dataSource.pendingAuthSession else { + return + } + guard var urlComponsents = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + dataSource.recordAuthSessionEvent( + eventName: "native-app-to-app-failed-to-resolve-url", + authSessionId: authSession.id + ) + return + } + urlComponsents.query = url.fragment + + if + let status = urlComponsents.queryItems?.first(where: { $0.name == "code" })?.value, + let authSessionId = urlComponsents.queryItems?.first(where: { $0.name == "authSessionId" })?.value, + authSessionId == dataSource.pendingAuthSession?.id + { + logUrlReceived(url, status: status, authSessionId: authSession.id) + handleAuthSessionCompletionWithStatus(status, authSession) + } else { + logUrlReceived(url, status: nil, authSessionId: authSession.id) + handleAuthSessionCompletionWithNoStatus(authSession, nil) + } + } + + func handleURLCallback(_ url: URL) -> Bool { + DispatchQueue.main.async { + self.unprocessedReturnURL = url + self.handleAuthSessionCompletionFromNativeRedirectIfNeeded() + } + return true + } +} + +// MARK: - Authentication restart helpers + +private extension PartnerAuthViewController { + + private func subscribeToURLAndAppActiveNotifications() { + assertMainQueue() + + subscribeToURLNotifications() + if !subscribedToAppActiveNotifications { + subscribedToAppActiveNotifications = true + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidBecomeActiveNotification), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + } + } + + private func subscribeToURLNotifications() { + assertMainQueue() + + guard let returnURL = dataSource.returnURL, + let url = URL(string: returnURL) + else { + return + } + if !subscribedToURLNotifications { + subscribedToURLNotifications = true + STPURLCallbackHandler.shared().register( + self, + for: url + ) + } + } + + private func unsubscribeFromNotifications() { + assertMainQueue() + + NotificationCenter.default.removeObserver( + self, + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + STPURLCallbackHandler.shared().unregisterListener(self) + subscribedToURLNotifications = false + subscribedToAppActiveNotifications = false + } + + @objc func handleDidBecomeActiveNotification() { + DispatchQueue.main.async { + self.handleAuthSessionCompletionFromNativeRedirectIfNeeded() + } + } + + private func clearStateAndUnsubscribeFromNotifications() { + unprocessedReturnURL = nil + continueStateView?.removeFromSuperview() + continueStateView = nil + unsubscribeFromNotifications() + } + + private func handleAuthSessionCompletionFromNativeRedirectIfNeeded() { + assertMainQueue() + + guard UIApplication.shared.applicationState == .active else { + /** + When we get url callback the app might not be in foreground state. + If we then proceed with authorization network request might fail as we will be doing background networking without special permission.. + */ + return + } + if let url = unprocessedReturnURL { + if let authSession = dataSource.pendingAuthSession { + dataSource.recordAuthSessionEvent( + eventName: "native-app-to-app-redirect-url-received", + authSessionId: authSession.id + ) + } + handleAuthSessionCompletionFromNativeRedirect(url) + clearStateAndUnsubscribeFromNotifications() + } else if let authSession = dataSource.pendingAuthSession { + self.checkIfAuthSessionWasSuccessful( + authSession: authSession, + completionHandler: { [weak self] isSuccess in + if isSuccess { + self?.clearStateAndUnsubscribeFromNotifications() + } else { + // the default case is to not do anything + // user can press "Continue" to re-start + // app-to-app + } + } + ) + } + } +} + +// MARK: - ASWebAuthenticationPresentationContextProviding + +/// :nodoc: +extension PartnerAuthViewController: ASWebAuthenticationPresentationContextProviding { + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return self.view.window ?? ASPresentationAnchor() + } +} + +private func IsToday(_ comparisonDate: Date) -> Bool { + return Calendar.current.startOfDay(for: comparisonDate) == Calendar.current.startOfDay(for: Date()) +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/PartnerAuth/PrepaneImageView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/PartnerAuth/PrepaneImageView.swift new file mode 100644 index 00000000..52fbd358 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/PartnerAuth/PrepaneImageView.swift @@ -0,0 +1,104 @@ +// +// PrepaneImageView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 1/10/23. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit +import WebKit + +final class PrepaneImageView: UIView { + + init(imageURLString: String) { + super.init(frame: .zero) + backgroundColor = .backgroundContainer + clipsToBounds = true + layer.cornerRadius = 8.0 + + // first we load an image (or GIF) into a WebView + let imageView = GIFImageView(gifUrlString: imageURLString) + // the WebView is surrounded by a background that imitates the GIF presented inside of a phone + let phoneBackgroundView = CreatePhoneBackgroundView(imageView: imageView) + // we center the phone+gif in the middle + let centeringView = CreateCenteringView(centeredView: phoneBackgroundView) + addAndPinSubview(centeringView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private func CreatePhoneBackgroundView(imageView: UIView) -> UIView { + let containerView = UIView() + + let backgroundPhoneImageView = UIImageView(image: Image.prepane_phone_background.makeImage()) + backgroundPhoneImageView.contentMode = .scaleToFill + backgroundPhoneImageView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(backgroundPhoneImageView) + NSLayoutConstraint.activate([ + backgroundPhoneImageView.topAnchor.constraint(equalTo: containerView.topAnchor), + backgroundPhoneImageView.widthAnchor.constraint(equalToConstant: 480), + backgroundPhoneImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), + backgroundPhoneImageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + ]) + + containerView.addAndPinSubview(imageView) + return containerView +} + +private func CreateCenteringView(centeredView: UIView) -> UIView { + let leftSpacerView = UIView() + leftSpacerView.setContentHuggingPriority(.defaultLow, for: .horizontal) + + let rightSpacerView = UIView() + rightSpacerView.setContentHuggingPriority(.defaultLow, for: .horizontal) + + let horizontalStackView = UIStackView( + arrangedSubviews: [leftSpacerView, centeredView, rightSpacerView] + ) + horizontalStackView.axis = .horizontal + horizontalStackView.distribution = .equalCentering + horizontalStackView.alignment = .center + return horizontalStackView +} + +private final class GIFImageView: UIView, WKNavigationDelegate { + + private let webView = WKWebView() + + override var intrinsicContentSize: CGSize { + return CGSize(width: 256, height: 264) + } + + init(gifUrlString: String) { + super.init(frame: .zero) + let htmlString = + """ + + + + + + + + + + + """ + + webView.scrollView.isScrollEnabled = false + webView.isUserInteractionEnabled = false + webView.loadHTMLString(htmlString, baseURL: nil) + addAndPinSubview(webView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/PartnerAuth/PrepaneView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/PartnerAuth/PrepaneView.swift new file mode 100644 index 00000000..d5cff57a --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/PartnerAuth/PrepaneView.swift @@ -0,0 +1,278 @@ +// +// PrepaneView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 1/9/23. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +final class PrepaneView: UIView { + + private let didSelectContinue: () -> Void + + init( + prepaneModel: FinancialConnectionsOAuthPrepane, + didSelectURL: @escaping (URL) -> Void, + didSelectContinue: @escaping () -> Void + ) { + self.didSelectContinue = didSelectContinue + super.init(frame: .zero) + backgroundColor = .customBackgroundColor + let bodyContainsImage = prepaneModel.body.entries?.contains(where: { + if case .image = $0.content { + return true + } else { + return false + } + }) ?? false + let paneLayoutView = PaneWithHeaderLayoutView( + icon: { + if let institutionIcon = prepaneModel.institutionIcon, + let institutionImageUrl = institutionIcon.default + { + return .view( + { + let institutionIconView = InstitutionIconView(size: .large) + institutionIconView.setImageUrl(institutionImageUrl) + return institutionIconView + }() + ) + } else { + return nil + } + }(), + title: prepaneModel.title, + subtitle: nil, + contentView: CreateContentView( + prepaneBodyModel: prepaneModel.body, + // put the partner notice in the BODY if an image exists + // (...because we want to avoid the partner notice clipping the image) + prepanePartnerNoticeModel: bodyContainsImage ? prepaneModel.partnerNotice : nil, + didSelectURL: didSelectURL + ), + headerAndContentSpacing: 8, + footerView: CreateFooterView( + prepaneCtaModel: prepaneModel.cta, + // put the partner notice in the FOOTER if an image does NOT exist + // (...because partner notice will not be able to clip image) + prepanePartnerNoticeModel: bodyContainsImage ? nil : prepaneModel.partnerNotice, + didSelectURL: didSelectURL, + view: self + ) + ) + paneLayoutView.addTo(view: self) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc fileprivate func didSelectContinueButton() { + didSelectContinue() + } +} + +private func CreateContentView( + prepaneBodyModel: FinancialConnectionsOAuthPrepane.OauthPrepaneBody, + prepanePartnerNoticeModel: FinancialConnectionsOAuthPrepane.OauthPrepanePartnerNotice?, + didSelectURL: @escaping (URL) -> Void +) -> UIView { + let verticalStackView = UIStackView() + verticalStackView.spacing = 22 + verticalStackView.axis = .vertical + + prepaneBodyModel.entries?.forEach { entry in + switch entry.content { + case .text(let text): + let label = AttributedTextView( + font: .label(.large), + boldFont: .label(.largeEmphasized), + linkFont: .label(.largeEmphasized), + textColor: .textPrimary + ) + label.setText(text, action: didSelectURL) + verticalStackView.addArrangedSubview(label) + case .image(let image): + if let imageUrl = image.default { + let prepaneImageView = PrepaneImageView(imageURLString: imageUrl) + verticalStackView.addArrangedSubview(prepaneImageView) + } + case .unparsable: + break // we encountered an unknown type, so just skip + } + } + + if let prepanePartnerNoticeModel = prepanePartnerNoticeModel { + verticalStackView.addArrangedSubview( + CreatePartnerDisclosureView( + partnerNoticeModel: prepanePartnerNoticeModel, + didSelectURL: didSelectURL + ) + ) + } + + return verticalStackView +} + +private func CreateFooterView( + prepaneCtaModel: FinancialConnectionsOAuthPrepane.OauthPrepaneCTA, + prepanePartnerNoticeModel: FinancialConnectionsOAuthPrepane.OauthPrepanePartnerNotice?, + didSelectURL: @escaping (URL) -> Void, + view: PrepaneView +) -> UIView { + let continueButton = Button(configuration: .financialConnectionsPrimary) + continueButton.title = prepaneCtaModel.text + continueButton.addTarget(view, action: #selector(PrepaneView.didSelectContinueButton), for: .touchUpInside) + continueButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + continueButton.heightAnchor.constraint(equalToConstant: 56) + ]) + continueButton.accessibilityIdentifier = "prepane_continue_button" + + let footerStackView = UIStackView() + footerStackView.axis = .vertical + footerStackView.spacing = 20 + + if let prepanePartnerNoticeModel = prepanePartnerNoticeModel { + footerStackView.addArrangedSubview( + CreatePartnerDisclosureView( + partnerNoticeModel: prepanePartnerNoticeModel, + didSelectURL: didSelectURL + ) + ) + } + footerStackView.addArrangedSubview(continueButton) + + return footerStackView +} + +private func CreatePartnerDisclosureView( + partnerNoticeModel: FinancialConnectionsOAuthPrepane.OauthPrepanePartnerNotice, + didSelectURL: @escaping (URL) -> Void +) -> UIView { + let horizontalStackView = UIStackView() + horizontalStackView.spacing = 12 + horizontalStackView.isLayoutMarginsRelativeArrangement = true + horizontalStackView.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: 10, + leading: 12, + bottom: 10, + trailing: 12 + ) + horizontalStackView.alignment = .center + horizontalStackView.backgroundColor = .backgroundContainer + horizontalStackView.layer.cornerRadius = 8 + + if let partnerIconUrlString = partnerNoticeModel.partnerIcon?.default { + horizontalStackView.addArrangedSubview( + { + let partnerIconImageView = UIImageView() + partnerIconImageView.setImage(with: partnerIconUrlString) + partnerIconImageView.clipsToBounds = true + partnerIconImageView.layer.cornerRadius = 4 + partnerIconImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + partnerIconImageView.widthAnchor.constraint(equalToConstant: 24), + partnerIconImageView.heightAnchor.constraint(equalToConstant: 24), + ]) + return partnerIconImageView + }() + ) + } + + horizontalStackView.addArrangedSubview( + { + let partnerDisclosureLabel = AttributedTextView( + font: .label(.small), + boldFont: .label(.smallEmphasized), + linkFont: .label(.smallEmphasized), + textColor: .textSecondary + ) + partnerDisclosureLabel.setText( + partnerNoticeModel.text, + action: didSelectURL + ) + return partnerDisclosureLabel + }() + ) + + return horizontalStackView +} + +#if DEBUG + +import SwiftUI + +private struct PrepaneViewUIViewRepresentable: UIViewRepresentable { + + func makeUIView(context: Context) -> PrepaneView { + PrepaneView( + prepaneModel: FinancialConnectionsOAuthPrepane( + institutionIcon: nil, + title: "Log in to Capital One and grant the right permissions", + body: FinancialConnectionsOAuthPrepane.OauthPrepaneBody( + entries: [ + .init( + content: .text("Be sure to select **Account Number & Routing Number**.") + ), + .init( + content: .image( + FinancialConnectionsImage( + default: "https://js.stripe.com/v3/f0620405e3235ff4736f6876f4d3d045.gif" + ) + ) + ), + .init( + content: .text( + "We will only share the [requested data](https://www.stripe.com) with [Merchant] even if your bank grants Stripe access to more." + ) + ), + ] + ), + partnerNotice: FinancialConnectionsOAuthPrepane.OauthPrepanePartnerNotice( + partnerIcon: nil, + text: + "Stripe works with partners like [Partner Name] to reliability offer access to thousands of financial institutions. [Learn more](https://www.stripe.com)" + ), + cta: FinancialConnectionsOAuthPrepane.OauthPrepaneCTA( + text: "Continue", + icon: nil + ), + dataAccessNotice: FinancialConnectionsDataAccessNotice( + title: "", + subtitle: nil, + body: FinancialConnectionsDataAccessNotice.Body(bullets: []), + connectedAccountNotice: nil, + learnMore: "", + cta: "" + ) + ), + didSelectURL: { _ in }, + didSelectContinue: {} + ) + } + + func updateUIView(_ uiView: PrepaneView, context: Context) {} +} + +@available(iOS 14.0, *) +struct PrepaneView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + VStack { + PrepaneViewUIViewRepresentable() + } + .frame(maxWidth: .infinity) + .background(Color.purple.opacity(0.1)) + .navigationTitle("Stripe") + .navigationBarTitleDisplayMode(.inline) + } + + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Placeholder/PlaceholderViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Placeholder/PlaceholderViewController.swift new file mode 100644 index 00000000..1d98c785 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Placeholder/PlaceholderViewController.swift @@ -0,0 +1,103 @@ +// +// ConsentViewController.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 6/7/22. +// + +@_spi(STP) import StripeUICore +import UIKit + +class PlaceholderViewController: UIViewController { + + // MARK: - UI Elements + + private lazy var label: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textAlignment = .center + label.text = paneTitle + return label + }() + + private lazy var actionButton: UIButton = { + let button = UIButton(type: .roundedRect) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(actionTitle, for: .normal) + button.addTarget(self, action: #selector(didTapActionButton), for: .touchUpInside) + return button + }() + + // MARK: - Properties + + private let paneTitle: String + private let actionTitle: String + private let actionBlock: () -> Void + + // MARK: - Init + + init(paneTitle: String, actionTitle: String, actionBlock: @escaping () -> Void) { + self.paneTitle = paneTitle + self.actionTitle = actionTitle + self.actionBlock = actionBlock + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - UIViewController + + override func viewDidLoad() { + super.viewDidLoad() + + installViews() + installConstraints() + } +} + +// MARK: - Helpers +fileprivate extension PlaceholderViewController { + + func installViews() { + view.backgroundColor = .systemBackground + view.addSubview(label) + view.addSubview(actionButton) + } + + func installConstraints() { + NSLayoutConstraint.activate([ + // Center temporary label + label.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + + // Pin button to the bottom of safe area + actionButton.leftAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.leftAnchor, + constant: Styling.actionButtonSpacing + ), + actionButton.rightAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.rightAnchor, + constant: -Styling.actionButtonSpacing + ), + actionButton.bottomAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.bottomAnchor, + constant: -Styling.actionButtonSpacing + ), + ]) + } + + @objc + func didTapActionButton() { + actionBlock() + } +} + +// MARK: - Styling + +fileprivate extension PlaceholderViewController { + enum Styling { + static let actionButtonSpacing: CGFloat = 20 + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ResetFlow/ResetFlowDataSource.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ResetFlow/ResetFlowDataSource.swift new file mode 100644 index 00000000..ead3755e --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ResetFlow/ResetFlowDataSource.swift @@ -0,0 +1,36 @@ +// +// ResetFlowDataSource.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/2/22. +// + +import Foundation +@_spi(STP) import StripeCore + +protocol ResetFlowDataSource: AnyObject { + var analyticsClient: FinancialConnectionsAnalyticsClient { get } + + func markLinkingMoreAccounts() -> Promise +} + +final class ResetFlowDataSourceImplementation: ResetFlowDataSource { + + private let apiClient: FinancialConnectionsAPIClient + private let clientSecret: String + let analyticsClient: FinancialConnectionsAnalyticsClient + + init( + apiClient: FinancialConnectionsAPIClient, + clientSecret: String, + analyticsClient: FinancialConnectionsAnalyticsClient + ) { + self.apiClient = apiClient + self.clientSecret = clientSecret + self.analyticsClient = analyticsClient + } + + func markLinkingMoreAccounts() -> Promise { + return apiClient.markLinkingMoreAccounts(clientSecret: clientSecret) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ResetFlow/ResetFlowViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ResetFlow/ResetFlowViewController.swift new file mode 100644 index 00000000..d8813814 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/ResetFlow/ResetFlowViewController.swift @@ -0,0 +1,75 @@ +// +// LinkMoreAccounts.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/2/22. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol ResetFlowViewControllerDelegate: AnyObject { + func resetFlowViewController( + _ viewController: ResetFlowViewController, + didSucceedWithManifest manifest: FinancialConnectionsSessionManifest + ) + func resetFlowViewController( + _ viewController: ResetFlowViewController, + didFailWithError error: Error + ) +} + +// Used in at least two scenarios: +// 1) User presses "Link another account" in Consent Pane +// 2) User selects "Select another bank" in an Error screen from Institution Picker +final class ResetFlowViewController: UIViewController { + + private let dataSource: ResetFlowDataSource + + weak var delegate: ResetFlowViewControllerDelegate? + + init(dataSource: ResetFlowDataSource) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .customBackgroundColor + navigationItem.hidesBackButton = true + + dataSource + .analyticsClient + .logPaneLoaded(pane: .resetFlow) + + let activityIndicator = ActivityIndicator(size: .large) + activityIndicator.color = .textDisabled + activityIndicator.backgroundColor = .customBackgroundColor + view.addAndPinSubviewToSafeArea(activityIndicator) + activityIndicator.startAnimating() + + dataSource.markLinkingMoreAccounts() + .observe(on: .main) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let manifest): + self.delegate?.resetFlowViewController(self, didSucceedWithManifest: manifest) + case .failure(let error): + self.dataSource + .analyticsClient + .logUnexpectedError( + error, + errorName: "ResetFlowLinkMoreAccountsError", + pane: .resetFlow + ) + self.delegate?.resetFlowViewController(self, didFailWithError: error) + } + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/AlwaysTemplateImageView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/AlwaysTemplateImageView.swift new file mode 100644 index 00000000..45fab410 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/AlwaysTemplateImageView.swift @@ -0,0 +1,36 @@ +// +// AlwaysTemplateImageView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 11/1/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +// A simple `UIImageView` subclass that ensures that every image that +// is set is marked as `alwaysTemplate` so the `image` tint color could +// be adjusted. +// +// This is helpful when images are returned from backend and we want to tint them. +final class AlwaysTemplateImageView: UIImageView { + + init(tintColor: UIColor) { + super.init(image: nil) + self.tintColor = tintColor + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var image: UIImage? { + get { + return super.image + } + set { + super.image = newValue?.withRenderingMode(.alwaysTemplate) + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/AttributedLabel.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/AttributedLabel.swift new file mode 100644 index 00000000..5e5216ca --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/AttributedLabel.swift @@ -0,0 +1,89 @@ +// +// Label.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 5/8/23. +// + +import Foundation +import UIKit + +// Prefer `AttributedLabel` over `AttributedTextView` for single-line text. +final class AttributedLabel: UILabel { + + private let customFont: FinancialConnectionsFont + private let customTextColor: UIColor + private var customTextAlignment: NSTextAlignment? + + // one can accidentally forget to call `setText` instead of `text` so + // this makes it convenient to use `AttributedLabel` + override var text: String? { + didSet { + setText(text ?? "") + } + } + + override var textAlignment: NSTextAlignment { + didSet { + self.customTextAlignment = textAlignment + } + } + + init(font: FinancialConnectionsFont, textColor: UIColor) { + self.customFont = font + self.customTextColor = textColor + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // UILabel with custom `lineHeight` via `NSParagraphStyle` was not properly + // centering the text, so here we adjust it to be centered. + override func drawText(in rect: CGRect) { + guard + let attributedText = self.attributedText, + attributedText.length > 0, // `attributes(at:effectiveRange)` crashes if empty string + let font = attributedText.attributes(at: 0, effectiveRange: nil)[.font] as? UIFont, + let paragraphStyle = attributedText.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle + else { + super.drawText(in: rect) + return + } + let uiFontLineHeight = font.lineHeight + let paragraphStyleLineHeight = paragraphStyle.minimumLineHeight + assert(paragraphStyle.minimumLineHeight == paragraphStyle.maximumLineHeight, "we are assuming that minimum and maximum are the same") + + if paragraphStyleLineHeight > uiFontLineHeight { + let lineHeightDifference = (paragraphStyle.minimumLineHeight - uiFontLineHeight) + let newRect = CGRect( + x: rect.origin.x, + y: rect.origin.y - lineHeightDifference / 2, + width: rect.width, + height: rect.height + ) + super.drawText(in: newRect) + } else { + super.drawText(in: rect) + } + } + + func setText(_ text: String) { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.minimumLineHeight = customFont.lineHeight + paragraphStyle.maximumLineHeight = customFont.lineHeight + if let customTextAlignment = customTextAlignment { + paragraphStyle.alignment = customTextAlignment + } + let string = NSMutableAttributedString( + string: text, + attributes: [ + .paragraphStyle: paragraphStyle, + .font: customFont.uiFont, + .foregroundColor: customTextColor, + ] + ) + attributedText = string + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/AttributedTextView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/AttributedTextView.swift new file mode 100644 index 00000000..082db269 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/AttributedTextView.swift @@ -0,0 +1,215 @@ +// +// AttributedTextView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 5/2/23. +// + +import Foundation +import SafariServices +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +// Adds support for markdown links and markdown bold. +// +// `AttributedTextView` is also the `UITextView` version of `AttributedLabel`. +final class AttributedTextView: HitTestView { + + private struct LinkDescriptor { + let range: NSRange + let urlString: String + let action: (URL) -> Void + } + + private let font: FinancialConnectionsFont + private let boldFont: FinancialConnectionsFont + private let linkFont: FinancialConnectionsFont + private let textColor: UIColor + private let alignCenter: Bool + private let textView: IncreasedHitTestTextView + private var linkURLStringToAction: [String: (URL) -> Void] = [:] + + init( + font: FinancialConnectionsFont, + boldFont: FinancialConnectionsFont, + linkFont: FinancialConnectionsFont, + textColor: UIColor, + linkColor: UIColor = .textBrand, + alignCenter: Bool = false + ) { + let textContainer = NSTextContainer(size: .zero) + let layoutManager = VerticalCenterLayoutManager() + layoutManager.addTextContainer(textContainer) + let textStorage = NSTextStorage() + textStorage.addLayoutManager(layoutManager) + self.textView = IncreasedHitTestTextView( + frame: .zero, + textContainer: textContainer + ) + self.font = font + self.boldFont = boldFont + self.linkFont = linkFont + self.textColor = textColor + self.alignCenter = alignCenter + super.init(frame: .zero) + textView.isScrollEnabled = false + textView.delaysContentTouches = false + textView.isEditable = false + textView.isSelectable = true + textView.backgroundColor = UIColor.clear + // Get rid of the extra padding added by default to UITextViews + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0.0 + textView.linkTextAttributes = [ + .foregroundColor: linkColor + ] + textView.delegate = self + // remove clipping so when user selects an attributed + // link, the selection area does not get clipped + textView.clipsToBounds = false + addAndPinSubview(textView) + + // enable faster tap recognizing + if let gestureRecognizers = textView.gestureRecognizers { + for gestureRecognizer in gestureRecognizers { + if let tapGestureRecognizer = gestureRecognizer as? UITapGestureRecognizer, + tapGestureRecognizer.numberOfTapsRequired == 2 + { + // double-tap gesture recognizer causes a delay + // to single-tap gesture recognizer so we + // disable it + tapGestureRecognizer.isEnabled = false + } + } + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Helper that automatically handles extracting links and, optionally, opening it via `SFSafariViewController` + func setText( + _ text: String, + action: @escaping ((URL) -> Void) = { url in + SFSafariViewController.present(url: url) + } + ) { + let textLinks = text.extractLinks() + setText( + textLinks.linklessString, + links: textLinks.links.map { + AttributedTextView.LinkDescriptor( + range: $0.range, + urlString: $0.urlString, + action: action + ) + } + ) + } + + private func setText( + _ text: String, + links: [LinkDescriptor] + ) { + let paragraphStyle = NSMutableParagraphStyle() + if alignCenter { + paragraphStyle.alignment = .center + } + paragraphStyle.minimumLineHeight = font.lineHeight + paragraphStyle.maximumLineHeight = font.lineHeight + let string = NSMutableAttributedString( + string: text, + attributes: [ + .paragraphStyle: paragraphStyle, + .font: font.uiFont, + .foregroundColor: textColor, + ] + ) + + // apply link attributes + for link in links { + string.addAttribute(.link, value: link.urlString, range: link.range) + + // setting font in `linkTextAttributes` does not work + string.addAttribute(.font, value: linkFont.uiFont, range: link.range) + + linkURLStringToAction[link.urlString] = link.action + } + + // apply bold attributes + string.addBoldFontAttributesByMarkdownRules(boldFont: boldFont.uiFont) + + textView.attributedText = string + } +} + +// MARK: + +extension AttributedTextView: UITextViewDelegate { + + func textView( + _ textView: UITextView, + shouldInteractWith URL: URL, + in characterRange: NSRange, + interaction: UITextItemInteraction + ) -> Bool { + if let linkAction = linkURLStringToAction[URL.absoluteString] { + linkAction(URL) + return false + } else { + assertionFailure("Expected every URL to have an action defined. keys:\(linkURLStringToAction); url:\(URL)") + } + return true + } + + func textViewDidChangeSelection(_ textView: UITextView) { + // disable the ability to select/copy the text as a way to improve UX + textView.selectedTextRange = nil + } +} + +private class IncreasedHitTestTextView: UITextView { + + // increase the area of NSAttributedString taps + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + // Note that increasing size here does NOT help to + // increase NSAttributedString implementation of how + // large a tap area is. As a result, this function + // can return `true` and the link-tap may still + // not happen. + let largerBounds = bounds.insetBy(dx: -20, dy: -20) + return largerBounds.contains(point) + } +} + +// UITextView with custom `lineHeight` via `NSParagraphStyle` was not properly +// centering the text, so here we adjust it to be centered. +private class VerticalCenterLayoutManager: NSLayoutManager { + override func drawGlyphs(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) { + let range = characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil) + guard + let attributedString = textStorage?.attributedSubstring(from: range), + attributedString.length > 0, // `attributes(at:effectiveRange)` crashes if empty string + let font = attributedString.attributes(at: 0, effectiveRange: nil)[.font] as? UIFont, + let paragraphStyle = attributedString.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle + else { + super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin) + return + } + let uiFontLineHeight = font.lineHeight + let paragraphStyleLineHeight = paragraphStyle.minimumLineHeight + assert(paragraphStyle.minimumLineHeight == paragraphStyle.maximumLineHeight, "we are assuming that minimum and maximum are the same") + if paragraphStyleLineHeight > uiFontLineHeight { + let lineHeightDifference = (paragraphStyleLineHeight - uiFontLineHeight) + let newOrigin = CGPoint( + x: origin.x, + y: origin.y - lineHeightDifference / 2 + ) + super.drawGlyphs(forGlyphRange: glyphsToShow, at: newOrigin) + } else { + super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin) + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/AuthFlowHelpers.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/AuthFlowHelpers.swift new file mode 100644 index 00000000..d7962cb4 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/AuthFlowHelpers.swift @@ -0,0 +1,88 @@ +// +// AuthFlowHelpers.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 10/5/22. +// + +import Foundation +import SafariServices +@_spi(STP) import StripeCore + +final class AuthFlowHelpers { + + private init() {} // only static functions used + + static func formatUrlString(_ urlString: String?) -> String? { + guard var urlString = urlString else { + return nil + } + if urlString.hasPrefix("https://") { + urlString.removeFirst("https://".count) + } + if urlString.hasPrefix("http://") { + urlString.removeFirst("http://".count) + } + if urlString.hasPrefix("www.") { + urlString.removeFirst("www.".count) + } + if urlString.hasSuffix("/") { + urlString.removeLast() + } + return urlString + } + + static func handleURLInTextFromBackend( + url: URL, + pane: FinancialConnectionsSessionManifest.NextPane, + analyticsClient: FinancialConnectionsAnalyticsClient, + handleStripeScheme: (_ urlHost: String?) -> Void + ) { + if let urlParameters = URLComponents(url: url, resolvingAgainstBaseURL: true), + let eventName = urlParameters.queryItems?.first(where: { $0.name == "eventName" })?.value + { + analyticsClient + .log( + eventName: eventName, + pane: pane + ) + } + + if url.scheme == "stripe" { + handleStripeScheme(url.host) + } else { + SFSafariViewController.present(url: url) + } + } + + static func networkingOTPErrorMessage( + fromError error: Error, + otpType: String + ) -> String? { + if + let error = error as? StripeError, + case .apiError(let apiError) = error + { + if apiError.code == "consumer_verification_code_invalid" { + return STPLocalizedString("Hmm, that code didn’t work. Double check it and try again.", "Error message when one-time-passcode (OTP) is invalid.") + } else if + apiError.code == "consumer_session_expired" + || apiError.code == "consumer_verification_expired" + || apiError.code == "consumer_verification_max_attempts_exceeded" + { + let leadingMessage = STPLocalizedString("It looks like the verification code you provided is not valid anymore.", "The leading text in an error message that explains that the one-type-passcode (OTP) the user provided is invalid. This is leading text embedded inside of larger text: 'It looks like the verification code you provided is not valid anymore. Try again, or contact us.'") + let trailingMessage = (otpType == "EMAIL") ? STPLocalizedString("Click “Resend code” and try again, or %@.", "Text as part of an error message that shows up when user entered an invalid one-time-passcode (OTP). '%@' will be replaced by text with a link: 'contact us'") : STPLocalizedString("Try again, or %@.", "Text as part of an error message that shows up when user entered an invalid one-time-passcode (OTP). '%@' will be replaced by text with a link: 'contact us'") + + let contactUsText = STPLocalizedString("contact us", "A link/button inside of text that can be tapped to visit a support website. This link will be embedded inside of larger text: 'It looks like the verification code you provided is not valid anymore. Try again, or contact us.'") + let contactUsUrlString = "https://support.link.co/contact/email?skipVerification=true" + let contactUsWithUrlText = "[\(contactUsText)](\(contactUsUrlString))" + + return leadingMessage + " " + String(format: trailingMessage, contactUsWithUrlText) + } else { + return nil + } + } else { + return nil + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/BulletPointLabelView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/BulletPointLabelView.swift new file mode 100644 index 00000000..b1438d6e --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/BulletPointLabelView.swift @@ -0,0 +1,62 @@ +// +// BulletPointLabelView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 11/21/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +final class BulletPointLabelView: HitTestView { + + private(set) var topPadding: CGFloat = 0 + private(set) var topLineHeight: CGFloat = 0 + + init( + title: String?, + content: String?, + didSelectURL: @escaping (URL) -> Void + ) { + super.init(frame: .zero) + let verticalLabelStackView = HitTestStackView() + verticalLabelStackView.axis = .vertical + verticalLabelStackView.spacing = 2 + if let title = title { + let font: FinancialConnectionsFont = .body(.medium) + let primaryLabel = AttributedTextView( + font: font, + boldFont: .body(.mediumEmphasized), + linkFont: .body(.mediumEmphasized), + textColor: .textPrimary + ) + primaryLabel.setText(title, action: didSelectURL) + verticalLabelStackView.addArrangedSubview(primaryLabel) + topPadding = font.topPadding + topLineHeight = font.lineHeight + } + if let content = content { + let displayingOnlyContent = (title == nil) + let font: FinancialConnectionsFont = displayingOnlyContent ? .body(.medium) : .body(.small) + let subtitleLabel = AttributedTextView( + font: font, + boldFont: displayingOnlyContent ? .body(.mediumEmphasized) : .body(.smallEmphasized), + linkFont: displayingOnlyContent ? .body(.mediumEmphasized) : .body(.smallEmphasized), + textColor: .textSecondary + ) + subtitleLabel.setText(content, action: didSelectURL) + verticalLabelStackView.addArrangedSubview(subtitleLabel) + if displayingOnlyContent { + topPadding = font.topPadding + topLineHeight = font.lineHeight + } + } + addAndPinSubview(verticalLabelStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/Button+Extensions.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/Button+Extensions.swift new file mode 100644 index 00000000..72621211 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/Button+Extensions.swift @@ -0,0 +1,47 @@ +// +// Button+Extensions.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/30/22. +// + +import Foundation +@_spi(STP) import StripeUICore + +// Fixes a SwiftUI preview bug where previews will crash +// if `.financialConnectionsPrimary` is directly referenced +func FinancialConnectionsPrimaryButtonConfiguration() -> Button.Configuration { + return .financialConnectionsPrimary +} + +extension Button.Configuration { + static var financialConnectionsPrimary: Button.Configuration { + var primaryButtonConfiguration = Button.Configuration.primary() + primaryButtonConfiguration.font = FinancialConnectionsFont.label(.largeEmphasized).uiFont + // default + primaryButtonConfiguration.backgroundColor = .textBrand + primaryButtonConfiguration.foregroundColor = .white + // disabled + primaryButtonConfiguration.disabledBackgroundColor = .textBrand + primaryButtonConfiguration.disabledForegroundColor = .white.withAlphaComponent(0.3) + // pressed + primaryButtonConfiguration.colorTransforms.highlightedBackground = .darken(amount: 0.23) // this tries to simulate `brand600` + primaryButtonConfiguration.colorTransforms.highlightedForeground = nil + return primaryButtonConfiguration + } + + static var financialConnectionsSecondary: Button.Configuration { + var secondaryButtonConfiguration = Button.Configuration.secondary() + secondaryButtonConfiguration.font = FinancialConnectionsFont.label(.largeEmphasized).uiFont + // default + secondaryButtonConfiguration.foregroundColor = .textPrimary + secondaryButtonConfiguration.backgroundColor = .backgroundContainer + // disabled + secondaryButtonConfiguration.disabledForegroundColor = .textPrimary.withAlphaComponent(0.3) + secondaryButtonConfiguration.disabledBackgroundColor = .backgroundContainer + // pressed + secondaryButtonConfiguration.colorTransforms.highlightedBackground = .darken(amount: 0.04) // this tries to simulate `neutral100` + secondaryButtonConfiguration.colorTransforms.highlightedForeground = nil + return secondaryButtonConfiguration + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/CloseConfirmationAlertHandler.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/CloseConfirmationAlertHandler.swift new file mode 100644 index 00000000..0ee16492 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/CloseConfirmationAlertHandler.swift @@ -0,0 +1,84 @@ +// +// CloseConfirmationAlertHandler.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/25/22. +// + +import Foundation +import UIKit + +final class CloseConfirmationAlertHandler { + + private init() {} + + static func present( + businessName: String?, + showNetworkingLanguageInConfirmationAlert: Bool, + didSelectOK: @escaping () -> Void + ) { + guard let topMostViewController = UIViewController.topMostViewController() else { + return + } + let alertController = UIAlertController( + title: STPLocalizedString( + "Are you sure you want to cancel?", + "The title of a pop-up that appears when the user attempts to exit the bank linking screen." + ), + message: { + if showNetworkingLanguageInConfirmationAlert { + if let businessName = businessName { + return String( + format: STPLocalizedString( + "If you cancel now, your account will be linked to %@ but it will not be saved to Link.", + "The subtitle/description of a pop-up that appears when the user attempts to exit the bank linking screen." + ), + businessName + ) + } else { + return STPLocalizedString( + "If you cancel now, your account will be linked but it will not be saved to Link.", + "The subtitle/description of a pop-up that appears when the user attempts to exit the bank linking screen." + ) + } + } else if let businessName = businessName { + return String( + format: STPLocalizedString( + "You haven’t finished linking your bank account to %@.", + "The subtitle/description of a pop-up that appears when the user attempts to exit the bank linking screen." + ), + businessName + ) + } else { + return STPLocalizedString( + "You haven’t finished linking your bank account to Stripe.", + "The subtitle/description of a pop-up that appears when the user attempts to exit the bank linking screen." + ) + } + }(), + preferredStyle: .alert + ) + alertController.addAction( + UIAlertAction( + title: STPLocalizedString( + "Back", + "A button title. The user encounters it as part of a confirmation pop-up when trying to exit a screen. Pressing it will close the pop-up, and will ensure that the screen does NOT exit." + ), + style: .cancel + ) + ) + alertController.addAction( + UIAlertAction( + title: STPLocalizedString( + "Yes, cancel", + "A button title. The user encounters it as part of a confirmation pop-up when trying to exit a screen. Pressing it will exit the screen, and cancel the process of connecting the users bank account." + ), + style: .destructive, + handler: { _ in + didSelectOK() + } + ) + ) + topMostViewController.present(alertController, animated: true, completion: nil) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/ConsentBottomSheetModel.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/ConsentBottomSheetModel.swift new file mode 100644 index 00000000..21cd5540 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/ConsentBottomSheetModel.swift @@ -0,0 +1,22 @@ +// +// ConsentBottomSheetModel.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 11/17/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +struct ConsentBottomSheetModel { + let title: String + let subtitle: String? + let body: Body + let extraNotice: String? + let learnMore: String + let cta: String + + struct Body: Decodable { + let bullets: [FinancialConnectionsBulletPoint] + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/ConsentBottomSheetView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/ConsentBottomSheetView.swift new file mode 100644 index 00000000..2332c260 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/ConsentBottomSheetView.swift @@ -0,0 +1,324 @@ +// +// ConsentBottomSheetView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 7/13/22. +// + +import Foundation +import SafariServices +@_spi(STP) import StripeUICore +import UIKit + +final class ConsentBottomSheetView: UIView { + + private let didSelectOKAction: () -> Void + + init( + model: ConsentBottomSheetModel, + didSelectOK: @escaping () -> Void, + didSelectURL: @escaping (URL) -> Void + ) { + self.didSelectOKAction = didSelectOK + super.init(frame: .zero) + backgroundColor = .customBackgroundColor + + let padding: CGFloat = 24 + let verticalStackView = HitTestStackView( + arrangedSubviews: [ + CreateContentView( + headerTitle: model.title, + headerSubtitle: model.subtitle, + bulletItems: model.body.bullets, + extraNotice: model.extraNotice, + learnMoreText: model.learnMore, + didSelectURL: didSelectURL + ), + CreateFooterView( + cta: model.cta, + actionTarget: self + ), + ] + ) + verticalStackView.axis = .vertical + verticalStackView.spacing = 36 // space between content and footer + verticalStackView.isLayoutMarginsRelativeArrangement = true + verticalStackView.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: padding, + leading: padding, + bottom: padding, + trailing: padding + ) + addAndPinSubview(verticalStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + roundCorners() // needs to be in `layoutSubviews` to get the correct size for the mask + } + + private func roundCorners() { + clipsToBounds = true + let path = UIBezierPath( + roundedRect: bounds, + byRoundingCorners: [.topLeft, .topRight], + cornerRadii: CGSize(width: 8, height: 8) + ) + let mask = CAShapeLayer() + mask.path = path.cgPath + layer.mask = mask + } + + @IBAction fileprivate func didSelectOK() { + didSelectOKAction() + } +} + +private func CreateContentView( + headerTitle: String, + headerSubtitle: String?, + bulletItems: [FinancialConnectionsBulletPoint], + extraNotice: String?, + learnMoreText: String, + didSelectURL: @escaping (URL) -> Void +) -> UIView { + let verticalStackView = HitTestStackView( + arrangedSubviews: [ + CreateHeaderView( + title: headerTitle, + subtitle: headerSubtitle, + didSelectURL: didSelectURL + ), + CreateBulletinAndExtraLabelView( + bulletItems: bulletItems, + extraNotice: extraNotice, + learnMoreText: learnMoreText, + didSelectURL: didSelectURL + ), + ] + ) + verticalStackView.axis = .vertical + verticalStackView.spacing = 24 + return verticalStackView +} + +private func CreateHeaderView( + title: String, + subtitle: String?, + didSelectURL: @escaping (URL) -> Void +) -> UIView { + let verticalStack = UIStackView() + verticalStack.axis = .vertical + verticalStack.spacing = 4 + + let headerLabel = AttributedTextView( + font: .heading(.medium), + boldFont: .heading(.medium), + linkFont: .heading(.medium), + textColor: .textPrimary + ) + headerLabel.setText(title, action: didSelectURL) + verticalStack.addArrangedSubview(headerLabel) + + if let subtitle = subtitle { + let subtitleLabel = AttributedTextView( + font: .body(.medium), + boldFont: .body(.mediumEmphasized), + linkFont: .body(.mediumEmphasized), + textColor: .textSecondary + ) + subtitleLabel.setText(subtitle, action: didSelectURL) + verticalStack.addArrangedSubview(subtitleLabel) + } + return verticalStack +} + +private func CreateBulletinAndExtraLabelView( + bulletItems: [FinancialConnectionsBulletPoint], + extraNotice: String?, + learnMoreText: String, + didSelectURL: @escaping (URL) -> Void +) -> UIView { + let verticalStackView = HitTestStackView( + arrangedSubviews: { + var subviews: [UIView] = [] + bulletItems.forEach { bulletItem in + subviews.append( + CreateBulletinView( + title: bulletItem.title, + subtitle: bulletItem.content, + iconUrl: bulletItem.icon?.default, + didSelectURL: didSelectURL + ) + ) + } + if let extraNotice = extraNotice { + let extraNoticeLabel = AttributedTextView( + font: .body(.small), + boldFont: .body(.smallEmphasized), + linkFont: .body(.smallEmphasized), + textColor: .textSecondary + ) + extraNoticeLabel.setText(extraNotice, action: didSelectURL) + subviews.append(extraNoticeLabel) + } + subviews.append( + CreateLearnMoreLabel( + text: learnMoreText, + didSelectURL: didSelectURL + ) + ) + return subviews + }() + ) + verticalStackView.axis = .vertical + verticalStackView.spacing = 12 + return verticalStackView +} + +private func CreateBulletinView( + title: String?, + subtitle: String?, + iconUrl: String?, + didSelectURL: @escaping (URL) -> Void +) -> UIView { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + if let iconUrl = iconUrl { + imageView.setImage(with: iconUrl) + } else { + imageView.image = Image.bullet.makeImage().withRenderingMode(.alwaysTemplate) + imageView.tintColor = .textPrimary + } + imageView.translatesAutoresizingMaskIntoConstraints = false + let imageDiameter: CGFloat = 16 + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: imageDiameter), + imageView.heightAnchor.constraint(equalToConstant: imageDiameter), + ]) + + let bulletPointLabelView = BulletPointLabelView( + title: title, + content: subtitle, + didSelectURL: didSelectURL + ) + let horizontalStackView = HitTestStackView( + arrangedSubviews: [ + { + // add padding to the icon so its better aligned with text + let paddingStackView = UIStackView(arrangedSubviews: [imageView]) + paddingStackView.isLayoutMarginsRelativeArrangement = true + paddingStackView.directionalLayoutMargins = NSDirectionalEdgeInsets( + // center the image in the middle of the first line height + top: max(0, (bulletPointLabelView.topLineHeight - imageDiameter) / 2), + leading: 0, + bottom: 0, + trailing: 0 + ) + return paddingStackView + }(), + bulletPointLabelView, + ] + ) + horizontalStackView.axis = .horizontal + horizontalStackView.spacing = 10 + horizontalStackView.alignment = .top + return horizontalStackView +} + +private func CreateLearnMoreLabel( + text: String, + didSelectURL: @escaping (URL) -> Void +) -> UIView { + let label = AttributedTextView( + font: .body(.small), + boldFont: .body(.smallEmphasized), + linkFont: .body(.smallEmphasized), + textColor: .textSecondary + ) + label.setText(text, action: didSelectURL) + return label +} + +private func CreateFooterView( + cta: String, + actionTarget: ConsentBottomSheetView +) -> UIView { + let okButton = Button(configuration: FinancialConnectionsPrimaryButtonConfiguration()) + okButton.title = cta + okButton.addTarget(actionTarget, action: #selector(ConsentBottomSheetView.didSelectOK), for: .touchUpInside) + okButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + okButton.heightAnchor.constraint(equalToConstant: 56) + ]) + return okButton +} + +#if DEBUG + +import SwiftUI + +private struct ConsentBottomSheetViewUIViewRepresentable: UIViewRepresentable { + + func makeUIView(context: Context) -> ConsentBottomSheetView { + ConsentBottomSheetView( + model: ConsentBottomSheetModel( + title: "When will [Merchant] use your data?", + subtitle: "[Merchant] will use your account and routing number, balances and transactions when:", + body: ConsentBottomSheetModel.Body( + bullets: [ + FinancialConnectionsBulletPoint( + icon: FinancialConnectionsImage(default: "https://b.stripecdn.com/connections-statics-srv/assets/SailIcon--checkCircle-green-3x.png"), + title: nil, + content: "Content Only" + ), + FinancialConnectionsBulletPoint( + icon: FinancialConnectionsImage(default: nil), + title: nil, + content: "Content Only" + ), + FinancialConnectionsBulletPoint( + icon: FinancialConnectionsImage(default: nil), + title: "Title And Content", + content: "Title And Content" + ), + FinancialConnectionsBulletPoint( + icon: FinancialConnectionsImage(default: nil), + title: "Title Only" + ), + ] + ), + extraNotice: "Extra Notice", + learnMore: "[Learn more](https://www.stripe.com)", + cta: "Got it" + ), + didSelectOK: {}, + didSelectURL: { _ in } + ) + } + + func updateUIView(_ uiView: ConsentBottomSheetView, context: Context) { + uiView.sizeToFit() + } +} + +struct ConsentBottomSheetView_Previews: PreviewProvider { + static var previews: some View { + if #available(iOS 14.0, *) { + VStack { + ConsentBottomSheetViewUIViewRepresentable() + .frame(width: 320) + .frame(height: 510) + + } + .frame(maxWidth: .infinity) + .background(Color.red.opacity(0.1)) + } + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/ConsentBottomSheetViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/ConsentBottomSheetViewController.swift new file mode 100644 index 00000000..bfe33fbe --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/ConsentBottomSheetViewController.swift @@ -0,0 +1,140 @@ +// +// ConsentBottomSheetViewController.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 7/13/22. +// + +import Foundation +import UIKit + +final class ConsentBottomSheetViewController: UIViewController { + + private let model: ConsentBottomSheetModel + private let didSelectURL: (URL) -> Void + + private var openContraint: NSLayoutConstraint? + private var closeContraint: NSLayoutConstraint? + + init( + model: ConsentBottomSheetModel, + didSelectURL: @escaping (URL) -> Void + ) { + self.model = model + self.didSelectURL = didSelectURL + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didTapBackground)) + tapGestureRecognizer.delegate = self + view.addGestureRecognizer(tapGestureRecognizer) + + let dataAccessNoticeView = ConsentBottomSheetView( + model: model, + didSelectOK: { [weak self] in + self?.dismiss(animated: true) + }, + didSelectURL: didSelectURL + ) + view.addSubview(dataAccessNoticeView) + dataAccessNoticeView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + dataAccessNoticeView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + dataAccessNoticeView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + openContraint = dataAccessNoticeView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + closeContraint = dataAccessNoticeView.topAnchor.constraint(equalTo: view.bottomAnchor) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + if isBeingPresented { + animateShowing(true) + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if isBeingDismissed { + animateShowing(false) + } + } + + // It is more better to do animations with custom UIViewController + // animations but this is meant to be a quick implementation. + private func animateShowing(_ isShowing: Bool) { + let setInitialState = { [weak self] in + self?.openContraint?.isActive = false + self?.closeContraint?.isActive = true + self?.view.layoutIfNeeded() + self?.view.backgroundColor = UIColor.black.withAlphaComponent(0.0) + } + let setFinalState = { [weak self] in + self?.closeContraint?.isActive = false + self?.openContraint?.isActive = true + self?.view.layoutIfNeeded() + self?.view.backgroundColor = UIColor.black.withAlphaComponent(0.5) + } + + if isShowing { + setInitialState() + } + + UIView.animate( + withDuration: 0.25, + delay: 0.0, + usingSpringWithDamping: 1, + initialSpringVelocity: 0.5, + options: [], + animations: { + if isShowing { + setFinalState() + } else { + setInitialState() + } + } + ) + } + + @objc private func didTapBackground() { + dismiss(animated: true) + } +} + +// MARK: - + +extension ConsentBottomSheetViewController: UIGestureRecognizerDelegate { + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + // only consider touches on the dark overlay area + return touch.view === self.view + } +} + +// MARK: - Presenting + +extension ConsentBottomSheetViewController { + + static func present( + withModel model: ConsentBottomSheetModel, + didSelectUrl: @escaping (URL) -> Void + ) { + let consentBottomSheetViewController = ConsentBottomSheetViewController( + model: model, + didSelectURL: didSelectUrl + ) + consentBottomSheetViewController.modalTransitionStyle = .crossDissolve + consentBottomSheetViewController.modalPresentationStyle = .overCurrentContext + // `false` for animations because we do a custom animation inside VC logic + UIViewController + .topMostViewController()? + .present(consentBottomSheetViewController, animated: false, completion: nil) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/HitTestStackView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/HitTestStackView.swift new file mode 100644 index 00000000..385944ed --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/HitTestStackView.swift @@ -0,0 +1,25 @@ +// +// HitTestStackView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 11/16/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +// A `UIStackView` that considers the touch area +// of subviews first because the subviews might have +// increased tap area. +class HitTestStackView: UIStackView { + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + for subview in subviews { + let subviewPoint = subview.convert(point, from: self) + if subview.point(inside: subviewPoint, with: event) { + return true + } + } + return super.point(inside: point, with: event) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/HitTestView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/HitTestView.swift new file mode 100644 index 00000000..97b49e2e --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/HitTestView.swift @@ -0,0 +1,25 @@ +// +// HitTestView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 11/16/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +// A `UIView` that considers the touch area +// of subviews first because the subviews might have +// increased tap area. +class HitTestView: UIView { + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + for subview in subviews { + let subviewPoint = subview.convert(point, from: self) + if subview.point(inside: subviewPoint, with: event) { + return true + } + } + return super.point(inside: point, with: event) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/InstitutionIconView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/InstitutionIconView.swift new file mode 100644 index 00000000..03fea54a --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/InstitutionIconView.swift @@ -0,0 +1,160 @@ +// +// InstitutionIconView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/27/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +final class InstitutionIconView: UIView { + + enum Size { + case small // 24x24 + case medium // 36x36 + case large // 40x40 + } + + private lazy var institutionImageView: UIImageView = { + let iconImageView = UIImageView() + return iconImageView + }() + private lazy var warningIconView: UIView = { + return CreateWarningIconView() + }() + + init(size: Size, showWarning: Bool = false) { + super.init(frame: .zero) + let diameter: CGFloat + let cornerRadius: CGFloat + switch size { + case .small: + diameter = 24 + cornerRadius = 4 + case .medium: + diameter = 36 + cornerRadius = 4 + case .large: + diameter = 40 + cornerRadius = 6 + } + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalToConstant: diameter), + heightAnchor.constraint(equalToConstant: diameter), + ]) + + addAndPinSubview(institutionImageView) + institutionImageView.layer.cornerRadius = cornerRadius + institutionImageView.clipsToBounds = true + + if showWarning { + addSubview(warningIconView) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + warningIconView.center = CGPoint(x: bounds.width, y: 0) + } + + func setImageUrl(_ imageUrl: String?) { + institutionImageView.setImage( + with: imageUrl, + placeholder: Image.brandicon_default.makeImage() + ) + } +} + +private func CreateWarningIconView() -> UIView { + let diameter: CGFloat = 20 + + let circleContainerView = UIView() + circleContainerView.backgroundColor = UIColor.customBackgroundColor + circleContainerView.layer.cornerRadius = diameter / 2 + circleContainerView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + circleContainerView.widthAnchor.constraint(equalToConstant: diameter), + circleContainerView.heightAnchor.constraint(equalToConstant: diameter), + ]) + + let image = Image.warning_circle.makeImage() + .withTintColor(.textCritical) + let imageView = UIImageView(image: image) + circleContainerView.addAndPinSubview( + imageView, + insets: NSDirectionalEdgeInsets( + top: 2, + leading: 2, + bottom: 2, + trailing: 2 + ) + ) + + return circleContainerView +} + +#if DEBUG + +import SwiftUI + +private struct InstitutionIconViewUIViewRepresentable: UIViewRepresentable { + + private let institution: FinancialConnectionsInstitution = FinancialConnectionsInstitution( + id: "123", + name: "Chase", + url: nil, + icon: nil, + logo: nil + ) + let size: InstitutionIconView.Size + let showWarning: Bool + + func makeUIView(context: Context) -> InstitutionIconView { + InstitutionIconView( + size: size, + showWarning: showWarning + ) + } + + func updateUIView(_ institutionIconView: InstitutionIconView, context: Context) { + institutionIconView.setImageUrl(institution.icon?.default) + } +} + +struct InstitutionIconView_Previews: PreviewProvider { + static var previews: some View { + VStack { + VStack(spacing: 10) { + InstitutionIconViewUIViewRepresentable( + size: .large, + showWarning: true + ) + + InstitutionIconViewUIViewRepresentable( + size: .medium, + showWarning: false + ) + + InstitutionIconViewUIViewRepresentable( + size: .small, + showWarning: false + ) + + Spacer() + } + .frame(width: 40, height: 200) + .padding() + + Spacer() + } + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/MerchantDataAccessView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/MerchantDataAccessView.swift new file mode 100644 index 00000000..c76920a3 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/MerchantDataAccessView.swift @@ -0,0 +1,276 @@ +// +// MerchantDataAccessView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/21/22. +// + +import Foundation +import SafariServices +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +final class MerchantDataAccessView: HitTestView { + + init( + isStripeDirect: Bool, + businessName: String?, + permissions: [StripeAPI.FinancialConnectionsAccount.Permissions], + isNetworking: Bool, + font: FinancialConnectionsFont, + boldFont: FinancialConnectionsFont, + alignCenter: Bool, + didSelectLearnMore: @escaping () -> Void + ) { + super.init(frame: .zero) + + // the asterisks are to bold the text via "markdown" + let leadingString: String + if isStripeDirect { + let localizedLeadingString = STPLocalizedString( + "Data accessible to Stripe:", + "This text is a lead-up to a disclosure that lists all of the bank data that Stripe will have access to. For example, the full text may read 'Data accessible to Stripe: Account details, transactions.'" + ) + leadingString = localizedLeadingString + } else { + if let businessName = businessName { + let localizedLeadingString = STPLocalizedString( + "Data accessible to %@:", + "This text is a lead-up to a disclosure that lists all of the bank data that a merchant (ex. Coca-Cola) will have access to. For example, the full text may read 'Data accessible to Coca-Cola: Account details, transactions.'" + ) + leadingString = String(format: localizedLeadingString, businessName) + } else { + let localizedLeadingString = STPLocalizedString( + "Data accessible to this business:", + "This text is a lead-up to a disclosure that lists all of the bank data that a business will have access to. For example, the full text may read 'Data accessible to this business: Account details, transactions.'" + ) + leadingString = localizedLeadingString + } + } + + // `payment_method` is "subsumed" by `account_numbers` + // + // BOTH (payment_method and account_numbers are valid permissions), + // but we want to "combine" them for better UX + let permissions = + permissions.contains(.accountNumbers) ? permissions.filter({ $0 != .paymentMethod }) : permissions + let permissionString = FormPermissionListString(permissions) + + let learnMoreUrlString: String + if isStripeDirect { + learnMoreUrlString = "https://stripe.com/docs/linked-accounts/faqs" + } else { + learnMoreUrlString = + "https://support.stripe.com/user/questions/what-data-does-stripe-access-from-my-linked-financial-account" + } + let learnMoreString = "[\(String.Localized.learn_more)](\(learnMoreUrlString))" + + let finalString: String + if isNetworking { + let localizedPermissionFullString = String( + format: STPLocalizedString( + "%@ through Link.", + "A sentence that describes what users banking data is accessible to Link. For example, the full sentence may say 'Account details, transactions, balances through Link.'" + ), + permissionString + ) + finalString = "\(leadingString) \(localizedPermissionFullString) \(learnMoreString)" + } else if isStripeDirect { + finalString = "\(leadingString) \(permissionString). \(learnMoreString)" + } else { + let localizedPermissionFullString = String( + format: STPLocalizedString( + "%@ through Stripe.", + "A sentence that describes what users banking data is accessible to Stripe. For example, the full sentence may say 'Account details, transactions, balances through Stripe.'" + ), + permissionString + ) + finalString = "\(leadingString) \(localizedPermissionFullString) \(learnMoreString)" + } + + let label = AttributedTextView( + font: font, + boldFont: boldFont, + linkFont: boldFont, + textColor: .textSecondary, + alignCenter: alignCenter + ) + label.setText( + finalString, + action: { url in + SFSafariViewController.present(url: url) + didSelectLearnMore() + } + ) + addAndPinSubview(label) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Helpers + +private func FormPermissionListString( + _ permissions: [StripeAPI.FinancialConnectionsAccount.Permissions] +) -> String { + var permissionListString = "" + for i in 0.. String { + switch permission { + case .paymentMethod: + // `payment_method` is "subsumed" by `account_numbers` + // + // BOTH (payment_method and account_numbers are valid permissions), + // but we want to "combine" them for better UX + fallthrough + case .accountNumbers: + return STPLocalizedString( + "account details", + "A type of user banking data that Stripe can have access to. In this case, account details involve things like being able to access a banks account and routing number." + ) + case .balances: + return STPLocalizedString( + "balances", + "A type of user banking data that Stripe can have access to. In this case, balances means account balance in a bank like $1,000." + ) + case .ownership: + return STPLocalizedString( + "account ownership details", + "A type of user banking data that Stripe can have access to. In this case, account ownership details entail things like users full name or address." + ) + case .transactions: + return STPLocalizedString( + "transactions", + "A type of user banking data that Stripe can have access to. In this case, transactions entails a list of transactions user has made on their debit card. For example, 'bought $5.00 coffee at 12:00 PM'" + ) + case .unparsable: + return STPLocalizedString( + "others", + "A type of user banking data that Stripe can have access to. In this case, 'others' mean an unknown, or generic type of user data. Maybe it's the users full name, maybe its the balance of the bank account (ex. $1,000)." + ) + } +} + +#if DEBUG + +import SwiftUI + +private struct MerchantDataAccessViewUIViewRepresentable: UIViewRepresentable { + + let isStripeDirect: Bool + let businessName: String? + let permissions: [StripeAPI.FinancialConnectionsAccount.Permissions] + + func makeUIView(context: Context) -> MerchantDataAccessView { + MerchantDataAccessView( + isStripeDirect: isStripeDirect, + businessName: businessName, + permissions: permissions, + isNetworking: false, + font: .body(.small), + boldFont: .body(.smallEmphasized), + alignCenter: Bool.random(), + didSelectLearnMore: {} + ) + } + + func updateUIView(_ uiView: MerchantDataAccessView, context: Context) {} +} + +struct MerchantDataAccessView_Previews: PreviewProvider { + static var previews: some View { + ScrollView { + VStack(spacing: 20) { + Group { + MerchantDataAccessViewUIViewRepresentable( + isStripeDirect: true, + businessName: nil, + permissions: [.accountNumbers] + ) + + MerchantDataAccessViewUIViewRepresentable( + isStripeDirect: false, + businessName: "Rocket Rides", + permissions: [.accountNumbers] + ) + + MerchantDataAccessViewUIViewRepresentable( + isStripeDirect: false, + businessName: nil, + permissions: [.accountNumbers] + ) + + MerchantDataAccessViewUIViewRepresentable( + isStripeDirect: false, + businessName: "Rocket Rides", + permissions: [.accountNumbers] + ) + + MerchantDataAccessViewUIViewRepresentable( + isStripeDirect: false, + businessName: "Rocket Rides", + permissions: [.accountNumbers, .paymentMethod] + ) + + MerchantDataAccessViewUIViewRepresentable( + isStripeDirect: false, + businessName: "Rocket Rides", + permissions: [.transactions, .ownership] + ) + + MerchantDataAccessViewUIViewRepresentable( + isStripeDirect: false, + businessName: "Rocket Rides", + permissions: [.transactions, .ownership, .balances] + ) + + MerchantDataAccessViewUIViewRepresentable( + isStripeDirect: false, + businessName: "Rocket Rides", + permissions: [.accountNumbers, .paymentMethod, .transactions, .ownership, .balances] + ) + + MerchantDataAccessViewUIViewRepresentable( + isStripeDirect: false, + businessName: "Rocket Rides", + permissions: [.unparsable] + ) + + MerchantDataAccessViewUIViewRepresentable( + isStripeDirect: true, + businessName: nil, + permissions: [] + ) + } + .frame(height: 60) + .padding(.horizontal) + } + } + + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/NetworkingOTPView/NetworkingOTPDataSource.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/NetworkingOTPView/NetworkingOTPDataSource.swift new file mode 100644 index 00000000..be3d25e4 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/NetworkingOTPView/NetworkingOTPDataSource.swift @@ -0,0 +1,106 @@ +// +// NetworkingOTPDataSource.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/28/23. +// + +import Foundation +@_spi(STP) import StripeCore + +protocol NetworkingOTPDataSourceDelegate: AnyObject { + func networkingOTPDataSource(_ dataSource: NetworkingOTPDataSource, didUpdateConsumerSession consumerSession: ConsumerSessionData) +} + +protocol NetworkingOTPDataSource: AnyObject { + var otpType: String { get } + var analyticsClient: FinancialConnectionsAnalyticsClient { get } + var pane: FinancialConnectionsSessionManifest.NextPane { get } + + func lookupConsumerSession() -> Future + func startVerificationSession() -> Future + func confirmVerificationSession(otpCode: String) -> Future +} + +final class NetworkingOTPDataSourceImplementation: NetworkingOTPDataSource { + + let otpType: String + private let emailAddress: String + private let customEmailType: String? + private let connectionsMerchantName: String? + private var consumerSession: ConsumerSessionData? { + didSet { + if let consumerSession = consumerSession { + delegate?.networkingOTPDataSource(self, didUpdateConsumerSession: consumerSession) + } + } + } + let pane: FinancialConnectionsSessionManifest.NextPane + private let apiClient: FinancialConnectionsAPIClient + private let clientSecret: String + let analyticsClient: FinancialConnectionsAnalyticsClient + weak var delegate: NetworkingOTPDataSourceDelegate? + + init( + otpType: String, + emailAddress: String, + customEmailType: String?, + connectionsMerchantName: String?, + pane: FinancialConnectionsSessionManifest.NextPane, + consumerSession: ConsumerSessionData?, + apiClient: FinancialConnectionsAPIClient, + clientSecret: String, + analyticsClient: FinancialConnectionsAnalyticsClient + ) { + self.otpType = otpType + self.emailAddress = emailAddress + self.customEmailType = customEmailType + self.connectionsMerchantName = connectionsMerchantName + self.pane = pane + self.consumerSession = consumerSession + self.apiClient = apiClient + self.clientSecret = clientSecret + self.analyticsClient = analyticsClient + } + + func lookupConsumerSession() -> Future { + apiClient + .consumerSessionLookup( + emailAddress: emailAddress, + clientSecret: clientSecret + ) + .chained { [weak self] lookupConsumerSessionResponse in + self?.consumerSession = lookupConsumerSessionResponse.consumerSession + return Promise(value: lookupConsumerSessionResponse) + } + } + + func startVerificationSession() -> Future { + guard let consumerSessionClientSecret = consumerSession?.clientSecret else { + return Promise(error: FinancialConnectionsSheetError.unknown(debugDescription: "invalid startVerificationSession call: no consumerSession.clientSecret")) + } + return apiClient.consumerSessionStartVerification( + otpType: otpType, + customEmailType: customEmailType, + connectionsMerchantName: connectionsMerchantName, + consumerSessionClientSecret: consumerSessionClientSecret + ).chained { [weak self] consumerSessionResponse in + self?.consumerSession = consumerSessionResponse.consumerSession + return Promise(value: consumerSessionResponse) + } + } + + func confirmVerificationSession(otpCode: String) -> Future { + guard let consumerSessionClientSecret = consumerSession?.clientSecret else { + return Promise(error: FinancialConnectionsSheetError.unknown(debugDescription: "invalid confirmVerificationSession state: no consumerSessionClientSecret")) + } + return apiClient.consumerSessionConfirmVerification( + otpCode: otpCode, + otpType: otpType, + consumerSessionClientSecret: consumerSessionClientSecret + ).chained { [weak self] consumerSessionResponse in + self?.consumerSession = consumerSessionResponse.consumerSession + return Promise(value: consumerSessionResponse) + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/NetworkingOTPView/NetworkingOTPView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/NetworkingOTPView/NetworkingOTPView.swift new file mode 100644 index 00000000..166d8afa --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/NetworkingOTPView/NetworkingOTPView.swift @@ -0,0 +1,162 @@ +// +// NetworkingOTPView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 2/28/23. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol NetworkingOTPViewDelegate: AnyObject { + func networkingOTPViewWillStartConsumerLookup(_ view: NetworkingOTPView) + func networkingOTPViewConsumerNotFound(_ view: NetworkingOTPView) + func networkingOTPView(_ view: NetworkingOTPView, didFailConsumerLookup error: Error) + + func networkingOTPViewWillStartVerification(_ view: NetworkingOTPView) + func networkingOTPView(_ view: NetworkingOTPView, didStartVerification consumerSession: ConsumerSessionData) + func networkingOTPView(_ view: NetworkingOTPView, didFailToStartVerification error: Error) + + func networkingOTPViewDidConfirmVerification(_ view: NetworkingOTPView) + func networkingOTPView(_ view: NetworkingOTPView, didTerminallyFailToConfirmVerification error: Error) +} + +final class NetworkingOTPView: UIView { + + private let dataSource: NetworkingOTPDataSource + weak var delegate: NetworkingOTPViewDelegate? + + private lazy var verticalStackView: UIStackView = { + let otpVerticalStackView = UIStackView( + arrangedSubviews: [ + otpTextField, + ] + ) + otpVerticalStackView.axis = .vertical + otpVerticalStackView.spacing = 8 + return otpVerticalStackView + }() + // TODO(kgaidis): make changes to `OneTimeCodeTextField` to + // make the font larger + private(set) lazy var otpTextField: OneTimeCodeTextField = { + let otpTextField = OneTimeCodeTextField(numberOfDigits: 6, theme: theme) + otpTextField.tintColor = .textBrand + otpTextField.addTarget(self, action: #selector(otpTextFieldDidChange), for: .valueChanged) + return otpTextField + }() + private lazy var theme: ElementsUITheme = { + var theme: ElementsUITheme = .default + theme.colors = { + var colors = ElementsUITheme.Color() + colors.border = .borderNeutral + colors.background = .customBackgroundColor + colors.textFieldText = .textPrimary + return colors + }() + return theme + }() + private var lastErrorView: UIView? + + init(dataSource: NetworkingOTPDataSource) { + self.dataSource = dataSource + super.init(frame: .zero) + addAndPinSubview(verticalStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func otpTextFieldDidChange() { + showErrorText(nil) // clear the error + + if otpTextField.isComplete { + userDidEnterValidOTPCode(otpTextField.value) + } + } + + private func showErrorText(_ errorText: String?) { + lastErrorView?.removeFromSuperview() + lastErrorView = nil + + if let errorText = errorText { + // TODO(kgaidis): rename & move `ManualEntryErrorView` to be more generic + let errorView = ManualEntryErrorView(text: errorText) + self.lastErrorView = errorView + verticalStackView.addArrangedSubview(errorView) + } + } + + func lookupConsumerAndStartVerification() { + delegate?.networkingOTPViewWillStartConsumerLookup(self) + dataSource.lookupConsumerSession() + .observe { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let lookupConsumerSessionResponse): + if lookupConsumerSessionResponse.exists { + self.startVerification() + } else { + self.delegate?.networkingOTPViewConsumerNotFound(self) + } + case .failure(let error): + self.delegate?.networkingOTPView(self, didFailConsumerLookup: error) + } + } + } + + func startVerification() { + delegate?.networkingOTPViewWillStartVerification(self) + dataSource.startVerificationSession() + .observe { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let consumerSessionResponse): + self.delegate?.networkingOTPView(self, didStartVerification: consumerSessionResponse.consumerSession) + + // call this AFTER the delegate to ensure that the delegate-handler + // adds the OTP view to the view-hierarchy + self.otpTextField.becomeFirstResponder() + case .failure(let error): + self.delegate?.networkingOTPView(self, didFailToStartVerification: error) + } + } + } + + private func userDidEnterValidOTPCode(_ otpCode: String) { + otpTextField.resignFirstResponder() + + dataSource.confirmVerificationSession(otpCode: otpCode) + .observe { [weak self] result in + guard let self = self else { return } + switch result { + case .success: + self.delegate?.networkingOTPViewDidConfirmVerification(self) + case .failure(let error): + if let errorMessage = AuthFlowHelpers.networkingOTPErrorMessage(fromError: error, otpType: self.dataSource.otpType) { + self.dataSource + .analyticsClient + .logExpectedError( + error, + errorName: "ConfirmVerificationSessionError", + pane: self.dataSource.pane + ) + + self.otpTextField.performInvalidCodeAnimation(shouldClearValue: false) + self.showErrorText(errorMessage) + } else { + self.dataSource + .analyticsClient + .logUnexpectedError( + error, + errorName: "ConfirmVerificationSessionError", + pane: self.dataSource.pane + ) + self.delegate?.networkingOTPView(self, didTerminallyFailToConfirmVerification: error) + } + } + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/PaneLayoutView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/PaneLayoutView.swift new file mode 100644 index 00000000..6987c3fb --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/PaneLayoutView.swift @@ -0,0 +1,51 @@ +// +// PaneLayoutView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/12/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +/// Reusable view that separates panes into two parts: +/// 1. A scroll view for content +/// 2. A footer that is "locked" and does not get affected by scroll view +/// +/// Purposefully NOT a `UIView` subclass because it should only be used via +/// `addToView` helper function. +final class PaneLayoutView { + + private weak var scrollViewContentView: UIView? + private let paneLayoutView: UIView + let scrollView: UIScrollView + + init(contentView: UIView, footerView: UIView?) { + self.scrollViewContentView = contentView + + let scrollView = UIScrollView() + self.scrollView = scrollView + scrollView.addAndPinSubview(contentView) + + let verticalStackView = HitTestStackView( + arrangedSubviews: [ + scrollView + ] + ) + if let footerView = footerView { + verticalStackView.addArrangedSubview(footerView) + } + verticalStackView.spacing = 0 + verticalStackView.axis = .vertical + self.paneLayoutView = verticalStackView + } + + func addTo(view: UIView) { + // this function encapsulates an error-prone sequence where we + // must add `paneLayoutView` (and all it's subviews) to the `view` + // BEFORE we can add a constraint for `UIScrollView` content + view.addAndPinSubviewToSafeArea(paneLayoutView) + scrollViewContentView?.widthAnchor.constraint(equalTo: view.safeAreaLayoutGuide.widthAnchor).isActive = true + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/PaneWithCustomHeaderLayoutView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/PaneWithCustomHeaderLayoutView.swift new file mode 100644 index 00000000..76d21b89 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/PaneWithCustomHeaderLayoutView.swift @@ -0,0 +1,80 @@ +// +// PaneWithCustomHeaderLayoutView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 10/19/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +/// Reusable view that separates panes into three parts: +/// 1. A header that is part of a "content scroll view." +/// 2. A body that is part of a "content scroll view." +/// 3. A footer that is "locked" and does not get affected by scroll view +/// +/// Purposefully NOT a `UIView` subclass because it should only be used via +/// `addToView` helper function. +final class PaneWithCustomHeaderLayoutView { + + private let paneLayoutView: PaneLayoutView + var scrollView: UIScrollView { + return paneLayoutView.scrollView + } + + init( + headerView: UIView, + headerTopMargin: CGFloat = 8.0, + contentView: UIView, + headerAndContentSpacing: CGFloat = 24.0, + footerView: UIView? + ) { + self.paneLayoutView = PaneLayoutView( + contentView: { + let verticalStackView = HitTestStackView( + arrangedSubviews: [ + headerView, + contentView, + ] + ) + verticalStackView.axis = .vertical + verticalStackView.spacing = headerAndContentSpacing + verticalStackView.isLayoutMarginsRelativeArrangement = true + verticalStackView.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: headerTopMargin, + leading: 24, + bottom: 16, + trailing: 24 + ) + return verticalStackView + }(), + footerView: { + if let footerView = footerView { + // This is only a `HitTestStackView` to add margins + let verticalStackView = HitTestStackView( + arrangedSubviews: [ + footerView + ] + ) + verticalStackView.axis = .vertical + verticalStackView.spacing = 0 + verticalStackView.isLayoutMarginsRelativeArrangement = true + verticalStackView.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: 20, + leading: 24, + bottom: 24, + trailing: 24 + ) + return verticalStackView + } else { + return nil + } + }() + ) + } + + func addTo(view: UIView) { + paneLayoutView.addTo(view: view) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/PaneWithHeaderLayoutView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/PaneWithHeaderLayoutView.swift new file mode 100644 index 00000000..e195641d --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/PaneWithHeaderLayoutView.swift @@ -0,0 +1,107 @@ +// +// PaneWithHeaderLayoutView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/12/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +/// Reusable view that separates panes into three parts: +/// 1. A header that is part of a "content scroll view" and that can only be customized with specific parameters. +/// 2. A body that is part of a "content scroll view." +/// 3. A footer that is "locked" and does not get affected by scroll view +/// +/// Purposefully NOT a `UIView` subclass because it should only be used via +/// `addToView` helper function. +final class PaneWithHeaderLayoutView { + + enum Icon { + case view(UIView) + } + + private let paneWithCustomHeaderLayoutView: PaneWithCustomHeaderLayoutView + var scrollView: UIScrollView { + return paneWithCustomHeaderLayoutView.scrollView + } + + init( + icon: Icon? = nil, + title: String, + subtitle: String? = nil, + contentView: UIView, + headerAndContentSpacing: CGFloat = 24.0, + footerView: UIView? + ) { + self.paneWithCustomHeaderLayoutView = PaneWithCustomHeaderLayoutView( + headerView: CreateHeaderView(icon: icon, title: title, subtitle: subtitle), + contentView: contentView, + headerAndContentSpacing: headerAndContentSpacing, + footerView: footerView + ) + } + + func addTo(view: UIView) { + paneWithCustomHeaderLayoutView.addTo(view: view) + } +} + +private func CreateHeaderView( + icon: PaneWithHeaderLayoutView.Icon?, + title: String, + subtitle: String? +) -> UIView { + let headerStackView = HitTestStackView() + headerStackView.axis = .vertical + headerStackView.spacing = 16 + headerStackView.alignment = .leading + if let icon = icon { + headerStackView.addArrangedSubview( + CreateIconView(iconType: icon) + ) + } + headerStackView.addArrangedSubview( + CreateTitleAndSubtitleView( + title: title, + subtitle: subtitle + ) + ) + return headerStackView +} + +private func CreateIconView(iconType: PaneWithHeaderLayoutView.Icon) -> UIView { + switch iconType { + case .view(let view): + return view + } +} + +private func CreateTitleAndSubtitleView(title: String, subtitle: String?) -> UIView { + let labelStackView = HitTestStackView() + labelStackView.axis = .vertical + labelStackView.spacing = 8 + + let titleLabel = AttributedTextView( + font: .heading(.large), + boldFont: .heading(.large), + linkFont: .heading(.large), + textColor: .textPrimary + ) + titleLabel.setText(title) + labelStackView.addArrangedSubview(titleLabel) + + if let subtitle = subtitle { + let subtitleLabel = AttributedTextView( + font: .body(.medium), + boldFont: .body(.mediumEmphasized), + linkFont: .body(.mediumEmphasized), + textColor: .textPrimary + ) + subtitleLabel.setText(subtitle) + labelStackView.addArrangedSubview(subtitleLabel) + } + + return labelStackView +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/ReusableInformationView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/ReusableInformationView.swift new file mode 100644 index 00000000..94cee600 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/ReusableInformationView.swift @@ -0,0 +1,175 @@ +// +// InformationViewController.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 7/25/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +/// A reusable view that allows developers to quickly +/// render information. +final class ReusableInformationView: UIView { + + enum IconType { + case view(UIView) + case loading + } + + struct ButtonConfiguration { + let title: String + let action: () -> Void + } + + private let primaryButtonAction: (() -> Void)? + private let secondaryButtonAction: (() -> Void)? + + init( + iconType: IconType, + title: String, + subtitle: String, + // the primary button is the bottom-most button + primaryButtonConfiguration: ButtonConfiguration? = nil, + secondaryButtonConfiguration: ButtonConfiguration? = nil + ) { + self.primaryButtonAction = primaryButtonConfiguration?.action + self.secondaryButtonAction = secondaryButtonConfiguration?.action + super.init(frame: .zero) + backgroundColor = .customBackgroundColor + + let paneLayoutView = PaneWithHeaderLayoutView( + icon: .view(CreateIconView(iconType: iconType)), + title: title, + subtitle: subtitle, + contentView: UIView(), + footerView: CreateFooterView( + primaryButtonConfiguration: primaryButtonConfiguration, + secondaryButtonConfiguration: secondaryButtonConfiguration, + view: self + ) + ) + paneLayoutView.addTo(view: self) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc fileprivate func didSelectPrimaryButton() { + primaryButtonAction?() + } + + @objc fileprivate func didSelectSecondaryButton() { + secondaryButtonAction?() + } +} + +private func CreateIconView(iconType: ReusableInformationView.IconType) -> UIView { + + switch iconType { + case .view(let iconView): + return iconView + case .loading: + return SpinnerIconView() + } +} + +private func CreateFooterView( + primaryButtonConfiguration: ReusableInformationView.ButtonConfiguration?, + secondaryButtonConfiguration: ReusableInformationView.ButtonConfiguration?, + view: ReusableInformationView +) -> UIView? { + guard + primaryButtonConfiguration != nil || secondaryButtonConfiguration != nil + else { + return nil // display no footer + } + let footerStackView = UIStackView() + footerStackView.axis = .vertical + footerStackView.spacing = 12 + if let secondaryButtonConfiguration = secondaryButtonConfiguration { + let secondaryButton = Button(configuration: .financialConnectionsSecondary) + secondaryButton.title = secondaryButtonConfiguration.title + secondaryButton.addTarget( + view, + action: #selector(ReusableInformationView.didSelectSecondaryButton), + for: .touchUpInside + ) + secondaryButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + secondaryButton.heightAnchor.constraint(equalToConstant: 56) + ]) + footerStackView.addArrangedSubview(secondaryButton) + } + if let primaryButtonConfiguration = primaryButtonConfiguration { + let primaryButton = Button(configuration: .financialConnectionsPrimary) + primaryButton.title = primaryButtonConfiguration.title + primaryButton.addTarget( + view, + action: #selector(ReusableInformationView.didSelectPrimaryButton), + for: .touchUpInside + ) + primaryButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + primaryButton.heightAnchor.constraint(equalToConstant: 56) + ]) + footerStackView.addArrangedSubview(primaryButton) + } + return footerStackView +} + +#if DEBUG + +import SwiftUI + +private struct ReusableInformationViewUIViewRepresentable: UIViewRepresentable { + + let primaryButtonConfiguration: ReusableInformationView.ButtonConfiguration? + let secondaryButtonConfiguration: ReusableInformationView.ButtonConfiguration? + + func makeUIView(context: Context) -> ReusableInformationView { + ReusableInformationView( + iconType: .loading, + title: "Establishing connection", + subtitle: "Please wait while a connection is established.", + primaryButtonConfiguration: primaryButtonConfiguration, + secondaryButtonConfiguration: secondaryButtonConfiguration + ) + } + + func updateUIView(_ uiView: ReusableInformationView, context: Context) {} +} + +struct ReusableInformationView_Previews: PreviewProvider { + static var previews: some View { + VStack { + ReusableInformationViewUIViewRepresentable( + primaryButtonConfiguration: ReusableInformationView.ButtonConfiguration( + title: "Try Again", + action: {} + ), + secondaryButtonConfiguration: ReusableInformationView.ButtonConfiguration( + title: "Enter Bank Details Manually", + action: {} + ) + ) + .frame(width: 320) + } + .frame(maxWidth: .infinity) + .background(Color.gray.opacity(0.1)) + + VStack { + ReusableInformationViewUIViewRepresentable( + primaryButtonConfiguration: nil, + secondaryButtonConfiguration: nil + ) + .frame(width: 320) + } + .frame(maxWidth: .infinity) + .background(Color.gray.opacity(0.1)) + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/SFSafariViewController+Extensions.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/SFSafariViewController+Extensions.swift new file mode 100644 index 00000000..585e8b08 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/SFSafariViewController+Extensions.swift @@ -0,0 +1,24 @@ +// +// SFSafariViewController+Extensions.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 6/16/22. +// + +import Foundation +import SafariServices + +extension SFSafariViewController { + + static func present(url: URL) { + guard + url.scheme == "http" || url.scheme == "https", + let topMostViewController = UIViewController.topMostViewController() + else { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + return + } + let safariViewController = SFSafariViewController(url: url) + topMostViewController.present(safariViewController, animated: true, completion: nil) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/SpinnerIconView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/SpinnerIconView.swift new file mode 100644 index 00000000..32818c9c --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/SpinnerIconView.swift @@ -0,0 +1,85 @@ +// +// SpinnerIconView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/28/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +final class SpinnerIconView: UIView { + + private lazy var iconImageView: UIImageView = { + let iconImageView = UIImageView() + iconImageView.backgroundColor = .clear + let image = Image.spinner.makeImage() + iconImageView.image = image + return iconImageView + }() + + init() { + super.init(frame: .zero) + backgroundColor = UIColor.clear + addAndPinSubview(iconImageView) + + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalToConstant: 40), + heightAnchor.constraint(equalToConstant: 40), + ]) + + startRotating() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + stopRotating() + } + + private func startRotating() { + let animationKey = "transform.rotation.z" + let animation = CABasicAnimation(keyPath: animationKey) + animation.toValue = NSNumber(value: .pi * 2.0) + animation.duration = 1 + animation.repeatCount = .infinity + animation.isCumulative = true + animation.isRemovedOnCompletion = false + animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + layer.add(animation, forKey: animationKey) + } + + private func stopRotating() { + layer.removeAllAnimations() + } +} + +#if DEBUG + +import SwiftUI + +private struct SpinnerIconViewUIViewRepresentable: UIViewRepresentable { + + func makeUIView(context: Context) -> SpinnerIconView { + SpinnerIconView() + } + + func updateUIView(_ uiView: SpinnerIconView, context: Context) {} +} + +struct SpinnerIconView_Previews: PreviewProvider { + static var previews: some View { + VStack { + SpinnerIconViewUIViewRepresentable() + .frame(width: 40, height: 40) + Spacer() + } + .padding() + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/SuccessIconView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/SuccessIconView.swift new file mode 100644 index 00000000..d849bf21 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/SuccessIconView.swift @@ -0,0 +1,74 @@ +// +// SuccessIconView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/14/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +final class SuccessIconView: UIView { + + private lazy var iconImageView: UIImageView = { + let iconImageView = UIImageView() + let image = Image.check.makeImage() + .withTintColor(.white) + iconImageView.image = image + return iconImageView + }() + + init() { + super.init(frame: .zero) + backgroundColor = UIColor.textSuccess + addSubview(iconImageView) + + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalToConstant: 40), + heightAnchor.constraint(equalToConstant: 40), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + iconImageView.sizeToFit() + iconImageView.center = CGPoint( + x: bounds.midX, + y: bounds.midY + ) + + layer.cornerRadius = bounds.size.width / 2.0 + } +} + +#if DEBUG + +import SwiftUI + +private struct SuccessIconViewUIViewRepresentable: UIViewRepresentable { + + func makeUIView(context: Context) -> SuccessIconView { + SuccessIconView() + } + + func updateUIView(_ uiView: SuccessIconView, context: Context) {} +} + +struct SuccessIconView_Previews: PreviewProvider { + static var previews: some View { + VStack { + SuccessIconViewUIViewRepresentable() + .frame(width: 40, height: 40) + Spacer() + } + .padding() + } +} + +#endif diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/TimeInterval+Extensions.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/TimeInterval+Extensions.swift new file mode 100644 index 00000000..53b35bbb --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/TimeInterval+Extensions.swift @@ -0,0 +1,16 @@ +// +// TimeInterval+Extensions.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 10/28/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +extension TimeInterval { + + var milliseconds: Int { + return Int(self * 1_000) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/UIImage+Extensions.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/UIImage+Extensions.swift new file mode 100644 index 00000000..337c83ec --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/UIImage+Extensions.swift @@ -0,0 +1,27 @@ +// +// UIImage+Extensions.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 11/17/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +extension UIImage { + + // create a new image with transparent insets around it + func withInsets(_ insets: UIEdgeInsets) -> UIImage? { + let newSize = CGSize( + width: size.width + insets.left * scale + insets.right * scale, + height: size.height + insets.top * scale + insets.bottom * scale + ) + UIGraphicsBeginImageContextWithOptions(newSize, false, scale) + let origin = CGPoint(x: insets.left * scale, y: insets.top * scale) + self.draw(at: origin) + let newImage = UIGraphicsGetImageFromCurrentImageContext()?.withRenderingMode(renderingMode) + UIGraphicsEndImageContext() + return newImage + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/UIImageView+Extensions.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/UIImageView+Extensions.swift new file mode 100644 index 00000000..85187318 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/UIImageView+Extensions.swift @@ -0,0 +1,72 @@ +// +// UIImageView+Extensions.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 10/11/22. +// + +import Foundation +import UIKit + +extension UIImageView { + + func setImage( + with urlString: String?, + placeholder: UIImage? = nil, + completionHandler: ((_ didDownloadImage: Bool) -> Void)? = nil + ) { + if let placeholder = placeholder { + image = placeholder + } + + guard let urlString = urlString else { + completionHandler?(false) + return + } + + // We use `tag` to ensure that if we call `setImage(with:)` multiple times, + // we ONLY set the image from the `urlString` for the last `urlString` passed. + // + // This avoids async bugs where an older image could override a newer image. + tag = urlString.hashValue + DownloadImage(urlString: urlString) { [weak self] image in + if let image = image { + DispatchQueue.main.async { + if self?.tag == urlString.hashValue { + self?.image = image + completionHandler?(true) + } + } + } else { + DispatchQueue.main.async { + if self?.tag == urlString.hashValue { + completionHandler?(false) + } + } + } + } + } +} + +private func DownloadImage( + urlString: String, + completionHandler: @escaping (UIImage?) -> Void +) { + guard let url = URL(string: urlString) else { + completionHandler(nil) + return + } + URLSession.shared.dataTask(with: url) { data, response, _ in + guard let response = response as? HTTPURLResponse else { + assertionFailure("we always expect to get back `HTTPURLResponse`") + completionHandler(nil) + return + } + if response.statusCode == 200, let data = data, let image = UIImage(data: data) { + completionHandler(image) + } else { + completionHandler(nil) + } + } + .resume() +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/UITableView+Extensions.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/UITableView+Extensions.swift new file mode 100644 index 00000000..8adb7a10 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/UITableView+Extensions.swift @@ -0,0 +1,31 @@ +// +// UITableView+Extensions.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 12/2/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import UIKit + +extension UITableView { + /** + Sets `tableHeaderView` of the table view by first changing the header frame size to system compressed size. + + This prevents various layout issues where we try to set a header before we have a size setup on table view. + */ + func setTableHeaderViewWithCompressedFrameSize(_ header: UIView) { + header.frame.size = header.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + self.tableHeaderView = header + } + + /** + Sets `tableFooterView` of the table view by first changing the footer frame size to system compressed size. + + This prevents various layout issues where we try to set a footer before we have a size setup on table view. + */ + func setTableFooterViewWithCompressedFrameSize(_ footer: UIView) { + footer.frame.size = footer.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + self.tableFooterView = footer + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/UIViewController+KeyboardAvoiding.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/UIViewController+KeyboardAvoiding.swift new file mode 100644 index 00000000..a70d624a --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Shared/UIViewController+KeyboardAvoiding.swift @@ -0,0 +1,179 @@ +// +// UIViewController+Stripe_KeyboardAvoiding.swift +// Stripe +// +// Created by Jack Flintermann on 4/15/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import UIKit + +typealias STPKeyboardFrameBlock = (CGRect, UIView?) -> Void +extension UIViewController { + + func stp_beginObservingKeyboardAndInsettingScrollView( + _ scrollView: UIScrollView?, + onChange block: STPKeyboardFrameBlock? + ) { + if let existing = stp_keyboardDetectingViewController() { + existing.removeFromParent() + existing.view.removeFromSuperview() + existing.didMove(toParent: nil) + } + let keyboardAvoiding = STPKeyboardDetectingViewController( + keyboardFrameBlock: block, + scrollView: scrollView + ) + addChild(keyboardAvoiding) + view.addSubview(keyboardAvoiding.view) + keyboardAvoiding.didMove(toParent: self) + } + + private func stp_keyboardDetectingViewController() -> STPKeyboardDetectingViewController? { + return + (children as NSArray).filtered( + using: NSPredicate(block: { viewController, _ in + return viewController is STPKeyboardDetectingViewController + }) + ).first as? STPKeyboardDetectingViewController + } +} + +// This is a private class that is only a UIViewController subclass by virtue of the fact +// that that makes it easier to attach to another UIViewController as a child. +class STPKeyboardDetectingViewController: UIViewController { + var lastKeyboardFrame = CGRect.zero + weak var lastResponder: UIView? + var keyboardFrameBlock: STPKeyboardFrameBlock? + weak var managedScrollView: UIScrollView? + var currentBottomInsetChange: CGFloat = 0.0 + + init( + keyboardFrameBlock block: STPKeyboardFrameBlock?, + scrollView: UIScrollView? + ) { + keyboardFrameBlock = block + super.init(nibName: nil, bundle: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillChangeFrame(_:)), + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(textFieldWillBeginEditing(_:)), + name: UITextField.textDidBeginEditingNotification, + object: nil + ) + managedScrollView = scrollView + currentBottomInsetChange = 0 + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override func loadView() { + let view = UIView() + view.backgroundColor = UIColor.clear + view.autoresizingMask = [] + self.view = view + } + + @objc func textFieldWillBeginEditing(_ notification: Notification) { + guard let textField = notification.object as? UITextField, let parentView = parent?.view, + textField.isDescendant(of: parentView) + else { + return + } + if let keyboardFrameBlock = keyboardFrameBlock, + textField != lastResponder && !lastKeyboardFrame.isEmpty + { + UIView.animate( + withDuration: 0.3, + delay: 0, + options: .curveEaseOut, + animations: { + keyboardFrameBlock(self.lastKeyboardFrame, textField) + } + ) + } + } + + @objc func keyboardWillChangeFrame(_ notification: Notification) { + // As of iOS 8, this all takes place inside the necessary animation block + // https://twitter.com/SmileyKeith/status/684100833823174656 + guard + var keyboardFrame = + (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)? + .cgRectValue, + let window = view.window + else { + return + } + keyboardFrame = window.convert(keyboardFrame, from: nil) + + if managedScrollView != nil { + if !lastKeyboardFrame.equalTo(keyboardFrame) { + let responder = parent?.view.stp_findFirstResponder() + lastResponder = responder + doKeyboardChangeAnimation(withNewFrame: keyboardFrame) + } + } + } + + func doKeyboardChangeAnimation(withNewFrame keyboardFrame: CGRect) { + lastKeyboardFrame = keyboardFrame + + if let managedScrollView = managedScrollView { + let scrollView = managedScrollView + let scrollViewSuperView = managedScrollView.superview + + var contentInsets = scrollView.contentInset + var scrollIndicatorInsets: UIEdgeInsets = .zero + #if !TARGET_OS_MACCATALYST + scrollIndicatorInsets = scrollView.verticalScrollIndicatorInsets + #else + scrollIndicatorInsets = scrollView.scrollIndicatorInsets + #endif + + let windowFrame = scrollViewSuperView?.convert( + scrollViewSuperView?.frame ?? CGRect.zero, + to: nil + ) + + let bottomIntersection = windowFrame?.intersection(keyboardFrame) + let bottomInsetDelta = + (bottomIntersection?.size.height ?? 0.0) - currentBottomInsetChange + contentInsets.bottom += bottomInsetDelta + scrollIndicatorInsets.bottom += bottomInsetDelta + currentBottomInsetChange += bottomInsetDelta + scrollView.contentInset = contentInsets + scrollView.scrollIndicatorInsets = scrollIndicatorInsets + } + + if let keyboardFrameBlock = keyboardFrameBlock { + keyboardFrameBlock(keyboardFrame, lastResponder) + } + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } +} + +private extension UIView { + func stp_findFirstResponder() -> UIView? { + if isFirstResponder { + return self + } + for subView in subviews { + let responder = subView.stp_findFirstResponder() + if let responder = responder { + return responder + } + } + return nil + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Success/SuccessAccountListView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Success/SuccessAccountListView.swift new file mode 100644 index 00000000..eb173e53 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Success/SuccessAccountListView.swift @@ -0,0 +1,124 @@ +// +// SuccessAccountListView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/16/22. +// + +import Foundation +@_spi(STP) import StripeUICore +import UIKit + +final class SuccessAccountListView: UIView { + + private let maxNumberOfAccountsListedBeforeShowingOnlyAccountCount = 4 + + init(institution: FinancialConnectionsInstitution, linkedAccounts: [FinancialConnectionsPartnerAccount]) { + super.init(frame: .zero) + let accountListView: UIView + if linkedAccounts.count > maxNumberOfAccountsListedBeforeShowingOnlyAccountCount { + accountListView = CreateAccountCountView(institution: institution, numberOfAccounts: linkedAccounts.count) + } else { + accountListView = CreateAccountListView(institution: institution, accounts: linkedAccounts) + } + addAndPinSubview(accountListView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private func CreateAccountCountView(institution: FinancialConnectionsInstitution, numberOfAccounts: Int) -> UIView { + let numberOfAccountsLabel = AttributedLabel( + font: .label(.mediumEmphasized), + textColor: .textSecondary + ) + numberOfAccountsLabel.textAlignment = .right + numberOfAccountsLabel.text = String( + format: STPLocalizedString( + "%d accounts", + "An textual description of how many bank accounts user has successfully connected (or linked). Once the bank accounts are connected (or linked), the user will be able to use those bank accounts for payments. %d will be replaced by the number of accounts connected (or linked)." + ), + numberOfAccounts + ) + + let horizontalStackView = UIStackView( + arrangedSubviews: [ + CreateIconWithLabelView(institution: institution, text: institution.name), + numberOfAccountsLabel, + ] + ) + horizontalStackView.axis = .horizontal + horizontalStackView.distribution = .fillProportionally + horizontalStackView.spacing = 8 + return horizontalStackView +} + +private func CreateAccountListView( + institution: FinancialConnectionsInstitution, + accounts: [FinancialConnectionsPartnerAccount] +) -> UIView { + let accountRowVerticalStackView = UIStackView( + arrangedSubviews: accounts.map { account in + CreateAccountRowView(institution: institution, account: account) + } + ) + accountRowVerticalStackView.axis = .vertical + accountRowVerticalStackView.spacing = 16 + return accountRowVerticalStackView +} + +private func CreateAccountRowView( + institution: FinancialConnectionsInstitution, + account: FinancialConnectionsPartnerAccount +) -> UIView { + let horizontalStackView = UIStackView() + horizontalStackView.axis = .horizontal + horizontalStackView.spacing = 8 + + horizontalStackView.addArrangedSubview( + CreateIconWithLabelView( + institution: institution, + text: account.name + ) + ) + + if let displayableAccountNumbers = account.displayableAccountNumbers { + let displayableAccountNumberLabel = AttributedLabel( + font: .label(.mediumEmphasized), + textColor: .textSecondary + ) + displayableAccountNumberLabel.text = "••••\(displayableAccountNumbers)" + // compress `account.name` instead of account number if text is long + displayableAccountNumberLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + horizontalStackView.addArrangedSubview(displayableAccountNumberLabel) + } + + return horizontalStackView +} + +private func CreateIconWithLabelView(institution: FinancialConnectionsInstitution, text: String) -> UIView { + let institutionIconView = InstitutionIconView(size: .small) + institutionIconView.setImageUrl(institution.icon?.default) + + let label = AttributedLabel( + font: .label(.mediumEmphasized), + textColor: .textPrimary + ) + label.text = text + label.translatesAutoresizingMaskIntoConstraints = false + label.setContentHuggingPriority(.defaultHigh, for: .horizontal) + // compress `account.name` instead of account number if text is long + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let horizontalStackView = UIStackView( + arrangedSubviews: [ + institutionIconView, + label, + ] + ) + horizontalStackView.axis = .horizontal + horizontalStackView.spacing = 8 + return horizontalStackView +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Success/SuccessBodyView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Success/SuccessBodyView.swift new file mode 100644 index 00000000..ac49c687 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Success/SuccessBodyView.swift @@ -0,0 +1,190 @@ +// +// SuccessContentView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/15/22. +// + +import Foundation +import SafariServices +@_spi(STP) import StripeUICore +import UIKit + +final class SuccessBodyView: HitTestView { + + init( + institution: FinancialConnectionsInstitution, + linkedAccounts: [FinancialConnectionsPartnerAccount], + isStripeDirect: Bool, + businessName: String?, + permissions: [StripeAPI.FinancialConnectionsAccount.Permissions], + accountDisconnectionMethod: FinancialConnectionsSessionManifest.AccountDisconnectionMethod?, + isEndUserFacing: Bool, + isNetworking: Bool, + analyticsClient: FinancialConnectionsAnalyticsClient, + didSelectDisconnectYourAccounts: @escaping () -> Void, + didSelectMerchantDataAccessLearnMore: @escaping () -> Void + ) { + super.init(frame: .zero) + let verticalStackView = HitTestStackView() + verticalStackView.axis = .vertical + verticalStackView.spacing = 12 + + if !linkedAccounts.isEmpty { + verticalStackView.addArrangedSubview( + CreateInformationBoxView( + accountsListView: SuccessAccountListView( + institution: institution, + linkedAccounts: linkedAccounts + ), + dataDisclosureView: CreateDataAccessDisclosureView( + isStripeDirect: isStripeDirect, + businessName: businessName, + permissions: permissions, + isNetworking: isNetworking, + didSelectLearnMore: didSelectMerchantDataAccessLearnMore + ) + ) + ) + } + verticalStackView.addArrangedSubview( + CreateDisconnectAccountLabel( + isLinkingOneAccount: (linkedAccounts.count == 1), + accountDisconnectionMethod: accountDisconnectionMethod ?? .email, + isEndUserFacing: isEndUserFacing, + didSelectDisconnectYourAccounts: didSelectDisconnectYourAccounts + ) + ) + + addAndPinSubview(verticalStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private func CreateInformationBoxView( + accountsListView: UIView, + dataDisclosureView: UIView +) -> UIView { + let informationBoxVerticalStackView = HitTestStackView( + arrangedSubviews: [ + accountsListView, + dataDisclosureView, + ] + ) + informationBoxVerticalStackView.axis = .vertical + informationBoxVerticalStackView.spacing = 16 + informationBoxVerticalStackView.isLayoutMarginsRelativeArrangement = true + informationBoxVerticalStackView.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: 12, + leading: 12, + bottom: 12, + trailing: 12 + ) + informationBoxVerticalStackView.backgroundColor = .backgroundContainer + informationBoxVerticalStackView.layer.cornerRadius = 8 + return informationBoxVerticalStackView +} + +private func CreateDataAccessDisclosureView( + isStripeDirect: Bool, + businessName: String?, + permissions: [StripeAPI.FinancialConnectionsAccount.Permissions], + isNetworking: Bool, + didSelectLearnMore: @escaping () -> Void +) -> UIView { + let separatorView = UIView() + separatorView.backgroundColor = .borderNeutral + separatorView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + separatorView.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.nativeScale) + ]) + + let verticalStackView = HitTestStackView( + arrangedSubviews: [ + separatorView, + MerchantDataAccessView( + isStripeDirect: isStripeDirect, + businessName: businessName, + permissions: permissions, + isNetworking: isNetworking, + font: .label(.small), + boldFont: .label(.smallEmphasized), + alignCenter: false, + didSelectLearnMore: didSelectLearnMore + ), + ] + ) + verticalStackView.axis = .vertical + verticalStackView.spacing = 11 + return verticalStackView +} + +private func CreateDisconnectAccountLabel( + isLinkingOneAccount: Bool, + accountDisconnectionMethod: FinancialConnectionsSessionManifest.AccountDisconnectionMethod, + isEndUserFacing: Bool, + didSelectDisconnectYourAccounts: @escaping () -> Void +) -> UIView { + let disconnectYourAccountLocalizedString: String = { + if isLinkingOneAccount { + return STPLocalizedString( + "disconnect your account", + "One part of larger text 'You can disconnect your account at any time.' The text instructs the user that the bank accounts they linked to Stripe, can always be disconnected later. The 'disconnect your account' part is clickable and will show user a support website." + ) + } else { + return STPLocalizedString( + "disconnect your accounts", + "One part of larger text 'You can disconnect your account at any time.' The text instructs the user that the bank accounts they linked to Stripe, can always be disconnected later. The 'disconnect your account' part is clickable and will show user a support website." + ) + } + }() + let fullLocalizedString = STPLocalizedString( + "You can %@ at any time.", + "The text instructs the user that the bank accounts they linked to Stripe, can always be disconnected later. '%@' will be replaced by 'disconnect your account', to form a full string: 'You can disconnect your account at any time.'." + ) + let disconnectionUrlString = DisconnectionURLString( + accountDisconnectionMethod: accountDisconnectionMethod, + isEndUserFacing: isEndUserFacing + ) + + let disconnectAccountLabel = AttributedTextView( + font: .body(.small), + boldFont: .body(.smallEmphasized), + linkFont: .body(.smallEmphasized), + textColor: .textSecondary + ) + disconnectAccountLabel.setText( + String(format: fullLocalizedString, "[\(disconnectYourAccountLocalizedString)](\(disconnectionUrlString))"), + action: { url in + SFSafariViewController.present(url: url) + didSelectDisconnectYourAccounts() + } + ) + return disconnectAccountLabel +} + +private func DisconnectionURLString( + accountDisconnectionMethod: FinancialConnectionsSessionManifest.AccountDisconnectionMethod, + isEndUserFacing: Bool +) -> String { + switch accountDisconnectionMethod { + case .support: + if isEndUserFacing { + return "https://support.stripe.com/user/how-do-i-disconnect-my-linked-financial-account" + } else { + return "https://support.stripe.com/how-to-disconnect-a-linked-financial-account" + } + case .dashboard: + return "https://dashboard.stripe.com/settings/linked-accounts" + case .link: + return + "https://support.link.co/questions/connecting-your-bank-account#how-do-i-disconnect-my-connected-bank-account" + case .unparsable: + fallthrough + case .email: + return "https://support.stripe.com/contact" + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Success/SuccessDataSource.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Success/SuccessDataSource.swift new file mode 100644 index 00000000..18ba73fd --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Success/SuccessDataSource.swift @@ -0,0 +1,51 @@ +// +// SuccessDataSource.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/12/22. +// + +import Foundation +@_spi(STP) import StripeCore + +protocol SuccessDataSource: AnyObject { + + var manifest: FinancialConnectionsSessionManifest { get } + var linkedAccounts: [FinancialConnectionsPartnerAccount] { get } + var institution: FinancialConnectionsInstitution { get } + var saveToLinkWithStripeSucceeded: Bool? { get } + var analyticsClient: FinancialConnectionsAnalyticsClient { get } + var showLinkMoreAccountsButton: Bool { get } +} + +final class SuccessDataSourceImplementation: SuccessDataSource { + + let manifest: FinancialConnectionsSessionManifest + let linkedAccounts: [FinancialConnectionsPartnerAccount] + let institution: FinancialConnectionsInstitution + let saveToLinkWithStripeSucceeded: Bool? + private let apiClient: FinancialConnectionsAPIClient + private let clientSecret: String + let analyticsClient: FinancialConnectionsAnalyticsClient + var showLinkMoreAccountsButton: Bool { + !manifest.singleAccount && !manifest.disableLinkMoreAccounts && !(manifest.isNetworkingUserFlow ?? false) + } + + init( + manifest: FinancialConnectionsSessionManifest, + linkedAccounts: [FinancialConnectionsPartnerAccount], + institution: FinancialConnectionsInstitution, + saveToLinkWithStripeSucceeded: Bool?, + apiClient: FinancialConnectionsAPIClient, + clientSecret: String, + analyticsClient: FinancialConnectionsAnalyticsClient + ) { + self.manifest = manifest + self.linkedAccounts = linkedAccounts + self.institution = institution + self.saveToLinkWithStripeSucceeded = saveToLinkWithStripeSucceeded + self.apiClient = apiClient + self.clientSecret = clientSecret + self.analyticsClient = analyticsClient + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Success/SuccessFooterView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Success/SuccessFooterView.swift new file mode 100644 index 00000000..80d3b3f4 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Success/SuccessFooterView.swift @@ -0,0 +1,119 @@ +// +// SuccessFooterView.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/15/22. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +final class SuccessFooterView: UIView { + + private let didSelectDone: (SuccessFooterView) -> Void + + private lazy var doneButton: Button = { + let doneButton = Button(configuration: .financialConnectionsPrimary) + doneButton.title = "Done" // TODO: replace with UIButton.doneButtonTitle once the SDK is localized + doneButton.addTarget(self, action: #selector(didSelectDoneButton), for: .touchUpInside) + doneButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + doneButton.heightAnchor.constraint(equalToConstant: 56) + ]) + doneButton.accessibilityIdentifier = "success_done_button" + return doneButton + }() + + init( + showFailedToLinkNotice: Bool, + businessName: String?, + didSelectDone: @escaping (SuccessFooterView) -> Void + ) { + self.didSelectDone = didSelectDone + super.init(frame: .zero) + + let footerStackView = UIStackView() + footerStackView.axis = .vertical + footerStackView.spacing = 24 + + if showFailedToLinkNotice { + let saveToLinkFailedNoticeView = CreateSaveToLinkFailedNoticeView( + businessName: businessName + ) + footerStackView.addArrangedSubview(saveToLinkFailedNoticeView) + } + footerStackView.addArrangedSubview(doneButton) + addAndPinSubviewToSafeArea(footerStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func didSelectDoneButton() { + didSelectDone(self) + } + + func setIsLoading(_ isLoading: Bool) { + doneButton.isLoading = isLoading + } +} + +private func CreateSaveToLinkFailedNoticeView( + businessName: String? +) -> UIView { + let errorLabelFont = FinancialConnectionsFont.label(.smallEmphasized) + let warningIconWidthAndHeight: CGFloat = 12 + let warningIconInsets = errorLabelFont.topPadding + let warningIconImageView = UIImageView() + warningIconImageView.image = Image.warning_triangle.makeImage() + .withTintColor(.textCritical) + // Align the icon to the center of the first line. + // + // UIStackView does not do a great job of doing this + // automatically. + .withAlignmentRectInsets( + UIEdgeInsets(top: -warningIconInsets, left: 0, bottom: warningIconInsets, right: 0) + ) + warningIconImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + warningIconImageView.widthAnchor.constraint(equalToConstant: warningIconWidthAndHeight), + warningIconImageView.heightAnchor.constraint(equalToConstant: warningIconWidthAndHeight), + ]) + + let errorLabel = AttributedLabel( + font: .label(.smallEmphasized), + textColor: .textPrimary + ) + errorLabel.numberOfLines = 0 + errorLabel.text = { + if let businessName = businessName { + return String(format: STPLocalizedString("Your account was connected to %@ but could not be saved to Link at this time.", "A warning message that explains the user that their bank account was successfully connected for payments, but it was not connected to Stripe's Link network. '%@' will be replaced by the business name, ex. Cola Cola Inc."), businessName) + } else { + return STPLocalizedString("Your account was connected but could not be saved to Link at this time.", "A warning message that explains the user that their bank account was successfully connected for payments, but it was not connected to Stripe's Link network.") + } + }() + errorLabel.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + + let horizontalStackView = HitTestStackView( + arrangedSubviews: [ + warningIconImageView, + errorLabel, + ] + ) + horizontalStackView.axis = .horizontal + horizontalStackView.alignment = .top + horizontalStackView.spacing = 8 + horizontalStackView.isLayoutMarginsRelativeArrangement = true + horizontalStackView.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: 10, + leading: 12, + bottom: 10, + trailing: 12 + ) + horizontalStackView.backgroundColor = .attention50 + horizontalStackView.layer.cornerRadius = 8 + return horizontalStackView +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Success/SuccessViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Success/SuccessViewController.swift new file mode 100644 index 00000000..338392e9 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/Success/SuccessViewController.swift @@ -0,0 +1,162 @@ +// +// SuccessViewController.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 8/12/22. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol SuccessViewControllerDelegate: AnyObject { + func successViewControllerDidSelectDone(_ viewController: SuccessViewController) +} + +final class SuccessViewController: UIViewController { + + private let dataSource: SuccessDataSource + weak var delegate: SuccessViewControllerDelegate? + + init(dataSource: SuccessDataSource) { + self.dataSource = dataSource + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .customBackgroundColor + navigationItem.hidesBackButton = true + + let showSaveToLinkFailedNotice = (dataSource.saveToLinkWithStripeSucceeded == false) + let paneWithHeaderLayoutView = PaneWithHeaderLayoutView( + icon: .view(SuccessIconView()), + title: STPLocalizedString( + "Success!", + "The title of the success screen that appears when a user is done with the process of connecting their bank account to an application. Now that the bank account is connected (or linked), the user will be able to use the bank account for payments." + ), + subtitle: CreateSubtitleText( + businessName: dataSource.manifest.businessName, + isLinkingOneAccount: (dataSource.linkedAccounts.count == 1), + isNetworkingUserFlow: dataSource.manifest.isNetworkingUserFlow, + saveToLinkWithStripeSucceeded: dataSource.saveToLinkWithStripeSucceeded + ), + contentView: SuccessBodyView( + institution: dataSource.institution, + linkedAccounts: dataSource.linkedAccounts, + isStripeDirect: dataSource.manifest.isStripeDirect ?? false, + businessName: dataSource.manifest.businessName, + permissions: dataSource.manifest.permissions, + accountDisconnectionMethod: dataSource.manifest.accountDisconnectionMethod, + isEndUserFacing: dataSource.manifest.isEndUserFacing ?? false, + isNetworking: dataSource.manifest.isNetworkingUserFlow == true && dataSource.saveToLinkWithStripeSucceeded == true, + analyticsClient: dataSource.analyticsClient, + didSelectDisconnectYourAccounts: { [weak self] in + guard let self = self else { return } + self.dataSource + .analyticsClient + .log( + eventName: "click.disconnect_link", + pane: .success + ) + }, + didSelectMerchantDataAccessLearnMore: { [weak self] in + guard let self = self else { return } + self.dataSource + .analyticsClient + .logMerchantDataAccessLearnMore(pane: .success) + } + ), + footerView: SuccessFooterView( + showFailedToLinkNotice: showSaveToLinkFailedNotice, + businessName: dataSource.manifest.businessName, + didSelectDone: { [weak self] footerView in + guard let self = self else { return } + // we NEVER set isLoading to `false` because + // we will always close the Auth Flow + footerView.setIsLoading(true) + self.dataSource + .analyticsClient + .log( + eventName: "click.done", + pane: .success + ) + self.delegate?.successViewControllerDidSelectDone(self) + } + ) + ) + paneWithHeaderLayoutView.addTo(view: view) + + dataSource + .analyticsClient + .logPaneLoaded(pane: .success) + + if showSaveToLinkFailedNotice { + dataSource + .analyticsClient + .log( + eventName: "networking.save_to_link_failed_notice", + pane: .success + ) + } + } +} + +private func CreateSubtitleText( + businessName: String?, + isLinkingOneAccount: Bool, + isNetworkingUserFlow: Bool?, + saveToLinkWithStripeSucceeded: Bool? +) -> String { + if isNetworkingUserFlow == true && saveToLinkWithStripeSucceeded == true { + if let businessName = businessName { + return String( + format: STPLocalizedString( + "Your account was successfully connected to %@ through Link.", + "The subtitle/description of the success screen that appears when a user is done with the process of connecting their bank account to an application. Now that the bank account is connected, the user will be able to use the bank account for payments. %@ will be replaced by the business name, for example, The Coca-Cola Company." + ), + businessName + ) + } else { + return STPLocalizedString( + "Your account was successfully connected to Link.", + "The subtitle/description of the success screen that appears when a user is done with the process of connecting their bank account to an application. Now that the bank account is connected, the user will be able to use the bank account for payments." + ) + } + } else if isLinkingOneAccount { + if let businessName = businessName { + return String( + format: STPLocalizedString( + "Your account was successfully linked to %@ through Stripe.", + "The subtitle/description of the success screen that appears when a user is done with the process of connecting their bank account to an application. Now that the bank account is connected (or linked), the user will be able to use the bank account for payments. %@ will be replaced by the business name, for example, The Coca-Cola Company." + ), + businessName + ) + } else { + return STPLocalizedString( + "Your account was successfully linked to Stripe.", + "The subtitle/description of the success screen that appears when a user is done with the process of connecting their bank account to an application. Now that the bank account is connected (or linked), the user will be able to use the bank account for payments." + ) + } + } else { // multiple bank accounts + if let businessName = businessName { + return String( + format: STPLocalizedString( + "Your accounts were successfully linked to %@ through Stripe.", + "The subtitle/description of the success screen that appears when a user is done with the process of connecting their bank accounts to an application. Now that the bank accounts are connected (or linked), the user will be able to use those bank accounts for payments. %@ will be replaced by the business name, for example, The Coca-Cola Company." + ), + businessName + ) + } else { + return STPLocalizedString( + "Your accounts were successfully linked to Stripe.", + "The subtitle/description of the success screen that appears when a user is done with the process of connecting their bank accounts to an application. Now that the bank accounts are connected (or linked), the user will be able to use those bank accounts for payments." + ) + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/TerminalError/TerminalErrorViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/TerminalError/TerminalErrorViewController.swift new file mode 100644 index 00000000..ddc7c455 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/TerminalError/TerminalErrorViewController.swift @@ -0,0 +1,90 @@ +// +// TerminalErrorViewController.swift +// StripeFinancialConnections +// +// Created by Krisjanis Gaidis on 9/15/22. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol TerminalErrorViewControllerDelegate: AnyObject { + func terminalErrorViewController(_ viewController: TerminalErrorViewController, didCloseWithError error: Error) + func terminalErrorViewControllerDidSelectManualEntry(_ viewController: TerminalErrorViewController) +} + +final class TerminalErrorViewController: UIViewController { + + private let error: Error + private let allowManualEntry: Bool + weak var delegate: TerminalErrorViewControllerDelegate? + + init(error: Error, allowManualEntry: Bool) { + self.error = error + self.allowManualEntry = allowManualEntry + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .customBackgroundColor + navigationItem.hidesBackButton = true + + let errorView = ReusableInformationView( + iconType: .view(CreateGenericErrorIconView()), + title: STPLocalizedString( + "Something went wrong", + "Title of a screen that shows an error. The error screen appears after user has selected a bank. The error is a generic one: something wrong happened and we are not sure what." + ), + subtitle: { + if allowManualEntry { + return STPLocalizedString( + "Your account can't be linked at this time. Please enter your bank details manually or try again later.", + "The subtitle/description of a screen that shows an error. The error is generic: something wrong happened and we are not sure what." + ) + } else { + return STPLocalizedString( + "Your account can't be linked at this time. Please try again later.", + "The subtitle/description of a screen that shows an error. The error is generic: something wrong happened and we are not sure what." + ) + } + }(), + primaryButtonConfiguration: { + if allowManualEntry { + return ReusableInformationView.ButtonConfiguration( + title: String.Localized.enter_bank_details_manually, + action: { [weak self] in + guard let self = self else { return } + self.delegate?.terminalErrorViewControllerDidSelectManualEntry(self) + } + ) + } else { + return ReusableInformationView.ButtonConfiguration( + title: "Close", // TODO: once we localize use String.Localized.close + action: { [weak self] in + guard let self = self else { return } + self.delegate?.terminalErrorViewController(self, didCloseWithError: self.error) + } + ) + } + }() + ) + view.addAndPinSubviewToSafeArea(errorView) + } +} + +private func CreateGenericErrorIconView() -> UIView { + let iconImageView = UIImageView(image: Image.generic_error.makeImage()) + iconImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + iconImageView.widthAnchor.constraint(equalToConstant: 40), + iconImageView.heightAnchor.constraint(equalToConstant: 40), + ]) + return iconImageView +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Placeholder.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Placeholder.swift new file mode 100644 index 00000000..9dc485a7 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Placeholder.swift @@ -0,0 +1,10 @@ +// +// Placeholder.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 11/9/21. +// + +import Foundation + +// TODO(vav): remove once we add actual code files diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/StripeCore+Import.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/StripeCore+Import.swift new file mode 100644 index 00000000..969a1ebc --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/StripeCore+Import.swift @@ -0,0 +1,9 @@ +// +// StripeCore+Import.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 12/3/21. +// + +import Foundation +@_exported import StripeCore diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Web/AuthenticationSessionManager.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Web/AuthenticationSessionManager.swift new file mode 100644 index 00000000..7912fcc1 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Web/AuthenticationSessionManager.swift @@ -0,0 +1,141 @@ +// +// AuthenticationSessionManager.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 12/3/21. +// + +import AuthenticationServices +@_spi(STP) import StripeCore +import UIKit + +final class AuthenticationSessionManager: NSObject { + + // MARK: - Types + + enum Result { + case success + case webCancelled + case nativeCancelled + case redirect(url: URL) + } + + // MARK: - Properties + + private var authSession: ASWebAuthenticationSession? + private let manifest: FinancialConnectionsSessionManifest + private var window: UIWindow? + + // MARK: - Init + + init(manifest: FinancialConnectionsSessionManifest, window: UIWindow?) { + self.manifest = manifest + self.window = window + } + + // MARK: - Public + + func start(additionalQueryParameters: String? = nil) -> Promise { + let promise = Promise() + + guard let hostedAuthUrl = manifest.hostedAuthUrl else { + promise.reject(with: FinancialConnectionsSheetError.unknown(debugDescription: "NULL `hostedAuthUrl`")) + return promise + } + + let urlString = hostedAuthUrl + (additionalQueryParameters ?? "") + + guard let url = URL(string: urlString) else { + promise.reject(with: FinancialConnectionsSheetError.unknown(debugDescription: "Malformed hosted auth URL")) + return promise + } + + guard let successUrl = manifest.successUrl else { + promise.reject(with: FinancialConnectionsSheetError.unknown(debugDescription: "NULL `successUrl`")) + return promise + } + + let authSession = ASWebAuthenticationSession( + url: url, + callbackURLScheme: URL(string: successUrl)?.scheme, + completionHandler: { [weak self] returnUrl, error in + guard let self = self else { return } + if let error = error { + if let authenticationSessionError = error as? ASWebAuthenticationSessionError { + switch authenticationSessionError.code { + case .canceledLogin: + promise.resolve(with: .nativeCancelled) + default: + promise.reject(with: authenticationSessionError) + } + } else { + promise.reject(with: error) + } + return + } + + guard let returnUrlString = returnUrl?.absoluteString else { + promise.reject(with: FinancialConnectionsSheetError.unknown(debugDescription: "Missing return URL")) + return + } + + if returnUrlString == self.manifest.successUrl { + promise.resolve(with: .success) + } else if returnUrlString == self.manifest.cancelUrl { + promise.resolve(with: .webCancelled) + } else if returnUrlString.hasNativeRedirectPrefix, + let targetURL = URL(string: returnUrlString.droppingNativeRedirectPrefix()) + { + promise.resolve(with: .redirect(url: targetURL)) + } else { + promise.reject(with: FinancialConnectionsSheetError.unknown(debugDescription: "Nil return URL")) + } + } + ) + authSession.presentationContextProvider = self + authSession.prefersEphemeralWebBrowserSession = true + + self.authSession = authSession + if #available(iOS 13.4, *) { + if !authSession.canStart { + promise.reject( + with: FinancialConnectionsSheetError.unknown(debugDescription: "Failed to start session") + ) + return promise + } + } + /** + This terribly hacky animation disabling is needed to control the presentation of ASWebAuthenticationSession underlying view controller. + Since we present a modal already that itself presents ASWebAuthenticationSession, the double modal animation is jarring and a bad UX. + We disable animations for a second. Sometimes there is a delay in creating the ASWebAuthenticationSession underlying view controller + to be safe, I made the delay a full second. I didn't find a good way to make this approach less clowny. + PresentedViewController is not KVO compliant and the notifications sent by presentation view controller that could help with knowing when + ASWebAuthenticationSession underlying view controller finished presenting are considered private API. + */ + let animationsEnabledOriginalValue = UIView.areAnimationsEnabled + UIView.setAnimationsEnabled(false) + + if !authSession.start() { + UIView.setAnimationsEnabled(animationsEnabledOriginalValue) + promise.reject(with: FinancialConnectionsSheetError.unknown(debugDescription: "Failed to start session")) + return promise + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + UIView.setAnimationsEnabled(animationsEnabledOriginalValue) + } + + return promise + } +} + +// MARK: - ASWebAuthenticationPresentationContextProviding + +/// :nodoc: + +extension AuthenticationSessionManager: ASWebAuthenticationPresentationContextProviding { + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return self.window ?? ASPresentationAnchor() + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Web/ContinueStateView.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Web/ContinueStateView.swift new file mode 100644 index 00000000..f9fc71df --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Web/ContinueStateView.swift @@ -0,0 +1,82 @@ +// +// ContinueStateView.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 10/5/22. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +class ContinueStateView: UIView { + + // MARK: - Properties + + private let didSelectContinue: () -> Void + + // MARK: - UIView + + init( + institutionImageUrl: String?, + didSelectContinue: @escaping () -> Void + ) { + self.didSelectContinue = didSelectContinue + super.init(frame: .zero) + backgroundColor = .customBackgroundColor + + let paneLayoutView = PaneWithHeaderLayoutView( + icon: .view( + { + let institutionIconView = InstitutionIconView(size: .large) + institutionIconView.setImageUrl(institutionImageUrl) + return institutionIconView + }() + ), + title: STPLocalizedString( + "Continue linking your account", + "Title for a label of a screen telling users to tap below to continue linking process." + ), + subtitle: STPLocalizedString( + "You haven't finished linking your account. Press continue to finish the process.", + "Title for a label explaining that the linking process hasn't finished yet." + ), + contentView: { + let clearView = UIView() + clearView.backgroundColor = .clear + return clearView + }(), + footerView: CreateFooterView( + view: self + ) + ) + paneLayoutView.addTo(view: self) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc fileprivate func didSelectContinueButton() { + didSelectContinue() + } +} + +private func CreateFooterView( + view: ContinueStateView +) -> UIView { + let continueButton = Button(configuration: .financialConnectionsPrimary) + continueButton.title = "Continue" // TODO: when Financial Connections starts supporting localization, change this to `String.Localized.continue` + continueButton.addTarget(view, action: #selector(ContinueStateView.didSelectContinueButton), for: .touchUpInside) + continueButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + continueButton.heightAnchor.constraint(equalToConstant: 56) + ]) + + let footerStackView = UIStackView() + footerStackView.axis = .vertical + footerStackView.spacing = 20 + footerStackView.addArrangedSubview(continueButton) + + return footerStackView +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Web/FinancialConnectionsAccountFetcher.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Web/FinancialConnectionsAccountFetcher.swift new file mode 100644 index 00000000..42dbf8ff --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Web/FinancialConnectionsAccountFetcher.swift @@ -0,0 +1,69 @@ +// +// FinancialConnectionsAccountFetcher.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 12/30/21. +// + +import Foundation +@_spi(STP) import StripeCore + +protocol FinancialConnectionsAccountFetcher { + func fetchAccounts( + initial: [StripeAPI.FinancialConnectionsAccount] + ) -> Future<[StripeAPI.FinancialConnectionsAccount]> +} + +class FinancialConnectionsAccountAPIFetcher: FinancialConnectionsAccountFetcher { + + // MARK: - Properties + + private let api: FinancialConnectionsAPIClient + private let clientSecret: String + + // MARK: - Init + + init(api: FinancialConnectionsAPIClient, clientSecret: String) { + self.api = api + self.clientSecret = clientSecret + } + + // MARK: - FinancialConnectionsAccountFetcher + + func fetchAccounts(initial: [StripeAPI.FinancialConnectionsAccount]) -> Future< + [StripeAPI.FinancialConnectionsAccount] + > { + return fetchAccounts(resultsSoFar: initial) + } +} + +// MARK: - Helpers + +extension FinancialConnectionsAccountAPIFetcher { + + private func fetchAccounts( + resultsSoFar: [StripeAPI.FinancialConnectionsAccount] + ) -> Future<[StripeAPI.FinancialConnectionsAccount]> { + let lastId = resultsSoFar.last?.id + let promise = api.fetchFinancialConnectionsAccounts( + clientSecret: clientSecret, + startingAfterAccountId: lastId + ) + return promise.chained { list in + let combinedResults = resultsSoFar + list.data + guard list.hasMore, combinedResults.count < Constants.maxAccountLimit else { + return Promise(value: combinedResults) + } + return self.fetchAccounts(resultsSoFar: combinedResults) + } + + } +} + +// MARK: - Constants + +extension FinancialConnectionsAccountAPIFetcher { + private enum Constants { + static let maxAccountLimit = 100 + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Web/FinancialConnectionsSessionFetcher.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Web/FinancialConnectionsSessionFetcher.swift new file mode 100644 index 00000000..c475504a --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Web/FinancialConnectionsSessionFetcher.swift @@ -0,0 +1,68 @@ +// +// FinancialConnectionsSessionFetcher.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 1/20/22. +// + +import Foundation +@_spi(STP) import StripeCore + +protocol FinancialConnectionsSessionFetcher { + func fetchSession() -> Future +} + +class FinancialConnectionsSessionAPIFetcher: FinancialConnectionsSessionFetcher { + + // MARK: - Properties + + private let api: FinancialConnectionsAPIClient + private let clientSecret: String + private let accountFetcher: FinancialConnectionsAccountFetcher + + // MARK: - Init + + init( + api: FinancialConnectionsAPIClient, + clientSecret: String, + accountFetcher: FinancialConnectionsAccountFetcher + ) { + self.api = api + self.clientSecret = clientSecret + self.accountFetcher = accountFetcher + } + + // MARK: - AccountFetcher + + func fetchSession() -> Future { + api.fetchFinancialConnectionsSession(clientSecret: clientSecret).chained { [weak self] session in + guard session.accounts.hasMore, let self = self else { + return Promise(value: session) + } + + return self.accountFetcher + .fetchAccounts(initial: session.accounts.data) + .chained { fullAccountList in + /** + Here we create a synthetic FinancialConnectionsSession object with full account list. + */ + let fullList = StripeAPI.FinancialConnectionsSession.AccountList( + data: fullAccountList, + hasMore: false + ) + let sessionWithFullAccountList = StripeAPI.FinancialConnectionsSession( + clientSecret: session.clientSecret, + id: session.id, + accounts: fullList, + livemode: session.livemode, + paymentAccount: session.paymentAccount, + bankAccountToken: session.bankAccountToken, + status: session.status, + statusDetails: session.statusDetails + ) + return Promise(value: sessionWithFullAccountList) + } + } + } + +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Web/FinancialConnectionsWebFlowViewController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Web/FinancialConnectionsWebFlowViewController.swift new file mode 100644 index 00000000..a65ee0a1 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Web/FinancialConnectionsWebFlowViewController.swift @@ -0,0 +1,320 @@ +// +// FinancialConnectionsWebFlowViewController.swift +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 12/1/21. +// + +import CoreMedia +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +protocol FinancialConnectionsWebFlowViewControllerDelegate: AnyObject { + + func financialConnectionsWebFlow( + viewController: FinancialConnectionsWebFlowViewController, + didFinish result: FinancialConnectionsSheet.Result + ) +} + +final class FinancialConnectionsWebFlowViewController: UIViewController { + + // MARK: - Properties + + weak var delegate: FinancialConnectionsWebFlowViewControllerDelegate? + + private var authSessionManager: AuthenticationSessionManager? + private var fetchSessionError: Error? + + // MARK: - Waiting state view + + private lazy var continueStateView: UIView = { + let view = ContinueStateView(institutionImageUrl: nil) { [weak self] in + guard let self = self else { return } + if let url = self.lastOpenedNativeURL { + self.redirect(to: url) + } else { + self.startAuthenticationSession(manifest: self.manifest) + } + } + return view + }() + + /** + Unfortunately there is a need for this state-full parameter. When we get url callback the app might not be in foreground state. + If we then restart authentication session ASWebAuthenticationSession will fail as you can't start it in a non-foreground state. + We keep the parameters as a state and pass on to resuming the authentication session and clearing this state. + */ + private var unprocessedReturnURLParameters: String? + private var subscribedToURLNotifications = false + private var subscribedToAppActiveNotifications = false + private var lastOpenedNativeURL: URL? + + private let clientSecret: String + private let apiClient: FinancialConnectionsAPIClient + private let sessionFetcher: FinancialConnectionsSessionFetcher + private let manifest: FinancialConnectionsSessionManifest + private let returnURL: String? + + // MARK: - UI + + private lazy var closeItem: UIBarButtonItem = { + let item = UIBarButtonItem( + image: Image.close.makeImage(template: false), + style: .plain, + target: self, + action: #selector(didTapClose) + ) + item.tintColor = .textDisabled + return item + }() + + private let loadingView = LoadingView(frame: .zero) + + // MARK: - Init + + init( + clientSecret: String, + apiClient: FinancialConnectionsAPIClient, + manifest: FinancialConnectionsSessionManifest, + sessionFetcher: FinancialConnectionsSessionFetcher, + returnURL: String? + ) { + self.clientSecret = clientSecret + self.apiClient = apiClient + self.manifest = manifest + self.sessionFetcher = sessionFetcher + self.returnURL = returnURL + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - UIViewController + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .customBackgroundColor + navigationItem.rightBarButtonItem = closeItem + loadingView.tryAgainButton.addTarget(self, action: #selector(didTapTryAgainButton), for: .touchUpInside) + view.addSubview(loadingView) + + continueStateView.isHidden = true + view.addSubview(continueStateView) + view.addAndPinSubviewToSafeArea(continueStateView) + + // start authentication session + loadingView.errorView.isHidden = true + startAuthenticationSession(manifest: manifest) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + loadingView.frame = view.bounds.inset(by: view.safeAreaInsets) + } +} + +// MARK: - Helpers + +extension FinancialConnectionsWebFlowViewController { + + private func notifyDelegate(result: FinancialConnectionsSheet.Result) { + delegate?.financialConnectionsWebFlow(viewController: self, didFinish: result) + delegate = nil // prevent the delegate from being called again + } + + private func startAuthenticationSession( + manifest: FinancialConnectionsSessionManifest, + additionalQueryParameters: String? = nil + ) { + guard authSessionManager == nil else { return } + loadingView.activityIndicatorView.stp_startAnimatingAndShow() + authSessionManager = AuthenticationSessionManager(manifest: manifest, window: view.window) + authSessionManager? + .start(additionalQueryParameters: additionalQueryParameters) + .observe(using: { [weak self] (result) in + guard let self = self else { return } + self.loadingView.activityIndicatorView.stp_stopAnimatingAndHide() + switch result { + case .success(.success): + self.fetchSession() + case .success(.webCancelled): + self.fetchSession(webCancelled: true) + case .success(.nativeCancelled): + self.fetchSession(userDidCancelInNative: true) + case .failure(let error): + self.notifyDelegate(result: .failed(error: error)) + case .success(.redirect(url: let url)): + self.redirect(to: url) + } + self.authSessionManager = nil + }) + } + + private func redirect(to url: URL) { + DispatchQueue.main.async { + self.continueStateView.isHidden = false + self.subscribeToURLAndAppActiveNotifications() + self.lastOpenedNativeURL = url + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + } + + private func fetchSession(userDidCancelInNative: Bool = false, webCancelled: Bool = false) { + loadingView.activityIndicatorView.stp_startAnimatingAndShow() + loadingView.errorView.isHidden = true + sessionFetcher + .fetchSession() + .observe { [weak self] (result) in + guard let self = self else { return } + self.loadingView.activityIndicatorView.stp_stopAnimatingAndHide() + switch result { + case .success(let session): + if userDidCancelInNative { + // Users can cancel the web flow even if they successfully linked + // accounts. As a result, we check whether they linked any + // before returning "cancelled." + if !session.accounts.data.isEmpty || session.paymentAccount != nil + || session.bankAccountToken != nil + { + self.notifyDelegate(result: .completed(session: session)) + } else { + self.notifyDelegate(result: .canceled) + } + } else if webCancelled { + if session.status == .cancelled && session.statusDetails?.cancelled?.reason == .customManualEntry { + self.notifyDelegate(result: .failed(error: FinancialConnectionsCustomManualEntryRequiredError())) + } else { + self.notifyDelegate(result: .canceled) + } + } else { + self.notifyDelegate(result: .completed(session: session)) + } + case .failure(let error): + self.loadingView.errorView.isHidden = false + self.fetchSessionError = error + } + } + } +} + +// MARK: - STPURLCallbackListener + +extension FinancialConnectionsWebFlowViewController: STPURLCallbackListener { + func handleURLCallback(_ url: URL) -> Bool { + DispatchQueue.main.async { + self.unprocessedReturnURLParameters = FinancialConnectionsWebFlowViewController.returnURLParameters( + from: url + ) + self.restartAuthenticationIfNeeded() + } + return true + } +} + +// MARK: - UI Helpers + +private extension FinancialConnectionsWebFlowViewController { + + @objc + private func didTapTryAgainButton() { + fetchSession() + } + + @objc + private func didTapClose() { + manuallyCloseWebFlowViewController() + } + + private func manuallyCloseWebFlowViewController() { + if let fetchSessionError = fetchSessionError { + notifyDelegate(result: .failed(error: fetchSessionError)) + } else { + notifyDelegate(result: .canceled) + } + } +} + +// MARK: - Authentication restart helpers + +private extension FinancialConnectionsWebFlowViewController { + + private func restartAuthenticationIfNeeded() { + dispatchPrecondition(condition: .onQueue(.main)) + + guard UIApplication.shared.applicationState == .active, let parameters = unprocessedReturnURLParameters else { + /** + When we get url callback the app might not be in foreground state. + If we then restart authentication session ASWebAuthenticationSession will fail as you can't start it in a non-foreground state. + */ + return + } + startAuthenticationSession(manifest: manifest, additionalQueryParameters: parameters) + unprocessedReturnURLParameters = nil + lastOpenedNativeURL = nil + continueStateView.isHidden = true + unsubscribeFromNotifications() + } + + private func subscribeToURLAndAppActiveNotifications() { + dispatchPrecondition(condition: .onQueue(.main)) + + subscribeToURLNotifications() + if !subscribedToAppActiveNotifications { + subscribedToAppActiveNotifications = true + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidBecomeActiveNotification), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + } + } + + private func subscribeToURLNotifications() { + dispatchPrecondition(condition: .onQueue(.main)) + + guard let returnURL = returnURL, let url = URL(string: returnURL) else { + return + } + if !subscribedToURLNotifications { + subscribedToURLNotifications = true + STPURLCallbackHandler.shared().register( + self, + for: url + ) + } + } + + @objc func handleDidBecomeActiveNotification() { + DispatchQueue.main.async { + self.restartAuthenticationIfNeeded() + } + } + + private func unsubscribeFromNotifications() { + dispatchPrecondition(condition: .onQueue(.main)) + + NotificationCenter.default.removeObserver( + self, + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + STPURLCallbackHandler.shared().unregisterListener(self) + subscribedToURLNotifications = false + subscribedToAppActiveNotifications = false + } + + private static func returnURLParameters(from incoming: URL) -> String { + let startPollingParam = "&startPolling=true" + guard let fragment = incoming.fragment else { + return startPollingParam + } + return startPollingParam + "&\(fragment)" + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnections/StripeFinancialConnections.h b/StripeFinancialConnections/StripeFinancialConnections/StripeFinancialConnections.h new file mode 100644 index 00000000..ef9874a6 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnections/StripeFinancialConnections.h @@ -0,0 +1,18 @@ +// +// StripeFinancialConnections.h +// StripeFinancialConnections +// +// Created by Vardges Avetisyan on 11/9/21. +// + +#import + +//! Project version number for StripeFinancialConnections. +FOUNDATION_EXPORT double StripeFinancialConnectionsVersionNumber; + +//! Project version string for StripeFinancialConnections. +FOUNDATION_EXPORT const unsigned char StripeFinancialConnectionsVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/APIPollingHelperTests.swift b/StripeFinancialConnections/StripeFinancialConnectionsTests/APIPollingHelperTests.swift new file mode 100644 index 00000000..06da2477 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/APIPollingHelperTests.swift @@ -0,0 +1,342 @@ +// +// APIPollingHelperTests.swift +// StripeFinancialConnectionsTests +// +// Created by Krisjanis Gaidis on 10/2/22. +// + +@testable@_spi(STP) import StripeCore +@testable import StripeFinancialConnections +import XCTest + +final class APIPollingHelperTests: XCTestCase { + + func testPollingSuccessOnFirstTry() throws { + let dataSource = DataSource( + numberOfRetriesUntilServerReturnsSuccess: 0, + maxNumberOfRetriesClientWillTry: 0 + ) + + let expectation = expectation(description: "expect that DataSource 'server' returns a value") + var result: Result? + dataSource.pollAPICall() + .observe { apiCallResult in + result = apiCallResult + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + + switch result { + case .success: + break // we expect success + case .failure: + XCTFail() + case .none: + XCTFail() + } + } + + func testPollingSuccessAfterFiveTries() throws { + let dataSource = DataSource( + numberOfRetriesUntilServerReturnsSuccess: 5, + maxNumberOfRetriesClientWillTry: 5 + ) + + let expectation = expectation(description: "expect that DataSource 'server' returns a value") + var result: Result? + dataSource.pollAPICall() + .observe { apiCallResult in + result = apiCallResult + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + + switch result { + case .success: + break // we expect success + case .failure: + XCTFail() + case .none: + XCTFail() + } + } + + func testClientPollingMoreThanWhatServerNeeds() throws { + let dataSource = DataSource( + numberOfRetriesUntilServerReturnsSuccess: 5, + // client is able to try more than the "5" required times + maxNumberOfRetriesClientWillTry: 10 + ) + + let expectation = expectation(description: "expect that DataSource 'server' returns a value") + var result: Result? + dataSource.pollAPICall() + .observe { apiCallResult in + result = apiCallResult + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + + switch result { + case .success: + break // we expect success + case .failure: + XCTFail() + case .none: + XCTFail() + } + } + + func testClientPollingLessThanWhatServerNeeds() throws { + let dataSource = DataSource( + numberOfRetriesUntilServerReturnsSuccess: 6, + maxNumberOfRetriesClientWillTry: 5 + ) + + let expectation = expectation(description: "expect that DataSource 'server' returns a value") + var result: Result? + dataSource.pollAPICall() + .observe { apiCallResult in + result = apiCallResult + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + + switch result { + case .success: + XCTFail() + case .failure: + break // we expect failure + case .none: + XCTFail() + } + } + + func testPollingDefaults() throws { + let dataSource = DataSource( + numberOfRetriesUntilServerReturnsSuccess: 2, + maxNumberOfRetriesClientWillTry: nil // use default polling + ) + + let expectation = expectation(description: "expect that DataSource 'server' returns a value") + var result: Result? + dataSource.pollAPICall() + .observe { apiCallResult in + result = apiCallResult + expectation.fulfill() + } + wait(for: [expectation], timeout: 10) // add extra time-out to make up for defaults + + switch result { + case .success: + break // we expect success + case .failure: + XCTFail() + case .none: + XCTFail() + } + } + + func testPollingTwice() throws { + let dataSource = DataSource( + numberOfRetriesUntilServerReturnsSuccess: 4, + maxNumberOfRetriesClientWillTry: 2 + ) + + // lets try polling, but it will fail because server needs 4 tries (we only try 2 times) + let firstPollingExpectation = expectation(description: "expect that DataSource 'server' returns a value") + var firstPollResult: Result? + dataSource.pollAPICall() + .observe { apiCallResult in + firstPollResult = apiCallResult + firstPollingExpectation.fulfill() + } + wait(for: [firstPollingExpectation], timeout: 5) + switch firstPollResult { + case .success: + XCTFail() + case .failure: + break // we expect failure + case .none: + XCTFail() + } + + // lets try polling again, and it should now succeed + // + // we expect client to "reset" the poll try count + let secondPollingExpectation = expectation(description: "expect that DataSource 'server' returns a value") + var secondPollResult: Result? + dataSource.pollAPICall() + .observe { apiCallResult in + secondPollResult = apiCallResult + secondPollingExpectation.fulfill() + } + wait(for: [secondPollingExpectation], timeout: 5) + switch secondPollResult { + case .success: + break // we expect to succeed the second time + case .failure: + XCTFail() + case .none: + XCTFail() + } + } + + func testPollingHelperDeallocAfterPollingFinishes() { + let apiCallFinishedExpectation = expectation(description: "") + let apiCall: () -> Future = { + DispatchQueue.main.async { + apiCallFinishedExpectation.fulfill() + } + return Promise(value: TestModel()) + } + + var apiPollingHelper: APIPollingHelper? = APIPollingHelper( + apiCall: apiCall, + pollTimingOptions: APIPollingHelper.PollTimingOptions( + initialPollDelay: 0.3 // delay to prevent api from calling immediately + ) + ) + // after this point `apiPollingHelper` should have a strong reference to itself + apiPollingHelper?.startPollingApiCall() + .observe { _ in } + weak var weakAPIPollingHelper = apiPollingHelper + apiPollingHelper = nil + XCTAssert(weakAPIPollingHelper != nil) + + // wait for the API call to finish + wait(for: [apiCallFinishedExpectation], timeout: 1) + + // the `nil` happens after DispatchQueue.main.async, so wait a little bit + let nilExpectation = expectation(description: "") + DispatchQueue.main.async { + nilExpectation.fulfill() + } + wait(for: [nilExpectation], timeout: 1) + + // at this point the API should have executed and polling helper should have deallocated itself + XCTAssert(weakAPIPollingHelper == nil) + } + + func testInitialDelay() { + var didCallAPI = false + let apiCall: () -> Future = { + didCallAPI = true + return Promise(value: TestModel()) + } + + let apiPollingHelper = APIPollingHelper( + apiCall: apiCall, + pollTimingOptions: APIPollingHelper.PollTimingOptions( + initialPollDelay: 0.5 // delay to prevent api from calling immediately + ) + ) + apiPollingHelper.startPollingApiCall() + .observe { _ in } + + let beforeDelayExpiresExpectation = expectation(description: "") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + beforeDelayExpiresExpectation.fulfill() + } + wait(for: [beforeDelayExpiresExpectation], timeout: 5) + + XCTAssert(!didCallAPI, "API call should be delayed") + + let afterDelayExpiresExpectation = expectation(description: "") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.26) { + afterDelayExpiresExpectation.fulfill() + } + wait(for: [afterDelayExpiresExpectation], timeout: 5) + + XCTAssert(didCallAPI, "API call should have been called already") + } + + func test202ErrorCreation() throws { + let error = Create202Error() + if case .apiError(let stripeAPIError) = error { + XCTAssert(stripeAPIError.statusCode == 202) + } else { + XCTFail() + } + } +} + +private final class DataSource { + + private var numberOfRetriesUntilServerReturnsSuccess: Int + private let maxNumberOfRetriesClientWillTry: Int? + + init( + numberOfRetriesUntilServerReturnsSuccess: Int, + maxNumberOfRetriesClientWillTry: Int? // null means to use default values + ) { + self.numberOfRetriesUntilServerReturnsSuccess = numberOfRetriesUntilServerReturnsSuccess + self.maxNumberOfRetriesClientWillTry = maxNumberOfRetriesClientWillTry + } + + func pollAPICall() -> Future { + let apiCall: () -> Future = { [weak self] in + guard let self = self else { + return Promise( + error: + FinancialConnectionsSheetError + .unknown( + debugDescription: "DataSource deallocated." + ) + ) + } + return self.serverAPICall() + } + + let apiPollingHelper = APIPollingHelper( + apiCall: apiCall, + pollTimingOptions: { + if let maxNumberOfRetriesClientWillTry = maxNumberOfRetriesClientWillTry { + return APIPollingHelper.PollTimingOptions( + initialPollDelay: 0, + maxNumberOfRetries: maxNumberOfRetriesClientWillTry, + retryInterval: 0 + ) + } else { + // use default Values + return APIPollingHelper.PollTimingOptions() + } + }() + ) + return apiPollingHelper.startPollingApiCall() + } + + // this method pretends to be a "server" that will + // send a "retry" `numberOfRetriesUntilServerReturnsSuccess` + // amount of times + private func serverAPICall() -> Future { + if numberOfRetriesUntilServerReturnsSuccess > 0 { + numberOfRetriesUntilServerReturnsSuccess -= 1 + return Promise(error: Create202Error()) + } else { + return Promise(value: TestModel()) + } + } +} + +private struct TestModel: Codable {} + +// "202 response status code indicates that the request has been +// accepted for processing, but the processing has not been completed" +private func Create202Error() -> StripeError { + let errorJson: [String: Any] = [ + "error": [ + "type": "api_error" + ], + ] + let errorJsonData = try! JSONSerialization.data( + withJSONObject: errorJson, + options: [.prettyPrinted] + ) + let decodedErrorResponse: StripeAPIErrorResponse = try! StripeJSONDecoder.decode( + jsonData: errorJsonData + ) + var apiError = decodedErrorResponse.error! + apiError.statusCode = 202 + return StripeError.apiError(apiError) +} diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/AccountFetcherTests.swift b/StripeFinancialConnections/StripeFinancialConnectionsTests/AccountFetcherTests.swift new file mode 100644 index 00000000..d29a34c3 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/AccountFetcherTests.swift @@ -0,0 +1,142 @@ +// +// AccountFetcherTests.swift +// StripeFinancialConnectionsTests +// +// Created by Vardges Avetisyan on 12/30/21. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripeCoreTestUtils +@testable import StripeFinancialConnections +import XCTest + +class PaginatedAPIClient: EmptyFinancialConnectionsAPIClient { + + // MARK: - Init + + init(count: Int, limit: Int) { + self.count = count + self.limit = limit + } + + // MARK: - Properties + + private let count: Int + private let limit: Int + private lazy var accounts: [StripeAPI.FinancialConnectionsAccount] = (0...count - 1).map { + StripeAPI.FinancialConnectionsAccount( + balance: nil, + balanceRefresh: nil, + ownership: nil, + ownershipRefresh: nil, + displayName: "\($0)", + institutionName: "TestBank", + last4: "\($0)", + category: .cash, + created: 1, + id: "\($0)", + livemode: false, + permissions: nil, + status: .active, + subcategory: .checking, + supportedPaymentMethodTypes: [.usBankAccount] + ) + } + + // MARK: - FinancialConnectionsAPIClient + + override func fetchFinancialConnectionsAccounts( + clientSecret: String, + startingAfterAccountId: String? + ) -> Promise { + guard let startingAfterAccountId = startingAfterAccountId, let index = Int(startingAfterAccountId) else { + let list = StripeAPI.FinancialConnectionsSession.AccountList( + data: subarray(start: 0), + hasMore: true + ) + return Promise(value: list) + + } + let subArray = subarray(start: index + 1) + let hasMore = index + limit < accounts.count - 1 + let list = StripeAPI.FinancialConnectionsSession.AccountList( + data: subArray, + hasMore: hasMore + ) + return Promise(value: list) + } + + // MARK: - Helpers + + private func subarray(start: Int) -> [StripeAPI.FinancialConnectionsAccount] { + guard start + limit < accounts.count else { + return [StripeAPI.FinancialConnectionsAccount](accounts[start...]) + } + return [StripeAPI.FinancialConnectionsAccount](accounts[start...start + limit]) + } +} + +class AccountFetcherTests: XCTestCase { + + func testPaginationMax100() { + let fetcher = FinancialConnectionsAccountAPIFetcher( + api: PaginatedAPIClient(count: 120, limit: 1), + clientSecret: "" + ) + fetcher.fetchAccounts(initial: []).observe { result in + switch result { + case .success(let linkedAccounts): + XCTAssertEqual(linkedAccounts.count, 100) + case .failure: + XCTFail() + } + } + } + + func testPaginationUnderLimit() { + let fetcher = FinancialConnectionsAccountAPIFetcher( + api: PaginatedAPIClient(count: 3, limit: 1), + clientSecret: "" + ) + fetcher.fetchAccounts(initial: []).observe { result in + switch result { + case .success(let linkedAccounts): + XCTAssertEqual(linkedAccounts.count, 3) + case .failure: + XCTFail() + } + } + } + + func testPaginationUnderLimitLargePageSize() { + let fetcher = FinancialConnectionsAccountAPIFetcher( + api: PaginatedAPIClient(count: 3, limit: 10), + clientSecret: "" + ) + fetcher.fetchAccounts(initial: []).observe { result in + switch result { + case .success(let linkedAccounts): + let info = linkedAccounts.map { $0.id } + print(info) + XCTAssertEqual(linkedAccounts.count, 3) + case .failure: + XCTFail() + } + } + } + + func testPaginationUnderLimitSmallPageSize() { + let fetcher = FinancialConnectionsAccountAPIFetcher( + api: PaginatedAPIClient(count: 80, limit: 10), + clientSecret: "" + ) + fetcher.fetchAccounts(initial: []).observe { result in + switch result { + case .success(let linkedAccounts): + XCTAssertEqual(linkedAccounts.count, 80) + case .failure: + XCTFail() + } + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/AccountPickerHelpersTests.swift b/StripeFinancialConnections/StripeFinancialConnectionsTests/AccountPickerHelpersTests.swift new file mode 100644 index 00000000..5512c57a --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/AccountPickerHelpersTests.swift @@ -0,0 +1,25 @@ +// +// AccountPickerHelpersTests.swift +// StripeFinancialConnectionsTests +// +// Created by Krisjanis Gaidis on 9/8/22. +// + +@testable import StripeFinancialConnections +import XCTest + +class AccountPickerHelpersTests: XCTestCase { + + func testCurrencyStrings() throws { + XCTAssert(AccountPickerHelpers.currencyString(currency: "usd", balanceAmount: 1000000) == "$10,000.00") + XCTAssert(AccountPickerHelpers.currencyString(currency: "usd", balanceAmount: 1000) == "$10.00") + XCTAssert(AccountPickerHelpers.currencyString(currency: "eur", balanceAmount: 10) == "€0.10") + XCTAssert(AccountPickerHelpers.currencyString(currency: "gbp", balanceAmount: 999) == "£9.99") + XCTAssert(AccountPickerHelpers.currencyString(currency: "jpy", balanceAmount: 543) == "¥543") + XCTAssert(AccountPickerHelpers.currencyString(currency: "krw", balanceAmount: 123456) == "₩123,456") + XCTAssert(AccountPickerHelpers.currencyString(currency: "usd", balanceAmount: 0) == "$0.00") + XCTAssert(AccountPickerHelpers.currencyString(currency: "usd", balanceAmount: -1000) == "-$10.00") + XCTAssert(AccountPickerHelpers.currencyString(currency: "usd", balanceAmount: -1000000) == "-$10,000.00") + + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/AuthFlowHelpersTests.swift b/StripeFinancialConnections/StripeFinancialConnectionsTests/AuthFlowHelpersTests.swift new file mode 100644 index 00000000..12e6cce0 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/AuthFlowHelpersTests.swift @@ -0,0 +1,30 @@ +// +// AuthFlowHelpersTests.swift +// StripeFinancialConnectionsTests +// +// Created by Krisjanis Gaidis on 10/5/22. +// + +@testable import StripeFinancialConnections +import XCTest + +class AuthFlowHelpersTests: XCTestCase { + + func testFormatUrlString() throws { + XCTAssert(AuthFlowHelpers.formatUrlString(nil) == nil) + XCTAssert(AuthFlowHelpers.formatUrlString("") == "") + XCTAssert(AuthFlowHelpers.formatUrlString("www.") == "") + XCTAssert(AuthFlowHelpers.formatUrlString("http://") == "") + XCTAssert(AuthFlowHelpers.formatUrlString("https://") == "") + XCTAssert(AuthFlowHelpers.formatUrlString("/") == "") + XCTAssert(AuthFlowHelpers.formatUrlString("stripe.com") == "stripe.com") + XCTAssert(AuthFlowHelpers.formatUrlString("stripe.com/") == "stripe.com") + XCTAssert(AuthFlowHelpers.formatUrlString("www.stripe.com") == "stripe.com") + XCTAssert(AuthFlowHelpers.formatUrlString("https://stripe.com") == "stripe.com") + XCTAssert(AuthFlowHelpers.formatUrlString("http://stripe.com") == "stripe.com") + XCTAssert(AuthFlowHelpers.formatUrlString("http://www.stripe.com") == "stripe.com") + XCTAssert(AuthFlowHelpers.formatUrlString("https://www.stripe.com") == "stripe.com") + XCTAssert(AuthFlowHelpers.formatUrlString("https://www.stripe.com/") == "stripe.com") + XCTAssert(AuthFlowHelpers.formatUrlString("https://www.wow.stripe.com/") == "wow.stripe.com") + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/EmptyFinancialConnectionsAPIClient.swift b/StripeFinancialConnections/StripeFinancialConnectionsTests/EmptyFinancialConnectionsAPIClient.swift new file mode 100644 index 00000000..33da60b7 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/EmptyFinancialConnectionsAPIClient.swift @@ -0,0 +1,186 @@ +// +// EmptyFinancialConnectionsAPIClient.swift +// StripeFinancialConnectionsTests +// +// Created by Krisjanis Gaidis on 1/20/23. +// + +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeCoreTestUtils +@testable import StripeFinancialConnections + +class EmptyFinancialConnectionsAPIClient: FinancialConnectionsAPIClient { + + func fetchFinancialConnectionsAccounts(clientSecret: String, startingAfterAccountId: String?) -> Promise< + StripeAPI.FinancialConnectionsSession.AccountList + > { + return Promise() + } + + func fetchFinancialConnectionsSession(clientSecret: String) -> Promise { + return Promise() + } + + func synchronize(clientSecret: String, returnURL: String?) -> Promise { + return Promise() + } + + func markConsentAcquired(clientSecret: String) -> Promise { + return Promise() + } + + func fetchFeaturedInstitutions(clientSecret: String) -> Promise { + return Promise() + } + + func fetchInstitutions(clientSecret: String, query: String) -> Future { + return Promise() + } + + func createAuthSession(clientSecret: String, institutionId: String) -> Promise { + return Promise() + } + + func cancelAuthSession(clientSecret: String, authSessionId: String) -> Promise { + return Promise() + } + + func retrieveAuthSession( + clientSecret: String, + authSessionId: String + ) -> Future { + return Promise() + } + + func fetchAuthSessionOAuthResults(clientSecret: String, authSessionId: String) -> Future< + FinancialConnectionsMixedOAuthParams + > { + return Promise() + } + + func authorizeAuthSession(clientSecret: String, authSessionId: String, publicToken: String?) -> Promise< + FinancialConnectionsAuthSession + > { + return Promise() + } + + func fetchAuthSessionAccounts( + clientSecret: String, + authSessionId: String, + initialPollDelay: TimeInterval + ) -> Future { + return Promise() + } + + func selectAuthSessionAccounts(clientSecret: String, authSessionId: String, selectedAccountIds: [String]) + -> Promise + { + return Promise() + } + + func markLinkingMoreAccounts(clientSecret: String) -> Promise { + return Promise() + } + + func completeFinancialConnectionsSession( + clientSecret: String, + terminalError: String? + ) -> Future { + return Promise() + } + + func attachBankAccountToLinkAccountSession( + clientSecret: String, + accountNumber: String, + routingNumber: String + ) -> Future { + return Promise() + } + + func attachLinkedAccountIdToLinkAccountSession( + clientSecret: String, + linkedAccountId: String, + consumerSessionClientSecret: String? + ) -> Future { + return Promise() + } + + func recordAuthSessionEvent( + clientSecret: String, + authSessionId: String, + eventNamespace: String, + eventName: String + ) -> Future { + return Promise() + } + + func saveAccountsToLink( + emailAddress: String?, + phoneNumber: String?, + country: String?, + selectedAccountIds: [String], + consumerSessionClientSecret: String?, + clientSecret: String + ) -> Future { + return Promise() + } + + func disableNetworking( + disabledReason: String?, + clientSecret: String + ) -> Future { + Promise() + } + + func fetchNetworkedAccounts( + clientSecret: String, + consumerSessionClientSecret: String + ) -> StripeCore.Future { + return Promise() + } + + func markLinkVerified( + clientSecret: String + ) -> StripeCore.Future { + return Promise() + } + + func selectNetworkedAccounts( + selectedAccountIds: [String], + clientSecret: String, + consumerSessionClientSecret: String + ) -> StripeCore.Future { + return Promise() + } + + func consumerSessionLookup( + emailAddress: String, + clientSecret: String + ) -> Future { + return Promise() + } + + func consumerSessionStartVerification( + otpType: String, + customEmailType: String?, + connectionsMerchantName: String?, + consumerSessionClientSecret: String + ) -> StripeCore.Future { + return Promise() + } + + func consumerSessionConfirmVerification( + otpCode: String, + otpType: String, + consumerSessionClientSecret: String + ) -> StripeCore.Future { + return Promise() + } + + func markLinkStepUpAuthenticationVerified( + clientSecret: String + ) -> Future { + return Promise() + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/FinancialConnectionsAnalyticsTest.swift b/StripeFinancialConnections/StripeFinancialConnectionsTests/FinancialConnectionsAnalyticsTest.swift new file mode 100644 index 00000000..620a3c02 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/FinancialConnectionsAnalyticsTest.swift @@ -0,0 +1,74 @@ +// +// FinancialConnectionsAnalyticsTest.swift +// StripeFinancialConnectionsTests +// +// Created by Vardges Avetisyan on 12/9/21. +// + +import XCTest + +@_spi(STP) import StripeCore +@testable import StripeFinancialConnections + +final class FinancialConnectionsSheetAnalyticsTest: XCTestCase { + + func testFinancialConnectionsSheetFailedAnalyticEncoding() { + let analytic = FinancialConnectionsSheetFailedAnalytic( + clientSecret: "test", + error: FinancialConnectionsSheetError.unknown(debugDescription: "some description") + ) + XCTAssertNotNil(analytic.error) + + let errorDict = analytic.error.serializeForLogging() + XCTAssertNil(errorDict["user_info"]) + XCTAssertEqual(errorDict["code"] as? Int, 0) + XCTAssertEqual(errorDict["domain"] as? String, "Stripe.FinancialConnectionsSheetError") + } + + func testFinancialConnectionsSheetCompletionAnalyticCompleted() { + let accountList = StripeAPI.FinancialConnectionsSession.AccountList(data: [], hasMore: false) + let session = StripeAPI.FinancialConnectionsSession( + clientSecret: "", + id: "", + accounts: accountList, + livemode: false, + paymentAccount: nil, + bankAccountToken: nil, + status: nil, + statusDetails: nil + ) + let analytic = FinancialConnectionsSheetCompletionAnalytic.make( + clientSecret: "secret", + result: .completed(session: session) + ) + guard let closedAnalytic = analytic as? FinancialConnectionsSheetClosedAnalytic else { + return XCTFail("Expected `FinancialConnectionsSheetClosedAnalytic`") + } + + XCTAssertEqual(closedAnalytic.clientSecret, "secret") + XCTAssertEqual(closedAnalytic.result, "completed") + } + + func testFinancialConnectionsSheetCompletionAnalyticCanceled() { + let analytic = FinancialConnectionsSheetCompletionAnalytic.make(clientSecret: "secret", result: .canceled) + guard let closedAnalytic = analytic as? FinancialConnectionsSheetClosedAnalytic else { + return XCTFail("Expected `FinancialConnectionsSheetClosedAnalytic`") + } + + XCTAssertEqual(closedAnalytic.clientSecret, "secret") + XCTAssertEqual(closedAnalytic.result, "cancelled") + } + + func testFinancialConnectionsSheetCompletionAnalyticFailed() { + let analytic = FinancialConnectionsSheetCompletionAnalytic.make( + clientSecret: "secret", + result: .failed(error: FinancialConnectionsSheetError.unknown(debugDescription: "some description")) + ) + guard let failedAnalytic = analytic as? FinancialConnectionsSheetFailedAnalytic else { + return XCTFail("Expected `FinancialConnectionsSheetFailedAnalytic`") + } + + XCTAssertEqual(failedAnalytic.clientSecret, "secret") + XCTAssert(failedAnalytic.error is FinancialConnectionsSheetError) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/FinancialConnectionsSessionTests.swift b/StripeFinancialConnections/StripeFinancialConnectionsTests/FinancialConnectionsSessionTests.swift new file mode 100644 index 00000000..89f8ee0b --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/FinancialConnectionsSessionTests.swift @@ -0,0 +1,53 @@ +// +// FinancialConnectionsSessionTests.swift +// StripeFinancialConnectionsTests +// +// Created by Vardges Avetisyan on 4/29/22. +// + +import Foundation +import StripeCoreTestUtils +@testable import StripeFinancialConnections +import XCTest + +enum FinancialConnectionsSessionMock: String, MockData { + typealias ResponseType = StripeAPI.FinancialConnectionsSession + var bundle: Bundle { return Bundle(for: ClassForBundle.self) } + + case bothAccountsAndLinkedAccountsPresent = "FinancialConnectionsSession_both_accounts_la" + case onlyAccountsPresent = "FinancialConnectionsSession_only_accounts" + case bothAccountsAndLinkedAccountsMissing = "FinancialConnectionsSession_only_both_missing" + case onlyLinkedAccountsPresent = "FinancialConnectionsSession_only_la" +} + +// Dummy class to determine this bundle +private class ClassForBundle {} + +final class FinancialConnectionsSessionTests: XCTestCase { + + func testBothAccountsAndLinkedAccountsPresentFavorsAccounts() { + guard let session = try? FinancialConnectionsSessionMock.bothAccountsAndLinkedAccountsPresent.make() else { + return XCTFail("Could not load FinancialConnectionsSession") + } + XCTAssertEqual(session.accounts.data.count, 5) + } + + func testOnlyAccountsPresentParsesCorrectly() { + guard let session = try? FinancialConnectionsSessionMock.onlyAccountsPresent.make() else { + return XCTFail("Could not load FinancialConnectionsSession") + } + XCTAssertEqual(session.accounts.data.count, 5) + } + + func testOnlyLinkedAccountsPresentParsesCorrectly() { + guard let session = try? FinancialConnectionsSessionMock.onlyLinkedAccountsPresent.make() else { + return XCTFail("Could not load FinancialConnectionsSession") + } + XCTAssertEqual(session.accounts.data.count, 5) + } + + func testBothAccountsAndLinkedAccountsMissingFailsToParse() { + XCTAssertThrowsError(try FinancialConnectionsSessionMock.bothAccountsAndLinkedAccountsMissing.make()) + } + +} diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/FinancialConnectionsSheetTests.swift b/StripeFinancialConnections/StripeFinancialConnectionsTests/FinancialConnectionsSheetTests.swift new file mode 100644 index 00000000..c0f9dec2 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/FinancialConnectionsSheetTests.swift @@ -0,0 +1,75 @@ +// +// FinancialConnectionsSheetTests.swift +// StripeFinancialConnectionsTests +// +// Created by Vardges Avetisyan on 11/9/21. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripeCoreTestUtils +@testable import StripeFinancialConnections +import XCTest + +class EmptySessionFetcher: FinancialConnectionsSessionFetcher { + func fetchSession() -> Future { + return Promise() + } +} + +class FinancialConnectionsSheetTests: XCTestCase { + private let mockViewController = UIViewController() + private let mockClientSecret = "las_123345" + private let mockAnalyticsClient = MockAnalyticsClient() + + override func setUpWithError() throws { + try super.setUpWithError() + mockAnalyticsClient.reset() + } + + func testAnalytics() { + let sheet = FinancialConnectionsSheet( + financialConnectionsSessionClientSecret: mockClientSecret, + returnURL: nil, + analyticsClient: mockAnalyticsClient + ) + sheet.present(from: mockViewController) { _ in } + + // Verify presented analytic is logged + XCTAssertEqual(mockAnalyticsClient.loggedAnalytics.count, 1) + guard + let presentedAnalytic = mockAnalyticsClient.loggedAnalytics.first + as? FinancialConnectionsSheetPresentedAnalytic + else { + return XCTFail("Expected `FinancialConnectionsSheetPresentedAnalytic`") + } + XCTAssertEqual(presentedAnalytic.clientSecret, mockClientSecret) + + // Mock that financialConnections is completed + let host = HostController( + api: EmptyFinancialConnectionsAPIClient(), + clientSecret: "test", + returnURL: nil, + publishableKey: "test", + stripeAccount: nil + ) + sheet.hostController(host, viewController: UIViewController(), didFinish: .canceled) + + // Verify closed analytic is logged + XCTAssertEqual(mockAnalyticsClient.loggedAnalytics.count, 2) + guard let closedAnalytic = mockAnalyticsClient.loggedAnalytics.last as? FinancialConnectionsSheetClosedAnalytic + else { + return XCTFail("Expected `FinancialConnectionsSheetClosedAnalytic`") + } + XCTAssertEqual(closedAnalytic.clientSecret, mockClientSecret) + XCTAssertEqual(closedAnalytic.result, "cancelled") + } + + func testAnalyticsProductUsage() { + _ = FinancialConnectionsSheet( + financialConnectionsSessionClientSecret: mockClientSecret, + returnURL: nil, + analyticsClient: mockAnalyticsClient + ) + XCTAssertEqual(mockAnalyticsClient.productUsage, ["FinancialConnectionsSheet"]) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/Info.plist b/StripeFinancialConnections/StripeFinancialConnectionsTests/Info.plist new file mode 100644 index 00000000..64d65ca4 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/ManualEntryValidatorTests.swift b/StripeFinancialConnections/StripeFinancialConnectionsTests/ManualEntryValidatorTests.swift new file mode 100644 index 00000000..3d246d27 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/ManualEntryValidatorTests.swift @@ -0,0 +1,76 @@ +// +// ManualEntryValidatorTests.swift +// StripeFinancialConnectionsTests +// +// Created by Krisjanis Gaidis on 8/26/22. +// + +@testable import StripeFinancialConnections +import XCTest + +class ManualEntryValidatorTests: XCTestCase { + + func testValidateRoutingNumber() throws { + XCTAssert(ManualEntryValidator.validateRoutingNumber("") != nil) + XCTAssert(ManualEntryValidator.validateRoutingNumber("1") != nil) + XCTAssert(ManualEntryValidator.validateRoutingNumber("12") != nil) + XCTAssert(ManualEntryValidator.validateRoutingNumber("123") != nil) + XCTAssert(ManualEntryValidator.validateRoutingNumber("1234") != nil) + XCTAssert(ManualEntryValidator.validateRoutingNumber("12345") != nil) + XCTAssert(ManualEntryValidator.validateRoutingNumber("123456") != nil) + XCTAssert(ManualEntryValidator.validateRoutingNumber("1234567") != nil) + XCTAssert(ManualEntryValidator.validateRoutingNumber("12345678") != nil) + XCTAssert(ManualEntryValidator.validateRoutingNumber("123456789") != nil) + XCTAssert(ManualEntryValidator.validateRoutingNumber("123456789") != nil) + XCTAssert(ManualEntryValidator.validateRoutingNumber("1234567890") != nil) + XCTAssert(ManualEntryValidator.validateRoutingNumber("021000021") == nil) + XCTAssert(ManualEntryValidator.validateRoutingNumber("011401533") == nil) + XCTAssert(ManualEntryValidator.validateRoutingNumber("091000019") == nil) + XCTAssert(ManualEntryValidator.validateRoutingNumber("x91000019") != nil) + XCTAssert(ManualEntryValidator.validateRoutingNumber("09100001x") != nil) + XCTAssert(ManualEntryValidator.validateRoutingNumber("0910x0019") != nil) + XCTAssert(ManualEntryValidator.validateRoutingNumber("-21000021") != nil) + XCTAssert(ManualEntryValidator.validateRoutingNumber(":21000021") != nil) + } + + func testValidateAccountingNumber() throws { + XCTAssert(ManualEntryValidator.validateAccountNumber("") != nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("1") == nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("12") == nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("123") == nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("1234") == nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("12345") == nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("123456") == nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("1234567") == nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("12345678") == nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("123456789") == nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("123456789") == nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("0123456789") == nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("00123456789") == nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("000123456789") == nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("0000123456789") == nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("00000123456789") == nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("000000123456789") == nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("0000000123456789") == nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("00000000123456789") == nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("000000000123456789") != nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("x0000000123456789") != nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("-0000000123456789") != nil) + XCTAssert(ManualEntryValidator.validateAccountNumber(":0000000123456789") != nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("0000000012345678x") != nil) + XCTAssert(ManualEntryValidator.validateAccountNumber("0000000x123456789") != nil) + } + + func testValidateAccountNumberConfirmation() throws { + XCTAssert(ManualEntryValidator.validateAccountNumberConfirmation("", accountNumber: "") != nil) + XCTAssert(ManualEntryValidator.validateAccountNumberConfirmation("1", accountNumber: "1") == nil) + XCTAssert( + ManualEntryValidator.validateAccountNumberConfirmation( + "00000000123456789", + accountNumber: "00000000123456789" + ) == nil + ) + XCTAssert(ManualEntryValidator.validateAccountNumberConfirmation("1", accountNumber: "2") != nil) + XCTAssert(ManualEntryValidator.validateAccountNumberConfirmation("2", accountNumber: "1") != nil) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/MarkdownBoldAttributedStringTests.swift b/StripeFinancialConnections/StripeFinancialConnectionsTests/MarkdownBoldAttributedStringTests.swift new file mode 100644 index 00000000..f0ff2738 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/MarkdownBoldAttributedStringTests.swift @@ -0,0 +1,168 @@ +// +// MarkdownBoldAttributedStringTests.swift +// StripeFinancialConnectionsTests +// +// Created by Krisjanis Gaidis on 9/22/22. +// + +@testable import StripeFinancialConnections +import XCTest + +class MarkdownBoldAttributedStringTests: XCTestCase { + + func testEmptyString() { + let attributedString = NSMutableAttributedString(string: "") + attributedString.addBoldFontAttributesByMarkdownRules( + boldFont: FinancialConnectionsFont.label(.smallEmphasized).uiFont + ) + XCTAssert(attributedString == NSMutableAttributedString(string: "")) + } + + func testOneAsterisk() { + let attributedString = NSMutableAttributedString(string: "*") + attributedString.addBoldFontAttributesByMarkdownRules( + boldFont: FinancialConnectionsFont.label(.smallEmphasized).uiFont + ) + XCTAssert(attributedString == NSMutableAttributedString(string: "*")) + } + + func testTwoAsterisk() { + let attributedString = NSMutableAttributedString(string: "**") + attributedString.addBoldFontAttributesByMarkdownRules( + boldFont: FinancialConnectionsFont.label(.smallEmphasized).uiFont + ) + XCTAssert(attributedString == NSMutableAttributedString(string: "**")) + } + + func testThreeAsterisk() { + let attributedString = NSMutableAttributedString(string: "***") + attributedString.addBoldFontAttributesByMarkdownRules( + boldFont: FinancialConnectionsFont.label(.smallEmphasized).uiFont + ) + XCTAssert(attributedString == NSMutableAttributedString(string: "***")) + } + + func testFourAsterisk() { + let attributedString = NSMutableAttributedString(string: "****") + attributedString.addBoldFontAttributesByMarkdownRules( + boldFont: FinancialConnectionsFont.label(.smallEmphasized).uiFont + ) + XCTAssert(attributedString == NSMutableAttributedString(string: "****")) + } + + func testFiveAsterisk() { + let attributedString = NSMutableAttributedString(string: "*****") + attributedString.addBoldFontAttributesByMarkdownRules( + boldFont: FinancialConnectionsFont.label(.smallEmphasized).uiFont + ) + XCTAssert(attributedString == NSMutableAttributedString(string: "*****")) + } + + func testNoAsterisks() { + let attributedString = NSMutableAttributedString(string: "bold string") + attributedString.addBoldFontAttributesByMarkdownRules( + boldFont: FinancialConnectionsFont.label(.smallEmphasized).uiFont + ) + XCTAssert(attributedString == NSMutableAttributedString(string: "bold string")) + } + + func testOneBold() { + let boldFont = FinancialConnectionsFont.label(.smallEmphasized).uiFont + let attributedString = NSMutableAttributedString(string: "**One Bold**") + attributedString.addBoldFontAttributesByMarkdownRules(boldFont: boldFont) + XCTAssert( + attributedString + == { + let expectedAttributedString = NSMutableAttributedString(string: "One Bold") + expectedAttributedString.addAttributes([.font: boldFont], range: NSRange(location: 0, length: 8)) + return expectedAttributedString + }() + ) + } + + // this is a double-check that tests aren't just returning "true" all the time + func testOneBoldNotEquals() { + let boldFont = FinancialConnectionsFont.label(.smallEmphasized).uiFont + let attributedString = NSMutableAttributedString(string: "**One Bold**") + attributedString.addBoldFontAttributesByMarkdownRules(boldFont: boldFont) + XCTAssert(attributedString != NSMutableAttributedString(string: "One Bold")) + } + + func testOneBoldComplex() { + let boldFont = FinancialConnectionsFont.label(.smallEmphasized).uiFont + let attributedString = NSMutableAttributedString(string: "**One - $1.00 Bold**") + attributedString.addBoldFontAttributesByMarkdownRules(boldFont: boldFont) + XCTAssert( + attributedString + == { + let expectedAttributedString = NSMutableAttributedString(string: "One - $1.00 Bold") + expectedAttributedString.addAttributes([.font: boldFont], range: NSRange(location: 0, length: 16)) + return expectedAttributedString + }() + ) + } + + func testOneBoldComplexVersionTwo() { + let boldFont = FinancialConnectionsFont.label(.smallEmphasized).uiFont + let attributedString = NSMutableAttributedString(string: "**One Bold** - $1.00") + attributedString.addBoldFontAttributesByMarkdownRules(boldFont: boldFont) + XCTAssert( + attributedString + == { + let expectedAttributedString = NSMutableAttributedString(string: "One Bold - $1.00") + expectedAttributedString.addAttributes([.font: boldFont], range: NSRange(location: 0, length: 8)) + return expectedAttributedString + }() + ) + } + + func testOneBoldWithURL() { + let boldFont = FinancialConnectionsFont.label(.smallEmphasized).uiFont + let attributedString = NSMutableAttributedString(string: "[**One Bold**](https://www.stripe.com)") + attributedString.addBoldFontAttributesByMarkdownRules(boldFont: boldFont) + XCTAssert( + attributedString + == { + let expectedAttributedString = NSMutableAttributedString( + string: "[One Bold](https://www.stripe.com)" + ) + expectedAttributedString.addAttributes([.font: boldFont], range: NSRange(location: 1, length: 8)) + return expectedAttributedString + }() + ) + } + + func testOneBoldWithExistingAttributes() { + let boldFont = FinancialConnectionsFont.label(.smallEmphasized).uiFont + let url = URL(string: "https://www.stripe.com")! + + let attributedString = NSMutableAttributedString(string: "word **One Bold** word") + attributedString.addAttributes([.link: url], range: NSRange(location: 5, length: 12)) + attributedString.addBoldFontAttributesByMarkdownRules(boldFont: boldFont) + + XCTAssert( + attributedString + == { + let expectedAttributedString = NSMutableAttributedString(string: "word One Bold word") + expectedAttributedString.addAttributes([.link: url], range: NSRange(location: 5, length: 8)) + expectedAttributedString.addAttributes([.font: boldFont], range: NSRange(location: 5, length: 8)) + return expectedAttributedString + }() + ) + } + + func testTwoBold() { + let boldFont = FinancialConnectionsFont.label(.smallEmphasized).uiFont + let attributedString = NSMutableAttributedString(string: "word **One Bold** word **Two Bold** word") + attributedString.addBoldFontAttributesByMarkdownRules(boldFont: boldFont) + XCTAssert( + attributedString + == { + let expectedAttributedString = NSMutableAttributedString(string: "word One Bold word Two Bold word") + expectedAttributedString.addAttributes([.font: boldFont], range: NSRange(location: 5, length: 8)) + expectedAttributedString.addAttributes([.font: boldFont], range: NSRange(location: 19, length: 8)) + return expectedAttributedString + }() + ) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/MockData/FinancialConnectionsSession_both_accounts_la.json b/StripeFinancialConnections/StripeFinancialConnectionsTests/MockData/FinancialConnectionsSession_both_accounts_la.json new file mode 100644 index 00000000..bdbe3b81 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/MockData/FinancialConnectionsSession_both_accounts_la.json @@ -0,0 +1,204 @@ +{ + "id": "fcsess_testststststm", + "object": "link_account_session", + "client_secret": "las_client_secrettest_tests", + "linked_accounts": { + "object": "list", + "data": [ + { + "id": "fca_1asdfe23dsdfs", + "object": "linked_account", + "balance": null, + "balance_refresh": null, + "category": "credit", + "created": 1651248322, + "display_name": "Test Credit Card", + "institution_name": "Bank of Testing", + "last4": "6666", + "livemode": true, + "ownership": null, + "ownership_refresh": null, + "permissions": [ + "payment_method" + ], + "status": "active", + "subcategory": "credit_card", + "supported_payment_method_types": [ + + ] + }, + { + "id": "fca_asdfasdfe", + "object": "linked_account", + "balance": null, + "balance_refresh": null, + "category": "cash", + "created": 1651248322, + "display_name": "Test Tests", + "institution_name": "Bank of Testing", + "last4": "5766", + "livemode": true, + "ownership": null, + "ownership_refresh": null, + "permissions": [ + "payment_method" + ], + "status": "active", + "subcategory": "savings", + "supported_payment_method_types": [ + "us_bank_account" + ] + }, + { + "id": "fca_1sdfwedfsdf", + "object": "linked_account", + "balance": null, + "balance_refresh": null, + "category": "cash", + "created": 1651248322, + "display_name": "Checking Test", + "institution_name": "Bank of Testing", + "last4": "4242", + "livemode": true, + "ownership": null, + "ownership_refresh": null, + "permissions": [ + "payment_method" + ], + "status": "active", + "subcategory": "checking", + "supported_payment_method_types": [ + "us_bank_account", + "link" + ] + } + ], + "has_more": false, + "total_count": 5, + "url": "/v1/linked_accounts" + }, + "accounts": { + "object": "list", + "data": [ + { + "id": "fca_1KtwJsdsdfdsf", + "object": "linked_account", + "balance": null, + "balance_refresh": null, + "category": "cash", + "created": 1651248322, + "display_name": "Bank Savings", + "institution_name": "Bank of Testing", + "last4": "7777", + "livemode": true, + "ownership": null, + "ownership_refresh": null, + "permissions": [ + "payment_method" + ], + "status": "active", + "subcategory": "savings", + "supported_payment_method_types": [ + "us_bank_account" + ] + }, + { + "id": "fca_1KtwJsdfadfsdf", + "object": "linked_account", + "balance": null, + "balance_refresh": null, + "category": "cash", + "created": 1651248322, + "display_name": "Test Bank", + "institution_name": "Bank of Testing", + "last4": "3333", + "livemode": true, + "ownership": null, + "ownership_refresh": null, + "permissions": [ + "payment_method" + ], + "status": "active", + "subcategory": "checking", + "supported_payment_method_types": [ + "us_bank_account", + "link" + ] + }, + { + "id": "fca_1asdfe23dsdfs", + "object": "linked_account", + "balance": null, + "balance_refresh": null, + "category": "credit", + "created": 1651248322, + "display_name": "Test Credit Card", + "institution_name": "Bank of Testing", + "last4": "6666", + "livemode": true, + "ownership": null, + "ownership_refresh": null, + "permissions": [ + "payment_method" + ], + "status": "active", + "subcategory": "credit_card", + "supported_payment_method_types": [ + + ] + }, + { + "id": "fca_asdfasdfe", + "object": "linked_account", + "balance": null, + "balance_refresh": null, + "category": "cash", + "created": 1651248322, + "display_name": "Test Tests", + "institution_name": "Bank of Testing", + "last4": "5766", + "livemode": true, + "ownership": null, + "ownership_refresh": null, + "permissions": [ + "payment_method" + ], + "status": "active", + "subcategory": "savings", + "supported_payment_method_types": [ + "us_bank_account" + ] + }, + { + "id": "fca_1sdfwedfsdf", + "object": "linked_account", + "balance": null, + "balance_refresh": null, + "category": "cash", + "created": 1651248322, + "display_name": "Checking Test", + "institution_name": "Bank of Testing", + "last4": "4242", + "livemode": true, + "ownership": null, + "ownership_refresh": null, + "permissions": [ + "payment_method" + ], + "status": "active", + "subcategory": "checking", + "supported_payment_method_types": [ + "us_bank_account", + "link" + ] + } + ], + "has_more": false, + "total_count": 5, + "url": "/v1/linked_accounts" + }, + "livemode": true, + "permissions": [ + "payment_method" + ] +} diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/MockData/FinancialConnectionsSession_only_accounts.json b/StripeFinancialConnections/StripeFinancialConnectionsTests/MockData/FinancialConnectionsSession_only_accounts.json new file mode 100644 index 00000000..037b9438 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/MockData/FinancialConnectionsSession_only_accounts.json @@ -0,0 +1,129 @@ +{ + "id": "fcsess_testststststm", + "object": "link_account_session", + "client_secret": "las_client_secrettest_tests", + "accounts": { + "object": "list", + "data": [ + { + "id": "fca_1KtwJsdsdfdsf", + "object": "linked_account", + "balance": null, + "balance_refresh": null, + "category": "cash", + "created": 1651248322, + "display_name": "Bank Savings", + "institution_name": "Bank of Testing", + "last4": "7777", + "livemode": true, + "ownership": null, + "ownership_refresh": null, + "permissions": [ + "payment_method" + ], + "status": "active", + "subcategory": "savings", + "supported_payment_method_types": [ + "us_bank_account" + ] + }, + { + "id": "fca_1KtwJsdfadfsdf", + "object": "linked_account", + "balance": null, + "balance_refresh": null, + "category": "cash", + "created": 1651248322, + "display_name": "Test Bank", + "institution_name": "Bank of Testing", + "last4": "3333", + "livemode": true, + "ownership": null, + "ownership_refresh": null, + "permissions": [ + "payment_method" + ], + "status": "active", + "subcategory": "checking", + "supported_payment_method_types": [ + "us_bank_account", + "link" + ] + }, + { + "id": "fca_1asdfe23dsdfs", + "object": "linked_account", + "balance": null, + "balance_refresh": null, + "category": "credit", + "created": 1651248322, + "display_name": "Test Credit Card", + "institution_name": "Bank of Testing", + "last4": "6666", + "livemode": true, + "ownership": null, + "ownership_refresh": null, + "permissions": [ + "payment_method" + ], + "status": "active", + "subcategory": "credit_card", + "supported_payment_method_types": [ + + ] + }, + { + "id": "fca_asdfasdfe", + "object": "linked_account", + "balance": null, + "balance_refresh": null, + "category": "cash", + "created": 1651248322, + "display_name": "Test Tests", + "institution_name": "Bank of Testing", + "last4": "5766", + "livemode": true, + "ownership": null, + "ownership_refresh": null, + "permissions": [ + "payment_method" + ], + "status": "active", + "subcategory": "savings", + "supported_payment_method_types": [ + "us_bank_account" + ] + }, + { + "id": "fca_1sdfwedfsdf", + "object": "linked_account", + "balance": null, + "balance_refresh": null, + "category": "cash", + "created": 1651248322, + "display_name": "Checking Test", + "institution_name": "Bank of Testing", + "last4": "4242", + "livemode": true, + "ownership": null, + "ownership_refresh": null, + "permissions": [ + "payment_method" + ], + "status": "active", + "subcategory": "checking", + "supported_payment_method_types": [ + "us_bank_account", + "link" + ] + } + ], + "has_more": false, + "total_count": 5, + "url": "/v1/linked_accounts" + }, + "livemode": true, + "permissions": [ + "payment_method" + ] +} diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/MockData/FinancialConnectionsSession_only_both_missing.json b/StripeFinancialConnections/StripeFinancialConnectionsTests/MockData/FinancialConnectionsSession_only_both_missing.json new file mode 100644 index 00000000..29d880ac --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/MockData/FinancialConnectionsSession_only_both_missing.json @@ -0,0 +1,9 @@ +{ + "id": "fcsess_testststststm", + "object": "link_account_session", + "client_secret": "las_client_secrettest_tests", + "livemode": true, + "permissions": [ + "payment_method" + ] +} diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/MockData/FinancialConnectionsSession_only_la.json b/StripeFinancialConnections/StripeFinancialConnectionsTests/MockData/FinancialConnectionsSession_only_la.json new file mode 100644 index 00000000..757e27fa --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/MockData/FinancialConnectionsSession_only_la.json @@ -0,0 +1,129 @@ +{ + "id": "fcsess_testststststm", + "object": "link_account_session", + "client_secret": "las_client_secrettest_tests", + "linked_accounts": { + "object": "list", + "data": [ + { + "id": "fca_1KtwJsdsdfdsf", + "object": "linked_account", + "balance": null, + "balance_refresh": null, + "category": "cash", + "created": 1651248322, + "display_name": "Bank Savings", + "institution_name": "Bank of Testing", + "last4": "7777", + "livemode": true, + "ownership": null, + "ownership_refresh": null, + "permissions": [ + "payment_method" + ], + "status": "active", + "subcategory": "savings", + "supported_payment_method_types": [ + "us_bank_account" + ] + }, + { + "id": "fca_1KtwJsdfadfsdf", + "object": "linked_account", + "balance": null, + "balance_refresh": null, + "category": "cash", + "created": 1651248322, + "display_name": "Test Bank", + "institution_name": "Bank of Testing", + "last4": "3333", + "livemode": true, + "ownership": null, + "ownership_refresh": null, + "permissions": [ + "payment_method" + ], + "status": "active", + "subcategory": "checking", + "supported_payment_method_types": [ + "us_bank_account", + "link" + ] + }, + { + "id": "fca_1asdfe23dsdfs", + "object": "linked_account", + "balance": null, + "balance_refresh": null, + "category": "credit", + "created": 1651248322, + "display_name": "Test Credit Card", + "institution_name": "Bank of Testing", + "last4": "6666", + "livemode": true, + "ownership": null, + "ownership_refresh": null, + "permissions": [ + "payment_method" + ], + "status": "active", + "subcategory": "credit_card", + "supported_payment_method_types": [ + + ] + }, + { + "id": "fca_asdfasdfe", + "object": "linked_account", + "balance": null, + "balance_refresh": null, + "category": "cash", + "created": 1651248322, + "display_name": "Test Tests", + "institution_name": "Bank of Testing", + "last4": "5766", + "livemode": true, + "ownership": null, + "ownership_refresh": null, + "permissions": [ + "payment_method" + ], + "status": "active", + "subcategory": "savings", + "supported_payment_method_types": [ + "us_bank_account" + ] + }, + { + "id": "fca_1sdfwedfsdf", + "object": "linked_account", + "balance": null, + "balance_refresh": null, + "category": "cash", + "created": 1651248322, + "display_name": "Checking Test", + "institution_name": "Bank of Testing", + "last4": "4242", + "livemode": true, + "ownership": null, + "ownership_refresh": null, + "permissions": [ + "payment_method" + ], + "status": "active", + "subcategory": "checking", + "supported_payment_method_types": [ + "us_bank_account", + "link" + ] + } + ], + "has_more": false, + "total_count": 5, + "url": "/v1/linked_accounts" + }, + "livemode": true, + "permissions": [ + "payment_method" + ] +} diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/SessionFetcherTests.swift b/StripeFinancialConnections/StripeFinancialConnectionsTests/SessionFetcherTests.swift new file mode 100644 index 00000000..7fcc959e --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/SessionFetcherTests.swift @@ -0,0 +1,108 @@ +// +// SessionFetcherTests.swift +// StripeFinancialConnectionsTests +// +// Created by Vardges Avetisyan on 1/20/22. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripeCoreTestUtils +@testable import StripeFinancialConnections +import XCTest + +class NoMoreAccountSessionAPIClient: EmptyFinancialConnectionsAPIClient { + + // MARK: - Properties + + private let hasMore: Bool + + // MARK: - Init + + init(hasMore: Bool) { + self.hasMore = hasMore + } + + // MARK: - FinancialConnectionsAPIClient + + override func fetchFinancialConnectionsAccounts(clientSecret: String, startingAfterAccountId: String?) -> Promise< + StripeAPI.FinancialConnectionsSession.AccountList + > { + let account = StripeAPI.FinancialConnectionsAccount( + balance: nil, + balanceRefresh: nil, + ownership: nil, + ownershipRefresh: nil, + displayName: nil, + institutionName: "bank", + last4: nil, + category: .credit, + created: 3, + id: "12", + livemode: false, + permissions: nil, + status: .active, + subcategory: .checking, + supportedPaymentMethodTypes: [.usBankAccount] + ) + let fullList = StripeAPI.FinancialConnectionsSession.AccountList(data: [account], hasMore: false) + return Promise(value: fullList) + } + + override func fetchFinancialConnectionsSession(clientSecret: String) -> Promise< + StripeAPI.FinancialConnectionsSession + > { + let fullList = StripeAPI.FinancialConnectionsSession.AccountList(data: [], hasMore: hasMore) + let sessionWithFullAccountList = StripeAPI.FinancialConnectionsSession( + clientSecret: "las", + id: "1234", + accounts: fullList, + livemode: false, + paymentAccount: nil, + bankAccountToken: nil, + status: nil, + statusDetails: nil + ) + return Promise(value: sessionWithFullAccountList) + } +} + +class SessionFetcherTests: XCTestCase { + + func testShouldNotFetchAccountsIfSessionIsExhaustive() { + let api = NoMoreAccountSessionAPIClient(hasMore: false) + let accountFetcher = FinancialConnectionsAccountAPIFetcher(api: api, clientSecret: "las") + let fetcher = FinancialConnectionsSessionAPIFetcher( + api: api, + clientSecret: "las", + accountFetcher: accountFetcher + ) + + fetcher.fetchSession().observe(on: nil) { (result) in + switch result { + case .success(let session): + XCTAssertEqual(session.accounts.data.count, 0) + case .failure: + XCTFail() + } + } + } + + func testShouldFetchMoreAccountsIfSessionHasMore() { + let api = NoMoreAccountSessionAPIClient(hasMore: true) + let accountFetcher = FinancialConnectionsAccountAPIFetcher(api: api, clientSecret: "las") + let fetcher = FinancialConnectionsSessionAPIFetcher( + api: api, + clientSecret: "las", + accountFetcher: accountFetcher + ) + + fetcher.fetchSession().observe(on: nil) { (result) in + switch result { + case .success(let session): + XCTAssertEqual(session.accounts.data.count, 1) + case .failure: + XCTFail() + } + } + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/SoftLinkTests.swift b/StripeFinancialConnections/StripeFinancialConnectionsTests/SoftLinkTests.swift new file mode 100644 index 00000000..eada4da8 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/SoftLinkTests.swift @@ -0,0 +1,19 @@ +// +// SoftLinkTests.swift +// StripeFinancialConnectionsTests +// +// Created by Vardges Avetisyan on 3/4/22. +// + +import Foundation +@_spi(STP) import StripeCore +import XCTest + +class SoftLinkTest: XCTestCase { + func testLoadingImplementationClass() { + let klass = + NSClassFromString("StripeFinancialConnections.FinancialConnectionsSDKImplementation") + as? FinancialConnectionsSDKInterface.Type + XCTAssertNotNil(klass) + } +} diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/StringExtensionsTests.swift b/StripeFinancialConnections/StripeFinancialConnectionsTests/StringExtensionsTests.swift new file mode 100644 index 00000000..2bbd0295 --- /dev/null +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/StringExtensionsTests.swift @@ -0,0 +1,49 @@ +// +// StringExtensionsTests.swift +// StripeFinancialConnectionsTests +// +// Created by Krisjanis Gaidis on 7/14/22. +// + +@testable import StripeFinancialConnections +import XCTest + +class StringExtensionsTests: XCTestCase { + + func testExtractingLinksFromString() throws { + XCTAssert("Not Equal Test".extractLinks() != ("Wrong Word", [])) + XCTAssert("No Link".extractLinks() == ("No Link", [])) + XCTAssert( + "[One Link](https://www.stripe.com/terms)".extractLinks() + == ( + "One Link", + [String.Link(range: NSRange(location: 0, length: 8), urlString: "https://www.stripe.com/terms")] + ) + ) + XCTAssert( + "[Complex Link](https://stripe.com/docs/api/financial_connections/ownership/object#financial_connections_ownership_object-id)" + .extractLinks() + == ( + "Complex Link", + [ + String.Link( + range: NSRange(location: 0, length: 12), + urlString: + "https://stripe.com/docs/api/financial_connections/ownership/object#financial_connections_ownership_object-id" + ), + ] + ) + ) + XCTAssert( + "Word [Link 1](https://www.stripe.com/link1) word [Link 2](https://www.stripe.com/link2) word" + .extractLinks() + == ( + "Word Link 1 word Link 2 word", + [ + String.Link(range: NSRange(location: 5, length: 6), urlString: "https://www.stripe.com/link1"), + String.Link(range: NSRange(location: 17, length: 6), urlString: "https://www.stripe.com/link2"), + ] + ) + ) + } +} diff --git a/StripeIdentity/Project.swift b/StripeIdentity/Project.swift new file mode 100644 index 00000000..3b9c035d --- /dev/null +++ b/StripeIdentity/Project.swift @@ -0,0 +1,20 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.stripeFramework( + name: "StripeIdentity", + resources: "StripeIdentity/Resources/**", + dependencies: [ + .project(target: "StripeCore", path: "//StripeCore"), + .project(target: "StripeUICore", path: "//StripeUICore"), + .project(target: "StripeCameraCore", path: "//StripeCameraCore"), + ], + unitTestOptions: .testOptions( + resources: "StripeIdentityTests/Mock Files/**", + dependencies: [ + .project(target: "StripeCoreTestUtils", path: "//StripeCore"), + .project(target: "StripeCameraCoreTestUtils", path: "//StripeCameraCore"), + ], + includesSnapshots: true + ) +) diff --git a/StripeIdentity/README.md b/StripeIdentity/README.md new file mode 100644 index 00000000..e78b2300 --- /dev/null +++ b/StripeIdentity/README.md @@ -0,0 +1,58 @@ +# Stripe Identity iOS SDK + +The Stripe Identity iOS SDK makes it quick and easy to verify your user's identity in your iOS app. We provide a prebuilt UI to collect your user's ID documents, match photo ID with selfies, and validate ID numbers. + +> To get access to the Identity iOS SDK, visit the [Identity Settings](https://dashboard.stripe.com/settings/identity) page and click **Enable**. + +## Table of contents + + +* [Features](#features) +* [Requirements](#requirements) +* [Getting started](#getting-started) + * [Integration](#integration) + * [Example](#example) +* [Manual linking](#manual-linking) + + + +## Features + +**Simplified security**: We've made it simple for you to securely collect your user's personally identifiable information (PII) such as identity document images. Sensitive PII data is sent directly to Stripe Identity instead of passing through your server. For more information, see our [integration security guide](https://stripe.com/docs/security). + +**Automatic document capture**: We automatically capture images of the front and back of government-issued photo ID to ensure a clear and readable image. + +**Selfie matching**: We capture photos of your user's face and review it to confirm that the photo ID belongs to them. For more information, see our guide on [adding selfie checks](https://stripe.com/docs/identity/selfie). + +**Identity information collection**: We collect name, date of birth, and government ID number to validate that it is real. + +**Prebuilt UI**: We provide [`IdentityVerificationSheet`](https://stripe.dev/stripe-ios/stripe-identity/Classes/IdentityVerificationSheet.html), a prebuilt UI that combines all the steps required to collect ID documents, selfies, and ID numbers into a single sheet that displays on top of your app. + +**Automated verification**: Stripe Identity's automated verification technology looks for patterns to help determine if an ID document is real or fake and uses distinctive physiological characteristics of faces to match your users' selfies to photos on their ID document. Collected identity information is checked against a global set of databases to confirm that it exists. Learn more about the [verification checks supported by Stripe Identity](https://stripe.com/docs/identity/verification-checks), [accessing verification results](https://stripe.com/docs/identity/access-verification-results), or our integration guide on [handling verification outcomes](https://stripe.com/docs/identity/handle-verification-outcomes). + +## Requirements + +The Stripe Identity iOS SDK is compatible with apps targeting iOS 13.0 or above. + +If you intend to use this SDK with Stripe's Identity service, you must not modify this SDK. Using a modified version of this SDK with Stripe's Identity service, without Stripe's written authorization, is a breach of your agreement with Stripe and may result in your Stripe account being shut down. + + +## Getting started + +### Integration + +Get started with Stripe Identity's [📚 iOS integration guide](https://stripe.com/docs/identity/verify-identity-documents?platform=ios) and [example project](../Example/IdentityVerification%20Example), or [📘 browse the SDK reference](https://stripe.dev/stripe-ios/stripe-identity/index.html) for fine-grained documentation of all the classes and methods in the SDK. + +> Identity SDK uses camera to scan documents and selfies, you'll need to set `NSCameraUsageDescription` in your application's plist, and provide a reason for accessing the camera (e.g. "This app uses the camera to take a picture of your identity documents."). + +### Example + +[Identity Verification Example](../Example/IdentityVerification%20Example) – This example demonstrates how to capture your users' ID documents on iOS and securely send them to Stripe Identity for identity verification. + +## Manual linking + +If you link the Stripe Identity library manually, use a version from our [releases](https://github.com/stripe/stripe-ios/releases) page and make sure to embed all of the following frameworks: +- `StripeIdentity.xcframework` +- `StripeCore.xcframework` +- `StripeCameraCore.xcframework` +- `StripeUICore.xcframework` diff --git a/StripeIdentity/StripeIdentity.xcodeproj/project.pbxproj b/StripeIdentity/StripeIdentity.xcodeproj/project.pbxproj new file mode 100644 index 00000000..4ed80137 --- /dev/null +++ b/StripeIdentity/StripeIdentity.xcodeproj/project.pbxproj @@ -0,0 +1,1850 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + 047B7B3A70037FA1172A164C /* HeaderViewSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94838BCA1111F735A8FBC072 /* HeaderViewSnapshotTest.swift */; }; + 063C2C263111DA52ECC92DC0 /* STPLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = E94605BCADE912561ED6F710 /* STPLocalizedString.swift */; }; + 0825B219E9B618265CDF5E89 /* VerificationPageStaticContentTextPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB06216204F856766C67E18 /* VerificationPageStaticContentTextPage.swift */; }; + 0A0B05F751A02CF3AB9E24DF /* ContentCenteringScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB10B7416AB6D7F94A57A8E3 /* ContentCenteringScrollView.swift */; }; + 0B431C601D24D6135162E8C9 /* CompleteOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDD22C65A41EB87DD9C96DF /* CompleteOptionView.swift */; }; + 0BA47205B2A7EE905006AFDF /* HeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48F9BFEB0E03E881C2D6017 /* HeaderView.swift */; }; + 0C3C571F4C5E8B00ED18A004 /* IndividualViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9145699AC820A2F2F916F142 /* IndividualViewControllerTest.swift */; }; + 0CC741018E3A07A925A4CCC3 /* FaceScanner+API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 725E481300FDDDD41EBAA835 /* FaceScanner+API.swift */; }; + 0F76E6755BCFB254876779E6 /* icon_warning2@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = DF85EEB9A816B13A485F7382 /* icon_warning2@3x.png */; }; + 0FBE6D53EBE7C2B93DEA34B4 /* IdentityFlowNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050900C738C01F6D96F7ED5B /* IdentityFlowNavigationController.swift */; }; + 10B606812179D64E67BFFFCF /* StripeCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4D0F4646D7565055396DD8F5 /* StripeCore.framework */; }; + 12542E187994CC0683E2E3D8 /* DocumentUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C334F59C93D3D11534FACBBA /* DocumentUploader.swift */; }; + 135B58FCD4663D3D8E4C82F7 /* VerificationPage_200.json in Resources */ = {isa = PBXBuildFile; fileRef = 3218882B7E1610E17BDB4522 /* VerificationPage_200.json */; }; + 19AADF41AB0794258D06264B /* icon_checkmark_92@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F696493D59EE069EB76B0CB4 /* icon_checkmark_92@3x.png */; }; + 1A85FB72FDFB361FE71C81C8 /* VerificationPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69753FF8ED7304500D4EFFB9 /* VerificationPage.swift */; }; + 1C9FE58DA3777F5B54F3AEC1 /* icon_checkmark@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 0CD0A4F2BB147D41D2DF6976 /* icon_checkmark@3x.png */; }; + 1D4D9CDF6B1D335963849940 /* VerificationSheetAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = E70585F354B2B8B1420311CB /* VerificationSheetAnalytics.swift */; }; + 1DB3040542D8274848932380 /* TimeInterval+StripeIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5237659945E4A05ADE6DB0 /* TimeInterval+StripeIdentity.swift */; }; + 1E48ABBE603C4DB065DD2093 /* CGImage+StripeIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 744CE13E8488F0E4AC951D6C /* CGImage+StripeIdentity.swift */; }; + 1ECF04022C1481B12FC86D05 /* CameraPreviewContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E204A65850D7EC60AB779B5 /* CameraPreviewContainerView.swift */; }; + 21F8B404DF172ED5CE4BB526 /* DebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF3CE83043E6DE26CE8F9C0E /* DebugViewController.swift */; }; + 2225D341AE96C5168C350C72 /* IndividualViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B71F1C5085AD91100943CFCA /* IndividualViewController.swift */; }; + 22C7DF527FAF0642A0850DEA /* DocumentFileUploadViewController+Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14534C235EF7B57DDB6B74F1 /* DocumentFileUploadViewController+Strings.swift */; }; + 230C42264D444023F416A7A1 /* IdentityAPIClientTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59680E002FC6E7BC9906BD2 /* IdentityAPIClientTest.swift */; }; + 24E6928BEA4044DB779D4189 /* VNBarcodeSymbology+StripeIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C78174C0059A8957832825 /* VNBarcodeSymbology+StripeIdentity.swift */; }; + 2522A0773650573501106F4D /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D40A569C2BB62F86D57C01 /* Image.swift */; }; + 25A7B4E2E5CB3FD549F6819B /* InstructionListViewSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8389CF3DBB12A384BCF0959F /* InstructionListViewSnapshotTest.swift */; }; + 286BF86B773F41210E015B2B /* IdentityDataCollecting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9CB453179082B1AE6B242FD /* IdentityDataCollecting.swift */; }; + 28CAAFE7E31A8D17233615F3 /* VerificationSheetFlowControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A69C1BC7C63512FE24B32433 /* VerificationSheetFlowControllerTest.swift */; }; + 28D0DEBE960D92E18CEB4D71 /* Enums+CustomStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = E26F8082A859AC1F5C5A3D13 /* Enums+CustomStringConvertible.swift */; }; + 2990114AB204BB607DA965A5 /* icon_warning@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1002F1EA113FC18896C55CDB /* icon_warning@3x.png */; }; + 29E974CDD5827CF390349EA4 /* FaceScannerOutput.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3E90C7076A10469E4893B73 /* FaceScannerOutput.swift */; }; + 2B7C095E1029C163D107CF68 /* ListViewSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D82EAF85863CAAA250E393 /* ListViewSnapshotTest.swift */; }; + 2C8C7EF7F7E594FD8E4171E7 /* VerificationPageStaticContentDocumentCapturePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6702A1AB7FBCD89A356A86FE /* VerificationPageStaticContentDocumentCapturePage.swift */; }; + 2E943A5B252D7927CE3C32E3 /* IdentityVerificationSheetError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DD5FEA0E4E7EF1A4BA9646B /* IdentityVerificationSheetError.swift */; }; + 2F13AF5BC25FF74743D55503 /* DocumentTypeSelectViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28361A39FEF902EF979BEEE /* DocumentTypeSelectViewControllerTest.swift */; }; + 2F330EA97B99545A50328BDD /* HTMLViewWithIconLabels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36ED32D7EE8F52D6FC7D8826 /* HTMLViewWithIconLabels.swift */; }; + 320565753A6D528A24336934 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 198B92E9E0D04CA24F208E07 /* ErrorView.swift */; }; + 33F0FF8DB7D784C95647B693 /* FaceCaptureData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4943A54EF548F28DB66DD1F /* FaceCaptureData.swift */; }; + 349A4B5CC24DF5539B773A84 /* MLDetectorConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18593C820FE0E35DDEE36956 /* MLDetectorConfiguration.swift */; }; + 34ABF501E050B043CE1E859C /* IdentityImageUploaderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08E49ED4B7010002D5FC734 /* IdentityImageUploaderTest.swift */; }; + 34C09421E30F9BA262AB1532 /* CGImage+StripeIdentityUnitTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DEBC1A25D2C446219E7E733 /* CGImage+StripeIdentityUnitTest.swift */; }; + 36F0967E814234FE61EF3A0B /* VerificationPageDataDob.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E9FA49B13AC0A8826F8257 /* VerificationPageDataDob.swift */; }; + 375C63BC59BFC5F8615E266F /* VerificationPageData_no_errors.json in Resources */ = {isa = PBXBuildFile; fileRef = 3AA3F3B346FFC8E58BF8739A /* VerificationPageData_no_errors.json */; }; + 37BCABAD8160BF4D14FA6449 /* ShadowConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AB451EC826E856E6BCB7070 /* ShadowConfiguration.swift */; }; + 3B3012E73D8B4820362F25BD /* DocumentTypeSelectViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9910B88643D95B2F9329E4A2 /* DocumentTypeSelectViewController.swift */; }; + 3B6DF83509C19AD199C1E1CE /* VerificationPageStaticContentCountryNotListedPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5750A42C108C4FBA7F735B4 /* VerificationPageStaticContentCountryNotListedPage.swift */; }; + 3B9DB565B6C06BC058BF62D6 /* IndividualViewControllerSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E703FD28F5352DFC4115F1C /* IndividualViewControllerSnapshotTest.swift */; }; + 3C228BF28D1D56F1E74AE7AB /* VerificationPageData_submitted.json in Resources */ = {isa = PBXBuildFile; fileRef = 99607F36AF24C978953964E9 /* VerificationPageData_submitted.json */; }; + 3C553B8ADA8136498FF8E011 /* VerificationPageStaticContentDocumentSelectPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB9BD2014C4A16821A2B8DC1 /* VerificationPageStaticContentDocumentSelectPage.swift */; }; + 3CB793BE23FC9716A0F35CCB /* IdentityElementsFactoryTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF56E95E47DA51C3517E1EC /* IdentityElementsFactoryTest.swift */; }; + 3D9D1BA3B4C7E2464D457FF7 /* VerificationPageDataRequirementError.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E15397783A643B74921A00 /* VerificationPageDataRequirementError.swift */; }; + 3E64C28D0E13D6F9F455C3F2 /* SuccessViewControllerSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FF19274A29802CDB696D1DF /* SuccessViewControllerSnapshotTest.swift */; }; + 3F9FB3BC02DDC7BCF4207F73 /* MotionBlurDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57F22CC7DFA4B98A1A533CE0 /* MotionBlurDetector.swift */; }; + 40609779D5BD7D6AE61FE01D /* PhoneOtpViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12F4C291ACBB73ED95504131 /* PhoneOtpViewControllerTest.swift */; }; + 41B1C1B08CA7DB8C18A7C9C4 /* VerificationPage_type_address.json in Resources */ = {isa = PBXBuildFile; fileRef = EA7EF0C427E327A1E945BC28 /* VerificationPage_type_address.json */; }; + 443F1D1D9FE899158D58461E /* cgimage_stripeidentity_test.png in Resources */ = {isa = PBXBuildFile; fileRef = 6F772F905FB81552FA1728B2 /* cgimage_stripeidentity_test.png */; }; + 459102C261053F38D3CBF25F /* CountryNotListedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66AA83F1165CFCCFDAF5D850 /* CountryNotListedViewController.swift */; }; + 45B515AC217F10059EE155E2 /* iOSSnapshotTestCase in Frameworks */ = {isa = PBXBuildFile; productRef = 960247DC321CBDB422FE713D /* iOSSnapshotTestCase */; }; + 46A6C4DCF553DF24F3674002 /* VerificationSheetController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5082A4100CEE0C8378AD185 /* VerificationSheetController.swift */; }; + 471F567D8201BBA6D82D9932 /* front_drivers_license.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 280A8D95B0045A940A917B1A /* front_drivers_license.jpg */; }; + 47E285A86960CDE275E5EA53 /* VerificationPageDataFace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14244B002D9F37B56BE8D4F4 /* VerificationPageDataFace.swift */; }; + 49FE192DE8D8DA9342A5AE60 /* IdentityTextButtonElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E1EF4A7AA64856F9C887C2A /* IdentityTextButtonElement.swift */; }; + 4A5FEB151B079C41FD40FED5 /* StripeCameraCoreTestUtils.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 14C231755842F322A25160ED /* StripeCameraCoreTestUtils.framework */; }; + 4A6D49DED2555FBDACF13B3F /* VerificationFlowWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658F7A165D731F5046ACDF29 /* VerificationFlowWebView.swift */; }; + 4A824CD6A0A518F616D428CF /* FaceScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D2F49DD0BAC5FEF308BA165 /* FaceScanner.swift */; }; + 4B46A1DC7BAE3ED249C4EF05 /* VerificationPageDataName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110A0200099F462B576F2ED4 /* VerificationPageDataName.swift */; }; + 4C9D354F6C1E9E89B13D0367 /* AnimatedBorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EA099682E4BE0A34CFCBE6C /* AnimatedBorderView.swift */; }; + 4D4430064CB7F2CE3AD3266A /* VerificationFlowWebViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EB7348CE21DB1A86706532 /* VerificationFlowWebViewControllerTest.swift */; }; + 4E39FB668EC68320579F23BC /* IDDetectorOutput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CDF024DA4F519CB21A717AA /* IDDetectorOutput.swift */; }; + 4E44D8C9967E301DB34EB6F0 /* VerificationPageDataDocumentFileData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 083865663173CDD9F77417A4 /* VerificationPageDataDocumentFileData.swift */; }; + 4E6AF5DD963FAB8B6E06F482 /* ImageScanningSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F99DB710C60AC2A98449D51 /* ImageScanningSession.swift */; }; + 4F20C4AF111778413B77DA22 /* DocumentScannerOutput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E1204F31DC9F8FEA0D312A /* DocumentScannerOutput.swift */; }; + 4FAB896404856F5AED99B89B /* DocumentScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C74D7B9CB1FF34AE667C65B3 /* DocumentScanner.swift */; }; + 50B7C62A3845596BB256BC3E /* VerificationPageDataPhone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9429D10FEFFFEFEE08DBA4F8 /* VerificationPageDataPhone.swift */; }; + 51076AC7B641980402566529 /* DebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E9AACF71C326D42567F09F /* DebugView.swift */; }; + 51DCAEAEEA0B32038A989A51 /* MLModelLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 748D38C4370E78DBC9E20E95 /* MLModelLoader.swift */; }; + 541CA755717969560BF2C694 /* FaceScannerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29E0AD187733319776268351 /* FaceScannerConfiguration.swift */; }; + 548C24D5A0F63E60BC83676F /* DocumentUploaderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE691D40B29AB0F31443479A /* DocumentUploaderMock.swift */; }; + 54909D14587287B674027374 /* CGImage_StripeIdentitySnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1096AFFD3877D9A22703E6CA /* CGImage_StripeIdentitySnapshotTest.swift */; }; + 54C882D8194AC39EB58BCDE7 /* DocumentCaptureViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA7F9B0204BF18C483A474A /* DocumentCaptureViewControllerTest.swift */; }; + 56299123BD0C28891DD7EFEC /* IdentityTopLevelDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 467BA77070B91055F1F2DCD4 /* IdentityTopLevelDestination.swift */; }; + 5642050BB7FEB20E05B41DCD /* IndividualWelcomeViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4952611570326FFC52F0F30 /* IndividualWelcomeViewControllerTest.swift */; }; + 56583B45EA53659E843E35F6 /* VerificationPageData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3B539D323CF2AACB580596B /* VerificationPageData.swift */; }; + 56E78CD83827B277B998188E /* icon_warning_92@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 9DBB998F589E062218E0BD90 /* icon_warning_92@3x.png */; }; + 590772C305CDA9FE9157F92C /* TruncatedDecimalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6918DC6A9681E769D44BC78 /* TruncatedDecimalTest.swift */; }; + 5B4F08774CC9CA1D93216D0D /* VerificationFlowWebViewTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E91362066B0F066C00CB45F2 /* VerificationFlowWebViewTest.swift */; }; + 5BC21FBB6B4A1B1C8EC04270 /* VerificationPageCollectedData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F4F55F9FBE67FCFE3767816 /* VerificationPageCollectedData.swift */; }; + 5DD28B1BE8DF2CC04E9190E5 /* MLDetectorMetricsTrackerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 786574907AC9065E6E658D9C /* MLDetectorMetricsTrackerMock.swift */; }; + 5E00E8AC4088548403B3E630 /* VerificationFlowWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48A449D0BE66D0A555183AF4 /* VerificationFlowWebViewController.swift */; }; + 5F569781EFCC445502DA574F /* InstructionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 423EA472FB8902C1ED6EBA60 /* InstructionListView.swift */; }; + 60B3D5B1B19D31D69D5049A0 /* SelfieUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BB8E3DF263505065E8545AB /* SelfieUploader.swift */; }; + 6174B8E57AEB44661C5B08E3 /* ErrorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A9F73BBC3ECDBAE226012A5 /* ErrorViewController.swift */; }; + 6195A200621E125B15A80285 /* VerificationPage_200_testMode.json in Resources */ = {isa = PBXBuildFile; fileRef = 16ADAC122F0818BB64C5EF41 /* VerificationPage_200_testMode.json */; }; + 62BE5C157A699B1041FFBE6B /* VerificationPageStaticContentIndividualPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4375E0DF02F4D0F09860AFE0 /* VerificationPageStaticContentIndividualPage.swift */; }; + 66BD1F1CB366B61D1ECD0E84 /* IdentityAnalyticsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC019FA1D6D8AEB598D9EC5 /* IdentityAnalyticsClient.swift */; }; + 683FA0F250620BE62EFA2807 /* ListItemViewSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65B0A0F124B3E0C5490D2B6 /* ListItemViewSnapshotTest.swift */; }; + 68BB1FEB385D943EC3CC926B /* IconLabelHTMLView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93A9C204870754D960586E10 /* IconLabelHTMLView.swift */; }; + 692A9E2ADC11AB3D26A92780 /* IndividualFormElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0CF1B086B99DEEF4F4F2A1D /* IndividualFormElement.swift */; }; + 695E12945F47ED3060B8AE19 /* StripeCameraCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CB639F497EF62CAE81822820 /* StripeCameraCore.framework */; }; + 6A06F81B7D6B68EA72A293F5 /* StripeIdentity.h in Headers */ = {isa = PBXBuildFile; fileRef = 9C69B4A6F36A8BD814CB05D9 /* StripeIdentity.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 6A1B2E52B09C2D436CF7794D /* IdentityVerificationSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F1B93DB4F1708B41125301 /* IdentityVerificationSheet.swift */; }; + 6B8DDA62E6ABFBC770A8AC86 /* ImageScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149453E1854C9566CC8D8484 /* ImageScanner.swift */; }; + 6BD9AE7267A9A43C740D7416 /* SelfieCaptureViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67BFE71088C74C03B05CBCD2 /* SelfieCaptureViewController.swift */; }; + 6C1E79C83069CDD64BF03563 /* back_drivers_license.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 9048C4B9F47CE82127D6C1B3 /* back_drivers_license.jpg */; }; + 6DC8C4AFA61DFDA20278114F /* VerificationPageData_submitted_not_closed.json in Resources */ = {isa = PBXBuildFile; fileRef = B38AE7F15E61D960EFA15659 /* VerificationPageData_submitted_not_closed.json */; }; + 6F896A9A689A7524F2AE823C /* SelfieWarmupViewSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21DB72C7F61A4A042D898236 /* SelfieWarmupViewSnapshotTest.swift */; }; + 709DB807A26A17B4571EA9D8 /* VerificationPage_type_doc_require_address.json in Resources */ = {isa = PBXBuildFile; fileRef = 2FC99DD88994E0DB581E19C6 /* VerificationPage_type_doc_require_address.json */; }; + 72461D38E6D468AAEC5BBFF6 /* DocumentFileUploadViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB927B052ADBE97524CC0F43 /* DocumentFileUploadViewController.swift */; }; + 7287EFE41CE74C92BCF86218 /* icon_info@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 188E601955C27EA3E98012AF /* icon_info@3x.png */; }; + 74701BC18362926FDED42E5A /* VerificationFlowResult+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52FB883B057ED79F2C58BC58 /* VerificationFlowResult+Equatable.swift */; }; + 752E548B7E39B3CD8FC2F76A /* VerificationSheetFlowControllerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDAA5C16C934746013C6CE6 /* VerificationSheetFlowControllerError.swift */; }; + 75AC5A117C885FBD0746291E /* ShadowedCorneredImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE238D8B6DAA92980E1F813A /* ShadowedCorneredImageView.swift */; }; + 760BDB66D60C8CCC7B72577C /* VerificationPage_type_idNumber.json in Resources */ = {isa = PBXBuildFile; fileRef = FCD3770DF9B6356D1A82870A /* VerificationPage_type_idNumber.json */; }; + 76890B79CA3C83BBD7362065 /* VerifyWebURLHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75A138199A6CC2DB31DD2D5 /* VerifyWebURLHelper.swift */; }; + 7797883309701F3BB36B1F20 /* VerificationPage_require_live_capture.json in Resources */ = {isa = PBXBuildFile; fileRef = 7FD2B7B0EDA3144115F080BC /* VerificationPage_require_live_capture.json */; }; + 78BC6F625665CE6EAB33EA62 /* ImageScannerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0B804DA867C37809656BB3 /* ImageScannerMock.swift */; }; + 79BA026188FE0DB22ECE1BB0 /* VisionBasedDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E170B07B4958901EE3864A4 /* VisionBasedDetector.swift */; }; + 7C261F9C94175AD5345D384A /* IdentityHTMLViewSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2AF434F2405C5F38F259112 /* IdentityHTMLViewSnapshotTest.swift */; }; + 7CA57E67AC14DA90AE87743B /* ListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BED2C3A9B548341D73D3CC /* ListItemView.swift */; }; + 7D4F147D0CEC17856D070EC2 /* SelfieWarmupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4023102F8B76C16522BC02B9 /* SelfieWarmupView.swift */; }; + 7F18785CB587E7F36288BF02 /* VerificationPage_200_no_consent_header.json in Resources */ = {isa = PBXBuildFile; fileRef = 7ECA816022703F05AD759897 /* VerificationPage_200_no_consent_header.json */; }; + 7F55104D44AE746B5E686780 /* HTMLTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 520D8A1D20ACE21966E806D9 /* HTMLTextView.swift */; }; + 812B45A86DD6B28A06A8FAC7 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 989A2C5379C60B9F0E0E2711 /* Localizable.strings */; }; + 81D31513A44A5B3A8830FC7C /* DebugViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEB322F07A4F2BE5B1FED0AD /* DebugViewControllerTest.swift */; }; + 867C0D3782143FC44501485C /* UINavigationController+StripeIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0871DEB2B2BA56EFADEEA6 /* UINavigationController+StripeIdentity.swift */; }; + 869F5D2A387FED3C1D1674BA /* PhoneOtpViewControllerSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DAFC23BB4CDD7DEB3286704 /* PhoneOtpViewControllerSnapshotTest.swift */; }; + 86B2EFF1DEE73738EB454F4D /* DocumentCaptureViewController+Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68FE94303A1D22C03D3157CE /* DocumentCaptureViewController+Strings.swift */; }; + 874EF196DF573CA524785AEF /* SelfieWarmupViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AFCD229BD9CB58303E19111 /* SelfieWarmupViewControllerTest.swift */; }; + 87927769E26B4189FB24D0C2 /* BottomAlignedLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5EE118A0368B2BBC342F162 /* BottomAlignedLabel.swift */; }; + 87994273B34C4783F92EF8FB /* IdentityFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFB1E6CDEA9002DB6353E7 /* IdentityFlowView.swift */; }; + 87A4B9B848DF1CFA11F32FC4 /* VerificationPageClearData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FC19E901C6E57D948833FD /* VerificationPageClearData.swift */; }; + 8802941AB1CAFB81BDE75249 /* VerificationPage_type_phone.json in Resources */ = {isa = PBXBuildFile; fileRef = E21D37D0CA9F5426F6318ADC /* VerificationPage_type_phone.json */; }; + 882C201AF4C8F48816DB740F /* IdentityElementsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31CC3450DDBE415B82707F4B /* IdentityElementsFactory.swift */; }; + 89562A38C9B3C7870B5F22F2 /* VerificationPage_type_doc_require_idNumber_and_address.json in Resources */ = {isa = PBXBuildFile; fileRef = 531D9220055F0F54CF6D72CE /* VerificationPage_type_doc_require_idNumber_and_address.json */; }; + 8DACF642D2E0C5FE35637EAE /* VerificationClientSecretTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD771C14C0711019D8AC9C2 /* VerificationClientSecretTest.swift */; }; + 8E420E7B59168A4B81C19123 /* BiometricConsentViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B264A8F97FA8CB0706F140F /* BiometricConsentViewControllerTest.swift */; }; + 8E5D382BF426061CDD83197B /* ImageScanningConcurrencyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC8F619B76D191FF2DEC8660 /* ImageScanningConcurrencyManager.swift */; }; + 8EB5D825D1ADC9DAB6E7D5A3 /* DocumentCaptureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF040F11A7D0693A7783654 /* DocumentCaptureView.swift */; }; + 8FEC054A2BF0649B64B6ED4F /* icon_add@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 9431C816BF67F677B9447746 /* icon_add@3x.png */; }; + 903114A8943889900AF6092C /* VerificationPageStaticContentIndividualWelcomePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8A8BACD4BA8E4E7DF039DAE /* VerificationPageStaticContentIndividualWelcomePage.swift */; }; + 905C6BC4E70CAF4AAFEAE246 /* BiometricConsentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20A2B98A36D6A860B39E615 /* BiometricConsentViewController.swift */; }; + 91C23784E05401CA9EF37140 /* SuccessViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC12E83001A164E12705498A /* SuccessViewController.swift */; }; + 924DE7C0E856DD2C5B80D53A /* Array+StripeIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC057ECE030E8374AD9CF61 /* Array+StripeIdentity.swift */; }; + 92B9210AA596D5A135F24710 /* SelfieUploader+API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EFF781FE9ED055F99F767D3 /* SelfieUploader+API.swift */; }; + 934D31A211E71520985D1D96 /* VerificationPageStaticContentDocumentCaptureModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B095FAF27C9C47DD4EDF5E4 /* VerificationPageStaticContentDocumentCaptureModels.swift */; }; + 9665F0EF90739A5D033C03A6 /* DocumentSide.swift in Sources */ = {isa = PBXBuildFile; fileRef = E310DFA6CAC355B848E82998 /* DocumentSide.swift */; }; + 96C17BE1FF29C4A24FAFFDBB /* IdentityFlowViewSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E7C993DB897B55B2A172CD9 /* IdentityFlowViewSnapshotTest.swift */; }; + 981D82CFC8B9EBF104B166F7 /* VerificationSheetFlowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AF8558B79CD52154D6EF3B /* VerificationSheetFlowController.swift */; }; + 9873265F2244B937E83F56BC /* SelfieCaptureViewController+Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 328DF3C5E53973F6943FC4BD /* SelfieCaptureViewController+Strings.swift */; }; + 99243751D031F96B17286315 /* DocumentFileUploadViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F0B317274417308FBA55E55 /* DocumentFileUploadViewControllerTest.swift */; }; + 99B6D8568AD7A79DD7A1C987 /* icon_selfie_warmup@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 53D07B5755756AB93F69256A /* icon_selfie_warmup@3x.png */; }; + 99CB78B1CAE5FC1BBA526B87 /* SelfieWarmupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D71B9B5C3055A361ABDBFB9 /* SelfieWarmupViewController.swift */; }; + 9A9B9B47F9CDA8AD1788A411 /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B48A1FCF125B2844F8CAD43 /* LoadingViewController.swift */; }; + 9D73810AC9A6AF921038DC54 /* DocumentScannerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5DE93D9C60DFB779F71D5DC /* DocumentScannerConfiguration.swift */; }; + 9F07B2D6AEC03BCCBCCF5908 /* AnimatedBorderViewSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A31C88110FBCDDA9FB912491 /* AnimatedBorderViewSnapshotTest.swift */; }; + 9F3790F6AB77036ADCC4B2C8 /* IdentityMLModelLoaderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 111A55FA39B400E6E87A5175 /* IdentityMLModelLoaderMock.swift */; }; + A030F131A965131A67388D97 /* DocumentCaptureViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99D92831ABFEBB1027419225 /* DocumentCaptureViewController.swift */; }; + A10A4F91CF1ECB610909ECD5 /* BiometricConsentViewControllerSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 957545FFD547CDA1CFB719E6 /* BiometricConsentViewControllerSnapshotTest.swift */; }; + A41FF7CAF8EBEBCB16115DCB /* DocumentUploaderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FE476C4E5F3E281B7E6E136 /* DocumentUploaderTest.swift */; }; + A44BC25EF5F443C7D0CBB783 /* VerificationSheetControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F459D2DF594C15C03585F3AF /* VerificationSheetControllerMock.swift */; }; + A47A4BB7D62116CAA3D29C2A /* HeaderIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FB4155E2BD74F6B44F8D68F /* HeaderIconView.swift */; }; + A6E190F4F1E9D599C2422624 /* VerificationSheetFlowControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CAC6276996043D1B3FC92F7 /* VerificationSheetFlowControllerMock.swift */; }; + A7AC2D0D9FDCF8AC5298708D /* StripeUICore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD376CBC869531C40036081B /* StripeUICore.framework */; }; + A7B8782A3E807D11A6E6A239 /* VerificationSheetAnalyticsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09F06CA1B36DA48B8E8BD7A5 /* VerificationSheetAnalyticsTest.swift */; }; + A8D87710369D0DCB43E481FF /* VerificationPageStaticContentSelfiePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAB01766577CAB2CF0C2CFFB /* VerificationPageStaticContentSelfiePage.swift */; }; + A8EDE16782B8ABEB359CADAC /* IdentityVerificationSheetTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D5C67D8BB38BC3EA6282FF2 /* IdentityVerificationSheetTest.swift */; }; + A8EEE8C2CD83374877393E8C /* ListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D4A09C8B4EBF06D00B80994 /* ListView.swift */; }; + A9C41FB2EB8034BCCD6CAC29 /* StripeIdentityBundleLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE8340912F2A489BB80D39B /* StripeIdentityBundleLocator.swift */; }; + AB4F7B2D348FAA2DD0BB40BD /* ErrorViewSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1FCF877D109689CA724EB4 /* ErrorViewSnapshotTest.swift */; }; + AB6B3BDF7D8F3F84EEA7208A /* StripeCore+Import.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45CA21A528C7D39BAA2F2B51 /* StripeCore+Import.swift */; }; + ABC1E2DDC2A4D02702A19E2B /* IndividualElementTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85E8B042346D6DF41AA6CCFC /* IndividualElementTest.swift */; }; + AD1ADA8B98C1FDB5C8C0A39F /* BarcodeDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63826305A96644687BF980CC /* BarcodeDetector.swift */; }; + ADA2674008520B832585A8EE /* IDDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB41D1D6B1D35C92EA6F2B7 /* IDDetector.swift */; }; + ADF66222ED710EAC0671C502 /* IndividualWelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4EB79390ADC3CFC714E404D /* IndividualWelcomeViewController.swift */; }; + AF16352FECF8EE27ECD6AAC9 /* MLDetectorMetricsTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0989CE5CF80CF69BD28675DE /* MLDetectorMetricsTracker.swift */; }; + AF48EAD4D52EB59134652A08 /* SnapshotTestMockData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 037D27A0AAECB846C8579452 /* SnapshotTestMockData.swift */; }; + AF90AF76409959324FB270EA /* HeaderIconViewSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C30DB2230DF9B47CD20AAF /* HeaderIconViewSnapshotTest.swift */; }; + B2377EEDCE440269E0766410 /* IdentityImageUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D4C3F2BAFB6BEFFFC1B566F /* IdentityImageUploader.swift */; }; + B292E22D9DD3198D44F249A7 /* SelfieCaptureViewSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F95321B67AA78B88A32E37BE /* SelfieCaptureViewSnapshotTest.swift */; }; + B38910D7C10E80AED2EB9358 /* icon_clock@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = C3288667B33AACBC2380CD9D /* icon_clock@3x.png */; }; + B5B5C232C6541F15AEF3C4BC /* StripeIdentity.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D604F6AABE4D6D5CCF802685 /* StripeIdentity.framework */; }; + B6A52680DC3B117DD0EE13E2 /* VerificationPage_type_doc_require_idNumber.json in Resources */ = {isa = PBXBuildFile; fileRef = CCB84457F36C8EA327239852 /* VerificationPage_type_doc_require_idNumber.json */; }; + B6B3F48D0B7539B28BD89AF7 /* PhoneOtpViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431967DEC3B17F8C9125E991 /* PhoneOtpViewController.swift */; }; + B6D4EF6BB1A9178AFA18675B /* SelfieScanningViewSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD7373453206D23A9ABF5E17 /* SelfieScanningViewSnapshotTest.swift */; }; + B6E0CD4C1CDE03985005F08D /* StripeCoreTestUtils.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5726517468179B69D50692C0 /* StripeCoreTestUtils.framework */; }; + B729E396A57DC7E1DC3D1FBA /* DocumentScanningViewSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B67F4A7C86FF6784C6844649 /* DocumentScanningViewSnapshotTest.swift */; }; + B8E47E623C9E4DAF53C3520C /* NonMaxSuppression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93F2EE0B99059C7BFB72AFAF /* NonMaxSuppression.swift */; }; + BA655B97F1F31F3B8BA14199 /* ImageScanningConcurrencyManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 005D7E5D0E239D61ED1E9796 /* ImageScanningConcurrencyManagerMock.swift */; }; + BD4686589B16ED96A748CCA8 /* DocumentScanningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A26F37BDBB5330AE87E7CB /* DocumentScanningView.swift */; }; + BDF0199A4A88D5E66751A033 /* InstructionalDocumentScanningViewSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01C54327529E9A3BD81F37F4 /* InstructionalDocumentScanningViewSnapshotTest.swift */; }; + BDF8D30919A290A0CF52059C /* header_icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 7CD11009097B994FEFF5393E /* header_icon.png */; }; + BFA24F4BD97F5BE9F5C0E934 /* IdentityUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEB6AB9A9BAF6E50B962C339 /* IdentityUI.swift */; }; + C1AA4B758A20557CDBE72FD9 /* SelfieScanningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18BE1F9B0EB046BF0E1C2D8B /* SelfieScanningView.swift */; }; + C1DF05059A82FE217A7831BA /* mock.html in Resources */ = {isa = PBXBuildFile; fileRef = ADA1D4AB1AE9B0E45C68FFD2 /* mock.html */; }; + C258E3B8806DFD2C288089DE /* VerificationPage_200_submitted.json in Resources */ = {isa = PBXBuildFile; fileRef = E0B9A86D071960D13AE81563 /* VerificationPage_200_submitted.json */; }; + C378303DE7D3674CE4C771B7 /* DocumentType+StripeIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1984CE45A111B5694A3F49 /* DocumentType+StripeIdentity.swift */; }; + C4BBB6E780EA8288EB46E795 /* ImageScanningSessionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE163E90F54FE4A075C7B74 /* ImageScanningSessionDelegate.swift */; }; + C5DB8461793523F9488DBBFA /* MLModelUnexpectedOutputError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FD2C7D94586AB8984DB7389 /* MLModelUnexpectedOutputError.swift */; }; + C5E8CA0B4DFEF0F017AEE8D7 /* NSAttributedString+HTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42768A09155B5439E724B360 /* NSAttributedString+HTML.swift */; }; + C63EAF360DB9887856543E68 /* ErrorViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20129AFBC6B4D304B494CBA6 /* ErrorViewControllerTest.swift */; }; + C809F1754942C27303CC0753 /* VerificationPageStaticContentPhoneOtpPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75CA839380FD9056B4636C9C /* VerificationPageStaticContentPhoneOtpPage.swift */; }; + C81F6192AA4A1B30AB1655A4 /* IdentityFlowNavigationControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22681AA4BBA6A907A0DD29DB /* IdentityFlowNavigationControllerTest.swift */; }; + C83DD219624645AACB2F29A9 /* RequiredInternationalAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40F28FD09DBAFAEB609EAD25 /* RequiredInternationalAddress.swift */; }; + C8A9ABCDD7A384756CC382D1 /* IdentityFlowViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE61B9C03DCE61E57CEC3F4 /* IdentityFlowViewController.swift */; }; + C9E49126B9BEC74E605C6DD0 /* SelfieCaptureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FEB1C2A314572B0234005A7 /* SelfieCaptureView.swift */; }; + CB09C0EFFCBA132257D6914D /* DebugViewControllerSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30748D6B84FC605EE612C2D3 /* DebugViewControllerSnapshotTest.swift */; }; + CB3C71FE1DCD8632B5ADBC5F /* VerificationPageFieldType.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2C25D8AD61094AB6530BCA4 /* VerificationPageFieldType.swift */; }; + CB3E51C6E407E6A4A1001AAC /* IdentityAnalyticsClientTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C43D3DC62FE3C5CC9FD22A50 /* IdentityAnalyticsClientTestHelpers.swift */; }; + CBA4B3CAFDFF232B72393842 /* VerificationClientSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E01DDEABC6ABAB0F03B7CEC /* VerificationClientSecret.swift */; }; + CE78EDF357433AFBB0929315 /* PhoneOtpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CDF2978746F82F5C5D169BA /* PhoneOtpView.swift */; }; + CE7F2D308363EEDD627AEBF7 /* InstructionalDocumentScanningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CDF8D78C430D9B26DD92B11 /* InstructionalDocumentScanningView.swift */; }; + CF5E139AD71239C64376815D /* String+Localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1559E966A487EE3867DD7B8 /* String+Localized.swift */; }; + CFA141490408B4644A4F0375 /* IdentityMockData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 738A5EB6B7F1C5621F190A72 /* IdentityMockData.swift */; }; + D19E35C5D5263F212071AE75 /* IDDetectorConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14ED7B7A9CC6DE447C14B5CB /* IDDetectorConstants.swift */; }; + D2309DFF2EDABB6C1A139513 /* VerificationPageDataIdNumber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A49996D90714452E6A533A /* VerificationPageDataIdNumber.swift */; }; + D3DA8FAC2DD250C34075FBC0 /* VerificationPageRequirements.swift in Sources */ = {isa = PBXBuildFile; fileRef = A676387474C2E5684F1A654D /* VerificationPageRequirements.swift */; }; + D55B24D4D03E43A9438BCFE5 /* IdNumberElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB73F630CCAFE6AAB9727C62 /* IdNumberElement.swift */; }; + DB6005016F47D09559DA0C98 /* DocumentType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AFAC1FACF9A36AAFC19BF2F /* DocumentType.swift */; }; + DCFBA69CC4437E2E6061F50B /* IdentityMLModelLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4546F9D432ACFF45E9CE1368 /* IdentityMLModelLoader.swift */; }; + DCFFA11A461B012F7515941F /* VerificationFlowWebViewSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22A64D0C0989BD43624BB506 /* VerificationFlowWebViewSnapshotTests.swift */; }; + E15D79E05C4FA68CA56B0CC8 /* TruncatedDecimal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F9DA29DC85773AC710A5201 /* TruncatedDecimal.swift */; }; + E1D4A5DD8C188725C5D23BBB /* VerificationPage_no_selfie.json in Resources */ = {isa = PBXBuildFile; fileRef = D5C7698331B899B80AF4F730 /* VerificationPage_no_selfie.json */; }; + E32820C9AEBD55B019C1E326 /* icon_camera@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 416BCE5C7BD7869793D0B75A /* icon_camera@3x.png */; }; + E5B984BE9EFC4E87D1866CB3 /* VerificationPageStaticContentSelfieModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B35B47EE955CE3D6A670CA0 /* VerificationPageStaticContentSelfieModels.swift */; }; + E646201B58937F04897840EB /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8AB1A0B05C00E547ED1A0D09 /* XCTest.framework */; }; + E64EE8A82FFB624F981D7E17 /* VerificationPageDataUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15E15D64B5F7EF76235D0BA5 /* VerificationPageDataUpdate.swift */; }; + E72C7C6D3632324FED428A68 /* IndividualWelcomeViewControllerSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99CDE8D3C390BCAEF13F938 /* IndividualWelcomeViewControllerSnapshotTest.swift */; }; + EA2A1C7D4A9FB826A2146808 /* DocumentScanner+API.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC4AE39C12E613AFB80FF4E8 /* DocumentScanner+API.swift */; }; + ED5777E49F4AE61121539EBC /* LaplacianBlurDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2061B962162CC2D8C4C6494C /* LaplacianBlurDetector.swift */; }; + EF277282173FFF6A52FE1EA3 /* FaceDetectorOutput.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9BA1457F98160EBCBEE670 /* FaceDetectorOutput.swift */; }; + EF343909D610063574D0F911 /* VerificationPageData_no_errors_needback.json in Resources */ = {isa = PBXBuildFile; fileRef = 9D112C14362D55A94E9B9E6E /* VerificationPageData_no_errors_needback.json */; }; + F146D14E6D35174CB94A8A48 /* FaceDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9BD50679E8B87237BF3955E /* FaceDetector.swift */; }; + F1CA28CC435B94C289B08257 /* DocumentUploader+API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643CB84029E6A12167838C3A /* DocumentUploader+API.swift */; }; + F1DAE15C305F9733A7C40673 /* VerificationPageDataRequirements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A566C584F41FE23108295E3 /* VerificationPageDataRequirements.swift */; }; + F2BBAF668109BC42FF4FCD09 /* IdentityAPIClientTestMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D0D5F9E17825856B55E4D1 /* IdentityAPIClientTestMock.swift */; }; + F2F041A9228AFAEFCB8A03B7 /* VerificationSheetControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FB8B409BCEBD89987293A94 /* VerificationSheetControllerTest.swift */; }; + F831E6E67E7F9057FED3F4B8 /* IdentityAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEF2BB05DF09AABFEEC1C66D /* IdentityAPIClient.swift */; }; + FA9F01846614FC4B30FDBF82 /* VerificationPageStaticContentConsentPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C84088B48BC2EA950AA25D2F /* VerificationPageStaticContentConsentPage.swift */; }; + FD6FB63B061FEABF17053225 /* NSAttributedString_HTMLSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D95CE7A231D14C9FD32A7848 /* NSAttributedString_HTMLSnapshotTest.swift */; }; + FDB82DE4F471545F5D7B4CCA /* MLMultiArray+StripeIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D731669A9237F0B147B366 /* MLMultiArray+StripeIdentity.swift */; }; + FF3D439778B2EF580053AC15 /* VerificationPageData_200.json in Resources */ = {isa = PBXBuildFile; fileRef = B75F0E4F340355B8661085F7 /* VerificationPageData_200.json */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + C5559AE430066C0A94C2686A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 0705CAA185B63201FD561508 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F251015845B21C036CFBC636; + remoteInfo = StripeIdentity; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 048FEFC038A0C2BC99CCB683 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + 559B276C9F23AEDE2D54B9A8 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 005D7E5D0E239D61ED1E9796 /* ImageScanningConcurrencyManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageScanningConcurrencyManagerMock.swift; sourceTree = ""; }; + 005EAC562365676CE56F306D /* ca-ES */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ca-ES"; path = "ca-ES.lproj/Localizable.strings"; sourceTree = ""; }; + 01C54327529E9A3BD81F37F4 /* InstructionalDocumentScanningViewSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionalDocumentScanningViewSnapshotTest.swift; sourceTree = ""; }; + 037D27A0AAECB846C8579452 /* SnapshotTestMockData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotTestMockData.swift; sourceTree = ""; }; + 03C30DB2230DF9B47CD20AAF /* HeaderIconViewSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderIconViewSnapshotTest.swift; sourceTree = ""; }; + 04E9AACF71C326D42567F09F /* DebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugView.swift; sourceTree = ""; }; + 050900C738C01F6D96F7ED5B /* IdentityFlowNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityFlowNavigationController.swift; sourceTree = ""; }; + 0631A038B5360C337B8E596C /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; + 083865663173CDD9F77417A4 /* VerificationPageDataDocumentFileData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageDataDocumentFileData.swift; sourceTree = ""; }; + 0989CE5CF80CF69BD28675DE /* MLDetectorMetricsTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLDetectorMetricsTracker.swift; sourceTree = ""; }; + 09F06CA1B36DA48B8E8BD7A5 /* VerificationSheetAnalyticsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationSheetAnalyticsTest.swift; sourceTree = ""; }; + 0ACA3969442F9B14DF70ABE7 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; + 0AFAC8C1757DE685E0E9BFB3 /* et-EE */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "et-EE"; path = "et-EE.lproj/Localizable.strings"; sourceTree = ""; }; + 0CD0A4F2BB147D41D2DF6976 /* icon_checkmark@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "icon_checkmark@3x.png"; sourceTree = ""; }; + 0CFB27562E05409A1C0263EA /* cs-CZ */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "cs-CZ"; path = "cs-CZ.lproj/Localizable.strings"; sourceTree = ""; }; + 0EA099682E4BE0A34CFCBE6C /* AnimatedBorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedBorderView.swift; sourceTree = ""; }; + 0F99DB710C60AC2A98449D51 /* ImageScanningSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageScanningSession.swift; sourceTree = ""; }; + 1002F1EA113FC18896C55CDB /* icon_warning@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "icon_warning@3x.png"; sourceTree = ""; }; + 1096AFFD3877D9A22703E6CA /* CGImage_StripeIdentitySnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGImage_StripeIdentitySnapshotTest.swift; sourceTree = ""; }; + 110A0200099F462B576F2ED4 /* VerificationPageDataName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageDataName.swift; sourceTree = ""; }; + 111A55FA39B400E6E87A5175 /* IdentityMLModelLoaderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityMLModelLoaderMock.swift; sourceTree = ""; }; + 12F4C291ACBB73ED95504131 /* PhoneOtpViewControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneOtpViewControllerTest.swift; sourceTree = ""; }; + 14244B002D9F37B56BE8D4F4 /* VerificationPageDataFace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageDataFace.swift; sourceTree = ""; }; + 14534C235EF7B57DDB6B74F1 /* DocumentFileUploadViewController+Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DocumentFileUploadViewController+Strings.swift"; sourceTree = ""; }; + 149453E1854C9566CC8D8484 /* ImageScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageScanner.swift; sourceTree = ""; }; + 14C231755842F322A25160ED /* StripeCameraCoreTestUtils.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCameraCoreTestUtils.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 14ED7B7A9CC6DE447C14B5CB /* IDDetectorConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IDDetectorConstants.swift; sourceTree = ""; }; + 15E15D64B5F7EF76235D0BA5 /* VerificationPageDataUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageDataUpdate.swift; sourceTree = ""; }; + 16ADAC122F0818BB64C5EF41 /* VerificationPage_200_testMode.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VerificationPage_200_testMode.json; sourceTree = ""; }; + 18593C820FE0E35DDEE36956 /* MLDetectorConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLDetectorConfiguration.swift; sourceTree = ""; }; + 188E601955C27EA3E98012AF /* icon_info@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "icon_info@3x.png"; sourceTree = ""; }; + 18BE1F9B0EB046BF0E1C2D8B /* SelfieScanningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfieScanningView.swift; sourceTree = ""; }; + 198B92E9E0D04CA24F208E07 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; + 1A566C584F41FE23108295E3 /* VerificationPageDataRequirements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageDataRequirements.swift; sourceTree = ""; }; + 1B264A8F97FA8CB0706F140F /* BiometricConsentViewControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BiometricConsentViewControllerTest.swift; sourceTree = ""; }; + 1CAC6276996043D1B3FC92F7 /* VerificationSheetFlowControllerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationSheetFlowControllerMock.swift; sourceTree = ""; }; + 1CDF8D78C430D9B26DD92B11 /* InstructionalDocumentScanningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionalDocumentScanningView.swift; sourceTree = ""; }; + 1E01DDEABC6ABAB0F03B7CEC /* VerificationClientSecret.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationClientSecret.swift; sourceTree = ""; }; + 1E7C993DB897B55B2A172CD9 /* IdentityFlowViewSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityFlowViewSnapshotTest.swift; sourceTree = ""; }; + 1F4F55F9FBE67FCFE3767816 /* VerificationPageCollectedData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageCollectedData.swift; sourceTree = ""; }; + 1FB4155E2BD74F6B44F8D68F /* HeaderIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderIconView.swift; sourceTree = ""; }; + 20129AFBC6B4D304B494CBA6 /* ErrorViewControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorViewControllerTest.swift; sourceTree = ""; }; + 2061B962162CC2D8C4C6494C /* LaplacianBlurDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaplacianBlurDetector.swift; sourceTree = ""; }; + 20D40A569C2BB62F86D57C01 /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = ""; }; + 21DB72C7F61A4A042D898236 /* SelfieWarmupViewSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfieWarmupViewSnapshotTest.swift; sourceTree = ""; }; + 22681AA4BBA6A907A0DD29DB /* IdentityFlowNavigationControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityFlowNavigationControllerTest.swift; sourceTree = ""; }; + 22A64D0C0989BD43624BB506 /* VerificationFlowWebViewSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationFlowWebViewSnapshotTests.swift; sourceTree = ""; }; + 22B3E26EBB8F43F7559C635B /* lt-LT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "lt-LT"; path = "lt-LT.lproj/Localizable.strings"; sourceTree = ""; }; + 2344DA31ADA3573F9C2EE7A0 /* sl-SI */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sl-SI"; path = "sl-SI.lproj/Localizable.strings"; sourceTree = ""; }; + 280A8D95B0045A940A917B1A /* front_drivers_license.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = front_drivers_license.jpg; sourceTree = ""; }; + 29E0AD187733319776268351 /* FaceScannerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaceScannerConfiguration.swift; sourceTree = ""; }; + 2AB451EC826E856E6BCB7070 /* ShadowConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowConfiguration.swift; sourceTree = ""; }; + 2B35B47EE955CE3D6A670CA0 /* VerificationPageStaticContentSelfieModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageStaticContentSelfieModels.swift; sourceTree = ""; }; + 2CDF024DA4F519CB21A717AA /* IDDetectorOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IDDetectorOutput.swift; sourceTree = ""; }; + 2E5237659945E4A05ADE6DB0 /* TimeInterval+StripeIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+StripeIdentity.swift"; sourceTree = ""; }; + 2EDD22C65A41EB87DD9C96DF /* CompleteOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompleteOptionView.swift; sourceTree = ""; }; + 2FAE3BFE9870AF3F0553AB9C /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; + 2FC476BFBFA09FDC174857A6 /* ro-RO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ro-RO"; path = "ro-RO.lproj/Localizable.strings"; sourceTree = ""; }; + 2FC99DD88994E0DB581E19C6 /* VerificationPage_type_doc_require_address.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VerificationPage_type_doc_require_address.json; sourceTree = ""; }; + 2FD2C7D94586AB8984DB7389 /* MLModelUnexpectedOutputError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLModelUnexpectedOutputError.swift; sourceTree = ""; }; + 2FEB1C2A314572B0234005A7 /* SelfieCaptureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfieCaptureView.swift; sourceTree = ""; }; + 30748D6B84FC605EE612C2D3 /* DebugViewControllerSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugViewControllerSnapshotTest.swift; sourceTree = ""; }; + 3118D24F94912E8A9BB86BFE /* fil */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fil; path = fil.lproj/Localizable.strings; sourceTree = ""; }; + 31CC3450DDBE415B82707F4B /* IdentityElementsFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityElementsFactory.swift; sourceTree = ""; }; + 3218882B7E1610E17BDB4522 /* VerificationPage_200.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VerificationPage_200.json; sourceTree = ""; }; + 328DF3C5E53973F6943FC4BD /* SelfieCaptureViewController+Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SelfieCaptureViewController+Strings.swift"; sourceTree = ""; }; + 36ED32D7EE8F52D6FC7D8826 /* HTMLViewWithIconLabels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLViewWithIconLabels.swift; sourceTree = ""; }; + 3954F595B70115874BD88CCC /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; + 3AA3F3B346FFC8E58BF8739A /* VerificationPageData_no_errors.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VerificationPageData_no_errors.json; sourceTree = ""; }; + 3AFAC1FACF9A36AAFC19BF2F /* DocumentType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentType.swift; sourceTree = ""; }; + 3B48A1FCF125B2844F8CAD43 /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = ""; }; + 3DD5FEA0E4E7EF1A4BA9646B /* IdentityVerificationSheetError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityVerificationSheetError.swift; sourceTree = ""; }; + 4023102F8B76C16522BC02B9 /* SelfieWarmupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfieWarmupView.swift; sourceTree = ""; }; + 40DAFA9171B6D631DE9B6830 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + 40F28FD09DBAFAEB609EAD25 /* RequiredInternationalAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequiredInternationalAddress.swift; sourceTree = ""; }; + 416BCE5C7BD7869793D0B75A /* icon_camera@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "icon_camera@3x.png"; sourceTree = ""; }; + 423EA472FB8902C1ED6EBA60 /* InstructionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionListView.swift; sourceTree = ""; }; + 42768A09155B5439E724B360 /* NSAttributedString+HTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+HTML.swift"; sourceTree = ""; }; + 431967DEC3B17F8C9125E991 /* PhoneOtpViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneOtpViewController.swift; sourceTree = ""; }; + 434BC5FFBC1163872413A40A /* StripeiOS Tests-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS Tests-Release.xcconfig"; sourceTree = ""; }; + 4375E0DF02F4D0F09860AFE0 /* VerificationPageStaticContentIndividualPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageStaticContentIndividualPage.swift; sourceTree = ""; }; + 4546F9D432ACFF45E9CE1368 /* IdentityMLModelLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityMLModelLoader.swift; sourceTree = ""; }; + 45CA21A528C7D39BAA2F2B51 /* StripeCore+Import.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StripeCore+Import.swift"; sourceTree = ""; }; + 467BA77070B91055F1F2DCD4 /* IdentityTopLevelDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityTopLevelDestination.swift; sourceTree = ""; }; + 48A449D0BE66D0A555183AF4 /* VerificationFlowWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationFlowWebViewController.swift; sourceTree = ""; }; + 48E1204F31DC9F8FEA0D312A /* DocumentScannerOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentScannerOutput.swift; sourceTree = ""; }; + 4A1FCF877D109689CA724EB4 /* ErrorViewSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorViewSnapshotTest.swift; sourceTree = ""; }; + 4A59C0CC87CDB7AABFE96686 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; + 4AFCD229BD9CB58303E19111 /* SelfieWarmupViewControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfieWarmupViewControllerTest.swift; sourceTree = ""; }; + 4C0B804DA867C37809656BB3 /* ImageScannerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageScannerMock.swift; sourceTree = ""; }; + 4D0F4646D7565055396DD8F5 /* StripeCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 4D2F49DD0BAC5FEF308BA165 /* FaceScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaceScanner.swift; sourceTree = ""; }; + 4D4C3F2BAFB6BEFFFC1B566F /* IdentityImageUploader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityImageUploader.swift; sourceTree = ""; }; + 4D5C67D8BB38BC3EA6282FF2 /* IdentityVerificationSheetTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityVerificationSheetTest.swift; sourceTree = ""; }; + 4DEBC1A25D2C446219E7E733 /* CGImage+StripeIdentityUnitTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGImage+StripeIdentityUnitTest.swift"; sourceTree = ""; }; + 4F6B26A21285054BEE9CA244 /* Project-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Debug.xcconfig"; sourceTree = ""; }; + 50A49996D90714452E6A533A /* VerificationPageDataIdNumber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageDataIdNumber.swift; sourceTree = ""; }; + 520D8A1D20ACE21966E806D9 /* HTMLTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLTextView.swift; sourceTree = ""; }; + 52DD224D2BE08E0553EF8AA6 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/Localizable.strings"; sourceTree = ""; }; + 52FB883B057ED79F2C58BC58 /* VerificationFlowResult+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VerificationFlowResult+Equatable.swift"; sourceTree = ""; }; + 531D9220055F0F54CF6D72CE /* VerificationPage_type_doc_require_idNumber_and_address.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VerificationPage_type_doc_require_idNumber_and_address.json; sourceTree = ""; }; + 53D07B5755756AB93F69256A /* icon_selfie_warmup@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "icon_selfie_warmup@3x.png"; sourceTree = ""; }; + 53F1B93DB4F1708B41125301 /* IdentityVerificationSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityVerificationSheet.swift; sourceTree = ""; }; + 5726517468179B69D50692C0 /* StripeCoreTestUtils.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCoreTestUtils.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 57F22CC7DFA4B98A1A533CE0 /* MotionBlurDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MotionBlurDetector.swift; sourceTree = ""; }; + 598A711C4A2B8988EFBEEF77 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; + 5A9F73BBC3ECDBAE226012A5 /* ErrorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorViewController.swift; sourceTree = ""; }; + 5B975279521B43A8F82EBAB3 /* nn-NO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "nn-NO"; path = "nn-NO.lproj/Localizable.strings"; sourceTree = ""; }; + 5D4A09C8B4EBF06D00B80994 /* ListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListView.swift; sourceTree = ""; }; + 5F0B317274417308FBA55E55 /* DocumentFileUploadViewControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentFileUploadViewControllerTest.swift; sourceTree = ""; }; + 6182719E4413B5136BB763D1 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 63826305A96644687BF980CC /* BarcodeDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeDetector.swift; sourceTree = ""; }; + 643CB84029E6A12167838C3A /* DocumentUploader+API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DocumentUploader+API.swift"; sourceTree = ""; }; + 658F7A165D731F5046ACDF29 /* VerificationFlowWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationFlowWebView.swift; sourceTree = ""; }; + 66AA83F1165CFCCFDAF5D850 /* CountryNotListedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountryNotListedViewController.swift; sourceTree = ""; }; + 6702A1AB7FBCD89A356A86FE /* VerificationPageStaticContentDocumentCapturePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageStaticContentDocumentCapturePage.swift; sourceTree = ""; }; + 67BFE71088C74C03B05CBCD2 /* SelfieCaptureViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfieCaptureViewController.swift; sourceTree = ""; }; + 68FE94303A1D22C03D3157CE /* DocumentCaptureViewController+Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DocumentCaptureViewController+Strings.swift"; sourceTree = ""; }; + 69753FF8ED7304500D4EFFB9 /* VerificationPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPage.swift; sourceTree = ""; }; + 6BC7102DF0964D1204E2BFEB /* mt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mt; path = mt.lproj/Localizable.strings; sourceTree = ""; }; + 6EB06216204F856766C67E18 /* VerificationPageStaticContentTextPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageStaticContentTextPage.swift; sourceTree = ""; }; + 6F772F905FB81552FA1728B2 /* cgimage_stripeidentity_test.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = cgimage_stripeidentity_test.png; sourceTree = ""; }; + 6FF56E95E47DA51C3517E1EC /* IdentityElementsFactoryTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityElementsFactoryTest.swift; sourceTree = ""; }; + 704D26339D2158FD8FADE1C2 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; + 725E481300FDDDD41EBAA835 /* FaceScanner+API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FaceScanner+API.swift"; sourceTree = ""; }; + 738A5EB6B7F1C5621F190A72 /* IdentityMockData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityMockData.swift; sourceTree = ""; }; + 744CE13E8488F0E4AC951D6C /* CGImage+StripeIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGImage+StripeIdentity.swift"; sourceTree = ""; }; + 748D38C4370E78DBC9E20E95 /* MLModelLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLModelLoader.swift; sourceTree = ""; }; + 74D9040BDF0DBA526C156C3D /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Localizable.strings"; sourceTree = ""; }; + 75CA839380FD9056B4636C9C /* VerificationPageStaticContentPhoneOtpPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageStaticContentPhoneOtpPage.swift; sourceTree = ""; }; + 786574907AC9065E6E658D9C /* MLDetectorMetricsTrackerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLDetectorMetricsTrackerMock.swift; sourceTree = ""; }; + 7B095FAF27C9C47DD4EDF5E4 /* VerificationPageStaticContentDocumentCaptureModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageStaticContentDocumentCaptureModels.swift; sourceTree = ""; }; + 7CD11009097B994FEFF5393E /* header_icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = header_icon.png; sourceTree = ""; }; + 7CDAA5C16C934746013C6CE6 /* VerificationSheetFlowControllerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationSheetFlowControllerError.swift; sourceTree = ""; }; + 7E204A65850D7EC60AB779B5 /* CameraPreviewContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPreviewContainerView.swift; sourceTree = ""; }; + 7EAFBBEEA27B11E702B78497 /* fr-CA */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "fr-CA"; path = "fr-CA.lproj/Localizable.strings"; sourceTree = ""; }; + 7ECA816022703F05AD759897 /* VerificationPage_200_no_consent_header.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VerificationPage_200_no_consent_header.json; sourceTree = ""; }; + 7F9DA29DC85773AC710A5201 /* TruncatedDecimal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TruncatedDecimal.swift; sourceTree = ""; }; + 7FB8B409BCEBD89987293A94 /* VerificationSheetControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationSheetControllerTest.swift; sourceTree = ""; }; + 7FD2B7B0EDA3144115F080BC /* VerificationPage_require_live_capture.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VerificationPage_require_live_capture.json; sourceTree = ""; }; + 80C34F71161E8793947E18E7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 80F9C4B0CFCD5FF45D203B62 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; + 82D731669A9237F0B147B366 /* MLMultiArray+StripeIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MLMultiArray+StripeIdentity.swift"; sourceTree = ""; }; + 8389CF3DBB12A384BCF0959F /* InstructionListViewSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstructionListViewSnapshotTest.swift; sourceTree = ""; }; + 85E8B042346D6DF41AA6CCFC /* IndividualElementTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndividualElementTest.swift; sourceTree = ""; }; + 8605B809AD7EBA140CC9DBE9 /* StripeiOS Tests-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS Tests-Debug.xcconfig"; sourceTree = ""; }; + 8AB1A0B05C00E547ED1A0D09 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; + 8AF040F11A7D0693A7783654 /* DocumentCaptureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentCaptureView.swift; sourceTree = ""; }; + 8BBFB1E6CDEA9002DB6353E7 /* IdentityFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityFlowView.swift; sourceTree = ""; }; + 8D71B9B5C3055A361ABDBFB9 /* SelfieWarmupViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfieWarmupViewController.swift; sourceTree = ""; }; + 8DAFC23BB4CDD7DEB3286704 /* PhoneOtpViewControllerSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneOtpViewControllerSnapshotTest.swift; sourceTree = ""; }; + 8E1EF4A7AA64856F9C887C2A /* IdentityTextButtonElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityTextButtonElement.swift; sourceTree = ""; }; + 8EFF781FE9ED055F99F767D3 /* SelfieUploader+API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SelfieUploader+API.swift"; sourceTree = ""; }; + 8FE476C4E5F3E281B7E6E136 /* DocumentUploaderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentUploaderTest.swift; sourceTree = ""; }; + 8FF19274A29802CDB696D1DF /* SuccessViewControllerSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuccessViewControllerSnapshotTest.swift; sourceTree = ""; }; + 9048C4B9F47CE82127D6C1B3 /* back_drivers_license.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = back_drivers_license.jpg; sourceTree = ""; }; + 9145699AC820A2F2F916F142 /* IndividualViewControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndividualViewControllerTest.swift; sourceTree = ""; }; + 93A9C204870754D960586E10 /* IconLabelHTMLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconLabelHTMLView.swift; sourceTree = ""; }; + 93F2EE0B99059C7BFB72AFAF /* NonMaxSuppression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonMaxSuppression.swift; sourceTree = ""; }; + 9429D10FEFFFEFEE08DBA4F8 /* VerificationPageDataPhone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageDataPhone.swift; sourceTree = ""; }; + 9431C816BF67F677B9447746 /* icon_add@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "icon_add@3x.png"; sourceTree = ""; }; + 94838BCA1111F735A8FBC072 /* HeaderViewSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderViewSnapshotTest.swift; sourceTree = ""; }; + 957545FFD547CDA1CFB719E6 /* BiometricConsentViewControllerSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BiometricConsentViewControllerSnapshotTest.swift; sourceTree = ""; }; + 98AF8558B79CD52154D6EF3B /* VerificationSheetFlowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationSheetFlowController.swift; sourceTree = ""; }; + 9910B88643D95B2F9329E4A2 /* DocumentTypeSelectViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentTypeSelectViewController.swift; sourceTree = ""; }; + 99607F36AF24C978953964E9 /* VerificationPageData_submitted.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VerificationPageData_submitted.json; sourceTree = ""; }; + 99D92831ABFEBB1027419225 /* DocumentCaptureViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentCaptureViewController.swift; sourceTree = ""; }; + 9A5A1304CA30DC06B715BD92 /* pl-PL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pl-PL"; path = "pl-PL.lproj/Localizable.strings"; sourceTree = ""; }; + 9BB8E3DF263505065E8545AB /* SelfieUploader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfieUploader.swift; sourceTree = ""; }; + 9C69B4A6F36A8BD814CB05D9 /* StripeIdentity.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StripeIdentity.h; sourceTree = ""; }; + 9CDF2978746F82F5C5D169BA /* PhoneOtpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneOtpView.swift; sourceTree = ""; }; + 9D112C14362D55A94E9B9E6E /* VerificationPageData_no_errors_needback.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VerificationPageData_no_errors_needback.json; sourceTree = ""; }; + 9DBB998F589E062218E0BD90 /* icon_warning_92@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "icon_warning_92@3x.png"; sourceTree = ""; }; + 9E170B07B4958901EE3864A4 /* VisionBasedDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisionBasedDetector.swift; sourceTree = ""; }; + 9E703FD28F5352DFC4115F1C /* IndividualViewControllerSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndividualViewControllerSnapshotTest.swift; sourceTree = ""; }; + A163E1EC771058E5DF5FFE66 /* Project-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Release.xcconfig"; sourceTree = ""; }; + A20A2B98A36D6A860B39E615 /* BiometricConsentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BiometricConsentViewController.swift; sourceTree = ""; }; + A2AF434F2405C5F38F259112 /* IdentityHTMLViewSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityHTMLViewSnapshotTest.swift; sourceTree = ""; }; + A2C25D8AD61094AB6530BCA4 /* VerificationPageFieldType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageFieldType.swift; sourceTree = ""; }; + A31C88110FBCDDA9FB912491 /* AnimatedBorderViewSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedBorderViewSnapshotTest.swift; sourceTree = ""; }; + A5082A4100CEE0C8378AD185 /* VerificationSheetController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationSheetController.swift; sourceTree = ""; }; + A5750A42C108C4FBA7F735B4 /* VerificationPageStaticContentCountryNotListedPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageStaticContentCountryNotListedPage.swift; sourceTree = ""; }; + A59680E002FC6E7BC9906BD2 /* IdentityAPIClientTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityAPIClientTest.swift; sourceTree = ""; }; + A5D517A4C8669379A029BBAD /* StripeIdentityTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StripeIdentityTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A5DE93D9C60DFB779F71D5DC /* DocumentScannerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentScannerConfiguration.swift; sourceTree = ""; }; + A676387474C2E5684F1A654D /* VerificationPageRequirements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageRequirements.swift; sourceTree = ""; }; + A69C1BC7C63512FE24B32433 /* VerificationSheetFlowControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationSheetFlowControllerTest.swift; sourceTree = ""; }; + A6E9FA49B13AC0A8826F8257 /* VerificationPageDataDob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageDataDob.swift; sourceTree = ""; }; + A8A8BACD4BA8E4E7DF039DAE /* VerificationPageStaticContentIndividualWelcomePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageStaticContentIndividualWelcomePage.swift; sourceTree = ""; }; + A99CDE8D3C390BCAEF13F938 /* IndividualWelcomeViewControllerSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndividualWelcomeViewControllerSnapshotTest.swift; sourceTree = ""; }; + AAB01766577CAB2CF0C2CFFB /* VerificationPageStaticContentSelfiePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageStaticContentSelfiePage.swift; sourceTree = ""; }; + AD7373453206D23A9ABF5E17 /* SelfieScanningViewSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfieScanningViewSnapshotTest.swift; sourceTree = ""; }; + AD8443DFD20A6F7E547E4DAE /* bg-BG */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "bg-BG"; path = "bg-BG.lproj/Localizable.strings"; sourceTree = ""; }; + ADA1D4AB1AE9B0E45C68FFD2 /* mock.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = mock.html; sourceTree = ""; }; + AE92BAAC65F86A87F25B9C08 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; + B17C20418C972E7B06B84370 /* StripeiOS-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS-Debug.xcconfig"; sourceTree = ""; }; + B2E15397783A643B74921A00 /* VerificationPageDataRequirementError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageDataRequirementError.swift; sourceTree = ""; }; + B2EB7348CE21DB1A86706532 /* VerificationFlowWebViewControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationFlowWebViewControllerTest.swift; sourceTree = ""; }; + B38AE7F15E61D960EFA15659 /* VerificationPageData_submitted_not_closed.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VerificationPageData_submitted_not_closed.json; sourceTree = ""; }; + B3B539D323CF2AACB580596B /* VerificationPageData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageData.swift; sourceTree = ""; }; + B3BED2C3A9B548341D73D3CC /* ListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListItemView.swift; sourceTree = ""; }; + B5EE118A0368B2BBC342F162 /* BottomAlignedLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomAlignedLabel.swift; sourceTree = ""; }; + B634C1568CBC56B3E33CE3E2 /* zh-HK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-HK"; path = "zh-HK.lproj/Localizable.strings"; sourceTree = ""; }; + B65B0A0F124B3E0C5490D2B6 /* ListItemViewSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListItemViewSnapshotTest.swift; sourceTree = ""; }; + B67F4A7C86FF6784C6844649 /* DocumentScanningViewSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentScanningViewSnapshotTest.swift; sourceTree = ""; }; + B71F1C5085AD91100943CFCA /* IndividualViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndividualViewController.swift; sourceTree = ""; }; + B75F0E4F340355B8661085F7 /* VerificationPageData_200.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VerificationPageData_200.json; sourceTree = ""; }; + B9BD50679E8B87237BF3955E /* FaceDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaceDetector.swift; sourceTree = ""; }; + B9CB453179082B1AE6B242FD /* IdentityDataCollecting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityDataCollecting.swift; sourceTree = ""; }; + BB10B7416AB6D7F94A57A8E3 /* ContentCenteringScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentCenteringScrollView.swift; sourceTree = ""; }; + BCA7F9B0204BF18C483A474A /* DocumentCaptureViewControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentCaptureViewControllerTest.swift; sourceTree = ""; }; + BCB41D1D6B1D35C92EA6F2B7 /* IDDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IDDetector.swift; sourceTree = ""; }; + BCC019FA1D6D8AEB598D9EC5 /* IdentityAnalyticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityAnalyticsClient.swift; sourceTree = ""; }; + BD9BA1457F98160EBCBEE670 /* FaceDetectorOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaceDetectorOutput.swift; sourceTree = ""; }; + C3288667B33AACBC2380CD9D /* icon_clock@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "icon_clock@3x.png"; sourceTree = ""; }; + C334F59C93D3D11534FACBBA /* DocumentUploader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentUploader.swift; sourceTree = ""; }; + C3C78174C0059A8957832825 /* VNBarcodeSymbology+StripeIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VNBarcodeSymbology+StripeIdentity.swift"; sourceTree = ""; }; + C43D3DC62FE3C5CC9FD22A50 /* IdentityAnalyticsClientTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityAnalyticsClientTestHelpers.swift; sourceTree = ""; }; + C4891127AAACF7829BEE80E2 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; + C48F9BFEB0E03E881C2D6017 /* HeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderView.swift; sourceTree = ""; }; + C4943A54EF548F28DB66DD1F /* FaceCaptureData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaceCaptureData.swift; sourceTree = ""; }; + C4EB79390ADC3CFC714E404D /* IndividualWelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndividualWelcomeViewController.swift; sourceTree = ""; }; + C74D7B9CB1FF34AE667C65B3 /* DocumentScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentScanner.swift; sourceTree = ""; }; + C84088B48BC2EA950AA25D2F /* VerificationPageStaticContentConsentPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageStaticContentConsentPage.swift; sourceTree = ""; }; + C8D0D5F9E17825856B55E4D1 /* IdentityAPIClientTestMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityAPIClientTestMock.swift; sourceTree = ""; }; + C8D82EAF85863CAAA250E393 /* ListViewSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViewSnapshotTest.swift; sourceTree = ""; }; + CB639F497EF62CAE81822820 /* StripeCameraCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCameraCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + CBC057ECE030E8374AD9CF61 /* Array+StripeIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+StripeIdentity.swift"; sourceTree = ""; }; + CC4AE39C12E613AFB80FF4E8 /* DocumentScanner+API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DocumentScanner+API.swift"; sourceTree = ""; }; + CCB84457F36C8EA327239852 /* VerificationPage_type_doc_require_idNumber.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VerificationPage_type_doc_require_idNumber.json; sourceTree = ""; }; + CE691D40B29AB0F31443479A /* DocumentUploaderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentUploaderMock.swift; sourceTree = ""; }; + CEE8340912F2A489BB80D39B /* StripeIdentityBundleLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeIdentityBundleLocator.swift; sourceTree = ""; }; + CF2EEA927FA9ACE159A67063 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; + CF3CE83043E6DE26CE8F9C0E /* DebugViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugViewController.swift; sourceTree = ""; }; + D105F7A265B60FE9AD82B0DF /* el-GR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "el-GR"; path = "el-GR.lproj/Localizable.strings"; sourceTree = ""; }; + D28361A39FEF902EF979BEEE /* DocumentTypeSelectViewControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentTypeSelectViewControllerTest.swift; sourceTree = ""; }; + D5C7698331B899B80AF4F730 /* VerificationPage_no_selfie.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VerificationPage_no_selfie.json; sourceTree = ""; }; + D604F6AABE4D6D5CCF802685 /* StripeIdentity.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeIdentity.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D6918DC6A9681E769D44BC78 /* TruncatedDecimalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TruncatedDecimalTest.swift; sourceTree = ""; }; + D6A26F37BDBB5330AE87E7CB /* DocumentScanningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentScanningView.swift; sourceTree = ""; }; + D75A138199A6CC2DB31DD2D5 /* VerifyWebURLHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifyWebURLHelper.swift; sourceTree = ""; }; + D95CE7A231D14C9FD32A7848 /* NSAttributedString_HTMLSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSAttributedString_HTMLSnapshotTest.swift; sourceTree = ""; }; + DC12E83001A164E12705498A /* SuccessViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuccessViewController.swift; sourceTree = ""; }; + DC8F619B76D191FF2DEC8660 /* ImageScanningConcurrencyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageScanningConcurrencyManager.swift; sourceTree = ""; }; + DEB322F07A4F2BE5B1FED0AD /* DebugViewControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugViewControllerTest.swift; sourceTree = ""; }; + DF85EEB9A816B13A485F7382 /* icon_warning2@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "icon_warning2@3x.png"; sourceTree = ""; }; + E0B9A86D071960D13AE81563 /* VerificationPage_200_submitted.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VerificationPage_200_submitted.json; sourceTree = ""; }; + E0CF1B086B99DEEF4F4F2A1D /* IndividualFormElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndividualFormElement.swift; sourceTree = ""; }; + E1FC19E901C6E57D948833FD /* VerificationPageClearData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageClearData.swift; sourceTree = ""; }; + E21D37D0CA9F5426F6318ADC /* VerificationPage_type_phone.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VerificationPage_type_phone.json; sourceTree = ""; }; + E26F8082A859AC1F5C5A3D13 /* Enums+CustomStringConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Enums+CustomStringConvertible.swift"; sourceTree = ""; }; + E310DFA6CAC355B848E82998 /* DocumentSide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentSide.swift; sourceTree = ""; }; + E3A5A9765EF649A580A0519F /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; + E3E90C7076A10469E4893B73 /* FaceScannerOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaceScannerOutput.swift; sourceTree = ""; }; + E4952611570326FFC52F0F30 /* IndividualWelcomeViewControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndividualWelcomeViewControllerTest.swift; sourceTree = ""; }; + E6C2F1CDF5594786FB4BAA49 /* ms-MY */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ms-MY"; path = "ms-MY.lproj/Localizable.strings"; sourceTree = ""; }; + E70585F354B2B8B1420311CB /* VerificationSheetAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationSheetAnalytics.swift; sourceTree = ""; }; + E850C3F1FEA42490F1B3761E /* StripeiOS-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS-Release.xcconfig"; sourceTree = ""; }; + E91362066B0F066C00CB45F2 /* VerificationFlowWebViewTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationFlowWebViewTest.swift; sourceTree = ""; }; + E94605BCADE912561ED6F710 /* STPLocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPLocalizedString.swift; sourceTree = ""; }; + EA7EF0C427E327A1E945BC28 /* VerificationPage_type_address.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VerificationPage_type_address.json; sourceTree = ""; }; + EB73F630CCAFE6AAB9727C62 /* IdNumberElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdNumberElement.swift; sourceTree = ""; }; + EB927B052ADBE97524CC0F43 /* DocumentFileUploadViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentFileUploadViewController.swift; sourceTree = ""; }; + EB9B2148051885B8591A13DE /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; + EBE61B9C03DCE61E57CEC3F4 /* IdentityFlowViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityFlowViewController.swift; sourceTree = ""; }; + EE1984CE45A111B5694A3F49 /* DocumentType+StripeIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DocumentType+StripeIdentity.swift"; sourceTree = ""; }; + EE238D8B6DAA92980E1F813A /* ShadowedCorneredImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowedCorneredImageView.swift; sourceTree = ""; }; + EEF2BB05DF09AABFEEC1C66D /* IdentityAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityAPIClient.swift; sourceTree = ""; }; + F08E49ED4B7010002D5FC734 /* IdentityImageUploaderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityImageUploaderTest.swift; sourceTree = ""; }; + F0CB25FB0857C39B915505DF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + F1559E966A487EE3867DD7B8 /* String+Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Localized.swift"; sourceTree = ""; }; + F43124EF97ADAB2BD1B7DEE2 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; + F44D3A78A44A0EB11BA51E5F /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/Localizable.strings; sourceTree = ""; }; + F459D2DF594C15C03585F3AF /* VerificationSheetControllerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationSheetControllerMock.swift; sourceTree = ""; }; + F46E474400B21A35FA6066F8 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; + F48A3390B6B74481B1F64EC6 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + F524F862B05C4744F3A5FCD4 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; + F696493D59EE069EB76B0CB4 /* icon_checkmark_92@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "icon_checkmark_92@3x.png"; sourceTree = ""; }; + F8103B1FD36D79D22EACF7EE /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; + F8521F2AA5740FAB9952B58E /* en-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-GB"; path = "en-GB.lproj/Localizable.strings"; sourceTree = ""; }; + F95321B67AA78B88A32E37BE /* SelfieCaptureViewSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfieCaptureViewSnapshotTest.swift; sourceTree = ""; }; + F9D552632FFF310FC6A4DB26 /* sk-SK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sk-SK"; path = "sk-SK.lproj/Localizable.strings"; sourceTree = ""; }; + FB119F30000F66B9660A44E8 /* lv-LV */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "lv-LV"; path = "lv-LV.lproj/Localizable.strings"; sourceTree = ""; }; + FB9BD2014C4A16821A2B8DC1 /* VerificationPageStaticContentDocumentSelectPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationPageStaticContentDocumentSelectPage.swift; sourceTree = ""; }; + FCD3770DF9B6356D1A82870A /* VerificationPage_type_idNumber.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VerificationPage_type_idNumber.json; sourceTree = ""; }; + FD376CBC869531C40036081B /* StripeUICore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeUICore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + FEB6AB9A9BAF6E50B962C339 /* IdentityUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityUI.swift; sourceTree = ""; }; + FF0871DEB2B2BA56EFADEEA6 /* UINavigationController+StripeIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationController+StripeIdentity.swift"; sourceTree = ""; }; + FFD771C14C0711019D8AC9C2 /* VerificationClientSecretTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationClientSecretTest.swift; sourceTree = ""; }; + FFE163E90F54FE4A075C7B74 /* ImageScanningSessionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageScanningSessionDelegate.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 65EE834154FF7B955E7239D7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 695E12945F47ED3060B8AE19 /* StripeCameraCore.framework in Frameworks */, + 10B606812179D64E67BFFFCF /* StripeCore.framework in Frameworks */, + A7AC2D0D9FDCF8AC5298708D /* StripeUICore.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 942AEFA5BCC5A034EF066C3E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E646201B58937F04897840EB /* XCTest.framework in Frameworks */, + 4A5FEB151B079C41FD40FED5 /* StripeCameraCoreTestUtils.framework in Frameworks */, + B6E0CD4C1CDE03985005F08D /* StripeCoreTestUtils.framework in Frameworks */, + B5B5C232C6541F15AEF3C4BC /* StripeIdentity.framework in Frameworks */, + 45B515AC217F10059EE155E2 /* iOSSnapshotTestCase in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 00D6B9FD0E5C501B4BF57EF9 /* StripeIdentityTests */ = { + isa = PBXGroup; + children = ( + F70954CA70A6D39896F5E2B5 /* Helpers */, + 21094F500C248952675A92E7 /* Mock Files */, + 6C8A36498C59C6AA87239ADC /* Snapshot */, + C240F6CABCF2EAA5A9F29733 /* Unit */, + F0CB25FB0857C39B915505DF /* Info.plist */, + ); + path = StripeIdentityTests; + sourceTree = ""; + }; + 0DB7E4B2463D591EEF46036C /* DocumentScanner */ = { + isa = PBXGroup; + children = ( + C74D7B9CB1FF34AE667C65B3 /* DocumentScanner.swift */, + A5DE93D9C60DFB779F71D5DC /* DocumentScannerConfiguration.swift */, + 48E1204F31DC9F8FEA0D312A /* DocumentScannerOutput.swift */, + ); + path = DocumentScanner; + sourceTree = ""; + }; + 1839EB588B1D039EC1FC1671 /* BuildConfigurations */ = { + isa = PBXGroup; + children = ( + 4F6B26A21285054BEE9CA244 /* Project-Debug.xcconfig */, + A163E1EC771058E5DF5FFE66 /* Project-Release.xcconfig */, + 8605B809AD7EBA140CC9DBE9 /* StripeiOS Tests-Debug.xcconfig */, + 434BC5FFBC1163872413A40A /* StripeiOS Tests-Release.xcconfig */, + B17C20418C972E7B06B84370 /* StripeiOS-Debug.xcconfig */, + E850C3F1FEA42490F1B3761E /* StripeiOS-Release.xcconfig */, + ); + name = BuildConfigurations; + path = ../BuildConfigurations; + sourceTree = ""; + }; + 1F0F886BEAFD97F97250E0A6 /* API Bindings */ = { + isa = PBXGroup; + children = ( + C79CD3902BA22B78A8E4A03E /* Models */, + CC4AE39C12E613AFB80FF4E8 /* DocumentScanner+API.swift */, + EE1984CE45A111B5694A3F49 /* DocumentType+StripeIdentity.swift */, + 643CB84029E6A12167838C3A /* DocumentUploader+API.swift */, + 725E481300FDDDD41EBAA835 /* FaceScanner+API.swift */, + EEF2BB05DF09AABFEEC1C66D /* IdentityAPIClient.swift */, + 8EFF781FE9ED055F99F767D3 /* SelfieUploader+API.swift */, + ); + path = "API Bindings"; + sourceTree = ""; + }; + 21094F500C248952675A92E7 /* Mock Files */ = { + isa = PBXGroup; + children = ( + 4A0742257602474CE098C00E /* Mock Photos */, + 281E4F7E9C4222212FE549EA /* VerificationPage */, + 7072DCFE3A5741D38837C873 /* VerificationPageData */, + ADA1D4AB1AE9B0E45C68FFD2 /* mock.html */, + ); + path = "Mock Files"; + sourceTree = ""; + }; + 281E4F7E9C4222212FE549EA /* VerificationPage */ = { + isa = PBXGroup; + children = ( + 7ECA816022703F05AD759897 /* VerificationPage_200_no_consent_header.json */, + E0B9A86D071960D13AE81563 /* VerificationPage_200_submitted.json */, + 16ADAC122F0818BB64C5EF41 /* VerificationPage_200_testMode.json */, + 3218882B7E1610E17BDB4522 /* VerificationPage_200.json */, + D5C7698331B899B80AF4F730 /* VerificationPage_no_selfie.json */, + 7FD2B7B0EDA3144115F080BC /* VerificationPage_require_live_capture.json */, + EA7EF0C427E327A1E945BC28 /* VerificationPage_type_address.json */, + 2FC99DD88994E0DB581E19C6 /* VerificationPage_type_doc_require_address.json */, + 531D9220055F0F54CF6D72CE /* VerificationPage_type_doc_require_idNumber_and_address.json */, + CCB84457F36C8EA327239852 /* VerificationPage_type_doc_require_idNumber.json */, + FCD3770DF9B6356D1A82870A /* VerificationPage_type_idNumber.json */, + E21D37D0CA9F5426F6318ADC /* VerificationPage_type_phone.json */, + ); + path = VerificationPage; + sourceTree = ""; + }; + 2EBA502F418D648EEF72EDB9 /* Detectors */ = { + isa = PBXGroup; + children = ( + 71CAD90E0B821CC823D976E9 /* FaceDetector */, + E32A0AA1EF4E64B83646D59C /* IDDetector */, + 63826305A96644687BF980CC /* BarcodeDetector.swift */, + 2061B962162CC2D8C4C6494C /* LaplacianBlurDetector.swift */, + 18593C820FE0E35DDEE36956 /* MLDetectorConfiguration.swift */, + 0989CE5CF80CF69BD28675DE /* MLDetectorMetricsTracker.swift */, + 57F22CC7DFA4B98A1A533CE0 /* MotionBlurDetector.swift */, + 9E170B07B4958901EE3864A4 /* VisionBasedDetector.swift */, + ); + path = Detectors; + sourceTree = ""; + }; + 33C89482CBE5232226CC592E /* ListView */ = { + isa = PBXGroup; + children = ( + B3BED2C3A9B548341D73D3CC /* ListItemView.swift */, + 5D4A09C8B4EBF06D00B80994 /* ListView.swift */, + ); + path = ListView; + sourceTree = ""; + }; + 33CCD5EF14F04A59D399E045 /* Coordinators */ = { + isa = PBXGroup; + children = ( + 8FE476C4E5F3E281B7E6E136 /* DocumentUploaderTest.swift */, + F08E49ED4B7010002D5FC734 /* IdentityImageUploaderTest.swift */, + 4D5C67D8BB38BC3EA6282FF2 /* IdentityVerificationSheetTest.swift */, + 7FB8B409BCEBD89987293A94 /* VerificationSheetControllerTest.swift */, + A69C1BC7C63512FE24B32433 /* VerificationSheetFlowControllerTest.swift */, + ); + path = Coordinators; + sourceTree = ""; + }; + 377C902DA22E0F9853BF81F4 /* FaceScanner */ = { + isa = PBXGroup; + children = ( + C4943A54EF548F28DB66DD1F /* FaceCaptureData.swift */, + 4D2F49DD0BAC5FEF308BA165 /* FaceScanner.swift */, + 29E0AD187733319776268351 /* FaceScannerConfiguration.swift */, + E3E90C7076A10469E4893B73 /* FaceScannerOutput.swift */, + ); + path = FaceScanner; + sourceTree = ""; + }; + 3B9E0DCE151FFC05A3D3E05B /* VerificationPage */ = { + isa = PBXGroup; + children = ( + 69753FF8ED7304500D4EFFB9 /* VerificationPage.swift */, + A2C25D8AD61094AB6530BCA4 /* VerificationPageFieldType.swift */, + A676387474C2E5684F1A654D /* VerificationPageRequirements.swift */, + C84088B48BC2EA950AA25D2F /* VerificationPageStaticContentConsentPage.swift */, + A5750A42C108C4FBA7F735B4 /* VerificationPageStaticContentCountryNotListedPage.swift */, + 7B095FAF27C9C47DD4EDF5E4 /* VerificationPageStaticContentDocumentCaptureModels.swift */, + 6702A1AB7FBCD89A356A86FE /* VerificationPageStaticContentDocumentCapturePage.swift */, + FB9BD2014C4A16821A2B8DC1 /* VerificationPageStaticContentDocumentSelectPage.swift */, + 4375E0DF02F4D0F09860AFE0 /* VerificationPageStaticContentIndividualPage.swift */, + A8A8BACD4BA8E4E7DF039DAE /* VerificationPageStaticContentIndividualWelcomePage.swift */, + 75CA839380FD9056B4636C9C /* VerificationPageStaticContentPhoneOtpPage.swift */, + 2B35B47EE955CE3D6A670CA0 /* VerificationPageStaticContentSelfieModels.swift */, + AAB01766577CAB2CF0C2CFFB /* VerificationPageStaticContentSelfiePage.swift */, + 6EB06216204F856766C67E18 /* VerificationPageStaticContentTextPage.swift */, + ); + path = VerificationPage; + sourceTree = ""; + }; + 4A0742257602474CE098C00E /* Mock Photos */ = { + isa = PBXGroup; + children = ( + 9048C4B9F47CE82127D6C1B3 /* back_drivers_license.jpg */, + 6F772F905FB81552FA1728B2 /* cgimage_stripeidentity_test.png */, + 280A8D95B0045A940A917B1A /* front_drivers_license.jpg */, + 7CD11009097B994FEFF5393E /* header_icon.png */, + ); + path = "Mock Photos"; + sourceTree = ""; + }; + 4A93B9E4F9CD7EF6BC8D8202 /* Selfie */ = { + isa = PBXGroup; + children = ( + 2FEB1C2A314572B0234005A7 /* SelfieCaptureView.swift */, + 18BE1F9B0EB046BF0E1C2D8B /* SelfieScanningView.swift */, + 4023102F8B76C16522BC02B9 /* SelfieWarmupView.swift */, + ); + path = Selfie; + sourceTree = ""; + }; + 4CC5469F73EDF726A534C43A /* Source */ = { + isa = PBXGroup; + children = ( + 6FF221966B2E1A26EB5771B8 /* Analytics */, + 1F0F886BEAFD97F97250E0A6 /* API Bindings */, + A15C667249E105B9A37EC6AA /* Categories */, + E4939A3B0BCBB7B44F717847 /* Elements */, + 882FA766B74C9E5C2E2F5382 /* Helpers */, + 921749968993B6299670C8F0 /* NativeComponents */, + 8B9E16932812CEDA69E79947 /* WebWrapper */, + E26F8082A859AC1F5C5A3D13 /* Enums+CustomStringConvertible.swift */, + 53F1B93DB4F1708B41125301 /* IdentityVerificationSheet.swift */, + 3DD5FEA0E4E7EF1A4BA9646B /* IdentityVerificationSheetError.swift */, + 45CA21A528C7D39BAA2F2B51 /* StripeCore+Import.swift */, + 1E01DDEABC6ABAB0F03B7CEC /* VerificationClientSecret.swift */, + E70585F354B2B8B1420311CB /* VerificationSheetAnalytics.swift */, + ); + path = Source; + sourceTree = ""; + }; + 4F960B140ECD6032C9036EC6 /* VerificationPageData */ = { + isa = PBXGroup; + children = ( + B3B539D323CF2AACB580596B /* VerificationPageData.swift */, + B2E15397783A643B74921A00 /* VerificationPageDataRequirementError.swift */, + 1A566C584F41FE23108295E3 /* VerificationPageDataRequirements.swift */, + ); + path = VerificationPageData; + sourceTree = ""; + }; + 500DB8B56AFF3E5D762BA726 /* StripeIdentity */ = { + isa = PBXGroup; + children = ( + DD11F7A6C34DDED8661D6182 /* Resources */, + 4CC5469F73EDF726A534C43A /* Source */, + 80C34F71161E8793947E18E7 /* Info.plist */, + 9C69B4A6F36A8BD814CB05D9 /* StripeIdentity.h */, + ); + path = StripeIdentity; + sourceTree = ""; + }; + 6C8A36498C59C6AA87239ADC /* Snapshot */ = { + isa = PBXGroup; + children = ( + A31C88110FBCDDA9FB912491 /* AnimatedBorderViewSnapshotTest.swift */, + 957545FFD547CDA1CFB719E6 /* BiometricConsentViewControllerSnapshotTest.swift */, + 1096AFFD3877D9A22703E6CA /* CGImage_StripeIdentitySnapshotTest.swift */, + 30748D6B84FC605EE612C2D3 /* DebugViewControllerSnapshotTest.swift */, + B67F4A7C86FF6784C6844649 /* DocumentScanningViewSnapshotTest.swift */, + 4A1FCF877D109689CA724EB4 /* ErrorViewSnapshotTest.swift */, + 03C30DB2230DF9B47CD20AAF /* HeaderIconViewSnapshotTest.swift */, + 94838BCA1111F735A8FBC072 /* HeaderViewSnapshotTest.swift */, + 1E7C993DB897B55B2A172CD9 /* IdentityFlowViewSnapshotTest.swift */, + A2AF434F2405C5F38F259112 /* IdentityHTMLViewSnapshotTest.swift */, + 9E703FD28F5352DFC4115F1C /* IndividualViewControllerSnapshotTest.swift */, + A99CDE8D3C390BCAEF13F938 /* IndividualWelcomeViewControllerSnapshotTest.swift */, + 01C54327529E9A3BD81F37F4 /* InstructionalDocumentScanningViewSnapshotTest.swift */, + 8389CF3DBB12A384BCF0959F /* InstructionListViewSnapshotTest.swift */, + B65B0A0F124B3E0C5490D2B6 /* ListItemViewSnapshotTest.swift */, + C8D82EAF85863CAAA250E393 /* ListViewSnapshotTest.swift */, + D95CE7A231D14C9FD32A7848 /* NSAttributedString_HTMLSnapshotTest.swift */, + 8DAFC23BB4CDD7DEB3286704 /* PhoneOtpViewControllerSnapshotTest.swift */, + F95321B67AA78B88A32E37BE /* SelfieCaptureViewSnapshotTest.swift */, + AD7373453206D23A9ABF5E17 /* SelfieScanningViewSnapshotTest.swift */, + 21DB72C7F61A4A042D898236 /* SelfieWarmupViewSnapshotTest.swift */, + 8FF19274A29802CDB696D1DF /* SuccessViewControllerSnapshotTest.swift */, + 22A64D0C0989BD43624BB506 /* VerificationFlowWebViewSnapshotTests.swift */, + ); + path = Snapshot; + sourceTree = ""; + }; + 6FF221966B2E1A26EB5771B8 /* Analytics */ = { + isa = PBXGroup; + children = ( + BCC019FA1D6D8AEB598D9EC5 /* IdentityAnalyticsClient.swift */, + ); + path = Analytics; + sourceTree = ""; + }; + 7018612D148DB5EC9137EEDF /* WebWrapper */ = { + isa = PBXGroup; + children = ( + B2EB7348CE21DB1A86706532 /* VerificationFlowWebViewControllerTest.swift */, + E91362066B0F066C00CB45F2 /* VerificationFlowWebViewTest.swift */, + ); + path = WebWrapper; + sourceTree = ""; + }; + 7072DCFE3A5741D38837C873 /* VerificationPageData */ = { + isa = PBXGroup; + children = ( + B75F0E4F340355B8661085F7 /* VerificationPageData_200.json */, + 9D112C14362D55A94E9B9E6E /* VerificationPageData_no_errors_needback.json */, + 3AA3F3B346FFC8E58BF8739A /* VerificationPageData_no_errors.json */, + B38AE7F15E61D960EFA15659 /* VerificationPageData_submitted_not_closed.json */, + 99607F36AF24C978953964E9 /* VerificationPageData_submitted.json */, + ); + path = VerificationPageData; + sourceTree = ""; + }; + 71CAD90E0B821CC823D976E9 /* FaceDetector */ = { + isa = PBXGroup; + children = ( + B9BD50679E8B87237BF3955E /* FaceDetector.swift */, + BD9BA1457F98160EBCBEE670 /* FaceDetectorOutput.swift */, + ); + path = FaceDetector; + sourceTree = ""; + }; + 735252221308FED2A1BB2982 /* Project */ = { + isa = PBXGroup; + children = ( + 1839EB588B1D039EC1FC1671 /* BuildConfigurations */, + 500DB8B56AFF3E5D762BA726 /* StripeIdentity */, + 00D6B9FD0E5C501B4BF57EF9 /* StripeIdentityTests */, + ); + name = Project; + sourceTree = ""; + }; + 7B6CB500A83165695FFF5CE4 /* IdentityHTMLView */ = { + isa = PBXGroup; + children = ( + 520D8A1D20ACE21966E806D9 /* HTMLTextView.swift */, + 36ED32D7EE8F52D6FC7D8826 /* HTMLViewWithIconLabels.swift */, + 93A9C204870754D960586E10 /* IconLabelHTMLView.swift */, + ); + path = IdentityHTMLView; + sourceTree = ""; + }; + 87394A902F17C225C9801233 /* ML */ = { + isa = PBXGroup; + children = ( + FB26528E34B8AFBD225061AF /* Helpers */, + 4546F9D432ACFF45E9CE1368 /* IdentityMLModelLoader.swift */, + ); + path = ML; + sourceTree = ""; + }; + 8809E2951514DA7B6B8E9C7F /* Categories */ = { + isa = PBXGroup; + children = ( + 4DEBC1A25D2C446219E7E733 /* CGImage+StripeIdentityUnitTest.swift */, + ); + path = Categories; + sourceTree = ""; + }; + 881271B2FC813730D937B96E /* ViewControllers */ = { + isa = PBXGroup; + children = ( + A20A2B98A36D6A860B39E615 /* BiometricConsentViewController.swift */, + 66AA83F1165CFCCFDAF5D850 /* CountryNotListedViewController.swift */, + CF3CE83043E6DE26CE8F9C0E /* DebugViewController.swift */, + 99D92831ABFEBB1027419225 /* DocumentCaptureViewController.swift */, + 68FE94303A1D22C03D3157CE /* DocumentCaptureViewController+Strings.swift */, + EB927B052ADBE97524CC0F43 /* DocumentFileUploadViewController.swift */, + 14534C235EF7B57DDB6B74F1 /* DocumentFileUploadViewController+Strings.swift */, + 9910B88643D95B2F9329E4A2 /* DocumentTypeSelectViewController.swift */, + 5A9F73BBC3ECDBAE226012A5 /* ErrorViewController.swift */, + EBE61B9C03DCE61E57CEC3F4 /* IdentityFlowViewController.swift */, + B71F1C5085AD91100943CFCA /* IndividualViewController.swift */, + C4EB79390ADC3CFC714E404D /* IndividualWelcomeViewController.swift */, + 3B48A1FCF125B2844F8CAD43 /* LoadingViewController.swift */, + 431967DEC3B17F8C9125E991 /* PhoneOtpViewController.swift */, + 67BFE71088C74C03B05CBCD2 /* SelfieCaptureViewController.swift */, + 328DF3C5E53973F6943FC4BD /* SelfieCaptureViewController+Strings.swift */, + 8D71B9B5C3055A361ABDBFB9 /* SelfieWarmupViewController.swift */, + DC12E83001A164E12705498A /* SuccessViewController.swift */, + ); + path = ViewControllers; + sourceTree = ""; + }; + 882FA766B74C9E5C2E2F5382 /* Helpers */ = { + isa = PBXGroup; + children = ( + 20D40A569C2BB62F86D57C01 /* Image.swift */, + E94605BCADE912561ED6F710 /* STPLocalizedString.swift */, + F1559E966A487EE3867DD7B8 /* String+Localized.swift */, + CEE8340912F2A489BB80D39B /* StripeIdentityBundleLocator.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 8A94C8D885B3CEA5BBB73D84 /* Localizations */ = { + isa = PBXGroup; + children = ( + 989A2C5379C60B9F0E0E2711 /* Localizable.strings */, + ); + path = Localizations; + sourceTree = ""; + }; + 8B9E16932812CEDA69E79947 /* WebWrapper */ = { + isa = PBXGroup; + children = ( + 658F7A165D731F5046ACDF29 /* VerificationFlowWebView.swift */, + 48A449D0BE66D0A555183AF4 /* VerificationFlowWebViewController.swift */, + D75A138199A6CC2DB31DD2D5 /* VerifyWebURLHelper.swift */, + ); + path = WebWrapper; + sourceTree = ""; + }; + 921749968993B6299670C8F0 /* NativeComponents */ = { + isa = PBXGroup; + children = ( + D9B3846C593CC86E22146C09 /* Coordinators */, + 2EBA502F418D648EEF72EDB9 /* Detectors */, + 87394A902F17C225C9801233 /* ML */, + 881271B2FC813730D937B96E /* ViewControllers */, + A1E77974EEA3CA7771B4F624 /* Views */, + E310DFA6CAC355B848E82998 /* DocumentSide.swift */, + B9CB453179082B1AE6B242FD /* IdentityDataCollecting.swift */, + 050900C738C01F6D96F7ED5B /* IdentityFlowNavigationController.swift */, + FEB6AB9A9BAF6E50B962C339 /* IdentityUI.swift */, + ); + path = NativeComponents; + sourceTree = ""; + }; + 9C674AEFDAF7791E3C23E052 /* Images */ = { + isa = PBXGroup; + children = ( + 9431C816BF67F677B9447746 /* icon_add@3x.png */, + 416BCE5C7BD7869793D0B75A /* icon_camera@3x.png */, + F696493D59EE069EB76B0CB4 /* icon_checkmark_92@3x.png */, + 0CD0A4F2BB147D41D2DF6976 /* icon_checkmark@3x.png */, + C3288667B33AACBC2380CD9D /* icon_clock@3x.png */, + 188E601955C27EA3E98012AF /* icon_info@3x.png */, + 53D07B5755756AB93F69256A /* icon_selfie_warmup@3x.png */, + 9DBB998F589E062218E0BD90 /* icon_warning_92@3x.png */, + 1002F1EA113FC18896C55CDB /* icon_warning@3x.png */, + DF85EEB9A816B13A485F7382 /* icon_warning2@3x.png */, + ); + path = Images; + sourceTree = ""; + }; + A15C667249E105B9A37EC6AA /* Categories */ = { + isa = PBXGroup; + children = ( + CBC057ECE030E8374AD9CF61 /* Array+StripeIdentity.swift */, + 744CE13E8488F0E4AC951D6C /* CGImage+StripeIdentity.swift */, + 82D731669A9237F0B147B366 /* MLMultiArray+StripeIdentity.swift */, + 42768A09155B5439E724B360 /* NSAttributedString+HTML.swift */, + 2E5237659945E4A05ADE6DB0 /* TimeInterval+StripeIdentity.swift */, + FF0871DEB2B2BA56EFADEEA6 /* UINavigationController+StripeIdentity.swift */, + C3C78174C0059A8957832825 /* VNBarcodeSymbology+StripeIdentity.swift */, + ); + path = Categories; + sourceTree = ""; + }; + A1E77974EEA3CA7771B4F624 /* Views */ = { + isa = PBXGroup; + children = ( + E8F859A98FD354C4D369663B /* DocumentCapture */, + 7B6CB500A83165695FFF5CE4 /* IdentityHTMLView */, + 33C89482CBE5232226CC592E /* ListView */, + 4A93B9E4F9CD7EF6BC8D8202 /* Selfie */, + B5EE118A0368B2BBC342F162 /* BottomAlignedLabel.swift */, + 7E204A65850D7EC60AB779B5 /* CameraPreviewContainerView.swift */, + 2EDD22C65A41EB87DD9C96DF /* CompleteOptionView.swift */, + BB10B7416AB6D7F94A57A8E3 /* ContentCenteringScrollView.swift */, + 04E9AACF71C326D42567F09F /* DebugView.swift */, + 198B92E9E0D04CA24F208E07 /* ErrorView.swift */, + 1FB4155E2BD74F6B44F8D68F /* HeaderIconView.swift */, + C48F9BFEB0E03E881C2D6017 /* HeaderView.swift */, + 8BBFB1E6CDEA9002DB6353E7 /* IdentityFlowView.swift */, + 423EA472FB8902C1ED6EBA60 /* InstructionListView.swift */, + 9CDF2978746F82F5C5D169BA /* PhoneOtpView.swift */, + 2AB451EC826E856E6BCB7070 /* ShadowConfiguration.swift */, + EE238D8B6DAA92980E1F813A /* ShadowedCorneredImageView.swift */, + ); + path = Views; + sourceTree = ""; + }; + B676F5058454EA479CE2B699 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 8AB1A0B05C00E547ED1A0D09 /* XCTest.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + BC502BBBD4439DAD6B20C36B /* Elements */ = { + isa = PBXGroup; + children = ( + 6FF56E95E47DA51C3517E1EC /* IdentityElementsFactoryTest.swift */, + 85E8B042346D6DF41AA6CCFC /* IndividualElementTest.swift */, + ); + path = Elements; + sourceTree = ""; + }; + BD87721D7BE91AA7EA25E952 /* ImageScanningSession */ = { + isa = PBXGroup; + children = ( + 0F99DB710C60AC2A98449D51 /* ImageScanningSession.swift */, + FFE163E90F54FE4A075C7B74 /* ImageScanningSessionDelegate.swift */, + ); + path = ImageScanningSession; + sourceTree = ""; + }; + C240F6CABCF2EAA5A9F29733 /* Unit */ = { + isa = PBXGroup; + children = ( + FA68623DC1EA02B69E91D537 /* API Bindings */, + 8809E2951514DA7B6B8E9C7F /* Categories */, + BC502BBBD4439DAD6B20C36B /* Elements */, + E05EEC720175CFA2B7B02DF9 /* NativeComponents */, + 7018612D148DB5EC9137EEDF /* WebWrapper */, + FFD771C14C0711019D8AC9C2 /* VerificationClientSecretTest.swift */, + 09F06CA1B36DA48B8E8BD7A5 /* VerificationSheetAnalyticsTest.swift */, + ); + path = Unit; + sourceTree = ""; + }; + C30D6A52DEFD4953D1003B59 = { + isa = PBXGroup; + children = ( + 735252221308FED2A1BB2982 /* Project */, + B676F5058454EA479CE2B699 /* Frameworks */, + DA1A05D0BE4F60655BC0E2CD /* Products */, + ); + sourceTree = ""; + }; + C79CD3902BA22B78A8E4A03E /* Models */ = { + isa = PBXGroup; + children = ( + 3B9E0DCE151FFC05A3D3E05B /* VerificationPage */, + 4F960B140ECD6032C9036EC6 /* VerificationPageData */, + DD82B7FB4D8A2A274B406258 /* VerificationPageDataUpdate */, + 3AFAC1FACF9A36AAFC19BF2F /* DocumentType.swift */, + 7F9DA29DC85773AC710A5201 /* TruncatedDecimal.swift */, + ); + path = Models; + sourceTree = ""; + }; + C9888FDFA7892093618E47C9 /* ViewControllers */ = { + isa = PBXGroup; + children = ( + 1B264A8F97FA8CB0706F140F /* BiometricConsentViewControllerTest.swift */, + DEB322F07A4F2BE5B1FED0AD /* DebugViewControllerTest.swift */, + BCA7F9B0204BF18C483A474A /* DocumentCaptureViewControllerTest.swift */, + 5F0B317274417308FBA55E55 /* DocumentFileUploadViewControllerTest.swift */, + D28361A39FEF902EF979BEEE /* DocumentTypeSelectViewControllerTest.swift */, + 20129AFBC6B4D304B494CBA6 /* ErrorViewControllerTest.swift */, + 22681AA4BBA6A907A0DD29DB /* IdentityFlowNavigationControllerTest.swift */, + 9145699AC820A2F2F916F142 /* IndividualViewControllerTest.swift */, + E4952611570326FFC52F0F30 /* IndividualWelcomeViewControllerTest.swift */, + 12F4C291ACBB73ED95504131 /* PhoneOtpViewControllerTest.swift */, + 4AFCD229BD9CB58303E19111 /* SelfieWarmupViewControllerTest.swift */, + ); + path = ViewControllers; + sourceTree = ""; + }; + D20B93DC17B56E6A4B98A3B0 /* ImageUploaders */ = { + isa = PBXGroup; + children = ( + C334F59C93D3D11534FACBBA /* DocumentUploader.swift */, + 4D4C3F2BAFB6BEFFFC1B566F /* IdentityImageUploader.swift */, + 9BB8E3DF263505065E8545AB /* SelfieUploader.swift */, + ); + path = ImageUploaders; + sourceTree = ""; + }; + D9B3846C593CC86E22146C09 /* Coordinators */ = { + isa = PBXGroup; + children = ( + F2A0B1EE1A4A9208812559DF /* ImageScanner */, + BD87721D7BE91AA7EA25E952 /* ImageScanningSession */, + D20B93DC17B56E6A4B98A3B0 /* ImageUploaders */, + 467BA77070B91055F1F2DCD4 /* IdentityTopLevelDestination.swift */, + A5082A4100CEE0C8378AD185 /* VerificationSheetController.swift */, + 98AF8558B79CD52154D6EF3B /* VerificationSheetFlowController.swift */, + 7CDAA5C16C934746013C6CE6 /* VerificationSheetFlowControllerError.swift */, + ); + path = Coordinators; + sourceTree = ""; + }; + DA1A05D0BE4F60655BC0E2CD /* Products */ = { + isa = PBXGroup; + children = ( + CB639F497EF62CAE81822820 /* StripeCameraCore.framework */, + 14C231755842F322A25160ED /* StripeCameraCoreTestUtils.framework */, + 4D0F4646D7565055396DD8F5 /* StripeCore.framework */, + 5726517468179B69D50692C0 /* StripeCoreTestUtils.framework */, + D604F6AABE4D6D5CCF802685 /* StripeIdentity.framework */, + A5D517A4C8669379A029BBAD /* StripeIdentityTests.xctest */, + FD376CBC869531C40036081B /* StripeUICore.framework */, + ); + name = Products; + sourceTree = ""; + }; + DD11F7A6C34DDED8661D6182 /* Resources */ = { + isa = PBXGroup; + children = ( + 9C674AEFDAF7791E3C23E052 /* Images */, + 8A94C8D885B3CEA5BBB73D84 /* Localizations */, + ); + path = Resources; + sourceTree = ""; + }; + DD82B7FB4D8A2A274B406258 /* VerificationPageDataUpdate */ = { + isa = PBXGroup; + children = ( + 40F28FD09DBAFAEB609EAD25 /* RequiredInternationalAddress.swift */, + E1FC19E901C6E57D948833FD /* VerificationPageClearData.swift */, + 1F4F55F9FBE67FCFE3767816 /* VerificationPageCollectedData.swift */, + A6E9FA49B13AC0A8826F8257 /* VerificationPageDataDob.swift */, + 083865663173CDD9F77417A4 /* VerificationPageDataDocumentFileData.swift */, + 14244B002D9F37B56BE8D4F4 /* VerificationPageDataFace.swift */, + 50A49996D90714452E6A533A /* VerificationPageDataIdNumber.swift */, + 110A0200099F462B576F2ED4 /* VerificationPageDataName.swift */, + 9429D10FEFFFEFEE08DBA4F8 /* VerificationPageDataPhone.swift */, + 15E15D64B5F7EF76235D0BA5 /* VerificationPageDataUpdate.swift */, + ); + path = VerificationPageDataUpdate; + sourceTree = ""; + }; + E05EEC720175CFA2B7B02DF9 /* NativeComponents */ = { + isa = PBXGroup; + children = ( + 33CCD5EF14F04A59D399E045 /* Coordinators */, + C9888FDFA7892093618E47C9 /* ViewControllers */, + ); + path = NativeComponents; + sourceTree = ""; + }; + E32A0AA1EF4E64B83646D59C /* IDDetector */ = { + isa = PBXGroup; + children = ( + BCB41D1D6B1D35C92EA6F2B7 /* IDDetector.swift */, + 14ED7B7A9CC6DE447C14B5CB /* IDDetectorConstants.swift */, + 2CDF024DA4F519CB21A717AA /* IDDetectorOutput.swift */, + ); + path = IDDetector; + sourceTree = ""; + }; + E4939A3B0BCBB7B44F717847 /* Elements */ = { + isa = PBXGroup; + children = ( + 31CC3450DDBE415B82707F4B /* IdentityElementsFactory.swift */, + 8E1EF4A7AA64856F9C887C2A /* IdentityTextButtonElement.swift */, + EB73F630CCAFE6AAB9727C62 /* IdNumberElement.swift */, + E0CF1B086B99DEEF4F4F2A1D /* IndividualFormElement.swift */, + ); + path = Elements; + sourceTree = ""; + }; + E8F859A98FD354C4D369663B /* DocumentCapture */ = { + isa = PBXGroup; + children = ( + 0EA099682E4BE0A34CFCBE6C /* AnimatedBorderView.swift */, + 8AF040F11A7D0693A7783654 /* DocumentCaptureView.swift */, + D6A26F37BDBB5330AE87E7CB /* DocumentScanningView.swift */, + 1CDF8D78C430D9B26DD92B11 /* InstructionalDocumentScanningView.swift */, + ); + path = DocumentCapture; + sourceTree = ""; + }; + F2A0B1EE1A4A9208812559DF /* ImageScanner */ = { + isa = PBXGroup; + children = ( + 0DB7E4B2463D591EEF46036C /* DocumentScanner */, + 377C902DA22E0F9853BF81F4 /* FaceScanner */, + 149453E1854C9566CC8D8484 /* ImageScanner.swift */, + DC8F619B76D191FF2DEC8660 /* ImageScanningConcurrencyManager.swift */, + ); + path = ImageScanner; + sourceTree = ""; + }; + F70954CA70A6D39896F5E2B5 /* Helpers */ = { + isa = PBXGroup; + children = ( + CE691D40B29AB0F31443479A /* DocumentUploaderMock.swift */, + C43D3DC62FE3C5CC9FD22A50 /* IdentityAnalyticsClientTestHelpers.swift */, + C8D0D5F9E17825856B55E4D1 /* IdentityAPIClientTestMock.swift */, + 111A55FA39B400E6E87A5175 /* IdentityMLModelLoaderMock.swift */, + 738A5EB6B7F1C5621F190A72 /* IdentityMockData.swift */, + 4C0B804DA867C37809656BB3 /* ImageScannerMock.swift */, + 005D7E5D0E239D61ED1E9796 /* ImageScanningConcurrencyManagerMock.swift */, + 786574907AC9065E6E658D9C /* MLDetectorMetricsTrackerMock.swift */, + 037D27A0AAECB846C8579452 /* SnapshotTestMockData.swift */, + 52FB883B057ED79F2C58BC58 /* VerificationFlowResult+Equatable.swift */, + F459D2DF594C15C03585F3AF /* VerificationSheetControllerMock.swift */, + 1CAC6276996043D1B3FC92F7 /* VerificationSheetFlowControllerMock.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + FA68623DC1EA02B69E91D537 /* API Bindings */ = { + isa = PBXGroup; + children = ( + A59680E002FC6E7BC9906BD2 /* IdentityAPIClientTest.swift */, + D6918DC6A9681E769D44BC78 /* TruncatedDecimalTest.swift */, + ); + path = "API Bindings"; + sourceTree = ""; + }; + FB26528E34B8AFBD225061AF /* Helpers */ = { + isa = PBXGroup; + children = ( + 748D38C4370E78DBC9E20E95 /* MLModelLoader.swift */, + 2FD2C7D94586AB8984DB7389 /* MLModelUnexpectedOutputError.swift */, + 93F2EE0B99059C7BFB72AFAF /* NonMaxSuppression.swift */, + ); + path = Helpers; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 9B1562BF42FD34FD2D6229B6 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 6A06F81B7D6B68EA72A293F5 /* StripeIdentity.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 5883CCEAA4C73E1DB1F0D3A5 /* StripeIdentityTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7C14F7F327D79A07F63042E0 /* Build configuration list for PBXNativeTarget "StripeIdentityTests" */; + buildPhases = ( + 2B7015585124CD15B8ECF68A /* Sources */, + 2FB257609A15689D3483EA9B /* Resources */, + 048FEFC038A0C2BC99CCB683 /* Embed Frameworks */, + 942AEFA5BCC5A034EF066C3E /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 90F68D87EFF3F3EE0E36B03A /* PBXTargetDependency */, + ); + name = StripeIdentityTests; + packageProductDependencies = ( + 960247DC321CBDB422FE713D /* iOSSnapshotTestCase */, + ); + productName = StripeIdentityTests; + productReference = A5D517A4C8669379A029BBAD /* StripeIdentityTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + F251015845B21C036CFBC636 /* StripeIdentity */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9E28A14ACC690D35FD59690A /* Build configuration list for PBXNativeTarget "StripeIdentity" */; + buildPhases = ( + 9B1562BF42FD34FD2D6229B6 /* Headers */, + 3DCE29146A781338C4D3AC2A /* Sources */, + A037CEBE08A7F64ADB4986B9 /* Resources */, + 559B276C9F23AEDE2D54B9A8 /* Embed Frameworks */, + 65EE834154FF7B955E7239D7 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = StripeIdentity; + productName = StripeIdentity; + productReference = D604F6AABE4D6D5CCF802685 /* StripeIdentity.framework */; + productType = "com.apple.product-type.framework"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 0705CAA185B63201FD561508 /* Project object */ = { + isa = PBXProject; + attributes = { + TargetAttributes = { + }; + }; + buildConfigurationList = 8BD83D322879659AB98AEA29 /* Build configuration list for PBXProject "StripeIdentity" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + "bg-BG", + "ca-ES", + "cs-CZ", + da, + de, + "el-GR", + en, + "en-GB", + es, + "es-419", + "et-EE", + fi, + fil, + fr, + "fr-CA", + hr, + hu, + id, + it, + ja, + ko, + "lt-LT", + "lv-LV", + "ms-MY", + mt, + nb, + nl, + "nn-NO", + "pl-PL", + "pt-BR", + "pt-PT", + "ro-RO", + ru, + "sk-SK", + "sl-SI", + sv, + tr, + vi, + "zh-HK", + "zh-Hans", + "zh-Hant", + ); + mainGroup = C30D6A52DEFD4953D1003B59; + packageReferences = ( + 1034C700B96E0B79C2107621 /* XCRemoteSwiftPackageReference "ios-snapshot-test-case" */, + ); + productRefGroup = DA1A05D0BE4F60655BC0E2CD /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + F251015845B21C036CFBC636 /* StripeIdentity */, + 5883CCEAA4C73E1DB1F0D3A5 /* StripeIdentityTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 2FB257609A15689D3483EA9B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6C1E79C83069CDD64BF03563 /* back_drivers_license.jpg in Resources */, + 443F1D1D9FE899158D58461E /* cgimage_stripeidentity_test.png in Resources */, + 471F567D8201BBA6D82D9932 /* front_drivers_license.jpg in Resources */, + BDF8D30919A290A0CF52059C /* header_icon.png in Resources */, + 135B58FCD4663D3D8E4C82F7 /* VerificationPage_200.json in Resources */, + 7F18785CB587E7F36288BF02 /* VerificationPage_200_no_consent_header.json in Resources */, + C258E3B8806DFD2C288089DE /* VerificationPage_200_submitted.json in Resources */, + 6195A200621E125B15A80285 /* VerificationPage_200_testMode.json in Resources */, + E1D4A5DD8C188725C5D23BBB /* VerificationPage_no_selfie.json in Resources */, + 7797883309701F3BB36B1F20 /* VerificationPage_require_live_capture.json in Resources */, + 41B1C1B08CA7DB8C18A7C9C4 /* VerificationPage_type_address.json in Resources */, + 709DB807A26A17B4571EA9D8 /* VerificationPage_type_doc_require_address.json in Resources */, + B6A52680DC3B117DD0EE13E2 /* VerificationPage_type_doc_require_idNumber.json in Resources */, + 89562A38C9B3C7870B5F22F2 /* VerificationPage_type_doc_require_idNumber_and_address.json in Resources */, + 760BDB66D60C8CCC7B72577C /* VerificationPage_type_idNumber.json in Resources */, + 8802941AB1CAFB81BDE75249 /* VerificationPage_type_phone.json in Resources */, + FF3D439778B2EF580053AC15 /* VerificationPageData_200.json in Resources */, + 375C63BC59BFC5F8615E266F /* VerificationPageData_no_errors.json in Resources */, + EF343909D610063574D0F911 /* VerificationPageData_no_errors_needback.json in Resources */, + 3C228BF28D1D56F1E74AE7AB /* VerificationPageData_submitted.json in Resources */, + 6DC8C4AFA61DFDA20278114F /* VerificationPageData_submitted_not_closed.json in Resources */, + C1DF05059A82FE217A7831BA /* mock.html in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A037CEBE08A7F64ADB4986B9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8FEC054A2BF0649B64B6ED4F /* icon_add@3x.png in Resources */, + E32820C9AEBD55B019C1E326 /* icon_camera@3x.png in Resources */, + 1C9FE58DA3777F5B54F3AEC1 /* icon_checkmark@3x.png in Resources */, + 19AADF41AB0794258D06264B /* icon_checkmark_92@3x.png in Resources */, + B38910D7C10E80AED2EB9358 /* icon_clock@3x.png in Resources */, + 7287EFE41CE74C92BCF86218 /* icon_info@3x.png in Resources */, + 99B6D8568AD7A79DD7A1C987 /* icon_selfie_warmup@3x.png in Resources */, + 0F76E6755BCFB254876779E6 /* icon_warning2@3x.png in Resources */, + 2990114AB204BB607DA965A5 /* icon_warning@3x.png in Resources */, + 56E78CD83827B277B998188E /* icon_warning_92@3x.png in Resources */, + 812B45A86DD6B28A06A8FAC7 /* Localizable.strings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 2B7015585124CD15B8ECF68A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 548C24D5A0F63E60BC83676F /* DocumentUploaderMock.swift in Sources */, + F2BBAF668109BC42FF4FCD09 /* IdentityAPIClientTestMock.swift in Sources */, + CB3E51C6E407E6A4A1001AAC /* IdentityAnalyticsClientTestHelpers.swift in Sources */, + 9F3790F6AB77036ADCC4B2C8 /* IdentityMLModelLoaderMock.swift in Sources */, + CFA141490408B4644A4F0375 /* IdentityMockData.swift in Sources */, + 78BC6F625665CE6EAB33EA62 /* ImageScannerMock.swift in Sources */, + BA655B97F1F31F3B8BA14199 /* ImageScanningConcurrencyManagerMock.swift in Sources */, + 5DD28B1BE8DF2CC04E9190E5 /* MLDetectorMetricsTrackerMock.swift in Sources */, + AF48EAD4D52EB59134652A08 /* SnapshotTestMockData.swift in Sources */, + 74701BC18362926FDED42E5A /* VerificationFlowResult+Equatable.swift in Sources */, + A44BC25EF5F443C7D0CBB783 /* VerificationSheetControllerMock.swift in Sources */, + A6E190F4F1E9D599C2422624 /* VerificationSheetFlowControllerMock.swift in Sources */, + 9F07B2D6AEC03BCCBCCF5908 /* AnimatedBorderViewSnapshotTest.swift in Sources */, + A10A4F91CF1ECB610909ECD5 /* BiometricConsentViewControllerSnapshotTest.swift in Sources */, + 54909D14587287B674027374 /* CGImage_StripeIdentitySnapshotTest.swift in Sources */, + CB09C0EFFCBA132257D6914D /* DebugViewControllerSnapshotTest.swift in Sources */, + B729E396A57DC7E1DC3D1FBA /* DocumentScanningViewSnapshotTest.swift in Sources */, + AB4F7B2D348FAA2DD0BB40BD /* ErrorViewSnapshotTest.swift in Sources */, + AF90AF76409959324FB270EA /* HeaderIconViewSnapshotTest.swift in Sources */, + 047B7B3A70037FA1172A164C /* HeaderViewSnapshotTest.swift in Sources */, + 96C17BE1FF29C4A24FAFFDBB /* IdentityFlowViewSnapshotTest.swift in Sources */, + 7C261F9C94175AD5345D384A /* IdentityHTMLViewSnapshotTest.swift in Sources */, + 3B9DB565B6C06BC058BF62D6 /* IndividualViewControllerSnapshotTest.swift in Sources */, + E72C7C6D3632324FED428A68 /* IndividualWelcomeViewControllerSnapshotTest.swift in Sources */, + 25A7B4E2E5CB3FD549F6819B /* InstructionListViewSnapshotTest.swift in Sources */, + BDF0199A4A88D5E66751A033 /* InstructionalDocumentScanningViewSnapshotTest.swift in Sources */, + 683FA0F250620BE62EFA2807 /* ListItemViewSnapshotTest.swift in Sources */, + 2B7C095E1029C163D107CF68 /* ListViewSnapshotTest.swift in Sources */, + FD6FB63B061FEABF17053225 /* NSAttributedString_HTMLSnapshotTest.swift in Sources */, + 869F5D2A387FED3C1D1674BA /* PhoneOtpViewControllerSnapshotTest.swift in Sources */, + B292E22D9DD3198D44F249A7 /* SelfieCaptureViewSnapshotTest.swift in Sources */, + B6D4EF6BB1A9178AFA18675B /* SelfieScanningViewSnapshotTest.swift in Sources */, + 6F896A9A689A7524F2AE823C /* SelfieWarmupViewSnapshotTest.swift in Sources */, + 3E64C28D0E13D6F9F455C3F2 /* SuccessViewControllerSnapshotTest.swift in Sources */, + DCFFA11A461B012F7515941F /* VerificationFlowWebViewSnapshotTests.swift in Sources */, + 230C42264D444023F416A7A1 /* IdentityAPIClientTest.swift in Sources */, + 590772C305CDA9FE9157F92C /* TruncatedDecimalTest.swift in Sources */, + 34C09421E30F9BA262AB1532 /* CGImage+StripeIdentityUnitTest.swift in Sources */, + 3CB793BE23FC9716A0F35CCB /* IdentityElementsFactoryTest.swift in Sources */, + ABC1E2DDC2A4D02702A19E2B /* IndividualElementTest.swift in Sources */, + A41FF7CAF8EBEBCB16115DCB /* DocumentUploaderTest.swift in Sources */, + 34ABF501E050B043CE1E859C /* IdentityImageUploaderTest.swift in Sources */, + A8EDE16782B8ABEB359CADAC /* IdentityVerificationSheetTest.swift in Sources */, + F2F041A9228AFAEFCB8A03B7 /* VerificationSheetControllerTest.swift in Sources */, + 28CAAFE7E31A8D17233615F3 /* VerificationSheetFlowControllerTest.swift in Sources */, + 8E420E7B59168A4B81C19123 /* BiometricConsentViewControllerTest.swift in Sources */, + 81D31513A44A5B3A8830FC7C /* DebugViewControllerTest.swift in Sources */, + 54C882D8194AC39EB58BCDE7 /* DocumentCaptureViewControllerTest.swift in Sources */, + 99243751D031F96B17286315 /* DocumentFileUploadViewControllerTest.swift in Sources */, + 2F13AF5BC25FF74743D55503 /* DocumentTypeSelectViewControllerTest.swift in Sources */, + C63EAF360DB9887856543E68 /* ErrorViewControllerTest.swift in Sources */, + C81F6192AA4A1B30AB1655A4 /* IdentityFlowNavigationControllerTest.swift in Sources */, + 0C3C571F4C5E8B00ED18A004 /* IndividualViewControllerTest.swift in Sources */, + 5642050BB7FEB20E05B41DCD /* IndividualWelcomeViewControllerTest.swift in Sources */, + 40609779D5BD7D6AE61FE01D /* PhoneOtpViewControllerTest.swift in Sources */, + 874EF196DF573CA524785AEF /* SelfieWarmupViewControllerTest.swift in Sources */, + 8DACF642D2E0C5FE35637EAE /* VerificationClientSecretTest.swift in Sources */, + A7B8782A3E807D11A6E6A239 /* VerificationSheetAnalyticsTest.swift in Sources */, + 4D4430064CB7F2CE3AD3266A /* VerificationFlowWebViewControllerTest.swift in Sources */, + 5B4F08774CC9CA1D93216D0D /* VerificationFlowWebViewTest.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3DCE29146A781338C4D3AC2A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + EA2A1C7D4A9FB826A2146808 /* DocumentScanner+API.swift in Sources */, + C378303DE7D3674CE4C771B7 /* DocumentType+StripeIdentity.swift in Sources */, + F1CA28CC435B94C289B08257 /* DocumentUploader+API.swift in Sources */, + 0CC741018E3A07A925A4CCC3 /* FaceScanner+API.swift in Sources */, + F831E6E67E7F9057FED3F4B8 /* IdentityAPIClient.swift in Sources */, + DB6005016F47D09559DA0C98 /* DocumentType.swift in Sources */, + E15D79E05C4FA68CA56B0CC8 /* TruncatedDecimal.swift in Sources */, + 1A85FB72FDFB361FE71C81C8 /* VerificationPage.swift in Sources */, + CB3C71FE1DCD8632B5ADBC5F /* VerificationPageFieldType.swift in Sources */, + D3DA8FAC2DD250C34075FBC0 /* VerificationPageRequirements.swift in Sources */, + FA9F01846614FC4B30FDBF82 /* VerificationPageStaticContentConsentPage.swift in Sources */, + 3B6DF83509C19AD199C1E1CE /* VerificationPageStaticContentCountryNotListedPage.swift in Sources */, + 934D31A211E71520985D1D96 /* VerificationPageStaticContentDocumentCaptureModels.swift in Sources */, + 2C8C7EF7F7E594FD8E4171E7 /* VerificationPageStaticContentDocumentCapturePage.swift in Sources */, + 3C553B8ADA8136498FF8E011 /* VerificationPageStaticContentDocumentSelectPage.swift in Sources */, + 62BE5C157A699B1041FFBE6B /* VerificationPageStaticContentIndividualPage.swift in Sources */, + 903114A8943889900AF6092C /* VerificationPageStaticContentIndividualWelcomePage.swift in Sources */, + C809F1754942C27303CC0753 /* VerificationPageStaticContentPhoneOtpPage.swift in Sources */, + E5B984BE9EFC4E87D1866CB3 /* VerificationPageStaticContentSelfieModels.swift in Sources */, + A8D87710369D0DCB43E481FF /* VerificationPageStaticContentSelfiePage.swift in Sources */, + 0825B219E9B618265CDF5E89 /* VerificationPageStaticContentTextPage.swift in Sources */, + 56583B45EA53659E843E35F6 /* VerificationPageData.swift in Sources */, + 3D9D1BA3B4C7E2464D457FF7 /* VerificationPageDataRequirementError.swift in Sources */, + F1DAE15C305F9733A7C40673 /* VerificationPageDataRequirements.swift in Sources */, + C83DD219624645AACB2F29A9 /* RequiredInternationalAddress.swift in Sources */, + 87A4B9B848DF1CFA11F32FC4 /* VerificationPageClearData.swift in Sources */, + 5BC21FBB6B4A1B1C8EC04270 /* VerificationPageCollectedData.swift in Sources */, + 36F0967E814234FE61EF3A0B /* VerificationPageDataDob.swift in Sources */, + 4E44D8C9967E301DB34EB6F0 /* VerificationPageDataDocumentFileData.swift in Sources */, + 47E285A86960CDE275E5EA53 /* VerificationPageDataFace.swift in Sources */, + D2309DFF2EDABB6C1A139513 /* VerificationPageDataIdNumber.swift in Sources */, + 4B46A1DC7BAE3ED249C4EF05 /* VerificationPageDataName.swift in Sources */, + 50B7C62A3845596BB256BC3E /* VerificationPageDataPhone.swift in Sources */, + E64EE8A82FFB624F981D7E17 /* VerificationPageDataUpdate.swift in Sources */, + 92B9210AA596D5A135F24710 /* SelfieUploader+API.swift in Sources */, + 66BD1F1CB366B61D1ECD0E84 /* IdentityAnalyticsClient.swift in Sources */, + 924DE7C0E856DD2C5B80D53A /* Array+StripeIdentity.swift in Sources */, + 1E48ABBE603C4DB065DD2093 /* CGImage+StripeIdentity.swift in Sources */, + FDB82DE4F471545F5D7B4CCA /* MLMultiArray+StripeIdentity.swift in Sources */, + C5E8CA0B4DFEF0F017AEE8D7 /* NSAttributedString+HTML.swift in Sources */, + 1DB3040542D8274848932380 /* TimeInterval+StripeIdentity.swift in Sources */, + 867C0D3782143FC44501485C /* UINavigationController+StripeIdentity.swift in Sources */, + 24E6928BEA4044DB779D4189 /* VNBarcodeSymbology+StripeIdentity.swift in Sources */, + D55B24D4D03E43A9438BCFE5 /* IdNumberElement.swift in Sources */, + 882C201AF4C8F48816DB740F /* IdentityElementsFactory.swift in Sources */, + 49FE192DE8D8DA9342A5AE60 /* IdentityTextButtonElement.swift in Sources */, + 692A9E2ADC11AB3D26A92780 /* IndividualFormElement.swift in Sources */, + 28D0DEBE960D92E18CEB4D71 /* Enums+CustomStringConvertible.swift in Sources */, + 2522A0773650573501106F4D /* Image.swift in Sources */, + 063C2C263111DA52ECC92DC0 /* STPLocalizedString.swift in Sources */, + CF5E139AD71239C64376815D /* String+Localized.swift in Sources */, + A9C41FB2EB8034BCCD6CAC29 /* StripeIdentityBundleLocator.swift in Sources */, + 6A1B2E52B09C2D436CF7794D /* IdentityVerificationSheet.swift in Sources */, + 2E943A5B252D7927CE3C32E3 /* IdentityVerificationSheetError.swift in Sources */, + 56299123BD0C28891DD7EFEC /* IdentityTopLevelDestination.swift in Sources */, + 4FAB896404856F5AED99B89B /* DocumentScanner.swift in Sources */, + 9D73810AC9A6AF921038DC54 /* DocumentScannerConfiguration.swift in Sources */, + 4F20C4AF111778413B77DA22 /* DocumentScannerOutput.swift in Sources */, + 33F0FF8DB7D784C95647B693 /* FaceCaptureData.swift in Sources */, + 4A824CD6A0A518F616D428CF /* FaceScanner.swift in Sources */, + 541CA755717969560BF2C694 /* FaceScannerConfiguration.swift in Sources */, + 29E974CDD5827CF390349EA4 /* FaceScannerOutput.swift in Sources */, + 6B8DDA62E6ABFBC770A8AC86 /* ImageScanner.swift in Sources */, + 8E5D382BF426061CDD83197B /* ImageScanningConcurrencyManager.swift in Sources */, + 4E6AF5DD963FAB8B6E06F482 /* ImageScanningSession.swift in Sources */, + C4BBB6E780EA8288EB46E795 /* ImageScanningSessionDelegate.swift in Sources */, + 12542E187994CC0683E2E3D8 /* DocumentUploader.swift in Sources */, + B2377EEDCE440269E0766410 /* IdentityImageUploader.swift in Sources */, + 60B3D5B1B19D31D69D5049A0 /* SelfieUploader.swift in Sources */, + 46A6C4DCF553DF24F3674002 /* VerificationSheetController.swift in Sources */, + 981D82CFC8B9EBF104B166F7 /* VerificationSheetFlowController.swift in Sources */, + 752E548B7E39B3CD8FC2F76A /* VerificationSheetFlowControllerError.swift in Sources */, + AD1ADA8B98C1FDB5C8C0A39F /* BarcodeDetector.swift in Sources */, + F146D14E6D35174CB94A8A48 /* FaceDetector.swift in Sources */, + EF277282173FFF6A52FE1EA3 /* FaceDetectorOutput.swift in Sources */, + ADA2674008520B832585A8EE /* IDDetector.swift in Sources */, + D19E35C5D5263F212071AE75 /* IDDetectorConstants.swift in Sources */, + 4E39FB668EC68320579F23BC /* IDDetectorOutput.swift in Sources */, + ED5777E49F4AE61121539EBC /* LaplacianBlurDetector.swift in Sources */, + 349A4B5CC24DF5539B773A84 /* MLDetectorConfiguration.swift in Sources */, + AF16352FECF8EE27ECD6AAC9 /* MLDetectorMetricsTracker.swift in Sources */, + 3F9FB3BC02DDC7BCF4207F73 /* MotionBlurDetector.swift in Sources */, + 79BA026188FE0DB22ECE1BB0 /* VisionBasedDetector.swift in Sources */, + 9665F0EF90739A5D033C03A6 /* DocumentSide.swift in Sources */, + 286BF86B773F41210E015B2B /* IdentityDataCollecting.swift in Sources */, + 0FBE6D53EBE7C2B93DEA34B4 /* IdentityFlowNavigationController.swift in Sources */, + BFA24F4BD97F5BE9F5C0E934 /* IdentityUI.swift in Sources */, + 51DCAEAEEA0B32038A989A51 /* MLModelLoader.swift in Sources */, + C5DB8461793523F9488DBBFA /* MLModelUnexpectedOutputError.swift in Sources */, + B8E47E623C9E4DAF53C3520C /* NonMaxSuppression.swift in Sources */, + DCFBA69CC4437E2E6061F50B /* IdentityMLModelLoader.swift in Sources */, + 905C6BC4E70CAF4AAFEAE246 /* BiometricConsentViewController.swift in Sources */, + 459102C261053F38D3CBF25F /* CountryNotListedViewController.swift in Sources */, + 21F8B404DF172ED5CE4BB526 /* DebugViewController.swift in Sources */, + 86B2EFF1DEE73738EB454F4D /* DocumentCaptureViewController+Strings.swift in Sources */, + A030F131A965131A67388D97 /* DocumentCaptureViewController.swift in Sources */, + 22C7DF527FAF0642A0850DEA /* DocumentFileUploadViewController+Strings.swift in Sources */, + 72461D38E6D468AAEC5BBFF6 /* DocumentFileUploadViewController.swift in Sources */, + 3B3012E73D8B4820362F25BD /* DocumentTypeSelectViewController.swift in Sources */, + 6174B8E57AEB44661C5B08E3 /* ErrorViewController.swift in Sources */, + C8A9ABCDD7A384756CC382D1 /* IdentityFlowViewController.swift in Sources */, + 2225D341AE96C5168C350C72 /* IndividualViewController.swift in Sources */, + ADF66222ED710EAC0671C502 /* IndividualWelcomeViewController.swift in Sources */, + 9A9B9B47F9CDA8AD1788A411 /* LoadingViewController.swift in Sources */, + B6B3F48D0B7539B28BD89AF7 /* PhoneOtpViewController.swift in Sources */, + 9873265F2244B937E83F56BC /* SelfieCaptureViewController+Strings.swift in Sources */, + 6BD9AE7267A9A43C740D7416 /* SelfieCaptureViewController.swift in Sources */, + 99CB78B1CAE5FC1BBA526B87 /* SelfieWarmupViewController.swift in Sources */, + 91C23784E05401CA9EF37140 /* SuccessViewController.swift in Sources */, + 87927769E26B4189FB24D0C2 /* BottomAlignedLabel.swift in Sources */, + 1ECF04022C1481B12FC86D05 /* CameraPreviewContainerView.swift in Sources */, + 0B431C601D24D6135162E8C9 /* CompleteOptionView.swift in Sources */, + 0A0B05F751A02CF3AB9E24DF /* ContentCenteringScrollView.swift in Sources */, + 51076AC7B641980402566529 /* DebugView.swift in Sources */, + 4C9D354F6C1E9E89B13D0367 /* AnimatedBorderView.swift in Sources */, + 8EB5D825D1ADC9DAB6E7D5A3 /* DocumentCaptureView.swift in Sources */, + BD4686589B16ED96A748CCA8 /* DocumentScanningView.swift in Sources */, + CE7F2D308363EEDD627AEBF7 /* InstructionalDocumentScanningView.swift in Sources */, + 320565753A6D528A24336934 /* ErrorView.swift in Sources */, + A47A4BB7D62116CAA3D29C2A /* HeaderIconView.swift in Sources */, + 0BA47205B2A7EE905006AFDF /* HeaderView.swift in Sources */, + 87994273B34C4783F92EF8FB /* IdentityFlowView.swift in Sources */, + 7F55104D44AE746B5E686780 /* HTMLTextView.swift in Sources */, + 2F330EA97B99545A50328BDD /* HTMLViewWithIconLabels.swift in Sources */, + 68BB1FEB385D943EC3CC926B /* IconLabelHTMLView.swift in Sources */, + 5F569781EFCC445502DA574F /* InstructionListView.swift in Sources */, + 7CA57E67AC14DA90AE87743B /* ListItemView.swift in Sources */, + A8EEE8C2CD83374877393E8C /* ListView.swift in Sources */, + CE78EDF357433AFBB0929315 /* PhoneOtpView.swift in Sources */, + C9E49126B9BEC74E605C6DD0 /* SelfieCaptureView.swift in Sources */, + C1AA4B758A20557CDBE72FD9 /* SelfieScanningView.swift in Sources */, + 7D4F147D0CEC17856D070EC2 /* SelfieWarmupView.swift in Sources */, + 37BCABAD8160BF4D14FA6449 /* ShadowConfiguration.swift in Sources */, + 75AC5A117C885FBD0746291E /* ShadowedCorneredImageView.swift in Sources */, + AB6B3BDF7D8F3F84EEA7208A /* StripeCore+Import.swift in Sources */, + CBA4B3CAFDFF232B72393842 /* VerificationClientSecret.swift in Sources */, + 1D4D9CDF6B1D335963849940 /* VerificationSheetAnalytics.swift in Sources */, + 4A6D49DED2555FBDACF13B3F /* VerificationFlowWebView.swift in Sources */, + 5E00E8AC4088548403B3E630 /* VerificationFlowWebViewController.swift in Sources */, + 76890B79CA3C83BBD7362065 /* VerifyWebURLHelper.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 90F68D87EFF3F3EE0E36B03A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = StripeIdentity; + target = F251015845B21C036CFBC636 /* StripeIdentity */; + targetProxy = C5559AE430066C0A94C2686A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 989A2C5379C60B9F0E0E2711 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + AD8443DFD20A6F7E547E4DAE /* bg-BG */, + 005EAC562365676CE56F306D /* ca-ES */, + 0CFB27562E05409A1C0263EA /* cs-CZ */, + F524F862B05C4744F3A5FCD4 /* da */, + 3954F595B70115874BD88CCC /* de */, + D105F7A265B60FE9AD82B0DF /* el-GR */, + 6182719E4413B5136BB763D1 /* en */, + F8521F2AA5740FAB9952B58E /* en-GB */, + F48A3390B6B74481B1F64EC6 /* es */, + 52DD224D2BE08E0553EF8AA6 /* es-419 */, + 0AFAC8C1757DE685E0E9BFB3 /* et-EE */, + 80F9C4B0CFCD5FF45D203B62 /* fi */, + 3118D24F94912E8A9BB86BFE /* fil */, + AE92BAAC65F86A87F25B9C08 /* fr */, + 7EAFBBEEA27B11E702B78497 /* fr-CA */, + F44D3A78A44A0EB11BA51E5F /* hr */, + CF2EEA927FA9ACE159A67063 /* hu */, + 4A59C0CC87CDB7AABFE96686 /* id */, + F46E474400B21A35FA6066F8 /* it */, + EB9B2148051885B8591A13DE /* ja */, + 704D26339D2158FD8FADE1C2 /* ko */, + 22B3E26EBB8F43F7559C635B /* lt-LT */, + FB119F30000F66B9660A44E8 /* lv-LV */, + E6C2F1CDF5594786FB4BAA49 /* ms-MY */, + 6BC7102DF0964D1204E2BFEB /* mt */, + 2FAE3BFE9870AF3F0553AB9C /* nb */, + F8103B1FD36D79D22EACF7EE /* nl */, + 5B975279521B43A8F82EBAB3 /* nn-NO */, + 9A5A1304CA30DC06B715BD92 /* pl-PL */, + F43124EF97ADAB2BD1B7DEE2 /* pt-BR */, + 74D9040BDF0DBA526C156C3D /* pt-PT */, + 2FC476BFBFA09FDC174857A6 /* ro-RO */, + 40DAFA9171B6D631DE9B6830 /* ru */, + F9D552632FFF310FC6A4DB26 /* sk-SK */, + 2344DA31ADA3573F9C2EE7A0 /* sl-SI */, + 0ACA3969442F9B14DF70ABE7 /* sv */, + C4891127AAACF7829BEE80E2 /* tr */, + 0631A038B5360C337B8E596C /* vi */, + 598A711C4A2B8988EFBEEF77 /* zh-Hans */, + E3A5A9765EF649A580A0519F /* zh-Hant */, + B634C1568CBC56B3E33CE3E2 /* zh-HK */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 02EBEC1B77551185440D525C /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 434BC5FFBC1163872413A40A /* StripeiOS Tests-Release.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeIdentityTests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeIdentityTests; + PRODUCT_NAME = StripeIdentityTests; + SDKROOT = iphoneos; + }; + name = Release; + }; + 10F159BEF3E08EF01CDA43CB /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E850C3F1FEA42490F1B3761E /* StripeiOS-Release.xcconfig */; + buildSettings = { + INFOPLIST_FILE = StripeIdentity/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "com.stripe.stripe-identity"; + PRODUCT_NAME = StripeIdentity; + SDKROOT = iphoneos; + }; + name = Release; + }; + 41CCDE6EB399A8EBA8367328 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4F6B26A21285054BEE9CA244 /* Project-Debug.xcconfig */; + buildSettings = { + }; + name = Debug; + }; + 80564639A42659CBBA6AD565 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A163E1EC771058E5DF5FFE66 /* Project-Release.xcconfig */; + buildSettings = { + }; + name = Release; + }; + 95BCFF11E6F19877B02921C2 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B17C20418C972E7B06B84370 /* StripeiOS-Debug.xcconfig */; + buildSettings = { + INFOPLIST_FILE = StripeIdentity/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "com.stripe.stripe-identity"; + PRODUCT_NAME = StripeIdentity; + SDKROOT = iphoneos; + }; + name = Debug; + }; + ACBD80600170C45C9CC051A4 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8605B809AD7EBA140CC9DBE9 /* StripeiOS Tests-Debug.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeIdentityTests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeIdentityTests; + PRODUCT_NAME = StripeIdentityTests; + SDKROOT = iphoneos; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 7C14F7F327D79A07F63042E0 /* Build configuration list for PBXNativeTarget "StripeIdentityTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + ACBD80600170C45C9CC051A4 /* Debug */, + 02EBEC1B77551185440D525C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 8BD83D322879659AB98AEA29 /* Build configuration list for PBXProject "StripeIdentity" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 41CCDE6EB399A8EBA8367328 /* Debug */, + 80564639A42659CBBA6AD565 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 9E28A14ACC690D35FD59690A /* Build configuration list for PBXNativeTarget "StripeIdentity" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 95BCFF11E6F19877B02921C2 /* Debug */, + 10F159BEF3E08EF01CDA43CB /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 1034C700B96E0B79C2107621 /* XCRemoteSwiftPackageReference "ios-snapshot-test-case" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/uber/ios-snapshot-test-case"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 960247DC321CBDB422FE713D /* iOSSnapshotTestCase */ = { + isa = XCSwiftPackageProductDependency; + productName = iOSSnapshotTestCase; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 0705CAA185B63201FD561508 /* Project object */; +} diff --git a/StripeIdentity/StripeIdentity.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/StripeIdentity/StripeIdentity.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/StripeIdentity/StripeIdentity.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/StripeIdentity/StripeIdentity.xcodeproj/xcshareddata/xcschemes/StripeIdentity.xcscheme b/StripeIdentity/StripeIdentity.xcodeproj/xcshareddata/xcschemes/StripeIdentity.xcscheme new file mode 100644 index 00000000..eb7e1bc2 --- /dev/null +++ b/StripeIdentity/StripeIdentity.xcodeproj/xcshareddata/xcschemes/StripeIdentity.xcscheme @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StripeIdentity/StripeIdentity/Info.plist b/StripeIdentity/StripeIdentity/Info.plist new file mode 100644 index 00000000..cd4a496b --- /dev/null +++ b/StripeIdentity/StripeIdentity/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(CURRENT_PROJECT_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/StripeIdentity/StripeIdentity/Resources/Images/icon_add@3x.png b/StripeIdentity/StripeIdentity/Resources/Images/icon_add@3x.png new file mode 100644 index 00000000..e1ba9932 Binary files /dev/null and b/StripeIdentity/StripeIdentity/Resources/Images/icon_add@3x.png differ diff --git a/StripeIdentity/StripeIdentity/Resources/Images/icon_camera@3x.png b/StripeIdentity/StripeIdentity/Resources/Images/icon_camera@3x.png new file mode 100644 index 00000000..d1bf3b6a Binary files /dev/null and b/StripeIdentity/StripeIdentity/Resources/Images/icon_camera@3x.png differ diff --git a/StripeIdentity/StripeIdentity/Resources/Images/icon_checkmark@3x.png b/StripeIdentity/StripeIdentity/Resources/Images/icon_checkmark@3x.png new file mode 100644 index 00000000..61858478 Binary files /dev/null and b/StripeIdentity/StripeIdentity/Resources/Images/icon_checkmark@3x.png differ diff --git a/StripeIdentity/StripeIdentity/Resources/Images/icon_checkmark_92@3x.png b/StripeIdentity/StripeIdentity/Resources/Images/icon_checkmark_92@3x.png new file mode 100644 index 00000000..441ace02 Binary files /dev/null and b/StripeIdentity/StripeIdentity/Resources/Images/icon_checkmark_92@3x.png differ diff --git a/StripeIdentity/StripeIdentity/Resources/Images/icon_clock@3x.png b/StripeIdentity/StripeIdentity/Resources/Images/icon_clock@3x.png new file mode 100644 index 00000000..acd99db5 Binary files /dev/null and b/StripeIdentity/StripeIdentity/Resources/Images/icon_clock@3x.png differ diff --git a/StripeIdentity/StripeIdentity/Resources/Images/icon_info@3x.png b/StripeIdentity/StripeIdentity/Resources/Images/icon_info@3x.png new file mode 100644 index 00000000..b4528fb6 Binary files /dev/null and b/StripeIdentity/StripeIdentity/Resources/Images/icon_info@3x.png differ diff --git a/StripeIdentity/StripeIdentity/Resources/Images/icon_selfie_warmup@3x.png b/StripeIdentity/StripeIdentity/Resources/Images/icon_selfie_warmup@3x.png new file mode 100644 index 00000000..8a08cd63 Binary files /dev/null and b/StripeIdentity/StripeIdentity/Resources/Images/icon_selfie_warmup@3x.png differ diff --git a/StripeIdentity/StripeIdentity/Resources/Images/icon_warning2@3x.png b/StripeIdentity/StripeIdentity/Resources/Images/icon_warning2@3x.png new file mode 100644 index 00000000..b0abed0b Binary files /dev/null and b/StripeIdentity/StripeIdentity/Resources/Images/icon_warning2@3x.png differ diff --git a/StripeIdentity/StripeIdentity/Resources/Images/icon_warning@3x.png b/StripeIdentity/StripeIdentity/Resources/Images/icon_warning@3x.png new file mode 100644 index 00000000..4c7160a4 Binary files /dev/null and b/StripeIdentity/StripeIdentity/Resources/Images/icon_warning@3x.png differ diff --git a/StripeIdentity/StripeIdentity/Resources/Images/icon_warning_92@3x.png b/StripeIdentity/StripeIdentity/Resources/Images/icon_warning_92@3x.png new file mode 100644 index 00000000..64912b60 Binary files /dev/null and b/StripeIdentity/StripeIdentity/Resources/Images/icon_warning_92@3x.png differ diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/bg-BG.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/bg-BG.lproj/Localizable.strings new file mode 100644 index 00000000..0defb8ab --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/bg-BG.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Като алтернатива можете да качите ръчно снимка на документа си за самоличност."; + +"App Settings" = "Настройки на приложението"; + +"Back driver's license photo successfully uploaded" = "Успешно качена снимка на задната страна на шофьорска книжка"; + +"Back identity card photo successfully uploaded" = "Успешно качена снимка на задната страна на лична карта"; + +"Back of driver's license" = "Задна страна на свидетелство за управление на МПС"; + +"Back of identity card" = "Задна страна на лична карта"; + +"Camera permission" = "Разрешение за камерата"; + +"Camera unavailable" = "Камерата не е достъпна"; + +"Capturing…" = "Заснемане..."; + +"Choose File" = "Избор на файл"; + +"Consent" = "Съгласие"; + +"Could not capture image" = "Заснемането беше неуспешно"; + +"Date of Birth" = "Дата на раждане"; + +"Date of birth does not look valid" = "Датата на раждане не изглежда валидна"; + +"Flip your driver's license over to the other side" = "Обърнете шофьорската си книжка от другата страна"; + +"Flip your identity card over to the other side" = "Обърнете личната си карта от другата страна."; + +"Front driver's license photo successfully uploaded" = "Успешно качена снимка на предната страна на шофьорска книжка"; + +"Front identity card photo successfully uploaded" = "Успешно качена снимка на предната страна на лична карта"; + +"Front of driver's license" = "Предна страна на свидетелство за управление на МПС"; + +"Front of identity card" = "Предна страна на лична карта"; + +"Go Back" = "Назад"; + +"Hold still, scanning" = "Застанете неподвижно, сканира се"; + +"ID Number" = "Идентификационен номер"; + +"ID Type" = "Тип идентификация"; + +"Image of passport" = "Изображение на паспорт"; + +"Individual CPF" = "CPF на физическо лице"; + +"Last 4 of Social Security number" = "Последните 4 цифри от социалноосигурителния номер"; + +"Loading" = "Зареждане"; + +"NRIC or FIN" = "NRIC или FIN"; + +"Passport" = "Паспорт"; + +"Passport photo successfully uploaded" = "Успешно качена снимка на паспорт"; + +"Personal ID number" = "Личен идентификационен номер"; + +"Personal Information" = "Лична информация"; + +"Phone Number" = "Телефонен номер"; + +"Phone Verification" = "Удостоверяване чрез телефон"; + +"Photo Library" = "Библиотека със снимки"; + +"Please upload an image of your passport" = "Моля, качете изображение на Вашия паспорт."; + +"Please upload images of the front and back of your driver's license" = "Моля, качете изображение на предната и задната част от Вашето свидетелство за управление на МПС."; + +"Please upload images of the front and back of your identity card" = "Моля, качете изображение на предната и задната част на Вашата лична карта."; + +"Position your driver's license in the center of the frame" = "Позиционирайте шофьорската си книжка в центъра на рамката"; + +"Position your face in the center of the frame." = "Позиционирайте лицето си в центъра на рамката."; + +"Position your identity card in the center of the frame" = "Позиционирайте личната си карта в центъра на рамката"; + +"Position your passport in the center of the frame" = "Позиционирайте паспорта си в центъра на рамката"; + +"Retake Photos" = "Повторно заснемане на снимки"; + +"Scan" = "Сканирай"; + +"Scanned" = "Сканиран"; + +"Select" = "Избиране"; + +"Select a location to upload the back of your identity document from" = "Изберете местоположение за качване на изображение на задната страна на Вашия документ за самоличност от"; + +"Select a location to upload the front of your identity document from" = "Изберете местоположение за качване на изображение на предната страна на Вашия документ за самоличност от"; + +"Select back driver's license photo" = "Изберете снимка на задната част на шофьорска книжка"; + +"Select back identity card photo" = "Изберете снимка на задната страна на лична карта"; + +"Select front driver's license photo" = "Изберете снимка на предната страна на шофьорска книжка"; + +"Select front identity card photo" = "Изберете снимка на предната страна на лична карта"; + +"Select passport photo" = "Изберете снимка на паспорт"; + +"Selfie" = "Селфи"; + +"Selfie captures" = "Заснети селфита"; + +"Selfie captures are complete" = "Заснемането на селфита е завършено"; + +"Take Photo" = "Заснемане"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Изображенията на Вашия документ за самоличност не са запазени. Искате ли да излезете?"; + +"There was an error accessing the camera." = "Възникна грешка, свързана с достъпа до камерата."; + +"Try Again" = "Опитайте отново"; + +"Unable to establish a connection." = "Не може да бъде осъществена връзка."; + +"Unsaved changes" = "Незапазени промени"; + +"Upload" = "Качване"; + +"Upload a Photo" = "Качете снимка"; + +"Upload your photo ID" = "Качете документ за самоличност със снимка"; + +"Uploading back driver's license photo" = "Качване на снимка на задната страна на шофьорска книжка"; + +"Uploading back identity card photo" = "Качване на снимка на задната страна на лична карта"; + +"Uploading front driver's license photo" = "Качване на снимка на предната страна на шофьорска книжка"; + +"Uploading front identity card photo" = "Качване на снимка на предната страна на лична карта"; + +"Uploading passport photo" = "Качване на снимка на паспорт"; + +"Verify your identity" = "Удостоверете Вашата самоличност"; + +"We could not capture a high-quality image." = "Не успяхме да заснемем изображение с високо качество."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Нуждаем се от разрешение за използване на Вашата камера. Моля, разрешете достъп до камерата в настройките на приложението."; + +"Welcome" = "Добре дошли"; + +"You can either try again or upload an image from your device." = "Можете да опитате отново или да качите изображение от устройството си."; + +"Your selfie images have not been saved. Do you want to leave?" = "Вашите селфи изображения не са запазени. Искате ли да излезете?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/ca-ES.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/ca-ES.lproj/Localizable.strings new file mode 100644 index 00000000..ae52a27b --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/ca-ES.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "També podeu pujar manualment una foto del document d'identitat."; + +"App Settings" = "Configuració de l'aplicació"; + +"Back driver's license photo successfully uploaded" = "La foto del revers del permís de conduir s'ha carregat correctament"; + +"Back identity card photo successfully uploaded" = "La foto del revers document d'identitat s'ha carregat correctament"; + +"Back of driver's license" = "Revers del permís de conduir"; + +"Back of identity card" = "Revers del document d'identitat"; + +"Camera permission" = "Permís de càmera"; + +"Camera unavailable" = "Càmera no disponible"; + +"Capturing…" = "S'està capturant la imatge..."; + +"Choose File" = "Selecciona fitxer"; + +"Consent" = "Consentiment"; + +"Could not capture image" = "No s'ha pogut capturar la imatge"; + +"Date of Birth" = "Data de naixement"; + +"Date of birth does not look valid" = "La data de naixement no és vàlida"; + +"Flip your driver's license over to the other side" = "Gireu el permís de conduir perquè se'n vegi l'altra cara"; + +"Flip your identity card over to the other side" = "Gireu el document d'identitat perquè se'n vegi l'altra cara"; + +"Front driver's license photo successfully uploaded" = "La foto de l'anvers del permís de conduir s'ha carregat correctament"; + +"Front identity card photo successfully uploaded" = "La foto de l'anvers del document d'identitat s'ha carregat correctament"; + +"Front of driver's license" = "Anvers del permís de conduir"; + +"Front of identity card" = "Anvers del document d'identitat"; + +"Go Back" = "Enrere"; + +"Hold still, scanning" = "S'està escanejant; espereu"; + +"ID Number" = "Número d'identificació"; + +"ID Type" = "Tipus d'identificació"; + +"Image of passport" = "Imatge del passaport"; + +"Individual CPF" = "CPF particular"; + +"Last 4 of Social Security number" = "Últims 4 números de la Seguretat Social"; + +"Loading" = "S'està carregant"; + +"NRIC or FIN" = "NRIC o FIN"; + +"Passport" = "Passaport"; + +"Passport photo successfully uploaded" = "La foto del passaport s'ha carregat correctament"; + +"Personal ID number" = "Número d'identificació personal"; + +"Personal Information" = "Dades personals"; + +"Phone Number" = "Número de telèfon"; + +"Phone Verification" = "Verificació del telèfon"; + +"Photo Library" = "Biblioteca de fotos"; + +"Please upload an image of your passport" = "Carregueu una imatge del passaport"; + +"Please upload images of the front and back of your driver's license" = "Carregueu les imatges de l'anvers i el revers del permís de conduir"; + +"Please upload images of the front and back of your identity card" = "Carregueu les imatges de l'anvers i el revers del document d'identitat"; + +"Position your driver's license in the center of the frame" = "Col·loqueu el permís de conduir al centre del marc"; + +"Position your face in the center of the frame." = "Col·loqueu la cara al centre del marc."; + +"Position your identity card in the center of the frame" = "Col·loqueu el document d'identitat al centre del marc"; + +"Position your passport in the center of the frame" = "Col·loqueu el passaport al centre del marc"; + +"Retake Photos" = "Torna a fer les fotos"; + +"Scan" = "Escaneig"; + +"Scanned" = "S'ha escanejat correctament"; + +"Select" = "Selecciona"; + +"Select a location to upload the back of your identity document from" = "Seleccioneu una ubicació des d'on carregar el revers del document d'identitat"; + +"Select a location to upload the front of your identity document from" = "Seleccioneu una ubicació des d'on carregar l'anvers del document d'identitat"; + +"Select back driver's license photo" = "Seleccioneu la foto del revers del permís de conduir"; + +"Select back identity card photo" = "Seleccioneu la foto del revers del document d'identitat"; + +"Select front driver's license photo" = "Seleccioneu la foto de l'anvers del permís de conduir"; + +"Select front identity card photo" = "Seleccioneu la foto del document d'identitat"; + +"Select passport photo" = "Seleccioneu la foto del passaport"; + +"Selfie" = "Autofoto"; + +"Selfie captures" = "Autofotos"; + +"Selfie captures are complete" = "S'han completat les autofotos"; + +"Take Photo" = "Fes foto"; + +"The images of your identity document have not been saved. Do you want to leave?" = "No s'han desat les imatges del document d'identitat. Voleu sortir?"; + +"There was an error accessing the camera." = "Hi ha hagut un error en accedir a la càmera."; + +"Try Again" = "Torna-ho a intentar"; + +"Unable to establish a connection." = "No s'ha pogut establir la connexió."; + +"Unsaved changes" = "Canvis no desats"; + +"Upload" = "Carrega"; + +"Upload a Photo" = "Carrega una foto"; + +"Upload your photo ID" = "Carregueu la vostra ID amb foto"; + +"Uploading back driver's license photo" = "S'està carregant la foto del revers del permís de conduir"; + +"Uploading back identity card photo" = "S'està carregant la foto del revers del document d'identitat"; + +"Uploading front driver's license photo" = "S'està carregant la foto de l'anvers del permís de conduir"; + +"Uploading front identity card photo" = "S'està carregant la foto de l'anvers del document d'identitat"; + +"Uploading passport photo" = "S'està carregant la foto del passaport"; + +"Verify your identity" = "Verifica la teva identitat"; + +"We could not capture a high-quality image." = "No hem pogut capturar una imatge d'alta qualitat."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Necessitem permís per utilitzar la càmera. Permeteu l'accés a la càmera a la configuració de l'aplicació."; + +"Welcome" = "Hola"; + +"You can either try again or upload an image from your device." = "Podeu tornar-ho a provar o carregar una imatge des del vostre dispositiu."; + +"Your selfie images have not been saved. Do you want to leave?" = "No s'han desat les autofotos. Voleu sortir?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/cs-CZ.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/cs-CZ.lproj/Localizable.strings new file mode 100644 index 00000000..46bdc92d --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/cs-CZ.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Můžete také ručně nahrát fotografii dokladu totožnosti."; + +"App Settings" = "Nastavení aplikace"; + +"Back driver's license photo successfully uploaded" = "Fotografie z přední strany řidičského průkazu byla úspěšně nahrána"; + +"Back identity card photo successfully uploaded" = "Fotografie ze zadní strany průkazu totožnosti byla úspěšně nahrána"; + +"Back of driver's license" = "Zadní strana řidičského průkazu"; + +"Back of identity card" = "Zadní strana průkazu totožnosti"; + +"Camera permission" = "Povolení k použití fotoaparátu"; + +"Camera unavailable" = "Fotoaparát není dostupný"; + +"Capturing…" = "Probíhá fotografování..."; + +"Choose File" = "Vybrat soubor"; + +"Consent" = "Souhlas"; + +"Could not capture image" = "Nebylo možné pořídit obrázek"; + +"Date of Birth" = "Datum narození"; + +"Date of birth does not look valid" = "Datum narození nevypadá jako platný údaj"; + +"Flip your driver's license over to the other side" = "Převraťte řidičský průkaz na druhou stranu"; + +"Flip your identity card over to the other side" = "Převraťte průkaz totožnosti na druhou stranu"; + +"Front driver's license photo successfully uploaded" = "Fotografie z přední strany řidičského průkazu byla úspěšně nahrána"; + +"Front identity card photo successfully uploaded" = "Fotografie z přední strany průkazu totožnosti byla úspěšně nahrána"; + +"Front of driver's license" = "Přední strana řidičského průkazu"; + +"Front of identity card" = "Přední strana průkazu totožnosti"; + +"Go Back" = "Zpět"; + +"Hold still, scanning" = "Držte rovně, probíhá skenování"; + +"ID Number" = "Identifikační číslo"; + +"ID Type" = "Typ ID"; + +"Image of passport" = "Obrázek cestovního pasu"; + +"Individual CPF" = "Individuální CPF"; + +"Last 4 of Social Security number" = "Poslední 4 pozice čísla sociálního pojištění"; + +"Loading" = "Načítání"; + +"NRIC or FIN" = "NRIC nebo FIN"; + +"Passport" = "Pas"; + +"Passport photo successfully uploaded" = "Pasová fotografie byla úspěšně nahrána"; + +"Personal ID number" = "Osobní identifikační číslo"; + +"Personal Information" = "Osobní informace"; + +"Phone Number" = "Telefonní číslo"; + +"Phone Verification" = "Telefonické ověření"; + +"Photo Library" = "Knihovna fotek"; + +"Please upload an image of your passport" = "Nahrajte prosím obrázek vašeho cestovního pasu"; + +"Please upload images of the front and back of your driver's license" = "Nahrajte prosím obrázky přední a zadní strany vašeho řidičského průkazu"; + +"Please upload images of the front and back of your identity card" = "Nahrajte prosím obrázky přední a zadní strany vašeho průkazu totožnosti"; + +"Position your driver's license in the center of the frame" = "Umístěte řidičský průkaz do středu rámečku"; + +"Position your face in the center of the frame." = "Svoji tvář umístěte do středu rámečku."; + +"Position your identity card in the center of the frame" = "Umístěte průkaz totožnosti do středu rámečku"; + +"Position your passport in the center of the frame" = "Umístěte pas do středu rámečku"; + +"Retake Photos" = "Znovu pořídit fotografie"; + +"Scan" = "Skenovat"; + +"Scanned" = "Naskenováno"; + +"Select" = "Vybrat"; + +"Select a location to upload the back of your identity document from" = "Vyberte místo k nahrání zadní strany vašeho průkazu totožnosti z"; + +"Select a location to upload the front of your identity document from" = "Vyberte místo k nahrání přední strany vašeho průkazu totožnosti z"; + +"Select back driver's license photo" = "Vyberte fotografii ze zadní strany řidičského průkazu"; + +"Select back identity card photo" = "Vyberte fotografii ze zadní strany řidičského průkazu"; + +"Select front driver's license photo" = "Vyberte fotografii z přední strany řidičského průkazu"; + +"Select front identity card photo" = "Vyberte fotografii z přední strany průkazu totožnosti"; + +"Select passport photo" = "Vyberte pasovou fotografii"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Pořízené snímky selfie"; + +"Selfie captures are complete" = "Pořízení snímků selfie je dokončené"; + +"Take Photo" = "Pořídit fotku"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Obrázky z vašeho průkazu totožnosti nebyly uloženy. Chcete odejít?"; + +"There was an error accessing the camera." = "Při přístupu k fotoaparátu došlo k chybě."; + +"Try Again" = "Zkusit znovu"; + +"Unable to establish a connection." = "Nelze navázat spojení."; + +"Unsaved changes" = "Neuložené změny"; + +"Upload" = "Nahrát"; + +"Upload a Photo" = "Nahrát fotografii"; + +"Upload your photo ID" = "Nahrajte svou fotografii"; + +"Uploading back driver's license photo" = "Nahrávání fotografie ze zadní strany řidičského průkazu"; + +"Uploading back identity card photo" = "Nahrávání fotografie ze zadní strany průkazu totožnosti"; + +"Uploading front driver's license photo" = "Nahrávání fotografie z přední strany řidičského průkazu"; + +"Uploading front identity card photo" = "Nahrávání fotografie z přední strany průkazu totožnosti"; + +"Uploading passport photo" = "Nahrávání pasové fotografie"; + +"Verify your identity" = "Potvrďte svou totožnost"; + +"We could not capture a high-quality image." = "Nepodařilo se nám pořídit vysoce kvalitní snímek."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Potřebujeme povolení k použití vašeho fotoaparátu. V nastavení aplikace povolte přístup k fotoaparátu."; + +"Welcome" = "Vítejte"; + +"You can either try again or upload an image from your device." = "Můžete to zkusit znovu nebo nahrát obrázek ze svého zařízení."; + +"Your selfie images have not been saved. Do you want to leave?" = "Vaše selfie snímky nebyly uloženy. Chcete skutečně skončit?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/da.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/da.lproj/Localizable.strings new file mode 100644 index 00000000..f90e1bb5 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/da.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Alternativt kan du manuelt uploade et foto af dit identifikationsdokument."; + +"App Settings" = "Appindstillinger"; + +"Back driver's license photo successfully uploaded" = "Foto af bagside af kørekort blev uploadet"; + +"Back identity card photo successfully uploaded" = "Foto af bagside af identitetskort blev uploadet"; + +"Back of driver's license" = "Bagside af kørekort"; + +"Back of identity card" = "Bagside af identitetskort"; + +"Camera permission" = "Kameratilladelse"; + +"Camera unavailable" = "Kamera ikke tilgængeligt"; + +"Capturing…" = "Optager..."; + +"Choose File" = "Vælg fil"; + +"Consent" = "Samtykke"; + +"Could not capture image" = "Kunne ikke tage billede"; + +"Date of Birth" = "Fødselsdato"; + +"Date of birth does not look valid" = "Fødselsdatoen ser ikke ud til at være gyldig."; + +"Flip your driver's license over to the other side" = "Vend dit kørekort om på bagsiden"; + +"Flip your identity card over to the other side" = "Vend dit identitetskort om på bagsiden"; + +"Front driver's license photo successfully uploaded" = "Foto af forside af kørekort blev uploadet"; + +"Front identity card photo successfully uploaded" = "Foto af forside af identitetskort blev uploadet"; + +"Front of driver's license" = "Forside af kørekort"; + +"Front of identity card" = "Forside af identitetskort"; + +"Go Back" = "Gå tilbage"; + +"Hold still, scanning" = "Hold stille, scanner"; + +"ID Number" = "ID-nummer"; + +"ID Type" = "ID-type"; + +"Image of passport" = "Billede af pas"; + +"Individual CPF" = "Individuel CPF"; + +"Last 4 of Social Security number" = "Sidste 4 cifre af personnummer"; + +"Loading" = "Indlæser"; + +"NRIC or FIN" = "NRIC eller FIN"; + +"Passport" = "Pas"; + +"Passport photo successfully uploaded" = "Foto af pas blev uploadet"; + +"Personal ID number" = "Personligt id-nummer"; + +"Personal Information" = "Personlige oplysninger"; + +"Phone Number" = "Telefonnummer"; + +"Phone Verification" = "Telefonbekræftelse"; + +"Photo Library" = "Fotobibliotek"; + +"Please upload an image of your passport" = "Upload et billede af dit pas"; + +"Please upload images of the front and back of your driver's license" = "Upload billede af forsiden og bagsiden af dit kørekort"; + +"Please upload images of the front and back of your identity card" = "Upload billeder af forsiden og bagsiden af dit identitetskort"; + +"Position your driver's license in the center of the frame" = "Placer dit kørekort midt i rammen"; + +"Position your face in the center of the frame." = "Placer dit ansigt i midten af rammen."; + +"Position your identity card in the center of the frame" = "Placer dit identitetskort midt i rammen"; + +"Position your passport in the center of the frame" = "Placer dit pas midt i rammen"; + +"Retake Photos" = "Tag billeder igen"; + +"Scan" = "Scan"; + +"Scanned" = "Scannet"; + +"Select" = "Vælg"; + +"Select a location to upload the back of your identity document from" = "Vælg en placering, som bagsiden af dit identitetsdokument skal uploades fra"; + +"Select a location to upload the front of your identity document from" = "Vælg en placering, som forsiden af dit identitetsdokument skal uploades fra"; + +"Select back driver's license photo" = "Vælg foto af bagside af kørekort"; + +"Select back identity card photo" = "Vælg foto af bagside af identitetskort"; + +"Select front driver's license photo" = "Vælg foto af forside af kørekort"; + +"Select front identity card photo" = "Vælg foto af forside af identitetskort"; + +"Select passport photo" = "Vælg foto af pas"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Selfie-optagelser"; + +"Selfie captures are complete" = "Optagelse af selfier er fuldført"; + +"Take Photo" = "Tag foto"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Billederne af dit identitetsdokument er ikke blevet gemt. Ønsker du at forlade denne session?"; + +"There was an error accessing the camera." = "Det var ikke muligt at få adgang til kameraet."; + +"Try Again" = "Prøv igen"; + +"Unable to establish a connection." = "Kunne ikke oprette forbindelse."; + +"Unsaved changes" = "Ikke-gemte ændringer"; + +"Upload" = "Upload"; + +"Upload a Photo" = "Upload et foto"; + +"Upload your photo ID" = "Upload dit foto-id"; + +"Uploading back driver's license photo" = "Uploader foto af bagside af kørekort"; + +"Uploading back identity card photo" = "Uploader foto af bagside af identitetskort"; + +"Uploading front driver's license photo" = "Uploader foto af forside af kørekort"; + +"Uploading front identity card photo" = "Uploader foto af forside af identitetskort"; + +"Uploading passport photo" = "Uploader foto af pas"; + +"Verify your identity" = "Bekræft din identitet"; + +"We could not capture a high-quality image." = "Vi kunne ikke tage et billede i høj kvalitet."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Vi skal bruge en tilladelse til at bruge dit kamera. Tillad kameraadgang i appindstillingerne."; + +"Welcome" = "Velkommen"; + +"You can either try again or upload an image from your device." = "Du kan enten prøve igen eller uploade et billede fra din enhed."; + +"Your selfie images have not been saved. Do you want to leave?" = "Dine selfie-billeder er ikke blevet gemt. Ønsker du at forlade denne session?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/de.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/de.lproj/Localizable.strings new file mode 100644 index 00000000..817e7393 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/de.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Alternativ können Sie manuell ein Foto Ihres Ausweisdokuments hochladen."; + +"App Settings" = "App-Einstellungen"; + +"Back driver's license photo successfully uploaded" = "Das Foto der Rückseite des Führerscheins wurde erfolgreich hochgeladen."; + +"Back identity card photo successfully uploaded" = "Das Foto der Rückseite des Ausweisdokuments wurde erfolgreich hochgeladen."; + +"Back of driver's license" = "Rückseite des Führerscheins"; + +"Back of identity card" = "Rückseite des Personalausweises"; + +"Camera permission" = "Kameraberechtigung"; + +"Camera unavailable" = "Kamera nicht verfügbar"; + +"Capturing…" = "Aufnahme läuft ..."; + +"Choose File" = "Datei auswählen"; + +"Consent" = "Zustimmen"; + +"Could not capture image" = "Aufnahme nicht möglich"; + +"Date of Birth" = "Geburtsdatum"; + +"Date of birth does not look valid" = "Das Geburtsdatum scheint ungültig zu sein"; + +"Flip your driver's license over to the other side" = "Drehen Sie Ihren Führerschein um"; + +"Flip your identity card over to the other side" = "Drehen Sie Ihr Ausweisdokument um"; + +"Front driver's license photo successfully uploaded" = "Das Foto der Vorderseite des Führerscheins wurde erfolgreich hochgeladen."; + +"Front identity card photo successfully uploaded" = "Das Foto der Vorderseite des Ausweisdokuments wurde erfolgreich hochgeladen."; + +"Front of driver's license" = "Vorderseite des Führerscheins"; + +"Front of identity card" = "Vorderseite des Personalausweises"; + +"Go Back" = "Zurück"; + +"Hold still, scanning" = "Bitte nicht bewegen, Scanvorgang läuft"; + +"ID Number" = "Ausweisnummer"; + +"ID Type" = "Art des Ausweisdokuments"; + +"Image of passport" = "Bild des Reisepasses"; + +"Individual CPF" = "Individuelle CPF"; + +"Last 4 of Social Security number" = "Letzte vier Ziffern der Sozialversicherungsnummer"; + +"Loading" = "Wird geladen"; + +"NRIC or FIN" = "NRIC oder FIN"; + +"Passport" = "Reisepass"; + +"Passport photo successfully uploaded" = "Das Foto des Reisepasses wurde erfolgreich hochgeladen."; + +"Personal ID number" = "Personalausweisnummer"; + +"Personal Information" = "Persönliche Angaben"; + +"Phone Number" = "Telefonnummer"; + +"Phone Verification" = "Telefonische Überprüfung"; + +"Photo Library" = "Fotobibliothek"; + +"Please upload an image of your passport" = "Bitte laden Sie ein Bild Ihres Reisepasses hoch"; + +"Please upload images of the front and back of your driver's license" = "Bitte laden Sie Bilder der Vorder- und Rückseite Ihres Führerscheins hoch"; + +"Please upload images of the front and back of your identity card" = "Bitte laden Sie Bilder der Vorder- und Rückseite Ihres Personalausweises hoch"; + +"Position your driver's license in the center of the frame" = "Platzieren Sie Ihren Führerschein genau in der Mitte des Rahmens."; + +"Position your face in the center of the frame." = "Platzieren Sie Ihr Gesicht genau in der Mitte des Rahmens."; + +"Position your identity card in the center of the frame" = "Platzieren Sie Ihr Ausweisdokument genau in der Mitte des Rahmens."; + +"Position your passport in the center of the frame" = "Platzieren Sie Ihren Reisepass genau in der Mitte des Rahmens."; + +"Retake Photos" = "Foto erneut aufnehmen"; + +"Scan" = "Scannen"; + +"Scanned" = "Eingescannt"; + +"Select" = "Auswählen"; + +"Select a location to upload the back of your identity document from" = "Wählen Sie einen Ort zum Hochladen der Rückseite Ihres Ausweisdokuments aus"; + +"Select a location to upload the front of your identity document from" = "Wählen Sie einen Ort zum Hochladen der Vorderseite Ihres Ausweisdokuments aus"; + +"Select back driver's license photo" = "Foto der Rückseite des Führerscheins auswählen"; + +"Select back identity card photo" = "Foto der Rückseite des Ausweisdokuments auswählen"; + +"Select front driver's license photo" = "Foto der Vorderseite des Führerscheins auswählen"; + +"Select front identity card photo" = "Foto der Vorderseite des Ausweisdokuments auswählen"; + +"Select passport photo" = "Foto des Reisepasses auswählen"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Selfie-Aufnahmen"; + +"Selfie captures are complete" = "Die Selfie-Aufnahmen sind abgeschlossen."; + +"Take Photo" = "Aufnahme machen"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Die Bilder Ihres Ausweisdokuments wurden nicht gespeichert. Möchten Sie die Seite verlassen?"; + +"There was an error accessing the camera." = "Beim Zugriff auf die Kamera ist ein Fehler aufgetreten."; + +"Try Again" = "Erneut versuchen"; + +"Unable to establish a connection." = "Es kann keine Verbindung hergestellt werden."; + +"Unsaved changes" = "Nicht gespeicherte Änderungen"; + +"Upload" = "Hochladen"; + +"Upload a Photo" = "Foto hochladen"; + +"Upload your photo ID" = "Lichtbildausweis hochladen"; + +"Uploading back driver's license photo" = "Foto der Rückseite des Führerscheins wird hochgeladen"; + +"Uploading back identity card photo" = "Foto der Rückseite des Ausweisdokuments wird hochgeladen"; + +"Uploading front driver's license photo" = "Foto der Vorderseite des Führerscheins wird hochgeladen"; + +"Uploading front identity card photo" = "Foto der Vorderseite des Ausweisdokuments wird hochgeladen"; + +"Uploading passport photo" = "Foto des Reisepasses wird hochgeladen"; + +"Verify your identity" = "Identität verifizieren"; + +"We could not capture a high-quality image." = "Wir konnten kein qualitativ hochwertiges Bild aufnehmen."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Wir benötigen die Erlaubnis, Ihre Kamera zu verwenden. Bitte gewähren Sie den Kamerazugriff in den App-Einstellungen."; + +"Welcome" = "Willkommen"; + +"You can either try again or upload an image from your device." = "Sie können es noch einmal versuchen oder ein Bild von Ihrem Endgerät hochladen."; + +"Your selfie images have not been saved. Do you want to leave?" = "Ihre Selfie-Aufnahmen wurden nicht gespeichert. Möchten Sie die Seite verlassen?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/el-GR.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/el-GR.lproj/Localizable.strings new file mode 100644 index 00000000..a5dc395b --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/el-GR.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Εναλλακτικά, μπορείτε να αποστείλετε με μη αυτόματο τρόπο μια φωτογραφία του εγγράφου της ταυτότητάς σας."; + +"App Settings" = "Ρυθμίσεις εφαρμογής"; + +"Back driver's license photo successfully uploaded" = "Η φωτογραφία της πίσω πλευράς του διπλώματος οδήγησης στάλθηκε με επιτυχία"; + +"Back identity card photo successfully uploaded" = "Η φωτογραφία της πίσω πλευράς της ταυτότητας στάλθηκε με επιτυχία"; + +"Back of driver's license" = "Πίσω πλευρά διπλώματος οδήγησης"; + +"Back of identity card" = "Πίσω πλευρά ταυτότητας"; + +"Camera permission" = "Άδεια κάμερας"; + +"Camera unavailable" = "Μη διαθέσιμη κάμερα"; + +"Capturing…" = "Καταγραφή…"; + +"Choose File" = "Επιλέξτε αρχείο"; + +"Consent" = "Συγκατάθεση"; + +"Could not capture image" = "Δεν ήταν δυνατή η καταγραφή εικόνας"; + +"Date of Birth" = "Ημερομηνία γέννησης"; + +"Date of birth does not look valid" = "Η ημερομηνία γέννησης δεν φαίνεται έγκυρη"; + +"Flip your driver's license over to the other side" = "Γυρίστε το δίπλωμα οδήγησής σας από την άλλη πλευρά"; + +"Flip your identity card over to the other side" = "Γυρίστε την ταυτότητά σας από την άλλη πλευρά"; + +"Front driver's license photo successfully uploaded" = "Η φωτογραφία της μπροστινής πλευράς του διπλώματος οδήγησης στάλθηκε με επιτυχία"; + +"Front identity card photo successfully uploaded" = "Η φωτογραφία της μπροστινής πλευράς της ταυτότητας στάλθηκε με επιτυχία"; + +"Front of driver's license" = "Μπροστινή πλευρά διπλώματος οδήγησης"; + +"Front of identity card" = "Μπροστινή πλευρά ταυτότητας"; + +"Go Back" = "Επιστροφή"; + +"Hold still, scanning" = "Κρατήστε σταθερά, γίνεται σάρωση"; + +"ID Number" = "Αριθμός ταυτότητας"; + +"ID Type" = "Τύπος αναγνωριστικού"; + +"Image of passport" = "Εικόνα διαβατηρίου"; + +"Individual CPF" = "Ατομικό CPF"; + +"Last 4 of Social Security number" = "Τελευταία 4 ψηφία του Αριθμού κοινωνικής ασφάλισης"; + +"Loading" = "Φόρτωση"; + +"NRIC or FIN" = "NRIC ή FIN"; + +"Passport" = "Διαβατήριο"; + +"Passport photo successfully uploaded" = "Η φωτογραφία του διαβατηρίου στάλθηκε με επιτυχία"; + +"Personal ID number" = "Αριθμός ταυτότητας"; + +"Personal Information" = "Προσωπικές πληροφορίες"; + +"Phone Number" = "Αριθμός τηλεφώνου"; + +"Phone Verification" = "Επαλήθευση αριθμού τηλεφώνου"; + +"Photo Library" = "Βιβλιοθήκη φωτογραφιών"; + +"Please upload an image of your passport" = "Αποστείλετε μια εικόνα του διαβατηρίου σας"; + +"Please upload images of the front and back of your driver's license" = "Αποστείλετε εικόνες της μπροστινής και της πίσω πλευράς του διπλώματος οδήγησης"; + +"Please upload images of the front and back of your identity card" = "Αποστείλετε εικόνες της μπροστινής και της πίσω πλευράς της ταυτότητάς σας"; + +"Position your driver's license in the center of the frame" = "Τοποθετήστε το δίπλωμα οδήγησής σας στο κέντρο του πλαισίου"; + +"Position your face in the center of the frame." = "Τοποθετήστε το πρόσωπό σας στο κέντρο του πλαισίου."; + +"Position your identity card in the center of the frame" = "Τοποθετήστε την ταυτότητά σας στο κέντρο του πλαισίου"; + +"Position your passport in the center of the frame" = "Τοποθετήστε το διαβατήριό σας στο κέντρο του πλαισίου"; + +"Retake Photos" = "Λήψη φωτογραφιών εκ νέου"; + +"Scan" = "Σάρωση"; + +"Scanned" = "Σαρώθηκε"; + +"Select" = "Επιλογή"; + +"Select a location to upload the back of your identity document from" = "Επιλέξτε την τοποθεσία από την οποία θα αποστείλετε την πίσω πλευρά του εγγράφου ταυτότητάς σας"; + +"Select a location to upload the front of your identity document from" = "Επιλέξτε την τοποθεσία από την οποία θα αποστείλετε την μπροστινή πλευρά του εγγράφου ταυτότητάς σας"; + +"Select back driver's license photo" = "Επιλογή φωτογραφίας πίσω πλευράς διπλώματος οδήγησης"; + +"Select back identity card photo" = "Επιλογή φωτογραφίας πίσω πλευράς ταυτότητας"; + +"Select front driver's license photo" = "Επιλογή φωτογραφίας μπροστινής πλευράς διπλώματος οδήγησης"; + +"Select front identity card photo" = "Επιλογή φωτογραφίας μπροστινής πλευράς ταυτότητας"; + +"Select passport photo" = "Επιλογή φωτογραφίας διαβατηρίου"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Λήψεις selfie"; + +"Selfie captures are complete" = "Οι λήψεις selfie ολοκληρώθηκαν"; + +"Take Photo" = "Λήψη φωτογραφίας"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Οι εικόνες του εγγράφου ταυτότητάς σας δεν έχουν αποθηκευτεί. Θέλετε να αποχωρήσετε;"; + +"There was an error accessing the camera." = "Προέκυψε σφάλμα κατά την πρόσβαση στην κάμερα."; + +"Try Again" = "Δοκιμάστε ξανά"; + +"Unable to establish a connection." = "Δεν είναι δυνατή η σύνδεση."; + +"Unsaved changes" = "Μη αποθηκευμένες αλλαγές"; + +"Upload" = "Αποστολή"; + +"Upload a Photo" = "Αποστολή φωτογραφίας"; + +"Upload your photo ID" = "Αποστείλετε την ταυτότητα με φωτογραφία"; + +"Uploading back driver's license photo" = "Αποστολή φωτογραφίας πίσω πλευράς διπλώματος οδήγησης"; + +"Uploading back identity card photo" = "Αποστολή φωτογραφίας πίσω πλευράς ταυτότητας"; + +"Uploading front driver's license photo" = "Αποστολή φωτογραφίας μπροστινής πλευράς διπλώματος οδήγησης"; + +"Uploading front identity card photo" = "Αποστολή φωτογραφίας μπροστινής πλευράς ταυτότητας"; + +"Uploading passport photo" = "Αποστολή φωτογραφίας διαβατηρίου"; + +"Verify your identity" = "Επαληθεύστε την ταυτότητά σας"; + +"We could not capture a high-quality image." = "Δεν ήταν δυνατή η καταγραφή εικόνας υψηλής ποιότητας."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Χρειαζόμαστε άδεια για να χρησιμοποιήσουμε την κάμερά σας. Επιτρέψτε την πρόσβαση της κάμερας από τις ρυθμίσεις εφαρμογής."; + +"Welcome" = "Καλωσήρθατε"; + +"You can either try again or upload an image from your device." = "Μπορείτε να δοκιμάσετε ξανά ή να αποστείλετε μια εικόνα από τη συσκευή σας."; + +"Your selfie images have not been saved. Do you want to leave?" = "Οι selfie δεν αποθηκεύτηκαν. Θέλετε να πραγματοποιήσετε έξοδο;"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/en-GB.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/en-GB.lproj/Localizable.strings new file mode 100644 index 00000000..66dbd02c --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/en-GB.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Alternatively, you may manually upload a photo of your identity document."; + +"App Settings" = "App Settings"; + +"Back driver's license photo successfully uploaded" = "Back driving licence photo successfully uploaded"; + +"Back identity card photo successfully uploaded" = "Back identity card photo successfully uploaded"; + +"Back of driver's license" = "Back of driving licence"; + +"Back of identity card" = "Back of identity card"; + +"Camera permission" = "Camera permission"; + +"Camera unavailable" = "Camera unavailable"; + +"Capturing…" = "Capturing…"; + +"Choose File" = "Choose File"; + +"Consent" = "Consent"; + +"Could not capture image" = "Could not capture image"; + +"Date of Birth" = "Date of Birth"; + +"Date of birth does not look valid" = "Date of birth does not look valid"; + +"Flip your driver's license over to the other side" = "Flip your driving licence over to the other side"; + +"Flip your identity card over to the other side" = "Flip your identity card over to the other side"; + +"Front driver's license photo successfully uploaded" = "Front driving licence photo successfully uploaded"; + +"Front identity card photo successfully uploaded" = "Front identity card photo successfully uploaded"; + +"Front of driver's license" = "Front of driving licence"; + +"Front of identity card" = "Front of identity card"; + +"Go Back" = "Go Back"; + +"Hold still, scanning" = "Hold still, scanning"; + +"ID Number" = "ID Number"; + +"ID Type" = "ID Type"; + +"Image of passport" = "Image of passport"; + +"Individual CPF" = "Individual CPF"; + +"Last 4 of Social Security number" = "Last 4 of Social Security number"; + +"Loading" = "Loading"; + +"NRIC or FIN" = "NRIC or FIN"; + +"Passport" = "Passport"; + +"Passport photo successfully uploaded" = "Passport photo successfully uploaded"; + +"Personal ID number" = "Personal ID number"; + +"Personal Information" = "Personal Information"; + +"Phone Number" = "Phone Number"; + +"Phone Verification" = "Phone Verification"; + +"Photo Library" = "Photo Library"; + +"Please upload an image of your passport" = "Please upload an image of your passport"; + +"Please upload images of the front and back of your driver's license" = "Please upload images of the front and back of your driving licence"; + +"Please upload images of the front and back of your identity card" = "Please upload images of the front and back of your identity card"; + +"Position your driver's license in the center of the frame" = "Position your driving licence in the centre of the frame"; + +"Position your face in the center of the frame." = "Position your face in the centre of the frame."; + +"Position your identity card in the center of the frame" = "Position your identity card in the centre of the frame"; + +"Position your passport in the center of the frame" = "Position your passport in the centre of the frame"; + +"Retake Photos" = "Retake Photos"; + +"Scan" = "Scan"; + +"Scanned" = "Scanned"; + +"Select" = "Select"; + +"Select a location to upload the back of your identity document from" = "Select a location to upload the back of your identity document from"; + +"Select a location to upload the front of your identity document from" = "Select a location to upload the front of your identity document from"; + +"Select back driver's license photo" = "Select back driving licence photo"; + +"Select back identity card photo" = "Select back identity card photo"; + +"Select front driver's license photo" = "Select front driving licence photo"; + +"Select front identity card photo" = "Select front identity card photo"; + +"Select passport photo" = "Select passport photo"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Selfie captures"; + +"Selfie captures are complete" = "Selfie captures are complete"; + +"Take Photo" = "Take Photo"; + +"The images of your identity document have not been saved. Do you want to leave?" = "The images of your identity document have not been saved. Do you want to leave?"; + +"There was an error accessing the camera." = "There was an error accessing the camera."; + +"Try Again" = "Try Again"; + +"Unable to establish a connection." = "Unable to establish a connection."; + +"Unsaved changes" = "Unsaved changes"; + +"Upload" = "Upload"; + +"Upload a Photo" = "Upload a Photo"; + +"Upload your photo ID" = "Upload your photo ID"; + +"Uploading back driver's license photo" = "Uploading back driving licence photo"; + +"Uploading back identity card photo" = "Uploading back identity card photo"; + +"Uploading front driver's license photo" = "Uploading front driving licence photo"; + +"Uploading front identity card photo" = "Uploading front identity card photo"; + +"Uploading passport photo" = "Uploading passport photo"; + +"Verify your identity" = "Verify your identity"; + +"We could not capture a high-quality image." = "We could not capture a high-quality image."; + +"We need permission to use your camera. Please allow camera access in app settings." = "We need permission to use your camera. Please allow camera access in app settings."; + +"Welcome" = "Welcome"; + +"You can either try again or upload an image from your device." = "You can either try again or upload an image from your device."; + +"Your selfie images have not been saved. Do you want to leave?" = "Your selfie images have not been saved. Do you want to leave?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/en.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/en.lproj/Localizable.strings new file mode 100644 index 00000000..7ea96f97 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/en.lproj/Localizable.strings @@ -0,0 +1,245 @@ +/* Body for selfie warmup page */ +"A few photos will be taken automatically on the next step to verify it's you" = "A few photos will be taken automatically on the next step to verify it's you"; + +/* Line 2 of error text displayed to the user when camera permissions have been denied and manually uploading a file is allowed */ +"Alternatively, you may manually upload a photo of your identity document." = "Alternatively, you may manually upload a photo of your identity document."; + +/* Opens the app's settings in the Settings app */ +"App Settings" = "App Settings"; + +/* Accessibility label when back driver's license photo has successfully uploaded */ +"Back driver's license photo successfully uploaded" = "Back driver's license photo successfully uploaded"; + +/* Accessibility label when back identity card photo has successfully uploaded */ +"Back identity card photo successfully uploaded" = "Back identity card photo successfully uploaded"; + +/* Description of back of driver's license image + Title of ID document scanning screen when scanning the back of a driver's license */ +"Back of driver's license" = "Back of driver's license"; + +/* Description of back of identity card image + Title of ID document scanning screen when scanning the back of an identity card */ +"Back of identity card" = "Back of identity card"; + +/* Error title displayed to the user when camera permissions have been denied + Title displayed when requesting camera permissions */ +"Camera permission" = "Camera permission"; + +/* Error title displayed to the user when the device's camera is not available */ +"Camera unavailable" = "Camera unavailable"; + +/* Instructional text for scanning selfies */ +"Capturing…" = "Capturing…"; + +/* When selected in an action sheet, opens the device's file system browser */ +"Choose File" = "Choose File"; + +/* Back button title for returning to consent screen of Identity verification */ +"Consent" = "Consent"; + +/* Error title displayed to the user if we could not scan a high quality image of the user's identity document in a reasonable amount of time */ +"Could not capture image" = "Could not capture image"; + +/* Label for Date of birth field */ +"Date of Birth" = "Date of Birth"; + +/* Message for invalid Date of birth field */ +"Date of birth does not look valid" = "Date of birth does not look valid"; + +/* Instructional text for scanning back of a driver's license */ +"Flip your driver's license over to the other side" = "Flip your driver's license over to the other side"; + +/* Instructional text for scanning back of a identity card */ +"Flip your identity card over to the other side" = "Flip your identity card over to the other side"; + +/* Accessibility label when front driver's license photo has successfully uploaded */ +"Front driver's license photo successfully uploaded" = "Front driver's license photo successfully uploaded"; + +/* Accessibility label when front identity card photo has successfully uploaded */ +"Front identity card photo successfully uploaded" = "Front identity card photo successfully uploaded"; + +/* Description of front of driver's license image + Title of ID document scanning screen when scanning the front of a driver's license */ +"Front of driver's license" = "Front of driver's license"; + +/* Description of front of identity card image + Title of ID document scanning screen when scanning the front of an identity card */ +"Front of identity card" = "Front of identity card"; + +/* Title for selfie warmup page */ +"Get ready to take a selfie" = "Get ready to take a selfie"; + +/* Button to go back to the previous screen */ +"Go Back" = "Go Back"; + +/* Instructional text when camera is focusing on a document while scanning it */ +"Hold still, scanning" = "Hold still, scanning"; + +/* Label for ID number section */ +"ID Number" = "ID Number"; + +/* Back button title to go back to screen to select form of identification (driver's license, passport, etc) to verify someone's identity */ +"ID Type" = "ID Type"; + +/* Description of passport image */ +"Image of passport" = "Image of passport"; + +/* Label for the ID field to collect individual CPF for Brazilian ID */ +"Individual CPF" = "Individual CPF"; + +/* Label for the ID field to collect last 4 of social security number for US ID */ +"Last 4 of Social Security number" = "Last 4 of Social Security number"; + +/* Status while screen is loading */ +"Loading" = "Loading"; + +/* Label for the ID field to collect NRIC or FIN for Singaporean ID */ +"NRIC or FIN" = "NRIC or FIN"; + +/* Title of ID document scanning screen when scanning a passport */ +"Passport" = "Passport"; + +/* Accessibility label when passport photo has successfully uploaded */ +"Passport photo successfully uploaded" = "Passport photo successfully uploaded"; + +/* Label for the personal id number field in the hosted verification details collection form for countries without an exception */ +"Personal ID number" = "Personal ID number"; + +/* Back button title for returning to the individual's perssonal infomation screen */ +"Personal Information" = "Personal Information"; + +/* Section title for collection phone number */ +"Phone Number" = "Phone Number"; + +/* Back button title for returning to the phone verification page */ +"Phone Verification" = "Phone Verification"; + +/* When selected in an action sheet, opens the device's photo library */ +"Photo Library" = "Photo Library"; + +/* Instructions for uploading images of passport */ +"Please upload an image of your passport" = "Please upload an image of your passport"; + +/* Instructions for uploading images of drivers license */ +"Please upload images of the front and back of your driver's license" = "Please upload images of the front and back of your driver's license"; + +/* Instructions for uploading images of identity card */ +"Please upload images of the front and back of your identity card" = "Please upload images of the front and back of your identity card"; + +/* Instructional text for scanning front of a driver's license */ +"Position your driver's license in the center of the frame" = "Position your driver's license in the center of the frame"; + +/* Instructional text for scanning selfies */ +"Position your face in the center of the frame." = "Position your face in the center of the frame."; + +/* Instructional text for scanning front of a identity card */ +"Position your identity card in the center of the frame" = "Position your identity card in the center of the frame"; + +/* Instructional text for scanning a passport */ +"Position your passport in the center of the frame" = "Position your passport in the center of the frame"; + +/* Button text displayed to the user to retake photo */ +"Retake Photos" = "Retake Photos"; + +/* Back button title for returning to the document scan screen */ +"Scan" = "Scan"; + +/* State when identity document has been successfully scanned */ +"Scanned" = "Scanned"; + +/* Button to select a file to upload */ +"Select" = "Select"; + +/* Help text for action sheet that presents ways to upload the back of an identity document image */ +"Select a location to upload the back of your identity document from" = "Select a location to upload the back of your identity document from"; + +/* Help text for action sheet that presents ways to upload the front of an identity document image */ +"Select a location to upload the front of your identity document from" = "Select a location to upload the front of your identity document from"; + +/* Accessibility label to select a photo of back of driver's license */ +"Select back driver's license photo" = "Select back driver's license photo"; + +/* Accessibility label to select a photo of back of identity card */ +"Select back identity card photo" = "Select back identity card photo"; + +/* Accessibility label to select a photo of front of driver's license */ +"Select front driver's license photo" = "Select front driver's license photo"; + +/* Accessibility label to select a photo of front of identity card */ +"Select front identity card photo" = "Select front identity card photo"; + +/* Accessibility label to select a photo of passport */ +"Select passport photo" = "Select passport photo"; + +/* Accessibility label of captured selfie images + Back button title for returning to the selfie screen */ +"Selfie" = "Selfie"; + +/* Title of selfie capture screen */ +"Selfie captures" = "Selfie captures"; + +/* Status text when selfie images have been captured */ +"Selfie captures are complete" = "Selfie captures are complete"; + +/* When selected in an action sheet, opens the device's camera interface */ +"Take Photo" = "Take Photo"; + +/* Text for message of warning alert */ +"The images of your identity document have not been saved. Do you want to leave?" = "The images of your identity document have not been saved. Do you want to leave?"; + +/* Error text displayed to the user when the device's camera is not available */ +"There was an error accessing the camera." = "There was an error accessing the camera."; + +/* Button to attempt to re-scan identity document image */ +"Try Again" = "Try Again"; + +/* Error message that displays when we're unable to connect to the server. */ +"Unable to establish a connection." = "Unable to establish a connection."; + +/* Title for warning alert */ +"Unsaved changes" = "Unsaved changes"; + +/* Back button label for the identity document file upload screen */ +"Upload" = "Upload"; + +/* Button that opens file upload screen + Button to upload a photo */ +"Upload a Photo" = "Upload a Photo"; + +/* Title of document upload screen */ +"Upload your photo ID" = "Upload your photo ID"; + +/* Accessibility label while photo of back of driver's license is uploading */ +"Uploading back driver's license photo" = "Uploading back driver's license photo"; + +/* Accessibility label while photo of back of identity card is uploading */ +"Uploading back identity card photo" = "Uploading back identity card photo"; + +/* Accessibility label while photo of front of driver's license is uploading */ +"Uploading front driver's license photo" = "Uploading front driver's license photo"; + +/* Accessibility label while photo of front of identity card is uploading */ +"Uploading front identity card photo" = "Uploading front identity card photo"; + +/* Accessibility label while photo of passport is uploading */ +"Uploading passport photo" = "Uploading passport photo"; + +/* Displays in the navigation bar title of the Identity Verification Sheet */ +"Verify your identity" = "Verify your identity"; + +/* Error text displayed to the user if we could not scan a high quality image of the user's identity document in a reasonable amount of time */ +"We could not capture a high-quality image." = "We could not capture a high-quality image."; + +/* Line 1 of error text displayed to the user when camera permissions have been denied + Text displayed when requesting camera permissions */ +"We need permission to use your camera. Please allow camera access in app settings." = "We need permission to use your camera. Please allow camera access in app settings."; + +/* Back button title for returning to welcome screen of Identity verification */ +"Welcome" = "Welcome"; + +/* Line 2 of error text displayed to the user if we could not scan a high quality image of the user's identity document in a reasonable amount of time and manually uploading a file is allowed */ +"You can either try again or upload an image from your device." = "You can either try again or upload an image from your device."; + +/* Text for message of warning alert */ +"Your selfie images have not been saved. Do you want to leave?" = "Your selfie images have not been saved. Do you want to leave?"; + diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/es-419.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/es-419.lproj/Localizable.strings new file mode 100644 index 00000000..d9fe3185 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/es-419.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "También puedes subir una foto del documento de identidad manualmente."; + +"App Settings" = "Configuración de la aplicación"; + +"Back driver's license photo successfully uploaded" = "La foto del dorso de la licencia de conducir se cargó correctamente."; + +"Back identity card photo successfully uploaded" = "La foto del dorso del documento de identidad se cargó correctamente."; + +"Back of driver's license" = "Dorso de la licencia de conducir"; + +"Back of identity card" = "Dorso del documento de identidad"; + +"Camera permission" = "Permiso de acceso a la cámara"; + +"Camera unavailable" = "Cámara no disponible"; + +"Capturing…" = "Capturando..."; + +"Choose File" = "Elegir archivo"; + +"Consent" = "Consentimiento"; + +"Could not capture image" = "No se pudo captar la imagen."; + +"Date of Birth" = "Fecha de nacimiento"; + +"Date of birth does not look valid" = "La fecha de nacimiento no parece válida."; + +"Flip your driver's license over to the other side" = "Da vuelta tu licencia de conducir."; + +"Flip your identity card over to the other side" = "Da vuelta tu documento de identidad."; + +"Front driver's license photo successfully uploaded" = "La foto del frente de la licencia de conducir se cargó correctamente."; + +"Front identity card photo successfully uploaded" = "La foto del frente del documento de identidad se cargó correctamente."; + +"Front of driver's license" = "Frente de la licencia de conducir"; + +"Front of identity card" = "Frente del documento de identidad"; + +"Go Back" = "Volver"; + +"Hold still, scanning" = "No te muevas. Escaneando..."; + +"ID Number" = "Número de documento"; + +"ID Type" = "Tipo de ID"; + +"Image of passport" = "Imagen del pasaporte"; + +"Individual CPF" = "CPF individual"; + +"Last 4 of Social Security number" = "Últimos 4 dígitos del número de seguridad social"; + +"Loading" = "Cargando"; + +"NRIC or FIN" = "NRIC o FIN"; + +"Passport" = "Pasaporte"; + +"Passport photo successfully uploaded" = "La foto del pasaporte se cargó correctamente."; + +"Personal ID number" = "Número de documento personal"; + +"Personal Information" = "Información personal"; + +"Phone Number" = "Número de teléfono"; + +"Phone Verification" = "Verificación por teléfono"; + +"Photo Library" = "Librería de fotos"; + +"Please upload an image of your passport" = "Carga una imagen del pasaporte."; + +"Please upload images of the front and back of your driver's license" = "Carga las imágenes del frente y el dorso de tu licencia de conducir."; + +"Please upload images of the front and back of your identity card" = "Carga las imágenes del frente y el dorso de tu documento de identidad."; + +"Position your driver's license in the center of the frame" = "Coloca la licencia de conducir en el centro del recuadro."; + +"Position your face in the center of the frame." = "Coloca la cara en el centro del recuadro."; + +"Position your identity card in the center of the frame" = "Coloca el documento de identidad en el centro del recuadro."; + +"Position your passport in the center of the frame" = "Coloca el pasaporte en el centro del recuadro."; + +"Retake Photos" = "Volver a sacar las fotos"; + +"Scan" = "Escanear"; + +"Scanned" = "Escaneado"; + +"Select" = "Seleccionar"; + +"Select a location to upload the back of your identity document from" = "Selecciona una ubicación desde donde cargar el dorso de tu documento de identidad."; + +"Select a location to upload the front of your identity document from" = "Selecciona una ubicación desde donde cargar el frente de tu documento de identidad."; + +"Select back driver's license photo" = "Seleccionar la foto del dorso de la licencia de conducir"; + +"Select back identity card photo" = "Seleccionar la foto del dorso del documento de identidad"; + +"Select front driver's license photo" = "Seleccionar la foto del frente de la licencia de conducir"; + +"Select front identity card photo" = "Seleccionar la foto del frente del documento de identidad"; + +"Select passport photo" = "Seleccionar la foto del pasaporte"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Capturas de selfie"; + +"Selfie captures are complete" = "Se completaron las capturas de selfie."; + +"Take Photo" = "Tomar una foto"; + +"The images of your identity document have not been saved. Do you want to leave?" = "No se guardaron las imágenes de tu documento de identidad. ¿Quieres salir?"; + +"There was an error accessing the camera." = "Se produjo un error al acceder a la cámara."; + +"Try Again" = "Vuelve a intentarlo"; + +"Unable to establish a connection." = "No fue posible establecer conexión."; + +"Unsaved changes" = "Cambios no guardados"; + +"Upload" = "Cargar"; + +"Upload a Photo" = "Cargar una foto"; + +"Upload your photo ID" = "Carga tu documento de identidad con foto"; + +"Uploading back driver's license photo" = "Cargando la foto del dorso de la licencia de conducir"; + +"Uploading back identity card photo" = "Cargando la foto del dorso del documento de identidad"; + +"Uploading front driver's license photo" = "Cargando la foto del frente de la licencia de conducir"; + +"Uploading front identity card photo" = "Cargando la foto del frente del documento de identidad"; + +"Uploading passport photo" = "Cargando la foto del pasaporte"; + +"Verify your identity" = "Verifica tu identidad"; + +"We could not capture a high-quality image." = "No pudimos captar una imagen de alta calidad."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Necesitamos tu autorización para usar la cámara. Permite el acceso a la cámara en la configuración de la aplicación."; + +"Welcome" = "Te damos la bienvenida"; + +"You can either try again or upload an image from your device." = "Puedes volver a intentarlo o cargar una imagen desde tu dispositivo."; + +"Your selfie images have not been saved. Do you want to leave?" = "No se guardaron las capturas de selfie. ¿Quieres salir?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/es.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/es.lproj/Localizable.strings new file mode 100644 index 00000000..1c6d335d --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/es.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "También puedes subir una foto de tu documento de identidad de forma manual."; + +"App Settings" = "Configuración de la aplicación"; + +"Back driver's license photo successfully uploaded" = "La foto del reverso del permiso de conducir se ha cargado correctamente"; + +"Back identity card photo successfully uploaded" = "La foto del reverso del documento de identidad se ha cargado correctamente"; + +"Back of driver's license" = "Reverso del permiso de conducir"; + +"Back of identity card" = "Reverso del documento de identidad"; + +"Camera permission" = "Permiso de cámara"; + +"Camera unavailable" = "Cámara no disponible"; + +"Capturing…" = "Realizando la selfie..."; + +"Choose File" = "Elegir archivo"; + +"Consent" = "Consentimiento"; + +"Could not capture image" = "No se ha podido capturar la imagen"; + +"Date of Birth" = "Fecha de nacimiento"; + +"Date of birth does not look valid" = "Parece que la fecha de nacimiento no es válida"; + +"Flip your driver's license over to the other side" = "Gira tu permiso de conducir hacia el lado contrario"; + +"Flip your identity card over to the other side" = "Gira tu documento de identidad hacia el lado contrario"; + +"Front driver's license photo successfully uploaded" = "La foto del anverso del permiso de conducir se ha cargado correctamente"; + +"Front identity card photo successfully uploaded" = "La foto del anverso del documento de identidad se ha cargado correctamente"; + +"Front of driver's license" = "Anverso del permiso de conducir"; + +"Front of identity card" = "Anverso del documento de identidad"; + +"Go Back" = "Volver atrás"; + +"Hold still, scanning" = "Espera, se está escaneando"; + +"ID Number" = "Número de ID"; + +"ID Type" = "Tipo de ID"; + +"Image of passport" = "Imagen del pasaporte"; + +"Individual CPF" = "CPF individual"; + +"Last 4 of Social Security number" = "Últimos 4 dígitos del número de seguridad social"; + +"Loading" = "Cargando"; + +"NRIC or FIN" = "NRIC o FIN"; + +"Passport" = "Pasaporte"; + +"Passport photo successfully uploaded" = "La foto del pasaporte se ha cargado correctamente"; + +"Personal ID number" = "Número de identificación personal"; + +"Personal Information" = "Datos personales"; + +"Phone Number" = "Número de teléfono"; + +"Phone Verification" = "Verificación por teléfono"; + +"Photo Library" = "Biblioteca de fotos"; + +"Please upload an image of your passport" = "Carga una imagen de tu pasaporte"; + +"Please upload images of the front and back of your driver's license" = "Carga las imágenes del anverso y el reverso de tu permiso de conducir"; + +"Please upload images of the front and back of your identity card" = "Carga las imágenes del anverso y el reverso de tu documento de identidad"; + +"Position your driver's license in the center of the frame" = "Coloca el permiso de conducir en el centro del marco"; + +"Position your face in the center of the frame." = "Coloca la cara en el centro del marco."; + +"Position your identity card in the center of the frame" = "Coloca el documento de identidad en el centro del marco"; + +"Position your passport in the center of the frame" = "Coloca el pasaporte en el centro del marco"; + +"Retake Photos" = "Volver a sacar las fotos"; + +"Scan" = "Escanear"; + +"Scanned" = "Escaneado"; + +"Select" = "Seleccionar"; + +"Select a location to upload the back of your identity document from" = "Selecciona una ubicación desde la que cargar el reverso de tu documento de identidad"; + +"Select a location to upload the front of your identity document from" = "Selecciona una ubicación desde la que cargar el anverso de tu documento de identidad"; + +"Select back driver's license photo" = "Selecciona la foto del reverso del permiso de conducir"; + +"Select back identity card photo" = "Selecciona la foto del reverso del documento de identidad"; + +"Select front driver's license photo" = "Selecciona la foto del anverso del permiso de conducir"; + +"Select front identity card photo" = "Selecciona la foto del anverso del documento de identidad"; + +"Select passport photo" = "Selecciona la foto del pasaporte"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Capturas de selfies"; + +"Selfie captures are complete" = "Se han realizado las selfies."; + +"Take Photo" = "Hacer una foto"; + +"The images of your identity document have not been saved. Do you want to leave?" = "No se han guardado las imágenes de tu documento de identidad. ¿Quieres salir?"; + +"There was an error accessing the camera." = "Ha habido un error al acceder a la cámara."; + +"Try Again" = "Reintentar"; + +"Unable to establish a connection." = "No se ha podido establecer conexión"; + +"Unsaved changes" = "Cambios no guardados"; + +"Upload" = "Cargar"; + +"Upload a Photo" = "Cargar una foto"; + +"Upload your photo ID" = "Sube tu documento de identidad con foto"; + +"Uploading back driver's license photo" = "Cargando la foto del reverso del permiso de conducir"; + +"Uploading back identity card photo" = "Cargando la foto del reverso del documento de identidad"; + +"Uploading front driver's license photo" = "Cargando la foto del anverso del permiso de conducir"; + +"Uploading front identity card photo" = "Cargando la foto del anverso del documento de identidad"; + +"Uploading passport photo" = "Cargando la foto del pasaporte"; + +"Verify your identity" = "Verifica tu identidad"; + +"We could not capture a high-quality image." = "No hemos podido captar una imagen de alta calidad."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Necesitamos permiso para usar tu cámara. Autoriza el acceso a la cámara en los ajustes de la aplicación."; + +"Welcome" = "Bienvenido"; + +"You can either try again or upload an image from your device." = "Puedes volver a intentarlo o subir una imagen desde tu dispositivo."; + +"Your selfie images have not been saved. Do you want to leave?" = "No se han guardado las selfies. ¿Quieres salir?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/et-EE.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/et-EE.lproj/Localizable.strings new file mode 100644 index 00000000..b5b52478 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/et-EE.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Samuti võite oma isikut tõendava dokumendi foto käsitsi üles laadida."; + +"App Settings" = "Rakenduse sätted"; + +"Back driver's license photo successfully uploaded" = "Juhiloa tagakülje foto üleslaadimine õnnestus"; + +"Back identity card photo successfully uploaded" = "ID-kaardi tagakülje foto üleslaadimine õnnestus"; + +"Back of driver's license" = "Juhiloa tagakülg"; + +"Back of identity card" = "ID-kaardi tagakülg"; + +"Camera permission" = "Kaamera luba"; + +"Camera unavailable" = "Kaamera pole saadaval"; + +"Capturing…" = "Pildistamine …"; + +"Choose File" = "Vali fail"; + +"Consent" = "Nõusolek"; + +"Could not capture image" = "Pilti ei saanud teha"; + +"Date of Birth" = "Sünnikuupäev"; + +"Date of birth does not look valid" = "Sünnikuupäev ei näi olevat õige"; + +"Flip your driver's license over to the other side" = "Pöörake juhiluba ümber"; + +"Flip your identity card over to the other side" = "Pöörake ID-kaart ümber"; + +"Front driver's license photo successfully uploaded" = "Juhiloa esikülje foto üleslaadimine õnnestus"; + +"Front identity card photo successfully uploaded" = "ID-kaardi esikülje foto üleslaadimine õnnestus"; + +"Front of driver's license" = "Juhiloa esikülg"; + +"Front of identity card" = "ID-kaardi esikülg"; + +"Go Back" = "Mine tagasi"; + +"Hold still, scanning" = "Hoidke paigal, skannimine"; + +"ID Number" = "Isikukood"; + +"ID Type" = "Isikut tõendava dokumendi liik"; + +"Image of passport" = "Passi pilt"; + +"Individual CPF" = "Eraisiku CPF"; + +"Last 4 of Social Security number" = "Sotsiaalkindlustuse numbri viimased 4 numbrit"; + +"Loading" = "Laadimine"; + +"NRIC or FIN" = "NRIC või FIN"; + +"Passport" = "Pass"; + +"Passport photo successfully uploaded" = "Passi foto üleslaadimine õnnestus"; + +"Personal ID number" = "Isikukood"; + +"Personal Information" = "Isikuteave"; + +"Phone Number" = "Telefoninumber"; + +"Phone Verification" = "Telefoni kinnitus"; + +"Photo Library" = "Fototeek"; + +"Please upload an image of your passport" = "Laadige üles oma passi pilt"; + +"Please upload images of the front and back of your driver's license" = "Laadige üles oma juhiloa esi- ja tagakülje pildid"; + +"Please upload images of the front and back of your identity card" = "Laadige üles oma ID-kaardi esi- ja tagakülje pildid"; + +"Position your driver's license in the center of the frame" = "Jälgige, et juhiluba oleks raami keskel"; + +"Position your face in the center of the frame." = "Jälgige, et nägu oleks raami keskel."; + +"Position your identity card in the center of the frame" = "Jälgige, et ID-kaart oleks raami keskel"; + +"Position your passport in the center of the frame" = "Jälgige, et pass oleks raami keskel"; + +"Retake Photos" = "Tee fotod uuesti"; + +"Scan" = "Skannimine"; + +"Scanned" = "Skannitud"; + +"Select" = "Vali"; + +"Select a location to upload the back of your identity document from" = "Valige asukoht, kust laadida üles oma isikut tõendava dokumendi tagakülg"; + +"Select a location to upload the front of your identity document from" = "Valige asukoht, kust laadida üles oma isikut tõendava dokumendi esikülg"; + +"Select back driver's license photo" = "Vali juhiloa tagakülje foto"; + +"Select back identity card photo" = "Vali ID-kaardi tagakülje foto"; + +"Select front driver's license photo" = "Vali juhiloa esikülje foto"; + +"Select front identity card photo" = "Vali ID-kaardi esikülje foto"; + +"Select passport photo" = "Vali passi foto"; + +"Selfie" = "Selfi"; + +"Selfie captures" = "Selfi pildistamine"; + +"Selfie captures are complete" = "Selfi on jäädvustatud"; + +"Take Photo" = "Pildista"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Teie isikut tõendava dokumendi pilte pole salvestatud. Kas soovite lahkuda?"; + +"There was an error accessing the camera." = "Kaamerale juurdepääsemisel esines tõrge."; + +"Try Again" = "Proovi uuesti"; + +"Unable to establish a connection." = "Ühendust ei saa luua."; + +"Unsaved changes" = "Salvestamata muudatused"; + +"Upload" = "Üleslaadimine"; + +"Upload a Photo" = "Laadi foto üles"; + +"Upload your photo ID" = "Laadige üles fotoga isikut tõendav dokument"; + +"Uploading back driver's license photo" = "Juhiloa tagakülje foto üleslaadimine"; + +"Uploading back identity card photo" = "ID-kaardi tagakülje foto üleslaadimine"; + +"Uploading front driver's license photo" = "Juhiloa esikülje foto üleslaadimine"; + +"Uploading front identity card photo" = "ID-kaardi esikülje foto üleslaadimine"; + +"Uploading passport photo" = "Passi foto üleslaadimine"; + +"Verify your identity" = "Kinnitage oma isik"; + +"We could not capture a high-quality image." = "Meil ei õnnestunud teha kõrge kvaliteediga pilti."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Vajame teie kaamera kasutamiseks luba. Andke rakenduse sätete kaudu juurdepääs kaamerale."; + +"Welcome" = "Tere tulemast"; + +"You can either try again or upload an image from your device." = "Võite kas uuesti proovida või pildi oma seadmest üles laadida."; + +"Your selfie images have not been saved. Do you want to leave?" = "Teie selfi pilte pole salvestatud. Kas soovite lahkuda?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/fi.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/fi.lproj/Localizable.strings new file mode 100644 index 00000000..1e6e9bc4 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/fi.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Voit vaihtoehtoisesti ladata henkilöllisyysasiakirjan kuvan manuaalisesti."; + +"App Settings" = "Sovelluksen asetukset"; + +"Back driver's license photo successfully uploaded" = "Ajokortin takapuolen kuva ladattu onnistuneesti"; + +"Back identity card photo successfully uploaded" = "Henkilökortin takapuolen kuva ladattu onnistuneesti"; + +"Back of driver's license" = "Ajokortin takapuoli"; + +"Back of identity card" = "Henkilökortin takapuoli"; + +"Camera permission" = "Kameran käyttöoikeus"; + +"Camera unavailable" = "Kamera ei käytössä"; + +"Capturing…" = "Otetaan kuvaa..."; + +"Choose File" = "Valitse tiedosto"; + +"Consent" = "Suostumus"; + +"Could not capture image" = "Kuvaa ei voitu ottaa"; + +"Date of Birth" = "Syntymäaika"; + +"Date of birth does not look valid" = "Syntymäaika ei näytä kelvolliselta"; + +"Flip your driver's license over to the other side" = "Käännä ajokortti toisinpäin"; + +"Flip your identity card over to the other side" = "Käännä henkilökortti toisinpäin"; + +"Front driver's license photo successfully uploaded" = "Ajokortin etupuolen kuva ladattu onnistuneesti"; + +"Front identity card photo successfully uploaded" = "Henkilökortin etupuolen kuva ladattu onnistuneesti"; + +"Front of driver's license" = "Ajokortin etupuoli"; + +"Front of identity card" = "Henkilökortin etupuoli"; + +"Go Back" = "Siirry takaisin"; + +"Hold still, scanning" = "Odota, skannaus kesken"; + +"ID Number" = "Tunnusnumero"; + +"ID Type" = "Henkilökortin tyyppi"; + +"Image of passport" = "Passin kuva"; + +"Individual CPF" = "Yksityishenkilön CPF"; + +"Last 4 of Social Security number" = "Sosiaaliturvatunnuksen 4 viimeistä merkkiä"; + +"Loading" = "Ladataan"; + +"NRIC or FIN" = "NRIC tai FIN"; + +"Passport" = "Passi"; + +"Passport photo successfully uploaded" = "Passikuva ladattu onnistuneesti"; + +"Personal ID number" = "Henkilötunnus"; + +"Personal Information" = "Henkilötiedot"; + +"Phone Number" = "Puhelinnumero"; + +"Phone Verification" = "Vahvistus puhelimella"; + +"Photo Library" = "Kuvakirjasto"; + +"Please upload an image of your passport" = "Lataa kuva passista"; + +"Please upload images of the front and back of your driver's license" = "Lataa ajokortin etu- ja takapuolen kuvat"; + +"Please upload images of the front and back of your identity card" = "Lataa henkilökortin etu- ja takapuolen kuvat"; + +"Position your driver's license in the center of the frame" = "Aseta ajokortti kehyksen keskelle"; + +"Position your face in the center of the frame." = "Aseta kasvosi kuvan keskelle."; + +"Position your identity card in the center of the frame" = "Aseta henkilökortti kehyksen keskelle"; + +"Position your passport in the center of the frame" = "Aseta passi kehyksen keskelle"; + +"Retake Photos" = "Ota kuvat uudelleen"; + +"Scan" = "Skannaa"; + +"Scanned" = "Skannattu"; + +"Select" = "Valitse"; + +"Select a location to upload the back of your identity document from" = "Valitse sijainti, josta haluat ladata henkilöllisyysasiakirjan takapuolen"; + +"Select a location to upload the front of your identity document from" = "Valitse sijainti, josta haluat ladata henkilöllisyysasiakirjan etupuolen"; + +"Select back driver's license photo" = "Valitse ajokortin takapuolen kuva"; + +"Select back identity card photo" = "Valitse henkilökortin takapuolen kuva"; + +"Select front driver's license photo" = "Valitse ajokortin etupuolen kuva"; + +"Select front identity card photo" = "Valitse henkilökortin etupuolen kuva"; + +"Select passport photo" = "Valitse passikuva"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Selfiet"; + +"Selfie captures are complete" = "Selfien ottaminen suortitettu"; + +"Take Photo" = "Ota kuva"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Henkilöllisyysasiakirjan kuvia ei ole tallennettu. Haluatko poistua?"; + +"There was an error accessing the camera." = "Kameran käytössä tapahtui virhe."; + +"Try Again" = "Yritä uudelleen"; + +"Unable to establish a connection." = "Yhteyttä ei voida muodostaa."; + +"Unsaved changes" = "Tallentamattomat muutokset"; + +"Upload" = "Lataa"; + +"Upload a Photo" = "Lataa kuva"; + +"Upload your photo ID" = "Lataa kuvallinen henkilöllisyystodistus"; + +"Uploading back driver's license photo" = "Ladataan ajokortin takapuolen kuvaa"; + +"Uploading back identity card photo" = "Ladataan henkilökortin takapuolen kuvaa"; + +"Uploading front driver's license photo" = "Ladataan ajokortin etupuolen kuvaa"; + +"Uploading front identity card photo" = "Ladataan henkilökortin etupuolen kuvaa"; + +"Uploading passport photo" = "Ladataan passikuvaa"; + +"Verify your identity" = "Vahvista henkilöllisyytesi"; + +"We could not capture a high-quality image." = "Emme kyenneet ottamaan korkealaatuista kuvaa."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Tarvitsemme kameran käyttöoikeuden. Salli käyttöoikeus sovelluksen asetuksissa."; + +"Welcome" = "Tervetuloa"; + +"You can either try again or upload an image from your device." = "Voit joko yrittää uudelleen tai ladata kuvan laitteestasi."; + +"Your selfie images have not been saved. Do you want to leave?" = "Selfiekuviasi ei ole tallennettu. Haluatko poistua?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/fil.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/fil.lproj/Localizable.strings new file mode 100644 index 00000000..39e16710 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/fil.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Bilang alternatibo, maaari kang manu-manong mag-upload ng larawan ng dokumento ng iyong pagkakakilanlan."; + +"App Settings" = "Mga Setting ng App"; + +"Back driver's license photo successfully uploaded" = "Tagumpay na na-upload ang larawan ng likod ng lisensya sa pagmamaneho"; + +"Back identity card photo successfully uploaded" = "Tagumpay na na-upload ang larawan ng likod ng kard ng pagkakakilanlan"; + +"Back of driver's license" = "Likod ng lisensya sa pagmamaneho"; + +"Back of identity card" = "Likod ng kard ng pagkakakilanlan"; + +"Camera permission" = "Pahintulot ng kamera"; + +"Camera unavailable" = "Hindi magamit ang kamera"; + +"Capturing…" = "Kinukunan..."; + +"Choose File" = "Pumili ng File"; + +"Consent" = "Pahintulot"; + +"Could not capture image" = "Hindi makunan ng imahe"; + +"Date of Birth" = "Petsa ng Kapanganakan"; + +"Date of birth does not look valid" = "Mukhang hindi balido ang petsa ng kapanganakan"; + +"Flip your driver's license over to the other side" = "Baliktarin ang iyong lisensya sa pagmamaneho"; + +"Flip your identity card over to the other side" = "Baliktarin ang iyong kard ng pagkakakilanlan"; + +"Front driver's license photo successfully uploaded" = "Tagumpay na na-upload ang larawan ng harap ng lisensya sa pagmamaneho"; + +"Front identity card photo successfully uploaded" = "Tagumpay na na-upload ang larawan ng harap ng kard ng pagkakakilanlan"; + +"Front of driver's license" = "Harap ng lisensya sa pagmamaneho"; + +"Front of identity card" = "Harap ng card ng pagkakakilanlan"; + +"Go Back" = "Bumalik"; + +"Hold still, scanning" = "Huwag gumalaw, ini-iscan"; + +"ID Number" = "Numero ng ID"; + +"ID Type" = "Uri ng ID"; + +"Image of passport" = "Imahe ng pasaporte"; + +"Individual CPF" = "Indibidwal na CPF"; + +"Last 4 of Social Security number" = "Huling 4 ng numero ng Social Security"; + +"Loading" = "Niloload"; + +"NRIC or FIN" = "NRIC o FIN"; + +"Passport" = "Pasaporte"; + +"Passport photo successfully uploaded" = "Tagumpay na na-upload ang larawan ng pasaporte"; + +"Personal ID number" = "Numero ng Personal na ID"; + +"Personal Information" = "Personal na Impormasyon"; + +"Phone Number" = "Numero ng Telepono"; + +"Phone Verification" = "Pag-verify sa Telepono"; + +"Photo Library" = "Library ng Larawan"; + +"Please upload an image of your passport" = "Mangyaring i-upload ang imahe ng iyong pasaporte"; + +"Please upload images of the front and back of your driver's license" = "Mangyaring i-upload ang mga imahe ng harap at likod ng iyong lisensya sa pagmamaneho"; + +"Please upload images of the front and back of your identity card" = "Mangyaring i-upload ang mga imahe ng harap at likod ng iyong card ng pagkakakilanlan"; + +"Position your driver's license in the center of the frame" = "Iposisyon ang iyong lisensya sa pagmamaneho sa gitna ng frame"; + +"Position your face in the center of the frame." = "Iposisyon ang iyong mukha sa gitna ng frame."; + +"Position your identity card in the center of the frame" = "Iposisyon ang iyong kard ng pagkakakilanlan sa gitna ng frame"; + +"Position your passport in the center of the frame" = "Iposisyon ang iyong pasaporte sa gitna ng frame"; + +"Retake Photos" = "Kunan Muli Ang Mga Larawan"; + +"Scan" = "I-scan"; + +"Scanned" = "Na-scan na"; + +"Select" = "Piliin"; + +"Select a location to upload the back of your identity document from" = "Pumili ng lokasyon kung saan i-a-upload ang likod ng iyong dokumento ng pagkakakilanlan"; + +"Select a location to upload the front of your identity document from" = "Pumili ng lokasyon kung saan i-a-upload ang harap ng iyong dokumento ng pagkakakilanlan"; + +"Select back driver's license photo" = "Pumili ng larawan ng likod ng lisensya sa pagmamaneho"; + +"Select back identity card photo" = "Pumili ng larawan ng likod ng kard ng pagkakakilanlan"; + +"Select front driver's license photo" = "Pumili ng larawan ng harap ng lisensya sa pagmamaneho"; + +"Select front identity card photo" = "Pumili ng larawan ng harap ng kard ng pagkakakilanlan"; + +"Select passport photo" = "Pumili ng larawan ng pasaporte"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Mga pagkuha ng selfie"; + +"Selfie captures are complete" = "Kumpleto na ang mga pagkuha ng selfie"; + +"Take Photo" = "Kumuha ng larawan"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Hindi na-save ang mga imahe ng iyong dokumento ng pagkakakilanlan. Gusto mo bang umalis?"; + +"There was an error accessing the camera." = "Nagkaroon ng error sa pag-access sa kamera"; + +"Try Again" = "Subukan Muli"; + +"Unable to establish a connection." = "Hindi mapagtibay ang koneksiyon."; + +"Unsaved changes" = "Mga hindi na-save na pagbabago"; + +"Upload" = "I-upload"; + +"Upload a Photo" = "Mag-upload ng Larawan"; + +"Upload your photo ID" = "I-upload ang iyong photo ID"; + +"Uploading back driver's license photo" = "Ina-upload ang larawan ng likod ng lisensya sa pagmamaneho"; + +"Uploading back identity card photo" = "Ina-upload ang larawan ng likod ng card ng pagkakakilanlan"; + +"Uploading front driver's license photo" = "Ina-upload ang larawan ng harap ng lisensya sa pagmamaneho"; + +"Uploading front identity card photo" = "Ina-upload ang larawan ng harap ng kard ng pagkakakilanlan"; + +"Uploading passport photo" = "Ina-upload ang larawan ng passport"; + +"Verify your identity" = "Patunayan ang iyong pagkakakilanlan"; + +"We could not capture a high-quality image." = "Hindi kami makakuha ng mataas na kalidad na imahe."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Kailangan namin ng pahintulot na gamitin ang iyong kamera. Mangyaring payagan ang pag-access ng kamera sa mga setting ng app."; + +"Welcome" = "Maligayang bati"; + +"You can either try again or upload an image from your device." = "Puwede mong subukan muli o mag-upload ng imahe mula sa iyong device."; + +"Your selfie images have not been saved. Do you want to leave?" = "Hindi pa na-save Ang iyong mga imaheng selfie. Gusto mo bang umalis?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/fr-CA.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/fr-CA.lproj/Localizable.strings new file mode 100644 index 00000000..7948d921 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/fr-CA.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Vous pouvez également téléverser manuellement une photo de votre pièce d'identité."; + +"App Settings" = "Paramètres de l'application"; + +"Back driver's license photo successfully uploaded" = "Photo du verso du permis de conduire téléversée"; + +"Back identity card photo successfully uploaded" = "Photo du verso de la carte d'identité téléversée"; + +"Back of driver's license" = "Verso du permis de conduire"; + +"Back of identity card" = "Verso de la carte d'identité"; + +"Camera permission" = "Autorisation d'accès à l'appareil photo"; + +"Camera unavailable" = "Appareil photo non disponible"; + +"Capturing…" = "Capture en cours..."; + +"Choose File" = "Choisir un fichier"; + +"Consent" = "Consentement"; + +"Could not capture image" = "Impossible de prendre une photo"; + +"Date of Birth" = "Date de naissance"; + +"Date of birth does not look valid" = "La date de naissance ne semble pas valide"; + +"Flip your driver's license over to the other side" = "Retournez votre permis de conduire"; + +"Flip your identity card over to the other side" = "Retournez votre carte d'identité"; + +"Front driver's license photo successfully uploaded" = "Photo du recto du permis de conduire téléversée"; + +"Front identity card photo successfully uploaded" = "Photo du recto de la carte d'identité téléversée"; + +"Front of driver's license" = "Recto du permis de conduire"; + +"Front of identity card" = "Recto de la carte d'identité"; + +"Go Back" = "Retour"; + +"Hold still, scanning" = "Ne bougez pas, numérisation en cours"; + +"ID Number" = "Numéro d'identification"; + +"ID Type" = "Type de pièce d'identité"; + +"Image of passport" = "Photo du passeport"; + +"Individual CPF" = "Numéro CPF"; + +"Last 4 of Social Security number" = "4 derniers chiffres du numéro de sécurité sociale"; + +"Loading" = "Chargement en cours"; + +"NRIC or FIN" = "Numéro NRIC ou FIN"; + +"Passport" = "Passeport"; + +"Passport photo successfully uploaded" = "Photo du passeport téléversée"; + +"Personal ID number" = "Numéro d'identification personnel"; + +"Personal Information" = "Données personnelles"; + +"Phone Number" = "Numéro de téléphone"; + +"Phone Verification" = "Vérification du numéro de téléphone"; + +"Photo Library" = "Bibliothèque de photos"; + +"Please upload an image of your passport" = "Veuillez téléverser une photo de votre passeport"; + +"Please upload images of the front and back of your driver's license" = "Veuillez téléverser une photo du recto et du verso de votre permis de conduire"; + +"Please upload images of the front and back of your identity card" = "Veuillez téléverser une photo du recto et du verso de votre carte d'identité"; + +"Position your driver's license in the center of the frame" = "Placez votre permis de conduire au centre du cadre"; + +"Position your face in the center of the frame." = "Placez votre visage au centre du cadre."; + +"Position your identity card in the center of the frame" = "Placez votre carte d'identité au centre du cadre"; + +"Position your passport in the center of the frame" = "Placez votre passeport au centre du cadre"; + +"Retake Photos" = "Reprendre des photos"; + +"Scan" = "Numériser"; + +"Scanned" = "Numérisé"; + +"Select" = "Sélectionner"; + +"Select a location to upload the back of your identity document from" = "Sélectionnez l'emplacement à partir duquel vous souhaitez téléverser le verso de votre pièce d'identité"; + +"Select a location to upload the front of your identity document from" = "Sélectionnez l'emplacement à partir duquel vous souhaitez téléverser le recto de votre pièce d'identité"; + +"Select back driver's license photo" = "Sélectionner le verso du permis de conduire"; + +"Select back identity card photo" = "Sélectionner le verso de la carte d'identité"; + +"Select front driver's license photo" = "Sélectionner le recto du permis de conduire"; + +"Select front identity card photo" = "Sélectionner le recto de la carte d'identité"; + +"Select passport photo" = "Sélectionner la photo du passeport"; + +"Selfie" = "Égoportrait"; + +"Selfie captures" = "Captures d'égoportraits"; + +"Selfie captures are complete" = "Les captures d'égoportraits sont terminées"; + +"Take Photo" = "Prendre une photo"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Les photos de votre pièce d'identité n'ont pas été enregistrées. Voulez-vous quitter?"; + +"There was an error accessing the camera." = "Une erreur est survenue lors de l'accès à l'appareil photo."; + +"Try Again" = "Réessayer"; + +"Unable to establish a connection." = "Connexion impossible."; + +"Unsaved changes" = "Modifications non enregistrées"; + +"Upload" = "Téléverser"; + +"Upload a Photo" = "Téléverser une photo"; + +"Upload your photo ID" = "Téléverser votre pièce d'identité avec photo"; + +"Uploading back driver's license photo" = "Téléversement de la photo du verso du permis de conduire"; + +"Uploading back identity card photo" = "Téléversement de la photo du verso de la carte d'identité"; + +"Uploading front driver's license photo" = "Téléversement de la photo du recto du permis de conduire"; + +"Uploading front identity card photo" = "Téléversement de la photo du recto de la pièce d'identité"; + +"Uploading passport photo" = "Téléversement de la photo du passeport"; + +"Verify your identity" = "Confirmer votre identité"; + +"We could not capture a high-quality image." = "Impossible de prendre une photo de bonne qualité."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Pour que nous puissions accéder à votre appareil photo, vous devez nous donner votre autorisation dans les paramètres de l'application."; + +"Welcome" = "Bienvenue"; + +"You can either try again or upload an image from your device." = "Vous pouvez réessayer ou téléverser une photo à partir de votre appareil."; + +"Your selfie images have not been saved. Do you want to leave?" = "Vos égoportraits n'ont pas été enregistrés. Voulez-vous quitter?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/fr.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/fr.lproj/Localizable.strings new file mode 100644 index 00000000..778a137a --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/fr.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Vous pouvez également charger manuellement une photo de votre pièce d’identité."; + +"App Settings" = "Paramètres de l'application"; + +"Back driver's license photo successfully uploaded" = "Photo du verso du permis de conduire chargée"; + +"Back identity card photo successfully uploaded" = "Photo du verso de la carte d'identité chargée"; + +"Back of driver's license" = "Verso du permis de conduire"; + +"Back of identity card" = "Verso de la carte d'identité"; + +"Camera permission" = "Autorisation d'accès à l'appareil photo"; + +"Camera unavailable" = "Appareil photo non disponible"; + +"Capturing…" = "Capture en cours..."; + +"Choose File" = "Choisir un fichier"; + +"Consent" = "Consentement"; + +"Could not capture image" = "Impossible de prendre une photo"; + +"Date of Birth" = "Date de naissance"; + +"Date of birth does not look valid" = "La date de naissance ne semble pas valide"; + +"Flip your driver's license over to the other side" = "Retournez votre permis de conduire"; + +"Flip your identity card over to the other side" = "Retournez votre carte d'identité"; + +"Front driver's license photo successfully uploaded" = "Photo du recto du permis de conduire chargée"; + +"Front identity card photo successfully uploaded" = "Photo du recto de la carte d'identité chargée"; + +"Front of driver's license" = "Recto du permis de conduire"; + +"Front of identity card" = "Recto de la carte d'identité"; + +"Go Back" = "Retour"; + +"Hold still, scanning" = "Maintenez le document en place, la numérisation est en cours"; + +"ID Number" = "Numéro d'identification"; + +"ID Type" = "Type de pièce d'identité"; + +"Image of passport" = "Photo du passeport"; + +"Individual CPF" = "Numéro CPF"; + +"Last 4 of Social Security number" = "4 derniers chiffres du numéro de sécurité sociale"; + +"Loading" = "Chargement en cours"; + +"NRIC or FIN" = "Numéro NRIC ou FIN"; + +"Passport" = "Passeport"; + +"Passport photo successfully uploaded" = "Photo du passeport chargée"; + +"Personal ID number" = "Numéro d'identification personnel"; + +"Personal Information" = "Informations personnelles"; + +"Phone Number" = "Numéro de téléphone"; + +"Phone Verification" = "Vérification du numéro de téléphone"; + +"Photo Library" = "Bibliothèque de photos"; + +"Please upload an image of your passport" = "Veuillez charger une photo de votre passeport"; + +"Please upload images of the front and back of your driver's license" = "Veuillez charger une photo du recto et du verso de votre permis de conduire"; + +"Please upload images of the front and back of your identity card" = "Veuillez charger une photo du recto et du verso de votre carte d'identité"; + +"Position your driver's license in the center of the frame" = "Placez votre permis de conduire au centre du cadre"; + +"Position your face in the center of the frame." = "Placez votre visage au centre du cadre."; + +"Position your identity card in the center of the frame" = "Placez votre carte d'identité au centre du cadre"; + +"Position your passport in the center of the frame" = "Placez votre passeport au centre du cadre"; + +"Retake Photos" = "Reprendre des photos"; + +"Scan" = "Numériser"; + +"Scanned" = "Numérisé"; + +"Select" = "Sélectionner"; + +"Select a location to upload the back of your identity document from" = "Sélectionnez l'emplacement à partir duquel vous souhaitez charger le verso de votre pièce d'identité"; + +"Select a location to upload the front of your identity document from" = "Sélectionnez l'emplacement à partir duquel vous souhaitez charger le recto de votre pièce d'identité"; + +"Select back driver's license photo" = "Sélectionner la photo du verso du permis de conduire"; + +"Select back identity card photo" = "Sélectionner la photo du verso de la carte d'identité"; + +"Select front driver's license photo" = "Sélectionner la photo du recto du permis de conduire"; + +"Select front identity card photo" = "Sélectionner la photo du recto de la carte d'identité"; + +"Select passport photo" = "Sélectionner la photo du passeport"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Captures de vos selfies"; + +"Selfie captures are complete" = "Les captures de vos selfies sont terminées"; + +"Take Photo" = "Prendre une photo"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Les images de votre pièce d'identité n'ont pas été enregistrées. Voulez-vous quitter ?"; + +"There was an error accessing the camera." = "Une erreur est survenue lors de l'accès à l'appareil photo."; + +"Try Again" = "Réessayer"; + +"Unable to establish a connection." = "Connexion impossible."; + +"Unsaved changes" = "Modifications non enregistrées"; + +"Upload" = "Charger"; + +"Upload a Photo" = "Charger une photo"; + +"Upload your photo ID" = "Charger votre document d'identité avec photo"; + +"Uploading back driver's license photo" = "Chargement de la photo du verso du permis de conduire"; + +"Uploading back identity card photo" = "Chargement de la photo du verso de la carte d'identité"; + +"Uploading front driver's license photo" = "Chargement de la photo du recto du permis de conduire"; + +"Uploading front identity card photo" = "Chargement de la photo du recto de la carte d'identité"; + +"Uploading passport photo" = "Chargement de la photo du passeport"; + +"Verify your identity" = "Vérifier votre identité"; + +"We could not capture a high-quality image." = "Impossible de prendre une photo de bonne qualité."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Pour que nous puissions accéder à votre appareil photo, vous devez nous donner votre autorisation dans les paramètres de l'application."; + +"Welcome" = "Bienvenue"; + +"You can either try again or upload an image from your device." = "Vous pouvez réessayer ou charger une image à partir de votre appareil."; + +"Your selfie images have not been saved. Do you want to leave?" = "Vos selfies n'ont pas été enregistrés. Voulez-vous quitter ?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/hr.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/hr.lproj/Localizable.strings new file mode 100644 index 00000000..2e42caee --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/hr.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Umjesto toga možete ručno prenijeti fotografiju svog osobnog dokumenta."; + +"App Settings" = "Postavke aplikacije"; + +"Back driver's license photo successfully uploaded" = "Fotografija stražnje strane vozačke dozvole je uspješno učitana"; + +"Back identity card photo successfully uploaded" = "Fotografija stražnje strane osobne iskaznice je uspješno učitana"; + +"Back of driver's license" = "Stražnja strana vozačke dozvole"; + +"Back of identity card" = "Stražnja strana osobne iskaznice"; + +"Camera permission" = "Dozvole kamere"; + +"Camera unavailable" = "Kamera nije dostupna"; + +"Capturing…" = "Snimanje…"; + +"Choose File" = "Odaberi datoteku"; + +"Consent" = "Privola"; + +"Could not capture image" = "Nismo uspjeli snimiti fotografiju"; + +"Date of Birth" = "Datum rođenja"; + +"Date of birth does not look valid" = "Datum rođenja ne izgleda ispravno"; + +"Flip your driver's license over to the other side" = "Okrenite svoju vozačku dozvolu na drugu stranu"; + +"Flip your identity card over to the other side" = "Okrenite svoju osobnu iskaznicu na drugu stranu"; + +"Front driver's license photo successfully uploaded" = "Fotografija prednje strane vozačke dozvole uspješno je učitana"; + +"Front identity card photo successfully uploaded" = "Fotografija prednje strane osobne iskaznice uspješno je učitana"; + +"Front of driver's license" = "Prednja strana vozačke dozvole"; + +"Front of identity card" = "Prednja strana osobne iskaznice"; + +"Go Back" = "Vrati se natrag"; + +"Hold still, scanning" = "Ne pomičite se, skeniranje u tijeku"; + +"ID Number" = "ID broj"; + +"ID Type" = "Vrsta osobnog dokumenta"; + +"Image of passport" = "Slika putovnice"; + +"Individual CPF" = "CPF porezni broj"; + +"Last 4 of Social Security number" = "4 zadnja broja socijalnog osiguranja"; + +"Loading" = "Učitavanje"; + +"NRIC or FIN" = "NRIC ili FIN"; + +"Passport" = "Putovnica"; + +"Passport photo successfully uploaded" = "Fotografija putovnice uspješno učitana"; + +"Personal ID number" = "Osobni identifikacijski broj"; + +"Personal Information" = "Osobni podaci"; + +"Phone Number" = "Telefonski broj"; + +"Phone Verification" = "Provjera uz pomoć telefona"; + +"Photo Library" = "Biblioteka fotografija"; + +"Please upload an image of your passport" = "Molimo učitajte sliku vaše putovnice"; + +"Please upload images of the front and back of your driver's license" = "Molimo vas učitajte slike prednje i stražnje strane vaše vozačke dozvole"; + +"Please upload images of the front and back of your identity card" = "Molimo vas učitajte slike prednje i stražnje strane vaše osobne iskaznice"; + +"Position your driver's license in the center of the frame" = "Postavite svoju vozačku dozvolu u sredinu okvira"; + +"Position your face in the center of the frame." = "Postavite svoje lice u središte okvira."; + +"Position your identity card in the center of the frame" = "Postavite svoju osobnu iskaznicu u sredinu okvira"; + +"Position your passport in the center of the frame" = "Postavite svoju putovnicu u sredinu okvira"; + +"Retake Photos" = "Ponovo snimi fotografije"; + +"Scan" = "Skenirani dokument"; + +"Scanned" = "Skenirano"; + +"Select" = "Odaberi"; + +"Select a location to upload the back of your identity document from" = "Odaberite lokaciju s koje ćete učitati stražnju stranu svog dokumenta"; + +"Select a location to upload the front of your identity document from" = "Odaberite lokaciju s koje ćete učitati prednju stranu svog dokumenta"; + +"Select back driver's license photo" = "Odaberite fotografiju stražnje strane osobne iskaznica"; + +"Select back identity card photo" = "Odaberite fotografiju stražnje strane osobne iskaznice"; + +"Select front driver's license photo" = "Odaberite fotografiju prednje strane vozačke dozvole"; + +"Select front identity card photo" = "Odaberite fotografiju prednje strane osobne iskaznice"; + +"Select passport photo" = "Odaberite fotografiju putovnice"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Snimanje selfie slika"; + +"Selfie captures are complete" = "Snimanje selfi slika je završeno"; + +"Take Photo" = "Snimi fotografiju"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Slike vašeg osobnog dokumenta nisu spremljene. Želite li otići?"; + +"There was an error accessing the camera." = "Došlo je do pogreške pri pristupanju kameri."; + +"Try Again" = "Pokušaj ponovno"; + +"Unable to establish a connection." = "Nije moguće uspostaviti vezu."; + +"Unsaved changes" = "Nespremljene promjene"; + +"Upload" = "Učitaj"; + +"Upload a Photo" = "Učitaj fotografiju"; + +"Upload your photo ID" = "Učitajte svoj identifikacijski dokument s fotografijom"; + +"Uploading back driver's license photo" = "Učitavanje fotografije stražnje strane vozačke dozvole"; + +"Uploading back identity card photo" = "Učitavanje fotografije stražnje strane osobne iskaznice"; + +"Uploading front driver's license photo" = "Učitavanje fotografije prednje strane vozačke dozvole"; + +"Uploading front identity card photo" = "Učitavanje fotografije prednje strane osobne iskaznice"; + +"Uploading passport photo" = "Učitavanje fotografije putovnice"; + +"Verify your identity" = "Potvrdite svoj identitet"; + +"We could not capture a high-quality image." = "Nismo uspjeli snimiti fotografiju visoke kvalitete."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Potrebna nam je dozvola za uporabu kamere. Molimo dozvolite pristup kameri u postavkama aplikacije."; + +"Welcome" = "Dobrodošli"; + +"You can either try again or upload an image from your device." = "Možete pokušati ponovno ili prenijeti sliku sa svog uređaja."; + +"Your selfie images have not been saved. Do you want to leave?" = "Vaše selfie slike nisu pohranjene. Želite li napustiti?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/hu.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/hu.lproj/Localizable.strings new file mode 100644 index 00000000..0aa6f1fc --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/hu.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Emellett lehetősége van arra is, hogy fotót töltsön fel az azonosító okmányáról."; + +"App Settings" = "Alkalmazásbeállítások"; + +"Back driver's license photo successfully uploaded" = "Vezetői engedély hátlapfotója sikeresen feltöltve"; + +"Back identity card photo successfully uploaded" = "Személyazonosító igazolvány hátlapfotója sikeresen feltöltve"; + +"Back of driver's license" = "Vezetői engedély hátlapja"; + +"Back of identity card" = "Személyazonosító igazolvány hátlapja"; + +"Camera permission" = "Kameraengedély"; + +"Camera unavailable" = "A kamera nem érhető el"; + +"Capturing…" = "Felvétel..."; + +"Choose File" = "Fájl kiválasztása"; + +"Consent" = "Hozzájárulás"; + +"Could not capture image" = "Nem sikerült felvételt készíteni"; + +"Date of Birth" = "Születési idő"; + +"Date of birth does not look valid" = "A születési idő megadása kötelező"; + +"Flip your driver's license over to the other side" = "Fordítsa vezetői engedélyét a másik oldalára"; + +"Flip your identity card over to the other side" = "Fordítsa személyazonosító igazolványát a másik oldalára"; + +"Front driver's license photo successfully uploaded" = "Vezetői engedély előlapfotója sikeresen feltöltve"; + +"Front identity card photo successfully uploaded" = "Személyazonosító igazolvány előlapfotója sikeresen feltöltve"; + +"Front of driver's license" = "Vezetői engedély előlapja"; + +"Front of identity card" = "Személyazonosító igazolvány előlapja"; + +"Go Back" = "Visszalépés"; + +"Hold still, scanning" = "Ne mozgassa, beolvasás folyamatban"; + +"ID Number" = "Azonosítószám"; + +"ID Type" = "Azonosító okmány típusa"; + +"Image of passport" = "Útlevél képe"; + +"Individual CPF" = "Személyes CPF"; + +"Last 4 of Social Security number" = "A társadalombiztosítási szám utolsó 4 számjegye"; + +"Loading" = "Betöltés folyamatban"; + +"NRIC or FIN" = "NRIC vagy FIN"; + +"Passport" = "Útlevél"; + +"Passport photo successfully uploaded" = "Útlevélfotó sikeresen feltöltve"; + +"Personal ID number" = "Személyazonosító szám"; + +"Personal Information" = "Személyes adatok"; + +"Phone Number" = "Telefonszám"; + +"Phone Verification" = "Telefonos ellenőrzés"; + +"Photo Library" = "Képek"; + +"Please upload an image of your passport" = "Töltse fel az útlevele képét"; + +"Please upload images of the front and back of your driver's license" = "Töltse fel a vezetői engedélyének előlapját és hátlapját"; + +"Please upload images of the front and back of your identity card" = "Töltse fel a személyazonosító igazolványának előlapját és hátlapját"; + +"Position your driver's license in the center of the frame" = "Igazítsa vezetői engedélyét a kép közepére"; + +"Position your face in the center of the frame." = "Igazítsa az arcát a kép közepére."; + +"Position your identity card in the center of the frame" = "Igazítsa személyazonosító igazolványát a kép közepére"; + +"Position your passport in the center of the frame" = "Igazítsa útlevelét a kép közepére"; + +"Retake Photos" = "Fényképek megismétlése"; + +"Scan" = "Beolvasás"; + +"Scanned" = "Beolvasva"; + +"Select" = "Kiválasztás"; + +"Select a location to upload the back of your identity document from" = "Válassza ki, hogy honnan szeretné feltölteni a személyazonosító okmányának hátlapját"; + +"Select a location to upload the front of your identity document from" = "Válassza ki, hogy honnan szeretné feltölteni a személyazonosító okmányának előlapját"; + +"Select back driver's license photo" = "Vezetői engedély hátlapfotójának kiválasztása"; + +"Select back identity card photo" = "Személyazonosító igazolvány hátlapfotójának kiválasztása"; + +"Select front driver's license photo" = "Vezetői engedély előlapfotójának kiválasztása"; + +"Select front identity card photo" = "Személyazonosító igazolvány előlapfotójának kiválasztása"; + +"Select passport photo" = "Útlevélfotó kiválasztása"; + +"Selfie" = "Szelfi"; + +"Selfie captures" = "Szelfi készítése"; + +"Selfie captures are complete" = "Szelfi felvétel kész"; + +"Take Photo" = "Fénykép készítése"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Nem mentette azonosító okmányának képeit. Biztosan kilép?"; + +"There was an error accessing the camera." = "Hiba történt a kamerához való hozzáférés során"; + +"Try Again" = "Újrapróbálkozás"; + +"Unable to establish a connection." = "Nem létesíthető kapcsolat."; + +"Unsaved changes" = "Mentetlen módosítások"; + +"Upload" = "Feltöltés"; + +"Upload a Photo" = "Fotó feltöltése"; + +"Upload your photo ID" = "Fényképes azonosító igazolvány feltöltése"; + +"Uploading back driver's license photo" = "Vezetői engedély hátlapfotójának feltöltése"; + +"Uploading back identity card photo" = "Személyazonosító igazolvány hátlapfotójának feltöltése"; + +"Uploading front driver's license photo" = "Vezetői engedély előlapfotójának feltöltése"; + +"Uploading front identity card photo" = "Személyazonosító igazolvány előlapfotójának feltöltése"; + +"Uploading passport photo" = "Útlevélfotó feltöltése"; + +"Verify your identity" = "A személyazonosság igazolása"; + +"We could not capture a high-quality image." = "Nem tudtunk jó minőségű képet készíteni."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Engedélyre van szükségünk, hogy hozzáférjünk a kamerához. Engedélyezze a kamerához való hozzáférést az alkalmazásbeállításokban."; + +"Welcome" = "Üdvözöljük!"; + +"You can either try again or upload an image from your device." = "Próbálkozhat újra vagy feltölthet egy képet eszközéről is."; + +"Your selfie images have not been saved. Do you want to leave?" = "Nincs mentve a szelfi, biztos kilép?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/id.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/id.lproj/Localizable.strings new file mode 100644 index 00000000..642a60ce --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/id.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Atau, Anda dapat mengunggah foto dokumen identitas secara manual"; + +"App Settings" = "Pengaturan Aplikasi"; + +"Back driver's license photo successfully uploaded" = "Foto bagian belakang surat izin mengemudi berhasil diunggah"; + +"Back identity card photo successfully uploaded" = "Foto bagian belakang kartu identitas mengemudi berhasil diunggah"; + +"Back of driver's license" = "Bagian belakang surat izin mengemudi"; + +"Back of identity card" = "Bagian belakang kartu identitas"; + +"Camera permission" = "Izin kamera"; + +"Camera unavailable" = "Kamera tidak tersedia"; + +"Capturing…" = "Mengambil gambar..."; + +"Choose File" = "Pilih File"; + +"Consent" = "Persetujuan"; + +"Could not capture image" = "Tidak dapat mengambil gambar"; + +"Date of Birth" = "Tanggal Lahir"; + +"Date of birth does not look valid" = "Tanggal lahir sepertinya tidak valid"; + +"Flip your driver's license over to the other side" = "Balikkan surat izin mengemudi Anda ke sisi yang lain"; + +"Flip your identity card over to the other side" = "Balikkan kartu identitas Anda ke sisi yang lain"; + +"Front driver's license photo successfully uploaded" = "Foto bagian depan surat izin mengemudi berhasil diunggah"; + +"Front identity card photo successfully uploaded" = "Foto bagian depan kartu identitas berhasil diunggah"; + +"Front of driver's license" = "Bagian depan surat izin mengemudi"; + +"Front of identity card" = "Bagian depan kartu identitas"; + +"Go Back" = "Kembali"; + +"Hold still, scanning" = "Jangan bergerak, memindai"; + +"ID Number" = "Nomor Identifikasi"; + +"ID Type" = "Tipe Identitas"; + +"Image of passport" = "Gambar paspor"; + +"Individual CPF" = "CPF perorangan"; + +"Last 4 of Social Security number" = "4 angka terakhir Nomor Jaminan Sosial"; + +"Loading" = "Memuat"; + +"NRIC or FIN" = "NRIC atau FIN"; + +"Passport" = "Paspor"; + +"Passport photo successfully uploaded" = "Foto paspor berhasil diunggah"; + +"Personal ID number" = "Nomor identifikasi pribadi"; + +"Personal Information" = "Informasi Pribadi"; + +"Phone Number" = "Nomor Telepon"; + +"Phone Verification" = "Verifikasi Telepon"; + +"Photo Library" = "Pustaka Foto"; + +"Please upload an image of your passport" = "Unggah gambar paspor Anda"; + +"Please upload images of the front and back of your driver's license" = "Unggah gambar bagian depan dan belakang surat izin mengemudi Anda"; + +"Please upload images of the front and back of your identity card" = "Unggah gambar bagian depan dan belakang kartu identitas Anda"; + +"Position your driver's license in the center of the frame" = "Posisikan surat izin mengemudi Anda di tengah bingkai"; + +"Position your face in the center of the frame." = "Posisikan wajah Anda di tengah bingkai."; + +"Position your identity card in the center of the frame" = "Posisikan kartu identitas Anda di tengah bingkai"; + +"Position your passport in the center of the frame" = "Posisikan paspor Anda di tengah bingkai"; + +"Retake Photos" = "Ambil Ulang Foto"; + +"Scan" = "Pindai"; + +"Scanned" = "Dipindai"; + +"Select" = "Pilih"; + +"Select a location to upload the back of your identity document from" = "Pilih lokasi untuk mengunggah bagian belakang dokumen identitas Anda"; + +"Select a location to upload the front of your identity document from" = "Pilih lokasi untuk mengunggah bagian depan dokumen identitas Anda"; + +"Select back driver's license photo" = "Pilih foto bagian belakang surat izin mengemudi"; + +"Select back identity card photo" = "Pilih foto bagian belakang kartu identitas"; + +"Select front driver's license photo" = "Pilih foto bagian depan surat izin mengemudi"; + +"Select front identity card photo" = "Pilih foto bagian depan kartu identitas"; + +"Select passport photo" = "Pilih foto paspor"; + +"Selfie" = "Swafoto"; + +"Selfie captures" = "Pengambilan gambar swafoto"; + +"Selfie captures are complete" = "Pengambilan gambar swafoto selesai"; + +"Take Photo" = "Ambil Foto"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Gambar dokumen identitas Anda belum disimpan. Apakah Anda ingin keluar?"; + +"There was an error accessing the camera." = "Ada kesalahan saat mengakses kamera."; + +"Try Again" = "Coba Lagi"; + +"Unable to establish a connection." = "Tidak dapat membuat koneksi."; + +"Unsaved changes" = "Perubahan tak tersimpan"; + +"Upload" = "Unggah"; + +"Upload a Photo" = "Unggah Foto"; + +"Upload your photo ID" = "Unggah ID berfoto Anda"; + +"Uploading back driver's license photo" = "Mengunggah foto bagian belakang surat izin mengemudi"; + +"Uploading back identity card photo" = "Mengunggah foto bagian belakang kartu identitas"; + +"Uploading front driver's license photo" = "Mengunggah foto bagian depan surat izin mengemudi"; + +"Uploading front identity card photo" = "Mengunggah foto bagian depan kartu identitas"; + +"Uploading passport photo" = "Mengunggah foto paspor"; + +"Verify your identity" = "Verifikasikan identitas Anda"; + +"We could not capture a high-quality image." = "Kami tidak dapat mengambil gambar berkualitas tinggi."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Kami memerlukan izin Anda untuk menggunakan kamera. Harap izinkan akses kamera di pengaturan aplikasi."; + +"Welcome" = "Selamat datang"; + +"You can either try again or upload an image from your device." = "Anda dapat mencoba lagi atau mengunggah gambar dari perangkat."; + +"Your selfie images have not been saved. Do you want to leave?" = "Gambar swafoto Anda belum disimpan. Apakah Anda ingin keluar?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/it.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/it.lproj/Localizable.strings new file mode 100644 index 00000000..eb9288ce --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/it.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "In alternativa, puoi caricare manualmente una foto del tuo documento di identità."; + +"App Settings" = "Impostazioni app"; + +"Back driver's license photo successfully uploaded" = "Foto del retro della patente di guida caricata"; + +"Back identity card photo successfully uploaded" = "Foto del retro della carta di identità caricata"; + +"Back of driver's license" = "Retro della patente di guida"; + +"Back of identity card" = "Retro del documento di identità"; + +"Camera permission" = "Autorizzazione fotocamera"; + +"Camera unavailable" = "Fotocamera non disponibile"; + +"Capturing…" = "Acquisizione in corso..."; + +"Choose File" = "Scegli file"; + +"Consent" = "Consenti"; + +"Could not capture image" = "Non è stato possibile acquisire l'immagine"; + +"Date of Birth" = "Data di nascita"; + +"Date of birth does not look valid" = "La data di nascita non è valida"; + +"Flip your driver's license over to the other side" = "Gira sull'altro lato la patente di guida"; + +"Flip your identity card over to the other side" = "Gira sull'altro lato la carta di identità"; + +"Front driver's license photo successfully uploaded" = "Foto del fronte della patente di guida caricata"; + +"Front identity card photo successfully uploaded" = "Foto del fronte della carta di identità caricata"; + +"Front of driver's license" = "Fronte della patente di guida"; + +"Front of identity card" = "Fronte della carta di identità"; + +"Go Back" = "Indietro"; + +"Hold still, scanning" = "Tienilo fermo. Scansione in corso"; + +"ID Number" = "Numero documento di identità"; + +"ID Type" = "Tipo di documento di identità"; + +"Image of passport" = "Immagine del passaporto"; + +"Individual CPF" = "CPF personale"; + +"Last 4 of Social Security number" = "Ultime quattro cifre del Social Security Number (SSN)"; + +"Loading" = "Caricamento in corso"; + +"NRIC or FIN" = "NRIC o FIN"; + +"Passport" = "Passaporto"; + +"Passport photo successfully uploaded" = "Foto del passaporto caricata"; + +"Personal ID number" = "Numero documento di identità personale"; + +"Personal Information" = "Informazioni personali"; + +"Phone Number" = "Numero di telefono"; + +"Phone Verification" = "Verifica del numero di telefono"; + +"Photo Library" = "Galleria foto"; + +"Please upload an image of your passport" = "Carica un'immagine del tuo passaporto"; + +"Please upload images of the front and back of your driver's license" = "Carica un'immagine fronte-retro della tua patente di guida"; + +"Please upload images of the front and back of your identity card" = "Carica un'immagine fronte-retro del tuo documento di identità"; + +"Position your driver's license in the center of the frame" = "Posiziona la patente di guida al centro della cornice"; + +"Position your face in the center of the frame." = "Posiziona il volto al centro della cornice."; + +"Position your identity card in the center of the frame" = "Posiziona la carta di identità al centro della cornice"; + +"Position your passport in the center of the frame" = "Posiziona il passaporto al centro della cornice"; + +"Retake Photos" = "Scatta di nuovo le foto"; + +"Scan" = "Scansiona"; + +"Scanned" = "Scansionato"; + +"Select" = "Seleziona"; + +"Select a location to upload the back of your identity document from" = "Seleziona una posizione da cui caricare il retro del tuo documento di identità"; + +"Select a location to upload the front of your identity document from" = "Seleziona una posizione da cui caricare il fronte del tuo documento di identità"; + +"Select back driver's license photo" = "Seleziona la foto del retro della patente di guida"; + +"Select back identity card photo" = "Seleziona la foto del retro della carta di identità"; + +"Select front driver's license photo" = "Seleziona la foto del fronte della patente di guida"; + +"Select front identity card photo" = "Seleziona la foto del fronte della carta di identità"; + +"Select passport photo" = "Seleziona la foto del passaporto"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Acquisizioni di selfie"; + +"Selfie captures are complete" = "L'acquisizione dei selfie è completa"; + +"Take Photo" = "Scatta foto"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Le immagini del tuo documento di identità non sono state salvate. Vuoi uscire?"; + +"There was an error accessing the camera." = "Si è verificato un errore durante l'accesso alla fotocamera."; + +"Try Again" = "Riprova"; + +"Unable to establish a connection." = "Impossibile stabilire una connessione."; + +"Unsaved changes" = "Modifiche non salvate"; + +"Upload" = "Carica"; + +"Upload a Photo" = "Carica una foto"; + +"Upload your photo ID" = "Carica il tuo documento d'identità con foto"; + +"Uploading back driver's license photo" = "Caricamento della foto del retro della patente di guida"; + +"Uploading back identity card photo" = "Caricamento della foto del retro della carta di identità"; + +"Uploading front driver's license photo" = "Caricamento della foto del fronte della patente di guida"; + +"Uploading front identity card photo" = "Caricamento della foto del fronte della carta di identità"; + +"Uploading passport photo" = "Caricamento della foto del passaporto"; + +"Verify your identity" = "Verifica la tua identità"; + +"We could not capture a high-quality image." = "Non è stato possibile acquisire un'immagine di alta qualità."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Devi autorizzare l'utilizzo della tua fotocamera. Concedi l'accesso alla fotocamera nelle impostazioni dell'app."; + +"Welcome" = "Ti diamo il benvenuto"; + +"You can either try again or upload an image from your device." = "Puoi riprovare o caricare un'immagine dal tuo dispositivo."; + +"Your selfie images have not been saved. Do you want to leave?" = "I tuoi selfie non sono stati salvati. Vuoi uscire?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/ja.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/ja.lproj/Localizable.strings new file mode 100644 index 00000000..5dd2ca3a --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/ja.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "または、身分証明書の写真を手動でアップロードすることもできます。"; + +"App Settings" = "アプリの設定"; + +"Back driver's license photo successfully uploaded" = "運転免許証の裏面の写真のアップロードが完了しました"; + +"Back identity card photo successfully uploaded" = "身分証明書の裏面の写真のアップロードが完了しました"; + +"Back of driver's license" = "運転免許証の裏面"; + +"Back of identity card" = "身分証明書の裏面"; + +"Camera permission" = "カメラの使用許可"; + +"Camera unavailable" = "カメラを利用できません"; + +"Capturing…" = "キャプチャーしています..."; + +"Choose File" = "ファイルを選択"; + +"Consent" = "同意する"; + +"Could not capture image" = "画像をキャプチャーできませんでした"; + +"Date of Birth" = "生年月日"; + +"Date of birth does not look valid" = "生年月日が無効のようです"; + +"Flip your driver's license over to the other side" = "運転免許証を裏返してください"; + +"Flip your identity card over to the other side" = "身分証明書を裏返してください"; + +"Front driver's license photo successfully uploaded" = "運転免許証の表面の写真のアップロードが完了しました"; + +"Front identity card photo successfully uploaded" = "身分証明書の表面の写真のアップロードが完了しました"; + +"Front of driver's license" = "運転免許証の表面"; + +"Front of identity card" = "身分証明書の表面"; + +"Go Back" = "戻る"; + +"Hold still, scanning" = "動かさないでください、スキャンしています"; + +"ID Number" = "ID 番号"; + +"ID Type" = "ID タイプ"; + +"Image of passport" = "パスポートの画像"; + +"Individual CPF" = "個人 CPF"; + +"Last 4 of Social Security number" = "社会保障番号の末尾 4 桁"; + +"Loading" = "読み込み中"; + +"NRIC or FIN" = "NRIC または FIN"; + +"Passport" = "パスポート"; + +"Passport photo successfully uploaded" = "パスポートの写真のアップロードが完了しました"; + +"Personal ID number" = "身分証明書番号"; + +"Personal Information" = "個人情報"; + +"Phone Number" = "電話番号"; + +"Phone Verification" = "電話番号の確認"; + +"Photo Library" = "フォトライブラリー"; + +"Please upload an image of your passport" = "パスポートの画像をアップロードしてください"; + +"Please upload images of the front and back of your driver's license" = "運転免許証の表裏両面の画像をアップロードしてください"; + +"Please upload images of the front and back of your identity card" = "身分証明書の表裏両面の画像をアップロードしてください"; + +"Position your driver's license in the center of the frame" = "運転免許証をフレームの中央に置いてください"; + +"Position your face in the center of the frame." = "フレームの中央に顔を置いてください。"; + +"Position your identity card in the center of the frame" = "身分証明書をフレームの中央に置いてください"; + +"Position your passport in the center of the frame" = "パスポートをフレームの中央に置いてください"; + +"Retake Photos" = "写真を撮り直す"; + +"Scan" = "スキャン"; + +"Scanned" = "スキャン終了"; + +"Select" = "選択"; + +"Select a location to upload the back of your identity document from" = "身分証明書の裏面をどこからアップロードするのかを選択してください"; + +"Select a location to upload the front of your identity document from" = "身分証明書の表面をどこからアップロードするのかを選択してください"; + +"Select back driver's license photo" = "運転免許証の裏面の写真を選択"; + +"Select back identity card photo" = "身分証明書の裏面の写真を選択"; + +"Select front driver's license photo" = "運転免許証の表面の写真を選択"; + +"Select front identity card photo" = "身分証明書の表面の写真を選択"; + +"Select passport photo" = "パスポートの写真を選択"; + +"Selfie" = "セルフィ―"; + +"Selfie captures" = "セルフィ―のキャプチャー"; + +"Selfie captures are complete" = "セルフィ―のキャプチャーが完了しました"; + +"Take Photo" = "写真を撮る"; + +"The images of your identity document have not been saved. Do you want to leave?" = "身分証明書の画像が保存されていません。終了しますか?"; + +"There was an error accessing the camera." = "カメラへのアクセス中にエラーが発生しました。"; + +"Try Again" = "やり直す"; + +"Unable to establish a connection." = "接続を確立できません"; + +"Unsaved changes" = "保存されていない変更"; + +"Upload" = "アップロード"; + +"Upload a Photo" = "写真をアップロード"; + +"Upload your photo ID" = "写真付き身分証明書をアップロードする"; + +"Uploading back driver's license photo" = "運転免許証の裏面をアップロードしています"; + +"Uploading back identity card photo" = "身分証明書の裏面をアップロードしています"; + +"Uploading front driver's license photo" = "運転免許証の表面をアップロードしています"; + +"Uploading front identity card photo" = "身分証明書の表面をアップロードしています"; + +"Uploading passport photo" = "パスポートの写真をアップロードしています"; + +"Verify your identity" = "本人確認を行う"; + +"We could not capture a high-quality image." = "高画質の画像をキャプチャーできませんでした。"; + +"We need permission to use your camera. Please allow camera access in app settings." = "カメラへのアクセス許可が必要です。アプリの設定でカメラへのアクセスを許可してください。"; + +"Welcome" = "初期画面"; + +"You can either try again or upload an image from your device." = "もう一度お試しいただくか、デバイスから画像をアプロードしてください。"; + +"Your selfie images have not been saved. Do you want to leave?" = "自撮りの画像が保存されていません。終了しますか?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/ko.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/ko.lproj/Localizable.strings new file mode 100644 index 00000000..4d8f5a5b --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/ko.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "또는 신분증 사진을 직접 업로드하실 수도 있습니다."; + +"App Settings" = "앱 설정"; + +"Back driver's license photo successfully uploaded" = "운전 면허증 뒷면 사진 업로드 성공"; + +"Back identity card photo successfully uploaded" = "신분증 뒷면 사진 업로드 성공"; + +"Back of driver's license" = "운전 면허증 뒷면"; + +"Back of identity card" = "신분증 뒷면"; + +"Camera permission" = "카메라 권한"; + +"Camera unavailable" = "카메라 사용 불가"; + +"Capturing…" = "캡처 중…"; + +"Choose File" = "파일 선택"; + +"Consent" = "동의"; + +"Could not capture image" = "이미지를 캡처할 수 없음"; + +"Date of Birth" = "생년월일"; + +"Date of birth does not look valid" = "생년월일이 유효하지 않습니다."; + +"Flip your driver's license over to the other side" = "운전 면허증 반대쪽 면이 보이게 뒤집으십시오."; + +"Flip your identity card over to the other side" = "신분증 반대쪽 면이 보이게 뒤집으십시오."; + +"Front driver's license photo successfully uploaded" = "운전 면허증 앞면 사진 업로드 성공"; + +"Front identity card photo successfully uploaded" = "신분증 앞면 사진 업로드 성공"; + +"Front of driver's license" = "운전 면허증 앞면"; + +"Front of identity card" = "신분증 앞면"; + +"Go Back" = "돌아가기"; + +"Hold still, scanning" = "움직이지 마십시오. 스캔하는 중입니다."; + +"ID Number" = "ID 번호"; + +"ID Type" = "신분증 유형"; + +"Image of passport" = "여권 이미지"; + +"Individual CPF" = "개별 CPF"; + +"Last 4 of Social Security number" = "사회 보장 번호의 마지막 4자리 숫자"; + +"Loading" = "로드 중"; + +"NRIC or FIN" = "NRIC 또는 FIN"; + +"Passport" = "여권"; + +"Passport photo successfully uploaded" = "여권 사진 업로드 성공"; + +"Personal ID number" = "개인 ID 번호"; + +"Personal Information" = "개인 정보"; + +"Phone Number" = "전화번호"; + +"Phone Verification" = "전화 확인"; + +"Photo Library" = "사진 라이브러리"; + +"Please upload an image of your passport" = "여권의 앞면과 뒷면 이미지를 업로드하십시오."; + +"Please upload images of the front and back of your driver's license" = "운전 면허증의 앞면과 뒷면 이미지를 업로드하십시오."; + +"Please upload images of the front and back of your identity card" = "신분증의 앞면과 뒷면 이미지를 업로드하십시오."; + +"Position your driver's license in the center of the frame" = "프레임 중앙에 운전 면허증을 놓으십시오."; + +"Position your face in the center of the frame." = "프레임 중앙에 얼굴을 놓으십시오."; + +"Position your identity card in the center of the frame" = "프레임 중앙에 신분증을 놓으십시오."; + +"Position your passport in the center of the frame" = "프레임 중앙에 여권을 놓으십시오."; + +"Retake Photos" = "사진 다시 찍기"; + +"Scan" = "스캔"; + +"Scanned" = "스캔 완료"; + +"Select" = "선택"; + +"Select a location to upload the back of your identity document from" = "신분 증명서 뒷면을 업로드할 위치 선택 -"; + +"Select a location to upload the front of your identity document from" = "신분 증명서 앞면을 업로드할 위치 선택 -"; + +"Select back driver's license photo" = "운전 면허증 뒷면 사진 선택"; + +"Select back identity card photo" = "신분증 뒷면 사진 선택"; + +"Select front driver's license photo" = "운전 면허증 앞면 사진 선택"; + +"Select front identity card photo" = "신분증 앞면 사진 선택"; + +"Select passport photo" = "여권 사진 선택"; + +"Selfie" = "셀카"; + +"Selfie captures" = "캡처된 셀카"; + +"Selfie captures are complete" = "셀카 캡처 완료"; + +"Take Photo" = "사진 촬영"; + +"The images of your identity document have not been saved. Do you want to leave?" = "신분 증명서 이미지가 저장되지 않았습니다. 종료하시겠습니까?"; + +"There was an error accessing the camera." = "카메라에 액세스하는 도중 오류가 발생했습니다."; + +"Try Again" = "다시 시도"; + +"Unable to establish a connection." = "연결을 설정할 수 없습니다."; + +"Unsaved changes" = "저장되지 않은 변경 사항"; + +"Upload" = "업로드"; + +"Upload a Photo" = "사진 업로드"; + +"Upload your photo ID" = "사진 ID 업로드"; + +"Uploading back driver's license photo" = "운전 면허증 뒷면 사진 업로드하는 중"; + +"Uploading back identity card photo" = "신분증 뒷면 사진 업로드하는 중"; + +"Uploading front driver's license photo" = "운전 면허증 앞면 사진 업로드하는 중"; + +"Uploading front identity card photo" = "신분증 앞면 사진 업로드하는 중"; + +"Uploading passport photo" = "여권 사진 업로드하는 중"; + +"Verify your identity" = "ID 확인"; + +"We could not capture a high-quality image." = "고품질 이미지를 캡처할 수 없습니다."; + +"We need permission to use your camera. Please allow camera access in app settings." = "카메라 사용 권한이 필요합니다. 앱 설정에서 카메라 액세스를 허용해 주십시오."; + +"Welcome" = "환영합니다."; + +"You can either try again or upload an image from your device." = "다시 시도하거나 디바이스에 있는 이미지를 업로드하실 수 있습니다."; + +"Your selfie images have not been saved. Do you want to leave?" = "셀카 이미지가 저장되지 않았습니다. 종료하시겠습니까?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/lt-LT.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/lt-LT.lproj/Localizable.strings new file mode 100644 index 00000000..a844541f --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/lt-LT.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Arba galite patys įkelti tapatybės dokumento nuotrauką."; + +"App Settings" = "Programos nustatymai"; + +"Back driver's license photo successfully uploaded" = "Sėkmingai įkelta vairuotojo pažymėjimo galinės pusės nuotrauka"; + +"Back identity card photo successfully uploaded" = "Sėkmingai įkelta asmens tapatybės kortelės galinės pusės nuotrauka"; + +"Back of driver's license" = "Vairuotojo pažymėjimo galinė pusė"; + +"Back of identity card" = "Asmens tapatybės kortelės galinė pusė"; + +"Camera permission" = "Fotoaparato leidimas"; + +"Camera unavailable" = "Fotoaparatas nepasiekiamas"; + +"Capturing…" = "Fiksuojama..."; + +"Choose File" = "Pasirinkite failą"; + +"Consent" = "Sutikti"; + +"Could not capture image" = "Nepavyko užfiksuoti vaizdo"; + +"Date of Birth" = "Gimimo data"; + +"Date of birth does not look valid" = "Gimimo data neatrodo tinkama"; + +"Flip your driver's license over to the other side" = "Apverskite vairuotojo pažymėjimą kita puse"; + +"Flip your identity card over to the other side" = "Apverskite asmens tapatybės kortelę kita puse"; + +"Front driver's license photo successfully uploaded" = "Sėkmingai įkelta vairuotojo pažymėjimo priekinės pusės nuotrauka"; + +"Front identity card photo successfully uploaded" = "Sėkmingai įkelta asmens tapatybės kortelės priekinės pusės nuotrauka"; + +"Front of driver's license" = "Vairuotojo pažymėjimo priekinė pusė"; + +"Front of identity card" = "Asmens tapatybės kortelės priekinė pusė"; + +"Go Back" = "Atgal"; + +"Hold still, scanning" = "Išlaikyti nejudinant, nuskaitoma"; + +"ID Number" = "Identifikacinis numeris"; + +"ID Type" = "Tapatybės dokumento tipas"; + +"Image of passport" = "Paso vaizdas"; + +"Individual CPF" = "Asmens CPF"; + +"Last 4 of Social Security number" = "Paskutiniai 4 socialinio draudimo numerio skaitmenys"; + +"Loading" = "Įkeliama"; + +"NRIC or FIN" = "NRIC arba FIN"; + +"Passport" = "Pasas"; + +"Passport photo successfully uploaded" = "Sėkmingai įkelta paso nuotrauka"; + +"Personal ID number" = "Asmens kodas"; + +"Personal Information" = "Asmeninė informacija"; + +"Phone Number" = "Telefono numeris"; + +"Phone Verification" = "Telefono patvirtinimas"; + +"Photo Library" = "Nuotraukų biblioteka"; + +"Please upload an image of your passport" = "Įkelkite paso vaizdą"; + +"Please upload images of the front and back of your driver's license" = "Įkelkite vairuotojo pažymėjimo priekinės ir galinės pusės vaizdus"; + +"Please upload images of the front and back of your identity card" = "Įkelkite asmens tapatybės kortelės priekinės ir galinės pusės vaizdus"; + +"Position your driver's license in the center of the frame" = "Vairuotojo pažymėjimas turi būti rėmelio centre"; + +"Position your face in the center of the frame." = "Veidas turi būti rėmelio centre."; + +"Position your identity card in the center of the frame" = "Asmens tapatybės kortelė turi būti rėmelio centre"; + +"Position your passport in the center of the frame" = "Pasas turi būti rėmelio centre"; + +"Retake Photos" = "Perfotografuoti"; + +"Scan" = "Nuskaityti"; + +"Scanned" = "Nuskaityta"; + +"Select" = "Pasirinkti"; + +"Select a location to upload the back of your identity document from" = "Pasirinkite vietą, iš kur įkelsite asmens tapatybės dokumento galinės pusės vaizdą"; + +"Select a location to upload the front of your identity document from" = "Pasirinkite vietą, iš kur įkelsite asmens tapatybės dokumento priekinės pusės vaizdą"; + +"Select back driver's license photo" = "Pasirinkite vairuotojo pažymėjimo galinės pusės nuotrauką"; + +"Select back identity card photo" = "Pasirinkite asmens tapatybės kortelės galinės pusės nuotrauką"; + +"Select front driver's license photo" = "Pasirinkite vairuotojo pažymėjimo priekinės pusės nuotrauką"; + +"Select front identity card photo" = "Pasirinkite asmens tapatybės kortelės priekinės pusės nuotrauką"; + +"Select passport photo" = "Pasirinkite paso nuotrauką"; + +"Selfie" = "Asmenukė"; + +"Selfie captures" = "Asmenukių įrašai"; + +"Selfie captures are complete" = "Baigti asmenukių įrašai"; + +"Take Photo" = "Fotografuoti"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Jūsų asmens tapatybės dokumento vaizdai neįrašyti. Ar norite išeiti?"; + +"There was an error accessing the camera." = "Bandant pasiekti fotoaparatą įvyko klaida."; + +"Try Again" = "Bandyti dar kartą"; + +"Unable to establish a connection." = "Nepavyko prisijungti."; + +"Unsaved changes" = "Neįrašyti keitimai"; + +"Upload" = "Įkelti"; + +"Upload a Photo" = "Įkelti nuotrauką"; + +"Upload your photo ID" = "Įkelkite savo asmens dokumento nuotrauką"; + +"Uploading back driver's license photo" = "Įkeliama vairuotojo pažymėjimo galinės pusės nuotrauka"; + +"Uploading back identity card photo" = "Įkeliama asmens tapatybės kortelės galinės pusės nuotrauka"; + +"Uploading front driver's license photo" = "Įkeliama vairuotojo pažymėjimo priekinės pusės nuotrauka"; + +"Uploading front identity card photo" = "Įkeliama asmens tapatybės kortelės priekinės pusės nuotrauka"; + +"Uploading passport photo" = "Įkeliama paso nuotrauka"; + +"Verify your identity" = "Patvirtinkite tapatybę"; + +"We could not capture a high-quality image." = "Nepavyko užfiksuoti aukštos kokybės vaizdo."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Mums reikia leidimo naudoti jūsų fotoaparatą. Suteikite priegą prie fotoaparato programos nustatymuose."; + +"Welcome" = "Pasveikinimas"; + +"You can either try again or upload an image from your device." = "Galite bandyti dar kartą arba įkelti vaizdą iš savo įrenginio."; + +"Your selfie images have not been saved. Do you want to leave?" = "Jūsų asmenukės vaizdai neįrašyti. Ar norite išeiti?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/lv-LV.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/lv-LV.lproj/Localizable.strings new file mode 100644 index 00000000..9073867f --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/lv-LV.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Vai arī varat manuāli augšupielādēt identifikācijas dokumenta fotoattēlu."; + +"App Settings" = "Lietotnes iestatījumi"; + +"Back driver's license photo successfully uploaded" = "Autovadītāja apliecības aizmugurējā puse ir veiksmīgi augšupielādēta"; + +"Back identity card photo successfully uploaded" = "Identifikācijas kartes aizmugurējā puse ir veiksmīgi augšupielādēta"; + +"Back of driver's license" = "Autovadītāja apliecības aizmugure"; + +"Back of identity card" = "Identifikācijas kartes aizmugure"; + +"Camera permission" = "Kameras atļauja"; + +"Camera unavailable" = "Kamera nav pieejama"; + +"Capturing…" = "Uzņem attēlu…"; + +"Choose File" = "Izvēlieties failu"; + +"Consent" = "Piekrišana"; + +"Could not capture image" = "Neizdevās uzņemt attēlu"; + +"Date of Birth" = "Dzimšanas datums"; + +"Date of birth does not look valid" = "Dzimšanas datums neizskatās derīgs"; + +"Flip your driver's license over to the other side" = "Apgrieziet autovadītāja apliecību uz otru pusi"; + +"Flip your identity card over to the other side" = "Apgrieziet identifikācijas karti uz otru pusi"; + +"Front driver's license photo successfully uploaded" = "Autovadītāja apliecības priekšpuse ir veiksmīgi augšupielādēta"; + +"Front identity card photo successfully uploaded" = "Identifikācijas kartes priekšpuse ir veiksmīgi augšupielādēta"; + +"Front of driver's license" = "Autovadītāja apliecības priekšpuse"; + +"Front of identity card" = "Identifikācijas kartes priekšpuse"; + +"Go Back" = "Doties atpakaļ"; + +"Hold still, scanning" = "Nekustiniet, notiek skenēšana"; + +"ID Number" = "ID numurs"; + +"ID Type" = "ID veids"; + +"Image of passport" = "Pases attēls"; + +"Individual CPF" = "Fiziskas personas CPF"; + +"Last 4 of Social Security number" = "Pēdējie 4 cipari no sociālās apdrošināšanas numura"; + +"Loading" = "Ielādē"; + +"NRIC or FIN" = "NRIC vai FIN"; + +"Passport" = "Pase"; + +"Passport photo successfully uploaded" = "Pases fotoattēls ir veiksmīgi augšupielādēts"; + +"Personal ID number" = "Personas ID numurs"; + +"Personal Information" = "Personas dati"; + +"Phone Number" = "Tālruņa numurs"; + +"Phone Verification" = "Tālruņa verifikācija"; + +"Photo Library" = "Attēlu bibliotēka"; + +"Please upload an image of your passport" = "Lūdzu, augšupielādējiet savas pases attēlu."; + +"Please upload images of the front and back of your driver's license" = "Lūdzu, augšupielādējiet attēlus no jūsu autovadītāja apliecības priekšpuses un aizmugures"; + +"Please upload images of the front and back of your identity card" = "Lūdzu, augšupielādējiet attēlus no jūsu identifikācijas kartes priekšpuses un aizmugures"; + +"Position your driver's license in the center of the frame" = "Gādājiet, lai autovadītāja apliecība būtu kadra centrā"; + +"Position your face in the center of the frame." = "Gādājiet, lai seja būtu kadra centrā."; + +"Position your identity card in the center of the frame" = "Gādājiet, lai identifikācijas karte būtu kadra centrā"; + +"Position your passport in the center of the frame" = "Gādājiet, lai pase būtu kadra centrā"; + +"Retake Photos" = "Uzņemt vēlreiz fotogrāfijas"; + +"Scan" = "Skenēt"; + +"Scanned" = "Skenēts"; + +"Select" = "Atlasīt"; + +"Select a location to upload the back of your identity document from" = "Atlasiet atrašanās vietu, kurā augšupielādēt jūsu identifikācijas dokumenta aizmuguri no"; + +"Select a location to upload the front of your identity document from" = "Atlasiet atrašanās vietu, kurā augšupielādēt jūsu identifikācijas dokumenta priekšpusi no"; + +"Select back driver's license photo" = "Atlasīt autovadītāja apliecības aizmugurējo fotoattēlu"; + +"Select back identity card photo" = "Atlasīt identifikācijas kartes aizmugurējo fotoattēlu"; + +"Select front driver's license photo" = "Atlasīt autovadītāja apliecības priekšpuses fotoattēlu"; + +"Select front identity card photo" = "Atlasīt identifikācijas kartes priekšpuses fotoattēlu"; + +"Select passport photo" = "Atlasīt pases fotoattēlu"; + +"Selfie" = "Pašportrets"; + +"Selfie captures" = "Pašportreta uzņēmumi"; + +"Selfie captures are complete" = "Pašportreta uzņēmumi ir pabeigti"; + +"Take Photo" = "Uzņemt attēlu"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Jūsu identifikācijas dokumenta attēli nav saglabāti. Vai vēlaties iziet?"; + +"There was an error accessing the camera." = "Radās kļūda, piekļūstot kamerai."; + +"Try Again" = "Mēģināt vēlreiz"; + +"Unable to establish a connection." = "Nevar izveidot savienojumu."; + +"Unsaved changes" = "Nesaglabātās izmaiņas"; + +"Upload" = "Augšupielādēt"; + +"Upload a Photo" = "Augšupielādēt fotoattēlu"; + +"Upload your photo ID" = "Augšupielādējiet savu ID dokumentu ar fotogrāfiju"; + +"Uploading back driver's license photo" = "Augšupielādē autovadītāja apliecības aizmugurējo fotoattēlu"; + +"Uploading back identity card photo" = "Augšupielādē identifikācijas kartes aizmugurējo fotoattēlu"; + +"Uploading front driver's license photo" = "Augšupielādē autovadītāja apliecības priekšpuses fotoattēlu"; + +"Uploading front identity card photo" = "Augšupielādē identifikācijas kartes priekšpuses fotoattēlu"; + +"Uploading passport photo" = "Augšupielādē pases fotoattēlu"; + +"Verify your identity" = "Verificējiet savu identitāti"; + +"We could not capture a high-quality image." = "Neizdevās uzņemt augstas kvalitātes attēlu."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Lai izmantotu jūsu kameru, mums ir nepieciešama jūsu atļauja. Lūdzu, savos lietotnes iestatījumos iespējojiet kameras piekļuvi."; + +"Welcome" = "Laipni lūdzam"; + +"You can either try again or upload an image from your device." = "Varat mēģināt vēlreiz vai augšupielādēt attēlu no jūsu ierīces."; + +"Your selfie images have not been saved. Do you want to leave?" = "Jūsu pašportreta attēli nav saglabāti. Vai vēlaties iziet?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/ms-MY.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/ms-MY.lproj/Localizable.strings new file mode 100644 index 00000000..851b9ab5 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/ms-MY.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Sebagai alternatif, anda boleh memuat naik foto dokumen pengenalan anda secara manual."; + +"App Settings" = "Tetapan Aplikasi"; + +"Back driver's license photo successfully uploaded" = "Foto bahagian belakang lesen memandu berjaya dimuat naik"; + +"Back identity card photo successfully uploaded" = "Foto bahagian belakang kad pengenalan berjaya dimuat naik"; + +"Back of driver's license" = "Bahagian belakang lesen memandu"; + +"Back of identity card" = "Bahagian belakang kad pengenalan"; + +"Camera permission" = "Keizinan kamera"; + +"Camera unavailable" = "Kamera tidak tersedia"; + +"Capturing…" = "Sedang ditangkap..."; + +"Choose File" = "Pilih Fail"; + +"Consent" = "Persetujuan"; + +"Could not capture image" = "Imej tidak dapat ditangkap"; + +"Date of Birth" = "Tarikh Lahir"; + +"Date of birth does not look valid" = "Tarikh lahir tampaknya tidak sah"; + +"Flip your driver's license over to the other side" = "Terbalikkan lesen memandu anda ke sebelah satu lagi"; + +"Flip your identity card over to the other side" = "Terbalikkan kad pengenalan anda ke sebelah satu lagi"; + +"Front driver's license photo successfully uploaded" = "Foto bahagian hadapan lesen memandu berjaya dimuat naik"; + +"Front identity card photo successfully uploaded" = "Foto bahagian hadapan kad pengenalan berjaya dimuat naik"; + +"Front of driver's license" = "Bahagian hadapan lesen memandu"; + +"Front of identity card" = "Bahagian hadapan kad pengenalan"; + +"Go Back" = "Undur"; + +"Hold still, scanning" = "Jangan bergerak, pengimbasan sedang berlangsung"; + +"ID Number" = "Nombor ID"; + +"ID Type" = "Jenis ID"; + +"Image of passport" = "Imej pasport"; + +"Individual CPF" = "CPF Individu"; + +"Last 4 of Social Security number" = "Empat digit terakhir nombor Keselamatan Sosial"; + +"Loading" = "Sedang dimuatkan"; + +"NRIC or FIN" = "NRIC atau FIN"; + +"Passport" = "Pasport"; + +"Passport photo successfully uploaded" = "Foto pasport berjaya dimuat naik"; + +"Personal ID number" = "Nombor ID peribadi"; + +"Personal Information" = "Maklumat Peribadi"; + +"Phone Number" = "Nombor Telefon"; + +"Phone Verification" = "Penentusahan Telefon"; + +"Photo Library" = "Pustaka Foto"; + +"Please upload an image of your passport" = "Sila muat naik imej pasport anda"; + +"Please upload images of the front and back of your driver's license" = "Sila muat naik imej hadapan dan belakang lesen memandu anda"; + +"Please upload images of the front and back of your identity card" = "Sila muat naik imej hadapan dan belakang kad pengenalan anda"; + +"Position your driver's license in the center of the frame" = "Letakkan lesen memandu anda di tengah bingkai"; + +"Position your face in the center of the frame." = "Letakkan muka anda di tengah bingkai"; + +"Position your identity card in the center of the frame" = "Letakkan kad pengenalan anda di tengah bingkai"; + +"Position your passport in the center of the frame" = "Letakkan pasport anda di tengah bingkai"; + +"Retake Photos" = "Ambil Semula Foto"; + +"Scan" = "Imbas"; + +"Scanned" = "Telah Diimbas"; + +"Select" = "Pilih"; + +"Select a location to upload the back of your identity document from" = "Pilih lokasi sumber untuk memuat naik bahagian belakang dokumen pengenalan anda"; + +"Select a location to upload the front of your identity document from" = "Pilih lokasi sumber untuk memuat naik bahagian hadapan dokumen pengenalan anda"; + +"Select back driver's license photo" = "Pilih foto bahagian belakang lesen memandu"; + +"Select back identity card photo" = "Pilih foto bahagian belakang kad pengenalan"; + +"Select front driver's license photo" = "Pilih foto bahagian hadapan lesen memandu"; + +"Select front identity card photo" = "Pilih foto bahagian hadapan kad pengenalan"; + +"Select passport photo" = "Pilih foto pasport"; + +"Selfie" = "Swafoto"; + +"Selfie captures" = "Tangkapan swafoto"; + +"Selfie captures are complete" = "Tangkapan swafoto selesai"; + +"Take Photo" = "Ambil Foto"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Imej dokumen pengenalan anda belum disimpan. Anda pasti ingin meninggalkannya?"; + +"There was an error accessing the camera." = "Ada ralat ketika mengakses kamera."; + +"Try Again" = "Cuba Lagi"; + +"Unable to establish a connection." = "Sambungan tidak dapat diwujudkan."; + +"Unsaved changes" = "Perubahan tidak disimpan"; + +"Upload" = "Muat Naik"; + +"Upload a Photo" = "Muat Naik Foto"; + +"Upload your photo ID" = "Muat naik ID berfoto anda"; + +"Uploading back driver's license photo" = "Foto bahagian belakang lesen memandu sedang dimuat naik"; + +"Uploading back identity card photo" = "Foto bahagian belakang kad pengenalan sedang dimuat naik"; + +"Uploading front driver's license photo" = "Foto bahagian hadapan lesen memandu sedang dimuat naik"; + +"Uploading front identity card photo" = "Foto bahagian hadapan kad pengenalan sedang dimuat naik"; + +"Uploading passport photo" = "Foto pasport sedang dimuat naik"; + +"Verify your identity" = "Tentusahkan identiti anda"; + +"We could not capture a high-quality image." = "Kami tidak dapat menangkap imej berkualiti tinggi."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Kami perlukan keizinan untuk menggunakan kamera anda. Sila benarkan akses kamera dalam tetapan aplikasi."; + +"Welcome" = "Selamat datang"; + +"You can either try again or upload an image from your device." = "Anda boleh cuba lagi atau muat naik imej daripada peranti anda."; + +"Your selfie images have not been saved. Do you want to leave?" = "Imej swafoto anda belum lagi disimpan. Adakah anda ingin meninggalkannya?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/mt.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/mt.lproj/Localizable.strings new file mode 100644 index 00000000..72c8b891 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/mt.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Inkella tista' ttella' ritratt tad-dokument tal-identità tiegħek int stess."; + +"App Settings" = "Settings tal-App"; + +"Back driver's license photo successfully uploaded" = "Ir-ritratt tan-naħa ta' wara tal-liċenzja tas-sewqan ittella'"; + +"Back identity card photo successfully uploaded" = "Ir-ritratt tan-naħa ta' wara tal-karta tal-identità ttella'"; + +"Back of driver's license" = "In-naħa ta' wara tal-liċenzja tas-sewqan"; + +"Back of identity card" = "In-naħa ta' wara tal-karta tal-identità"; + +"Camera permission" = "Permess għall-kamera"; + +"Camera unavailable" = "Il-kamera mhux disponibbli"; + +"Capturing…" = "Qed nieħdu l-istessi..."; + +"Choose File" = "Agħżel Fajl"; + +"Consent" = "Kunsens"; + +"Could not capture image" = "Ma stajniex nieħdu r-ritratt"; + +"Date of Birth" = "Data tat-twelid"; + +"Date of birth does not look valid" = "Id-data tat-twelid ma tidhirx li hija tajba"; + +"Flip your driver's license over to the other side" = "Dawwar il-liċenzja tas-sewqan tiegħek"; + +"Flip your identity card over to the other side" = "Aqleb il-kard tal-identità tiegħek għan-naħa l-oħra"; + +"Front driver's license photo successfully uploaded" = "Ir-ritratt tan-naħa ta' quddiem tal-liċenzja tas-sewqan ittella' b'suċċes"; + +"Front identity card photo successfully uploaded" = "Ir-ritratt tan-naħa ta' quddiem tal-kard tal-identità ttella' b'suċċes"; + +"Front of driver's license" = "In-naħa ta' quddiem tal-liċenzja tas-sewqan"; + +"Front of identity card" = "In-naħa ta' quddiem tal-karta tal-identità"; + +"Go Back" = "Mur Lura"; + +"Hold still, scanning" = "Tiċċaqlaqx, qed niskennjaw"; + +"ID Number" = "In-Numru tal-ID"; + +"ID Type" = "It-Tip tad-Dokument tal-Identità"; + +"Image of passport" = "Ritratt tal-passaport"; + +"Individual CPF" = "CPF personali"; + +"Last 4 of Social Security number" = "L-aħħar 4 ċifri tan-numru tas-Sigurtà Soċjali"; + +"Loading" = "Tielgħa"; + +"NRIC or FIN" = "NRIC jew FIN"; + +"Passport" = "Passaport"; + +"Passport photo successfully uploaded" = "Ir-ritratt tal-passaport ittella'"; + +"Personal ID number" = "In-numru tal-ID personali"; + +"Personal Information" = "Dettalji personali"; + +"Phone Number" = "Numru tal-Mowbajl"; + +"Phone Verification" = "Verifika bil-Mowbajl"; + +"Photo Library" = "Ġabra tar-Ritratti"; + +"Please upload an image of your passport" = "Jekk jogħġbok tella' ritratt tal-passaport tiegħek"; + +"Please upload images of the front and back of your driver's license" = "Jekk jogħġbok tella' r-ritratti tan-naħa ta' quddiem u ta' wara tal-liċenzja tas-sewqan tiegħek"; + +"Please upload images of the front and back of your identity card" = "Jekk jogħġbok tella' r-ritratti tan-naħa ta' quddiem u ta' wara tal-karta tal-identità tiegħek"; + +"Position your driver's license in the center of the frame" = "Qiegħed il-liċenzja tas-sewqan tiegħek f'nofs il-gwarniċ"; + +"Position your face in the center of the frame." = "Qiegħed wiċċek f'nofs il-gwarniċ."; + +"Position your identity card in the center of the frame" = "Qiegħed il-karta tal-identità tiegħek f'nofs il-gwarniċ"; + +"Position your passport in the center of the frame" = "Qiegħed il-passaport tiegħek f'nofs il-gwarniċ"; + +"Retake Photos" = "Erġa' ħu r-ritratti"; + +"Scan" = "Skennja"; + +"Scanned" = "Skennjat"; + +"Select" = "Agħżel"; + +"Select a location to upload the back of your identity document from" = "Agħżel il-post minn fejn tixtieq ittella' n-naħa ta' wara tad-dokument tal-identità"; + +"Select a location to upload the front of your identity document from" = "Agħżel il-post minn fejn tixtieq ittella' n-naħa ta' quddiem tad-dokument tal-identità"; + +"Select back driver's license photo" = "Agħżel ir-ritratt tan-naħa ta' wara tal-liċenzja tas-sewqan"; + +"Select back identity card photo" = "Agħżel ir-ritratt tan-naħa ta' wara tal-karta tal-identità"; + +"Select front driver's license photo" = "Agħżel ir-ritratt tan-naħa ta' quddiem tal-liċenzja tas-sewqan"; + +"Select front identity card photo" = "Agħżel ir-ritratt tan-naħa ta' quddiem tal-karta tal-identità"; + +"Select passport photo" = "Agħżel ir-ritratt tal-passaport"; + +"Selfie" = "Stessu"; + +"Selfie captures" = "Ġbid tal-istessu"; + +"Selfie captures are complete" = "L-istessi nġibdu"; + +"Take Photo" = "Ħu Ritratt"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Ir-ritratti tad-dokument tal-identità tiegħek ma ġewx issejvjati. Trid toħroġ xorta waħda?"; + +"There was an error accessing the camera." = "Seħħ żball meta aċċessajna l-kamera."; + +"Try Again" = "Erġa' Pprova"; + +"Unable to establish a connection." = "Ma nistgħux nagħmlu konnessjoni."; + +"Unsaved changes" = "Il-bidliet ma ġewx issejvjati"; + +"Upload" = "Tella'"; + +"Upload a Photo" = "Tella' Ritratt"; + +"Upload your photo ID" = "Tella' d-dokument tal-identità b'ritratt tiegħek"; + +"Uploading back driver's license photo" = "Qed ittella' r-ritratt tan-naħa ta' wara tal-liċenzja tas-sewqan"; + +"Uploading back identity card photo" = "Qed ittella' r-ritratt tan-naħa ta' wara tal-kartà tal-identità"; + +"Uploading front driver's license photo" = "Qed ittella' r-ritratt tan-naħa ta' quddiem tal-liċenzja tas-sewqan"; + +"Uploading front identity card photo" = "Qed ittella' r-ritratt tan-naħa ta' quddiem tal-karta tal-identità"; + +"Uploading passport photo" = "Qed ittella' r-ritratt tal-passaport"; + +"Verify your identity" = "Ivverifika l-identità tiegħek"; + +"We could not capture a high-quality image." = "Ma stajniex nieħdu ritratt ta' kwalità tajba."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Neħtieġu l-permess biex bużaw il-kamera tiegħek. Jekk jogħġbok agħti aċċess għall-kamera mis-settings tal-apps."; + +"Welcome" = "Merħba"; + +"You can either try again or upload an image from your device." = "Tista' terġa' tipprova jew tagħżel li ttella' ritratt mit-tagħmir tiegħek."; + +"Your selfie images have not been saved. Do you want to leave?" = "L-istessi tiegħek għadhom ma ntrefgħux. Tixtieq toħroġ?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/nb.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/nb.lproj/Localizable.strings new file mode 100644 index 00000000..02990100 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/nb.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Alternativt kan du laste opp et bilde av identitetsdokumentet manuelt."; + +"App Settings" = "Appinnstillinger"; + +"Back driver's license photo successfully uploaded" = "Bilde av baksiden av førerkortet ble lastet opp"; + +"Back identity card photo successfully uploaded" = "Bilde av baksiden av identitetskortet ble lastet opp"; + +"Back of driver's license" = "Baksiden av førerkortet"; + +"Back of identity card" = "Baksiden av identitetskortet"; + +"Camera permission" = "Kameratilgang"; + +"Camera unavailable" = "Kamera utilgjengelig"; + +"Capturing…" = "Tar bilde …"; + +"Choose File" = "Velg fil"; + +"Consent" = "Samtykke"; + +"Could not capture image" = "Kunne ikke ta bilde"; + +"Date of Birth" = "Fødselsdato"; + +"Date of birth does not look valid" = "Fødselsdato ser ikke gyldig ut"; + +"Flip your driver's license over to the other side" = "Snu førerkortet over til den andre siden"; + +"Flip your identity card over to the other side" = "Snu identitetskortet over til den andre siden"; + +"Front driver's license photo successfully uploaded" = "Bilde av forsiden av førerkortet ble lastet opp"; + +"Front identity card photo successfully uploaded" = "Bilde av forsiden av identitetskortet ble lastet opp"; + +"Front of driver's license" = "Forsiden av førerkortet"; + +"Front of identity card" = "Forsiden av identitetskortet"; + +"Go Back" = "Gå tilbake"; + +"Hold still, scanning" = "Vær rolig, skanner"; + +"ID Number" = "ID-nummer"; + +"ID Type" = "ID-type"; + +"Image of passport" = "Bilde av pass"; + +"Individual CPF" = "Individuell CPF"; + +"Last 4 of Social Security number" = "Siste 4 sifre i personnummer"; + +"Loading" = "Laster inn"; + +"NRIC or FIN" = "NRIC eller FIN"; + +"Passport" = "Pass"; + +"Passport photo successfully uploaded" = "Passbildet ble lastet opp"; + +"Personal ID number" = "Personlig ID-nummer"; + +"Personal Information" = "Personopplysninger"; + +"Phone Number" = "Telefonnummer"; + +"Phone Verification" = "Telefonverifisering"; + +"Photo Library" = "Bildebibliotek"; + +"Please upload an image of your passport" = "Last opp et bilde av passet ditt"; + +"Please upload images of the front and back of your driver's license" = "Last opp bilder av for- og baksiden av førerkortet ditt"; + +"Please upload images of the front and back of your identity card" = "Last opp bilder av for- og baksiden av identitetskortet ditt"; + +"Position your driver's license in the center of the frame" = "Plasser førerkortet midt i bildet"; + +"Position your face in the center of the frame." = "Plasser ansiktet midt i bildet."; + +"Position your identity card in the center of the frame" = "Plasser identitetskortet midt i bildet"; + +"Position your passport in the center of the frame" = "Plasser passet midt i bildet"; + +"Retake Photos" = "Ta bilder på nytt"; + +"Scan" = "Skann"; + +"Scanned" = "Skannet"; + +"Select" = "Velg"; + +"Select a location to upload the back of your identity document from" = "Velg en plassering du vil laste opp baksiden av identitetsdokumentet ditt fra"; + +"Select a location to upload the front of your identity document from" = "Velg en plassering du vil laste opp forsiden av identitetsdokumentet ditt fra"; + +"Select back driver's license photo" = "Velg bilde av baksiden av førerkortet"; + +"Select back identity card photo" = "Velg bilde av baksiden av identitetskortet"; + +"Select front driver's license photo" = "Velg bilde av forsiden av førerkortet"; + +"Select front identity card photo" = "Velg bilde av forsiden av identitetskortet"; + +"Select passport photo" = "Velg passbilde"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Selfiebilder"; + +"Selfie captures are complete" = "Selfiebilder er fullført"; + +"Take Photo" = "Ta bilde"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Bildet av identitetsdokumentet er ikke lagret. Vil du forlate?"; + +"There was an error accessing the camera." = "Det oppstod en feil med å få tilgang til kameraet."; + +"Try Again" = "Prøv igjen"; + +"Unable to establish a connection." = "Kan ikke opprette en tilkobling."; + +"Unsaved changes" = "Ulagrede endringer"; + +"Upload" = "Last opp"; + +"Upload a Photo" = "Last opp et bilde"; + +"Upload your photo ID" = "Last opp din bilde-ID"; + +"Uploading back driver's license photo" = "Laster opp bilde av baksiden av førerkortet"; + +"Uploading back identity card photo" = "Laster opp bilde av baksiden av identitetskortet"; + +"Uploading front driver's license photo" = "Laster opp bilde av forsiden av førerkortet"; + +"Uploading front identity card photo" = "Laster opp bilde av forsiden av identitetskortet"; + +"Uploading passport photo" = "Laster opp passbilde"; + +"Verify your identity" = "Verifiser identiteten din"; + +"We could not capture a high-quality image." = "Vi kunne ikke ta et bilde av høy kvalitet."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Vi trenger tillatelse for å bruke kameraet ditt. Gi tilgang til kameraet i appinnstillingene."; + +"Welcome" = "Velkommen"; + +"You can either try again or upload an image from your device." = "Du kan enten prøve på nytt eller laste opp et bilde fra enheten din."; + +"Your selfie images have not been saved. Do you want to leave?" = "Selfiebildene er ikke lagret. Vil du forlate?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/nl.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/nl.lproj/Localizable.strings new file mode 100644 index 00000000..35affdd6 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/nl.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Als alternatief kun je ook handmatig een foto van je identiteitsbewijs uploaden."; + +"App Settings" = "App-instellingen"; + +"Back driver's license photo successfully uploaded" = "Foto van achterkant van rijbewijs is geüpload"; + +"Back identity card photo successfully uploaded" = "Foto van achterkant van identiteitskaart is geüpload"; + +"Back of driver's license" = "Achterkant van rijbewijs"; + +"Back of identity card" = "Achterkant van identiteitskaart"; + +"Camera permission" = "Toestemming voor camera"; + +"Camera unavailable" = "Camera niet beschikbaar"; + +"Capturing…" = "Selfie wordt gemaakt..."; + +"Choose File" = "Bestand kiezen"; + +"Consent" = "Toestaan"; + +"Could not capture image" = "Niet gelukt om afbeelding vast te leggen"; + +"Date of Birth" = "Geboortedatum"; + +"Date of birth does not look valid" = "Geboortedatum lijkt ongeldig te zijn"; + +"Flip your driver's license over to the other side" = "Draai je rijbewijs om, zodat de andere kant zichtbaar is"; + +"Flip your identity card over to the other side" = "Draai je identiteitskaart om, zodat de andere kant zichtbaar is"; + +"Front driver's license photo successfully uploaded" = "Foto van voorkant van rijbewijs is geüpload"; + +"Front identity card photo successfully uploaded" = "Foto van voorkant van identiteitskaart is geüpload"; + +"Front of driver's license" = "Voorkant van rijbewijs"; + +"Front of identity card" = "Voorkant van identiteitskaart"; + +"Go Back" = "Terug"; + +"Hold still, scanning" = "Niet bewegen, het document wordt gescand"; + +"ID Number" = "Identificatienummer"; + +"ID Type" = "ID-type"; + +"Image of passport" = "Afbeelding van paspoort"; + +"Individual CPF" = "CPF natuurlijk persoon"; + +"Last 4 of Social Security number" = "Laatste vier cijfers van socialezekerheidsnummer"; + +"Loading" = "Bezig met laden"; + +"NRIC or FIN" = "NRIC of FIN"; + +"Passport" = "Paspoort"; + +"Passport photo successfully uploaded" = "Foto van paspoort is geüpload"; + +"Personal ID number" = "Persoonlijk identificatienummer"; + +"Personal Information" = "Persoonlijke gegevens"; + +"Phone Number" = "Telefoonnummer"; + +"Phone Verification" = "Telefonische verificatie"; + +"Photo Library" = "Fotobibliotheek"; + +"Please upload an image of your passport" = "Upload een afbeelding van je paspoort"; + +"Please upload images of the front and back of your driver's license" = "Upload afbeeldingen van de voor- en achterkant van je rijbewijs"; + +"Please upload images of the front and back of your identity card" = "Upload afbeeldingen van de voor- en achterkant van je identiteitskaart"; + +"Position your driver's license in the center of the frame" = "Plaats je rijbewijs midden in het frame"; + +"Position your face in the center of the frame." = "Houd je gezicht in het midden van het frame."; + +"Position your identity card in the center of the frame" = "Plaats je identiteitskaart midden in het frame"; + +"Position your passport in the center of the frame" = "Plaats je paspoort midden in het frame"; + +"Retake Photos" = "Nieuwe foto's maken"; + +"Scan" = "Scannen"; + +"Scanned" = "Gescand"; + +"Select" = "Selecteren"; + +"Select a location to upload the back of your identity document from" = "Selecteer een locatie vanwaar je de achterkant van je identiteitsbewijs wilt uploaden"; + +"Select a location to upload the front of your identity document from" = "Selecteer een locatie vanwaar je de voorkant van je identiteitsbewijs wilt uploaden"; + +"Select back driver's license photo" = "Foto van achterkant van rijbewijs selecteren"; + +"Select back identity card photo" = "Foto van achterkant van identiteitskaart selecteren"; + +"Select front driver's license photo" = "Foto van voorkant van rijbewijs selecteren"; + +"Select front identity card photo" = "Foto van voorkant van identiteitskaart selecteren"; + +"Select passport photo" = "Foto van paspoort selecteren"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Selfieopnamen"; + +"Selfie captures are complete" = "Selfieopnamen zijn voltooid"; + +"Take Photo" = "Foto maken"; + +"The images of your identity document have not been saved. Do you want to leave?" = "De afbeeldingen van je identiteitsbewijs zijn niet opgeslagen. Wil je afsluiten?"; + +"There was an error accessing the camera." = "Fout bij het verkrijgen van toegang tot de camera."; + +"Try Again" = "Opnieuw proberen"; + +"Unable to establish a connection." = "Kan geen verbinding maken"; + +"Unsaved changes" = "Niet-opgeslagen wijzigingen"; + +"Upload" = "Uploaden"; + +"Upload a Photo" = "Een foto uploaden"; + +"Upload your photo ID" = "Je identiteitsbewijs uploaden"; + +"Uploading back driver's license photo" = "Foto van achterkant van rijbewijs uploaden"; + +"Uploading back identity card photo" = "Foto van achterkant van identiteitskaart uploaden"; + +"Uploading front driver's license photo" = "Foto van voorkant van rijbewijs uploaden"; + +"Uploading front identity card photo" = "Foto van voorkant van identiteitskaart uploaden"; + +"Uploading passport photo" = "Foto van paspoort uploaden"; + +"Verify your identity" = "Je identiteit verifiëren"; + +"We could not capture a high-quality image." = "We kunnen geen afbeelding in hoge kwaliteit vastleggen."; + +"We need permission to use your camera. Please allow camera access in app settings." = "We hebben toestemming nodig om je camera te gebruiken. Sta dit toe via de app-instellingen."; + +"Welcome" = "Welkom"; + +"You can either try again or upload an image from your device." = "Probeer het nogmaals of upload een afbeelding van je apparaat."; + +"Your selfie images have not been saved. Do you want to leave?" = "De afbeeldingen van je selfie zijn niet opgeslagen. Wil je afsluiten?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/nn-NO.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/nn-NO.lproj/Localizable.strings new file mode 100644 index 00000000..76d15a28 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/nn-NO.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Alternativt kan du laste opp eit bilete av ID-dokumentet ditt manuelt."; + +"App Settings" = "Appinnstillingar"; + +"Back driver's license photo successfully uploaded" = "Bilete av baksida av førarkort lasta opp"; + +"Back identity card photo successfully uploaded" = "Bilete av baksida av identifikasjonskort lasta opp"; + +"Back of driver's license" = "Baksida av førarkort"; + +"Back of identity card" = "Baksida av ID-kort"; + +"Camera permission" = "Kameraløyve"; + +"Camera unavailable" = "Kamera ikkje tilgjengeleg"; + +"Capturing…" = "Tar bilete ..."; + +"Choose File" = "Vel fil"; + +"Consent" = "Samtykke"; + +"Could not capture image" = "Kunne ikkje ta bilete"; + +"Date of Birth" = "Fødselsdato"; + +"Date of birth does not look valid" = "Fødselsdato ser ikkje gyldig ut"; + +"Flip your driver's license over to the other side" = "Snu førarkortet ditt"; + +"Flip your identity card over to the other side" = "Snu ID-kortet ditt"; + +"Front driver's license photo successfully uploaded" = "Bilete av framsida av førarkort lasta opp"; + +"Front identity card photo successfully uploaded" = "Bilete av framsida av identifikasjonskort lasta opp"; + +"Front of driver's license" = "Framsida av førarkort"; + +"Front of identity card" = "Framsida av identifikasjonskort"; + +"Go Back" = "Gå tilbake"; + +"Hold still, scanning" = "Hold i ro, skannar"; + +"ID Number" = "ID-nummer"; + +"ID Type" = "ID-type"; + +"Image of passport" = "Bilete av pass"; + +"Individual CPF" = "Individuell CPF"; + +"Last 4 of Social Security number" = "Siste 4 tak i personnummeret"; + +"Loading" = "Lastar"; + +"NRIC or FIN" = "NRIC eller FIN"; + +"Passport" = "Pass"; + +"Passport photo successfully uploaded" = "Bilete av pass lasta opp"; + +"Personal ID number" = "Personleg ID-nummer"; + +"Personal Information" = "Personleg informasjon"; + +"Phone Number" = "Telefonnummer"; + +"Phone Verification" = "Telefonstadfesting"; + +"Photo Library" = "Biletebibliotek"; + +"Please upload an image of your passport" = "Last opp eit bilete av passet ditt"; + +"Please upload images of the front and back of your driver's license" = "Last opp bilete av framsida og bakside av førarkortet ditt"; + +"Please upload images of the front and back of your identity card" = "Last opp bilete av framsida og bakside av identifikasjonskortet ditt"; + +"Position your driver's license in the center of the frame" = "Posisjoner førarkortet ditt i midten av biletet"; + +"Position your face in the center of the frame." = "Posisjoner andletet ditt i midten av biletet"; + +"Position your identity card in the center of the frame" = "Posisjoner ID-kortet ditt i midten av biletet"; + +"Position your passport in the center of the frame" = "Posisjoner passet ditt i midten av biletet"; + +"Retake Photos" = "Ta bilete på nytt"; + +"Scan" = "Skann"; + +"Scanned" = "Skanna"; + +"Select" = "Vel"; + +"Select a location to upload the back of your identity document from" = "Vel ei plassering du vil laste opp baksida av ID-dokumentet ditt frå"; + +"Select a location to upload the front of your identity document from" = "Vel ei plassering du vil laste opp framsida av ID-dokumentet ditt frå"; + +"Select back driver's license photo" = "Vel bilete for baksida av førarkort"; + +"Select back identity card photo" = "Vel bilete for baksida av ID-kort"; + +"Select front driver's license photo" = "Vel bilete for framsida av førarkort"; + +"Select front identity card photo" = "Vel bilete for framsida av ID-kort"; + +"Select passport photo" = "Vel bilete av pass"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Selfiebilete"; + +"Selfie captures are complete" = "Selfiebilete blei tatt"; + +"Take Photo" = "Ta bilete"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Bileta av ID-dokumentet ditt har ikkje blitt lagra. Vil du avslutte?"; + +"There was an error accessing the camera." = "Det oppstod ein feil ved tilgang til kameraet."; + +"Try Again" = "Prøv igjen"; + +"Unable to establish a connection." = "Kan ikkje opprette tilkopling."; + +"Unsaved changes" = "Ulagra endringar"; + +"Upload" = "Last opp"; + +"Upload a Photo" = "Last opp eit bilete"; + +"Upload your photo ID" = "Last opp legitimasjon med foto"; + +"Uploading back driver's license photo" = "Lastar opp baksida av førarkort"; + +"Uploading back identity card photo" = "Lastar opp baksida av ID-kort"; + +"Uploading front driver's license photo" = "Lastar opp framsida av førarkort"; + +"Uploading front identity card photo" = "Lastar opp framsida av ID-kort"; + +"Uploading passport photo" = "Lastar opp bilete av pass"; + +"Verify your identity" = "Stadfest identiteten din"; + +"We could not capture a high-quality image." = "Vi kunne ikkje ta eit bilete i høg kvalitet."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Vi treng løyve for å bruke kameraet ditt. Gi tilgang til kameraet i appinnstillingane."; + +"Welcome" = "Velkomen"; + +"You can either try again or upload an image from your device." = "Du kan anten prøve igjen eller laste opp eit bilete frå eininga di."; + +"Your selfie images have not been saved. Do you want to leave?" = "Selfiebileta dine blei ikkje lagra. Vil du avslutte?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/pl-PL.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/pl-PL.lproj/Localizable.strings new file mode 100644 index 00000000..d4527648 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/pl-PL.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Można też ręcznie przesłać zdjęcie dokumentu tożsamości."; + +"App Settings" = "Ustawienia aplikacji"; + +"Back driver's license photo successfully uploaded" = "Przesłano zdjęcie tylnej strony prawa jazdy"; + +"Back identity card photo successfully uploaded" = "Przesłano zdjęcie tylnej strony dowodu tożsamości"; + +"Back of driver's license" = "Tylna strona prawa jazdy"; + +"Back of identity card" = "Tylna strona dokumentu tożsamości"; + +"Camera permission" = "Zezwolenie dla aparatu"; + +"Camera unavailable" = "Aparat niedostępny"; + +"Capturing…" = "Przechwytywanie…"; + +"Choose File" = "Wybierz plik"; + +"Consent" = "Zgoda"; + +"Could not capture image" = "Nie można wykonać zdjęcia"; + +"Date of Birth" = "Data urodzenia"; + +"Date of birth does not look valid" = "Data urodzenia wygląda na nieprawidłową"; + +"Flip your driver's license over to the other side" = "Obróć prawo jazdy na drugą stronę"; + +"Flip your identity card over to the other side" = "Obróć dowód tożsamości na drugą stronę"; + +"Front driver's license photo successfully uploaded" = "Przesłano zdjęcie przedniej strony prawa jazdy"; + +"Front identity card photo successfully uploaded" = "Przesłano zdjęcie przedniej strony dowodu tożsamości"; + +"Front of driver's license" = "Przednia strona prawa jazdy"; + +"Front of identity card" = "Przednia strona dokumentu tożsamości"; + +"Go Back" = "Wróć"; + +"Hold still, scanning" = "Zaczekaj, trwa skanowanie"; + +"ID Number" = "Numer identyfikacyjny"; + +"ID Type" = "Typ dokumentu tożsamości"; + +"Image of passport" = "Zdjęcie paszportu"; + +"Individual CPF" = "CPF osoby fizycznej"; + +"Last 4 of Social Security number" = "Ostatnie 4 cyfry numeru ubezpieczenia społecznego"; + +"Loading" = "Wczytywanie"; + +"NRIC or FIN" = "NRIC lub FIN"; + +"Passport" = "Paszport"; + +"Passport photo successfully uploaded" = "Udało się przesłać zdjęcie paszportu"; + +"Personal ID number" = "Numer dowodu tożsamości"; + +"Personal Information" = "Dane osobowe"; + +"Phone Number" = "Numer telefonu"; + +"Phone Verification" = "Weryfikacja numeru telefonu"; + +"Photo Library" = "Biblioteka zdjęć"; + +"Please upload an image of your passport" = "Prześlij zdjęcie paszportu"; + +"Please upload images of the front and back of your driver's license" = "Prześlij zdjęcia przedniej i tylnej strony prawa jazdy"; + +"Please upload images of the front and back of your identity card" = "Prześlij zdjęcia przedniej i tylnej strony dokumentu tożsamości"; + +"Position your driver's license in the center of the frame" = "Umieść prawo jazdy w centrum kadru"; + +"Position your face in the center of the frame." = "Umieść twarz w centrum kadru."; + +"Position your identity card in the center of the frame" = "Umieść dowód tożsamości w centrum kadru"; + +"Position your passport in the center of the frame" = "Umieść paszport w centrum kadru"; + +"Retake Photos" = "zrób zdjęcia ponownie"; + +"Scan" = "Skanowanie"; + +"Scanned" = "Zeskanowano"; + +"Select" = "Wybierz"; + +"Select a location to upload the back of your identity document from" = "Wybierz lokalizację, z której chcesz przesłać tylną stronę dokumentu tożsamości"; + +"Select a location to upload the front of your identity document from" = "Wybierz lokalizację, z której chcesz przesłać przednią stronę dokumentu tożsamości"; + +"Select back driver's license photo" = "Wybierz zdjęcie tyłu prawa jazdy"; + +"Select back identity card photo" = "Wybierz zdjęcie tyłu dowodu tożsamości"; + +"Select front driver's license photo" = "Wybierz zdjęcie przodu prawa jazdy"; + +"Select front identity card photo" = "Wybierz zdjęcie tyłu dowodu tożsamości"; + +"Select passport photo" = "Wybierz zdjęcie paszportu"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Przechwytywanie selfie"; + +"Selfie captures are complete" = "Zakończono przechwytywanie selfie"; + +"Take Photo" = "Zrób zdjęcie"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Nie zapisano zdjęć Twojego dowodu tożsamości. Czy chcesz wyjść?"; + +"There was an error accessing the camera." = "Wystąpił błąd dostępu do aparatu."; + +"Try Again" = "Spróbuj ponownie"; + +"Unable to establish a connection." = "Nie można nawiązać połączenia."; + +"Unsaved changes" = "Niezapisane zmiany"; + +"Upload" = "Prześlij"; + +"Upload a Photo" = "Prześlij zdjęcie"; + +"Upload your photo ID" = "Prześlij dokument tożsamości ze zdjęciem"; + +"Uploading back driver's license photo" = "Przesyłanie zdjęcia tyłu prawa jazdy"; + +"Uploading back identity card photo" = "Przesyłanie zdjęcia tyłu dowodu tożsamości"; + +"Uploading front driver's license photo" = "Przesyłanie zdjęcia przodu prawa jazdy"; + +"Uploading front identity card photo" = "Przesyłanie zdjęcia przodu dowodu tożsamości"; + +"Uploading passport photo" = "Przesyłanie zdjęcia paszportu"; + +"Verify your identity" = "Zweryfikuj tożsamość"; + +"We could not capture a high-quality image." = "Nie można zarejestrować obrazu wysokiej jakości."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Potrzebujemy pozwolenia na używanie Twojego aparatu. Zezwól w ustawieniach aplikacji na dostęp do aparatu."; + +"Welcome" = "Witamy"; + +"You can either try again or upload an image from your device." = "Możesz spróbować ponownie lub przesłać obraz z urządzenia."; + +"Your selfie images have not been saved. Do you want to leave?" = "Nie zapisano Twoich selfie. Czy chcesz wyjść?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/pt-BR.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/pt-BR.lproj/Localizable.strings new file mode 100644 index 00000000..53773aaf --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/pt-BR.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Você também pode carregar manualmente uma foto do documento de identificação."; + +"App Settings" = "Ajustes do aplicativo"; + +"Back driver's license photo successfully uploaded" = "Foto do verso da carteira de habilitação carregada"; + +"Back identity card photo successfully uploaded" = "Foto do verso da carteira de identidade carregada"; + +"Back of driver's license" = "Verso da CNH"; + +"Back of identity card" = "Verso da identidade"; + +"Camera permission" = "Permissão para câmera"; + +"Camera unavailable" = "Câmera indisponível"; + +"Capturing…" = "Capturando…"; + +"Choose File" = "Escolher arquivo"; + +"Consent" = "Autorizar"; + +"Could not capture image" = "Não foi possível capturar a imagem"; + +"Date of Birth" = "Data de nascimento"; + +"Date of birth does not look valid" = "A data de nascimento não é válida"; + +"Flip your driver's license over to the other side" = "Vire a carteira de habilitação para o outro lado"; + +"Flip your identity card over to the other side" = "Vire a carteira de identidade para o outro lado"; + +"Front driver's license photo successfully uploaded" = "Foto da frente da carteira de habilitação carregada"; + +"Front identity card photo successfully uploaded" = "Foto da frente da carteira de identidade carregada"; + +"Front of driver's license" = "Frente da CNH"; + +"Front of identity card" = "Frente da identidade"; + +"Go Back" = "Voltar"; + +"Hold still, scanning" = "Segure firme. Digitalizando."; + +"ID Number" = "Número da identificação"; + +"ID Type" = "Tipo de documento"; + +"Image of passport" = "Imagem do passaporte"; + +"Individual CPF" = "CPF"; + +"Last 4 of Social Security number" = "Últimos 4 dígitos do Social Security Number"; + +"Loading" = "Carregando"; + +"NRIC or FIN" = "NRIC ou FIN"; + +"Passport" = "Passaporte"; + +"Passport photo successfully uploaded" = "Foto do passaporte carregada"; + +"Personal ID number" = "Número de identificação pessoal"; + +"Personal Information" = "Dados pessoais"; + +"Phone Number" = "Número de telefone"; + +"Phone Verification" = "Verificação de telefone"; + +"Photo Library" = "Fotos"; + +"Please upload an image of your passport" = "Carregue uma imagem do seu passaporte"; + +"Please upload images of the front and back of your driver's license" = "Carregue imagens (frente e verso) da sua CNH"; + +"Please upload images of the front and back of your identity card" = "Carregue imagens (frente e verso) do seu documento de identidade"; + +"Position your driver's license in the center of the frame" = "Posicione a carteira de habilitação no centro do quadro"; + +"Position your face in the center of the frame." = "Posicione seu rosto no centro do quadro."; + +"Position your identity card in the center of the frame" = "Posicione a carteira de identidade no centro do quadro"; + +"Position your passport in the center of the frame" = "Posicione o passaporte no centro do quadro"; + +"Retake Photos" = "Tirar fotos novamente"; + +"Scan" = "Digitalizar"; + +"Scanned" = "Digitalizado"; + +"Select" = "Selecionar"; + +"Select a location to upload the back of your identity document from" = "Selecione um local para carregar o verso do documento de identidade"; + +"Select a location to upload the front of your identity document from" = "Selecione um local para carregar a frente do documento de identidade"; + +"Select back driver's license photo" = "Selecionar foto do verso da carteira de habilitação"; + +"Select back identity card photo" = "Selecionar foto do verso da carteira de identidade"; + +"Select front driver's license photo" = "Selecionar foto da frente da carteira de habilitação"; + +"Select front identity card photo" = "Selecionar foto da frente da carteira de identidade"; + +"Select passport photo" = "Selecionar foto do passaporte"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Capturas de selfie"; + +"Selfie captures are complete" = "As capturas de selfie foram concluídas"; + +"Take Photo" = "Tirar foto"; + +"The images of your identity document have not been saved. Do you want to leave?" = "As imagens do documento de identificação não foram salvas. Deseja sair?"; + +"There was an error accessing the camera." = "Erro ao acessar a câmera."; + +"Try Again" = "Tentar novamente"; + +"Unable to establish a connection." = "Não foi possível estabelecer a conexão."; + +"Unsaved changes" = "Alterações não salvas"; + +"Upload" = "Carregar"; + +"Upload a Photo" = "Carregar uma foto"; + +"Upload your photo ID" = "Carregue um documento de identificação com foto"; + +"Uploading back driver's license photo" = "Carregar foto do verso da carteira de habilitação"; + +"Uploading back identity card photo" = "Carregar foto do verso da carteira de identidade"; + +"Uploading front driver's license photo" = "Carregar foto da frente da carteira de habilitação"; + +"Uploading front identity card photo" = "Carregar foto da frente da carteira de identidade"; + +"Uploading passport photo" = "Carregar foto do passaporte"; + +"Verify your identity" = "Verifique sua identidade"; + +"We could not capture a high-quality image." = "Não foi possível capturar uma imagem com qualidade."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Autorize o acesso à câmera nas configurações do aplicativo."; + +"Welcome" = "Olá"; + +"You can either try again or upload an image from your device." = "Você pode tentar novamente ou carregar uma imagem do dispositivo."; + +"Your selfie images have not been saved. Do you want to leave?" = "As imagens da sua selfie não foram salvas. Quer sair?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/pt-PT.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/pt-PT.lproj/Localizable.strings new file mode 100644 index 00000000..4f511871 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/pt-PT.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Em alternativa, pode carregar manualmente uma fotografia do seu documento de identificação."; + +"App Settings" = "Definições da aplicação"; + +"Back driver's license photo successfully uploaded" = "Fotografia do verso da carta de condução carregada com sucesso"; + +"Back identity card photo successfully uploaded" = "Fotografia do verso do cartão de identidade carregada com sucesso"; + +"Back of driver's license" = "Verso da carta de condução"; + +"Back of identity card" = "Verso do cartão de identidade"; + +"Camera permission" = "Permissão da câmara"; + +"Camera unavailable" = "Câmara indisponível"; + +"Capturing…" = "A captar…"; + +"Choose File" = "Escolher ficheiro"; + +"Consent" = "Autorizar"; + +"Could not capture image" = "Não foi possível capturar a imagem"; + +"Date of Birth" = "Data de nascimento"; + +"Date of birth does not look valid" = "A data de nascimento não parece ser válida"; + +"Flip your driver's license over to the other side" = "Vire a sua carta de condução para o outro lado"; + +"Flip your identity card over to the other side" = "Vire o seu cartão de identidade para o outro lado"; + +"Front driver's license photo successfully uploaded" = "Fotografia da frente da carta de condução carregada com sucesso"; + +"Front identity card photo successfully uploaded" = "Fotografia da frente do cartão de identidade carregada com sucesso"; + +"Front of driver's license" = "Frente da carta de condução"; + +"Front of identity card" = "Frente do cartão de identidade"; + +"Go Back" = "Voltar"; + +"Hold still, scanning" = "Segure com firmeza, a capturar"; + +"ID Number" = "Número de identificação"; + +"ID Type" = "Tipo de identificação"; + +"Image of passport" = "Imagem do passaporte"; + +"Individual CPF" = "CPF particular"; + +"Last 4 of Social Security number" = "Últimos 4 dígitos do número da segurança social"; + +"Loading" = "A carregar"; + +"NRIC or FIN" = "NRIC ou FIN"; + +"Passport" = "Passaporte"; + +"Passport photo successfully uploaded" = "Fotografia do passaporte carregada com sucesso"; + +"Personal ID number" = "Número de identificação pessoal"; + +"Personal Information" = "Informações pessoais"; + +"Phone Number" = "Número de telefone"; + +"Phone Verification" = "Verificação de telefone"; + +"Photo Library" = "Biblioteca de fotografias"; + +"Please upload an image of your passport" = "Carregue uma imagem do seu passaporte"; + +"Please upload images of the front and back of your driver's license" = "Carregue imagens da parte da frente e do verso da sua carta de condução"; + +"Please upload images of the front and back of your identity card" = "Carregue imagens da parte da frente e do verso do seu cartão de identidade"; + +"Position your driver's license in the center of the frame" = "Posicione a sua carta de condução no centro da moldura"; + +"Position your face in the center of the frame." = "Posicione o seu rosto no centro da moldura."; + +"Position your identity card in the center of the frame" = "Posicione o seu cartão de identidade no centro da moldura"; + +"Position your passport in the center of the frame" = "Posicione o seu passaporte no centro da moldura"; + +"Retake Photos" = "Voltar a tirar fotografias"; + +"Scan" = "Digitalizar"; + +"Scanned" = "Digitalizado"; + +"Select" = "Selecionar"; + +"Select a location to upload the back of your identity document from" = "Selecione uma localização a partir da qual pretende carregar o verso do seu documento de identidade"; + +"Select a location to upload the front of your identity document from" = "Selecione uma localização a partir da qual pretende carregar a frente do seu documento de identidade"; + +"Select back driver's license photo" = "Selecione a fotografia do verso da sua carta de condução"; + +"Select back identity card photo" = "Selecione a fotografia do verso do seu cartão de identidade"; + +"Select front driver's license photo" = "Selecione a fotografia da frente da sua carta de condução"; + +"Select front identity card photo" = "Selecione a fotografia da frente do seu cartão de identidade."; + +"Select passport photo" = "Selecione a fotografia do passaporte"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Capturas de selfie"; + +"Selfie captures are complete" = "Capturas de selfie concluídas"; + +"Take Photo" = "Tirar fotografia"; + +"The images of your identity document have not been saved. Do you want to leave?" = "As imagens do seu documento de identificação não foram guardadas. Tem a certeza que quer sair?"; + +"There was an error accessing the camera." = "Ocorreu um erro ao aceder à câmara."; + +"Try Again" = "Tentar novamente"; + +"Unable to establish a connection." = "Não é possível estabelecer ligação."; + +"Unsaved changes" = "Alterações não guardadas"; + +"Upload" = "Carregar"; + +"Upload a Photo" = "Carregar fotografia"; + +"Upload your photo ID" = "Carregar o seu documento de identificação com fotografia"; + +"Uploading back driver's license photo" = "A carregar a fotografia do verso da carta de condução"; + +"Uploading back identity card photo" = "A carregar a fotografia do verso do cartão de identidade"; + +"Uploading front driver's license photo" = "A carregar a fotografia da frente da carta de condução"; + +"Uploading front identity card photo" = "A carregar a fotografia da frente do cartão de identidade"; + +"Uploading passport photo" = "A carregar a fotografia do passaporte"; + +"Verify your identity" = "Verifique a sua identidade"; + +"We could not capture a high-quality image." = "Não foi possível captar uma imagem de alta qualidade."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Precisamos de permissão para utilizar a sua câmara. Permita o acesso à câmara nas definições da aplicação."; + +"Welcome" = "Bem-vindo"; + +"You can either try again or upload an image from your device." = "Pode tentar novamente ou carregar uma imagem a partir do seu dispositivo."; + +"Your selfie images have not been saved. Do you want to leave?" = "As suas imagens de selfie não foram guardadas. Pretende sair?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/ro-RO.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/ro-RO.lproj/Localizable.strings new file mode 100644 index 00000000..bb5daa04 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/ro-RO.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Alternativ, puteți încărca manual o fotografie a documentului dvs. de identitate."; + +"App Settings" = "Setările aplicației"; + +"Back driver's license photo successfully uploaded" = "Fotografia verso-ului permisului de conducere a fost încărcată cu succes"; + +"Back identity card photo successfully uploaded" = "Fotografia verso-ului cărții de identitate a fost încărcată cu succes"; + +"Back of driver's license" = "Verso-ul permisului de conducere"; + +"Back of identity card" = "Verso-ul cărții de identitate"; + +"Camera permission" = "Permisiune cameră"; + +"Camera unavailable" = "Cameră indisponibilă"; + +"Capturing…" = "Se captează…"; + +"Choose File" = "Selectare fișier"; + +"Consent" = "Consimțământ"; + +"Could not capture image" = "Nu s-a putut capta imaginea"; + +"Date of Birth" = "Data nașterii"; + +"Date of birth does not look valid" = "Data nașterii pare a nu fi validă"; + +"Flip your driver's license over to the other side" = "Întoarceți permisul de conducere pe partea cealaltă"; + +"Flip your identity card over to the other side" = "Întoarceți cartea de identitate pe partea cealaltă"; + +"Front driver's license photo successfully uploaded" = "Fotografia părții frontale a permisului de conducere a fost încărcată cu succes"; + +"Front identity card photo successfully uploaded" = "Fotografia părții frontale a cărții de identitate a fost încărcată cu succes"; + +"Front of driver's license" = "Partea frontală a permisului de conducere"; + +"Front of identity card" = "Partea frontală a cărții de identitate"; + +"Go Back" = "Înapoi"; + +"Hold still, scanning" = "Nu mișcați, se scanează"; + +"ID Number" = "Număr de identificare"; + +"ID Type" = "Tip identificare"; + +"Image of passport" = "Imaginea pașaportului"; + +"Individual CPF" = "CPF persoană fizică"; + +"Last 4 of Social Security number" = "Ultimele 4 cifre ale numărului de securitate socială"; + +"Loading" = "Se încarcă"; + +"NRIC or FIN" = "NRIC sau FIN"; + +"Passport" = "Pașaport"; + +"Passport photo successfully uploaded" = "Fotografia pașaportului a fost încărcată cu succes"; + +"Personal ID number" = "Număr de identificare personal"; + +"Personal Information" = "Informații cu caracter personal"; + +"Phone Number" = "Număr de telefon"; + +"Phone Verification" = "Verificare număr de telefon"; + +"Photo Library" = "Galerie foto"; + +"Please upload an image of your passport" = "Vă rugăm să încărcați o imagine a pașaportului dvs."; + +"Please upload images of the front and back of your driver's license" = "Vă rugăm să încărcați imagini cu partea frontală și verso-ul permisului dvs. de conducere"; + +"Please upload images of the front and back of your identity card" = "Vă rugăm să încărcați imagini cu partea frontală și verso-ul cărții dvs. de identitate"; + +"Position your driver's license in the center of the frame" = "Poziționați-vă permisul de conducere în centrul cadrului"; + +"Position your face in the center of the frame." = "Poziționați-vă fața în centrul cadrului."; + +"Position your identity card in the center of the frame" = "Poziționați-vă cartea de identitate în centrul cadrului"; + +"Position your passport in the center of the frame" = "Poziționați-vă pașaportul în centrul cadrului"; + +"Retake Photos" = "Faceți din nou fotografii"; + +"Scan" = "Scanare"; + +"Scanned" = "Scanat"; + +"Select" = "Selectare"; + +"Select a location to upload the back of your identity document from" = "Selectați o locație din care să încărcați verso-ului documentului dvs. de identitate"; + +"Select a location to upload the front of your identity document from" = "Selectați o locație din care să încărcați partea frontală a documentului dvs. de identitate"; + +"Select back driver's license photo" = "Selectați fotografia verso-ului permisului de conducere"; + +"Select back identity card photo" = "Selectați fotografia verso-ului cărții de identitate"; + +"Select front driver's license photo" = "Selectați fotografia părții frontale a permisului de conducere"; + +"Select front identity card photo" = "Selectați fotografia părții frontale a cărții de identitate"; + +"Select passport photo" = "Selectați fotografia pașaportului"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Capturi de tip selfie"; + +"Selfie captures are complete" = "Capturile de tip selfie sunt complete"; + +"Take Photo" = "Faceți o fotografie"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Imaginile documentului dvs. de identitate nu au fost salvate. Doriți să ieșiți?"; + +"There was an error accessing the camera." = "A apărut o eroare la accesarea camerei."; + +"Try Again" = "Încercați din nou"; + +"Unable to establish a connection." = "Nu s-a putut stabili o conexiune."; + +"Unsaved changes" = "Modificări nesalvate"; + +"Upload" = "Încărcare"; + +"Upload a Photo" = "Încărcați o fotografie"; + +"Upload your photo ID" = "Încărcați documentul de identitate cu fotografie"; + +"Uploading back driver's license photo" = "Se încarcă fotografia verso-ului permisului de conducere"; + +"Uploading back identity card photo" = "Se încarcă fotografia verso-ului cărții de identitate"; + +"Uploading front driver's license photo" = "Se încarcă fotografia părții frontale a permisului de conducere"; + +"Uploading front identity card photo" = "Se încarcă fotografia părții frontale a cărții de identitate"; + +"Uploading passport photo" = "Se încarcă fotografia pașaportului"; + +"Verify your identity" = "Verificați-vă identitatea"; + +"We could not capture a high-quality image." = "Nu am putut captura o imagine de înaltă calitate."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Avem nevoie de permisiunea de a utiliza camera dvs. Vă rugăm să permiteți accesul la cameră în setările aplicației."; + +"Welcome" = "Bine ați venit"; + +"You can either try again or upload an image from your device." = "Puteți să încercați din nou sau să încărcați o imagine de pe dispozitivul dvs."; + +"Your selfie images have not been saved. Do you want to leave?" = "Fotografiile dvs. de tip selfie nu au fost salvate. Doriți să ieșiți?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/ru.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/ru.lproj/Localizable.strings new file mode 100644 index 00000000..c07222bc --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/ru.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "В качестве альтернативы вы можете загрузить фотографию своего удостоверения личности."; + +"App Settings" = "Настройки приложения"; + +"Back driver's license photo successfully uploaded" = "Фотография оборотной стороны водительских прав успешно отправлена"; + +"Back identity card photo successfully uploaded" = "Фотография оборотной стороны удостоверения личности успешно отправлена"; + +"Back of driver's license" = "Оборот водительского удостоверения"; + +"Back of identity card" = "Оборот удостоверения личности"; + +"Camera permission" = "Разрешение на доступ к камере"; + +"Camera unavailable" = "Камера недоступна"; + +"Capturing…" = "Идет съемка..."; + +"Choose File" = "Выберите файл"; + +"Consent" = "Согласие"; + +"Could not capture image" = "Не удалось получить изображение"; + +"Date of Birth" = "Дата рождения"; + +"Date of birth does not look valid" = "Похоже, дата рождения недействительна"; + +"Flip your driver's license over to the other side" = "Переверните водительские права другой стороной"; + +"Flip your identity card over to the other side" = "Переверните удостоверение личности другой стороной"; + +"Front driver's license photo successfully uploaded" = "Фотография лицевой стороны водительских прав успешно отправлена"; + +"Front identity card photo successfully uploaded" = "Фотография лицевой стороны удостоверения личности успешно отправлена"; + +"Front of driver's license" = "Лицевая сторона водительского удостоверения"; + +"Front of identity card" = "Лицевая сторона удостоверения личности"; + +"Go Back" = "Назад"; + +"Hold still, scanning" = "Держите документ неподвижно, идет сканирование"; + +"ID Number" = "Номер удостоверения личности"; + +"ID Type" = "Тип удостоверения"; + +"Image of passport" = "Изображение паспорта"; + +"Individual CPF" = "Личный номер CPF"; + +"Last 4 of Social Security number" = "Последние 4 цифры номера социального страхования"; + +"Loading" = "Идет загрузка"; + +"NRIC or FIN" = "Номер NRIC или FIN"; + +"Passport" = "Паспорт"; + +"Passport photo successfully uploaded" = "Фотография паспорта успешно отправлена"; + +"Personal ID number" = "Номер документа, удостоверяющего личность"; + +"Personal Information" = "Персональная информация"; + +"Phone Number" = "Номер телефона"; + +"Phone Verification" = "Проверка по телефону"; + +"Photo Library" = "Библиотека фотографий"; + +"Please upload an image of your passport" = "Отправьте изображение вашего паспорта"; + +"Please upload images of the front and back of your driver's license" = "Отправьте изображения лицевой стороны и оборота вашего водительского удостоверения"; + +"Please upload images of the front and back of your identity card" = "Отправьте изображения лицевой стороны и оборота вашего удостоверения личности"; + +"Position your driver's license in the center of the frame" = "Поместите водительское удостоверение в центр рамки"; + +"Position your face in the center of the frame." = "Расположите лицо по центру рамки"; + +"Position your identity card in the center of the frame" = "Поместите удостоверение личности в центр рамки"; + +"Position your passport in the center of the frame" = "Поместите паспорт в центр рамки"; + +"Retake Photos" = "Повторные фото"; + +"Scan" = "Сканировать"; + +"Scanned" = "Сканировано"; + +"Select" = "Выбрать"; + +"Select a location to upload the back of your identity document from" = "Выберите папку с изображением оборота вашего документа, удостоверяющего личность, для отправки"; + +"Select a location to upload the front of your identity document from" = "Выберите папку с изображением лицевой стороны вашего документа, удостоверяющего личность, для отправки"; + +"Select back driver's license photo" = "Выбрать фотографию оборотной стороны водительских прав"; + +"Select back identity card photo" = "Выбрать фотографию оборотной стороны удостоверения личности"; + +"Select front driver's license photo" = "Выбрать фотографию лицевой стороны водительских прав"; + +"Select front identity card photo" = "Выбрать фотографию лицевой стороны удостоверения личности"; + +"Select passport photo" = "Выбрать фотографию паспорта"; + +"Selfie" = "Селфи"; + +"Selfie captures" = "Съемка селфи"; + +"Selfie captures are complete" = "Съемка селфи выполнена"; + +"Take Photo" = "Сделать фото"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Изображения вашего удостоверения личности не сохранены. Вы все-таки хотите выйти?"; + +"There was an error accessing the camera." = "При попытке доступа к камере произошла ошибка."; + +"Try Again" = "Повторите попытку"; + +"Unable to establish a connection." = "Не удалось установить соединение."; + +"Unsaved changes" = "Несохраненные изменения"; + +"Upload" = "Отправить"; + +"Upload a Photo" = "Отправьте фотографию"; + +"Upload your photo ID" = "Отправьте удостоверение личности с фото"; + +"Uploading back driver's license photo" = "Отправляется фотография оборотной стороны водительских прав"; + +"Uploading back identity card photo" = "Отправляется фотография оборотной стороны удостоверения личности"; + +"Uploading front driver's license photo" = "Отправляется фотография лицевой стороны водительских прав"; + +"Uploading front identity card photo" = "Отправляется фотография лицевой стороны удостоверения личности"; + +"Uploading passport photo" = "Отправляется фотография паспорта"; + +"Verify your identity" = "Подтвердите свою личность"; + +"We could not capture a high-quality image." = "Не удалось получить качественное изображение."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Нам требуется разрешение на доступ к камере. Разрешите доступ к камере в настройках приложения."; + +"Welcome" = "Добро пожаловать"; + +"You can either try again or upload an image from your device." = "Попробуйте еще раз или загрузите изображение со своего устройства."; + +"Your selfie images have not been saved. Do you want to leave?" = "Ваши селфи-изображения не были сохранены. Хотите выйти?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/sk-SK.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/sk-SK.lproj/Localizable.strings new file mode 100644 index 00000000..5aa0131d --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/sk-SK.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Prípadne môžete nahrať fotografiu vášho dokladu totožnosti."; + +"App Settings" = "Nastavenia aplikácie"; + +"Back driver's license photo successfully uploaded" = "Fotografia zadnej strany vodičského preukazu bola úspešne nahraná"; + +"Back identity card photo successfully uploaded" = "Fotografia zadnej strany preukazu totožnosti bola úspešne nahraná"; + +"Back of driver's license" = "Zadná strana vodičského preukazu"; + +"Back of identity card" = "Zadná strana preukazu totožnosti"; + +"Camera permission" = "Povolenie fotoaparátu"; + +"Camera unavailable" = "Fotoaparát je nedostupný"; + +"Capturing…" = "Zaznamenáva sa..."; + +"Choose File" = "Vybrať súbor"; + +"Consent" = "Súhlas"; + +"Could not capture image" = "Nepodarilo sa nasnímať fotografiu"; + +"Date of Birth" = "Dátum narodenia"; + +"Date of birth does not look valid" = "Zdá sa, že dátum narodenia je neplatný"; + +"Flip your driver's license over to the other side" = "Otočte svoj vodičský preukaz na druhú stranu"; + +"Flip your identity card over to the other side" = "Otočte svoj preukaz totožnosti na druhú stranu"; + +"Front driver's license photo successfully uploaded" = "Fotografia prednej strany vodičského preukazu bola úspešne nahraná"; + +"Front identity card photo successfully uploaded" = "Fotografia prednej strany preukazu totožnosti bola úspešne nahraná"; + +"Front of driver's license" = "Predná strana vodičského preukazu"; + +"Front of identity card" = "Predná strana preukazu totožnosti"; + +"Go Back" = "Späť"; + +"Hold still, scanning" = "Držte pokojne, skenuje sa"; + +"ID Number" = "Identifikačné číslo"; + +"ID Type" = "Typ ID"; + +"Image of passport" = "Fotografia pasu"; + +"Individual CPF" = "Individuálne CPF"; + +"Last 4 of Social Security number" = "Posledné 4 číslice čísla sociálneho poistenia"; + +"Loading" = "Nahrávanie"; + +"NRIC or FIN" = "NRIC alebo FIN"; + +"Passport" = "Pas"; + +"Passport photo successfully uploaded" = "Fotografia pasu bola úspešne nahraná"; + +"Personal ID number" = "Osobné identifikačné číslo"; + +"Personal Information" = "Osobné informácie"; + +"Phone Number" = "Telefónne číslo"; + +"Phone Verification" = "Telefonické overenie"; + +"Photo Library" = "Knižnica fotografií"; + +"Please upload an image of your passport" = "Nahrajte fotografiu pasu"; + +"Please upload images of the front and back of your driver's license" = "Nahrajte fotografie prednej a zadnej strany vodičského preukazu"; + +"Please upload images of the front and back of your identity card" = "Nahrajte fotografie prednej a zadnej strany preukazu totožnosti"; + +"Position your driver's license in the center of the frame" = "Umiestnite svoj vodičský preukaz do stredu rámika"; + +"Position your face in the center of the frame." = "Umiestnite tvár do stredu rámika."; + +"Position your identity card in the center of the frame" = "Umiestnite svoj preukaz totožnosti do stredu rámika"; + +"Position your passport in the center of the frame" = "Umiestnite svoj pas do stredu rámika"; + +"Retake Photos" = "Znova odfotiť"; + +"Scan" = "Skenovať"; + +"Scanned" = "Naskenované"; + +"Select" = "Zvoliť"; + +"Select a location to upload the back of your identity document from" = "Vyberte umiestnenie, z ktorého chcete nahrať zadnú stranu dokladu totožnosti"; + +"Select a location to upload the front of your identity document from" = "Vyberte umiestnenie, z ktorého chcete nahrať prednú stranu dokladu totožnosti"; + +"Select back driver's license photo" = "Vyberte fotografiu zadnej strany vodičského preukazu"; + +"Select back identity card photo" = "Vyberte fotografiu zadnej strany preukazu totožnosti"; + +"Select front driver's license photo" = "Vyberte fotografiu prednej strany vodičského preukazu"; + +"Select front identity card photo" = "Vyberte fotografiu prednej strany preukazu totožnosti"; + +"Select passport photo" = "Vyberte fotografiu pasu"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Selfie zábery"; + +"Selfie captures are complete" = "Selfie zábery sú dokončené"; + +"Take Photo" = "Odfotiť"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Obrázky vášho dokladu totožnosti sa neuložili. Chcete odísť?"; + +"There was an error accessing the camera." = "Pri prístupe k fotoaparátu sa vyskytla chyba."; + +"Try Again" = "Skúste to znova"; + +"Unable to establish a connection." = "Nepodarilo sa vytvoriť spojenie."; + +"Unsaved changes" = "Neuložené zmeny"; + +"Upload" = "Nahrať"; + +"Upload a Photo" = "Nahrať fotografiu"; + +"Upload your photo ID" = "Nahrajte doklad totožnosti s fotografiou"; + +"Uploading back driver's license photo" = "Nahrávanie fotografie zo zadnej strany vodičského preukazu"; + +"Uploading back identity card photo" = "Nahrávanie fotografie zo zadnej strany preukazu totožnosti"; + +"Uploading front driver's license photo" = "Nahrávanie fotografie z prednej strany vodičského preukazu"; + +"Uploading front identity card photo" = "Nahrávanie fotografie z prednej strany preukazu totožnosti"; + +"Uploading passport photo" = "Nahrať fotografiu z pasu"; + +"Verify your identity" = "Overiť totožnosť"; + +"We could not capture a high-quality image." = "Nepodarilo sa nasnímať obrázok vo vysokej kvalite."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Potrebujeme povolenie na použitie vášho fotoaparátu. Povoľte prístup k fotoaparátu v nastaveniach aplikácie."; + +"Welcome" = "Vitajte"; + +"You can either try again or upload an image from your device." = "Môžete to skúsiť znova alebo nahrať obrázok zo svojho zariadenia."; + +"Your selfie images have not been saved. Do you want to leave?" = "Obrázky selfie sa neuložili. Chcete odísť?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/sl-SI.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/sl-SI.lproj/Localizable.strings new file mode 100644 index 00000000..1e05a470 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/sl-SI.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Fotografijo svojega osebnega dokumenta pa lahko tudi ročno naložite."; + +"App Settings" = "Nastavitve aplikacije"; + +"Back driver's license photo successfully uploaded" = "Fotografija hrbtne strani vozniškega dovoljenja je bila uspešno naložena"; + +"Back identity card photo successfully uploaded" = "Fotografija hrbtne strani osebne izkaznice je bila uspešno naložena"; + +"Back of driver's license" = "Hrbtna stran vozniškega dovoljenja"; + +"Back of identity card" = "Hrbtna stran osebne izkaznice"; + +"Camera permission" = "Dovoljenje za kamero"; + +"Camera unavailable" = "Kamera ni na voljo"; + +"Capturing…" = "Zajemanje ..."; + +"Choose File" = "Izberite datoteko"; + +"Consent" = "Soglasje"; + +"Could not capture image" = "Slike ni bilo mogoče posneti"; + +"Date of Birth" = "Datum rojstva"; + +"Date of birth does not look valid" = "Videti je, da datum rojstva ni veljaven"; + +"Flip your driver's license over to the other side" = "Obrnite vozniško dovoljenje na drugo stran"; + +"Flip your identity card over to the other side" = "Obrnite osebno izkaznico na drugo stran"; + +"Front driver's license photo successfully uploaded" = "Fotografija sprednje strani vozniškega dovoljenja je bila uspešno naložena"; + +"Front identity card photo successfully uploaded" = "Fotografija sprednje strani osebne izkaznice je bila uspešno naložena"; + +"Front of driver's license" = "Sprednja stran vozniškega dovoljenja"; + +"Front of identity card" = "Sprednja stran osebne izkaznice"; + +"Go Back" = "Nazaj"; + +"Hold still, scanning" = "Optično branje – držite pri miru"; + +"ID Number" = "Številka osebnega dokumenta"; + +"ID Type" = "Vrsta osebnega dokumenta"; + +"Image of passport" = "Slika potnega lista"; + +"Individual CPF" = "CPF posameznika"; + +"Last 4 of Social Security number" = "Zadnje 4 števke številke socialnega zavarovanja"; + +"Loading" = "Nalaganje"; + +"NRIC or FIN" = "NRIC ali FIN"; + +"Passport" = "Potni list"; + +"Passport photo successfully uploaded" = "Fotografija potnega lista je bila uspešno naložena"; + +"Personal ID number" = "Številka osebne izkaznice"; + +"Personal Information" = "Osebni podatki"; + +"Phone Number" = "Telefonska številka"; + +"Phone Verification" = "Preverjanje po telefonu"; + +"Photo Library" = "Knjižnica fotografij"; + +"Please upload an image of your passport" = "Naložite sliko potnega lista"; + +"Please upload images of the front and back of your driver's license" = "Naložite sliki sprednje in hrbtne strani vozniškega dovoljenja"; + +"Please upload images of the front and back of your identity card" = "Naložite sliki sprednje in hrbtne strani osebne izkaznice"; + +"Position your driver's license in the center of the frame" = "Zagotovite, da je vozniško dovoljenje na sredini okvirja"; + +"Position your face in the center of the frame." = "Zagotovite, da je vaš obraz na sredini okvira."; + +"Position your identity card in the center of the frame" = "Zagotovite, da je osebna izkaznica na sredini okvirja"; + +"Position your passport in the center of the frame" = "Zagotovite, da je potni list na sredini okvirja"; + +"Retake Photos" = "Znova posnemi fotografije"; + +"Scan" = "Optično preberi"; + +"Scanned" = "Optično prebrano"; + +"Select" = "Izberi"; + +"Select a location to upload the back of your identity document from" = "Izberite mesto, s katerega želite naložiti hrbtno stran osebnega dokumenta"; + +"Select a location to upload the front of your identity document from" = "Izberite mesto, s katerega želite naložiti sprednjo stran osebnega dokumenta"; + +"Select back driver's license photo" = "Izberite fotografijo hrbtne strani vozniškega dovoljenja"; + +"Select back identity card photo" = "Izberite fotografijo hrbtne strani osebne izkaznice"; + +"Select front driver's license photo" = "Izberite fotografijo sprednje strani vozniškega dovoljenja"; + +"Select front identity card photo" = "Izberite fotografijo sprednje strani osebne izkaznice"; + +"Select passport photo" = "Izberite fotografijo potnega lista"; + +"Selfie" = "Selfi"; + +"Selfie captures" = "Posnetki selfija"; + +"Selfie captures are complete" = "Selfiji so posneti"; + +"Take Photo" = "Fotografiraj"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Slike vašega osebnega dokumenta niso shranjene. Ali želite zapreti to stran?"; + +"There was an error accessing the camera." = "Pri dostopu do kamere je prišlo do napake."; + +"Try Again" = "Poskusi znova"; + +"Unable to establish a connection." = "Povezave ni mogoče vzpostaviti."; + +"Unsaved changes" = "Neshranjene spremembe"; + +"Upload" = "Naloži"; + +"Upload a Photo" = "Naloži fotografijo"; + +"Upload your photo ID" = "Naložite osebni dokument s svojo fotografijo"; + +"Uploading back driver's license photo" = "Nalaganje fotografije hrbtne strani vozniškega dovoljenja"; + +"Uploading back identity card photo" = "Nalaganje fotografije hrbtne strani osebne izkaznice"; + +"Uploading front driver's license photo" = "Nalaganje fotografije sprednje strani vozniškega dovoljenja"; + +"Uploading front identity card photo" = "Nalaganje fotografije sprednje strani osebne izkaznice"; + +"Uploading passport photo" = "Nalaganje potnega lista"; + +"Verify your identity" = "Preverite svojo identiteto"; + +"We could not capture a high-quality image." = "Ni bilo mogoče zajeti visokokakovostne slike."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Potrebujemo dovoljenje za uporabo vaše kamere. Dovolite dostop do kamere v nastavitvah aplikacije."; + +"Welcome" = "Dobrodošli"; + +"You can either try again or upload an image from your device." = "Poskusite znova ali pa naložite sliko iz svoje naprave."; + +"Your selfie images have not been saved. Do you want to leave?" = "Slike vašega selfija niso shranjene. Ali želite zapreti to stran?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/sv.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/sv.lproj/Localizable.strings new file mode 100644 index 00000000..8977e987 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/sv.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Du kan även manuellt ladda upp ett foto av din id-handling."; + +"App Settings" = "Appinställningar"; + +"Back driver's license photo successfully uploaded" = "Bilden på körkortets baksida har laddats upp"; + +"Back identity card photo successfully uploaded" = "Bilden på id-kortets baksida har laddats upp"; + +"Back of driver's license" = "Baksida av körkort"; + +"Back of identity card" = "Baksida av id-kort"; + +"Camera permission" = "Kamerabehörighet"; + +"Camera unavailable" = "Kamera ej tillgänglig"; + +"Capturing…" = "Tar bild …"; + +"Choose File" = "Välj fil"; + +"Consent" = "Samtycke"; + +"Could not capture image" = "Det gick inte att ta bilden"; + +"Date of Birth" = "Födelsedatum"; + +"Date of birth does not look valid" = "Födelsedatumet verkar inte vara giltig"; + +"Flip your driver's license over to the other side" = "Vänd på körkortet"; + +"Flip your identity card over to the other side" = "Vänd på id-handlingen"; + +"Front driver's license photo successfully uploaded" = "Bilden på körkortets framsida har laddats upp"; + +"Front identity card photo successfully uploaded" = "Bilden på id-kortets framsida har laddats upp"; + +"Front of driver's license" = "Framsida av körkort"; + +"Front of identity card" = "Framsida av id-kort"; + +"Go Back" = "Gå tillbaka"; + +"Hold still, scanning" = "Rör dig inte, dokumentet skannas in"; + +"ID Number" = "ID-nummer"; + +"ID Type" = "Typ av id-handling"; + +"Image of passport" = "Bild av pass"; + +"Individual CPF" = "Personligt CPF"; + +"Last 4 of Social Security number" = "Fyra sista siffrorna i socialförsäkringsnumret"; + +"Loading" = "Laddar"; + +"NRIC or FIN" = "NRIC eller FIN"; + +"Passport" = "Pass"; + +"Passport photo successfully uploaded" = "Passfotot har laddats upp"; + +"Personal ID number" = "Personligt ID-nummer"; + +"Personal Information" = "Personuppgifter"; + +"Phone Number" = "Telefonnummer"; + +"Phone Verification" = "Telefonverifiering"; + +"Photo Library" = "Fotobibliotek"; + +"Please upload an image of your passport" = "Ladda upp en bild på ditt pass"; + +"Please upload images of the front and back of your driver's license" = "Ladda upp bilderna på fram- och baksidan av ditt körkort"; + +"Please upload images of the front and back of your identity card" = "Ladda upp bilderna på fram- och baksidan av ditt id-kort"; + +"Position your driver's license in the center of the frame" = "Placera körkortet i mitten av ramen"; + +"Position your face in the center of the frame." = "Placera ansiktet i mitten av ramen."; + +"Position your identity card in the center of the frame" = "Placera id-kortet i mitten av ramen"; + +"Position your passport in the center of the frame" = "Placera passet i mitten av ramen"; + +"Retake Photos" = "Ta bilden på nytt"; + +"Scan" = "Skanna dokument"; + +"Scanned" = "Dokumentet har skannats"; + +"Select" = "Välj"; + +"Select a location to upload the back of your identity document from" = "Välj från vilken plats du vill ladda upp baksidan på din id-handling"; + +"Select a location to upload the front of your identity document from" = "Välj från vilken plats du vill ladda upp framsidan på din id-handling"; + +"Select back driver's license photo" = "Välj en bild på körkortets baksida"; + +"Select back identity card photo" = "Välj en bild på id-kortets baksida"; + +"Select front driver's license photo" = "Välj en bild på körkortets framsida"; + +"Select front identity card photo" = "Välj en bild på id-kortets framsida"; + +"Select passport photo" = "Välj passfoto"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Selfiebilder"; + +"Selfie captures are complete" = "Selfiebilderna har tagits"; + +"Take Photo" = "Ta bild"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Bilderna av din id-handling har inte sparats. Vill du avsluta?"; + +"There was an error accessing the camera." = "Ett fel uppstod vid åtkomst till kameran."; + +"Try Again" = "Försök igen"; + +"Unable to establish a connection." = "Det gick inte att upprätta en anslutning."; + +"Unsaved changes" = "Ändringarna har inte sparats"; + +"Upload" = "Ladda upp"; + +"Upload a Photo" = "Ladda upp en bild"; + +"Upload your photo ID" = "Ladda upp ditt foto-id"; + +"Uploading back driver's license photo" = "Laddar upp bild på körkortets baksida"; + +"Uploading back identity card photo" = "Laddar upp bild på id-kortets baksida"; + +"Uploading front driver's license photo" = "Laddar upp bild på körkortets framsida"; + +"Uploading front identity card photo" = "Laddar upp bild på id-kortets framsida"; + +"Uploading passport photo" = "Laddar upp passfoto"; + +"Verify your identity" = "Verifiera din identitet"; + +"We could not capture a high-quality image." = "Det gick inte att ta en bild av hög kvalitet."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Vi behöver behörighet att använda din kamera. Tillåt kameraåtkomst i appens inställningar."; + +"Welcome" = "Välkommen"; + +"You can either try again or upload an image from your device." = "Du kan antingen försöka igen eller ladda upp en bild från din enhet."; + +"Your selfie images have not been saved. Do you want to leave?" = "Dina selfies har inte sparats. Vill du avsluta?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/tr.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/tr.lproj/Localizable.strings new file mode 100644 index 00000000..57665282 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/tr.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Kimlik belgenizin fotoğrafını manuel olarak da yükleyebilirsiniz."; + +"App Settings" = "Uygulama Ayarları"; + +"Back driver's license photo successfully uploaded" = "Sürücü belgesinin arka yüz fotoğrafı başarıyla yüklendi"; + +"Back identity card photo successfully uploaded" = "Kimliğin arka yüz fotoğrafı başarıyla yüklendi"; + +"Back of driver's license" = "Sürücü belgesinin arka yüzü"; + +"Back of identity card" = "Kimliğin arka yüzü"; + +"Camera permission" = "Kamera izni"; + +"Camera unavailable" = "Kamera kullanılamıyor"; + +"Capturing…" = "Çekiliyor..."; + +"Choose File" = "Dosya Seç"; + +"Consent" = "Onay"; + +"Could not capture image" = "Fotoğraf alınamadı"; + +"Date of Birth" = "Doğum Tarihi"; + +"Date of birth does not look valid" = "Doğum tarihi geçerli görünmüyor"; + +"Flip your driver's license over to the other side" = "Sürücü belgenizin diğer yüzünü çevirin"; + +"Flip your identity card over to the other side" = "Kimliğinizin diğer yüzünü çevirin"; + +"Front driver's license photo successfully uploaded" = "Sürücü belgesinin ön yüz fotoğrafı başarıyla yüklendi"; + +"Front identity card photo successfully uploaded" = "Kimliğin ön yüz fotoğrafı başarıyla yüklendi"; + +"Front of driver's license" = "Sürücü belgesinin ön yüzü"; + +"Front of identity card" = "Kimliğin ön yüzü"; + +"Go Back" = "Geri Git"; + +"Hold still, scanning" = "Bekleyin, taranıyor"; + +"ID Number" = "Kimlik Numarası"; + +"ID Type" = "Kimlik Belgesi Türü"; + +"Image of passport" = "Pasaport fotoğrafı"; + +"Individual CPF" = "Bireysel CPF"; + +"Last 4 of Social Security number" = "Sosyal Güvenlik numarasının son 4 hanesi"; + +"Loading" = "Yükleniyor"; + +"NRIC or FIN" = "NRIC veya FIN"; + +"Passport" = "Pasaport"; + +"Passport photo successfully uploaded" = "Pasaport fotoğrafı başarıyla yüklendi"; + +"Personal ID number" = "Kişisel kimlik numarası"; + +"Personal Information" = "Kişisel Bilgiler"; + +"Phone Number" = "Telefon Numarası"; + +"Phone Verification" = "Telefon doğrulama"; + +"Photo Library" = "Fotoğraf Kitaplığı"; + +"Please upload an image of your passport" = "Lütfen pasaportunuzun bir fotoğrafını yükleyin"; + +"Please upload images of the front and back of your driver's license" = "Lütfen sürücü belgenizin ön ve arka yüzünün fotoğraflarını yükleyin"; + +"Please upload images of the front and back of your identity card" = "Lütfen kimlik belgenizin ön ve arka yüzlerinin fotoğraflarını yükleyin"; + +"Position your driver's license in the center of the frame" = "Sürücü belgenizi çerçevenin ortasına yerleştirin"; + +"Position your face in the center of the frame." = "Yüzünüzü çerçevenin ortasına yerleştirin."; + +"Position your identity card in the center of the frame" = "Kimliğinizi çerçevenin ortasına yerleştirin"; + +"Position your passport in the center of the frame" = "Pasaportunuzu çerçevenin ortasına yerleştirin"; + +"Retake Photos" = "Fotoğrafları Tekrar Çek"; + +"Scan" = "Tara"; + +"Scanned" = "Tarandı"; + +"Select" = "Seç"; + +"Select a location to upload the back of your identity document from" = "Kimlik belgenizin arka yüzünü yükleyeceğiniz konumu seçin"; + +"Select a location to upload the front of your identity document from" = "Kimlik belgenizin ön yüzünü yükleyeceğiniz konumu seçin"; + +"Select back driver's license photo" = "Sürücü belgesinin arka yüz fotoğrafını seç"; + +"Select back identity card photo" = "Kimliğin arka yüz fotoğrafını seç"; + +"Select front driver's license photo" = "Sürücü belgesinin ön yüz fotoğrafını seç"; + +"Select front identity card photo" = "Kimliğin ön yüz fotoğrafını seç"; + +"Select passport photo" = "Pasaport fotoğrafı seç"; + +"Selfie" = "Özçekim"; + +"Selfie captures" = "Özçekim çekimleri"; + +"Selfie captures are complete" = "Özçekim çekimleri tamamlandı"; + +"Take Photo" = "Fotoğraf Çek"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Kimlik belgenizin fotoğrafları kaydedilmedi. Ayrılmak istiyor musunuz?"; + +"There was an error accessing the camera." = "Kameraya erişilirken bir hata oluştu."; + +"Try Again" = "Tekrar Dene"; + +"Unable to establish a connection." = "Bağlantı kurulamıyor."; + +"Unsaved changes" = "Kaydedilmemiş değişiklikler"; + +"Upload" = "Yükle"; + +"Upload a Photo" = "Fotoğraf Yükle"; + +"Upload your photo ID" = "Fotoğraflı kimliğinizi yükleyin"; + +"Uploading back driver's license photo" = "Sürücü belgesinin arka yüz fotoğrafı yükleniyor"; + +"Uploading back identity card photo" = "Kimliğin arka yüz fotoğrafı yükleniyor"; + +"Uploading front driver's license photo" = "Sürücü belgesinin ön yüz fotoğrafı yükleniyor"; + +"Uploading front identity card photo" = "Kimliğin ön yüz fotoğrafı yükleniyor"; + +"Uploading passport photo" = "Pasaport fotoğrafı yükleniyor"; + +"Verify your identity" = "Kimliğinizi doğrulayın"; + +"We could not capture a high-quality image." = "Yüksek kaliteli bir fotoğraf yakalayamadık."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Kameranızı kullanmamız için izniniz gerekiyor. Lütfen uygulama ayarlarından kamera erişimine izin verin."; + +"Welcome" = "Hoş Geldiniz"; + +"You can either try again or upload an image from your device." = "Tekrar deneyebilir veya cihazınızdan bir fotoğraf yükleyebilirsiniz."; + +"Your selfie images have not been saved. Do you want to leave?" = "Özçekim görüntüleriniz kaydedilmedi. Çıkmak istiyor musunuz?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/vi.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/vi.lproj/Localizable.strings new file mode 100644 index 00000000..ba859fcb --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/vi.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "Ngoài ra, bạn có thể tự tải lên ảnh giấy tờ tùy thân của mình."; + +"App Settings" = "Cài đặt Ứng dụng"; + +"Back driver's license photo successfully uploaded" = "Đã thành công tải lên ảnh mặt sau bằng lái xe"; + +"Back identity card photo successfully uploaded" = "Đã thành công tải lên ảnh mặt sau thẻ căn cước"; + +"Back of driver's license" = "Mặt sau bằng lái xe"; + +"Back of identity card" = "Mặt sau thẻ căn cước"; + +"Camera permission" = "Cấp quyền máy ảnh"; + +"Camera unavailable" = "Máy ảnh không khả dụng"; + +"Capturing…" = "Đang chụp…"; + +"Choose File" = "Chọn tệp"; + +"Consent" = "Chấp thuận"; + +"Could not capture image" = "Không thể chụp ảnh"; + +"Date of Birth" = "Ngày sinh"; + +"Date of birth does not look valid" = "Ngày sinh có vẻ không hợp lệ"; + +"Flip your driver's license over to the other side" = "Lật bằng lái xe của bạn sang mặt khác"; + +"Flip your identity card over to the other side" = "Lật thẻ căn cước của bạn sang mặt khác"; + +"Front driver's license photo successfully uploaded" = "Đã thành công tải lên ảnh mặt trước bằng lái xe"; + +"Front identity card photo successfully uploaded" = "Đã thành công tải lên ảnh mặt trước thẻ căn cước"; + +"Front of driver's license" = "Mặt trước bằng lái xe"; + +"Front of identity card" = "Mặt trước thẻ căn cước"; + +"Go Back" = "Quay lại"; + +"Hold still, scanning" = "Giữ yên, đang quét"; + +"ID Number" = "Số ID"; + +"ID Type" = "Loại ID"; + +"Image of passport" = "Ảnh hộ chiếu"; + +"Individual CPF" = "CPF cá nhân"; + +"Last 4 of Social Security number" = "4 số cuối của số An Sinh Xã Hội"; + +"Loading" = "Đang tải"; + +"NRIC or FIN" = "NRIC hoặc FIN"; + +"Passport" = "Hộ chiếu"; + +"Passport photo successfully uploaded" = "Đã thành công tải lên ảnh hộ chiếu"; + +"Personal ID number" = "Số ID Cá nhân"; + +"Personal Information" = "Thông tin cá nhân"; + +"Phone Number" = "Số điện thoại"; + +"Phone Verification" = "Xác minh Số điện thoại"; + +"Photo Library" = "Thư viện ảnh"; + +"Please upload an image of your passport" = "Vui lòng tải lên hình ảnh hộ chiếu"; + +"Please upload images of the front and back of your driver's license" = "Vui lòng tải lên hình ảnh mặt trước và mặt sau của bằng lái xe"; + +"Please upload images of the front and back of your identity card" = "Vui lòng tải lên hình ảnh mặt trước và mặt sau của thẻ căn cước"; + +"Position your driver's license in the center of the frame" = "Đặt bằng lái xe của bạn ở giữa khung hình"; + +"Position your face in the center of the frame." = "Đặt khuôn mặt của bạn ở giữa khung hình."; + +"Position your identity card in the center of the frame" = "Đặt thẻ căn cước của bạn ở giữa khung hình"; + +"Position your passport in the center of the frame" = "Đặt hộ chiếu của bạn ở giữa khung hình"; + +"Retake Photos" = "Chụp lại ảnh"; + +"Scan" = "Quét"; + +"Scanned" = "Đã quét"; + +"Select" = "Chọn"; + +"Select a location to upload the back of your identity document from" = "Chọn một vị trí để tải lên mặt sau giấy tờ tùy thân của bạn"; + +"Select a location to upload the front of your identity document from" = "Chọn một vị trí để tải lên mặt trước giấy tờ tùy thân của bạn"; + +"Select back driver's license photo" = "Chọn ảnh mặt sau bằng lái xe"; + +"Select back identity card photo" = "Chọn ảnh mặt sau thẻ căn cước"; + +"Select front driver's license photo" = "Chọn ảnh mặt trước bằng lái xe"; + +"Select front identity card photo" = "Chọn ảnh mặt trước thẻ căn cước"; + +"Select passport photo" = "Chọn ảnh hộ chiếu"; + +"Selfie" = "Selfie"; + +"Selfie captures" = "Chụp ảnh selfie"; + +"Selfie captures are complete" = "Chụp ảnh selfie đã hoàn tất"; + +"Take Photo" = "Chụp ảnh"; + +"The images of your identity document have not been saved. Do you want to leave?" = "Ảnh của giấy tờ tùy thân của bạn chưa được lưu. Bạn có muốn rời đi không?"; + +"There was an error accessing the camera." = "Đã xảy ra lỗi khi truy cập máy ảnh."; + +"Try Again" = "Hãy thử lại"; + +"Unable to establish a connection." = "Không thể thiết lập kết nối."; + +"Unsaved changes" = "Thay đổi chưa lưu"; + +"Upload" = "Tải lên"; + +"Upload a Photo" = "Tải lên ảnh"; + +"Upload your photo ID" = "Tải lên ID có ảnh của bạn"; + +"Uploading back driver's license photo" = "Đang tải lên ảnh mặt sau bằng lái xe"; + +"Uploading back identity card photo" = "Đang tải lên ảnh mặt sau thẻ căn cước"; + +"Uploading front driver's license photo" = "Đang tải lên ảnh mặt trước bằng lái xe"; + +"Uploading front identity card photo" = "Đang tải lên ảnh mặt trước thẻ căn cước"; + +"Uploading passport photo" = "Đang tải lên ảnh hộ chiếu"; + +"Verify your identity" = "Xác minh danh tính của bạn"; + +"We could not capture a high-quality image." = "Chúng tôi không thể chụp ảnh có chất lượng cao."; + +"We need permission to use your camera. Please allow camera access in app settings." = "Chúng tôi cần được cấp quyền để sử dụng máy ảnh của bạn. Vui lòng cho phép truy cập máy ảnh trong phần cài đặt ứng dụng."; + +"Welcome" = "Chào mừng"; + +"You can either try again or upload an image from your device." = "Bạn có thể thử lại hoặc tải lên hình ảnh từ thiết bị của mình."; + +"Your selfie images have not been saved. Do you want to leave?" = "Ảnh selfie của bạn chưa được lưu. Bạn có muốn rời khỏi?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/zh-HK.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/zh-HK.lproj/Localizable.strings new file mode 100644 index 00000000..fc01f083 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/zh-HK.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "您也可以手動上載您的身份證件的照片。"; + +"App Settings" = "應用設定"; + +"Back driver's license photo successfully uploaded" = "駕駛執照背面照片已成功上載"; + +"Back identity card photo successfully uploaded" = "身份證背面照片已成功上載"; + +"Back of driver's license" = "駕駛執照背面"; + +"Back of identity card" = "身份證背面"; + +"Camera permission" = "相機許可"; + +"Camera unavailable" = "相機不可用"; + +"Capturing…" = "正在拍攝..."; + +"Choose File" = "選擇文件"; + +"Consent" = "許可"; + +"Could not capture image" = "無法捕獲圖片"; + +"Date of Birth" = "出生日期"; + +"Date of birth does not look valid" = "出生日期看似無效"; + +"Flip your driver's license over to the other side" = "將駕駛執照翻到另一面"; + +"Flip your identity card over to the other side" = "將身份證翻到另一面"; + +"Front driver's license photo successfully uploaded" = "駕駛執照正面照片已成功上載"; + +"Front identity card photo successfully uploaded" = "身份證正面照片已成功上載"; + +"Front of driver's license" = "駕駛執照正面"; + +"Front of identity card" = "身份證正面"; + +"Go Back" = "返回"; + +"Hold still, scanning" = "保持不動,正在掃描"; + +"ID Number" = "證件號碼"; + +"ID Type" = "證件類型"; + +"Image of passport" = "護照照片"; + +"Individual CPF" = "個人 CPF"; + +"Last 4 of Social Security number" = "社會安全號碼後 4 位"; + +"Loading" = "正在載入"; + +"NRIC or FIN" = "NRIC 或 FIN"; + +"Passport" = "護照"; + +"Passport photo successfully uploaded" = "護照照片已成功上載"; + +"Personal ID number" = "個人身份證件號碼"; + +"Personal Information" = "個人資訊"; + +"Phone Number" = "電話號碼"; + +"Phone Verification" = "電話驗證"; + +"Photo Library" = "照片庫"; + +"Please upload an image of your passport" = "請上載您的護照的照片"; + +"Please upload images of the front and back of your driver's license" = "請上載您的駕駛執照正反面的照片"; + +"Please upload images of the front and back of your identity card" = "請上載您的身份證正反面的照片"; + +"Position your driver's license in the center of the frame" = "將駕駛執照放在畫面正中間"; + +"Position your face in the center of the frame." = "將臉放在畫面正中間。"; + +"Position your identity card in the center of the frame" = "將身份證放在畫面正中間"; + +"Position your passport in the center of the frame" = "將護照放在畫面正中間"; + +"Retake Photos" = "重新拍照"; + +"Scan" = "掃描"; + +"Scanned" = "已掃描"; + +"Select" = "選擇"; + +"Select a location to upload the back of your identity document from" = "選擇一個位置,從那裡您上載您的身份證的背面"; + +"Select a location to upload the front of your identity document from" = "選擇一個位置,從那裡您上載您的身份證的正面"; + +"Select back driver's license photo" = "選擇駕駛執照背面照片"; + +"Select back identity card photo" = "選擇身份證背面照片"; + +"Select front driver's license photo" = "選擇駕駛執照正面照片"; + +"Select front identity card photo" = "選擇身份證正面照片"; + +"Select passport photo" = "選擇護照照片"; + +"Selfie" = "自拍照"; + +"Selfie captures" = "自拍照拍攝"; + +"Selfie captures are complete" = "已完成自拍照拍攝"; + +"Take Photo" = "拍照"; + +"The images of your identity document have not been saved. Do you want to leave?" = "尚未保存您的身份證件的照片。想要離開嗎?"; + +"There was an error accessing the camera." = "訪問相機時發生了錯誤。"; + +"Try Again" = "重試"; + +"Unable to establish a connection." = "無法建立連接。"; + +"Unsaved changes" = "未保存的更改"; + +"Upload" = "上載"; + +"Upload a Photo" = "上載一張照片"; + +"Upload your photo ID" = "上載您帶照片的身份證件"; + +"Uploading back driver's license photo" = "正在上載駕駛執照背面照片"; + +"Uploading back identity card photo" = "正在上載身份證背面照片"; + +"Uploading front driver's license photo" = "正在上載駕駛執照正面照片"; + +"Uploading front identity card photo" = "正在上載身份證正面照片"; + +"Uploading passport photo" = "正在上載護照照片"; + +"Verify your identity" = "驗證您的身份"; + +"We could not capture a high-quality image." = "我們未能捕捉到高品質圖片。"; + +"We need permission to use your camera. Please allow camera access in app settings." = "我們需要使用您的相機的許可。請在應用設定中允許使用相機。"; + +"Welcome" = "歡迎"; + +"You can either try again or upload an image from your device." = "您可以重試或從設備上載一張照片。"; + +"Your selfie images have not been saved. Do you want to leave?" = "尚未保存您的自拍照。想要離開嗎?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/zh-Hans.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/zh-Hans.lproj/Localizable.strings new file mode 100644 index 00000000..60028fd8 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "您也可以手动上传您的身份证件的照片。"; + +"App Settings" = "应用设置"; + +"Back driver's license photo successfully uploaded" = "驾驶证背面照片已成功上传"; + +"Back identity card photo successfully uploaded" = "身份证背面照片已成功上传"; + +"Back of driver's license" = "驾驶证背面"; + +"Back of identity card" = "身份证背面"; + +"Camera permission" = "相机许可"; + +"Camera unavailable" = "相机不可用"; + +"Capturing…" = "正在拍摄..."; + +"Choose File" = "选择文件"; + +"Consent" = "许可"; + +"Could not capture image" = "无法拍摄图片"; + +"Date of Birth" = "出生日期"; + +"Date of birth does not look valid" = "出生日期看似无效"; + +"Flip your driver's license over to the other side" = "将驾驶证翻到另一面"; + +"Flip your identity card over to the other side" = "将身份证翻到另一面"; + +"Front driver's license photo successfully uploaded" = "驾驶证正面照片已成功上传"; + +"Front identity card photo successfully uploaded" = "身份证正面照片已成功上传"; + +"Front of driver's license" = "驾驶证正面"; + +"Front of identity card" = "身份证正面"; + +"Go Back" = "返回"; + +"Hold still, scanning" = "保持不动,正在扫描"; + +"ID Number" = "证件号码"; + +"ID Type" = "证件类型"; + +"Image of passport" = "护照照片"; + +"Individual CPF" = "个人 CPF"; + +"Last 4 of Social Security number" = "社会保障号码后 4 位"; + +"Loading" = "正在加载"; + +"NRIC or FIN" = "NRIC 或 FIN"; + +"Passport" = "护照"; + +"Passport photo successfully uploaded" = "护照照片已成功上传"; + +"Personal ID number" = "个人身份证件号码"; + +"Personal Information" = "个人信息"; + +"Phone Number" = "电话号码"; + +"Phone Verification" = "电话验证"; + +"Photo Library" = "照片库"; + +"Please upload an image of your passport" = "请上传您的护照的照片"; + +"Please upload images of the front and back of your driver's license" = "请上传您的驾驶证正反面的照片"; + +"Please upload images of the front and back of your identity card" = "请上传您的身份证正反面的照片"; + +"Position your driver's license in the center of the frame" = "将驾驶证放在画面正中间"; + +"Position your face in the center of the frame." = "将脸放在画面正中间。"; + +"Position your identity card in the center of the frame" = "将身份证放在画面正中间"; + +"Position your passport in the center of the frame" = "将护照放在画面正中间"; + +"Retake Photos" = "重新拍照"; + +"Scan" = "扫描"; + +"Scanned" = "已扫描"; + +"Select" = "选择"; + +"Select a location to upload the back of your identity document from" = "选择一个位置,从那里您上传您的身份证的背面"; + +"Select a location to upload the front of your identity document from" = "选择一个位置,从那里您上传您的身份证的正面"; + +"Select back driver's license photo" = "选择驾驶证背面照片"; + +"Select back identity card photo" = "选择身份证背面照片"; + +"Select front driver's license photo" = "选择驾驶证正面照片"; + +"Select front identity card photo" = "选择身份证正面照片"; + +"Select passport photo" = "选择护照照片"; + +"Selfie" = "自拍照"; + +"Selfie captures" = "自拍照拍摄"; + +"Selfie captures are complete" = "已完成自拍照拍摄"; + +"Take Photo" = "拍照"; + +"The images of your identity document have not been saved. Do you want to leave?" = "尚未保存您的身份证件的照片。想要离开吗?"; + +"There was an error accessing the camera." = "访问相机时发生了错误。"; + +"Try Again" = "重试"; + +"Unable to establish a connection." = "无法建立连接。"; + +"Unsaved changes" = "未保存的更改"; + +"Upload" = "上传"; + +"Upload a Photo" = "上传一张照片"; + +"Upload your photo ID" = "上传您的带照片身份证件"; + +"Uploading back driver's license photo" = "正在上传驾驶证背面照片"; + +"Uploading back identity card photo" = "正在上传身份证背面照片"; + +"Uploading front driver's license photo" = "正在上传驾驶证正面照片"; + +"Uploading front identity card photo" = "正在上传身份证正面照片"; + +"Uploading passport photo" = "正在上传护照照片"; + +"Verify your identity" = "验证您的身份"; + +"We could not capture a high-quality image." = "我们未能捕捉到高质量图片。"; + +"We need permission to use your camera. Please allow camera access in app settings." = "我们需要使用您的相机的许可。请在应用设置中允许使用相机。"; + +"Welcome" = "欢迎"; + +"You can either try again or upload an image from your device." = "您可以重试或从设备上传一张照片。"; + +"Your selfie images have not been saved. Do you want to leave?" = "尚未保存您的自拍照。想要离开吗?"; diff --git a/StripeIdentity/StripeIdentity/Resources/Localizations/zh-Hant.lproj/Localizable.strings b/StripeIdentity/StripeIdentity/Resources/Localizations/zh-Hant.lproj/Localizable.strings new file mode 100644 index 00000000..74f28ff5 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Resources/Localizations/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,153 @@ +"Alternatively, you may manually upload a photo of your identity document." = "您也可以手動上傳您的身分證件的照片。"; + +"App Settings" = "應用設定"; + +"Back driver's license photo successfully uploaded" = "駕駛執照背面照片已成功上傳"; + +"Back identity card photo successfully uploaded" = "身份證背面照片已成功上傳"; + +"Back of driver's license" = "駕駛執照背面"; + +"Back of identity card" = "身分證背面"; + +"Camera permission" = "攝像機許可"; + +"Camera unavailable" = "相機不可用"; + +"Capturing…" = "正在拍攝..."; + +"Choose File" = "選擇檔案"; + +"Consent" = "許可"; + +"Could not capture image" = "無法捕獲圖片"; + +"Date of Birth" = "出生日期"; + +"Date of birth does not look valid" = "出生日期看似無效"; + +"Flip your driver's license over to the other side" = "將駕駛執照翻到另一面"; + +"Flip your identity card over to the other side" = "將身份證翻到另一面"; + +"Front driver's license photo successfully uploaded" = "駕駛執照正面照片已成功上傳"; + +"Front identity card photo successfully uploaded" = "身份證正面照片已成功上傳"; + +"Front of driver's license" = "駕駛執照正面"; + +"Front of identity card" = "身分證正面"; + +"Go Back" = "返回"; + +"Hold still, scanning" = "保持不動,正在掃描"; + +"ID Number" = "證件號碼"; + +"ID Type" = "證件類型"; + +"Image of passport" = "護照照片"; + +"Individual CPF" = "個人 CPF"; + +"Last 4 of Social Security number" = "社會安全號碼後 4 位"; + +"Loading" = "正在載入"; + +"NRIC or FIN" = "NRIC 或 FIN"; + +"Passport" = "護照"; + +"Passport photo successfully uploaded" = "護照照片已成功上傳"; + +"Personal ID number" = "個人身分證件號碼"; + +"Personal Information" = "個人資訊"; + +"Phone Number" = "電話號碼"; + +"Phone Verification" = "電話驗證"; + +"Photo Library" = "照片庫"; + +"Please upload an image of your passport" = "請上傳您的護照的照片"; + +"Please upload images of the front and back of your driver's license" = "請上傳您的駕駛執照正反面的照片"; + +"Please upload images of the front and back of your identity card" = "請上傳您的身分證正反面的照片"; + +"Position your driver's license in the center of the frame" = "將駕駛執照放在畫面正中間"; + +"Position your face in the center of the frame." = "將臉放在畫面正中間。"; + +"Position your identity card in the center of the frame" = "將身份證放在畫面正中間"; + +"Position your passport in the center of the frame" = "將護照放在畫面正中間"; + +"Retake Photos" = "重新拍照"; + +"Scan" = "掃描"; + +"Scanned" = "已掃描"; + +"Select" = "選擇"; + +"Select a location to upload the back of your identity document from" = "選擇一個位置,從那裡您上傳您的身分證的背面"; + +"Select a location to upload the front of your identity document from" = "選擇一個位置,從那裡您上傳您的身分證的正面"; + +"Select back driver's license photo" = "選擇駕駛執照背面照片"; + +"Select back identity card photo" = "選擇身份證背面照片"; + +"Select front driver's license photo" = "選擇駕駛執照正面照片"; + +"Select front identity card photo" = "選擇身份證正面照片"; + +"Select passport photo" = "選擇護照照片"; + +"Selfie" = "自拍照"; + +"Selfie captures" = "自拍照拍攝"; + +"Selfie captures are complete" = "已完成自拍照拍攝"; + +"Take Photo" = "拍照"; + +"The images of your identity document have not been saved. Do you want to leave?" = "尚未保存您的身份證件的照片。想要離開嗎?"; + +"There was an error accessing the camera." = "訪問相機時發生了錯誤。"; + +"Try Again" = "重試"; + +"Unable to establish a connection." = "無法建立連接。"; + +"Unsaved changes" = "未保存的更改"; + +"Upload" = "上傳"; + +"Upload a Photo" = "上傳一張照片"; + +"Upload your photo ID" = "上傳您帶照片的身分證件"; + +"Uploading back driver's license photo" = "正在上傳駕駛執照背面照片"; + +"Uploading back identity card photo" = "正在上傳身份證背面照片"; + +"Uploading front driver's license photo" = "正在上傳駕駛執照正面照片"; + +"Uploading front identity card photo" = "正在上傳身份證正面照片"; + +"Uploading passport photo" = "正在上傳護照照片"; + +"Verify your identity" = "驗證您的身份"; + +"We could not capture a high-quality image." = "我們未能捕捉到高品質圖片。"; + +"We need permission to use your camera. Please allow camera access in app settings." = "我們需要使用您的相機的許可。請在應用設定中允許使用相機。"; + +"Welcome" = "歡迎"; + +"You can either try again or upload an image from your device." = "您可以重試或從裝置上傳一張照片。"; + +"Your selfie images have not been saved. Do you want to leave?" = "尚未保存您的自拍照。想要離開嗎?"; diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/DocumentScanner+API.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/DocumentScanner+API.swift new file mode 100644 index 00000000..6437e8b6 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/DocumentScanner+API.swift @@ -0,0 +1,45 @@ +// +// DocumentScanner+API.swift +// StripeIdentity +// +// Created by Mel Ludowise on 4/14/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +import Vision + +extension DocumentScanner.Configuration { + // TODO: collect historical data and update the threshold from server. + static let defaultBlurThreshold: Decimal = 0.0 + + init( + from capturePageConfig: StripeAPI.VerificationPageStaticContentDocumentCapturePage, + for locale: Locale = .autoupdatingCurrent + ) { + self.init( + idDetectorMinScore: capturePageConfig.models.idDetectorMinScore.floatValue, + idDetectorMinIOU: capturePageConfig.models.idDetectorMinIou.floatValue, + motionBlurMinIOU: capturePageConfig.motionBlurMinIou.floatValue, + motionBlurMinDuration: TimeInterval(capturePageConfig.motionBlurMinDuration) / 1000, + backIdCardBarcodeSymbology: capturePageConfig.symbology(for: locale), + backIdCardBarcodeTimeout: TimeInterval(capturePageConfig.iosIdCardBackBarcodeTimeout) + / 1000, + blurThreshold: (capturePageConfig.blurThreshold ?? DocumentScanner.Configuration.defaultBlurThreshold).floatValue, + highResImageCorpPadding: capturePageConfig.highResImageCropPadding + ) + } +} + +extension StripeAPI.VerificationPageStaticContentDocumentCapturePage { + func symbology(for locale: Locale) -> VNBarcodeSymbology? { + guard let regionCode = locale.regionCode, + let symbologyString = iosIdCardBackCountryBarcodeSymbologies[regionCode] + else { + return nil + } + + return VNBarcodeSymbology(fromStringValue: symbologyString) + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/DocumentType+StripeIdentity.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/DocumentType+StripeIdentity.swift new file mode 100644 index 00000000..05535319 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/DocumentType+StripeIdentity.swift @@ -0,0 +1,21 @@ +// +// DocumentType+StripeIdentity.swift +// StripeIdentity +// +// Created by Mel Ludowise on 1/11/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +extension DocumentType { + var hasBack: Bool { + switch self { + case .passport: + return false + case .drivingLicense, + .idCard: + return true + } + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/DocumentUploader+API.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/DocumentUploader+API.swift new file mode 100644 index 00000000..a4a96942 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/DocumentUploader+API.swift @@ -0,0 +1,63 @@ +// +// DocumentUploader+API.swift +// StripeIdentity +// +// Created by Mel Ludowise on 1/6/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCameraCore +import UIKit + +extension IdentityImageUploader.Configuration { + init( + from capturePageConfig: StripeAPI.VerificationPageStaticContentDocumentCapturePage + ) { + self.init( + filePurpose: capturePageConfig.filePurpose, + highResImageCompressionQuality: capturePageConfig.highResImageCompressionQuality, + highResImageCropPadding: capturePageConfig.highResImageCropPadding, + highResImageMaxDimension: capturePageConfig.highResImageMaxDimension, + lowResImageCompressionQuality: capturePageConfig.lowResImageCompressionQuality, + lowResImageMaxDimension: capturePageConfig.lowResImageMaxDimension + ) + } +} + +extension StripeAPI.VerificationPageDataDocumentFileData { + init( + documentScannerOutput: DocumentScannerOutput?, + highResImage: String, + lowResImage: String?, + exifMetadata: CameraExifMetadata?, + uploadMethod: FileUploadMethod + ) { + // TODO(mludowise|IDPROD-3269): Encode additional properties from scanner output + let scores = documentScannerOutput?.idDetectorOutput.allClassificationScores + self.init( + backScore: scores?[.idCardBack].map { TwoDecimalFloat($0) }, + brightnessValue: exifMetadata?.brightnessValue.map { TwoDecimalFloat(double: $0) }, + cameraLensModel: exifMetadata?.lensModel, + exposureDuration: documentScannerOutput?.cameraProperties.map { + Int($0.exposureDuration.seconds * 1000) + }, + exposureIso: documentScannerOutput?.cameraProperties.map { + TwoDecimalFloat($0.exposureISO) + }, + focalLength: exifMetadata?.focalLength.map { TwoDecimalFloat(double: $0) }, + frontCardScore: scores?[.idCardFront].map { TwoDecimalFloat($0) }, + highResImage: highResImage, + invalidScore: scores?[.invalid].map { TwoDecimalFloat($0) }, + iosBarcodeDecoded: documentScannerOutput?.barcode?.hasBarcode, + iosBarcodeSymbology: documentScannerOutput?.barcode?.symbology.stringValue, + iosTimeToFindBarcode: documentScannerOutput?.barcode.map { + Int($0.timeTryingToFindBarcode * 1000) + }, + isVirtualCamera: documentScannerOutput?.cameraProperties?.isVirtualDevice, + lowResImage: lowResImage, + passportScore: scores?[.passport].map { TwoDecimalFloat($0) }, + uploadMethod: uploadMethod + ) + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/FaceScanner+API.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/FaceScanner+API.swift new file mode 100644 index 00000000..e81937cd --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/FaceScanner+API.swift @@ -0,0 +1,28 @@ +// +// FaceScanner+API.swift +// StripeIdentity +// +// Created by Mel Ludowise on 6/2/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +extension FaceScanner.Configuration { + init( + from selfiePageConfig: StripeAPI.VerificationPageStaticContentSelfiePage + ) { + self.init( + faceDetectorMinScore: selfiePageConfig.models.faceDetectorMinScore.floatValue, + faceDetectorMinIOU: selfiePageConfig.models.faceDetectorMinIou.floatValue, + maxCenteredThreshold: .init( + x: selfiePageConfig.maxCenteredThresholdX, + y: selfiePageConfig.maxCenteredThresholdY + ), + minEdgeThreshold: selfiePageConfig.minEdgeThreshold, + minCoverageThreshold: selfiePageConfig.minCoverageThreshold, + maxCoverageThreshold: selfiePageConfig.maxCoverageThreshold + ) + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/IdentityAPIClient.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/IdentityAPIClient.swift new file mode 100644 index 00000000..434deb07 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/IdentityAPIClient.swift @@ -0,0 +1,175 @@ +// +// IdentityAPIClient.swift +// StripeIdentity +// +// Created by Mel Ludowise on 10/26/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +import UIKit + +protocol IdentityAPIClient: AnyObject { + var verificationSessionId: String { get } + var apiVersion: Int { get set } + + func getIdentityVerificationPage() -> Promise + + func updateIdentityVerificationPageData( + updating verificationData: StripeAPI.VerificationPageDataUpdate + ) -> Promise + + func submitIdentityVerificationPage() -> Promise + + func uploadImage( + _ image: UIImage, + compressionQuality: CGFloat, + purpose: String, + fileName: String + ) -> Future + + func verifyTestVerificationSession( + simulateDelay: Bool + ) -> Promise + + func unverifyTestVerificationSession( + simulateDelay: Bool + ) -> Promise + + func generatePhoneOtp() -> Promise + + func cannotPhoneVerifyOtp() -> Promise +} + +final class IdentityAPIClientImpl: IdentityAPIClient { + /// The latest production-ready version of the VerificationPages API that the + /// SDK is capable of using. + /// + /// - Note: Update this value when a new API version is ready for use in production. + static let productionApiVersion: Int = 4 + + var betas: Set { + return ["identity_client_api=v\(apiVersion)"] + } + + let apiClient: STPAPIClient + let verificationSessionId: String + + /// The VerificationPages API version used to make all API requests. + /// + /// - Note: This should only be modified when testing endpoints not yet in production. + var apiVersion = IdentityAPIClientImpl.productionApiVersion { + didSet { + apiClient.betas = betas + } + } + + private init( + verificationSessionId: String, + apiClient: STPAPIClient + ) { + self.verificationSessionId = verificationSessionId + self.apiClient = apiClient + } + + convenience init( + verificationSessionId: String, + ephemeralKeySecret: String + ) { + self.init( + verificationSessionId: verificationSessionId, + apiClient: STPAPIClient(publishableKey: ephemeralKeySecret) + ) + apiClient.betas = betas + apiClient.appInfo = STPAPIClient.shared.appInfo + } + + func getIdentityVerificationPage() -> Promise { + return apiClient.get( + resource: APIEndpointVerificationPage(id: verificationSessionId), + parameters: [:] + ) + } + + func updateIdentityVerificationPageData( + updating verificationData: StripeAPI.VerificationPageDataUpdate + ) -> Promise { + return apiClient.post( + resource: APIEndpointVerificationPageData(id: verificationSessionId), + object: verificationData + ) + } + + func submitIdentityVerificationPage() -> Promise { + return apiClient.post( + resource: APIEndpointVerificationPageSubmit(id: verificationSessionId), + parameters: [:] + ) + } + + func uploadImage( + _ image: UIImage, + compressionQuality: CGFloat, + purpose: String, + fileName: String + ) -> Future { + return apiClient.uploadImageAndGetMetrics( + image, + compressionQuality: compressionQuality, + purpose: purpose, + fileName: fileName, + ownedBy: verificationSessionId + ) + } + + func verifyTestVerificationSession(simulateDelay: Bool) -> Promise { + return apiClient.post( + resource: APIEndpointVerificationPageTestingVerify(id: verificationSessionId), + parameters: ["simulate_delay": simulateDelay] + ) + } + + func unverifyTestVerificationSession(simulateDelay: Bool) -> Promise { + return apiClient.post( + resource: APIEndpointVerificationPageTestingUnverify(id: verificationSessionId), + parameters: ["simulate_delay": simulateDelay] + ) + } + + func generatePhoneOtp() -> StripeCore.Promise { + return apiClient.post( + resource: APIEndpointVerificationPagePhoneOtpGenerate(id: verificationSessionId), + parameters: [:] + ) + } + + func cannotPhoneVerifyOtp() -> StripeCore.Promise { + return apiClient.post( + resource: APIEndpointVerificationPagePhoneOtpCannotVerify(id: verificationSessionId), + parameters: [:] + ) + } +} + +private func APIEndpointVerificationPage(id: String) -> String { + return "identity/verification_pages/\(id)" +} +private func APIEndpointVerificationPageData(id: String) -> String { + return "identity/verification_pages/\(id)/data" +} +private func APIEndpointVerificationPageSubmit(id: String) -> String { + return "identity/verification_pages/\(id)/submit" +} +private func APIEndpointVerificationPageTestingVerify(id: String) -> String { + return "identity/verification_pages/\(id)/testing/verify" +} +private func APIEndpointVerificationPageTestingUnverify(id: String) -> String { + return "identity/verification_pages/\(id)/testing/unverify" +} +private func APIEndpointVerificationPagePhoneOtpGenerate(id: String) -> String { + return "identity/verification_pages/\(id)/phone_otp/generate" +} +private func APIEndpointVerificationPagePhoneOtpCannotVerify(id: String) -> String { + return "identity/verification_pages/\(id)/phone_otp/cannot_verify" +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/DocumentType.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/DocumentType.swift new file mode 100644 index 00000000..13162e7e --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/DocumentType.swift @@ -0,0 +1,16 @@ +// +// DocumentType.swift +// StripeIdentity +// +// Created by Mel Ludowise on 2/18/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// +import Foundation + +enum DocumentType: String, Encodable, CaseIterable, Equatable { + // NOTE: The declaration order determines the default order these + // are displayed in the UI on the document selection screen + case drivingLicense = "driving_license" + case idCard = "id_card" + case passport +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/TruncatedDecimal.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/TruncatedDecimal.swift new file mode 100644 index 00000000..5e7661d3 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/TruncatedDecimal.swift @@ -0,0 +1,61 @@ +// +// TruncatedDecimal.swift +// StripeIdentity +// +// Created by Mel Ludowise on 2/9/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// Truncates the decimal number to a specific number of decimal places when +/// encoding it. +protocol TruncatedDecimal: Codable, Equatable { + /// The value type this decimal is wrapping (e.g. Float, Double, CGFloat) + associatedtype ValueType: (FloatingPoint & CVarArg & Codable & LosslessStringConvertible) + + /// The number of decimal digits that should be encoded + static var numberOfDecimalDigits: UInt { get } + + /// The wrapped value + var value: ValueType { get } + + init(_ value: ValueType) +} + +// MARK: - Codable + +extension TruncatedDecimal { + init( + from decoder: Decoder + ) throws { + self.init(try ValueType(from: decoder)) + } + + func encode(to encoder: Encoder) throws { + // Because STPAPIClient always encodes as form data, we can use a + // string-encoding to format the number to the correct decimal places + let string = String(format: "%.\(Self.numberOfDecimalDigits)f", value) + try string.encode(to: encoder) + } +} + +// MARK: - TwoDecimalFloat +/// Truncates a float to 2 decimal places when encoding it +struct TwoDecimalFloat: TruncatedDecimal { + static let numberOfDecimalDigits: UInt = 2 + + let value: Float + + init( + _ value: Float + ) { + self.value = value + } + + init( + double: Double + ) { + self.init(Float(double)) + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPage.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPage.swift new file mode 100644 index 00000000..58b75206 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPage.swift @@ -0,0 +1,53 @@ +// +// VerificationPage.swift +// +// Generated by swagger-codegen +// https://github.com/swagger-api/swagger-codegen +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + + /// A VerificationPage contains the static content and initial state that is required for Stripe Identity's native mobile SDKs to render the verification flow. + + struct VerificationPage: Decodable, Equatable { + enum Status: String, Codable, Equatable { + case canceled = "canceled" + case processing = "processing" + case requiresInput = "requires_input" + case verified = "verified" + } + let biometricConsent: VerificationPageStaticContentConsentPage + let documentCapture: VerificationPageStaticContentDocumentCapturePage + let documentSelect: VerificationPageStaticContentDocumentSelectPage + let individual: VerificationPageStaticContentIndividualPage + let countryNotListed: VerificationPageStaticContentCountryNotListedPage + let individualWelcome: VerificationPageStaticContentIndividualWelcomePage + let phoneOtp: VerificationPageStaticContentPhoneOtpPage? + /// The short-lived URL that can be used in the case that the client cannot support the VerificationSession. + let fallbackUrl: String + /// Unique identifier for the object. + let id: String + /// Has the value `true` if the object exists in live mode or the value `false` if the object exists in test mode. + let livemode: Bool + let requirements: VerificationPageRequirements + /// Static content for the selfie page + let selfie: VerificationPageStaticContentSelfiePage? + /// Status of the associated VerificationSession. + let status: Status + /// If true, the associated VerificationSession has been submitted for processing. + let submitted: Bool + let success: VerificationPageStaticContentTextPage + /// If true, the client cannot support the VerificationSession. + let unsupportedClient: Bool + } + +} + +extension StripeAPI.VerificationPage { + func copyWithNewMissings(newMissings: Set) -> StripeAPI.VerificationPage { + return StripeAPI.VerificationPage(biometricConsent: self.biometricConsent, documentCapture: self.documentCapture, documentSelect: self.documentSelect, individual: self.individual, countryNotListed: self.countryNotListed, individualWelcome: self.individualWelcome, phoneOtp: self.phoneOtp, fallbackUrl: self.fallbackUrl, id: self.id, livemode: self.livemode, requirements: StripeAPI.VerificationPageRequirements(missing: newMissings), selfie: self.selfie, status: self.status, submitted: self.submitted, success: self.success, unsupportedClient: self.unsupportedClient) + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageFieldType.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageFieldType.swift new file mode 100644 index 00000000..6bc2771f --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageFieldType.swift @@ -0,0 +1,25 @@ +// +// VerificationPageFieldType.swift +// StripeIdentity +// +// Created by Mel Ludowise on 2/26/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +extension StripeAPI { + enum VerificationPageFieldType: String, Codable, Equatable, CaseIterable { + case biometricConsent = "biometric_consent" + case face = "face" + case idDocumentBack = "id_document_back" + case idDocumentFront = "id_document_front" + case idDocumentType = "id_document_type" + case idNumber = "id_number" + case dob = "dob" + case name = "name" + case address = "address" + case phoneNumber = "phone_number" + case phoneOtp = "phone_otp" + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageRequirements.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageRequirements.swift new file mode 100644 index 00000000..3a866e7e --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageRequirements.swift @@ -0,0 +1,17 @@ +// +// VerificationPageRequirements.swift +// +// Generated by swagger-codegen +// https://github.com/swagger-api/swagger-codegen +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + + struct VerificationPageRequirements: Decodable, Equatable { + let missing: Set + } + +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentConsentPage.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentConsentPage.swift new file mode 100644 index 00000000..44a00ba9 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentConsentPage.swift @@ -0,0 +1,23 @@ +// +// VerificationPageStaticContentConsentPage.swift +// +// Generated by swagger-codegen +// https://github.com/swagger-api/swagger-codegen +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + + struct VerificationPageStaticContentConsentPage: Decodable, Equatable { + let acceptButtonText: String + let body: String + let declineButtonText: String + let privacyPolicy: String? + let timeEstimate: String? + let title: String? + let scrollToContinueButtonText: String + } + +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentCountryNotListedPage.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentCountryNotListedPage.swift new file mode 100644 index 00000000..44cb799d --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentCountryNotListedPage.swift @@ -0,0 +1,19 @@ +// +// VerificationPageStaticContentCountryNotListedPage.swift +// StripeIdentity +// +// Created by Chen Cen on 2/1/23. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + struct VerificationPageStaticContentCountryNotListedPage: Decodable, Equatable { + let title: String + let body: String + let cancelButtonText: String + let idFromOtherCountryTextButtonText: String + let addressFromOtherCountryTextButtonText: String + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentDocumentCaptureModels.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentDocumentCaptureModels.swift new file mode 100644 index 00000000..0464ac17 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentDocumentCaptureModels.swift @@ -0,0 +1,19 @@ +// +// VerificationPageStaticContentDocumentCaptureModels.swift +// +// Generated by swagger-codegen +// https://github.com/swagger-api/swagger-codegen +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + + struct VerificationPageStaticContentDocumentCaptureModels: Decodable, Equatable { + let idDetectorMinIou: Decimal + let idDetectorMinScore: Decimal + let idDetectorUrl: String + } + +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentDocumentCapturePage.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentDocumentCapturePage.swift new file mode 100644 index 00000000..7c713f12 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentDocumentCapturePage.swift @@ -0,0 +1,31 @@ +// +// VerificationPageStaticContentDocumentCapturePage.swift +// +// Generated by swagger-codegen +// https://github.com/swagger-api/swagger-codegen +// + +import CoreGraphics +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + + struct VerificationPageStaticContentDocumentCapturePage: Decodable, Equatable { + let autocaptureTimeout: Int + let filePurpose: String + let highResImageCompressionQuality: CGFloat + let highResImageCropPadding: CGFloat + let highResImageMaxDimension: Int + let iosIdCardBackBarcodeTimeout: Int + let iosIdCardBackCountryBarcodeSymbologies: [String: String] + let lowResImageCompressionQuality: CGFloat + let lowResImageMaxDimension: Int + let models: VerificationPageStaticContentDocumentCaptureModels + let motionBlurMinDuration: Int + let motionBlurMinIou: Decimal + let requireLiveCapture: Bool + let blurThreshold: Decimal? + } + +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentDocumentSelectPage.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentDocumentSelectPage.swift new file mode 100644 index 00000000..a9b81a5f --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentDocumentSelectPage.swift @@ -0,0 +1,20 @@ +// +// VerificationPageStaticContentDocumentSelectPage.swift +// +// Generated by swagger-codegen +// https://github.com/swagger-api/swagger-codegen +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + + struct VerificationPageStaticContentDocumentSelectPage: Decodable, Equatable { + let body: String? + let buttonText: String + let idDocumentTypeAllowlist: [String: String] + let title: String + } + +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentIndividualPage.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentIndividualPage.swift new file mode 100644 index 00000000..c54e5f8a --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentIndividualPage.swift @@ -0,0 +1,22 @@ +// +// VerificationPageStaticContentIndividualPage.swift +// StripeIdentity +// +// Created by Chen Cen on 1/27/23. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + + struct VerificationPageStaticContentIndividualPage: Decodable, Equatable { + let addressCountries: [String: String] + let buttonText: String + let title: String + let idNumberCountries: [String: String] + let idNumberCountryNotListedTextButtonText: String + let addressCountryNotListedTextButtonText: String + let phoneNumberCountries: [String: String] + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentIndividualWelcomePage.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentIndividualWelcomePage.swift new file mode 100644 index 00000000..02b50813 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentIndividualWelcomePage.swift @@ -0,0 +1,22 @@ +// +// VerificationPageStaticContentIndividualWelcomePage.swift +// StripeIdentity +// +// Created by Chen Cen on 2/14/23. +// + +import Foundation + +@_spi(STP) import StripeCore + +extension StripeAPI { + + struct VerificationPageStaticContentIndividualWelcomePage: Decodable, Equatable { + let getStartedButtonText: String + let body: String + let privacyPolicy: String + let timeEstimate: String + let title: String + } + +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentPhoneOtpPage.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentPhoneOtpPage.swift new file mode 100644 index 00000000..6cf41677 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentPhoneOtpPage.swift @@ -0,0 +1,23 @@ +// +// VerificationPageStaticContentPhoneOtpPage.swift +// StripeIdentity +// +// Created by Chen Cen on 6/14/23. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + + struct VerificationPageStaticContentPhoneOtpPage: Decodable, Equatable { + let title: String + let body: String + let redactedPhoneNumber: String? + let errorOtpMessage: String + let resendButtonText: String + let cannotVerifyButtonText: String + let otpLength: Int + } + +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentSelfieModels.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentSelfieModels.swift new file mode 100644 index 00000000..de6138f3 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentSelfieModels.swift @@ -0,0 +1,19 @@ +// +// VerificationPageStaticContentSelfieModels.swift +// +// Generated by swagger-codegen +// https://github.com/swagger-api/swagger-codegen +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + + struct VerificationPageStaticContentSelfieModels: Decodable, Equatable { + let faceDetectorMinIou: Decimal + let faceDetectorMinScore: Decimal + let faceDetectorUrl: String + } + +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentSelfiePage.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentSelfiePage.swift new file mode 100644 index 00000000..68f4e61b --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentSelfiePage.swift @@ -0,0 +1,34 @@ +// +// VerificationPageStaticContentSelfiePage.swift +// +// Generated by swagger-codegen +// https://github.com/swagger-api/swagger-codegen +// + +import CoreGraphics +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + + struct VerificationPageStaticContentSelfiePage: Decodable, Equatable { + let autocaptureTimeout: Int + let filePurpose: String + let highResImageCompressionQuality: CGFloat + let highResImageCropPadding: CGFloat + let highResImageMaxDimension: Int + let lowResImageCompressionQuality: CGFloat + let lowResImageMaxDimension: Int + let maxCenteredThresholdX: CGFloat + let maxCenteredThresholdY: CGFloat + let maxCoverageThreshold: CGFloat + let minCoverageThreshold: CGFloat + let minEdgeThreshold: CGFloat + let models: VerificationPageStaticContentSelfieModels + let numSamples: Int + let sampleInterval: Int + let trainingConsentText: String + let blurThreshold: Decimal? + } + +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentTextPage.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentTextPage.swift new file mode 100644 index 00000000..ce7ee0a7 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPage/VerificationPageStaticContentTextPage.swift @@ -0,0 +1,19 @@ +// +// VerificationPageStaticContentTextPage.swift +// +// Generated by swagger-codegen +// https://github.com/swagger-api/swagger-codegen +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + + struct VerificationPageStaticContentTextPage: Decodable, Equatable { + let body: String + let buttonText: String + let title: String + } + +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageData/VerificationPageData.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageData/VerificationPageData.swift new file mode 100644 index 00000000..942a23e6 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageData/VerificationPageData.swift @@ -0,0 +1,42 @@ +// +// VerificationPageData.swift +// +// Generated by swagger-codegen +// https://github.com/swagger-api/swagger-codegen +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + + /// VerificationPageData contains the state of a verification, including what information needs to be collected to complete the verification flow. + + struct VerificationPageData: Decodable, Equatable { + typealias Status = VerificationPage.Status + + /// Unique identifier for the object. + let id: String + let requirements: VerificationPageDataRequirements + /// Status of the associated VerificationSession. + let status: Status + /// If true, the associated VerificationSession has been submitted for processing. + let submitted: Bool + + /// If true, the associated VerificationSession has been closed and can no longer be modified. + /// After submitting, closed might be false if needs to fallback from phone verification to document verification. + let closed: Bool + } + +} + +extension StripeAPI.VerificationPageData { + /// When submitted but is not closed and there is still missing requirements, need to fallback. + func needsFallback() -> Bool { + return submitted && !closed && !requirements.missing.isEmpty + } + + func submittedAndClosed() -> Bool { + return submitted && closed + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageData/VerificationPageDataRequirementError.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageData/VerificationPageDataRequirementError.swift new file mode 100644 index 00000000..85555bd8 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageData/VerificationPageDataRequirementError.swift @@ -0,0 +1,21 @@ +// +// VerificationPageDataRequirementError.swift +// +// Generated by swagger-codegen +// https://github.com/swagger-api/swagger-codegen +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + + struct VerificationPageDataRequirementError: Decodable, Equatable { + let backButtonText: String? + let body: String + let continueButtonText: String? + let requirement: VerificationPageFieldType + let title: String + } + +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageData/VerificationPageDataRequirements.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageData/VerificationPageDataRequirements.swift new file mode 100644 index 00000000..4b15ccc2 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageData/VerificationPageDataRequirements.swift @@ -0,0 +1,18 @@ +// +// VerificationPageDataRequirements.swift +// +// Generated by swagger-codegen +// https://github.com/swagger-api/swagger-codegen +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + + struct VerificationPageDataRequirements: Decodable, Equatable { + let errors: [VerificationPageDataRequirementError] + let missing: Set + } + +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/RequiredInternationalAddress.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/RequiredInternationalAddress.swift new file mode 100644 index 00000000..72d5d28f --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/RequiredInternationalAddress.swift @@ -0,0 +1,20 @@ +// +// RequiredInternationalAddress.swift +// StripeIdentity +// +// Created by Chen Cen on 1/27/23. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + struct RequiredInternationalAddress: Encodable, Equatable { + let line1: String + let line2: String? + let city: String? + let postalCode: String? + let state: String? + let country: String? + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageClearData.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageClearData.swift new file mode 100644 index 00000000..56bb9ae5 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageClearData.swift @@ -0,0 +1,44 @@ +// +// VerificationPageClearData.swift +// StripeIdentity +// +// Created by Mel Ludowise on 3/2/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + struct VerificationPageClearData: Encodable, Equatable { + let biometricConsent: Bool? + let face: Bool? + let idDocumentBack: Bool? + let idDocumentFront: Bool? + let idDocumentType: Bool? + let idNumber: Bool? + let dob: Bool? + let name: Bool? + let address: Bool? + let phoneOtp: Bool? + } +} + +extension StripeAPI.VerificationPageClearData { + init( + clearFields fields: Set + ) { + self.init( + biometricConsent: fields.contains(.biometricConsent), + face: fields.contains(.face), + idDocumentBack: fields.contains(.idDocumentBack), + idDocumentFront: fields.contains(.idDocumentFront), + idDocumentType: fields.contains(.idDocumentType), + idNumber: fields.contains(.idNumber), + dob: fields.contains(.dob), + name: fields.contains(.name), + address: fields.contains(.address), + phoneOtp: fields.contains(.phoneOtp) + ) + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageCollectedData.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageCollectedData.swift new file mode 100644 index 00000000..b9704e77 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageCollectedData.swift @@ -0,0 +1,159 @@ +// +// VerificationPageCollectedData.swift +// StripeIdentity +// +// Created by Mel Ludowise on 2/26/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + struct VerificationPageCollectedData: Encodable, Equatable { + + private(set) var biometricConsent: Bool? + private(set) var face: VerificationPageDataFace? + private(set) var idDocumentBack: VerificationPageDataDocumentFileData? + private(set) var idDocumentFront: VerificationPageDataDocumentFileData? + private(set) var idDocumentType: DocumentType? + private(set) var idNumber: VerificationPageDataIdNumber? + private(set) var dob: VerificationPageDataDob? + private(set) var name: VerificationPageDataName? + private(set) var address: RequiredInternationalAddress? + private(set) var phone: VerificationPageDataPhone? + private(set) var phoneOtp: String? + + init( + biometricConsent: Bool? = nil, + face: VerificationPageDataFace? = nil, + idDocumentBack: VerificationPageDataDocumentFileData? = nil, + idDocumentFront: VerificationPageDataDocumentFileData? = nil, + idDocumentType: DocumentType? = nil, + idNumber: VerificationPageDataIdNumber? = nil, + dob: VerificationPageDataDob? = nil, + name: VerificationPageDataName? = nil, + address: RequiredInternationalAddress? = nil, + phone: VerificationPageDataPhone? = nil, + phoneOtp: String? = nil + ) { + self.biometricConsent = biometricConsent + self.face = face + self.idDocumentBack = idDocumentBack + self.idDocumentFront = idDocumentFront + self.idDocumentType = idDocumentType + self.idNumber = idNumber + self.dob = dob + self.name = name + self.address = address + self.phone = phone + self.phoneOtp = phoneOtp + } + } +} + +/// All mutating functions needs to pass all values explicitly to the new object, as the default value would be nil. +extension StripeAPI.VerificationPageCollectedData { + /// Returns a new `VerificationPageCollectedData`, merging the data from this + /// one with the provided one. + func merging( + _ otherData: StripeAPI.VerificationPageCollectedData + ) -> StripeAPI.VerificationPageCollectedData { + return StripeAPI.VerificationPageCollectedData( + biometricConsent: otherData.biometricConsent ?? self.biometricConsent, + face: otherData.face ?? self.face, + idDocumentBack: otherData.idDocumentBack ?? self.idDocumentBack, + idDocumentFront: otherData.idDocumentFront ?? self.idDocumentFront, + idDocumentType: otherData.idDocumentType ?? self.idDocumentType, + idNumber: otherData.idNumber ?? self.idNumber, + dob: otherData.dob ?? self.dob, + name: otherData.name ?? self.name, + address: otherData.address ?? self.address, + phone: otherData.phone ?? self.phone, + phoneOtp: otherData.phoneOtp ?? self.phoneOtp + ) + } + + /// Merges the data from the provided `VerificationPageCollectedData` into this one. + mutating func merge(_ otherData: StripeAPI.VerificationPageCollectedData) { + self = self.merging(otherData) + } + + mutating func clearData(field: StripeAPI.VerificationPageFieldType) { + switch field { + case .biometricConsent: + self.biometricConsent = nil + case .face: + self.face = nil + case .idDocumentBack: + self.idDocumentBack = nil + case .idDocumentFront: + self.idDocumentFront = nil + case .idDocumentType: + self.idDocumentType = nil + case .idNumber: + self.idNumber = nil + case .dob: + self.dob = nil + case .name: + self.name = nil + case .address: + self.address = nil + case .phoneNumber: + self.phone = nil + case .phoneOtp: + self.phoneOtp = nil + } + } + + /// Helper to determine the front document score for analytics purposes + var frontDocumentScore: TwoDecimalFloat? { + switch idDocumentType { + case .drivingLicense, + .idCard: + return idDocumentFront?.frontCardScore + case .passport: + return idDocumentFront?.passportScore + case .none: + return nil + } + } + + var collectedTypes: Set { + var ret = Set() + if self.biometricConsent != nil { + ret.insert(.biometricConsent) + } + if self.face != nil { + ret.insert(.face) + } + if self.idDocumentBack != nil { + ret.insert(.idDocumentBack) + } + if self.idDocumentFront != nil { + ret.insert(.idDocumentFront) + } + if self.idDocumentType != nil { + ret.insert(.idDocumentType) + } + if self.idNumber != nil { + ret.insert(.idNumber) + } + if self.dob != nil { + ret.insert(.dob) + } + if self.name != nil { + ret.insert(.name) + } + if self.address != nil { + ret.insert(.address) + } + if self.phone != nil { + ret.insert(.phoneNumber) + } + if self.phoneOtp != nil { + ret.insert(.phoneOtp) + } + return ret + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataDob.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataDob.swift new file mode 100644 index 00000000..35a738b3 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataDob.swift @@ -0,0 +1,17 @@ +// +// VerificationPageDataDob.swift +// StripeIdentity +// +// Created by Chen Cen on 1/27/23. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + struct VerificationPageDataDob: Encodable, Equatable { + let day: String? + let month: String? + let year: String? + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataDocumentFileData.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataDocumentFileData.swift new file mode 100644 index 00000000..b17c29a0 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataDocumentFileData.swift @@ -0,0 +1,47 @@ +// +// VerificationPageDataDocumentFileData.swift +// StripeIdentity +// +// Created by Mel Ludowise on 12/8/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + struct VerificationPageDataDocumentFileData: Encodable, Equatable { + + enum FileUploadMethod: String, Encodable, Equatable { + /// Document image was auto-captured from the camera feed using ML models + case autoCapture = "auto_capture" + /// Document was uploaded from the file system + case fileUpload = "file_upload" + /// Document image was captured from the camera feed manually + case manualCapture = "manual_capture" + } + + /// If auto-captured, probability score of 'back' result from ML model. + let backScore: TwoDecimalFloat? + let brightnessValue: TwoDecimalFloat? + let cameraLensModel: String? + let exposureDuration: Int? + let exposureIso: TwoDecimalFloat? + let focalLength: TwoDecimalFloat? + /// If auto-captured, probability score of 'front_id' result from ML model. + let frontCardScore: TwoDecimalFloat? + /// File ID of uploaded image. If user auto-captured, this will be cropped to the bounds of the document. + let highResImage: String + /// If auto-captured, probability score of 'invalid' result from ML model. + let invalidScore: TwoDecimalFloat? + let iosBarcodeDecoded: Bool? + let iosBarcodeSymbology: String? + let iosTimeToFindBarcode: Int? + let isVirtualCamera: Bool? + /// If auto-captured, file ID of uploaded un-cropped image. + let lowResImage: String? + /// If auto-captured, probability score of 'passport' result from ML model. + let passportScore: TwoDecimalFloat? + /// Method of getting the document image + let uploadMethod: FileUploadMethod + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataFace.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataFace.swift new file mode 100644 index 00000000..c0ea8e54 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataFace.swift @@ -0,0 +1,48 @@ +// +// VerificationPageDataFace.swift +// StripeIdentity +// +// Created by Mel Ludowise on 6/10/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + struct VerificationPageDataFace: Encodable, Equatable { + + /// File ID of uploaded image for best selfie frame. This will be cropped to the bounds of the face in the image. + let bestHighResImage: String + /// File ID of uploaded image for best selfie frame. This will be un-cropped. + let bestLowResImage: String + /// File ID of uploaded image for first selfie frame. This will be cropped to the bounds of the face in the image. + let firstHighResImage: String + /// File ID of uploaded image for first selfie frame. This will be un-cropped. + let firstLowResImage: String + /// File ID of uploaded image for last selfie frame. This will be cropped to the bounds of the face in the image. + let lastHighResImage: String + /// File ID of uploaded image for last selfie frame. This will be un-cropped. + let lastLowResImage: String + /// FaceDetector score for the best selfie frame. + let bestFaceScore: TwoDecimalFloat + /// Variance of the FaceDetector scores over all selfie frames. + let faceScoreVariance: TwoDecimalFloat + /// The total number of selfie frames taken. + let numFrames: Int + /// Camera brightness value for the best selfie frame. + let bestBrightnessValue: TwoDecimalFloat? + /// Camera lens model for the best selfie frame. + let bestCameraLensModel: String? + /// Camera exposure duration for the best selfie frame. + let bestExposureDuration: Int? + /// Camera exposure ISO for the best selfie frame + let bestExposureIso: TwoDecimalFloat? + /// Camera focal length for the best selfie frame. + let bestFocalLength: TwoDecimalFloat? + /// If the best selfie frame was taken by a virtual camera. + let bestIsVirtualCamera: Bool? + /// Whether the user consents for their selfie to be used for training purposes + let trainingConsent: Bool + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataIdNumber.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataIdNumber.swift new file mode 100644 index 00000000..294e8601 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataIdNumber.swift @@ -0,0 +1,17 @@ +// +// VerificationPageDataIdNumber.swift +// StripeIdentity +// +// Created by Chen Cen on 1/27/23. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + struct VerificationPageDataIdNumber: Encodable, Equatable { + let country: String? + let partialValue: String? + let value: String? + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataName.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataName.swift new file mode 100644 index 00000000..2901644e --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataName.swift @@ -0,0 +1,16 @@ +// +// VerificationPageDataName.swift +// StripeIdentity +// +// Created by Chen Cen on 1/27/23. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + struct VerificationPageDataName: Encodable, Equatable { + let firstName: String? + let lastName: String? + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataPhone.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataPhone.swift new file mode 100644 index 00000000..eea738c2 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataPhone.swift @@ -0,0 +1,16 @@ +// +// VerificationPageDataPhone.swift +// StripeIdentity +// +// Created by Chen Cen on 6/12/23. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + struct VerificationPageDataPhone: Encodable, Equatable { + let country: String? + let phoneNumber: String? + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataUpdate.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataUpdate.swift new file mode 100644 index 00000000..9a0a608e --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/Models/VerificationPageDataUpdate/VerificationPageDataUpdate.swift @@ -0,0 +1,18 @@ +// +// VerificationPageDataUpdate.swift +// StripeIdentity +// +// Created by Mel Ludowise on 11/2/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +extension StripeAPI { + struct VerificationPageDataUpdate: Encodable, Equatable { + + let clearData: VerificationPageClearData? + let collectedData: VerificationPageCollectedData? + } +} diff --git a/StripeIdentity/StripeIdentity/Source/API Bindings/SelfieUploader+API.swift b/StripeIdentity/StripeIdentity/Source/API Bindings/SelfieUploader+API.swift new file mode 100644 index 00000000..0e27e052 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/API Bindings/SelfieUploader+API.swift @@ -0,0 +1,64 @@ +// +// SelfieUploader+API.swift +// StripeIdentity +// +// Created by Mel Ludowise on 6/2/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCameraCore +@_spi(STP) import StripeCore +import UIKit + +extension IdentityImageUploader.Configuration { + init( + from selfiePageConfig: StripeAPI.VerificationPageStaticContentSelfiePage + ) { + self.init( + filePurpose: selfiePageConfig.filePurpose, + highResImageCompressionQuality: selfiePageConfig.highResImageCompressionQuality, + highResImageCropPadding: selfiePageConfig.highResImageCropPadding, + highResImageMaxDimension: selfiePageConfig.highResImageMaxDimension, + lowResImageCompressionQuality: selfiePageConfig.lowResImageCompressionQuality, + lowResImageMaxDimension: selfiePageConfig.lowResImageMaxDimension + ) + } +} + +extension StripeAPI.VerificationPageDataFace { + init( + uploadedFiles: SelfieUploader.FileData, + capturedImages: FaceCaptureData, + bestFrameExifMetadata: CameraExifMetadata?, + trainingConsent: Bool + ) { + self.init( + bestHighResImage: uploadedFiles.bestHighResFile.id, + bestLowResImage: uploadedFiles.bestLowResFile.id, + firstHighResImage: uploadedFiles.firstHighResFile.id, + firstLowResImage: uploadedFiles.firstLowResFile.id, + lastHighResImage: uploadedFiles.lastHighResFile.id, + lastLowResImage: uploadedFiles.lastLowResFile.id, + bestFaceScore: .init(capturedImages.bestMiddle.scannerOutput.faceScore), + faceScoreVariance: .init(capturedImages.faceScoreVariance), + numFrames: capturedImages.numSamples, + bestBrightnessValue: bestFrameExifMetadata?.brightnessValue.map { + TwoDecimalFloat(double: $0) + }, + bestCameraLensModel: bestFrameExifMetadata?.lensModel, + bestExposureDuration: capturedImages.bestMiddle.scannerOutput.cameraProperties.map { + Int($0.exposureDuration.seconds * 1000) + }, + bestExposureIso: capturedImages.bestMiddle.scannerOutput.cameraProperties.map { + TwoDecimalFloat($0.exposureISO) + }, + bestFocalLength: bestFrameExifMetadata?.focalLength.map { + TwoDecimalFloat(double: $0) + }, + bestIsVirtualCamera: capturedImages.bestMiddle.scannerOutput.cameraProperties? + .isVirtualDevice, + trainingConsent: trainingConsent + ) + } +} diff --git a/StripeIdentity/StripeIdentity/Source/Analytics/IdentityAnalyticsClient.swift b/StripeIdentity/StripeIdentity/Source/Analytics/IdentityAnalyticsClient.swift new file mode 100644 index 00000000..074b5c96 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/Analytics/IdentityAnalyticsClient.swift @@ -0,0 +1,505 @@ +// +// IdentityAnalyticsClient.swift +// StripeIdentity +// +// Created by Mel Ludowise on 6/7/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +import UIKit + +enum IdentityAnalyticsClientError: AnalyticLoggableError { + /// `startTrackingTimeToScreen` was called twice in a row without calling + /// `stopTrackingTimeToScreenAndLogIfNeeded` + case timeToScreenAlreadyStarted( + alreadyStartedForScreen: IdentityAnalyticsClient.ScreenName?, + requestedForScreen: IdentityAnalyticsClient.ScreenName? + ) + + func analyticLoggableSerializeForLogging() -> [String: Any] { + var payload: [String: Any] = [ + "domain": (self as NSError).domain + ] + switch self { + case .timeToScreenAlreadyStarted(let alreadyStartedForScreen, let requestedForScreen): + payload["type"] = "timeToScreenAlreadyStarted" + if let alreadyStartedForScreen = alreadyStartedForScreen { + payload["previous_tracked_screen"] = alreadyStartedForScreen.rawValue + } + if let requestedForScreen = requestedForScreen { + payload["new_tracked_screen"] = requestedForScreen.rawValue + } + } + return payload + } +} + +/// Wrapper for AnalyticsClient that formats Identity-specific analytics +final class IdentityAnalyticsClient { + + enum EventName: String { + // MARK: UI + case sheetPresented = "sheet_presented" + case sheetClosed = "sheet_closed" + case verificationFailed = "verification_failed" + case verificationCanceled = "verification_canceled" + case verificationSucceeded = "verification_succeeded" + case screenAppeared = "screen_presented" + case cameraError = "camera_error" + case cameraPermissionDenied = "camera_permission_denied" + case cameraPermissionGranted = "camera_permission_granted" + case documentCaptureTimeout = "document_timeout" + case selfieCaptureTimeout = "selfie_timeout" + // MARK: Performance + case averageFPS = "average_fps" + case modelPerformance = "model_performance" + case imageUpload = "image_upload" + case timeToScreen = "time_to_screen" + // MARK: Errors + case genericError = "generic_error" + } + + enum ScreenName: String { + case biometricConsent = "consent" + case documentTypeSelect = "document_select" + case documentCapture = "live_capture" + case documentFileUpload = "file_upload" + case selfieCapture = "selfie" + case success = "confirmation" + case individual = "individual" + case phoneOtp = "phone_otp" + case individual_welcome = "individual_welcome" + case error = "error" + case countryNotListed = "country_not_listed" + case debug = "debug" + } + + /// Name of the scanner logged in scanning performance events + enum ScannerName: String { + case document + case selfie + } + + static let sharedAnalyticsClient = AnalyticsClientV2( + clientId: "mobile-identity-sdk", + origin: "stripe-identity-ios" + ) + + let verificationSessionId: String + let analyticsClient: AnalyticsClientV2Protocol + + /// Total number of times the front of the document was attempted to be scanned. + private(set) var numDocumentFrontScanAttempts = 0 + + /// Total number of times the back of the document was attempted to be scanned. + private(set) var numDocumentBackScanAttempts = 0 + + /// Total number of times a selfie was attempted to be scanned. + private(set) var numSelfieScanAttempts = 0 + + /// Tracks the start time for `timeToScreen` analytic + private(set) var timeToScreenStartTime: Date? + /// The last screen transitioned to for `timeToScreen` analytic + private(set) var timeToScreenFromScreen: ScreenName? + + private(set) var blurScoreFront: Float? + private(set) var blurScoreBack: Float? + + init( + verificationSessionId: String, + analyticsClient: AnalyticsClientV2Protocol = IdentityAnalyticsClient.sharedAnalyticsClient + ) { + self.verificationSessionId = verificationSessionId + self.analyticsClient = analyticsClient + } + + // MARK: - UI Events + + /// Increments the number of times a scan was initiated for the specified side of the document + func countDidStartDocumentScan(for side: DocumentSide) { + switch side { + case .front: + numDocumentFrontScanAttempts += 1 + case .back: + numDocumentBackScanAttempts += 1 + } + } + + /// Increments the number of times a scan was initiated for a selfie + func countDidStartSelfieScan() { + numSelfieScanAttempts += 1 + } + + func updateBlurScore(_ blurScore: Float, for side: DocumentSide) { + if side == .front { + blurScoreFront = blurScore + } else { + blurScoreBack = blurScore + } + } + + private func logAnalytic( + _ eventName: EventName, + metadata: [String: Any] + ) { + analyticsClient.log( + eventName: eventName.rawValue, + parameters: [ + "verification_session": verificationSessionId, + "event_metadata": metadata, + ] + ) + } + + /// Logs an event when the verification sheet is presented + func logSheetPresented() { + logAnalytic( + .sheetPresented, + metadata: [:] + ) + } + + /// Logs a closed, failed, or canceled analytic events, depending on the result + func logSheetClosedFailedOrCanceled( + result: IdentityVerificationSheet.VerificationFlowResult, + sheetController: VerificationSheetControllerProtocol, + filePath: StaticString = #filePath, + line: UInt = #line + ) { + switch result { + case .flowCompleted: + logSheetClosed(sessionResult: "flow_complete") + + case .flowCanceled: + logVerificationCanceled( + sheetController: sheetController + ) + logSheetClosed(sessionResult: "flow_canceled") + + case .flowFailed(let error): + logVerificationFailed( + sheetController: sheetController, + error: error, + filePath: filePath, + line: line + ) + } + } + + /// Helper to create metadata common to both failed, canceled, and succeed analytic events + private func failedCanceledSucceededCommonMetadataPayload( + sheetController: VerificationSheetControllerProtocol + ) -> [String: Any] { + var metadata: [String: Any] = [:] + + if let idDocumentType = sheetController.collectedData.idDocumentType { + metadata["scan_type"] = idDocumentType.rawValue + } + if let verificationPage = try? sheetController.verificationPageResponse?.get() { + metadata["require_selfie"] = verificationPage.requirements.missing.contains(.face) + metadata["from_fallback_url"] = verificationPage.unsupportedClient + } + if let frontUploadMethod = sheetController.collectedData.idDocumentFront?.uploadMethod { + metadata["doc_front_upload_type"] = frontUploadMethod.rawValue + } + if let backUploadMethod = sheetController.collectedData.idDocumentBack?.uploadMethod { + metadata["doc_back_upload_type"] = backUploadMethod.rawValue + } + + return metadata + } + + /// Logs an event when the verification sheet is closed + private func logSheetClosed(sessionResult: String) { + logAnalytic( + .sheetClosed, + metadata: [ + "session_result": sessionResult + ] + ) + } + + /// Logs an event when verification sheet fails + private func logVerificationFailed( + sheetController: VerificationSheetControllerProtocol, + error: Error, + filePath: StaticString, + line: UInt + ) { + var metadata = failedCanceledSucceededCommonMetadataPayload( + sheetController: sheetController + ) + metadata["error"] = AnalyticsClientV2.serialize( + error: error, + filePath: filePath, + line: line + ) + + logAnalytic(.verificationFailed, metadata: metadata) + } + + /// Logs an event when verification sheet is canceled + private func logVerificationCanceled( + sheetController: VerificationSheetControllerProtocol + ) { + var metadata = failedCanceledSucceededCommonMetadataPayload( + sheetController: sheetController + ) + if let lastScreen = sheetController.flowController.analyticsLastScreen { + metadata["last_screen_name"] = lastScreen.analyticsScreenName.rawValue + } + + logAnalytic(.verificationCanceled, metadata: metadata) + } + + /// Logs an event when verification sheet succeeds + func logVerificationSucceeded( + sheetController: VerificationSheetControllerProtocol + ) { + var metadata = failedCanceledSucceededCommonMetadataPayload( + sheetController: sheetController + ) + + metadata["doc_front_retry_times"] = max(0, numDocumentFrontScanAttempts - 1) + metadata["doc_back_retry_times"] = max(0, numDocumentBackScanAttempts - 1) + metadata["selfie_retry_times"] = max(0, numSelfieScanAttempts - 1) + + if let frontScore = sheetController.collectedData.frontDocumentScore { + metadata["doc_front_model_score"] = frontScore.value + } + if let backScore = sheetController.collectedData.idDocumentBack?.backScore { + metadata["doc_back_model_score"] = backScore.value + } + if let bestFaceScore = sheetController.collectedData.face?.bestFaceScore { + metadata["selfie_model_score"] = bestFaceScore.value + } + if let blurScoreFront = blurScoreFront { + metadata["doc_front_blur_score"] = blurScoreFront + } + if let blurScoreBack = blurScoreBack { + metadata["doc_back_blur_score"] = blurScoreBack + } + + logAnalytic(.verificationSucceeded, metadata: metadata) + } + + /// Logs an event when a screen is presented + func logScreenAppeared( + screenName: ScreenName, + sheetController: VerificationSheetControllerProtocol + ) { + var metadata: [String: Any] = [ + "screen_name": screenName.rawValue + ] + if let idDocumentType = sheetController.collectedData.idDocumentType { + metadata["scan_type"] = idDocumentType.rawValue + } + logAnalytic(.screenAppeared, metadata: metadata) + } + + /// Logs an event when a camera error occurs + func logCameraError( + sheetController: VerificationSheetControllerProtocol, + error: Error, + filePath: StaticString = #filePath, + line: UInt = #line + ) { + var metadata: [String: Any] = [:] + if let idDocumentType = sheetController.collectedData.idDocumentType { + metadata["scan_type"] = idDocumentType.rawValue + } + metadata["error"] = AnalyticsClientV2.serialize( + error: error, + filePath: filePath, + line: line + ) + logAnalytic(.cameraError, metadata: metadata) + } + + /// Logs either a permission denied or granted event when the camera permissions are checked prior to starting a camera session + func logCameraPermissionsChecked( + sheetController: VerificationSheetControllerProtocol, + isGranted: Bool? + ) { + var metadata: [String: Any] = [:] + if let idDocumentType = sheetController.collectedData.idDocumentType { + metadata["scan_type"] = idDocumentType.rawValue + } + + let eventName: EventName = + (isGranted == true) ? .cameraPermissionGranted : .cameraPermissionDenied + + logAnalytic(eventName, metadata: metadata) + } + + /// Logs an event when document capture times out + func logDocumentCaptureTimeout( + idDocumentType: DocumentType, + documentSide: DocumentSide + ) { + logAnalytic( + .documentCaptureTimeout, + metadata: [ + "scan_type": idDocumentType.rawValue, + "side": documentSide.rawValue, + ] + ) + } + + /// Logs an event when selfie capture times out + func logSelfieCaptureTimeout() { + logAnalytic(.selfieCaptureTimeout, metadata: [:]) + } + + // MARK: - Performance Events + + /// Logs the a scan's average number of frames per seconds processed + func logAverageFramesPerSecond( + averageFPS: Double, + numFrames: Int, + scannerName: ScannerName + ) { + logAnalytic( + .averageFPS, + metadata: [ + "type": scannerName.rawValue, + "value": averageFPS, + "frames": numFrames, + ] + ) + } + + /// Logs the average inference and post-processing times for every ML model used for one scan + func logModelPerformance( + mlModelMetricsTrackers: [MLDetectorMetricsTrackerProtocol] + ) { + mlModelMetricsTrackers.forEach { metricsTracker in + // Cache values to avoid weakly capturing performanceTracker + let modelName = metricsTracker.modelName + + metricsTracker.getPerformanceMetrics(completeOn: .main) { averageMetrics, numFrames in + guard numFrames > 0 else { return } + self.logModelPerformance( + modelName: modelName, + averageMetrics: averageMetrics, + numFrames: numFrames + ) + } + } + } + + /// Logs an ML model's average inference and post-process time during a scan + private func logModelPerformance( + modelName: String, + averageMetrics: MLDetectorMetricsTracker.Metrics, + numFrames: Int + ) { + logAnalytic( + .modelPerformance, + metadata: [ + "ml_model": modelName, + "inference": averageMetrics.inference.milliseconds, + "postprocess": averageMetrics.postProcess.milliseconds, + "frames": numFrames, + ] + ) + } + + /// Logs the time it takes to upload an image along with its file size and compression quality + func logImageUpload( + idDocumentType: DocumentType?, + timeToUpload: TimeInterval, + compressionQuality: CGFloat, + fileId: String, + fileName: String, + fileSizeBytes: Int + ) { + // NOTE: File size is logged in kB + var metadata: [String: Any] = [ + "value": timeToUpload.milliseconds, + "id": fileId, + "compression_quality": compressionQuality, + "file_name": fileName, + "file_size": fileSizeBytes / 1024, + ] + if let idDocumentType = idDocumentType { + metadata["scan_type"] = idDocumentType.rawValue + } + + logAnalytic(.imageUpload, metadata: metadata) + } + + /// Tracks the time when a user taps a button to continue to the next screen. + /// Should be followed by a call to `stopTrackingTimeToScreenAndLogIfNeeded` + /// when the next screen appears. + func startTrackingTimeToScreen( + from fromScreen: ScreenName? + ) { + if timeToScreenStartTime != nil { + logGenericError( + error: IdentityAnalyticsClientError.timeToScreenAlreadyStarted( + alreadyStartedForScreen: timeToScreenFromScreen, + requestedForScreen: fromScreen + ) + ) + } + timeToScreenStartTime = Date() + timeToScreenFromScreen = fromScreen + } + + /// Logs the time it takes for a screen to appear after the user takes an + /// action to proceed to the next screen in the flow. + /// If `startTrackingTimeToScreen` was not called before calling this method, + /// an analytic is not logged. + func stopTrackingTimeToScreenAndLogIfNeeded(to toScreen: ScreenName) { + let endTime = Date() + + defer { + // Reset state properties + self.timeToScreenStartTime = nil + self.timeToScreenFromScreen = nil + } + + // This method could be called unnecessarily from `viewDidAppear` in the + // case that the view controller was presenting another screen that was + // dismissed or the back button was used. Only log an analytic if there's + // `startTrackingTimeToScreen` was called. + guard let startTime = timeToScreenStartTime, + timeToScreenFromScreen != toScreen + else { + return + } + + var metadata: [String: Any] = [ + "value": endTime.timeIntervalSince(startTime).milliseconds, + "to_screen_name": toScreen.rawValue, + ] + if let fromScreen = timeToScreenFromScreen { + metadata["from_screen_name"] = fromScreen.rawValue + } + + logAnalytic(.timeToScreen, metadata: metadata) + } + + // MARK: - Error Events + + /// Logs when an error occurs. + func logGenericError( + error: Error, + filePath: StaticString = #filePath, + line: UInt = #line + ) { + logAnalytic( + .genericError, + metadata: [ + "error_details": AnalyticsClientV2.serialize( + error: error, + filePath: filePath, + line: line + ), + ] + ) + } +} diff --git a/StripeIdentity/StripeIdentity/Source/Categories/Array+StripeIdentity.swift b/StripeIdentity/StripeIdentity/Source/Categories/Array+StripeIdentity.swift new file mode 100644 index 00000000..d33d16cb --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/Categories/Array+StripeIdentity.swift @@ -0,0 +1,33 @@ +// +// Array+StripeIdentity.swift +// StripeIdentity +// +// Created by Mel Ludowise on 5/10/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +// Borrowed from https://stackoverflow.com/a/47210788/4133371 +extension Array { + func sum(with transform: (Element) -> T) -> T { + return self.reduce(0) { partialResult, element in + return partialResult + transform(element) + } + } + + func average(with transform: (Element) -> T) -> T { + return sum(with: transform) / T(self.count) + } + + func standardDeviation(with transform: (Element) -> T) -> T { + let mean = average(with: transform) + + let v: T = reduce(0) { partialResult, element in + let distanceToMean = transform(element) - mean + return partialResult + (distanceToMean * distanceToMean) + } + + return sqrt(v / (T(self.count) - 1)) + } +} diff --git a/StripeIdentity/StripeIdentity/Source/Categories/CGImage+StripeIdentity.swift b/StripeIdentity/StripeIdentity/Source/Categories/CGImage+StripeIdentity.swift new file mode 100644 index 00000000..93a143ef --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/Categories/CGImage+StripeIdentity.swift @@ -0,0 +1,119 @@ +// +// CGImage+StripeIdentity.swift +// StripeIdentity +// +// Created by Mel Ludowise on 12/8/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import CoreGraphics +@_spi(STP) import StripeCore +import UIKit + +enum STPCGImageError: String, Error { + /// The image could not be cropped + case unableToCrop + /// The image could not be scaled down + case unableToScaleDown +} + +extension CGImage { + + enum CropPaddingComputationMethod { + /// The pixel crop padding is a function of the maximum width or height of the image + case maxImageWidthOrHeight + /// The pixel crop padding is a function of the width of the region of interest + case regionWidth + } + + /// Crops the image to a given region of interest plus padding. + /// + /// - Parameters: + /// - normalizedRegion: A rect, in image coordinates, defining the area to add padding to and then crop, where origin is bottom-left. + /// - cropPadding: A value, ranging between 0–1, that is added as padding to the region of interest. + /// - computationMethod: The method which the crop padding pixel value is computed from. + /// + /// - Returns: An image cropped to the given specifications. + /// + /// - Throws: STPCGImageError if the image could not be cropped + /// + /// - Note: + /// The pixel value of the padding added to region of interest is defined as `cropPadding * max(width, height)`. + func cropping( + toNormalizedRegion normalizedRegion: CGRect, + withPadding cropPadding: CGFloat, + computationMethod: CropPaddingComputationMethod + ) throws -> CGImage { + guard + let image = cropping( + to: computePixelCropArea( + normalizedRegion: normalizedRegion, + pixelPadding: computePixelPadding( + padding: cropPadding, + normalizedRegion: normalizedRegion, + computationMethod: computationMethod + ) + ) + ) + else { + throw STPCGImageError.unableToCrop + } + return image + } + + func computePixelPadding( + padding: CGFloat, + normalizedRegion: CGRect, + computationMethod: CropPaddingComputationMethod + ) -> CGFloat { + switch computationMethod { + case .maxImageWidthOrHeight: + return padding * CGFloat(max(width, height)) + case .regionWidth: + return padding * normalizedRegion.width * CGFloat(width) + } + } + + func computePixelCropArea( + normalizedRegion: CGRect, + pixelPadding: CGFloat + ) -> CGRect { + let pixelRegionOfInterest = CGRect( + x: normalizedRegion.minX * CGFloat(width), + y: normalizedRegion.minY * CGFloat(height), + width: normalizedRegion.width * CGFloat(width), + height: normalizedRegion.height * CGFloat(height) + ) + return pixelRegionOfInterest.insetBy( + dx: -pixelPadding, + dy: -pixelPadding + ) + } + + /// Scales the image, maintaining its aspect ratio, to a maximum dimension. + /// If the image size is already smaller than the given dimension, it will + /// maintain its original dimension. + /// + /// - Parameter maxPixelDimension: The maximum dimensions, in pixels, the returned image should be. + /// + /// - Returns: An image scaled down to the max dimensions. + /// + /// - Throws: STPCGImageError if the image could not be scaled + func scaledDown( + toMaxPixelDimension maxPixelDimension: CGSize + ) throws -> CGImage { + let scale = computeScale(maxPixelDimension: maxPixelDimension) + guard let image = UIImage(cgImage: self).resized(to: scale)?.cgImage else { + throw STPCGImageError.unableToScaleDown + } + return image + } + + func computeScale( + maxPixelDimension: CGSize + ) -> CGFloat { + let horizontalScale = min(maxPixelDimension.width, CGFloat(width)) / CGFloat(width) + let verticalScale = min(maxPixelDimension.height, CGFloat(height)) / CGFloat(height) + return min(horizontalScale, verticalScale) + } +} diff --git a/StripeIdentity/StripeIdentity/Source/Categories/MLMultiArray+StripeIdentity.swift b/StripeIdentity/StripeIdentity/Source/Categories/MLMultiArray+StripeIdentity.swift new file mode 100644 index 00000000..715245c5 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/Categories/MLMultiArray+StripeIdentity.swift @@ -0,0 +1,16 @@ +// +// MLMultiArray+StripeIdentity.swift +// StripeIdentity +// +// Created by Mel Ludowise on 1/26/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import CoreML +import Foundation + +extension MLMultiArray { + subscript(key: [Int]) -> NSNumber { + return self[key.map { NSNumber(value: $0) }] + } +} diff --git a/StripeIdentity/StripeIdentity/Source/Categories/NSAttributedString+HTML.swift b/StripeIdentity/StripeIdentity/Source/Categories/NSAttributedString+HTML.swift new file mode 100644 index 00000000..5dae5fa0 --- /dev/null +++ b/StripeIdentity/StripeIdentity/Source/Categories/NSAttributedString+HTML.swift @@ -0,0 +1,245 @@ +// +// NSAttributedString+HTML.swift +// StripeIdentity +// +// Created by Mel Ludowise on 2/1/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// +import Foundation +@_spi(STP) import StripeCore +@_spi(STP) import StripeUICore +import UIKit + +/// Specifies how to style HTML used to generate an NSAttributedString. +struct HTMLStyle { + static let `default` = HTMLStyle( + bodyFont: UIFont.preferredFont(forTextStyle: .body, weight: .regular), + bodyColor: .label, + h1Font: UIFont.preferredFont(forTextStyle: .title1), + h2Font: UIFont.preferredFont(forTextStyle: .title2), + h3Font: UIFont.preferredFont(forTextStyle: .title3), + h4Font: UIFont.preferredFont(forTextStyle: .headline), + h5Font: UIFont.preferredFont(forTextStyle: .subheadline), + h6Font: UIFont.preferredFont(forTextStyle: .footnote), + isLinkUnderlined: false + ) + + let bodyFont: UIFont + let bodyColor: UIColor? + + let h1Font: UIFont? + let h1Color: UIColor? + + let h2Font: UIFont? + let h2Color: UIColor? + + let h3Font: UIFont? + let h3Color: UIColor? + + let h4Font: UIFont? + let h4Color: UIColor? + + let h5Font: UIFont? + let h5Color: UIColor? + + let h6Font: UIFont? + let h6Color: UIColor? + + let isLinkUnderlined: Bool + let shouldCenterText: Bool + + init( + bodyFont: UIFont, + bodyColor: UIColor? = nil, + h1Font: UIFont? = nil, + h1Color: UIColor? = nil, + h2Font: UIFont? = nil, + h2Color: UIColor? = nil, + h3Font: UIFont? = nil, + h3Color: UIColor? = nil, + h4Font: UIFont? = nil, + h4Color: UIColor? = nil, + h5Font: UIFont? = nil, + h5Color: UIColor? = nil, + h6Font: UIFont? = nil, + h6Color: UIColor? = nil, + isLinkUnderlined: Bool = false, + shouldCenterText: Bool = false + ) { + self.bodyFont = bodyFont + self.bodyColor = bodyColor + self.h1Font = h1Font + self.h1Color = h1Color + self.h2Font = h2Font + self.h2Color = h2Color + self.h3Font = h3Font + self.h3Color = h3Color + self.h4Font = h4Font + self.h4Color = h4Color + self.h5Font = h5Font + self.h5Color = h5Color + self.h6Font = h6Font + self.h6Color = h6Color + self.isLinkUnderlined = isLinkUnderlined + self.shouldCenterText = shouldCenterText + } + + fileprivate static func cssText( + _ cssName: String, + font: UIFont?, + color: UIColor?, + shouldCenterText: Bool + ) -> String { + guard font != nil || color != nil else { + return "" + } + + let fontAttributes = font.map { font -> String in + // If the specified font is the same family as the system font, + // then use "-apple-system" instead. Otherwise, the html renderer will + // only use the non-bold variation of the system font, breaking any bold + // font configurations. + var familyName = font.familyName + if familyName == UIFont.systemFont(ofSize: font.pointSize).familyName { + familyName = "-apple-system" + } + + let fontWeight = + font.fontDescriptor.symbolicTraits.contains(.traitBold) + ? "bold" + : "regular" + + return """ + font-family: "\(familyName)"; + font-size: \(font.pointSize); + font-weight: "\(fontWeight)"; + """ + } + + let colorAttributes = color.map { color -> String in + return "color: \(color.cssValue);" + } + + let centerAttribute = shouldCenterText ? "text-align: center;" : "" + + return """ + \(cssName) { + \(fontAttributes ?? "") + \(colorAttributes ?? "") + \(centerAttribute) + } + """ + } + + /// Constructs a style HTML tag from the properties of this HTMLStyle + fileprivate var styleElementText: String { + var text = " + """ + + return text + } +} + +extension NSAttributedString { + /// Initializes an NSAttributedString from HTML with the specified style. + /// + /// - Note: + /// By default, when an attributed string is built from HTML, the font defaults + /// to Times New Roman with 11pt font. Setting a font on the UILabel or + /// UITextView displaying the attributed string does not override the font. + /// + /// This initializer wraps the HTML string in a ` + """ + } + + static func makeAttributedString( + from html: String, + configuration: Configuration + ) async throws -> NSAttributedString { + // tags don't work with `NSAttributedString.loadFromHTML` on iOS 13/14. As a workaround, we'll replace with in this String, and manually replace them with images later: + // 1. Replace the tags with and pull out the image URLs + let (html, imageURLs) = htmlReplacingImageTags(html: html) + // 2. Construct the attributed string + let css = makeCSS(for: configuration.font) + let (attributedString, _) = try await NSAttributedString.fromHTML(css + html, options: [:]) + // 3. Fetch the images + var images = [URL: UIImage]() + for imageURL in imageURLs { + images[imageURL] = try await loadImage(url: imageURL, apiClient: configuration.apiClient) + } + // 4. Replace the links in the attributed string with image attachments + let mAttributedString = NSMutableAttributedString(attributedString: attributedString) + mAttributedString.enumerateAttribute(.link, in: NSRange(0.. PaymentMethodMessagingContentResponse { + let parameters = Self.makeMessagingContentEndpointParams(configuration: configuration) + var request = configuration.apiClient.configuredRequest( + for: APIEndpoint, + additionalHeaders: [:] + ) + request.stp_addParameters(toURL: parameters) + return try await withCheckedThrowingContinuation { continuation in + configuration.apiClient.get( + url: APIEndpoint, + parameters: parameters + ) { (result: Result) in + continuation.resume(with: result) + } + } + } + + static func makeMessagingContentEndpointParams(configuration: Configuration) -> [String: Any] { + let logoColor: String + switch UITraitCollection.current.isDarkMode + ? configuration.imageColor.userInterfaceStyleDark + : configuration.imageColor.userInterfaceStyleLight + { + case .light: + logoColor = "white" + case .dark: + logoColor = "black" + case .color: + logoColor = "color" + } + return [ + "payment_methods": configuration.paymentMethods.map { (paymentMethod) -> String in + switch paymentMethod { + case .klarna: return "klarna" + case .afterpayClearpay: return "afterpay_clearpay" + } + }, + "currency": configuration.currency, + "amount": configuration.amount, + "country": configuration.countryCode, + "client": "ios", + "logo_color": logoColor, + "locale": Locale.canonicalLanguageIdentifier(from: configuration.locale.identifier), + ] + } + + static func loadImage(url: URL, apiClient: STPAPIClient) async throws -> UIImage? { + let request = apiClient.configuredRequest(for: url) + let (data, _) = try await apiClient.urlSession.data(for: request) + return UIImage(data: data, scale: 3)?.withRenderingMode(.alwaysOriginal) + } +} + +// MARK: - STPAnalyticsProtocol +extension PaymentMethodMessagingView: STPAnalyticsProtocol { + @_spi(STP) public static var stp_analyticsIdentifier = "PaymentMethodMessagingView" +} + +extension PaymentMethodMessagingView { + func log(analytic: Analytic) { + Self.analyticsClient.log(analytic: analytic, apiClient: configuration.apiClient) + } + + enum Analytic: StripeCore.Analytic { + case loadFailed(duration: TimeInterval) + case loadSucceeded(duration: TimeInterval) + case tapped + var event: StripeCore.STPAnalyticEvent { + switch self { + case .loadFailed: return .paymentMethodMessagingViewLoadFailed + case .loadSucceeded: return .paymentMethodMessagingViewLoadSucceeded + case .tapped: return .paymentMethodMessagingViewTapped + } + } + var params: [String: Any] { + switch self { + case .loadFailed(let duration), .loadSucceeded(let duration): + return [ + "duration": duration + ] + case .tapped: + return [:] + } + } + } +} + +// MARK: - NSAttributedString helpers +extension NSAttributedString { + func withFontSize(_ size: CGFloat) -> NSAttributedString { + let mutable = NSMutableAttributedString(attributedString: self) + mutable.enumerateAttributes(in: NSRange(0.. STPFormTextField { + let textField = STPFormTextField(frame: CGRect.zero) + textField.keyboardType = .asciiCapableNumberPad + textField.textAlignment = .natural + + textField.font = formFont + textField.defaultColor = formTextColor + textField.errorColor = formTextErrorColor + textField.placeholderColor = formPlaceholderColor + textField.keyboardAppearance = formKeyboardAppearance + + textField.validText = true + textField.selectionEnabled = true + return textField + } + + class func _nameTextFieldLabel() -> String { + return String.Localized.name + } + + class func _emailTextFieldLabel() -> String { + return String.Localized.email + } + + class func _bsbNumberTextFieldLabel() -> String { + return String.Localized.bank_account + } + + class func _accountNumberTextFieldLabel() -> String { + return self._bsbNumberTextFieldLabel() // same label + } + + func _updateValidText(for formTextField: STPFormTextField) { + if formTextField == _bsbNumberTextField { + formTextField.validText = + viewModel.isInputValid( + formTextField.text ?? "", + for: .BSBNumber, + editing: formTextField.isFirstResponder + ) + } else if formTextField == _accountNumberTextField { + formTextField.validText = + viewModel.isInputValid( + formTextField.text ?? "", + for: .accountNumber, + editing: formTextField.isFirstResponder + ) + } else if formTextField == _nameTextField { + formTextField.validText = + viewModel.isInputValid( + formTextField.text ?? "", + for: .name, + editing: formTextField.isFirstResponder + ) + } else if formTextField == _emailTextField { + formTextField.validText = + viewModel.isInputValid( + formTextField.text ?? "", + for: .email, + editing: formTextField.isFirstResponder + ) + } else { + assert( + false, + "Shouldn't call for text field not managed by \(NSStringFromClass(STPAUBECSDebitFormView.self))" + ) + } + } + + /// :nodoc: + @objc + public override func systemLayoutSizeFitting( + _ targetSize: CGSize, + withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, + verticalFittingPriority: UILayoutPriority + ) -> CGSize { + // UITextViews don't play nice with autolayout, so we have to add a temporary height constraint + // to get this method to account for the full, non-scrollable size of _mandateLabel + layoutIfNeeded() + let tempConstraint = mandateLabel.heightAnchor.constraint( + equalToConstant: mandateLabel.contentSize.height + ) + tempConstraint.isActive = true + let size = super.systemLayoutSizeFitting( + targetSize, + withHorizontalFittingPriority: horizontalFittingPriority, + verticalFittingPriority: verticalFittingPriority + ) + tempConstraint.isActive = false + return size + + } + + func _defaultBSBLabelTextColor() -> UIColor { + return UIColor.secondaryLabel + } + + func _updateBSBLabel() { + var isErrorString = false + bsbLabel.text = viewModel.bsbLabel( + forInput: _bsbNumberTextField.text, + editing: _bsbNumberTextField.isFirstResponder, + isErrorString: &isErrorString + ) + bsbLabel.textColor = isErrorString ? formTextErrorColor : _defaultBSBLabelTextColor() + } + + // MARK: - STPMultiFormFieldDelegate + func formTextFieldDidStartEditing( + _ formTextField: STPFormTextField, + inMultiForm multiFormField: STPMultiFormTextField + ) { + _updateValidText(for: formTextField) + if formTextField == _bsbNumberTextField { + _updateBSBLabel() + } + } + + func formTextFieldDidEndEditing( + _ formTextField: STPFormTextField, + inMultiForm multiFormField: STPMultiFormTextField + ) { + _updateValidText(for: formTextField) + if formTextField == _bsbNumberTextField { + _updateBSBLabel() + } + } + + func modifiedIncomingTextChange( + _ input: NSAttributedString, + for formTextField: STPFormTextField, + inMultiForm multiFormField: STPMultiFormTextField + ) -> NSAttributedString { + if formTextField == _bsbNumberTextField { + return NSAttributedString( + string: viewModel.formattedString(forInput: input.string, in: .BSBNumber), + attributes: _bsbNumberTextField.defaultTextAttributes + ) + } else if formTextField == _accountNumberTextField { + return NSAttributedString( + string: viewModel.formattedString(forInput: input.string, in: .accountNumber), + attributes: _accountNumberTextField.defaultTextAttributes + ) + } else if formTextField == _nameTextField { + return NSAttributedString( + string: viewModel.formattedString(forInput: input.string, in: .name), + attributes: _nameTextField.defaultTextAttributes + ) + } else if formTextField == _emailTextField { + return NSAttributedString( + string: viewModel.formattedString(forInput: input.string, in: .email), + attributes: _emailTextField.defaultTextAttributes + ) + } else { + assert( + false, + "Shouldn't call for text field not managed by \(NSStringFromClass(STPAUBECSDebitFormView.self))" + ) + return input + } + } + + func formTextFieldTextDidChange( + _ formTextField: STPFormTextField, + inMultiForm multiFormField: STPMultiFormTextField + ) { + _updateValidText(for: formTextField) + + let hadCompletePaymentMethod = viewModel.paymentMethodParams != nil + + if formTextField == _bsbNumberTextField { + viewModel.bsbNumber = formTextField.text + + _updateBSBLabel() + bankIconView.image = viewModel.bankIcon(forInput: formTextField.text) + + // Since BSB number affects validity for the account number as well, we also need to update that field + _updateValidText(for: _accountNumberTextField) + + if viewModel.isFieldComplete( + withInput: formTextField.text ?? "", + in: .BSBNumber, + editing: formTextField.isFirstResponder + ) { + focusNextForm() + } + } else if formTextField == _accountNumberTextField { + viewModel.accountNumber = formTextField.text + if viewModel.isFieldComplete( + withInput: formTextField.text ?? "", + in: .accountNumber, + editing: formTextField.isFirstResponder + ) { + focusNextForm() + } + } else if formTextField == _nameTextField { + viewModel.name = formTextField.text + } else if formTextField == _emailTextField { + viewModel.email = formTextField.text + } else { + assert( + false, + "Shouldn't call for text field not managed by \(NSStringFromClass(STPAUBECSDebitFormView.self))" + ) + } + + let nowHasCompletePaymentMethod = viewModel.paymentMethodParams != nil + if hadCompletePaymentMethod != nowHasCompletePaymentMethod { + becsDebitFormDelegate?.auBECSDebitForm( + self, + didChangeToStateComplete: nowHasCompletePaymentMethod + ) + } + } + + func isFormFieldComplete( + _ formTextField: STPFormTextField, + inMultiForm multiFormField: STPMultiFormTextField + ) -> Bool { + if formTextField == _bsbNumberTextField { + return viewModel.isFieldComplete( + withInput: formTextField.text ?? "", + in: .BSBNumber, + editing: false + ) + } else if formTextField == _accountNumberTextField { + return viewModel.isFieldComplete( + withInput: formTextField.text ?? "", + in: .accountNumber, + editing: false + ) + } else if formTextField == _nameTextField { + return viewModel.isFieldComplete( + withInput: formTextField.text ?? "", + in: .name, + editing: false + ) + } else if formTextField == _emailTextField { + return viewModel.isFieldComplete( + withInput: formTextField.text ?? "", + in: .email, + editing: false + ) + } else { + assert( + false, + "Shouldn't call for text field not managed by \(NSStringFromClass(STPAUBECSDebitFormView.self))" + ) + return false + } + } + + // MARK: - UITextViewDelegate + /// :nodoc: + @objc + public func textView( + _ textView: UITextView, + shouldInteractWith URL: URL, + in characterRange: NSRange, + interaction: UITextItemInteraction + ) -> Bool { + return true + } + + // MARK: - STPFormTextFieldContainer (Overrides) + /// :nodoc: + @objc public override var formFont: UIFont { + get { + super.formFont + } + set { + super.formFont = newValue + labeledNameField.formLabelFont = newValue + labeledEmailField.formLabelFont = newValue + } + } + + /// :nodoc: + @objc public override var formTextColor: UIColor { + get { + super.formTextColor + } + set { + super.formTextColor = newValue + labeledNameField.formLabelTextColor = newValue + labeledEmailField.formLabelTextColor = newValue + } + } +} + +extension STPAUBECSDebitFormView { + func nameTextField() -> STPFormTextField { + return _nameTextField + } + + func emailTextField() -> STPFormTextField { + return _emailTextField + } + + func bsbNumberTextField() -> STPFormTextField { + return _bsbNumberTextField + } + + func accountNumberTextField() -> STPFormTextField { + return _accountNumberTextField + } +} diff --git a/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPCardFormView+SwiftUI.swift b/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPCardFormView+SwiftUI.swift new file mode 100644 index 00000000..ace69f53 --- /dev/null +++ b/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPCardFormView+SwiftUI.swift @@ -0,0 +1,66 @@ +// +// STPCardFormView+SwiftUI.swift +// StripePaymentsUI +// +// Created by Cameron Sabol on 3/8/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripePayments +import SwiftUI + +extension STPCardFormView { + + /// A SwiftUI representation of STPCardFormView + public struct Representable: UIViewRepresentable { + @Binding var paymentMethodParams: STPPaymentMethodParams + @Binding var isComplete: Bool + + let cardFormViewStyle: STPCardFormViewStyle + + /// Initialize a SwiftUI representation of an STPCardFormView. + /// - Parameter style: The visual style to apply to the STPCardFormView. @see STPCardFormViewStyle + /// - Parameter paymentMethodParams: A binding to the payment card text field's contents. + /// The STPPaymentMethodParams will be `nil` if the card form view's contents are invalid or incomplete. + public init( + _ style: STPCardFormViewStyle = .standard, + paymentMethodParams: Binding, + isComplete: Binding + ) { + cardFormViewStyle = style + _paymentMethodParams = paymentMethodParams + _isComplete = isComplete + } + + public func makeCoordinator() -> Coordinator { + return Coordinator(parent: self) + } + + public func makeUIView(context: Context) -> STPCardFormView { + let cardFormView = STPCardFormView(style: cardFormViewStyle) + cardFormView.delegate = context.coordinator + cardFormView.cardParams = paymentMethodParams + return cardFormView + } + + public func updateUIView(_ cardFormView: STPCardFormView, context: Context) { + cardFormView.cardParams = paymentMethodParams + } + } + + /// :nodoc: + public class Coordinator: NSObject, STPCardFormViewDelegate { + + var parent: Representable + init( + parent: Representable + ) { + self.parent = parent + } + + /// :no-doc: + public func cardFormView(_ form: STPCardFormView, didChangeToStateComplete complete: Bool) { + parent.isComplete = complete + } + } +} diff --git a/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPCardFormView.swift b/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPCardFormView.swift new file mode 100644 index 00000000..1fed9d60 --- /dev/null +++ b/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPCardFormView.swift @@ -0,0 +1,799 @@ +// +// STPCardFormView.swift +// StripePaymentsUI +// +// Created by Cameron Sabol on 10/22/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments +@_spi(STP) import StripeUICore +import UIKit + +/// Options for configuring the display of an `STPCardFormView` instance. +@objc +public enum STPCardFormViewStyle: Int { + /// Draws the form in a rounded rect with full separators between + /// each input field. + case standard + + /// Draws the form without an outer border and underlines under + /// each input field. + case borderless +} + +/// `STPCardFormViewDelegate` defines the interface that should be adopted to receive +/// updates from `STPCardFormView` instances. +@objc +public protocol STPCardFormViewDelegate: NSObjectProtocol { + /// Delegate method that is called when all of the form view's required inputs + /// are complete or transition away from all being complete. These transitions + /// correspond to `cardForView.cardParams` returning a nil value or not. + func cardFormView(_ form: STPCardFormView, didChangeToStateComplete complete: Bool) +} + +/// Internal only delegate methods for STPCardFormView +@_spi(STP) public protocol STPCardFormViewInternalDelegate { + /// Delegate method that is called when the selected country is changed. + func cardFormView(_ form: STPCardFormView, didUpdateSelectedCountry countryCode: String?) +} + +/// `STPCardFormView` provides a multiline interface for users to input their +/// credit card details as well as billing postal code and provides an interface to access +/// the created `STPPaymentMethodParams`. +/// `STPCardFormView` includes both the input fields as well as an error label that +/// is displayed when invalid input is detected. +public class STPCardFormView: STPFormView { + + let numberField: STPCardNumberInputTextField + let cvcField: STPCardCVCInputTextField + let expiryField: STPCardExpiryInputTextField + + let billingAddressSubForm: BillingAddressSubForm + let postalCodeRequirement: STPPostalCodeRequirement + let inputMode: STPCardNumberInputTextField.InputMode + + @_spi(STP) public var countryField: STPCountryPickerInputField { + return billingAddressSubForm.countryPickerField + } + + @_spi(STP) public var postalCodeField: STPPostalCodeInputTextField { + return billingAddressSubForm.postalCodeField + } + + var stateField: STPGenericInputTextField? { + return billingAddressSubForm.stateField + } + + @_spi(STP) public var countryCode: String? { + didSet { + updateCountryCodeValues() + } + } + + private func updateCountryCodeValues() { + postalCodeField.countryCode = countryCode + set( + textField: postalCodeField, + isHidden: !STPPostalCodeValidator.postalCodeIsRequired( + forCountryCode: countryCode, + with: postalCodeRequirement + ), + animated: window != nil + ) + stateField?.placeholder = StripeSharedStrings.localizedStateString(for: countryCode) + } + + var hideShadow: Bool = false { + didSet { + sectionViews.forEach { (sectionView) in + sectionView.stackView.hideShadow = hideShadow + } + } + } + + /// The delegate to notify when the card form transitions to or from being complete. + /// - seealso: STPCardFormViewDelegate + @objc + public weak var delegate: STPCardFormViewDelegate? + + internal var internalDelegate: STPCardFormViewInternalDelegate? { + return delegate as? STPCardFormViewInternalDelegate + } + + var _backgroundColor: UIColor? + + /// :nodoc: + @objc + public override var backgroundColor: UIColor? { + get { + switch style { + + case .standard: + return sectionViews.first?.stackView.customBackgroundColor + + case .borderless: + return _backgroundColor + + } + } + set { + switch style { + + case .standard: + super.backgroundColor = nil + sectionViews.forEach({ $0.stackView.customBackgroundColor = newValue }) + + case .borderless: + _backgroundColor = newValue + super.backgroundColor = backgroundColor + + } + } + } + + var _disabledBackgroundColor: UIColor? + + /// The background color that is automatically applied to the input fields when `isUserInteractionEnabled` is set to `false. + /// @note `STPCardFormView` uses text colors, most of which are iOS system colors, that are designed to be as + /// accessible as possible, so any customization should avoid decreasing contrast between the text and background. + @objc + public var disabledBackgroundColor: UIColor? { + get { + switch style { + + case .standard: + return sectionViews.first?.stackView.customBackgroundDisabledColor + + case .borderless: + return _disabledBackgroundColor + + } + } + set { + switch style { + + case .standard: + sectionViews.forEach({ $0.stackView.customBackgroundDisabledColor = newValue }) + + case .borderless: + _disabledBackgroundColor = disabledBackgroundColor + } + } + } + + /// A configured `STPPaymentMethodParams` with the entered card number, expiration date, cvc, and + /// postal code (if applicable). If any field is invalid or incomplete then this property will return `nil`. + /// You can monitor when `STPCardFormView` has complete details by implementing + /// `STPFormViewDelegate` and setting the `STPCardFormView's` `delegate` + /// property. + @objc + public internal(set) var cardParams: STPPaymentMethodParams? { + get { + guard case .valid = numberField.validator.validationState, + let cardNumber = numberField.validator.inputValue, + case .valid = cvcField.validator.validationState, + let cvc = cvcField.validator.inputValue, + case .valid = expiryField.validator.validationState, + let expiryStrings = expiryField.expiryStrings, + let monthInt = Int(expiryStrings.month), + let yearInt = Int(expiryStrings.year), + let billingDetails = billingAddressSubForm.billingDetails + else { + return nil + } + + if let bindedPaymentMethodParams = _bindedPaymentMethodParams { + updateBindedPaymentMethodParams() + return bindedPaymentMethodParams + } + + let cardParams = STPPaymentMethodCardParams() + cardParams.number = cardNumber + cardParams.cvc = cvc + cardParams.expMonth = NSNumber(value: monthInt) + cardParams.expYear = NSNumber(value: yearInt) + + return STPPaymentMethodParams( + card: cardParams, + billingDetails: billingDetails, + metadata: nil + ) + } + set { + if let card = newValue?.card { + if let number = card.number { + numberField.text = number + } + if let expMonth = card.expMonth, let expYear = card.expYear { + let expText = String( + format: "%02lu%02lu", + Int(truncating: expMonth), + Int(truncating: expYear) % 100 + ) + expiryField.text = expText + } + if let cvc = card.cvc { + cvcField.text = cvc + } + } + billingAddressSubForm.billingDetails = newValue?.billingDetails + // MUST be called after setting field values + _bindedPaymentMethodParams = newValue + } + } + + @_spi(STP) public func _stpinternal_setCardParams(_ params: STPPaymentMethodParams?) { + self.cardParams = params + } + + var _bindedPaymentMethodParams: STPPaymentMethodParams? { + didSet { + updateBindedPaymentMethodParams() + } + } + + func updateBindedPaymentMethodParams() { + guard let bindedPaymentMethodParams = _bindedPaymentMethodParams else { + return + } + + let cardParams = bindedPaymentMethodParams.card ?? STPPaymentMethodCardParams() + bindedPaymentMethodParams.card = cardParams + cardParams.number = numberField.inputValue + cardParams.cvc = cvcField.inputValue + if let expiryStrings = expiryField.expiryStrings, + let monthInt = Int(expiryStrings.month), + let yearInt = Int(expiryStrings.year) + { + cardParams.expMonth = NSNumber(value: monthInt) + cardParams.expYear = NSNumber(value: yearInt) + } else { + cardParams.expMonth = nil + cardParams.expYear = nil + } + + let billingDetails = + bindedPaymentMethodParams.billingDetails ?? STPPaymentMethodBillingDetails() + bindedPaymentMethodParams.billingDetails = billingDetails + billingAddressSubForm.updateBindedBillingDetails(billingDetails) + } + + func updateCurrentBackgroundColor() { + switch style { + + case .standard: + break // no-op, switching background color is handled at the section view layer + + case .borderless: + // if there's a backgroundColor set but no disabledBackgroundColor + // assume no color change for disabled state + super.backgroundColor = + isUserInteractionEnabled + ? backgroundColor : (disabledBackgroundColor ?? backgroundColor) + } + } + + @objc + public override var isUserInteractionEnabled: Bool { + didSet { + updateCurrentBackgroundColor() + if inputMode == .panLocked { + self.numberField.isUserInteractionEnabled = false + } + } + } + + let style: STPCardFormViewStyle + + /// Public initializer for `STPCardFormView`. + /// @param style The visual style to use for this instance. @see STPCardFormViewStyle + @objc + public convenience init( + style: STPCardFormViewStyle = .standard + ) { + self.init( + billingAddressCollection: .automatic, + includeCardScanning: false, + mergeBillingFields: true, + style: style, + prefillDetails: nil + ) + + hideShadow = true + // manually call the didSet behavior of hideShadow since that's not triggered in initializers + sectionViews.forEach { (sectionView) in + sectionView.stackView.hideShadow = hideShadow + // remove default background coloring + sectionView.stackView.customBackgroundColor = nil + } + + STPAnalyticsClient.sharedClient.addClass(toProductUsageIfNecessary: STPCardFormView.self) + } + + @_spi(STP) public convenience init( + billingAddressCollection: BillingAddressCollectionLevel, + includeCardScanning: Bool = true, + mergeBillingFields: Bool = false, + style: STPCardFormViewStyle = .standard, + postalCodeRequirement: STPPostalCodeRequirement = .standard, + prefillDetails: PrefillDetails? = nil, + inputMode: STPCardNumberInputTextField.InputMode = .standard + ) { + self.init( + numberField: STPCardNumberInputTextField( + inputMode: inputMode, + prefillDetails: prefillDetails + ), + cvcField: STPCardCVCInputTextField(prefillDetails: prefillDetails), + expiryField: STPCardExpiryInputTextField(prefillDetails: prefillDetails), + billingAddressSubForm: BillingAddressSubForm( + billingAddressCollection: billingAddressCollection, + postalCodeRequirement: postalCodeRequirement + ), + includeCardScanning: includeCardScanning, + mergeBillingFields: mergeBillingFields, + style: style, + postalCodeRequirement: postalCodeRequirement, + prefillDetails: prefillDetails, + inputMode: inputMode + ) + } + + required init( + numberField: STPCardNumberInputTextField, + cvcField: STPCardCVCInputTextField, + expiryField: STPCardExpiryInputTextField, + billingAddressSubForm: BillingAddressSubForm, + includeCardScanning: Bool, + mergeBillingFields: Bool, + style: STPCardFormViewStyle = .standard, + postalCodeRequirement: STPPostalCodeRequirement = .standard, + prefillDetails: PrefillDetails? = nil, + inputMode: STPCardNumberInputTextField.InputMode = .standard + ) { + self.numberField = numberField + self.cvcField = cvcField + self.expiryField = expiryField + self.billingAddressSubForm = billingAddressSubForm + self.style = style + self.postalCodeRequirement = postalCodeRequirement + self.inputMode = inputMode + + if inputMode == .panLocked { + self.numberField.isUserInteractionEnabled = false + } + + var scanButton: UIButton? + if includeCardScanning { + let cardScanningAvailable: Bool = { + var scannerClassObject: AnyObject.Type? + if let scanner = NSClassFromString("STPCardScanner") { + scannerClassObject = scanner + } else if let scanner = NSClassFromString("STPCardScanner_legacy") { + scannerClassObject = scanner + } + let scannerClass = scannerClassObject as? STPCardScanningProtocol.Type + return scannerClass?.cardScanningAvailable ?? false + }() + if cardScanningAvailable { + let fontMetrics = UIFontMetrics(forTextStyle: .body) + let labelFont = fontMetrics.scaledFont( + for: UIFont.systemFont(ofSize: 13, weight: .semibold) + ) + let iconConfig = UIImage.SymbolConfiguration( + font: fontMetrics.scaledFont( + for: UIFont.systemFont(ofSize: 9, weight: .semibold) + ) + ) + + scanButton = UIButton(type: .system) + scanButton?.setTitle(String.Localized.scan_card, for: .normal) + scanButton?.setImage( + UIImage(systemName: "camera.fill", withConfiguration: iconConfig), + for: .normal + ) + scanButton?.setContentSpacing(4, withEdgeInsets: .zero) + scanButton?.tintColor = .label + scanButton?.titleLabel?.font = labelFont + scanButton?.setContentHuggingPriority(.defaultLow + 1, for: .horizontal) + } + } + + var rows: [[STPFormInput]] = [ + [numberField], + [expiryField, cvcField], + ] + if mergeBillingFields { + rows.append(contentsOf: billingAddressSubForm.formSection.rows) + } + + let cardParamsSection = STPFormView.Section( + rows: rows, + title: mergeBillingFields ? nil : String.Localized.card_information, + accessoryButton: scanButton + ) + + super.init( + sections: mergeBillingFields + ? [cardParamsSection] : [cardParamsSection, billingAddressSubForm.formSection] + ) + numberField.addObserver(self) + cvcField.addObserver(self) + expiryField.addObserver(self) + billingAddressSubForm.formSection.rows.forEach({ $0.forEach({ $0.addObserver(self) }) }) + scanButton?.addTarget(self, action: #selector(scanButtonTapped), for: .touchUpInside) + countryCode = countryField.inputValue + updateCountryCodeValues() + + switch style { + + case .standard: + break + + case .borderless: + sectionViews.forEach { (sectionView) in + sectionView.stackView.separatorStyle = .partial + sectionView.stackView.drawBorder = false + sectionView.insetFooterLabel = true + } + } + } + + required init?( + coder: NSCoder + ) { + fatalError("init(coder:) has not been implemented") + } + + required init( + sections: [Section] + ) { + fatalError("init(sections:) has not been implemented") + } + + override func shouldAutoAdvance( + for input: STPInputTextField, + with validationState: STPValidatedInputState, + from previousState: STPValidatedInputState + ) -> Bool { + if input == numberField { + if case .valid = validationState { + if case .processing = previousState { + return false + } else { + return true + } + } else { + return false + } + } else if input == postalCodeField { + if case .valid = validationState { + if countryCode == "US" { + return true + } + } else { + return false + } + } else if input == cvcField { + if case .valid = validationState { + return (input.validator.inputValue?.count ?? 0) + >= STPCardValidator.maxCVCLength(for: cvcField.cardBrand) + } else { + return false + } + } else if billingAddressSubForm.formSection.contains(input) { + return false + } + return super.shouldAutoAdvance(for: input, with: validationState, from: previousState) + } + + override func validationDidUpdate( + to state: STPValidatedInputState, + from previousState: STPValidatedInputState, + for unformattedInput: String?, + in input: STPFormInput + ) { + guard let textField = input as? STPInputTextField else { + return + } + + if textField == numberField { + cvcField.cardBrand = numberField.cardBrand + } else if textField == countryField { + let countryChanged = textField.inputValue != countryCode + + countryCode = countryField.inputValue + + let shouldFocusOnPostalCode = + countryChanged + && STPPostalCodeValidator.postalCodeIsRequired( + forCountryCode: countryCode, + with: postalCodeRequirement + ) + + if shouldFocusOnPostalCode { + _ = postalCodeField.becomeFirstResponder() + } + + if countryChanged { + self.internalDelegate?.cardFormView(self, didUpdateSelectedCountry: countryCode) + } + } + super.validationDidUpdate( + to: state, + from: previousState, + for: unformattedInput, + in: textField + ) + if case .valid = state, state != previousState { + if cardParams != nil { + // we transitioned to complete + delegate?.cardFormView(self, didChangeToStateComplete: true) + formViewInternalDelegate?.formView(self, didChangeToStateComplete: true) + } + } else if case .valid = previousState, state != previousState { + delegate?.cardFormView(self, didChangeToStateComplete: false) + formViewInternalDelegate?.formView(self, didChangeToStateComplete: false) + } + + updateBindedPaymentMethodParams() + } + + @objc func scanButtonTapped(sender: UIButton) { + self.formViewInternalDelegate?.formView(self, didTapAccessoryButton: sender) + } + + /// Returns true iff the form can mark the error to one of its fields + @_spi(STP) public func markFormErrors(for apiError: Error) -> Bool { + let error = apiError as NSError + guard let errorCode = error.userInfo[STPError.stripeErrorCodeKey] as? String else { + return false + } + switch errorCode { + case "incorrect_number", "invalid_number": + numberField.validator.validationState = .invalid( + errorMessage: error.userInfo[NSLocalizedDescriptionKey] as? String + ?? numberField.validator.defaultErrorMessage + ) + return true + + case "invalid_expiry_month", "invalid_expiry_year", "expired_card": + expiryField.validator.validationState = .invalid( + errorMessage: error.userInfo[NSLocalizedDescriptionKey] as? String + ?? expiryField.validator.defaultErrorMessage + ) + return true + + case "invalid_cvc", "incorrect_cvc": + cvcField.validator.validationState = .invalid( + errorMessage: error.userInfo[NSLocalizedDescriptionKey] as? String + ?? cvcField.validator.defaultErrorMessage + ) + return true + + case "incorrect_zip": + postalCodeField.validator.validationState = .invalid( + errorMessage: error.userInfo[NSLocalizedDescriptionKey] as? String + ?? postalCodeField.validator.defaultErrorMessage + ) + return true + + default: + return false + } + } +} + +/// :nodoc: +extension STPCardFormView { + class BillingAddressSubForm: NSObject { + let formSection: STPFormView.Section + + let postalCodeField: STPPostalCodeInputTextField + let countryPickerField: STPCountryPickerInputField = STPCountryPickerInputField() + let stateField: STPGenericInputTextField? + + let line1Field: STPGenericInputTextField? + let line2Field: STPGenericInputTextField? + let cityField: STPGenericInputTextField? + + var billingDetails: STPPaymentMethodBillingDetails? { + get { + let billingDetails = STPPaymentMethodBillingDetails() + let address = STPPaymentMethodAddress() + + if !postalCodeField.isHidden { + if case .valid = postalCodeField.validationState { + address.postalCode = postalCodeField.postalCode + } else { + return nil + } + } + + if case .valid = countryPickerField.validationState { + address.country = countryPickerField.inputValue + } else { + return nil + } + + billingDetails.address = address + return billingDetails + } + + set { + let address = newValue?.address + + // MUST set country code before postal code + if let countryCode = address?.country { + countryPickerField.select(countryCode: countryCode) + } + + postalCodeField.text = address?.postalCode + + if let stateField = stateField { + stateField.text = address?.state + } + + if let line1Field = line1Field { + line1Field.text = address?.line1 + } + + if let line2Field = line2Field { + line2Field.text = address?.line2 + } + + if let cityField = cityField { + cityField.text = address?.city + } + } + } + + func updateBindedBillingDetails(_ billingDetails: STPPaymentMethodBillingDetails) { + let address = billingDetails.address ?? STPPaymentMethodAddress() + + if !postalCodeField.isHidden { + address.postalCode = postalCodeField.postalCode + } else { + address.postalCode = nil + } + + address.country = countryPickerField.inputValue + + if let stateField = stateField { + address.state = stateField.inputValue + } + + if let line1Field = line1Field { + address.line1 = line1Field.inputValue + } + + if let line2Field = line2Field { + address.line2 = line2Field.inputValue + } + + if let cityField = cityField { + address.city = cityField.inputValue + } + + billingDetails.address = address + } + + required init( + billingAddressCollection: BillingAddressCollectionLevel, + postalCodeRequirement: STPPostalCodeRequirement + ) { + postalCodeField = STPPostalCodeInputTextField( + postalCodeRequirement: postalCodeRequirement + ) + + let rows: [[STPInputTextField]] + let title: String + switch billingAddressCollection { + + case .automatic: + stateField = nil + line1Field = nil + line2Field = nil + cityField = nil + rows = [ + [countryPickerField], + [postalCodeField], + ] + title = String.Localized.country_or_region + + case .required: + stateField = STPGenericInputTextField( + placeholder: StripeSharedStrings.localizedStateString( + for: Locale.autoupdatingCurrent.regionCode + ), + textContentType: .addressState + ) + line1Field = STPGenericInputTextField( + placeholder: String.Localized.address_line1, + textContentType: .streetAddressLine1, + keyboardType: .numbersAndPunctuation + ) + line2Field = STPGenericInputTextField( + placeholder: STPLocalizedString( + "Address line 2 (optional)", + "Address line 2 placeholder for billing address form." + ), + textContentType: .streetAddressLine2, + keyboardType: .numbersAndPunctuation, + optional: true + ) + cityField = STPGenericInputTextField( + placeholder: String.Localized.city, + textContentType: .addressCity + ) + rows = [ + // Country selector + [countryPickerField], + // Address line 1 + [line1Field!], + // Address line 2 + [line2Field!], + // City, Postal code + [cityField!, postalCodeField], + // State + [stateField!], + ] + title = String.Localized.billing_address_lowercase + } + + formSection = STPFormView.Section(rows: rows, title: title, accessoryButton: nil) + } + + } +} + +/// :nodoc: +@_spi(STP) extension STPCardFormView: STPAnalyticsProtocol { + @_spi(STP) public static var stp_analyticsIdentifier: String = "STPCardFormView" +} + +extension STPCardFormView { + + @_spi(STP) public struct PrefillDetails { + @_spi(STP) public let last4: String + @_spi(STP) public let expiryMonth: Int + @_spi(STP) public let expiryYear: Int + @_spi(STP) public let cardBrand: STPCardBrand + + @_spi(STP) public var formattedLast4: String { + return "•••• \(last4)" + } + + @_spi(STP) public var formattedExpiry: String { + let paddedZero = expiryMonth < 10 + return "\(paddedZero ? "0" : "")\(expiryMonth)/\(expiryYear)" + } + + @_spi(STP) public init( + last4: String, + expiryMonth: Int, + expiryYear: Int, + cardBrand: STPCardBrand + ) { + self.last4 = last4 + self.expiryMonth = expiryMonth + self.expiryYear = expiryYear + self.cardBrand = cardBrand + } + } +} + +@_spi(STP) public protocol STPCardScanningProtocol { + static var cardScanningAvailable: Bool { get } +} + +/// Billing address collection modes for PaymentSheet +@_spi(STP) public enum BillingAddressCollectionLevel { + /// (Default) PaymentSheet will only collect the necessary billing address information + case automatic + + /// PaymentSheet will always collect full billing address details + case required +} diff --git a/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPFloatingPlaceholderTextField.swift b/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPFloatingPlaceholderTextField.swift new file mode 100644 index 00000000..642fb028 --- /dev/null +++ b/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPFloatingPlaceholderTextField.swift @@ -0,0 +1,422 @@ +// +// STPFloatingPlaceholderTextField.swift +// StripePaymentsUI +// +// Created by Cameron Sabol on 10/7/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeUICore +import UIKit + +/// A `UITextField` subclass that moves the placeholder text to the top leading side of the field +/// instead of hiding it upon text entry or editing. +@_spi(STP) public class STPFloatingPlaceholderTextField: UITextField { + + struct LayoutConstants { + static let defaultHeight: CGFloat = 40 + + static let horizontalMargin: CGFloat = 11 + static let horizontalSpacing: CGFloat = 4 + + static let floatingPlaceholderScale: CGFloat = 0.75 + + static let defaultPlaceholderColor: UIColor = .secondaryLabel + + static let floatingPlaceholderColor: UIColor = .secondaryLabel + } + + let placeholderLabel: UILabel = { + let label = UILabel() + label.textColor = STPFloatingPlaceholderTextField.LayoutConstants.defaultPlaceholderColor + label.adjustsFontForContentSizeCategory = true + return label + }() + + var lastAnimator: UIViewPropertyAnimator? + + var changingFirstResponderStatus = false + + var defaultPlaceholderColor: UIColor = STPFloatingPlaceholderTextField.LayoutConstants + .defaultPlaceholderColor + var floatingPlaceholderColor: UIColor = STPFloatingPlaceholderTextField.LayoutConstants + .floatingPlaceholderColor + + var placeholderColor: UIColor { + get { + return placeholderLabel.textColor + } + set { + placeholderLabel.textColor = newValue + } + } + + override init( + frame: CGRect + ) { + super.init(frame: frame) + setupSubviews() + } + + required init?( + coder: NSCoder + ) { + super.init(coder: coder) + setupSubviews() + } + + func setupSubviews() { + // even though the default font value for UITextFields is body, on iOS 13 at least they do not respect + // the font size settings. Resetting here fixes + font = UIFont.preferredFont(forTextStyle: .body) + adjustsFontForContentSizeCategory = true + + placeholderLabel.font = font + placeholderLabel.textAlignment = textAlignment + placeholderColor = defaultPlaceholderColor + addSubview(placeholderLabel) + } + + func floatingPlaceholderHeight() -> CGFloat { + let placeholderLabelHeight = placeholderLabel.textRect( + forBounds: CGRect( + x: 0, + y: 0, + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ), + limitedToNumberOfLines: 1 + ).height + return placeholderLabelHeight + * STPFloatingPlaceholderTextField.LayoutConstants.floatingPlaceholderScale + } + + func contentPadding() -> UIEdgeInsets { + + let floatingPlaceholderLabelHeight = floatingPlaceholderHeight() + let availableHeight = bounds.height + + // + // |----------------------------------------------------| + // |_______|vMargin_____________________________________| + // | | | + // |_______|floatingPlacholderLabelHeight_______________| + // | | | + // | | | availableHeight + // | | | + // |_______|textEntryHeight_____________________________| + // |_______|vMargin_____________________________________| + // + // vMargin is calculated as follows: + // + // We want the text content to be vertically centered, giving the equation: + // (floatingPlaceholderLabelHeight + textEntryHeight)/2 = availableHeight/2 + // + // We want the distance from the top to the midpoint of the floating placeholder to + // be the same as the distance from the bottom to the center of the text entry rect, + // but scaled by floatingPlaceholderScale giving: + // floatingPlaceholderScale * (textEntryHeight/2 + vMargin) = floatingPlacholderLabelHeight/2 + vMargin + // + + let vMargin = + floatingPlaceholderLabelHeight > 0 + ? max( + 0, + STPFloatingPlaceholderTextField.LayoutConstants.floatingPlaceholderScale + * (availableHeight - floatingPlaceholderLabelHeight + - (floatingPlaceholderLabelHeight + / STPFloatingPlaceholderTextField.LayoutConstants + .floatingPlaceholderScale)) + / CGFloat(2) + ) : 0 + + var leftMargin = STPFloatingPlaceholderTextField.LayoutConstants.horizontalMargin + if leftView != nil, + leftViewMode == .always + { + leftMargin = + leftMargin + self.leftViewRect(forBounds: bounds).width + + STPFloatingPlaceholderTextField.LayoutConstants.horizontalSpacing + } + + var rightMargin = STPFloatingPlaceholderTextField.LayoutConstants.horizontalMargin + if rightView != nil, + rightViewMode == .always + { + rightMargin = + rightMargin + self.rightViewRect(forBounds: bounds).width + + STPFloatingPlaceholderTextField.LayoutConstants.horizontalSpacing + } + + let isRTL = traitCollection.layoutDirection == .rightToLeft + + return UIEdgeInsets( + top: vMargin, + left: isRTL ? rightMargin : leftMargin, + bottom: vMargin, + right: isRTL ? leftMargin : rightMargin + ) + } + + func textEntryFieldInset() -> UIEdgeInsets { + var inset = contentPadding() + if isEditing || !(text?.isEmpty ?? true) { + // contentPadding pads the top to the floating placeholder so for text + // entry we need to offset past that + let floatingPlaceholderLabelHeight = floatingPlaceholderHeight() + inset.top = inset.top + floatingPlaceholderLabelHeight + } + return inset + } + + func textEntryFrame() -> CGRect { + return bounds.inset(by: textEntryFieldInset()) + } + + func layoutPlaceholder(animated: Bool) { + guard !(placeholder?.isEmpty ?? true) else { + return + } + layoutIfNeeded() + + var placeholderFrame = textEntryFrame() + placeholderFrame.size.width = min( + placeholderFrame.size.width, + placeholderLabel.textRect(forBounds: placeholderFrame, limitedToNumberOfLines: 1).width + ) + if traitCollection.layoutDirection == .rightToLeft { + placeholderFrame.origin.x = textEntryFrame().maxX - placeholderFrame.width + } + var placeholderTransform = CGAffineTransform.identity + var placeholderColor: UIColor = defaultPlaceholderColor + + let minimized = isEditing || !(text?.isEmpty ?? true) + + if minimized { + let scale = STPFloatingPlaceholderTextField.LayoutConstants.floatingPlaceholderScale + + placeholderFrame.origin.y = self.contentPadding().top + if traitCollection.layoutDirection == .rightToLeft { + // shift origin to the right by the amount the text is compressed horizontally + placeholderFrame.origin.x = + placeholderFrame.origin.x + (1 - scale) * placeholderFrame.width + } + // scaling the width here leads to a clean up and down animation + placeholderFrame.size.width = placeholderFrame.width * scale + placeholderFrame.size.height = + placeholderLabel.textRect(forBounds: placeholderFrame, limitedToNumberOfLines: 1) + .height * scale + placeholderTransform = placeholderTransform.scaledBy(x: scale, y: scale) + placeholderColor = floatingPlaceholderColor + } + + if animated { + // Stop any in-flight animations + lastAnimator?.stopAnimation(true) + let params = UISpringTimingParameters( + mass: 1.0, + dampingRatio: 0.93, + frequencyResponse: 0.22 + ) + let animator = UIViewPropertyAnimator(duration: 0, timingParameters: params) + animator.isInterruptible = true + animator.addAnimations { + self.placeholderLabel.transform = placeholderTransform + self.placeholderLabel.frame = placeholderFrame + if !minimized { + // when we are animating back to center, change color immediately + self.placeholderColor = placeholderColor + } + } + animator.addCompletion { (_) in + if minimized { + // when animating away from center, change color at end of animation + self.placeholderColor = placeholderColor + } + } + animator.startAnimation() + self.lastAnimator = animator + } else { + placeholderLabel.transform = placeholderTransform + placeholderLabel.frame = placeholderFrame + self.placeholderColor = placeholderColor + } + } + +} + +// MARK: UITextField Overrides +extension STPFloatingPlaceholderTextField { + + /// :nodoc: + @objc public override var placeholder: String? { + get { + return placeholderLabel.text + } + set { + placeholderLabel.text = newValue + self.accessibilityLabel = newValue + invalidateIntrinsicContentSize() + setNeedsLayout() + } + } + + /// :nodoc: + @objc public override var attributedPlaceholder: NSAttributedString? { + get { + return placeholderLabel.attributedText + } + set { + placeholderLabel.attributedText = newValue + self.accessibilityLabel = newValue?.string + invalidateIntrinsicContentSize() + setNeedsLayout() + } + } + + /// :nodoc: + @objc public override var font: UIFont? { + didSet { + placeholderLabel.font = font + } + } + + /// :nodoc: + @objc public override var textAlignment: NSTextAlignment { + didSet { + placeholderLabel.textAlignment = textAlignment + } + } + + /// :nodoc: + @objc public override var leftViewMode: UITextField.ViewMode { + get { + return super.leftViewMode + } + set { + if newValue != .always && newValue != .never { + assert(false, "Only .always or .never are supported") + super.leftViewMode = .never + } else { + super.leftViewMode = newValue + } + } + } + + /// :nodoc: + @objc public override var rightViewMode: UITextField.ViewMode { + get { + return super.rightViewMode + } + set { + if newValue != .always && newValue != .never { + assert(false, "Only .always or .never are supported") + super.rightViewMode = .never + } else { + super.rightViewMode = newValue + } + } + } + + /// :nodoc: + @objc public override func layoutSubviews() { + super.layoutSubviews() + // internally, becoming first responder triggers a layout which we want to suppress + // so we can animate + if !changingFirstResponderStatus { + layoutPlaceholder(animated: false) + } + } + + /// :nodoc: + @objc public override func becomeFirstResponder() -> Bool { + changingFirstResponderStatus = true + let ret = super.becomeFirstResponder() + layoutPlaceholder(animated: true) + changingFirstResponderStatus = false + return ret + } + + /// :nodoc: + @objc public override func resignFirstResponder() -> Bool { + changingFirstResponderStatus = true + let ret = super.resignFirstResponder() + layoutPlaceholder(animated: true) + changingFirstResponderStatus = false + return ret + } + + /// :nodoc: + @objc public override func textRect(forBounds bounds: CGRect) -> CGRect { + // N.B. The bounds passed here are not the same as self.bounds + // which is why we don't just use textEntryFrame() + return bounds.inset(by: textEntryFieldInset()) + } + + /// :nodoc: + @objc public override func placeholderRect(forBounds bounds: CGRect) -> CGRect { + // N.B. The bounds passed here are not the same as self.bounds + // which is why we don't just use textEntryFrame() + return bounds.inset(by: textEntryFieldInset()) + } + + /// :nodoc: + @objc public override func editingRect(forBounds bounds: CGRect) -> CGRect { + // N.B. The bounds passed here are not the same as self.bounds + // which is why we don't just use textEntryFrame() + return bounds.inset(by: textEntryFieldInset()) + } + + /// :nodoc: + @objc public override func leftViewRect(forBounds bounds: CGRect) -> CGRect { + var leftViewRect = super.leftViewRect(forBounds: bounds) + leftViewRect.origin.x = + leftViewRect.origin.x + STPFloatingPlaceholderTextField.LayoutConstants.horizontalMargin + return leftViewRect + } + + /// :nodoc: + @objc public override func rightViewRect(forBounds bounds: CGRect) -> CGRect { + var rightViewRect = super.rightViewRect(forBounds: bounds) + rightViewRect.origin.x = + rightViewRect.origin.x + - STPFloatingPlaceholderTextField.LayoutConstants.horizontalMargin + return rightViewRect + } + + /// :nodoc: + @objc public override var intrinsicContentSize: CGSize { + let height = UIFontMetrics.default.scaledValue( + for: STPFloatingPlaceholderTextField.LayoutConstants.defaultHeight + ) + let contentPadding = self.contentPadding() + return CGSize( + width: placeholderLabel.intrinsicContentSize.width + contentPadding.left + + contentPadding.right, + height: height + ) + } + + /// :nodoc: + @objc public override func sizeThatFits(_ size: CGSize) -> CGSize { + var size = super.sizeThatFits(size) + size.height = max(size.height, intrinsicContentSize.height) + size.width = max(size.width, intrinsicContentSize.width) + return size + } + + /// :nodoc: + @objc public override func systemLayoutSizeFitting( + _ targetSize: CGSize, + withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, + verticalFittingPriority: UILayoutPriority + ) -> CGSize { + var size = super.systemLayoutSizeFitting( + targetSize, + withHorizontalFittingPriority: horizontalFittingPriority, + verticalFittingPriority: verticalFittingPriority + ) + size.width = max(size.width, intrinsicContentSize.width) + return size + } +} diff --git a/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPFormTextFieldContainer.swift b/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPFormTextFieldContainer.swift new file mode 100644 index 00000000..cd136ee0 --- /dev/null +++ b/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPFormTextFieldContainer.swift @@ -0,0 +1,33 @@ +// +// STPFormTextFieldContainer.swift +// StripePaymentsUI +// +// Created by Cameron Sabol on 3/12/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +/// STPFormTextFieldContainer is a protocol that views can conform to to provide customization properties for the field form views that they contain. +@objc public protocol STPFormTextFieldContainer: NSObjectProtocol { + /// The font used in each child field. Default is `.body`. + dynamic var formFont: UIFont { get set } + /// The text color to be used when entering valid text. Default is `.label` on iOS 13.0 and later and `.darkText` on earlier versions. + dynamic var formTextColor: UIColor { get set } + /// The text color to be used when the user has entered invalid information, + /// such as an invalid card number. + /// Default is `.red`. + dynamic var formTextErrorColor: UIColor { get set } + /// The text placeholder color used in each child field. + /// This will also set the color of the card placeholder icon. + /// Default is `.placeholderText` on iOS 13.0 and `.lightGray` on earlier versions. + dynamic var formPlaceholderColor: UIColor { get set } + /// The cursor color for the field. + /// This is a proxy for the view's tintColor property, exposed for clarity only + /// (in other words, calling setCursorColor is identical to calling setTintColor). + dynamic var formCursorColor: UIColor { get set } + /// The keyboard appearance for the field. + /// Default is `.default`. + dynamic var formKeyboardAppearance: UIKeyboardAppearance { get set } +} diff --git a/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPFormView.swift b/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPFormView.swift new file mode 100644 index 00000000..7e3e51e4 --- /dev/null +++ b/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPFormView.swift @@ -0,0 +1,695 @@ +// +// STPFormView.swift +// StripePaymentsUI +// +// Created by Cameron Sabol on 10/22/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeUICore +import UIKit + +/// Base protocol to support manually backspacing between form inputs and +/// responding to different inputs receiving/losing focus. +protocol STPFormContainer: NSObjectProtocol { + func inputTextFieldDidBackspaceOnEmpty(_ textField: STPInputTextField) + func inputTextFieldWillBecomeFirstResponder(_ textField: STPInputTextField) + func inputTextFieldDidResignFirstResponder(_ textField: STPInputTextField) +} + +/// Internal version of `STPFormViewDelegate` that also includes additional methods for controlling +/// form view interactions. +@_spi(STP) public protocol STPFormViewInternalDelegate: NSObjectProtocol { + func formView(_ form: STPFormView, didChangeToStateComplete complete: Bool) + func formViewWillBecomeFirstResponder(_ form: STPFormView) + func formView(_ form: STPFormView, didTapAccessoryButton button: UIButton) +} + +/// Protocol for observing the state of a specific input field within an `STPFormView`. +protocol STPFormInputValidationObserver: NSObjectProtocol { + func validationDidUpdate( + to state: STPValidatedInputState, + from previousState: STPValidatedInputState, + for unformattedInput: String?, + in input: STPFormInput + ) +} + +/// Protocol for various input types that may be in an `STPFormView`. +protocol STPFormInput where Self: UIView { + + var formContainer: STPFormContainer? { get set } + + var validationState: STPValidatedInputState { get } + var inputValue: String? { get } + + func addObserver(_ validationObserver: STPFormInputValidationObserver) + func removeObserver(_ validationObserver: STPFormInputValidationObserver) + + var wantsAutoFocus: Bool { get } + +} + +/// `STPFormView` is a base class for the Stripe SDK's form input UI. You should use one of the available subclasses +/// (`STPCardFormView`) rather than instantiating an `STPFormView` instance directly. +public class STPFormView: UIView, STPFormInputValidationObserver { + + static let borderlessInset: CGFloat = StackViewWithSeparator.borderlessInset + + let sections: [Section] + let sectionViews: [SectionView] + + let vStack: UIStackView + + static let borderWidth: CGFloat = 1 + static let cornerRadius: CGFloat = 6 + static let interSectionSpacing: CGFloat = 7 + + @_spi(STP) public weak var formViewInternalDelegate: STPFormViewInternalDelegate? + + required init( + sections: [Section] + ) { + self.sections = sections + + vStack = UIStackView() + var sectionViews = [SectionView]() + for section in sections { + let sectionView = SectionView(section: section) + sectionViews.append(sectionView) + sectionView.translatesAutoresizingMaskIntoConstraints = false + vStack.addArrangedSubview(sectionView) + } + + self.sectionViews = sectionViews + super.init(frame: .zero) + + vStack.axis = .vertical + vStack.distribution = .fillProportionally + vStack.spacing = STPFormView.interSectionSpacing + vStack.translatesAutoresizingMaskIntoConstraints = false + + sequentialFields.forEach({ $0.formContainer = self }) + addSubview(vStack) + NSLayoutConstraint.activate([ + vStack.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: vStack.trailingAnchor), + vStack.topAnchor.constraint(equalTo: topAnchor), + bottomAnchor.constraint(equalTo: vStack.bottomAnchor), + ]) + } + + required init?( + coder: NSCoder + ) { + fatalError("init(coder:) has not been implemented") + } + + func shouldAutoAdvance( + for input: STPInputTextField, + with validationState: STPValidatedInputState, + from previousState: STPValidatedInputState + ) -> Bool { + if case .valid = validationState { + return true + } + return false + } + + func sectionView(for input: STPInputTextField) -> SectionView? { + return sectionViews.first { (sectionView) -> Bool in + sectionView.section.contains(input) + } + } + + func set(textField: STPInputTextField, isHidden: Bool, animated: Bool) { + guard isHidden != textField.isHidden, + let rowView = textField.superview as? UIStackView, + let sectionView = sectionView(for: textField) + else { + return + } + var hideContainer = true + for input in rowView.arrangedSubviews { + if input == textField { + if !isHidden { + hideContainer = false + break + } + } else if !input.isHidden { + hideContainer = false + break + } + } + + if animated { + + if hideContainer != rowView.isHidden { + if textField.isHidden { + textField.alpha = 0 + textField.isHidden = isHidden + } else { + textField.alpha = 1 + } + } + + self.setNeedsLayout() + self.layoutIfNeeded() + + rowView.invalidateIntrinsicContentSize() + sectionView.stackView.invalidateIntrinsicContentSize() + UIView.animate(withDuration: 0.2) { + textField.alpha = isHidden ? 0 : 1 + if hideContainer == rowView.isHidden { + textField.isHidden = isHidden + } + rowView.isHidden = hideContainer + + rowView.layoutIfNeeded() + self.setNeedsLayout() + self.layoutIfNeeded() + + } completion: { (_) in + textField.isHidden = isHidden + } + + } else { + textField.isHidden = isHidden + rowView.isHidden = hideContainer + } + } + + // MARK: - UIResponder + /// :nodoc: + @objc + public override var canResignFirstResponder: Bool { + if let currentFirstResponderField = currentFirstResponderField() { + return currentFirstResponderField.canResignFirstResponder + } else { + return true + } + } + + /// :nodoc: + @objc + public override func resignFirstResponder() -> Bool { + let ret = super.resignFirstResponder() + if let currentFirstResponderField = currentFirstResponderField() { + return currentFirstResponderField.resignFirstResponder() + } else { + return ret + } + } + + /// :nodoc: + @objc + public override var isFirstResponder: Bool { + return super.isFirstResponder || currentFirstResponderField()?.isFirstResponder ?? false + } + + /// :nodoc: + @objc + public override var canBecomeFirstResponder: Bool { + return sequentialFields.count > 0 + } + + /// :nodoc: + @objc + public override func becomeFirstResponder() -> Bool { + // grab the next first responder before calling super (which will cause any current first responder to resign) + var firstResponder: STPFormInput? + if currentFirstResponderField() != nil { + // we are already first responder, move to next field sequentially + firstResponder = nextInSequenceFirstResponderField() ?? sequentialFields.first + } else { + // Default to the first nonvalid subfield when becoming first responder + firstResponder = firstNonValidSubField() + } + + self.formViewInternalDelegate?.formViewWillBecomeFirstResponder(self) + let ret = super.becomeFirstResponder() + if let firstResponder = firstResponder { + return firstResponder.becomeFirstResponder() + } else { + return ret + } + } + + /// :nodoc: + @objc + public override var isUserInteractionEnabled: Bool { + didSet { + for sectionView in sectionViews { + sectionView.isUserInteractionEnabled = isUserInteractionEnabled + } + } + } + + // MARK: - Helpers + + var sequentialFields: [STPFormInput] { + return sections.reduce(into: [STPFormInput]()) { (result, section) in + result.append( + contentsOf: section.rows.reduce(into: [STPInputTextField]()) { (_, row) in + for input in row { + if !input.isHidden { + result.append(input) + } + } + } + ) + } + } + + func currentFirstResponderField() -> STPFormInput? { + for field in sequentialFields { + if field.isFirstResponder { + return field + } + } + return nil + } + + func previousField(_ wantsAutoFocusOnly: Bool = false) -> STPFormInput? { + if let currentFirstResponder = currentFirstResponderField() { + for (index, field) in sequentialFields.enumerated() { + if field == currentFirstResponder { + var i = index - 1 + while i >= 0 { + let input = sequentialFields[i] + if !wantsAutoFocusOnly || (wantsAutoFocusOnly && input.wantsAutoFocus) { + return input + } + i -= 1 + } + return nil + } + } + } + return nil + } + + @_spi(STP) public func nextFirstResponderFieldBecomeFirstResponder() { + nextFirstResponderField()?.becomeFirstResponder() + } + + func nextFirstResponderField(_ wantsAutoFocusOnly: Bool = false) -> STPFormInput? { + if let nextField = nextInSequenceFirstResponderField(wantsAutoFocusOnly) { + return nextField + } else { + if currentFirstResponderField() == nil { + // if we don't currently have a first responder, consider the first non-valid field the next one + return firstNonValidSubField(wantsAutoFocusOnly) + } else { + return lastSubField(wantsAutoFocusOnly) + } + } + } + + func nextInSequenceFirstResponderField(_ wantsAutoFocusOnly: Bool = false) -> STPFormInput? { + if let currentFirstResponder = currentFirstResponderField() { + for (index, field) in sequentialFields.enumerated() { + if field == currentFirstResponder { + var i = index + 1 + while i < sequentialFields.count { + let input = sequentialFields[i] + if !wantsAutoFocusOnly || (wantsAutoFocusOnly && input.wantsAutoFocus) { + return input + } + i += 1 + } + return nil + } + } + } + return nil + } + + func firstNonValidSubField(_ wantsAutoFocusOnly: Bool = false) -> STPFormInput? { + for field in sequentialFields { + if case .valid = field.validationState { + // this field is valid + } else { + if !wantsAutoFocusOnly || (wantsAutoFocusOnly && field.wantsAutoFocus) { + return field + } + } + } + return nil + } + + func lastSubField(_ wantsAutoFocusOnly: Bool = false) -> STPFormInput? { + for field in sequentialFields.reversed() { + if !wantsAutoFocusOnly || (wantsAutoFocusOnly && field.wantsAutoFocus) { + return field + } + } + return nil + } + + func configureFooter(in sectionView: STPFormView.SectionView) { + let fields = sectionView.sequentialFields + let invalidFields = fields.filter { (field) -> Bool in + if case .invalid = field.validationState { + return true + } else { + return false + } + } + if let firstInvalid = invalidFields.first, + case .invalid(let errorMessage) = firstInvalid.validationState, + let nonNilErrorMessage = errorMessage + { + sectionView.footerTextColor = InputFormColors.errorColor + sectionView.footerText = nonNilErrorMessage + return + } + + let incompleteFields = fields.filter { (field) -> Bool in + if case .incomplete = field.validationState, !field.isFirstResponder, + !(field.inputValue?.isEmpty ?? true) + { + return true + } else { + return false + } + } + let incompleteFieldsWithMessages = incompleteFields.filter { (field) -> Bool in + if case .incomplete(let description) = field.validationState, description != nil { + return true + } else { + return false + } + } + + if let firstIncomplete = incompleteFieldsWithMessages.first, + case .incomplete(let description) = firstIncomplete.validationState, + let nonNilDescription = description + { + sectionView.footerTextColor = InputFormColors.errorColor + sectionView.footerText = nonNilDescription + return + } + + if let firstCompleteWithMessageField = fields.first(where: { (field) -> Bool in + if case .valid(let message) = field.validationState, message != nil { + return true + } else { + return false + } + }), + case .valid(let message) = firstCompleteWithMessageField.validationState, + let nonNilMessage = message + { + sectionView.footerTextColor = .label + sectionView.footerText = nonNilMessage + return + } + + sectionView.footerTextColor = .label + sectionView.footerText = nil + } + + // MARK: - STPInputTextFieldValidationObserver + + func validationDidUpdate( + to state: STPValidatedInputState, + from previousState: STPValidatedInputState, + for unformattedInput: String?, + in input: STPFormInput + ) { + guard let textField = input as? STPInputTextField, + let sectionView = sectionView(for: textField) + else { + assertionFailure("Should not receive updates for uncontained inputs") + return + } + + let fieldsInSection = sectionView.sequentialFields + + if fieldsInSection.first(where: { + if case .invalid = $0.validationState { + return true + } else { + return false + } + }) != nil { + sectionView.separatorColor = InputFormColors.errorColor + } else { + sectionView.separatorColor = InputFormColors.outlineColor + } + + configureFooter(in: sectionView) + + if textField == currentFirstResponderField() + && shouldAutoAdvance(for: textField, with: state, from: previousState) + { + if let nextField = nextFirstResponderField(true) { + _ = nextField.becomeFirstResponder() + UIAccessibility.post(notification: .screenChanged, argument: nextField) + } + } + } +} + +/// :nodoc: +extension STPFormView: STPFormContainer { + func inputTextFieldDidBackspaceOnEmpty(_ textField: STPInputTextField) { + guard textField == currentFirstResponderField() else { + return + } + + let previous = previousField(true) + _ = previous?.becomeFirstResponder() + UIAccessibility.post(notification: .screenChanged, argument: previous) + if let previousTextField = previous as? STPInputTextField, + previousTextField.hasText + { + previousTextField.deleteBackward() + } + } + + func inputTextFieldWillBecomeFirstResponder(_ textField: STPInputTextField) { + self.formViewInternalDelegate?.formViewWillBecomeFirstResponder(self) + + // Always update on become firstResponder in case some fields + // were hidden or unhidden + if textField == lastSubField() { + textField.returnKeyType = .done + } else { + // Note that observed on iOS 14 that setting .next here + // sometimes messes up the keyboardType for asciiCapableNumberPad + textField.returnKeyType = .default + } + + if let sectionView = sectionView(for: textField) { + configureFooter(in: sectionView) + } + } + + func inputTextFieldDidResignFirstResponder(_ textField: STPInputTextField) { + if let sectionView = sectionView(for: textField) { + configureFooter(in: sectionView) + } + } +} + +/// Internal types +extension STPFormView { + struct Section { + let rows: [[STPFormInput]] + let title: String? + let accessoryButton: UIButton? + + func contains(_ input: STPInputTextField) -> Bool { + for row in rows.compactMap({ $0 as? [STPInputTextField] }) { + if row.contains(input) { + return true + } + } + return false + } + } + + class SectionView: UIView { + let section: Section + + let stackView: StackViewWithSeparator = StackViewWithSeparator() + + static let titleVerticalMargin: CGFloat = 4 + + let footerLabel = UILabel() + + var footerTextColor: UIColor { + get { + return footerLabel.textColor + } + set { + footerLabel.textColor = newValue + } + } + + var footerText: String? { + get { + return footerLabel.text + } + set { + if let newValue = newValue, !newValue.isEmpty { + footerLabel.text = newValue + } else { + // We don't want this to ever be empty for sizing reasons + footerLabel.text = " " + } + } + } + + var insetFooterLabel: Bool = false { + didSet { + footerLabelLeadingConstraint.constant = + insetFooterLabel ? STPFormView.borderlessInset : 0 + } + } + + lazy var footerLabelLeadingConstraint: NSLayoutConstraint = { + return footerLabel.leadingAnchor.constraint(equalTo: leadingAnchor) + }() + + @objc + override var isUserInteractionEnabled: Bool { + didSet { + stackView.isUserInteractionEnabled = isUserInteractionEnabled + for field in sequentialFields { + field.isUserInteractionEnabled = isUserInteractionEnabled + } + } + } + + required init( + section: Section + ) { + self.section = section + let rows = section.rows + + let rowViews = rows.map { (row) -> StackViewWithSeparator in + let stackView = StackViewWithSeparator(arrangedSubviews: row) + stackView.axis = .horizontal + stackView.distribution = .fillEqually + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = STPFormView.borderWidth + stackView.separatorColor = InputFormColors.outlineColor + return stackView + } + + super.init(frame: .zero) + for rowView in rowViews { + stackView.addArrangedSubview(rowView) + } + + stackView.axis = .vertical + stackView.distribution = .fillEqually + stackView.spacing = STPFormView.borderWidth + stackView.separatorColor = InputFormColors.outlineColor + + stackView.drawBorder = true + stackView.borderCornerRadius = STPFormView.cornerRadius + + stackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(stackView) + var constraints: [NSLayoutConstraint] = [ + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: stackView.trailingAnchor), + ] + + if let title = section.title { + let titleLabel = UILabel() + titleLabel.text = title + let fontMetrics = UIFontMetrics(forTextStyle: .body) + titleLabel.font = fontMetrics.scaledFont( + for: UIFont.systemFont(ofSize: 13, weight: .semibold) + ) + titleLabel.textColor = .secondaryLabel + titleLabel.accessibilityTraits = [.header] + + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.setContentHuggingPriority(.required, for: .vertical) + + var arrangedSubviews: [UIView] = [titleLabel] + + if let button = section.accessoryButton { + button.setContentHuggingPriority(.defaultLow + 1, for: .horizontal) + titleLabel.setContentCompressionResistancePriority( + .defaultHigh + 1, + for: .horizontal + ) + button.translatesAutoresizingMaskIntoConstraints = false + arrangedSubviews.append(button) + } + + let headerView = UIStackView(arrangedSubviews: arrangedSubviews) + headerView.translatesAutoresizingMaskIntoConstraints = false + addSubview(headerView) + constraints.append(contentsOf: [ + headerView.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: headerView.trailingAnchor), + headerView.topAnchor.constraint(equalTo: topAnchor), + stackView.topAnchor.constraint( + equalTo: headerView.bottomAnchor, + constant: SectionView.titleVerticalMargin + ), + ]) + } else { + constraints.append(stackView.topAnchor.constraint(equalTo: topAnchor)) + } + + footerLabel.translatesAutoresizingMaskIntoConstraints = false + footerLabel.font = .preferredFont(forTextStyle: .caption1) + addSubview(footerLabel) + footerText = " " + constraints.append(contentsOf: [ + footerLabelLeadingConstraint, + trailingAnchor.constraint(equalTo: footerLabel.trailingAnchor), + footerLabel.topAnchor.constraint( + equalTo: stackView.bottomAnchor, + constant: SectionView.titleVerticalMargin + ), + bottomAnchor.constraint(equalTo: footerLabel.bottomAnchor), + ]) + + // the initial layout of a SectionView will log constraint errors if it has a row with multiple + // inputs because the non-zero spacing conflicts with the default 0 horizontal size. Mark the + // constraints as priority required-1 to avoid those unhelpful logs + constraints.forEach({ + $0.priority = UILayoutPriority(rawValue: UILayoutPriority.required.rawValue - 1) + }) + NSLayoutConstraint.activate(constraints) + setContentHuggingPriority(.required, for: .vertical) + } + + required init( + coder: NSCoder + ) { + fatalError("init(coder:) has not been implemented") + } + + var separatorColor: UIColor = InputFormColors.outlineColor { + didSet { + stackView.separatorColor = separatorColor + for rowView in stackView.arrangedSubviews.compactMap({ + $0 as? StackViewWithSeparator + }) { + rowView.separatorColor = separatorColor + } + } + } + + var sequentialFields: [STPFormInput] { + return section.rows.reduce(into: [STPFormInput]()) { (result, row) in + for input in row { + if !input.isHidden { + result.append(input) + } + } + } + } + } +} diff --git a/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPMultiFormTextField.swift b/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPMultiFormTextField.swift new file mode 100644 index 00000000..be7cc5c7 --- /dev/null +++ b/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPMultiFormTextField.swift @@ -0,0 +1,337 @@ +// +// STPMultiFormTextField.swift +// StripePaymentsUI +// +// Created by Cameron Sabol on 3/4/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +import UIKit + +/// STPMultiFormFieldDelegate provides methods for a delegate to respond to editing and text changes. +@objc protocol STPMultiFormFieldDelegate: NSObjectProtocol { + /// Called when the text field becomes the first responder. + func formTextFieldDidStartEditing( + _ formTextField: STPFormTextField, + inMultiForm multiFormField: STPMultiFormTextField + ) + /// Called when the text field resigns from being the first responder. + func formTextFieldDidEndEditing( + _ formTextField: STPFormTextField, + inMultiForm multiFormField: STPMultiFormTextField + ) + /// Called when the text within the form text field changes. + func formTextFieldTextDidChange( + _ formTextField: STPFormTextField, + inMultiForm multiFormField: STPMultiFormTextField + ) + /// Called to get any additional formatting from the delegate for the string input to the form text field. + func modifiedIncomingTextChange( + _ input: NSAttributedString, + for formTextField: STPFormTextField, + inMultiForm multiFormField: STPMultiFormTextField + ) -> NSAttributedString + /// Delegates should implement this method so that STPMultiFormTextField when the contents of the form text field renders it complete. + func isFormFieldComplete( + _ formTextField: STPFormTextField, + inMultiForm multiFormField: STPMultiFormTextField + ) -> Bool +} + +/// STPMultiFormTextField is a lightweight UIView that wraps a collection of STPFormTextFields and can automatically move to the next form field when one is completed. +public class STPMultiFormTextField: UIView, STPFormTextFieldContainer, UITextFieldDelegate, + STPFormTextFieldDelegate +{ + /// :nodoc: + @objc + public func textField( + _ textField: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + if let textField = textField as? STPFormTextField, + let delegateProxy = textField.delegateProxy + { + return delegateProxy.textField( + textField, + shouldChangeCharactersIn: range, + replacementString: string + ) + } + return true + } + + /// The collection of STPFormTextFields that this instance manages. + + private var _formTextFields: [STPFormTextField]? + var formTextFields: [STPFormTextField]? { + get { + _formTextFields + } + set(formTextFields) { + _formTextFields = formTextFields + for field in formTextFields ?? [] { + field.formDelegate = self + } + } + } + /// The STPMultiFormTextField's delegate. + @objc weak var multiFormFieldDelegate: STPMultiFormFieldDelegate? + + /// Calling this method will make the next incomplete STPFormTextField in `formTextFields` become the first responder. + /// If all of the form text fields are already complete, then the last field in `formTextFields` will become the first responder. + @objc public func focusNextForm() { + let nextField = _nextFirstResponderField() + if nextField == _currentFirstResponderField() { + // If this doesn't actually advance us, resign first responder + nextField?.resignFirstResponder() + } else { + nextField?.becomeFirstResponder() + } + } + + // MARK: - UIResponder + /// :nodoc: + @objc public override var canResignFirstResponder: Bool { + if _currentFirstResponderField() != nil { + return _currentFirstResponderField()?.canResignFirstResponder ?? false + } else { + return true + } + } + + /// :nodoc: + @objc + public override func resignFirstResponder() -> Bool { + super.resignFirstResponder() + if _currentFirstResponderField() != nil { + return _currentFirstResponderField()?.resignFirstResponder() ?? false + } else { + return true + } + } + + /// :nodoc: + @objc public override var isFirstResponder: Bool { + return super.isFirstResponder || _currentFirstResponderField()?.isFirstResponder ?? false + } + + /// :nodoc: + @objc public override var canBecomeFirstResponder: Bool { + return (formTextFields?.count ?? 0) > 0 + } + + /// :nodoc: + @objc + public override func becomeFirstResponder() -> Bool { + // grab the next first responder before calling super (which will cause any current first responder to resign) + var firstResponder: STPFormTextField? + if _currentFirstResponderField() != nil { + // we are already first responder, move to next field sequentially + firstResponder = _nextInSequenceFirstResponderField() ?? formTextFields?.first + } else { + // Default to the first invalid subfield when becoming first responder + firstResponder = _firstInvalidSubField() + } + + super.becomeFirstResponder() + return firstResponder?.becomeFirstResponder() ?? false + } + + // MARK: - UITextFieldDelegate + /// :nodoc: + @objc + public func textFieldDidEndEditing(_ textField: UITextField) { + let formTextField = (textField is STPFormTextField) ? textField as? STPFormTextField : nil + textField.layoutIfNeeded() + + if let formTextField = formTextField { + multiFormFieldDelegate?.formTextFieldDidEndEditing(formTextField, inMultiForm: self) + } + } + + /// :nodoc: + @objc + public func textFieldDidBeginEditing(_ textField: UITextField) { + let formTextField = (textField is STPFormTextField) ? textField as? STPFormTextField : nil + if let formTextField = formTextField { + multiFormFieldDelegate?.formTextFieldDidStartEditing(formTextField, inMultiForm: self) + } + } + + /// :nodoc: + @objc + public func textFieldShouldReturn(_ textField: UITextField) -> Bool { + let nextInSequence = _nextInSequenceFirstResponderField() + if let nextInSequence = nextInSequence { + nextInSequence.becomeFirstResponder() + return false + } else { + textField.resignFirstResponder() + return true + } + } + + // MARK: - STPFormTextFieldDelegate + @objc func formTextFieldDidBackspace(onEmpty formTextField: STPFormTextField) { + let previous = _previousField() + previous?.becomeFirstResponder() + UIAccessibility.post(notification: .screenChanged, argument: nil) + if previous?.hasText ?? false { + previous?.deleteBackward() + } + } + + @objc func formTextField( + _ formTextField: STPFormTextField, + modifyIncomingTextChange input: NSAttributedString + ) -> NSAttributedString { + return + (multiFormFieldDelegate?.modifiedIncomingTextChange( + input, + for: formTextField, + inMultiForm: self + ))! + } + + @objc func formTextFieldTextDidChange(_ formTextField: STPFormTextField) { + multiFormFieldDelegate?.formTextFieldTextDidChange( + formTextField, + inMultiForm: self + ) + } + + // MARK: - Helpers + func _currentFirstResponderField() -> STPFormTextField? { + for textField in formTextFields ?? [] { + if textField.isFirstResponder { + return textField + } + } + return nil + } + + func _previousField() -> STPFormTextField? { + let currentSubResponder = _currentFirstResponderField() + if let currentSubResponder = currentSubResponder { + let index = formTextFields?.firstIndex(of: currentSubResponder) ?? NSNotFound + if index != NSNotFound && index > 0 { + return formTextFields?[index - 1] + } + } + return nil + } + + func _nextFirstResponderField() -> STPFormTextField? { + let nextField = _nextInSequenceFirstResponderField() + if let nextField = nextField { + return nextField + } else { + if _currentFirstResponderField() == nil { + // if we don't currently have a first responder, consider the first invalid field the next one + return _firstInvalidSubField() + } else { + return _lastSubField() + } + } + } + + func _nextInSequenceFirstResponderField() -> STPFormTextField? { + let currentFirstResponder = _currentFirstResponderField() + if let currentFirstResponder = currentFirstResponder { + let index = formTextFields?.firstIndex(of: currentFirstResponder) ?? NSNotFound + if index != NSNotFound { + let nextField = + formTextFields!.stp_boundSafeObject(at: index + 1) + if let nextField = nextField { + return nextField + } + } + } + + return nil + } + + func _firstInvalidSubField() -> STPFormTextField? { + for textField in formTextFields ?? [] { + if !(multiFormFieldDelegate?.isFormFieldComplete(textField, inMultiForm: self) ?? false) + { + return textField + } + } + return nil + } + + func _lastSubField() -> STPFormTextField { + return (formTextFields?.last)! + } + + // MARK: - STPFormTextFieldContainer + @objc public var formFont: UIFont = UIFont.preferredFont(forTextStyle: .body) { + didSet { + if formFont != oldValue { + for textField in formTextFields ?? [] { + textField.font = formFont + } + } + } + } + + @objc public var formTextColor: UIColor = .label + { + didSet { + if oldValue != formTextColor { + for textField in formTextFields ?? [] { + textField.defaultColor = formTextColor + } + } + } + } + + @objc public var formTextErrorColor: UIColor = .systemRed + { + didSet { + if oldValue != formTextErrorColor { + for textField in formTextFields ?? [] { + textField.errorColor = formTextErrorColor + } + } + } + } + + @objc public var formPlaceholderColor: UIColor = .placeholderText + { + didSet { + if oldValue != formPlaceholderColor { + for textField in formTextFields ?? [] { + textField.placeholderColor = formPlaceholderColor + } + } + } + } + + @objc public var formCursorColor: UIColor { + get { + self.tintColor + } + set { + if newValue != tintColor { + tintColor = newValue + for textField in formTextFields ?? [] { + textField.tintColor = tintColor + } + } + } + } + + @objc public var formKeyboardAppearance: UIKeyboardAppearance = .default { + didSet { + if oldValue != formKeyboardAppearance { + for textField in formTextFields ?? [] { + textField.keyboardAppearance = formKeyboardAppearance + } + } + } + } +} diff --git a/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPPaymentCardTextField+SwiftUI.swift b/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPPaymentCardTextField+SwiftUI.swift new file mode 100644 index 00000000..f7e2fca5 --- /dev/null +++ b/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPPaymentCardTextField+SwiftUI.swift @@ -0,0 +1,66 @@ +// +// STPPaymentCardTextField+SwiftUI.swift +// StripePaymentsUI +// +// Created by David Estes on 2/1/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripePayments +import SwiftUI + +extension STPPaymentCardTextField { + + /// A SwiftUI representation of an STPPaymentCardTextField. + public struct Representable: UIViewRepresentable { + @Binding var paymentMethodParams: STPPaymentMethodParams? + + /// Initialize a SwiftUI representation of an STPPaymentCardTextField. + /// - Parameter paymentMethodParams: A binding to the payment card text field's contents. + /// The STPPaymentMethodParams will be `nil` if the payment card text field's contents are invalid. + public init( + paymentMethodParams: Binding + ) { + _paymentMethodParams = paymentMethodParams + } + + public func makeCoordinator() -> Coordinator { + return Coordinator(parent: self) + } + + public func makeUIView(context: Context) -> STPPaymentCardTextField { + let paymentCardField = STPPaymentCardTextField() + if let paymentMethodParams = paymentMethodParams { + paymentCardField.paymentMethodParams = paymentMethodParams + } + paymentCardField.delegate = context.coordinator + paymentCardField.setContentHuggingPriority(.required, for: .vertical) + + return paymentCardField + } + + public func updateUIView(_ paymentCardField: STPPaymentCardTextField, context: Context) { + if let paymentMethodParams = paymentMethodParams { + paymentCardField.paymentMethodParams = paymentMethodParams + } + } + + public class Coordinator: NSObject, STPPaymentCardTextFieldDelegate { + var parent: Representable + init( + parent: Representable + ) { + self.parent = parent + } + + public func paymentCardTextFieldDidChange(_ cardField: STPPaymentCardTextField) { + let paymentMethodParams = cardField.paymentMethodParams + if !cardField.isValid { + parent.paymentMethodParams = nil + return + } + parent.paymentMethodParams = paymentMethodParams + } + } + } +} diff --git a/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPPaymentCardTextField.swift b/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPPaymentCardTextField.swift new file mode 100644 index 00000000..ec0c3449 --- /dev/null +++ b/StripePaymentsUI/StripePaymentsUI/Source/UI Components/STPPaymentCardTextField.swift @@ -0,0 +1,2340 @@ +// +// STPPaymentCardTextField.swift +// StripePaymentsUI +// +// Created by Jack Flintermann on 7/16/15. +// Copyright (c) 2015 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) import StripePayments +@_spi(STP) import StripeUICore +import UIKit + +/// STPPaymentCardTextField is a text field with similar properties to UITextField, +/// but specialized for credit/debit card information. It manages +/// multiple UITextFields under the hood to collect this information. It's +/// designed to fit on a single line, and from a design perspective can be used +/// anywhere a UITextField would be appropriate. +@IBDesignable +@objc(STPPaymentCardTextField) +open class STPPaymentCardTextField: UIControl, UIKeyInput, STPFormTextFieldDelegate { + /// :nodoc: + @objc + open func textField( + _ textField: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + if let textField = textField as? STPFormTextField, + let delegateProxy = textField.delegateProxy + { + return delegateProxy.textField( + textField, + shouldChangeCharactersIn: range, + replacementString: string + ) + } + return true + } + + private var metadataLoadingIndicator: STPCardLoadingIndicator? + + /// - seealso: STPPaymentCardTextFieldDelegate + @IBOutlet open weak var delegate: STPPaymentCardTextFieldDelegate? + + /// The font used in each child field. Default is `UIFont.systemFont(ofSize:18)`. + @objc open var font: UIFont = { + return UIFontMetrics.default.scaledFont(for: UIFont.systemFont(ofSize: 18)) + }() + { + didSet { + for field in allFields { + field.font = font + } + + sizingField.font = font + clearSizingCache() + + setNeedsLayout() + } + } + + /// The text color to be used when entering valid text. Default is `.label`. + @objc open var textColor: UIColor = .label + { + didSet { + for field in allFields { + field.defaultColor = textColor + } + } + } + + /// The text color to be used when the user has entered invalid information, + /// such as an invalid card number. + /// Default is `.red`. + @objc open var textErrorColor: UIColor = .systemRed + { + didSet { + for field in allFields { + field.errorColor = textErrorColor + } + } + } + + /// The text placeholder color used in each child field. + /// This will also set the color of the card placeholder icon. + /// Default is `.systemGray2`. + @objc open var placeholderColor: UIColor = placeholderGrayColor { + didSet { + brandImageView.tintColor = placeholderColor + + for field in allFields { + field.placeholderColor = placeholderColor + } + } + } + + @IBInspectable private var _numberPlaceholder: String? + /// The placeholder for the card number field. + /// Default is "4242424242424242". + /// If this is set to something that resembles a card number, it will automatically + /// format it as such (in other words, you don't need to add spaces to this string). + @IBInspectable open var numberPlaceholder: String? { + get { + _numberPlaceholder + } + set(numberPlaceholder) { + _numberPlaceholder = numberPlaceholder + numberField.placeholder = _numberPlaceholder + } + } + + @IBInspectable private var _expirationPlaceholder: String? + /// The placeholder for the expiration field. Defaults to "MM/YY". + @IBInspectable open var expirationPlaceholder: String? { + get { + _expirationPlaceholder + } + set(expirationPlaceholder) { + _expirationPlaceholder = expirationPlaceholder + expirationField.placeholder = _expirationPlaceholder + } + } + + @IBInspectable private var _cvcPlaceholder: String? + /// The placeholder for the cvc field. Defaults to "CVC". + @IBInspectable open var cvcPlaceholder: String? { + get { + _cvcPlaceholder + } + set(cvcPlaceholder) { + _cvcPlaceholder = cvcPlaceholder + cvcField.placeholder = _cvcPlaceholder + } + } + + @IBInspectable private var _postalCodePlaceholder: String? + /// The placeholder for the postal code field. Defaults to "ZIP" for United States + /// or @"Postal" for all other country codes. + @IBInspectable open var postalCodePlaceholder: String? { + get { + _postalCodePlaceholder + } + set(postalCodePlaceholder) { + _postalCodePlaceholder = postalCodePlaceholder + updatePostalFieldPlaceholder() + } + } + /// The cursor color for the field. + /// This is a proxy for the view's tintColor property, exposed for clarity only + /// (in other words, calling setCursorColor is identical to calling setTintColor). + @objc open var cursorColor: UIColor { + get { + tintColor + } + set { + self.tintColor = newValue + } + } + + var _borderColor: UIColor? = placeholderGrayColor + /// The border color for the field. + /// Can be nil (in which case no border will be drawn). + /// Default is .systemGray2. + @objc open var borderColor: UIColor? { + get { + _borderColor + } + set { + _borderColor = newValue + if let borderColor = newValue { + self.layer.borderColor = (borderColor.copy() as! UIColor).cgColor + } else { + self.layer.borderColor = UIColor.clear.cgColor + } + } + } + + var _borderWidth: CGFloat = 1.0 + /// The width of the field's border. + /// Default is 1.0. + @objc open var borderWidth: CGFloat { + get { + _borderWidth + } + set { + _borderWidth = newValue + layer.borderWidth = borderWidth + } + } + + var _cornerRadius: CGFloat = 5.0 + /// The corner radius for the field's border. + /// Default is 5.0. + @objc open var cornerRadius: CGFloat { + get { + _cornerRadius + } + set { + _cornerRadius = cornerRadius + layer.cornerRadius = newValue + } + } + + /// The keyboard appearance for the field. + /// Default is UIKeyboardAppearanceDefault. + @objc open var keyboardAppearance: UIKeyboardAppearance = .default { + didSet { + for field in allFields { + field.keyboardAppearance = keyboardAppearance + } + } + } + + private var _inputView: UIView? + /// This behaves identically to setting the inputView for each child text field. + @objc open override var inputView: UIView? { + get { + _inputView + } + set(inputView) { + _inputView = inputView + + for field in allFields { + field.inputView = inputView + } + } + } + + private var _inputAccessoryView: UIView? + /// This behaves identically to setting the inputAccessoryView for each child text field. + @objc open override var inputAccessoryView: UIView? { + get { + _inputAccessoryView + } + set(inputAccessoryView) { + _inputAccessoryView = inputAccessoryView + + for field in allFields { + field.inputAccessoryView = inputAccessoryView + } + } + } + /// The curent brand image displayed in the receiver. + @objc open private(set) var brandImage: UIImage? + /// Whether or not the form currently contains a valid card number, + /// expiration date, CVC, and postal code (if required). + /// - seealso: STPCardValidator + + @objc dynamic open var isValid: Bool { + return viewModel.isValid + } + /// Enable/disable selecting or editing the field. Useful when submitting card details to Stripe. + + @objc open override var isEnabled: Bool { + get { + super.isEnabled + } + set(enabled) { + super.isEnabled = enabled + for textField in allFields { + textField.isEnabled = enabled + } + } + } + /// The current card number displayed by the field. + /// May or may not be valid, unless `isValid` is true, in which case it is guaranteed + /// to be valid. + @objc open var cardNumber: String? { + return viewModel.cardNumber + } + /// The current expiration month displayed by the field (1 = January, etc). + /// May or may not be valid, unless `isValid` is true, in which case it is + /// guaranteed to be valid. + @objc open var expirationMonth: Int { + if let monthString = viewModel.expirationMonth, let month = Int(monthString) { + return month + } + return 0 + } + /// The current expiration month displayed by the field, as a string. T + /// This may or may not be a valid entry (i.e. "0") unless `isValid` is true. + /// It may be also 0-prefixed (i.e. "01" for January). + @objc open var formattedExpirationMonth: String? { + return viewModel.expirationMonth + } + /// The current expiration year displayed by the field, modulo 100 + /// (e.g. the year 2015 will be represented as 15). + /// May or may not be valid, unless `isValid` is true, in which case it is + /// guaranteed to be valid. + + @objc open var expirationYear: Int { + if let yearString = viewModel.expirationYear, let year = Int(yearString) { + return year + } + return 0 + } + /// The current expiration year displayed by the field, as a string. + /// This is a 2-digit year (i.e. "15"), and may or may not be a valid entry + /// unless `isValid` is true. + + @objc open var formattedExpirationYear: String? { + return viewModel.expirationYear + } + /// The current card CVC displayed by the field. + /// May or may not be valid, unless `isValid` is true, in which case it + /// is guaranteed to be valid. + + @objc open var cvc: String? { + return viewModel.cvc + } + + /// The current card ZIP or postal code displayed by the field. + @objc open var postalCode: String? { + get { + if postalCodeEntryEnabled { + return viewModel.postalCode + } else { + return nil + } + } + set { + if postalCodeEntryEnabled { + if newValue != postalCode { + setText(newValue, inField: .postalCode) + } + } + } + } + /// Controls if a postal code entry field can be displayed to the user. + /// Default is YES. + /// If YES, the type of code entry shown is controlled by the set `countryCode` + /// value. Some country codes may result in no postal code entry being shown if + /// those countries do not commonly use postal codes. + /// If NO, no postal code entry will ever be displayed. + @objc open var postalCodeEntryEnabled: Bool { + get { + return viewModel.postalCodeRequired + } + set(postalCodeEntryEnabled) { + viewModel.postalCodeRequested = postalCodeEntryEnabled + } + } + /// The two-letter ISO country code that corresponds to the user's billing address. + /// If `postalCodeEntryEnabled` is YES, this controls which type of entry is allowed. + /// If `postalCodeEntryEnabled` is NO, this property currently has no effect. + /// If set to nil and postal code entry is enabled, the country from the user's current + /// locale will be filled in. Otherwise the specific country code set will be used. + /// By default this will fetch the user's current country code from NSLocale. + + @objc open var countryCode: String? { + get { + return viewModel.postalCodeCountryCode + } + set(cCode) { + if viewModel.postalCodeCountryCode == cCode { + return + } + let countryCode = (cCode ?? Locale.autoupdatingCurrent.regionCode) + viewModel.postalCodeCountryCode = countryCode + updatePostalFieldPlaceholder() + + // This will revalidate and reformat + setText(postalCode, inField: .postalCode) + } + } + /// Convenience property for creating an `STPPaymentMethodCardParams` from the currently entered information + /// or programmatically setting the field's contents. For example, if you're using another library + /// to scan your user's credit card with a camera, you can assemble that data into an `STPPaymentMethodCardParams` + /// object and set this property to that object to prefill the fields you've collected. + /// Accessing this property returns a *copied* `cardParams`. The only way to change properties in this + /// object is to make changes to a `STPPaymentMethodCardParams` you own (retrieved from this text field if desired), + /// and then set this property to the new value. + /// + /// - Warning: Deprecated. Use `.paymentMethodParams` instead. If you must access the STPPaymentMethodCardParams, use `.paymentMethodParams.card`. + @available( + *, + deprecated, + message: + "Use .paymentMethodParams instead. If you must access the STPPaymentMethodCardParams, use .paymentMethodParams.card." + ) + @objc open var cardParams: STPPaymentMethodCardParams { + get { + // `card` will always exist + return paymentMethodParams.card! + } + set { + paymentMethodParams = STPPaymentMethodParams( + card: newValue, + billingDetails: nil, + metadata: nil + ) + } + } + + /// Convenience property for creating an `STPPaymentMethodParams` from the currently entered information + /// or programmatically setting the field's contents. For example, if you're using another library + /// to scan your user's credit card with a camera, you can assemble that data into an `STPPaymentMethodParams` + /// object and set this property to that object to prefill the fields you've collected. + /// Accessing this property returns a *copied* `paymentMethodParams`. The only way to change properties in this + /// object is to make changes to a `STPPaymentMethodParams` you own (retrieved from this text field if desired), + /// and then set this property to the new value. + @objc open var paymentMethodParams: STPPaymentMethodParams { + get { + let newParams = internalCardParams + newParams.number = cardNumber + if let monthString = viewModel.expirationMonth, let month = Int(monthString) { + newParams.expMonth = NSNumber(value: month) + } + if let yearString = viewModel.expirationYear, let year = Int(yearString) { + newParams.expYear = NSNumber(value: year) + } + newParams.cvc = cvc + internalCardParams = newParams + let cardToReturn = newParams.copy() as! STPPaymentMethodCardParams + var billingDetails = internalBillingDetails?.copy() as? STPPaymentMethodBillingDetails + if let postalCode = self.postalCode, !postalCode.isEmpty { + // If we don't have an internal billing details, create a new one to populate the postal code + billingDetails = billingDetails ?? STPPaymentMethodBillingDetails() + let address = STPPaymentMethodAddress() + address.postalCode = postalCode + address.country = countryCode ?? Locale.autoupdatingCurrent.regionCode + billingDetails!.address = address // billingDetails will always be non-nil + } + return STPPaymentMethodParams( + card: cardToReturn, + billingDetails: billingDetails, + metadata: internalMetadata + ) + } + set(callersCardParams) { + guard case .card = callersCardParams.type, + callersCardParams.card != nil + else { + assertionFailure("\(type(of: self)) only supports Card STPPaymentMethodParams") + return + } + + // Always set the metadata + internalMetadata = callersCardParams.metadata + + let currentPaymentMethodParams = self.paymentMethodParams + if (callersCardParams.card ?? STPPaymentMethodCardParams()).isEqual( + currentPaymentMethodParams.card + ) && callersCardParams.billingDetails == currentPaymentMethodParams.billingDetails { + // These are identical card params: Don't take any action. + return + } + // Due to the way this class is written, programmatically setting field text + // behaves identically to user entering text (and will have the same forwarding + // on to next responder logic). + // + // We have some custom logic here in the main accesible programmatic setter + // to dance around this a bit. First we save what is the current responder + // at the time this method was called. Later logic after text setting should be: + // 1. If we were not first responder, we should still not be first responder + // (but layout might need updating depending on PAN validity) + // 2. If original field is still not valid, it is still first responder + // (manually reset it back to first responder) + // 3. Otherwise the first subfield with invalid text should now be first responder + let originalSubResponder = currentFirstResponderField() + + // #1031 small footgun hiding here. Use copies to protect from mutations of + // `internalCardParams` in the `cardParams` property accessor and any mutations + // the app code might make to their `callersCardParams` object. + let desiredCardParams = + (callersCardParams.card ?? STPPaymentMethodCardParams()).copy() + as! STPPaymentMethodCardParams + internalCardParams = desiredCardParams.copy() as! STPPaymentMethodCardParams + + if let newBillingDetails = callersCardParams.billingDetails { + // If we receive billing details, set a copy of these as our internal billing details + internalBillingDetails = newBillingDetails.copy() as? STPPaymentMethodBillingDetails + } else { + // Otherwise, unset billing details + internalBillingDetails = nil + } + // Set the postal code, unsetting if nil + postalCode = internalBillingDetails?.address?.postalCode + + // If an explicit country code is passed, set it. Otherwise use the default behavior (NSLocale.current) + if let countryCode = callersCardParams.billingDetails?.address?.country { + self.countryCode = countryCode + } + + setText(desiredCardParams.number, inField: .number) + let expirationPresent = + desiredCardParams.expMonth != nil && desiredCardParams.expYear != nil + if expirationPresent { + let text = String( + format: "%02lu%02lu", + UInt(desiredCardParams.expMonth?.intValue ?? 0), + UInt(desiredCardParams.expYear?.intValue ?? 0) % 100 + ) + setText(text, inField: .expiration) + } else { + setText("", inField: .expiration) + } + setText(desiredCardParams.cvc, inField: .CVC) + + if isFirstResponder { + var fieldType = STPCardFieldType.number + if let originalSubResponderTag = originalSubResponder?.tag, + let lastFieldType = STPCardFieldType(rawValue: originalSubResponderTag) + { + fieldType = lastFieldType + } + var state: STPCardValidationState = .incomplete + + switch fieldType { + case .number: + state = + viewModel.hasCompleteMetadataForCardNumber + ? STPCardValidator.validationState( + forNumber: viewModel.cardNumber ?? "", + validatingCardBrand: true + ) + : .incomplete + case .expiration: + state = viewModel.validationStateForExpiration() + case .CVC: + state = viewModel.validationStateForCVC() + case .postalCode: + state = viewModel.validationStateForPostalCode() + } + + if state == .valid { + let nextField = _firstInvalidAutoAdvanceField() + if let nextField = nextField { + nextField.becomeFirstResponder() + } else { + resignFirstResponder() + } + } else { + originalSubResponder?.becomeFirstResponder() + } + } else { + layoutViews( + toFocus: nil, + becomeFirstResponder: true, + animated: false, + completion: nil + ) + } + + // update the card image, falling back to the number field image if not editing + if expirationField.isFirstResponder { + updateImage(for: .expiration) + } else if cvcField.isFirstResponder { + updateImage(for: .CVC) + } else { + updateImage(for: .number) + } + updateCVCPlaceholder() + } + } + + /// Causes the text field to begin editing. Presents the keyboard. + /// - Returns: Whether or not the text field successfully began editing. + /// - seealso: UIResponder + @objc @discardableResult open override func becomeFirstResponder() -> Bool { + let firstResponder = currentFirstResponderField() ?? nextFirstResponderField() + return firstResponder.becomeFirstResponder() + } + + /// Causes the text field to stop editing. Dismisses the keyboard. + /// - Returns: Whether or not the field successfully stopped editing. + /// - seealso: UIResponder + @discardableResult open override func resignFirstResponder() -> Bool { + super.resignFirstResponder() + let success = currentFirstResponderField()?.resignFirstResponder() ?? false + layoutViews( + toFocus: nil, + becomeFirstResponder: false, + animated: true, + completion: nil + ) + updateImage(for: .number) + return success + } + + /// Resets all of the contents of all of the fields. If the field is currently being edited, the number field will become selected. + @objc open func clear() { + for field in allFields { + field.text = "" + } + let postalCodeRequested = viewModel.postalCodeRequested + viewModel = STPPaymentCardTextFieldViewModel() + viewModel.postalCodeRequested = postalCodeRequested + onChange() + updateImage(for: .number) + updateCVCPlaceholder() + weak var weakSelf = self + layoutViews( + toFocus: NSNumber(value: STPCardFieldType.postalCode.rawValue), + becomeFirstResponder: true, + animated: true + ) { _ in + guard let strongSelf = weakSelf else { + return + } + if strongSelf.isFirstResponder { + strongSelf.numberField.becomeFirstResponder() + } + } + } + + /// Returns the cvc image used for a card brand. + /// Override this method in a subclass if you would like to provide custom images. + /// - Parameter cardBrand: The brand of card entered. + /// - Returns: The cvc image used for a card brand. + @objc(cvcImageForCardBrand:) open class func cvcImage(for cardBrand: STPCardBrand) -> UIImage? { + return STPImageLibrary.cvcImage(for: cardBrand) + } + + /// Returns the brand image used for a card brand. + /// Override this method in a subclass if you would like to provide custom images. + /// - Parameter cardBrand: The brand of card entered. + /// - Returns: The brand image used for a card brand. + @objc(brandImageForCardBrand:) open class func brandImage( + for cardBrand: STPCardBrand + ) + -> UIImage? + { + return STPImageLibrary.cardBrandImage(for: cardBrand) + } + + /// Returns the error image used for a card brand. + /// Override this method in a subclass if you would like to provide custom images. + /// - Parameter cardBrand: The brand of card entered. + /// - Returns: The error image used for a card brand. + @objc(errorImageForCardBrand:) open class func errorImage( + for cardBrand: STPCardBrand + ) + -> UIImage? + { + return STPImageLibrary.errorImage(for: cardBrand) + } + + /// Returns the rectangle in which the receiver draws its brand image. + /// - Parameter bounds: The bounding rectangle of the receiver. + /// - Returns: the rectangle in which the receiver draws its brand image. + @objc(brandImageRectForBounds:) open func brandImageRect(forBounds bounds: CGRect) -> CGRect { + let height = CGFloat(min(bounds.size.height, brandImageView.image?.size.height ?? 0)) + // the -1 to y here helps the image actually be centered + return CGRect( + x: STPPaymentCardTextFieldDefaultPadding, + y: 0.5 * bounds.size.height - 0.5 * height - 1, + width: brandImageView.image?.size.width ?? 0.0, + height: height + ) + } + + /// Returns the rectangle in which the receiver draws the text fields. + /// - Parameter bounds: The bounding rectangle of the receiver. + /// - Returns: The rectangle in which the receiver draws the text fields. + @objc(fieldsRectForBounds:) open func fieldsRect(forBounds bounds: CGRect) -> CGRect { + let brandImageRect = self.brandImageRect(forBounds: bounds) + return CGRect( + x: brandImageRect.maxX, + y: 0, + width: bounds.width - brandImageRect.maxX, + height: bounds.height + ) + } + + @objc internal lazy var brandImageView: UIImageView = UIImageView( + image: Self.brandImage(for: .unknown) + ) + + @objc internal var cardBrandDropDown: DropdownFieldElement? + + @objc internal lazy var fieldsView: UIView = UIView() + @objc internal lazy var numberField: STPFormTextField = { + return build() + }() + @objc internal lazy var expirationField: STPFormTextField = { + return build() + }() + @objc internal lazy var cvcField: STPFormTextField = { + return build() + }() + @objc internal lazy var postalCodeField: STPFormTextField = { + return build() + }() + + @objc internal lazy var viewModel: STPPaymentCardTextFieldViewModel = + STPPaymentCardTextFieldViewModel() + + @objc internal var internalCardParams = STPPaymentMethodCardParams() + @objc internal var internalBillingDetails: STPPaymentMethodBillingDetails? + @objc internal var internalMetadata: [String: String]? + + @objc @_spi(STP) public var allFields: [STPFormTextField] = [] + private lazy var sizingField: STPFormTextField = { + let field = build() + field.formDelegate = nil + return field + }() + private lazy var sizingLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + return label + }() + // These track the input parameters to the brand image setter so that we can + // later perform proper transition animations when new values are set + private var currentBrandImageFieldType: STPCardFieldType = .number + private var currentBrandImageBrand: STPCardBrand = .unknown + /// This is a number-wrapped STPCardFieldType (or nil) that layout uses + /// to determine how it should move/animate its subviews so that the chosen + /// text field is fully visible. + @objc internal var focusedTextFieldForLayout: NSNumber? + // Creating and measuring the size of attributed strings is expensive so + // cache the values here. + private var textToWidthCache: [String: NSNumber] = [:] + private var numberToWidthCache: [String: NSNumber] = [:] + /// These bits lets us track beginEditing and endEditing for payment text field + /// as a whole (instead of on a per-subview basis). + /// DO NOT read this values directly. Use the return value from + /// `getAndUpdateSubviewEditingTransitionStateFromCall:` which updates them all + /// and returns you the correct current state for the method you are in. + /// The state transitons in the should/did begin/end editing callbacks for all + /// our subfields. If we get a shouldEnd AND a shouldBegin before getting either's + /// matching didEnd/didBegin, then we are transitioning focus between our subviews + /// (and so we ourselves should not consider us to have begun or ended editing). + /// But if we get a should and did called on their own without a matching opposite + /// pair (shouldBegin/didBegin or shouldEnd/didEnd) then we are transitioning + /// into/out of our subviews from/to outside of ourselves + private var isMidSubviewEditingTransitionInternal = false + private var receivedUnmatchedShouldBeginEditing = false + private var receivedUnmatchedShouldEndEditing = false + + let STPPaymentCardTextFieldDefaultPadding: CGFloat = 13 + + let STPPaymentCardTextFieldDefaultInsets: CGFloat = 13 + + let STPPaymentCardTextFieldMinimumPadding: CGFloat = 10 + + static let STPCBCBrandIconMaxWidth = 24.0 + + // MARK: initializers + /// :nodoc: + required public init?( + coder aDecoder: NSCoder + ) { + super.init(coder: aDecoder) + commonInit() + } + + /// :nodoc: + public override init( + frame: CGRect + ) { + super.init(frame: frame) + commonInit() + } + + func commonInit() { + STPAnalyticsClient.sharedClient.addClass( + toProductUsageIfNecessary: STPPaymentCardTextField.self + ) + + // We're using ivars here because UIAppearance tracks when setters are + // called, and won't override properties that have already been customized + layer.borderColor = _borderColor?.cgColor + layer.cornerRadius = _cornerRadius + layer.borderWidth = _borderWidth + + clipsToBounds = true + + brandImageView.contentMode = .center + brandImageView.backgroundColor = UIColor.clear + brandImageView.tintColor = placeholderColor + + // This does not offer quick-type suggestions (as iOS 11.2), but does pick + // the best keyboard (maybe other, hidden behavior?) + numberField.textContentType = .creditCardNumber + numberField.autoFormattingBehavior = .cardNumbers + numberField.tag = STPCardFieldType.number.rawValue + numberField.accessibilityLabel = STPLocalizedString( + "card number", + "accessibility label for text field" + ) + numberPlaceholder = viewModel.defaultPlaceholder() + + expirationField.autoFormattingBehavior = .expiration + expirationField.tag = STPCardFieldType.expiration.rawValue + expirationField.alpha = 0 + expirationField.isAccessibilityElement = false + expirationField.accessibilityLabel = STPLocalizedString( + "expiration date", + "accessibility label for text field" + ) + expirationPlaceholder = STPLocalizedString( + "MM/YY", + "label for text field to enter card expiry" + ) + + cvcField.tag = STPCardFieldType.CVC.rawValue + cvcField.alpha = 0 + cvcField.isAccessibilityElement = false + cvcPlaceholder = nil + cvcField.accessibilityLabel = defaultCVCPlaceholder() + + postalCodeField.textContentType = .postalCode + postalCodeField.tag = STPCardFieldType.postalCode.rawValue + postalCodeField.alpha = 0 + postalCodeField.isAccessibilityElement = false + postalCodeField.keyboardType = .numbersAndPunctuation + // Placeholder is set by country code setter + + fieldsView.clipsToBounds = true + fieldsView.backgroundColor = UIColor.clear + + allFields = [numberField, expirationField, cvcField, postalCodeField].compactMap { $0 } + + addSubview(self.fieldsView) + for field in allFields { + self.fieldsView.addSubview(field) + } + + addSubview(brandImageView) + // On small screens, the number field fits ~4 numbers, and the brandImage is just as large. + // Previously, taps on the brand image would *dismiss* the keyboard. Make it move to the numberField instead + brandImageView.isUserInteractionEnabled = true + brandImageView.addGestureRecognizer( + UITapGestureRecognizer( + target: numberField, + action: #selector(UIResponder.becomeFirstResponder) + ) + ) + + self.addCBCIfNeeded() + + focusedTextFieldForLayout = nil + updateCVCPlaceholder() + resetSubviewEditingTransitionState() + + viewModel.postalCodeRequested = true + countryCode = Locale.autoupdatingCurrent.regionCode + + sizingField.formDelegate = nil + + // We need to add sizingField and sizingLabel to the view + // hierarchy so they can accurately size for dynamic font + // sizes. + // Set them to hidden and send to back + sizingField.isHidden = true + sizingLabel.isHidden = true + addSubview(sizingField) + addSubview(sizingLabel) + sendSubviewToBack(sizingLabel) + sendSubviewToBack(sizingField) + + } + + func addCBCIfNeeded() { + if shouldShowCBC && cardBrandDropDown == nil { + let cardBrandDropDown = DropdownFieldElement.makeCardBrandDropdown(maxWidth: Self.STPCBCBrandIconMaxWidth) + cardBrandDropDown.view.translatesAutoresizingMaskIntoConstraints = false + + addSubview(cardBrandDropDown.view) + NSLayoutConstraint.activate( + [ + cardBrandDropDown.view.topAnchor.constraint( + equalTo: self.brandImageView.topAnchor + ), + cardBrandDropDown.view.bottomAnchor.constraint( + equalTo: self.brandImageView.bottomAnchor + ), + cardBrandDropDown.view.centerXAnchor.constraint( + equalTo: self.brandImageView.centerXAnchor + ), + ].compactMap { $0 } + ) + self.cardBrandDropDown = cardBrandDropDown + // Relayout the brand image as needed + updateImage(for: .number) + } + } + + // MARK: appearance properties + func clearSizingCache() { + textToWidthCache = [:] + numberToWidthCache = [:] + } + + static let placeholderGrayColor: UIColor = .systemGray2 + + open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if previousTraitCollection?.preferredContentSizeCategory + != traitCollection.preferredContentSizeCategory + { + clearSizingCache() + setNeedsLayout() + } + } + + /// :nodoc: + @objc open override var backgroundColor: UIColor? { + get { + let defaultColor = UIColor.systemBackground + + return super.backgroundColor ?? defaultColor + } + set { + super.backgroundColor = newValue + self.numberField.backgroundColor = newValue + } + } + + /// :nodoc: + @objc open override var contentVerticalAlignment: UIControl.ContentVerticalAlignment { + get { + return super.contentVerticalAlignment + } + set(contentVerticalAlignment) { + super.contentVerticalAlignment = contentVerticalAlignment + for field in allFields { + field.contentVerticalAlignment = contentVerticalAlignment + } + switch contentVerticalAlignment { + case .center: + brandImageView.contentMode = .center + case .bottom: + brandImageView.contentMode = .bottom + case .fill: + brandImageView.contentMode = .top + case .top: + brandImageView.contentMode = .top + @unknown default: + break + } + } + } + + func updatePostalFieldPlaceholder() { + if postalCodePlaceholder == nil { + let placeholder = defaultPostalFieldPlaceholder(forCountryCode: countryCode) + postalCodeField.placeholder = placeholder + postalCodeField.accessibilityLabel = placeholder + } else { + postalCodeField.placeholder = postalCodePlaceholder + postalCodeField.accessibilityLabel = postalCodePlaceholder + } + } + + func defaultPostalFieldPlaceholder(forCountryCode countryCode: String?) -> String? { + if countryCode?.uppercased() == "US" { + return String.Localized.zip + } else { + return String.Localized.postal_code + } + } + + // MARK: UIControl + + // MARK: UIResponder & related methods + /// :nodoc: + @objc open override var isFirstResponder: Bool { + return currentFirstResponderField() != nil + } + + /// :nodoc: + @objc open override var canBecomeFirstResponder: Bool { + let firstResponder = currentFirstResponderField() ?? nextFirstResponderField() + return firstResponder.canBecomeFirstResponder + } + + /// Returns the next text field to be edited, in priority order: + /// 1. If we're currently in a text field, returns the next one (ignoring postalCodeField if postalCodeEntryEnabled == NO) + /// 2. Otherwise, returns the first invalid field (either cycling back from the end or as it gains 1st responder) + /// 3. As a final fallback, just returns the last field + func nextFirstResponderField() -> STPFormTextField { + let currentFirstResponder = currentFirstResponderField() + if let currentFirstResponder = currentFirstResponder { + let index = allFields.firstIndex(of: currentFirstResponder) ?? NSNotFound + if index != NSNotFound { + let nextField = + allFields.stp_boundSafeObject(at: index + 1) + if nextField != nil && (postalCodeEntryEnabled || nextField != postalCodeField) { + return nextField! + } + } + } + + if (numberField.text?.count ?? 0) == 0 { + return numberField + } + + return _firstInvalidAutoAdvanceField() ?? lastSubField() + } + + func _firstInvalidAutoAdvanceField() -> STPFormTextField? { + if viewModel.validationStateForExpiration() != .valid { + return expirationField + } else if viewModel.validationStateForCVC() != .valid { + return cvcField + } else if postalCodeEntryEnabled && viewModel.validationStateForPostalCode() != .valid { + return postalCodeField + } else { + return nil + } + } + + func lastSubField() -> STPFormTextField { + return (postalCodeEntryEnabled ? postalCodeField : cvcField) + } + + @objc func currentFirstResponderField() -> STPFormTextField? { + for textField in allFields { + if textField.isFirstResponder { + return textField + } + } + return nil + } + + /// :nodoc: + @objc open override var canResignFirstResponder: Bool { + return currentFirstResponderField()?.canResignFirstResponder ?? false + } + + func previousField() -> STPFormTextField? { + let currentSubResponder = currentFirstResponderField() + if let currentSubResponder = currentSubResponder { + let index = allFields.firstIndex(of: currentSubResponder) ?? NSNotFound + if index != NSNotFound && index > 0 { + return allFields[index - 1] + } + } + return nil + } + + // MARK: public convenience methods + + @objc func valid() -> Bool { + return isValid + } + + // MARK: readonly variables + + func setText(_ text: String?, inField field: STPCardFieldType) { + let nonNilText = text ?? "" + var textField: STPFormTextField? + switch field { + case .number: + textField = numberField + case .expiration: + textField = expirationField + case .CVC: + textField = cvcField + case .postalCode: + textField = postalCodeField + } + textField?.text = nonNilText + } + + func numberFullWidth() -> CGFloat { + return CGFloat( + max( + width(forCardNumber: viewModel.cardNumber), + width(forCardNumber: viewModel.defaultPlaceholder()) + ) + ) + } + + func numberCompressedWidth() -> CGFloat { + + var cardNumber = self.cardNumber + if (cardNumber?.count ?? 0) == 0 { + cardNumber = viewModel.defaultPlaceholder() + } + + let currentBrand = STPCardValidator.brand(forNumber: cardNumber ?? "") + let sortedCardNumberFormat = + (STPCardValidator.cardNumberFormat(forCardNumber: cardNumber ?? "") as NSArray) + .sortedArray( + using: #selector(getter: NSNumber.uintValue) + ) as! [NSNumber] + let fragmentLength = STPCardValidator.fragmentLength(for: currentBrand) + let maxLength: Int = max(Int(fragmentLength), sortedCardNumberFormat.last!.intValue) + + let maxCompressedString = "".padding(toLength: maxLength, withPad: "8", startingAt: 0) + return width(forText: maxCompressedString) + } + + func cvcFieldWidth() -> CGFloat { + if focusedTextFieldForLayout != NSNumber(value: STPCardFieldType.CVC.rawValue) + && viewModel.validationStateForCVC() == .valid + { + // If we're not focused and have valid text, size exactly to what is entered + return width(forText: viewModel.cvc) + } else { + // Otherwise size to fit our placeholder or what is likely to be the + // largest possible string enterable (whichever is larger) + let maxCvcLength = Int(STPCardValidator.maxCVCLength(for: viewModel.brand)) + var longestCvc = "888" + if maxCvcLength == 4 { + longestCvc = "8888" + } + + return CGFloat(max(width(forText: cvcField.placeholder), width(forText: longestCvc))) + } + } + + func expirationFieldWidth() -> CGFloat { + if focusedTextFieldForLayout == nil && viewModel.validationStateForExpiration() == .valid { + // If we're not focused and have valid text, size exactly to what is entered + return width(forText: viewModel.rawExpiration) + } else { + // Otherwise size to fit our placeholder or what is likely to be the + // largest possible string enterable (whichever is larger) + return CGFloat( + max(width(forText: expirationField.placeholder), width(forText: "88/88")) + ) + } + } + + func postalCodeFieldFullWidth() -> CGFloat { + let compressedWidth = postalCodeFieldCompressedWidth() + let currentTextWidth = width(forText: viewModel.postalCode) + + if currentTextWidth <= compressedWidth { + return compressedWidth + } else if countryCode?.uppercased() == "US" { + // This format matches ZIP+4 which is currently disabled since it is + // not used for billing, but could be useful for future shipping addr purposes + return width(forText: "88888-8888 ") + } else { + // This format more closely matches the typical max UK/Canadian size which is our most common non-US market currently + return width(forText: "888 8888 ") + } + } + + func postalCodeFieldCompressedWidth() -> CGFloat { + var maxTextWidth: CGFloat = 0 + if countryCode?.uppercased() == "US" { + // The QuickType ZIP suggestion adds a space at the end, so we will too for calculating our bounds + maxTextWidth = width(forText: "88888 ") + } else { + // This format more closely matches the typical max UK/Canadian size which is our most common non-US market currently + maxTextWidth = width(forText: "888 8888 ") + } + + let placeholderWidth = width( + forText: defaultPostalFieldPlaceholder(forCountryCode: countryCode) + ) + return CGFloat(max(maxTextWidth, placeholderWidth)) + } + + /// :nodoc: + @objc open override var intrinsicContentSize: CGSize { + + let imageSize = brandImageView.image?.size + + sizingField.text = viewModel.defaultPlaceholder() + sizingField.sizeToFit() + let textHeight = sizingField.frame.height + let imageHeight = (imageSize?.height ?? 0.0) + (STPPaymentCardTextFieldDefaultInsets) + let height = ceil(CGFloat((max(max(imageHeight, textHeight), 44)))) + + var width = + STPPaymentCardTextFieldDefaultInsets + (imageSize?.width ?? 0.0) + + STPPaymentCardTextFieldDefaultInsets + numberFullWidth() + + STPPaymentCardTextFieldDefaultInsets + + width = ceil(width) + + return CGSize(width: width, height: height) + } + + enum STPCardTextFieldState: Int { + case visible + case compressed + case hidden + } + + func minimumPaddingForViews( + withWidth width: CGFloat, + pan panVisibility: STPCardTextFieldState, + expiry expiryVisibility: STPCardTextFieldState, + cvc cvcVisibility: STPCardTextFieldState, + postal postalVisibility: STPCardTextFieldState + ) -> CGFloat { + + var requiredWidth: CGFloat = 0 + var paddingsRequired: CGFloat = -1 + + if panVisibility != .hidden { + paddingsRequired += 1 + requiredWidth += + (panVisibility == .compressed) ? numberCompressedWidth() : numberFullWidth() + } + + if expiryVisibility != .hidden { + paddingsRequired += 1 + requiredWidth += expirationFieldWidth() + } + + if cvcVisibility != .hidden { + paddingsRequired += 1 + requiredWidth += cvcFieldWidth() + } + + if postalVisibility != .hidden && postalCodeEntryEnabled { + paddingsRequired += 1 + requiredWidth += + (postalVisibility == .compressed) + ? postalCodeFieldCompressedWidth() : postalCodeFieldFullWidth() + } + + if paddingsRequired > 0 { + return ceil((width - requiredWidth) / paddingsRequired) + } else { + return STPPaymentCardTextFieldMinimumPadding + } + } + + /// :nodoc: + @objc + open override func layoutSubviews() { + super.layoutSubviews() + recalculateSubviewLayout() + } + + func recalculateSubviewLayout() { + + let bounds = self.bounds + + brandImageView.frame = brandImageRect(forBounds: bounds) + let fieldsViewRect = fieldsRect(forBounds: bounds) + fieldsView.frame = fieldsViewRect + + let availableFieldsWidth = fieldsViewRect.width - (2 * STPPaymentCardTextFieldDefaultInsets) + + // These values are filled in via the if statements and then used + // to do the proper layout at the end + let fieldsHeight = fieldsViewRect.height + var hPadding = STPPaymentCardTextFieldDefaultPadding + var panVisibility: STPCardTextFieldState = .visible + var expiryVisibility: STPCardTextFieldState = .visible + var cvcVisibility: STPCardTextFieldState = .visible + var postalVisibility: STPCardTextFieldState = postalCodeEntryEnabled ? .visible : .hidden + + let calculateMinimumPaddingWithLocalVars: (() -> CGFloat) = { + return self.minimumPaddingForViews( + withWidth: availableFieldsWidth, + pan: panVisibility, + expiry: expiryVisibility, + cvc: cvcVisibility, + postal: postalVisibility + ) + } + + hPadding = calculateMinimumPaddingWithLocalVars() + + if hPadding >= STPPaymentCardTextFieldMinimumPadding { + // Can just render everything at full size + // Do Nothing + } else { + // Need to do selective view compression/hiding + + if focusedTextFieldForLayout == nil { + // No field is currently being edited - + // + // Render all fields visible: + // Show compressed PAN, visible CVC and expiry, fill remaining space + // with postal if necessary + // + // The most common way to be in this state is the user finished entry + // and has moved on to another field (so we want to show summary) + // but possibly some fields are invalid + while hPadding < STPPaymentCardTextFieldMinimumPadding { + // Try hiding things in this order + if panVisibility == .visible { + panVisibility = .compressed + } else if postalVisibility == .visible { + postalVisibility = .compressed + } else { + // Can't hide anything else, set to minimum and stop + hPadding = STPPaymentCardTextFieldMinimumPadding + break + } + hPadding = calculateMinimumPaddingWithLocalVars() + } + } else { + switch STPCardFieldType(rawValue: focusedTextFieldForLayout?.intValue ?? 0)! { + case .number: + // The user is entering PAN + // + // It must be fully visible. Everything else is optional + + while hPadding < STPPaymentCardTextFieldMinimumPadding { + if postalVisibility == .visible { + postalVisibility = .compressed + } else if postalVisibility == .compressed { + postalVisibility = .hidden + } else if cvcVisibility == .visible { + cvcVisibility = .hidden + } else if expiryVisibility == .visible { + expiryVisibility = .hidden + } else { + hPadding = STPPaymentCardTextFieldMinimumPadding + break + } + hPadding = calculateMinimumPaddingWithLocalVars() + } + case .expiration: + // The user is entering expiration date + // + // It must be fully visible, and the next and previous fields + // must be visible so they can be tapped over to + while hPadding < STPPaymentCardTextFieldMinimumPadding { + if panVisibility == .visible { + panVisibility = .compressed + } else if postalVisibility == .visible { + postalVisibility = .compressed + } else if postalVisibility == .compressed { + postalVisibility = .hidden + } else { + hPadding = STPPaymentCardTextFieldMinimumPadding + break + } + hPadding = calculateMinimumPaddingWithLocalVars() + } + case .CVC: + // The user is entering CVC + // + // It must be fully visible, and the next and previous fields + // must be visible so they can be tapped over to (although + // there might not be a next field) + while hPadding < STPPaymentCardTextFieldMinimumPadding { + if panVisibility == .visible { + panVisibility = .compressed + } else if postalVisibility == .visible { + postalVisibility = .compressed + } else if panVisibility == .compressed { + panVisibility = .hidden + } else { + hPadding = STPPaymentCardTextFieldMinimumPadding + break + } + hPadding = calculateMinimumPaddingWithLocalVars() + } + case .postalCode: + // The user is entering postal code + // + // It must be fully visible, and the previous field must + // be visible + while hPadding < STPPaymentCardTextFieldMinimumPadding { + if panVisibility == .visible { + panVisibility = .compressed + } else if panVisibility == .compressed { + panVisibility = .hidden + } else if expiryVisibility == .visible { + expiryVisibility = .hidden + } else { + hPadding = STPPaymentCardTextFieldMinimumPadding + break + } + hPadding = calculateMinimumPaddingWithLocalVars() + } + } + } + } + + // -- Do layout here -- + var xOffset = STPPaymentCardTextFieldDefaultInsets + var width: CGFloat = 0 + + // Make all fields actually slightly wider than needed so that when the + // cursor is at the end position the contents aren't clipped off to the left side + let additionalWidth = self.width(forText: "8") + + if panVisibility == .compressed { + // Need to lower xOffset so pan is partially off-screen + + let hasEnteredCardNumber = (cardNumber?.count ?? 0) > 0 + let compressedCardNumber = + viewModel.compressedCardNumber(withPlaceholder: numberPlaceholder) ?? "" + let cardNumberToHide = (hasEnteredCardNumber ? cardNumber : numberPlaceholder)? + .stp_string( + byRemovingSuffix: compressedCardNumber + ) + + if (cardNumberToHide?.count ?? 0) > 0 + && STPCardValidator.stringIsNumeric(cardNumberToHide ?? "") + { + width = + hasEnteredCardNumber ? self.width(forCardNumber: cardNumber) : numberFullWidth() + + let hiddenWidth = self.width(forCardNumber: cardNumberToHide) + xOffset -= hiddenWidth + let maskView = UIView( + frame: CGRect( + x: hiddenWidth, + y: 0, + width: width - hiddenWidth, + height: fieldsHeight + ) + ) + maskView.backgroundColor = UIColor.label + maskView.isOpaque = true + maskView.isUserInteractionEnabled = false + UIView.performWithoutAnimation({ + self.numberField.mask = maskView + }) + } else { + width = numberCompressedWidth() + UIView.performWithoutAnimation({ + self.numberField.mask = nil + }) + } + } else { + width = numberFullWidth() + UIView.performWithoutAnimation({ + self.numberField.mask = nil + }) + + if panVisibility == .hidden { + // Need to lower xOffset so pan is fully off screen + xOffset = xOffset - width - hPadding + } + } + + numberField.frame = CGRect( + x: xOffset, + y: 0, + width: CGFloat(min(width + additionalWidth, fieldsView.frame.width - additionalWidth)), + height: fieldsHeight + ) + xOffset += width + hPadding + + width = expirationFieldWidth() + expirationField.frame = CGRect( + x: xOffset, + y: 0, + width: width + additionalWidth, + height: fieldsHeight + ) + // If the field isn't visible, we don't want to move the xOffset forward. + if expiryVisibility != .hidden { + xOffset += width + hPadding + } + + width = cvcFieldWidth() + cvcField.frame = CGRect( + x: xOffset, + y: 0, + width: width + additionalWidth, + height: fieldsHeight + ) + if cvcVisibility != .hidden { + xOffset += width + hPadding + } + + if postalCodeEntryEnabled { + width = fieldsView.frame.size.width - xOffset - STPPaymentCardTextFieldDefaultInsets + postalCodeField.frame = CGRect( + x: xOffset, + y: 0, + width: width + additionalWidth, + height: fieldsHeight + ) + } + + let updateFieldVisibility: ((STPFormTextField?, STPCardTextFieldState) -> Void)? = { + field, + fieldState in + if fieldState == .hidden { + field?.alpha = 0.0 + field?.isAccessibilityElement = false + } else { + field?.alpha = 1.0 + field?.isAccessibilityElement = true + } + } + + updateFieldVisibility?(numberField, panVisibility) + updateFieldVisibility?(expirationField, expiryVisibility) + updateFieldVisibility?(cvcField, cvcVisibility) + updateFieldVisibility?(postalCodeField, postalCodeEntryEnabled ? postalVisibility : .hidden) + } + + // MARK: - private helper methods + func build() -> STPFormTextField { + let textField = STPFormTextField(frame: CGRect.zero) + textField.backgroundColor = UIColor.clear + textField.adjustsFontForContentSizeCategory = true + // setCountryCode: updates the postalCodeField keyboardType, this is safe + textField.keyboardType = .asciiCapableNumberPad + textField.textAlignment = .left + textField.font = font + textField.defaultColor = textColor + textField.errorColor = textErrorColor + textField.placeholderColor = placeholderColor + textField.formDelegate = self + textField.validText = true + return textField + } + + typealias STPLayoutAnimationCompletionBlock = (Bool) -> Void + + func layoutViews( + toFocus focusedField: NSNumber?, + becomeFirstResponder shouldBecomeFirstResponder: Bool, + animated: Bool, + completion: STPLayoutAnimationCompletionBlock? + ) { + + var fieldtoFocus = focusedField + + if fieldtoFocus == nil + && !(focusedTextFieldForLayout == NSNumber(value: STPCardFieldType.number.rawValue)) + { + fieldtoFocus = NSNumber(value: STPCardFieldType.number.rawValue) + if shouldBecomeFirstResponder { + numberField.becomeFirstResponder() + } + } + + if (fieldtoFocus == nil && focusedTextFieldForLayout == nil) + || (fieldtoFocus != nil && (focusedTextFieldForLayout == fieldtoFocus)) + { + if let completion = completion { + completion(true) + } + return + } + + focusedTextFieldForLayout = fieldtoFocus + + let animations: (() -> Void)? = { + self.recalculateSubviewLayout() + } + + if animated { + let duration: TimeInterval = 0.3 + if let animations = animations { + UIView.animate( + withDuration: duration, + delay: 0, + usingSpringWithDamping: 0.85, + initialSpringVelocity: 0, + options: [], + animations: animations, + completion: completion + ) + } + } else { + animations?() + } + } + + func width(forAttributedText attributedText: NSAttributedString?) -> CGFloat { + // UITextField doesn't seem to size correctly here for unknown reasons + // But UILabel reliably calculates size correctly using this method + sizingLabel.attributedText = attributedText + sizingLabel.sizeToFit() + return ceil(sizingLabel.bounds.width) + + } + + func width(forText text: String?) -> CGFloat { + guard let text = text, text.count > 0 else { + return 0 + } + + if let cachedValue = textToWidthCache[text] { + return CGFloat(cachedValue.doubleValue) + } + sizingField.autoFormattingBehavior = .none + sizingField.text = STPNonLocalizedString(text) + let cachedValue = NSNumber( + value: Float(width(forAttributedText: sizingField.attributedText)) + ) + textToWidthCache[text] = cachedValue + return CGFloat(cachedValue.doubleValue) + } + + func width(forCardNumber cardNumber: String?) -> CGFloat { + guard let cardNumber = cardNumber, cardNumber.count > 0 else { + return 0 + } + + if let cachedValue = numberToWidthCache[cardNumber] { + return CGFloat(cachedValue.doubleValue) + } + sizingField.autoFormattingBehavior = .cardNumbers + sizingField.text = cardNumber + let cachedValue = NSNumber( + value: Float(width(forAttributedText: sizingField.attributedText)) + ) + numberToWidthCache[cardNumber] = cachedValue + return CGFloat(cachedValue.doubleValue) + } + + // MARK: STPFormTextFieldDelegate + @objc(formTextFieldDidBackspaceOnEmpty:) func formTextFieldDidBackspace( + onEmpty formTextField: STPFormTextField + ) { + let previous = previousField() + previous?.becomeFirstResponder() + UIAccessibility.post(notification: .screenChanged, argument: nil) + if let previous = previous, previous.hasText, let previousText = previous.text { + // `UITextField.deleteBackwards` doesn't update the `text` property directly, and we depend on the `didSet` + // call on it to update our backing store. + // To get around this we manually remove the last character instead of calling `deleteBackwards` in the + // previous field. + previous.text = String(previousText.dropLast()) + } + } + + @objc(formTextField:modifyIncomingTextChange:) func formTextField( + _ formTextField: STPFormTextField, + modifyIncomingTextChange input: NSAttributedString + ) -> NSAttributedString { + guard let fieldType = STPCardFieldType(rawValue: formTextField.tag) else { + return NSAttributedString(string: "") + } + switch fieldType { + case .number: + viewModel.cardNumber = input.string + setNeedsLayout() + case .expiration: + viewModel.rawExpiration = input.string + case .CVC: + viewModel.cvc = input.string + case .postalCode: + viewModel.postalCode = input.string + setNeedsLayout() + } + + switch fieldType { + case .number: + return NSAttributedString( + string: viewModel.cardNumber ?? "", + attributes: numberField.defaultTextAttributes + ) + case .expiration: + return NSAttributedString( + string: viewModel.rawExpiration ?? "", + attributes: expirationField.defaultTextAttributes + ) + case .CVC: + return NSAttributedString( + string: viewModel.cvc ?? "", + attributes: cvcField.defaultTextAttributes + ) + case .postalCode: + return NSAttributedString( + string: viewModel.postalCode ?? "", + attributes: cvcField.defaultTextAttributes + ) + } + } + + @objc(formTextFieldTextDidChange:) func formTextFieldTextDidChange( + _ formTextField: STPFormTextField + ) { + guard let fieldType = STPCardFieldType(rawValue: formTextField.tag) else { + return + } + + formTextField.validText = true + + switch fieldType { + case .number: + let number = viewModel.cardNumber + + // Changing the card number field can invalidate the cvc, e.g. going from 4 digit Amex cvc to 3 digit Visa + // it is not expected that the brand will change based on network response so we update this immediately + // as well as in the completion just in case + updateCVCPlaceholder() + cvcField.validText = viewModel.validationStateForCVC() != .invalid + updateImage(for: fieldType) + + updateCardBrandsIfNeeded() + + if viewModel.hasCompleteMetadataForCardNumber { + let state = STPCardValidator.validationState( + forNumber: viewModel.cardNumber ?? "", + validatingCardBrand: true + ) + updateCVCPlaceholder() + cvcField.validText = viewModel.validationStateForCVC() != .invalid + formTextField.validText = state != .invalid + + if state == .valid { + // auto-advance + nextFirstResponderField().becomeFirstResponder() + UIAccessibility.post(notification: .screenChanged, argument: nil) + } + } else { + viewModel.validationStateForCardNumber(handler: { state in + if self.viewModel.cardNumber == number { + self.updateCVCPlaceholder() + self.cvcField.validText = self.viewModel.validationStateForCVC() != .invalid + formTextField.validText = state != .invalid + if state == .valid { + // log that user entered full complete PAN before we got a network response + STPAnalyticsClient.sharedClient + .logUserEnteredCompletePANBeforeMetadataLoaded() + } + self.onChange() + } + // Update image on response because we may want to remove the loading indicator + if let tag = (self.currentFirstResponderField() ?? self.numberField)?.tag, + let current = STPCardFieldType(rawValue: tag) + { + self.updateImage(for: current) + } + // no auto-advance + }) + + if viewModel.isNumberMaxLength { + let isValidLuhn = STPCardValidator.stringIsValidLuhn(viewModel.cardNumber ?? "") + formTextField.validText = isValidLuhn + if isValidLuhn { + // auto-advance + nextFirstResponderField().becomeFirstResponder() + UIAccessibility.post(notification: .screenChanged, argument: nil) + } + } + } + case .expiration: + let state = viewModel.validationStateForExpiration() + formTextField.validText = state != .invalid + if state == .valid { + // auto-advance + nextFirstResponderField().becomeFirstResponder() + UIAccessibility.post(notification: .screenChanged, argument: nil) + } + case .CVC: + let state = viewModel.validationStateForCVC() + formTextField.validText = state != .invalid + if state == .valid { + // Even though any CVC longer than the min required CVC length + // is valid, we don't want to forward on to the next field + // unless it is actually >= the max cvc length (otherwise when + // postal code is showing, you can't easily enter CVCs longer than + // the minimum. + let sanitizedCvc = STPCardValidator.sanitizedNumericString( + for: formTextField.text ?? "" + ) + if sanitizedCvc.count >= STPCardValidator.maxCVCLength(for: viewModel.brand) { + // auto-advance + nextFirstResponderField().becomeFirstResponder() + UIAccessibility.post(notification: .screenChanged, argument: nil) + } + } + case .postalCode: + formTextField.validText = viewModel.validationStateForPostalCode() != .invalid + // no auto-advance + // Similar to the UX problems on CVC, since our Postal Code validation + // is pretty light, we want to block auto-advance here. In the US, this + // allows users to enter 9 digit zips if they want, and as many as they + // need in non-US countries (where >0 characters is "valid") + } + + onChange() + } + + enum STPFieldEditingTransitionCallSite: Int { + case shouldBegin + case shouldEnd + case didBegin + case didEnd + } + + // Explanation of the logic here is with the definition of these properties + // at the top of this file + @discardableResult func getAndUpdateSubviewEditingTransitionState( + fromCall sendingMethod: STPFieldEditingTransitionCallSite + ) -> Bool { + var stateToReturn: Bool + switch sendingMethod { + case .shouldBegin: + receivedUnmatchedShouldBeginEditing = true + if receivedUnmatchedShouldEndEditing { + isMidSubviewEditingTransitionInternal = true + } + stateToReturn = isMidSubviewEditingTransitionInternal + case .shouldEnd: + receivedUnmatchedShouldEndEditing = true + if receivedUnmatchedShouldBeginEditing { + isMidSubviewEditingTransitionInternal = true + } + stateToReturn = isMidSubviewEditingTransitionInternal + case .didBegin: + stateToReturn = isMidSubviewEditingTransitionInternal + receivedUnmatchedShouldBeginEditing = false + if receivedUnmatchedShouldEndEditing == false { + isMidSubviewEditingTransitionInternal = false + } + case .didEnd: + stateToReturn = isMidSubviewEditingTransitionInternal + receivedUnmatchedShouldEndEditing = false + if receivedUnmatchedShouldBeginEditing == false { + isMidSubviewEditingTransitionInternal = false + } + } + + return stateToReturn + } + + func resetSubviewEditingTransitionState() { + isMidSubviewEditingTransitionInternal = false + receivedUnmatchedShouldBeginEditing = false + receivedUnmatchedShouldEndEditing = false + } + + /// :nodoc: + @objc + open func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { + getAndUpdateSubviewEditingTransitionState(fromCall: .shouldBegin) + return true + } + + /// :nodoc: + @objc + open func textFieldDidBeginEditing(_ textField: UITextField) { + let isMidSubviewEditingTransition = getAndUpdateSubviewEditingTransitionState( + fromCall: .didBegin + ) + + layoutViews( + toFocus: NSNumber(value: textField.tag), + becomeFirstResponder: true, + animated: true, + completion: nil + ) + + if !isMidSubviewEditingTransition { + if delegate?.responds( + to: #selector( + STPPaymentCardTextFieldDelegate.paymentCardTextFieldDidBeginEditing(_:)) + ) + ?? false + { + delegate?.paymentCardTextFieldDidBeginEditing?(self) + } + } + + guard let cardType = STPCardFieldType(rawValue: textField.tag) else { + return + } + switch cardType { + case .number: + (textField as? STPFormTextField)?.validText = true + if delegate?.responds( + to: #selector( + STPPaymentCardTextFieldDelegate.paymentCardTextFieldDidBeginEditingNumber(_:)) + ) ?? false { + delegate?.paymentCardTextFieldDidBeginEditingNumber?(self) + } + case .CVC: + if delegate?.responds( + to: #selector( + STPPaymentCardTextFieldDelegate.paymentCardTextFieldDidBeginEditingCVC(_:)) + ) + ?? false + { + delegate?.paymentCardTextFieldDidBeginEditingCVC?(self) + } + case .expiration: + if delegate?.responds( + to: #selector( + STPPaymentCardTextFieldDelegate.paymentCardTextFieldDidBeginEditingExpiration( + _: + )) + ) + ?? false + { + delegate?.paymentCardTextFieldDidBeginEditingExpiration?(self) + } + case .postalCode: + if delegate?.responds( + to: #selector( + STPPaymentCardTextFieldDelegate.paymentCardTextFieldDidBeginEditingPostalCode( + _: + )) + ) + ?? false + { + delegate?.paymentCardTextFieldDidBeginEditingPostalCode?(self) + } + } + updateImage(for: cardType) + } + + /// :nodoc: + @objc + open func textFieldShouldEndEditing(_ textField: UITextField) -> Bool { + getAndUpdateSubviewEditingTransitionState(fromCall: .shouldEnd) + updateImage(for: .number) + return true + } + + /// :nodoc: + @objc + open func textFieldDidEndEditing(_ textField: UITextField) { + let isMidSubviewEditingTransition = getAndUpdateSubviewEditingTransitionState( + fromCall: .didEnd + ) + + guard let cardType = STPCardFieldType(rawValue: textField.tag) else { + return + } + + switch cardType { + case .number: + viewModel.validationStateForCardNumber(handler: { state in + if state == .incomplete && !textField.isEditing { + (textField as? STPFormTextField)?.validText = false + } + }) + if delegate?.responds( + to: #selector( + STPPaymentCardTextFieldDelegate.paymentCardTextFieldDidEndEditingNumber(_:)) + ) + ?? false + { + delegate?.paymentCardTextFieldDidEndEditingNumber?(self) + } + case .CVC: + if delegate?.responds( + to: #selector( + STPPaymentCardTextFieldDelegate.paymentCardTextFieldDidEndEditingCVC(_:)) + ) + ?? false + { + delegate?.paymentCardTextFieldDidEndEditingCVC?(self) + } + case .expiration: + if delegate?.responds( + to: #selector( + STPPaymentCardTextFieldDelegate.paymentCardTextFieldDidEndEditingExpiration(_:)) + ) ?? false { + delegate?.paymentCardTextFieldDidEndEditingExpiration?(self) + } + case .postalCode: + if delegate?.responds( + to: #selector( + STPPaymentCardTextFieldDelegate.paymentCardTextFieldDidEndEditingPostalCode(_:)) + ) ?? false { + delegate?.paymentCardTextFieldDidEndEditingPostalCode?(self) + } + } + + if !isMidSubviewEditingTransition { + layoutViews( + toFocus: nil, + becomeFirstResponder: false, + animated: true, + completion: nil + ) + updateImage(for: .number) + if delegate?.responds( + to: #selector(STPPaymentCardTextFieldDelegate.paymentCardTextFieldDidEndEditing(_:)) + ) + ?? false + { + delegate?.paymentCardTextFieldDidEndEditing?(self) + } + } + } + + /// :nodoc: + @objc + open func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if textField == lastSubField() && _firstInvalidAutoAdvanceField() == nil { + // User pressed return in the last field, and all fields are valid + if delegate?.responds( + to: #selector( + STPPaymentCardTextFieldDelegate.paymentCardTextFieldWillEndEditing(forReturn:)) + ) + ?? false + { + delegate?.paymentCardTextFieldWillEndEditing?(forReturn: self) + } + resignFirstResponder() + } else { + // otherwise, move to the next field + nextFirstResponderField().becomeFirstResponder() + UIAccessibility.post(notification: .screenChanged, argument: nil) + } + + return false + } + + @objc internal func brandImage( + for fieldType: STPCardFieldType, + validationState: STPCardValidationState + ) -> UIImage? { + switch fieldType { + case .number: + if validationState == .invalid { + return Self.errorImage(for: viewModel.brand) + } else { + if viewModel.hasCompleteMetadataForCardNumber { + return Self.brandImage(for: viewModel.brand) + } else { + return Self.brandImage(for: .unknown) + } + } + case .CVC: + return Self.cvcImage(for: viewModel.brand) + case .expiration: + return Self.brandImage(for: viewModel.brand) + case .postalCode: + return Self.brandImage(for: viewModel.brand) + } + } + + func brandImageAnimationOptions( + forNewType newType: STPCardFieldType, + newBrand: STPCardBrand, + oldType: STPCardFieldType, + oldBrand: STPCardBrand + ) -> UIView.AnimationOptions { + + if newType == .CVC && oldType != .CVC { + // Transitioning to show CVC + + if newBrand != .amex { + // CVC is on the back + return [.curveEaseInOut, .transitionFlipFromRight] + } + } else if newType != .CVC && oldType == .CVC { + // Transitioning to stop showing CVC + + if oldBrand != .amex { + // CVC was on the back + return [.curveEaseInOut, .transitionFlipFromLeft] + } + } + + // All other cases just cross dissolve + return [.curveEaseInOut, .transitionCrossDissolve] + + } + + func updateImage(for fieldType: STPCardFieldType) { + + // If CBC is enabled... + if let cardBrandDropDown = cardBrandDropDown { + // Show unknown card brand if we have under 9 pan digits and no card brands + // CBC dropdown always has one item (a placeholder) + if let cardNumber = cardNumber, cardNumber.count >= 8 && cardBrandDropDown.items.count > 2 { + // Show the dropdown if we have 8 or more digits and more than 2 items (placeholder + at least 2 brands), otherwise fall through and show brand as normal + cardBrandDropDown.view.isHidden = false + brandImageView.isHidden = true + } else { + cardBrandDropDown.view.isHidden = true + brandImageView.isHidden = false + } + recalculateSubviewLayout() + } + + let addLoadingIndicator: (() -> Void)? = { + if self.metadataLoadingIndicator == nil { + self.metadataLoadingIndicator = STPCardLoadingIndicator() + + self.metadataLoadingIndicator?.translatesAutoresizingMaskIntoConstraints = false + if let metadataLoadingIndicator = self.metadataLoadingIndicator { + self.addSubview(metadataLoadingIndicator) + } + NSLayoutConstraint.activate( + [ + self.metadataLoadingIndicator?.rightAnchor.constraint( + equalTo: self.brandImageView.rightAnchor + ), + self.metadataLoadingIndicator?.topAnchor.constraint( + equalTo: self.brandImageView.topAnchor + ), + ].compactMap { $0 } + ) + } + + let loadingIndicator = self.metadataLoadingIndicator + if !(loadingIndicator?.isHidden ?? false) { + return + } + + loadingIndicator?.alpha = 0.0 + loadingIndicator?.isHidden = false + UIView.animate( + withDuration: 0.6, + delay: 0, + options: .curveEaseInOut, + animations: { + loadingIndicator?.alpha = 1.0 + } + ) { _ in + loadingIndicator?.alpha = 1.0 + } + } + + let removeLoadingIndicator: (() -> Void)? = { + if self.metadataLoadingIndicator != nil + && !(self.metadataLoadingIndicator?.isHidden ?? false) + { + let loadingIndicator = self.metadataLoadingIndicator + + UIView.animate( + withDuration: 0.6, + delay: 0, + options: .curveEaseInOut, + animations: { + loadingIndicator?.alpha = 0.0 + } + ) { _ in + loadingIndicator?.alpha = 0.0 + loadingIndicator?.isHidden = true + } + } + } + + let applyBrandImage: ((STPCardFieldType, STPCardValidationState) -> Void)? = { + applyFieldType, + validationState in + let image = self.brandImage(for: applyFieldType, validationState: validationState) + if !(image == self.brandImageView.image) { + + let newBrand = self.viewModel.brand + let imageAnimationOptions = self.brandImageAnimationOptions( + forNewType: fieldType, + newBrand: newBrand, + oldType: self.currentBrandImageFieldType, + oldBrand: self.currentBrandImageBrand + ) + + self.currentBrandImageFieldType = applyFieldType + self.currentBrandImageBrand = newBrand + + UIView.transition( + with: self.brandImageView, + duration: 0.2, + options: imageAnimationOptions, + animations: { + self.brandImageView.image = image + } + ) + } + } + + if !(viewModel.hasCompleteMetadataForCardNumber) + && STPBINController.shared.isLoadingCardMetadata(forPrefix: viewModel.cardNumber ?? "") + { + applyBrandImage?(.number, .incomplete) + // delay a bit before showing loading indicator because the response may come quickly + DispatchQueue.main.asyncAfter( + deadline: DispatchTime.now() + Double( + Int64(kCardLoadingAnimationDelay * Double(NSEC_PER_SEC)) + ) + / Double(NSEC_PER_SEC), + execute: { + if !(self.viewModel.hasCompleteMetadataForCardNumber) + && STPBINController.shared.isLoadingCardMetadata( + forPrefix: self.viewModel.cardNumber ?? "" + ) + { + addLoadingIndicator?() + } + } + ) + } else { + removeLoadingIndicator?() + + switch fieldType { + case .number: + applyBrandImage?( + .number, + STPCardValidator.validationState( + forNumber: viewModel.cardNumber ?? "", + validatingCardBrand: true + ) + ) + case .expiration: + applyBrandImage?(fieldType, (viewModel.validationStateForExpiration())) + case .CVC: + applyBrandImage?(fieldType, (viewModel.validationStateForCVC())) + case .postalCode: + applyBrandImage?(fieldType, (viewModel.validationStateForPostalCode())) + } + } + } + + // MARK: Card brand choice + // For internal testing + @_spi(STP) public var alwaysEnableCBC: Bool = false { + didSet { + addCBCIfNeeded() + } + } + + private var shouldShowCBC: Bool { + // TODO: Pull this from the wallet-config endpoint + return alwaysEnableCBC + } + + private var cardBrands = Set() + func updateCardBrandsIfNeeded() { + guard cardBrandDropDown != nil else { + // Do nothing, CBC is not initializaed + return + } + self.viewModel.fetchCardBrands { [weak self] cardBrands in + self?.cardBrandDropDown?.update(items: DropdownFieldElement.items(from: cardBrands, theme: .default, maxWidth: Self.STPCBCBrandIconMaxWidth)) + self?.updateImage(for: .number) + } + } + + func defaultCVCPlaceholder() -> String? { + if viewModel.brand == .amex { + return String.Localized.cvv + } else { + return String.Localized.cvc + } + } + + func updateCVCPlaceholder() { + if let cvcPlaceholder = cvcPlaceholder { + cvcField.placeholder = cvcPlaceholder + cvcField.accessibilityLabel = cvcPlaceholder + } else { + cvcField.placeholder = defaultCVCPlaceholder() + cvcField.accessibilityLabel = defaultCVCPlaceholder() + } + } + + func onChange() { + if delegate?.responds( + to: #selector(STPPaymentCardTextFieldDelegate.paymentCardTextFieldDidChange(_:)) + ) + ?? false + { + delegate?.paymentCardTextFieldDidChange?(self) + } + sendActions(for: .valueChanged) + } + + // MARK: UIKeyInput + /// :nodoc: + @objc open var hasText: Bool { + return numberField.hasText || expirationField.hasText || cvcField.hasText + } + + /// :nodoc: + @objc + open func insertText(_ text: String) { + currentFirstResponderField()?.insertText(text) + } + + /// :nodoc: + @objc + open func deleteBackward() { + currentFirstResponderField()?.deleteBackward() + } + + /// :nodoc: + @objc + open class func keyPathsForValuesAffectingIsValid() -> Set { + return Set([ + "viewModel.isValid", + "viewModel.hasCompleteMetadataForCardNumber", + ]) + } +} + +/// This protocol allows a delegate to be notified when a payment text field's +/// contents change, which can in turn be used to take further actions depending +/// on the validity of its contents. +@objc public protocol STPPaymentCardTextFieldDelegate: NSObjectProtocol { + /// Called when either the card number, expiration, or CVC changes. At this point, + /// one can call `isValid` on the text field to determine, for example, + /// whether or not to enable a button to submit the form. Example: + /// - (void)paymentCardTextFieldDidChange:(STPPaymentCardTextField *)textField { + /// self.paymentButton.enabled = textField.isValid; + /// } + /// - Parameter textField: the text field that has changed + @objc optional func paymentCardTextFieldDidChange(_ textField: STPPaymentCardTextField) + /// Called when editing begins in the text field as a whole. + /// After receiving this callback, you will always also receive a callback for which + /// specific subfield of the view began editing. + @objc optional func paymentCardTextFieldDidBeginEditing(_ textField: STPPaymentCardTextField) + /// Notification that the user pressed the `return` key after completely filling + /// out the STPPaymentCardTextField with data that passes validation. + /// The Stripe SDK is going to `resignFirstResponder` on the `STPPaymentCardTextField` + /// to dismiss the keyboard after this delegate method returns, however if your app wants + /// to do something more (ex: move first responder to another field), this is a good + /// opportunity to do that. + /// This is delivered *before* the corresponding `paymentCardTextFieldDidEndEditing:` + /// - Parameter textField: The STPPaymentCardTextField that was being edited when the user pressed return + @objc optional func paymentCardTextFieldWillEndEditing( + forReturn textField: STPPaymentCardTextField + ) + /// Called when editing ends in the text field as a whole. + /// This callback is always preceded by an callback for which + /// specific subfield of the view ended its editing. + @objc optional func paymentCardTextFieldDidEndEditing(_ textField: STPPaymentCardTextField) + /// Called when editing begins in the payment card field's number field. + @objc optional func paymentCardTextFieldDidBeginEditingNumber( + _ textField: STPPaymentCardTextField + ) + /// Called when editing ends in the payment card field's number field. + @objc optional func paymentCardTextFieldDidEndEditingNumber( + _ textField: STPPaymentCardTextField + ) + /// Called when editing begins in the payment card field's CVC field. + @objc optional func paymentCardTextFieldDidBeginEditingCVC(_ textField: STPPaymentCardTextField) + /// Called when editing ends in the payment card field's CVC field. + @objc optional func paymentCardTextFieldDidEndEditingCVC(_ textField: STPPaymentCardTextField) + /// Called when editing begins in the payment card field's expiration field. + @objc optional func paymentCardTextFieldDidBeginEditingExpiration( + _ textField: STPPaymentCardTextField + ) + /// Called when editing ends in the payment card field's expiration field. + @objc optional func paymentCardTextFieldDidEndEditingExpiration( + _ textField: STPPaymentCardTextField + ) + /// Called when editing begins in the payment card field's ZIP/postal code field. + @objc optional func paymentCardTextFieldDidBeginEditingPostalCode( + _ textField: STPPaymentCardTextField + ) + /// Called when editing ends in the payment card field's ZIP/postal code field. + @objc optional func paymentCardTextFieldDidEndEditingPostalCode( + _ textField: STPPaymentCardTextField + ) +} + +private let kCardLoadingAnimationDelay: TimeInterval = 0.1 + +/// :nodoc: +@_spi(STP) extension STPPaymentCardTextField: STPAnalyticsProtocol { + @_spi(STP) public static var stp_analyticsIdentifier = "STPPaymentCardTextField" +} diff --git a/StripePaymentsUI/StripePaymentsUI/StripePaymentsUI.h b/StripePaymentsUI/StripePaymentsUI/StripePaymentsUI.h new file mode 100644 index 00000000..bc8b2c3e --- /dev/null +++ b/StripePaymentsUI/StripePaymentsUI/StripePaymentsUI.h @@ -0,0 +1,18 @@ +// +// StripePaymentsUI.h +// StripePaymentsUI +// +// Created by David Estes on 6/30/22. +// + +#import + +//! Project version number for StripePaymentsUI. +FOUNDATION_EXPORT double StripePaymentsUIVersionNumber; + +//! Project version string for StripePaymentsUI. +FOUNDATION_EXPORT const unsigned char StripePaymentsUIVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/StripePaymentsUI/StripePaymentsUITests/Info.plist b/StripePaymentsUI/StripePaymentsUITests/Info.plist new file mode 100644 index 00000000..64d65ca4 --- /dev/null +++ b/StripePaymentsUI/StripePaymentsUITests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/StripePaymentsUI/StripePaymentsUITests/STPAnalyticsClient+PaymentsUITests.swift b/StripePaymentsUI/StripePaymentsUITests/STPAnalyticsClient+PaymentsUITests.swift new file mode 100644 index 00000000..900ac727 --- /dev/null +++ b/StripePaymentsUI/StripePaymentsUITests/STPAnalyticsClient+PaymentsUITests.swift @@ -0,0 +1,33 @@ +// +// STPAnalyticsClient+PaymentsUITests.swift +// StripePaymentsUITests +// +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +import XCTest + +// swift-format-ignore +@_spi(STP) @testable import StripeCore + +// swift-format-ignore +@_spi(STP) @testable import StripePayments + +// swift-format-ignore +@_spi(STP) @testable import StripePaymentsUI + +class STPAnalyticsClientPaymentsUITest: XCTestCase { + func testPaymentsUISDKVariantPayload() throws { + // setup + let analytic = GenericPaymentAnalytic( + event: .paymentMethodCreation, + paymentConfiguration: nil, + productUsage: [], + additionalParams: [:] + ) + let client = STPAnalyticsClient() + let payload = client.payload(from: analytic) + XCTAssertEqual("payments-ui", payload["pay_var"] as? String) + } +} diff --git a/StripePaymentsUI/StripePaymentsUITests/STPPaymentCardTextFieldSnapshotTests.swift b/StripePaymentsUI/StripePaymentsUITests/STPPaymentCardTextFieldSnapshotTests.swift new file mode 100644 index 00000000..43b2bd8b --- /dev/null +++ b/StripePaymentsUI/StripePaymentsUITests/STPPaymentCardTextFieldSnapshotTests.swift @@ -0,0 +1,63 @@ +// +// STPPaymentCardTextFieldSnapshotTests.swift +// StripePaymentsUI +// +// Created by David Estes on 9/26/23. +// + +import iOSSnapshotTestCase +@_spi(STP)@testable import StripeCore +import StripeCoreTestUtils +import StripePaymentsTestUtils +@_spi(STP)@testable import StripePaymentsUI +@_spi(STP)@testable import StripeUICore + +class STPPaymentCardTextFieldSnapshotTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() +// self.recordMode = true + } + + var paymentCardTextField: STPPaymentCardTextField { + return STPPaymentCardTextField(frame: CGRect(x: 0, y: 0, width: 400, height: 50)) + } + + func testPaymentCardTextField() { + let pctf = paymentCardTextField + STPSnapshotVerifyView(pctf) + } + + func testPaymentCardTextFieldWithNumber() { + let pctf = paymentCardTextField + let card = STPPaymentMethodCardParams() + card.number = "4242424242424242" + card.expMonth = 12 + card.expYear = 43 + // dear future engineer in 2043: i'm sorry + card.cvc = "123" + let params = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + pctf.paymentMethodParams = params + STPSnapshotVerifyView(pctf) + } + + func testPaymentCardTextFieldCBC() { + STPAPIClient.shared.publishableKey = STPTestingDefaultPublishableKey + let pctf = paymentCardTextField + pctf.alwaysEnableCBC = true + let card = STPPaymentMethodCardParams() + card.number = "4973019750239993" + card.expMonth = 12 + card.expYear = 43 + card.cvc = "123" + let params = STPPaymentMethodParams(card: card, billingDetails: nil, metadata: nil) + pctf.paymentMethodParams = params + let exp = expectation(description: "Wait for CBC load") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + self.STPSnapshotVerifyView(pctf) + exp.fulfill() + } + waitForExpectations(timeout: 3.0) + } + +} diff --git a/StripeUICore/Project.swift b/StripeUICore/Project.swift new file mode 100644 index 00000000..1d52ef4d --- /dev/null +++ b/StripeUICore/Project.swift @@ -0,0 +1,16 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.stripeFramework( + name: "StripeUICore", + resources: "StripeUICore/Resources/**", + dependencies: [ + .project(target: "StripeCore", path: "//StripeCore"), + ], + unitTestOptions: .testOptions( + dependencies: [ + .project(target: "StripeCoreTestUtils", path: "//StripeCore"), + ], + includesSnapshots: true + ) +) diff --git a/StripeUICore/StripeUICore.xcodeproj/project.pbxproj b/StripeUICore/StripeUICore.xcodeproj/project.pbxproj new file mode 100644 index 00000000..e1a8e772 --- /dev/null +++ b/StripeUICore/StripeUICore.xcodeproj/project.pbxproj @@ -0,0 +1,1170 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + 0019E30E7B8189C3DEA719DC /* STPVPANumberValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D634B04FC897DFB74B81DED6 /* STPVPANumberValidator.swift */; }; + 019C76A03A30A67AE9F1FAEE /* PickerFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68F3046A526D3A4CE402FB83 /* PickerFieldView.swift */; }; + 02B503A3227EF1409ABF53F1 /* UIColor+StripeUICore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C4AEB27F9734178E20836A /* UIColor+StripeUICore.swift */; }; + 073D41F1EC0560423FEF87AA /* AddressSpecProviderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83BC5E25F42045563F4A660B /* AddressSpecProviderTest.swift */; }; + 08C773D1E6A5452B7BD7CF81 /* TextFieldElement+AddressFactoryTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE57C3D398C94604889D9F61 /* TextFieldElement+AddressFactoryTest.swift */; }; + 0A3130227F7602524C9824D3 /* TextFieldElement+AddressFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDD7C1AAB9D26E00526AFB26 /* TextFieldElement+AddressFactory.swift */; }; + 0BA1E7C26903E45912FDE25A /* String+Localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D558F65E4C23971C94BAD3E /* String+Localized.swift */; }; + 0CB675370A06DEC6F23608C4 /* AddressSpec+ElementFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A4D27EC1A9207F327B1BF20 /* AddressSpec+ElementFactory.swift */; }; + 0D8AE1CEFBE7FA004A3DD62D /* CheckboxButtonSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D917F78E89D67691EF760D14 /* CheckboxButtonSnapshotTests.swift */; }; + 0FBBB5C7FD2DA9A11E7FE2BC /* FloatingPlaceholderTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD764D16A365FD46700FB25 /* FloatingPlaceholderTextFieldView.swift */; }; + 11BD8DFB36FACB6966D0236F /* String+CountryEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = A73D88DFAB03D663217399D0 /* String+CountryEmoji.swift */; }; + 11C99D83A1DA88996A45BA47 /* DynamicImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE775CAF20488D81C9167B4 /* DynamicImageView.swift */; }; + 1424B1529E24582122F86149 /* PhoneNumberElementSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7796F7211034806DA24D1710 /* PhoneNumberElementSnapshotTests.swift */; }; + 159D7B9A2960A1CD7E661191 /* UIStackView+StripeUICore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5855EA2F4F38847E3E47935D /* UIStackView+StripeUICore.swift */; }; + 163A77E9E4C40AFFA5226E23 /* TestFieldElement+AccountFactoryTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83462E27CEA3580E1BD3E2CD /* TestFieldElement+AccountFactoryTest.swift */; }; + 1EB16D8F60923EE9C890CE43 /* STPEmailAddressValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE233EED40F9D3FADDFE5951 /* STPEmailAddressValidator.swift */; }; + 225B20CEF547BB1F6C6D447E /* BSBNumberProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3DD58BEF337017E7286459 /* BSBNumberProvider.swift */; }; + 2AA023A5031CFF0E56C7A14E /* DateFieldElementTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08B0431535F20802E386624B /* DateFieldElementTest.swift */; }; + 2ADEF1482E62A173A0C7F7AD /* au_becs_bsb.json in Resources */ = {isa = PBXBuildFile; fileRef = 6E66B0CF81D4F8EDBB642C9F /* au_becs_bsb.json */; }; + 2F1D03471202E25FD7682148 /* Locale+StripeUICore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36342ED77935BE9A32B082EC /* Locale+StripeUICore.swift */; }; + 2FD444AC5FC064EFCF6259ED /* STPVPANumberValidatorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53AA9FCEF2640C493D140033 /* STPVPANumberValidatorTest.swift */; }; + 30972A45F8A32DEEC17DA4F6 /* UITraitCollection+StripeUICore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAA22461E52111D9A4E8B133 /* UITraitCollection+StripeUICore.swift */; }; + 32971AFF5D0DF9B28E9C464C /* SectionContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C0A3DE4BCF8DA03AD17144 /* SectionContainerView.swift */; }; + 32D69CBB3CE780A29AA553AB /* BSBNumberProviderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0408AC0298D342DADD1F84A5 /* BSBNumberProviderTest.swift */; }; + 336B882978CA47EE46260774 /* SectionElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BB17B284B6D063AF0329DAD /* SectionElement.swift */; }; + 343DD093A6095DEAA06D245E /* InputFormColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB5A05E653BFD01FF65E193C /* InputFormColors.swift */; }; + 36376E12AE7ABA21FFC474E6 /* Events.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4844F0B5436706225DE5A176 /* Events.swift */; }; + 377058C2363FA0348AFBD32E /* AddressSectionElement+DummyAddressLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B66D666BB3EA733B92A60AA /* AddressSectionElement+DummyAddressLine.swift */; }; + 39E61B5E6A88E5AF1922EE62 /* TextFieldFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34178B51599FDCB0D0D7475A /* TextFieldFormatter.swift */; }; + 4755A24604B396D5A25058CD /* StripeUICore.h in Headers */ = {isa = PBXBuildFile; fileRef = CB8A9F7B4B2E8AA5A7E4FE98 /* StripeUICore.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 48D3B7C2983A8A25C6599119 /* OneTimeCodeTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC2BFFB32C50AB4EECA90326 /* OneTimeCodeTextField.swift */; }; + 4A5EADAF2F6514299BA4B8D8 /* FormElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB1FE2B6BCD6FC77117A2521 /* FormElement.swift */; }; + 4B05CF7F485F0AED498DAE49 /* OneTimeCodeTextField-TextStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 815DB8DE48AE3CE1609C8316 /* OneTimeCodeTextField-TextStorage.swift */; }; + 4B414F0A0E46D914C89B3741 /* StaticElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 583B93BE0152CDF7383A37E7 /* StaticElement.swift */; }; + 4C519A87445AB16A55FE2408 /* PhoneNumberElementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 794D2AA5365F9DE155083927 /* PhoneNumberElementTests.swift */; }; + 4C98A44C61BC71CABF8A9BF2 /* StripeCoreTestUtils.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 40DF1A7FC5D84F937042D172 /* StripeCoreTestUtils.framework */; }; + 4CD207111219BF250A400ACC /* UIWindow+StripeUICore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59215CEEBA0D56FF41C3E412 /* UIWindow+StripeUICore.swift */; }; + 504FA2DE5FE66FDE90842019 /* STPLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4F4CE2A0B16DA57DB249227 /* STPLocalizedString.swift */; }; + 57C288DFD2CC2CFC216E47CC /* PickerTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0355EA21D04DDC29E620CEA /* PickerTextField.swift */; }; + 5936629C4665BC698C3458B1 /* TextFieldElementConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8367A676E35A663E320E1B37 /* TextFieldElementConfiguration.swift */; }; + 5A9B21FE5A6941713087B94B /* SectionElement+MultiElementRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6737026DF19C8D10AE8114A /* SectionElement+MultiElementRow.swift */; }; + 62601F856C41CFA1C7A8B18F /* UIView+StripeUICore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDBBC76FD31F07EC67C52075 /* UIView+StripeUICore.swift */; }; + 63632799CD2134991E0EA510 /* iOSSnapshotTestCase in Frameworks */ = {isa = PBXBuildFile; productRef = 9B701A244243959A191FF16F /* iOSSnapshotTestCase */; }; + 64E61A5E0A705F1C4582381A /* PhoneNumberTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263B7E56A83CFA4BA8385638 /* PhoneNumberTests.swift */; }; + 65B9A839BD4AAC315231B421 /* TextFieldElement+AccountFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69C3FF914611057972ABA41 /* TextFieldElement+AccountFactory.swift */; }; + 6606DC43D230ADD183AEF5DA /* RegionCodeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA00332BBDCE27F6F5A615C4 /* RegionCodeProvider.swift */; }; + 67216EB4E004BDB1D2E49BD4 /* IDNumberTextFieldConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92623B785C43F351D8A944D1 /* IDNumberTextFieldConfiguration.swift */; }; + 67FCE4493235656689E915F6 /* UIBarButtonItem+StripeUICore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5321D6CCBA2AA9162A96080C /* UIBarButtonItem+StripeUICore.swift */; }; + 68F7D5EEB894A68DDC184ADA /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20209CDCC3856CE548DA4D25 /* Image.swift */; }; + 69B082B15479DCC4560E3D92 /* UIColor+StripeUICoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48C9A13C4663EA8ACA6EA2E5 /* UIColor+StripeUICoreTests.swift */; }; + 6CB223E48029E6BA6ED48041 /* LinkOpeningTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E80F72A0A72DDC5F246ED51F /* LinkOpeningTextView.swift */; }; + 710D7FB87C39EEB0DB1F3E75 /* STPBlikCodeValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99B2D80F3FBAC287CBF86B0 /* STPBlikCodeValidator.swift */; }; + 717D176DAF084461C18F2A09 /* StripeUICore.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 69B1E4AE618E237C0EE5036F /* StripeUICore.xcassets */; }; + 7272A43410D4BA1365D71E70 /* UIButton+StripeUICore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A60A0D2417D3A70D500EE30 /* UIButton+StripeUICore.swift */; }; + 74F65B6435E46B0FC8386FBB /* BSBNumberTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CCBFC1830139737F2E947F1 /* BSBNumberTests.swift */; }; + 778BACD1A29BEDEE21ED3FBE /* ActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8678CFB3968AE5232932C461 /* ActivityIndicator.swift */; }; + 7B90479C19C30407FC21B228 /* ImageMaker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A681C4DEAE7B35B4DC0BD0FB /* ImageMaker.swift */; }; + 7E0A56FBC86BCBB58D0440AE /* StripeCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B8DC1E33CDF3B3FE549A7210 /* StripeCore.framework */; }; + 80B0519BC9CC21D9B650FC88 /* UISpringTimingParameters+StripeUICore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A81696ECEEF728F25202B6 /* UISpringTimingParameters+StripeUICore.swift */; }; + 824858D45F9D952BBDF822E2 /* CALayer+StripeUICore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8B1E38153B0C5ED6CD459C /* CALayer+StripeUICore.swift */; }; + 86427678E119E4AD22410E30 /* PhoneNumberElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D0478259F9F7A04A8CF43BB /* PhoneNumberElement.swift */; }; + 88F53AB8F31B1DA2187E5740 /* IDNumberTextFieldConfigurationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8995A59E3BC6D26B8030C874 /* IDNumberTextFieldConfigurationTest.swift */; }; + 8B8D3BD090415EFF9948D2C2 /* ContainerElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4893E4B43FEFCE920D432F0C /* ContainerElement.swift */; }; + 900EFF5918D96B9716CFB673 /* UIFont+StripeUICore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43943E8FF7CD279F8D8605D3 /* UIFont+StripeUICore.swift */; }; + 93CF3CE90B520B4A25208E95 /* SectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF3C9C0D2302B8E7F421EFBD /* SectionView.swift */; }; + 976AFE0D02FE65BFA1757E4E /* NSAttributedString+StripeUICoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982AA7FCDBD1D5264A5FB040 /* NSAttributedString+StripeUICoreTests.swift */; }; + 986924FDA1EEF4146CD81B50 /* TextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A312A55A629CED2C6404F1 /* TextFieldView.swift */; }; + 993E51173490AAE5D7AF4C4E /* TextFieldElementTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9131CB11D2A0898B16D44C4E /* TextFieldElementTest.swift */; }; + 9DBFEB7045692EE931CE014D /* Locale+StripeUICoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2749BB93AD1B5A9FB3B9B97 /* Locale+StripeUICoreTests.swift */; }; + 9DEAD347B741A53E2F1764B4 /* TextFieldElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = A004788FA286E5ED22334238 /* TextFieldElement.swift */; }; + 9E308BC63E9FB185619E5859 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D4621DA31A1A5FC51F76E562 /* XCTest.framework */; }; + A21277FDCDD0C6BFB73A4B51 /* SectionElementTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62539E4F0904670C008DB18E /* SectionElementTest.swift */; }; + A29B5AC2F03116E2F48970EE /* TextOrDropdownElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDD864C601BFB09A851715FA /* TextOrDropdownElement.swift */; }; + A3F0D42EB3A3FF2299F2F473 /* BankRoutingNumber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312236317D0DA79F2D32CBE2 /* BankRoutingNumber.swift */; }; + A5C379C7D1EEF497FD845306 /* STPEmailAddressValidatorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312E77021827300B2504F016 /* STPEmailAddressValidatorTest.swift */; }; + A5E59185A9708613676988C6 /* DateFieldElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = B274D6A71A11DA7B98D502AA /* DateFieldElement.swift */; }; + AA8F938C3A8B7BC7F11B5048 /* Enums+CustomStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EECCC1035F00C122B2B1ED /* Enums+CustomStringConvertible.swift */; }; + B106FCB3D7C3DE7C40F0AE5C /* AddressSectionElementTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C55F7532752871E779F47278 /* AddressSectionElementTest.swift */; }; + B1B177526E04EE65A9D1C64A /* DynamicHeightContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9E7CFED5747279A5976D7BA /* DynamicHeightContainerView.swift */; }; + B6107E5E2D6E116417D22DD9 /* StripeUICoreBundleLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E68363D1C6BB6468AC1DDA4C /* StripeUICoreBundleLocator.swift */; }; + BC40443B2A2F7130351589A7 /* NSAttributedString+StripeUICore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B8AFA3891DA214D9FBEF650 /* NSAttributedString+StripeUICore.swift */; }; + BE42104922DCA4DCB3919DD4 /* AddressSectionElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5CCD5D11A49D48D8D21C62 /* AddressSectionElement.swift */; }; + BEB4F0E3B6218CA3E5DE95F9 /* STPBlikCodeValidatorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2055BFB32FE9CF519DC25C8F /* STPBlikCodeValidatorTest.swift */; }; + C1CA6209591EDDBBE019FF22 /* FormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 558DF5BAEF6E23E0C0F2CFF1 /* FormView.swift */; }; + C23C78D87D8E6682F31345CB /* DropdownFieldElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BAD4C8EE686B0F9E86DBC8D /* DropdownFieldElement.swift */; }; + C3D6D899B671398717F22520 /* CheckboxElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F1EF8010A475D99C7190FF9 /* CheckboxElement.swift */; }; + C44A57646A325EE26B75E6BF /* CheckboxButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F6E799C8CB484FE83CB87E0 /* CheckboxButton.swift */; }; + C6B11F4F219F7ED04321A33F /* DateFieldElementSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E2652D09C1A4EBBDE2FB61 /* DateFieldElementSnapshotTest.swift */; }; + CF9D4CC40A7008DD1E8136A3 /* DoneButtonToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19F61D5356203ED977ED42E /* DoneButtonToolbar.swift */; }; + D01976C07EB39B2BED64CCAC /* ButtonSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5E2F9A5B1A3E8E7B765E4F /* ButtonSnapshotTest.swift */; }; + D083BAAF86707F9865AF2AF4 /* String+RegionCodeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA1688A2CE2037846F8E3937 /* String+RegionCodeProvider.swift */; }; + D47D77A0B82DC6AE15E0A74E /* UIViewController+StripeUICore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12973742CA1AA39D3B84F072 /* UIViewController+StripeUICore.swift */; }; + D6AEB6D5567AAD1B44E4AF70 /* StripeUICore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 868B2E8EDC242DB5AFFB3D0C /* StripeUICore.framework */; }; + D6D8BCCF86C964B40D2FA58E /* UIKeyboardType+StripeUICore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE651DD3E5873538C7CE743 /* UIKeyboardType+StripeUICore.swift */; }; + D7DB5C5724CD47E33245B25A /* Element.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB02AB36074791D39F0CDC1 /* Element.swift */; }; + D912BF580DBAB7416040B637 /* localized_address_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 4E25905FCD05DF8B72888AAF /* localized_address_data.json */; }; + DA6550F1FFA1376DB656D6E0 /* NSDirectionalEdgeInsets+StripeUICore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB52FF6CAC981EFA60A81AFF /* NSDirectionalEdgeInsets+StripeUICore.swift */; }; + E40999CDEDEA23451CA89707 /* TextFieldElement+Validation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFB80CA75F00847E4D74F821 /* TextFieldElement+Validation.swift */; }; + E94BA0179485AED17D412865 /* AddressSpecProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B844EED0578B990F4772CD01 /* AddressSpecProvider.swift */; }; + E9CA12DAB591AC834CE9539A /* AddressSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC72344B4B313CE07A8AA33 /* AddressSpec.swift */; }; + ECCA9BD118D763DBF658E5FD /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 71C8163AEB97D2FF8BB3A1C8 /* Localizable.strings */; }; + EDDE1C83333AB1A1F2BD0F3E /* StackViewWithSeparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72394C7783071B1C6ED82A48 /* StackViewWithSeparator.swift */; }; + EE28852FFCF42A8C47098051 /* TextFieldFormatterTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32FABC26C892C74FD444E88 /* TextFieldFormatterTest.swift */; }; + F0EB247FEFF4600CD44B8261 /* DropdownFieldElementTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF3B67DE90B0C1D71257D333 /* DropdownFieldElementTest.swift */; }; + F86769C5CFD9AD3732127951 /* DropdownFieldElement+AddressFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374D0BE980D58B75FA04DE66 /* DropdownFieldElement+AddressFactory.swift */; }; + F901303E0B78F2D8E4C8A2F1 /* PhoneNumber.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF05370E4825D3225D9A6910 /* PhoneNumber.swift */; }; + FAD790056C7A9E645A6B2C74 /* ElementsUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85AC8D5E9700C8471D225C22 /* ElementsUI.swift */; }; + FB33F2F446570394AABB7EC7 /* CompatibleColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E657F1ABE46BA5C0692D9D41 /* CompatibleColor.swift */; }; + FC1F8C4DC70C8212B507AE7E /* AddressSectionElementSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB7836BA51814D0841B688E1 /* AddressSectionElementSnapshotTest.swift */; }; + FC48FCDC5FD43E5E8AFC32D2 /* TextFieldElement+Factory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D4BD46908791D2E4150C4DC /* TextFieldElement+Factory.swift */; }; + FDF52A43A01CD72D4B5A2CA9 /* Button.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB48FCA3B2447A5F4CCEC69 /* Button.swift */; }; + FF4E844383E5A5FD5C099B41 /* DropdownFieldElementSnapshotTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62575FAAAAEFF31406C9B417 /* DropdownFieldElementSnapshotTest.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 980541F3E23EAD7E20DBCA47 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A39BBBD15F0F6B54725E52AF /* Project object */; + proxyType = 1; + remoteGlobalIDString = DE3C3F3D3BB67DD660A44B1E; + remoteInfo = StripeUICore; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 7823EBE0BD66DC6070DC1530 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + 8B58BADB5D787E66A0A50257 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 00455FBF8F3D7C9B0E65DA54 /* lv-LV */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "lv-LV"; path = "lv-LV.lproj/Localizable.strings"; sourceTree = ""; }; + 011872F74559CB0D0D61170C /* sk-SK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sk-SK"; path = "sk-SK.lproj/Localizable.strings"; sourceTree = ""; }; + 02DA3E661669718BD61C702D /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 0408AC0298D342DADD1F84A5 /* BSBNumberProviderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BSBNumberProviderTest.swift; sourceTree = ""; }; + 08B0431535F20802E386624B /* DateFieldElementTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFieldElementTest.swift; sourceTree = ""; }; + 0D558F65E4C23971C94BAD3E /* String+Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Localized.swift"; sourceTree = ""; }; + 0ED24D748AEB8B1E0BA1FBAD /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; + 11C37DE7B064DC9E3F7E55CD /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; + 12973742CA1AA39D3B84F072 /* UIViewController+StripeUICore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+StripeUICore.swift"; sourceTree = ""; }; + 18C5B42A6D5AF223F0CFB23F /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + 1A50D68D24D1DE0459ED9529 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; + 20209CDCC3856CE548DA4D25 /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = ""; }; + 2055BFB32FE9CF519DC25C8F /* STPBlikCodeValidatorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPBlikCodeValidatorTest.swift; sourceTree = ""; }; + 263B7E56A83CFA4BA8385638 /* PhoneNumberTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneNumberTests.swift; sourceTree = ""; }; + 2AAE278AD27DB71B88C97B1A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 2BAA056C8170A4B0E8C763AD /* sl-SI */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "sl-SI"; path = "sl-SI.lproj/Localizable.strings"; sourceTree = ""; }; + 2D9B5640C88DD94D17666E0E /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; + 312236317D0DA79F2D32CBE2 /* BankRoutingNumber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BankRoutingNumber.swift; sourceTree = ""; }; + 312E77021827300B2504F016 /* STPEmailAddressValidatorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPEmailAddressValidatorTest.swift; sourceTree = ""; }; + 34178B51599FDCB0D0D7475A /* TextFieldFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldFormatter.swift; sourceTree = ""; }; + 34EC2F4C025AD1B1DD44CF78 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; + 36342ED77935BE9A32B082EC /* Locale+StripeUICore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locale+StripeUICore.swift"; sourceTree = ""; }; + 374D0BE980D58B75FA04DE66 /* DropdownFieldElement+AddressFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DropdownFieldElement+AddressFactory.swift"; sourceTree = ""; }; + 3A99D0F01D29E302C52217FE /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; + 3B8AFA3891DA214D9FBEF650 /* NSAttributedString+StripeUICore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+StripeUICore.swift"; sourceTree = ""; }; + 40DF1A7FC5D84F937042D172 /* StripeCoreTestUtils.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCoreTestUtils.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 40E0822DE6CFA0D3CCD5D513 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 43943E8FF7CD279F8D8605D3 /* UIFont+StripeUICore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+StripeUICore.swift"; sourceTree = ""; }; + 4844F0B5436706225DE5A176 /* Events.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Events.swift; sourceTree = ""; }; + 4893E4B43FEFCE920D432F0C /* ContainerElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerElement.swift; sourceTree = ""; }; + 48C9A13C4663EA8ACA6EA2E5 /* UIColor+StripeUICoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+StripeUICoreTests.swift"; sourceTree = ""; }; + 48E2652D09C1A4EBBDE2FB61 /* DateFieldElementSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFieldElementSnapshotTest.swift; sourceTree = ""; }; + 4B66D666BB3EA733B92A60AA /* AddressSectionElement+DummyAddressLine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AddressSectionElement+DummyAddressLine.swift"; sourceTree = ""; }; + 4CBAE2B07C5575F19470464D /* StripeiOS-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS-Release.xcconfig"; sourceTree = ""; }; + 4D4BD46908791D2E4150C4DC /* TextFieldElement+Factory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextFieldElement+Factory.swift"; sourceTree = ""; }; + 4E25905FCD05DF8B72888AAF /* localized_address_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = localized_address_data.json; sourceTree = ""; }; + 4EE651DD3E5873538C7CE743 /* UIKeyboardType+StripeUICore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIKeyboardType+StripeUICore.swift"; sourceTree = ""; }; + 4F37C42EA28BA31EC5CCDC7C /* fr-CA */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "fr-CA"; path = "fr-CA.lproj/Localizable.strings"; sourceTree = ""; }; + 4FD764D16A365FD46700FB25 /* FloatingPlaceholderTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingPlaceholderTextFieldView.swift; sourceTree = ""; }; + 51C4AEB27F9734178E20836A /* UIColor+StripeUICore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+StripeUICore.swift"; sourceTree = ""; }; + 5321D6CCBA2AA9162A96080C /* UIBarButtonItem+StripeUICore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBarButtonItem+StripeUICore.swift"; sourceTree = ""; }; + 53900894BD0E2FF59029D2B6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; + 53AA9FCEF2640C493D140033 /* STPVPANumberValidatorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPVPANumberValidatorTest.swift; sourceTree = ""; }; + 558DF5BAEF6E23E0C0F2CFF1 /* FormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormView.swift; sourceTree = ""; }; + 583B93BE0152CDF7383A37E7 /* StaticElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticElement.swift; sourceTree = ""; }; + 5855EA2F4F38847E3E47935D /* UIStackView+StripeUICore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIStackView+StripeUICore.swift"; sourceTree = ""; }; + 59215CEEBA0D56FF41C3E412 /* UIWindow+StripeUICore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindow+StripeUICore.swift"; sourceTree = ""; }; + 5B5CCD5D11A49D48D8D21C62 /* AddressSectionElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressSectionElement.swift; sourceTree = ""; }; + 5BAD4C8EE686B0F9E86DBC8D /* DropdownFieldElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropdownFieldElement.swift; sourceTree = ""; }; + 62539E4F0904670C008DB18E /* SectionElementTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionElementTest.swift; sourceTree = ""; }; + 62575FAAAAEFF31406C9B417 /* DropdownFieldElementSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropdownFieldElementSnapshotTest.swift; sourceTree = ""; }; + 64EECCC1035F00C122B2B1ED /* Enums+CustomStringConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Enums+CustomStringConvertible.swift"; sourceTree = ""; }; + 67C454346BE566F9F689543B /* Project-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Debug.xcconfig"; sourceTree = ""; }; + 68F3046A526D3A4CE402FB83 /* PickerFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickerFieldView.swift; sourceTree = ""; }; + 699ED5466892D95ADC151B64 /* ro-RO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ro-RO"; path = "ro-RO.lproj/Localizable.strings"; sourceTree = ""; }; + 69B1E4AE618E237C0EE5036F /* StripeUICore.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = StripeUICore.xcassets; sourceTree = ""; }; + 6A4D27EC1A9207F327B1BF20 /* AddressSpec+ElementFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AddressSpec+ElementFactory.swift"; sourceTree = ""; }; + 6E66B0CF81D4F8EDBB642C9F /* au_becs_bsb.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = au_becs_bsb.json; sourceTree = ""; }; + 6F6E799C8CB484FE83CB87E0 /* CheckboxButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxButton.swift; sourceTree = ""; }; + 70B4BE2ACC46DF88949121CD /* zh-HK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-HK"; path = "zh-HK.lproj/Localizable.strings"; sourceTree = ""; }; + 71A81696ECEEF728F25202B6 /* UISpringTimingParameters+StripeUICore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISpringTimingParameters+StripeUICore.swift"; sourceTree = ""; }; + 71DCDDEE36E2CD98E4A03752 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; + 72394C7783071B1C6ED82A48 /* StackViewWithSeparator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackViewWithSeparator.swift; sourceTree = ""; }; + 72EB2DE23B3740D8DA3081E5 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; + 74EDF4CC65F409F55E14EB25 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; + 7796F7211034806DA24D1710 /* PhoneNumberElementSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneNumberElementSnapshotTests.swift; sourceTree = ""; }; + 794D2AA5365F9DE155083927 /* PhoneNumberElementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneNumberElementTests.swift; sourceTree = ""; }; + 7B899349F7EF770D8F19509D /* ca-ES */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ca-ES"; path = "ca-ES.lproj/Localizable.strings"; sourceTree = ""; }; + 7B8B1E38153B0C5ED6CD459C /* CALayer+StripeUICore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CALayer+StripeUICore.swift"; sourceTree = ""; }; + 7BB17B284B6D063AF0329DAD /* SectionElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionElement.swift; sourceTree = ""; }; + 7D4E60C42E09A23ACD60BCD8 /* bg-BG */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "bg-BG"; path = "bg-BG.lproj/Localizable.strings"; sourceTree = ""; }; + 7DC72344B4B313CE07A8AA33 /* AddressSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressSpec.swift; sourceTree = ""; }; + 7EB48FCA3B2447A5F4CCEC69 /* Button.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Button.swift; sourceTree = ""; }; + 815DB8DE48AE3CE1609C8316 /* OneTimeCodeTextField-TextStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OneTimeCodeTextField-TextStorage.swift"; sourceTree = ""; }; + 81A4AE07CB9049B6D8C8D7D2 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; + 8278020A829AE8C5CB8B6A9C /* ms-MY */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "ms-MY"; path = "ms-MY.lproj/Localizable.strings"; sourceTree = ""; }; + 83462E27CEA3580E1BD3E2CD /* TestFieldElement+AccountFactoryTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TestFieldElement+AccountFactoryTest.swift"; sourceTree = ""; }; + 8367A676E35A663E320E1B37 /* TextFieldElementConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldElementConfiguration.swift; sourceTree = ""; }; + 83BC5E25F42045563F4A660B /* AddressSpecProviderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressSpecProviderTest.swift; sourceTree = ""; }; + 848196B6CCE964A735BFCD46 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; + 85AC8D5E9700C8471D225C22 /* ElementsUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementsUI.swift; sourceTree = ""; }; + 8678CFB3968AE5232932C461 /* ActivityIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicator.swift; sourceTree = ""; }; + 868B2E8EDC242DB5AFFB3D0C /* StripeUICore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeUICore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8995A59E3BC6D26B8030C874 /* IDNumberTextFieldConfigurationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IDNumberTextFieldConfigurationTest.swift; sourceTree = ""; }; + 8A60A0D2417D3A70D500EE30 /* UIButton+StripeUICore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIButton+StripeUICore.swift"; sourceTree = ""; }; + 8D0478259F9F7A04A8CF43BB /* PhoneNumberElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneNumberElement.swift; sourceTree = ""; }; + 8F1EF8010A475D99C7190FF9 /* CheckboxElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxElement.swift; sourceTree = ""; }; + 9131CB11D2A0898B16D44C4E /* TextFieldElementTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldElementTest.swift; sourceTree = ""; }; + 92623B785C43F351D8A944D1 /* IDNumberTextFieldConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IDNumberTextFieldConfiguration.swift; sourceTree = ""; }; + 94FB3B96E7884F5B5C1C3EA8 /* fil */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fil; path = fil.lproj/Localizable.strings; sourceTree = ""; }; + 9559041BED6315C71F796A5F /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; + 982AA7FCDBD1D5264A5FB040 /* NSAttributedString+StripeUICoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+StripeUICoreTests.swift"; sourceTree = ""; }; + 9CCBFC1830139737F2E947F1 /* BSBNumberTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BSBNumberTests.swift; sourceTree = ""; }; + 9E3254388F5DAB8E8C76AE04 /* StripeiOS Tests-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS Tests-Debug.xcconfig"; sourceTree = ""; }; + A004788FA286E5ED22334238 /* TextFieldElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldElement.swift; sourceTree = ""; }; + A032BB17A6FA85EE4E3D00AE /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; + A1A02A2319937F5033495984 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; + A360D5AE016620B560FB8A77 /* cs-CZ */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "cs-CZ"; path = "cs-CZ.lproj/Localizable.strings"; sourceTree = ""; }; + A4F4CE2A0B16DA57DB249227 /* STPLocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPLocalizedString.swift; sourceTree = ""; }; + A681C4DEAE7B35B4DC0BD0FB /* ImageMaker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageMaker.swift; sourceTree = ""; }; + A73D88DFAB03D663217399D0 /* String+CountryEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+CountryEmoji.swift"; sourceTree = ""; }; + AB2C786B198F91AE124C790A /* lt-LT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "lt-LT"; path = "lt-LT.lproj/Localizable.strings"; sourceTree = ""; }; + AC2BFFB32C50AB4EECA90326 /* OneTimeCodeTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneTimeCodeTextField.swift; sourceTree = ""; }; + AE4AB8A1DB70CDE06D9887CF /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; + B081CD063903B2FDBE327A15 /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/Localizable.strings; sourceTree = ""; }; + B2161853A89C273B745B8A60 /* el-GR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "el-GR"; path = "el-GR.lproj/Localizable.strings"; sourceTree = ""; }; + B274D6A71A11DA7B98D502AA /* DateFieldElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFieldElement.swift; sourceTree = ""; }; + B32FABC26C892C74FD444E88 /* TextFieldFormatterTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldFormatterTest.swift; sourceTree = ""; }; + B6737026DF19C8D10AE8114A /* SectionElement+MultiElementRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SectionElement+MultiElementRow.swift"; sourceTree = ""; }; + B844EED0578B990F4772CD01 /* AddressSpecProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressSpecProvider.swift; sourceTree = ""; }; + B84B32F86F684EA5233DDED5 /* nn-NO */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "nn-NO"; path = "nn-NO.lproj/Localizable.strings"; sourceTree = ""; }; + B871CED2EB8A1F7EEC0FE087 /* pl-PL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pl-PL"; path = "pl-PL.lproj/Localizable.strings"; sourceTree = ""; }; + B8DC1E33CDF3B3FE549A7210 /* StripeCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripeCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B9CAB799E5645867F2400F0F /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Localizable.strings"; sourceTree = ""; }; + B9E7CFED5747279A5976D7BA /* DynamicHeightContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicHeightContainerView.swift; sourceTree = ""; }; + BA1688A2CE2037846F8E3937 /* String+RegionCodeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+RegionCodeProvider.swift"; sourceTree = ""; }; + BA366F6105D04C9136240341 /* en-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-GB"; path = "en-GB.lproj/Localizable.strings"; sourceTree = ""; }; + BB7836BA51814D0841B688E1 /* AddressSectionElementSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressSectionElementSnapshotTest.swift; sourceTree = ""; }; + BD52DCDA490D452AAA4B5E44 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + C0355EA21D04DDC29E620CEA /* PickerTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickerTextField.swift; sourceTree = ""; }; + C19F61D5356203ED977ED42E /* DoneButtonToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoneButtonToolbar.swift; sourceTree = ""; }; + C55F7532752871E779F47278 /* AddressSectionElementTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressSectionElementTest.swift; sourceTree = ""; }; + C85C015B47A8EBF87E5F662E /* et-EE */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "et-EE"; path = "et-EE.lproj/Localizable.strings"; sourceTree = ""; }; + CB52FF6CAC981EFA60A81AFF /* NSDirectionalEdgeInsets+StripeUICore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSDirectionalEdgeInsets+StripeUICore.swift"; sourceTree = ""; }; + CB8A9F7B4B2E8AA5A7E4FE98 /* StripeUICore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StripeUICore.h; sourceTree = ""; }; + CC3DD58BEF337017E7286459 /* BSBNumberProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BSBNumberProvider.swift; sourceTree = ""; }; + CDE775CAF20488D81C9167B4 /* DynamicImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicImageView.swift; sourceTree = ""; }; + CF3B67DE90B0C1D71257D333 /* DropdownFieldElementTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropdownFieldElementTest.swift; sourceTree = ""; }; + CF3C9C0D2302B8E7F421EFBD /* SectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionView.swift; sourceTree = ""; }; + CFB80CA75F00847E4D74F821 /* TextFieldElement+Validation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextFieldElement+Validation.swift"; sourceTree = ""; }; + D1FB364DF60C1DEE0DA8EE75 /* Project-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Project-Release.xcconfig"; sourceTree = ""; }; + D2749BB93AD1B5A9FB3B9B97 /* Locale+StripeUICoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locale+StripeUICoreTests.swift"; sourceTree = ""; }; + D4621DA31A1A5FC51F76E562 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; + D59C1B7B69C92DFC9A25DAB2 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/Localizable.strings"; sourceTree = ""; }; + D5A312A55A629CED2C6404F1 /* TextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldView.swift; sourceTree = ""; }; + D634B04FC897DFB74B81DED6 /* STPVPANumberValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPVPANumberValidator.swift; sourceTree = ""; }; + D69C3FF914611057972ABA41 /* TextFieldElement+AccountFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextFieldElement+AccountFactory.swift"; sourceTree = ""; }; + D917F78E89D67691EF760D14 /* CheckboxButtonSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxButtonSnapshotTests.swift; sourceTree = ""; }; + DE233EED40F9D3FADDFE5951 /* STPEmailAddressValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPEmailAddressValidator.swift; sourceTree = ""; }; + E0C8DFA9A617506071C59815 /* mt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mt; path = mt.lproj/Localizable.strings; sourceTree = ""; }; + E242FECC90FE2644CF99692D /* StripeiOS Tests-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS Tests-Release.xcconfig"; sourceTree = ""; }; + E395C593AC970A8C79A8109C /* StripeiOS-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "StripeiOS-Debug.xcconfig"; sourceTree = ""; }; + E522C47E0874F612E330DA38 /* StripeUICoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StripeUICoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + E657F1ABE46BA5C0692D9D41 /* CompatibleColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompatibleColor.swift; sourceTree = ""; }; + E68363D1C6BB6468AC1DDA4C /* StripeUICoreBundleLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeUICoreBundleLocator.swift; sourceTree = ""; }; + E80F72A0A72DDC5F246ED51F /* LinkOpeningTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkOpeningTextView.swift; sourceTree = ""; }; + E99B2D80F3FBAC287CBF86B0 /* STPBlikCodeValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPBlikCodeValidator.swift; sourceTree = ""; }; + EA00332BBDCE27F6F5A615C4 /* RegionCodeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegionCodeProvider.swift; sourceTree = ""; }; + EAB02AB36074791D39F0CDC1 /* Element.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Element.swift; sourceTree = ""; }; + EB1FE2B6BCD6FC77117A2521 /* FormElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormElement.swift; sourceTree = ""; }; + EB5E2F9A5B1A3E8E7B765E4F /* ButtonSnapshotTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonSnapshotTest.swift; sourceTree = ""; }; + EB942257C8B6B357AD0F6FC9 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; + EDD7C1AAB9D26E00526AFB26 /* TextFieldElement+AddressFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextFieldElement+AddressFactory.swift"; sourceTree = ""; }; + EDD864C601BFB09A851715FA /* TextOrDropdownElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextOrDropdownElement.swift; sourceTree = ""; }; + F1C0A3DE4BCF8DA03AD17144 /* SectionContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionContainerView.swift; sourceTree = ""; }; + FAA22461E52111D9A4E8B133 /* UITraitCollection+StripeUICore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITraitCollection+StripeUICore.swift"; sourceTree = ""; }; + FB5A05E653BFD01FF65E193C /* InputFormColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputFormColors.swift; sourceTree = ""; }; + FDBBC76FD31F07EC67C52075 /* UIView+StripeUICore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+StripeUICore.swift"; sourceTree = ""; }; + FE57C3D398C94604889D9F61 /* TextFieldElement+AddressFactoryTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextFieldElement+AddressFactoryTest.swift"; sourceTree = ""; }; + FF05370E4825D3225D9A6910 /* PhoneNumber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneNumber.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 0D674E67240745BB10E9C307 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7E0A56FBC86BCBB58D0440AE /* StripeCore.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B361F19E111D84FD84927757 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9E308BC63E9FB185619E5859 /* XCTest.framework in Frameworks */, + 4C98A44C61BC71CABF8A9BF2 /* StripeCoreTestUtils.framework in Frameworks */, + D6AEB6D5567AAD1B44E4AF70 /* StripeUICore.framework in Frameworks */, + 63632799CD2134991E0EA510 /* iOSSnapshotTestCase in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0737F3733178B902986E6128 /* Form */ = { + isa = PBXGroup; + children = ( + EB1FE2B6BCD6FC77117A2521 /* FormElement.swift */, + 558DF5BAEF6E23E0C0F2CFF1 /* FormView.swift */, + ); + path = Form; + sourceTree = ""; + }; + 14BA0999EF98D5C19F7A0018 /* Elements */ = { + isa = PBXGroup; + children = ( + C55F7532752871E779F47278 /* AddressSectionElementTest.swift */, + 83BC5E25F42045563F4A660B /* AddressSpecProviderTest.swift */, + 0408AC0298D342DADD1F84A5 /* BSBNumberProviderTest.swift */, + 08B0431535F20802E386624B /* DateFieldElementTest.swift */, + CF3B67DE90B0C1D71257D333 /* DropdownFieldElementTest.swift */, + 8995A59E3BC6D26B8030C874 /* IDNumberTextFieldConfigurationTest.swift */, + 794D2AA5365F9DE155083927 /* PhoneNumberElementTests.swift */, + 62539E4F0904670C008DB18E /* SectionElementTest.swift */, + 83462E27CEA3580E1BD3E2CD /* TestFieldElement+AccountFactoryTest.swift */, + FE57C3D398C94604889D9F61 /* TextFieldElement+AddressFactoryTest.swift */, + 9131CB11D2A0898B16D44C4E /* TextFieldElementTest.swift */, + B32FABC26C892C74FD444E88 /* TextFieldFormatterTest.swift */, + ); + path = Elements; + sourceTree = ""; + }; + 17217EBC2C89A90814900B85 /* Checkbox */ = { + isa = PBXGroup; + children = ( + 6F6E799C8CB484FE83CB87E0 /* CheckboxButton.swift */, + 8F1EF8010A475D99C7190FF9 /* CheckboxElement.swift */, + ); + path = Checkbox; + sourceTree = ""; + }; + 254AA347B50AF1B8370A43DF /* Elements */ = { + isa = PBXGroup; + children = ( + 17217EBC2C89A90814900B85 /* Checkbox */, + F08533861A4AC1BB01315CCE /* Factories */, + 0737F3733178B902986E6128 /* Form */, + CF2B7051099FA08B7F0C446F /* PhoneNumber */, + B15F8A0D7BC00200896F00FD /* PickerField */, + B3A1C7E17AE91C2352DE42DB /* Section */, + 82B232DB32C55ABC81F03EEA /* TextField */, + 4893E4B43FEFCE920D432F0C /* ContainerElement.swift */, + B274D6A71A11DA7B98D502AA /* DateFieldElement.swift */, + 5BAD4C8EE686B0F9E86DBC8D /* DropdownFieldElement.swift */, + EAB02AB36074791D39F0CDC1 /* Element.swift */, + 85AC8D5E9700C8471D225C22 /* ElementsUI.swift */, + 583B93BE0152CDF7383A37E7 /* StaticElement.swift */, + EDD864C601BFB09A851715FA /* TextOrDropdownElement.swift */, + ); + path = Elements; + sourceTree = ""; + }; + 361DA94D6B150221B8694910 /* Categories */ = { + isa = PBXGroup; + children = ( + D2749BB93AD1B5A9FB3B9B97 /* Locale+StripeUICoreTests.swift */, + 982AA7FCDBD1D5264A5FB040 /* NSAttributedString+StripeUICoreTests.swift */, + 48C9A13C4663EA8ACA6EA2E5 /* UIColor+StripeUICoreTests.swift */, + ); + path = Categories; + sourceTree = ""; + }; + 36DAB16F61F10767C1FAA6CB /* Resources */ = { + isa = PBXGroup; + children = ( + A33F87F760EA97E2416B5E3F /* JSON */, + 4EE613673FA66E06A8963989 /* Localizations */, + 69B1E4AE618E237C0EE5036F /* StripeUICore.xcassets */, + ); + path = Resources; + sourceTree = ""; + }; + 3F7B82C31FE216F26BAA22BD /* Views */ = { + isa = PBXGroup; + children = ( + C19F61D5356203ED977ED42E /* DoneButtonToolbar.swift */, + B9E7CFED5747279A5976D7BA /* DynamicHeightContainerView.swift */, + CDE775CAF20488D81C9167B4 /* DynamicImageView.swift */, + E80F72A0A72DDC5F246ED51F /* LinkOpeningTextView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 47D21B13FCCDA3A340E0E45B /* Source */ = { + isa = PBXGroup; + children = ( + 4FBEFD693426C6259B6BF7D6 /* Categories */, + 91389CA21E7E02309F469572 /* Controls */, + 254AA347B50AF1B8370A43DF /* Elements */, + 6409D732BE9F9BD02673B79B /* Helpers */, + 7C026500149D281CB84ECA33 /* Validators */, + 3F7B82C31FE216F26BAA22BD /* Views */, + 4844F0B5436706225DE5A176 /* Events.swift */, + 20209CDCC3856CE548DA4D25 /* Image.swift */, + ); + path = Source; + sourceTree = ""; + }; + 4EE613673FA66E06A8963989 /* Localizations */ = { + isa = PBXGroup; + children = ( + 71C8163AEB97D2FF8BB3A1C8 /* Localizable.strings */, + ); + path = Localizations; + sourceTree = ""; + }; + 4FBEFD693426C6259B6BF7D6 /* Categories */ = { + isa = PBXGroup; + children = ( + 7B8B1E38153B0C5ED6CD459C /* CALayer+StripeUICore.swift */, + 64EECCC1035F00C122B2B1ED /* Enums+CustomStringConvertible.swift */, + 36342ED77935BE9A32B082EC /* Locale+StripeUICore.swift */, + 3B8AFA3891DA214D9FBEF650 /* NSAttributedString+StripeUICore.swift */, + CB52FF6CAC981EFA60A81AFF /* NSDirectionalEdgeInsets+StripeUICore.swift */, + 5321D6CCBA2AA9162A96080C /* UIBarButtonItem+StripeUICore.swift */, + 8A60A0D2417D3A70D500EE30 /* UIButton+StripeUICore.swift */, + 51C4AEB27F9734178E20836A /* UIColor+StripeUICore.swift */, + 43943E8FF7CD279F8D8605D3 /* UIFont+StripeUICore.swift */, + 4EE651DD3E5873538C7CE743 /* UIKeyboardType+StripeUICore.swift */, + 71A81696ECEEF728F25202B6 /* UISpringTimingParameters+StripeUICore.swift */, + 5855EA2F4F38847E3E47935D /* UIStackView+StripeUICore.swift */, + FAA22461E52111D9A4E8B133 /* UITraitCollection+StripeUICore.swift */, + FDBBC76FD31F07EC67C52075 /* UIView+StripeUICore.swift */, + 12973742CA1AA39D3B84F072 /* UIViewController+StripeUICore.swift */, + 59215CEEBA0D56FF41C3E412 /* UIWindow+StripeUICore.swift */, + ); + path = Categories; + sourceTree = ""; + }; + 55059C90F775384FCFE9D116 /* Elements */ = { + isa = PBXGroup; + children = ( + BB7836BA51814D0841B688E1 /* AddressSectionElementSnapshotTest.swift */, + D917F78E89D67691EF760D14 /* CheckboxButtonSnapshotTests.swift */, + 48E2652D09C1A4EBBDE2FB61 /* DateFieldElementSnapshotTest.swift */, + 62575FAAAAEFF31406C9B417 /* DropdownFieldElementSnapshotTest.swift */, + 7796F7211034806DA24D1710 /* PhoneNumberElementSnapshotTests.swift */, + ); + path = Elements; + sourceTree = ""; + }; + 5A4B0EAF2FA9E28C057A2AB2 /* BuildConfigurations */ = { + isa = PBXGroup; + children = ( + 67C454346BE566F9F689543B /* Project-Debug.xcconfig */, + D1FB364DF60C1DEE0DA8EE75 /* Project-Release.xcconfig */, + 9E3254388F5DAB8E8C76AE04 /* StripeiOS Tests-Debug.xcconfig */, + E242FECC90FE2644CF99692D /* StripeiOS Tests-Release.xcconfig */, + E395C593AC970A8C79A8109C /* StripeiOS-Debug.xcconfig */, + 4CBAE2B07C5575F19470464D /* StripeiOS-Release.xcconfig */, + ); + name = BuildConfigurations; + path = ../BuildConfigurations; + sourceTree = ""; + }; + 6053854E6A29F72565EEA479 /* Validators */ = { + isa = PBXGroup; + children = ( + 9CCBFC1830139737F2E947F1 /* BSBNumberTests.swift */, + 263B7E56A83CFA4BA8385638 /* PhoneNumberTests.swift */, + 2055BFB32FE9CF519DC25C8F /* STPBlikCodeValidatorTest.swift */, + 312E77021827300B2504F016 /* STPEmailAddressValidatorTest.swift */, + 53AA9FCEF2640C493D140033 /* STPVPANumberValidatorTest.swift */, + ); + path = Validators; + sourceTree = ""; + }; + 6409D732BE9F9BD02673B79B /* Helpers */ = { + isa = PBXGroup; + children = ( + E657F1ABE46BA5C0692D9D41 /* CompatibleColor.swift */, + A681C4DEAE7B35B4DC0BD0FB /* ImageMaker.swift */, + FB5A05E653BFD01FF65E193C /* InputFormColors.swift */, + EA00332BBDCE27F6F5A615C4 /* RegionCodeProvider.swift */, + 72394C7783071B1C6ED82A48 /* StackViewWithSeparator.swift */, + A4F4CE2A0B16DA57DB249227 /* STPLocalizedString.swift */, + A73D88DFAB03D663217399D0 /* String+CountryEmoji.swift */, + 0D558F65E4C23971C94BAD3E /* String+Localized.swift */, + BA1688A2CE2037846F8E3937 /* String+RegionCodeProvider.swift */, + E68363D1C6BB6468AC1DDA4C /* StripeUICoreBundleLocator.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 69644936855065629CAD92BE /* Unit */ = { + isa = PBXGroup; + children = ( + 361DA94D6B150221B8694910 /* Categories */, + 14BA0999EF98D5C19F7A0018 /* Elements */, + 6053854E6A29F72565EEA479 /* Validators */, + ); + path = Unit; + sourceTree = ""; + }; + 6A97BE4FB28466364C6C6F2D /* Snapshot */ = { + isa = PBXGroup; + children = ( + B2DFE0CA51E45495789EAE0C /* Controls */, + 55059C90F775384FCFE9D116 /* Elements */, + ); + path = Snapshot; + sourceTree = ""; + }; + 6F2D1EDE6459166E8599C7F6 /* Products */ = { + isa = PBXGroup; + children = ( + B8DC1E33CDF3B3FE549A7210 /* StripeCore.framework */, + 40DF1A7FC5D84F937042D172 /* StripeCoreTestUtils.framework */, + 868B2E8EDC242DB5AFFB3D0C /* StripeUICore.framework */, + E522C47E0874F612E330DA38 /* StripeUICoreTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 778494B821DEC0138FB4D78F /* Address */ = { + isa = PBXGroup; + children = ( + 5B5CCD5D11A49D48D8D21C62 /* AddressSectionElement.swift */, + 4B66D666BB3EA733B92A60AA /* AddressSectionElement+DummyAddressLine.swift */, + 7DC72344B4B313CE07A8AA33 /* AddressSpec.swift */, + 6A4D27EC1A9207F327B1BF20 /* AddressSpec+ElementFactory.swift */, + B844EED0578B990F4772CD01 /* AddressSpecProvider.swift */, + ); + path = Address; + sourceTree = ""; + }; + 7C026500149D281CB84ECA33 /* Validators */ = { + isa = PBXGroup; + children = ( + 312236317D0DA79F2D32CBE2 /* BankRoutingNumber.swift */, + FF05370E4825D3225D9A6910 /* PhoneNumber.swift */, + E99B2D80F3FBAC287CBF86B0 /* STPBlikCodeValidator.swift */, + DE233EED40F9D3FADDFE5951 /* STPEmailAddressValidator.swift */, + D634B04FC897DFB74B81DED6 /* STPVPANumberValidator.swift */, + ); + path = Validators; + sourceTree = ""; + }; + 82B232DB32C55ABC81F03EEA /* TextField */ = { + isa = PBXGroup; + children = ( + 4FD764D16A365FD46700FB25 /* FloatingPlaceholderTextFieldView.swift */, + A004788FA286E5ED22334238 /* TextFieldElement.swift */, + CFB80CA75F00847E4D74F821 /* TextFieldElement+Validation.swift */, + 8367A676E35A663E320E1B37 /* TextFieldElementConfiguration.swift */, + 34178B51599FDCB0D0D7475A /* TextFieldFormatter.swift */, + D5A312A55A629CED2C6404F1 /* TextFieldView.swift */, + ); + path = TextField; + sourceTree = ""; + }; + 89921F2BD8A1893F01033619 = { + isa = PBXGroup; + children = ( + 9F91E3826B63A2743D9EAFD8 /* Project */, + E4A3A33D13A26769A10EA12B /* Frameworks */, + 6F2D1EDE6459166E8599C7F6 /* Products */, + ); + sourceTree = ""; + }; + 89DCFAE23D35B3B8313A52E2 /* StripeUICore */ = { + isa = PBXGroup; + children = ( + 36DAB16F61F10767C1FAA6CB /* Resources */, + 47D21B13FCCDA3A340E0E45B /* Source */, + 40E0822DE6CFA0D3CCD5D513 /* Info.plist */, + CB8A9F7B4B2E8AA5A7E4FE98 /* StripeUICore.h */, + ); + path = StripeUICore; + sourceTree = ""; + }; + 91389CA21E7E02309F469572 /* Controls */ = { + isa = PBXGroup; + children = ( + 8678CFB3968AE5232932C461 /* ActivityIndicator.swift */, + 7EB48FCA3B2447A5F4CCEC69 /* Button.swift */, + 815DB8DE48AE3CE1609C8316 /* OneTimeCodeTextField-TextStorage.swift */, + AC2BFFB32C50AB4EECA90326 /* OneTimeCodeTextField.swift */, + ); + path = Controls; + sourceTree = ""; + }; + 95A37FFB73E533980DEB7AC6 /* StripeUICoreTests */ = { + isa = PBXGroup; + children = ( + 6A97BE4FB28466364C6C6F2D /* Snapshot */, + 69644936855065629CAD92BE /* Unit */, + 2AAE278AD27DB71B88C97B1A /* Info.plist */, + ); + path = StripeUICoreTests; + sourceTree = ""; + }; + 9F91E3826B63A2743D9EAFD8 /* Project */ = { + isa = PBXGroup; + children = ( + 5A4B0EAF2FA9E28C057A2AB2 /* BuildConfigurations */, + 89DCFAE23D35B3B8313A52E2 /* StripeUICore */, + 95A37FFB73E533980DEB7AC6 /* StripeUICoreTests */, + ); + name = Project; + sourceTree = ""; + }; + A33F87F760EA97E2416B5E3F /* JSON */ = { + isa = PBXGroup; + children = ( + 6E66B0CF81D4F8EDBB642C9F /* au_becs_bsb.json */, + 4E25905FCD05DF8B72888AAF /* localized_address_data.json */, + ); + path = JSON; + sourceTree = ""; + }; + B15F8A0D7BC00200896F00FD /* PickerField */ = { + isa = PBXGroup; + children = ( + 68F3046A526D3A4CE402FB83 /* PickerFieldView.swift */, + C0355EA21D04DDC29E620CEA /* PickerTextField.swift */, + ); + path = PickerField; + sourceTree = ""; + }; + B2DFE0CA51E45495789EAE0C /* Controls */ = { + isa = PBXGroup; + children = ( + EB5E2F9A5B1A3E8E7B765E4F /* ButtonSnapshotTest.swift */, + ); + path = Controls; + sourceTree = ""; + }; + B3A1C7E17AE91C2352DE42DB /* Section */ = { + isa = PBXGroup; + children = ( + F1C0A3DE4BCF8DA03AD17144 /* SectionContainerView.swift */, + 7BB17B284B6D063AF0329DAD /* SectionElement.swift */, + B6737026DF19C8D10AE8114A /* SectionElement+MultiElementRow.swift */, + CF3C9C0D2302B8E7F421EFBD /* SectionView.swift */, + ); + path = Section; + sourceTree = ""; + }; + CF2B7051099FA08B7F0C446F /* PhoneNumber */ = { + isa = PBXGroup; + children = ( + 8D0478259F9F7A04A8CF43BB /* PhoneNumberElement.swift */, + ); + path = PhoneNumber; + sourceTree = ""; + }; + E4A3A33D13A26769A10EA12B /* Frameworks */ = { + isa = PBXGroup; + children = ( + D4621DA31A1A5FC51F76E562 /* XCTest.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + E6F4FB2DDDAEEC6EAA177E77 /* BSB */ = { + isa = PBXGroup; + children = ( + CC3DD58BEF337017E7286459 /* BSBNumberProvider.swift */, + ); + path = BSB; + sourceTree = ""; + }; + F08533861A4AC1BB01315CCE /* Factories */ = { + isa = PBXGroup; + children = ( + 778494B821DEC0138FB4D78F /* Address */, + E6F4FB2DDDAEEC6EAA177E77 /* BSB */, + 374D0BE980D58B75FA04DE66 /* DropdownFieldElement+AddressFactory.swift */, + 92623B785C43F351D8A944D1 /* IDNumberTextFieldConfiguration.swift */, + D69C3FF914611057972ABA41 /* TextFieldElement+AccountFactory.swift */, + EDD7C1AAB9D26E00526AFB26 /* TextFieldElement+AddressFactory.swift */, + 4D4BD46908791D2E4150C4DC /* TextFieldElement+Factory.swift */, + ); + path = Factories; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 7124034061B4EB1E9FEE5082 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 4755A24604B396D5A25058CD /* StripeUICore.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + A4B3F8AEF10396425E1A79D0 /* StripeUICoreTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = FBFE0F03077B5B14BAA451B5 /* Build configuration list for PBXNativeTarget "StripeUICoreTests" */; + buildPhases = ( + 1EEBDA54180851BDA264A95A /* Sources */, + 22944EF7CA9505D7EAA24DDF /* Resources */, + 8B58BADB5D787E66A0A50257 /* Embed Frameworks */, + B361F19E111D84FD84927757 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + C47525D30F1B3DA22E5700ED /* PBXTargetDependency */, + ); + name = StripeUICoreTests; + packageProductDependencies = ( + 9B701A244243959A191FF16F /* iOSSnapshotTestCase */, + ); + productName = StripeUICoreTests; + productReference = E522C47E0874F612E330DA38 /* StripeUICoreTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + DE3C3F3D3BB67DD660A44B1E /* StripeUICore */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0F1ADFB3411970E0071C1BC7 /* Build configuration list for PBXNativeTarget "StripeUICore" */; + buildPhases = ( + 7124034061B4EB1E9FEE5082 /* Headers */, + 8E796B4724BEBD4752011F86 /* Sources */, + 5744B094678B91577696C76C /* Resources */, + 7823EBE0BD66DC6070DC1530 /* Embed Frameworks */, + 0D674E67240745BB10E9C307 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = StripeUICore; + productName = StripeUICore; + productReference = 868B2E8EDC242DB5AFFB3D0C /* StripeUICore.framework */; + productType = "com.apple.product-type.framework"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A39BBBD15F0F6B54725E52AF /* Project object */ = { + isa = PBXProject; + attributes = { + TargetAttributes = { + }; + }; + buildConfigurationList = 2331F8341192F01F80C0469D /* Build configuration list for PBXProject "StripeUICore" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + "bg-BG", + "ca-ES", + "cs-CZ", + da, + de, + "el-GR", + en, + "en-GB", + es, + "es-419", + "et-EE", + fi, + fil, + fr, + "fr-CA", + hr, + hu, + id, + it, + ja, + ko, + "lt-LT", + "lv-LV", + "ms-MY", + mt, + nb, + nl, + "nn-NO", + "pl-PL", + "pt-BR", + "pt-PT", + "ro-RO", + ru, + "sk-SK", + "sl-SI", + sv, + tr, + vi, + "zh-HK", + "zh-Hans", + "zh-Hant", + ); + mainGroup = 89921F2BD8A1893F01033619; + packageReferences = ( + 538E8F52DECFD138BE60A67D /* XCRemoteSwiftPackageReference "ios-snapshot-test-case" */, + ); + productRefGroup = 6F2D1EDE6459166E8599C7F6 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + DE3C3F3D3BB67DD660A44B1E /* StripeUICore */, + A4B3F8AEF10396425E1A79D0 /* StripeUICoreTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 22944EF7CA9505D7EAA24DDF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5744B094678B91577696C76C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2ADEF1482E62A173A0C7F7AD /* au_becs_bsb.json in Resources */, + D912BF580DBAB7416040B637 /* localized_address_data.json in Resources */, + ECCA9BD118D763DBF658E5FD /* Localizable.strings in Resources */, + 717D176DAF084461C18F2A09 /* StripeUICore.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 1EEBDA54180851BDA264A95A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D01976C07EB39B2BED64CCAC /* ButtonSnapshotTest.swift in Sources */, + FC1F8C4DC70C8212B507AE7E /* AddressSectionElementSnapshotTest.swift in Sources */, + 0D8AE1CEFBE7FA004A3DD62D /* CheckboxButtonSnapshotTests.swift in Sources */, + C6B11F4F219F7ED04321A33F /* DateFieldElementSnapshotTest.swift in Sources */, + FF4E844383E5A5FD5C099B41 /* DropdownFieldElementSnapshotTest.swift in Sources */, + 1424B1529E24582122F86149 /* PhoneNumberElementSnapshotTests.swift in Sources */, + 9DBFEB7045692EE931CE014D /* Locale+StripeUICoreTests.swift in Sources */, + 976AFE0D02FE65BFA1757E4E /* NSAttributedString+StripeUICoreTests.swift in Sources */, + 69B082B15479DCC4560E3D92 /* UIColor+StripeUICoreTests.swift in Sources */, + B106FCB3D7C3DE7C40F0AE5C /* AddressSectionElementTest.swift in Sources */, + 073D41F1EC0560423FEF87AA /* AddressSpecProviderTest.swift in Sources */, + 32D69CBB3CE780A29AA553AB /* BSBNumberProviderTest.swift in Sources */, + 2AA023A5031CFF0E56C7A14E /* DateFieldElementTest.swift in Sources */, + F0EB247FEFF4600CD44B8261 /* DropdownFieldElementTest.swift in Sources */, + 88F53AB8F31B1DA2187E5740 /* IDNumberTextFieldConfigurationTest.swift in Sources */, + 4C519A87445AB16A55FE2408 /* PhoneNumberElementTests.swift in Sources */, + A21277FDCDD0C6BFB73A4B51 /* SectionElementTest.swift in Sources */, + 163A77E9E4C40AFFA5226E23 /* TestFieldElement+AccountFactoryTest.swift in Sources */, + 08C773D1E6A5452B7BD7CF81 /* TextFieldElement+AddressFactoryTest.swift in Sources */, + 993E51173490AAE5D7AF4C4E /* TextFieldElementTest.swift in Sources */, + EE28852FFCF42A8C47098051 /* TextFieldFormatterTest.swift in Sources */, + 74F65B6435E46B0FC8386FBB /* BSBNumberTests.swift in Sources */, + 64E61A5E0A705F1C4582381A /* PhoneNumberTests.swift in Sources */, + BEB4F0E3B6218CA3E5DE95F9 /* STPBlikCodeValidatorTest.swift in Sources */, + A5C379C7D1EEF497FD845306 /* STPEmailAddressValidatorTest.swift in Sources */, + 2FD444AC5FC064EFCF6259ED /* STPVPANumberValidatorTest.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8E796B4724BEBD4752011F86 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 824858D45F9D952BBDF822E2 /* CALayer+StripeUICore.swift in Sources */, + AA8F938C3A8B7BC7F11B5048 /* Enums+CustomStringConvertible.swift in Sources */, + 2F1D03471202E25FD7682148 /* Locale+StripeUICore.swift in Sources */, + BC40443B2A2F7130351589A7 /* NSAttributedString+StripeUICore.swift in Sources */, + DA6550F1FFA1376DB656D6E0 /* NSDirectionalEdgeInsets+StripeUICore.swift in Sources */, + 67FCE4493235656689E915F6 /* UIBarButtonItem+StripeUICore.swift in Sources */, + 7272A43410D4BA1365D71E70 /* UIButton+StripeUICore.swift in Sources */, + 02B503A3227EF1409ABF53F1 /* UIColor+StripeUICore.swift in Sources */, + 900EFF5918D96B9716CFB673 /* UIFont+StripeUICore.swift in Sources */, + D6D8BCCF86C964B40D2FA58E /* UIKeyboardType+StripeUICore.swift in Sources */, + 80B0519BC9CC21D9B650FC88 /* UISpringTimingParameters+StripeUICore.swift in Sources */, + 159D7B9A2960A1CD7E661191 /* UIStackView+StripeUICore.swift in Sources */, + 30972A45F8A32DEEC17DA4F6 /* UITraitCollection+StripeUICore.swift in Sources */, + 62601F856C41CFA1C7A8B18F /* UIView+StripeUICore.swift in Sources */, + D47D77A0B82DC6AE15E0A74E /* UIViewController+StripeUICore.swift in Sources */, + 4CD207111219BF250A400ACC /* UIWindow+StripeUICore.swift in Sources */, + 778BACD1A29BEDEE21ED3FBE /* ActivityIndicator.swift in Sources */, + FDF52A43A01CD72D4B5A2CA9 /* Button.swift in Sources */, + 4B05CF7F485F0AED498DAE49 /* OneTimeCodeTextField-TextStorage.swift in Sources */, + 48D3B7C2983A8A25C6599119 /* OneTimeCodeTextField.swift in Sources */, + C44A57646A325EE26B75E6BF /* CheckboxButton.swift in Sources */, + C3D6D899B671398717F22520 /* CheckboxElement.swift in Sources */, + 8B8D3BD090415EFF9948D2C2 /* ContainerElement.swift in Sources */, + A5E59185A9708613676988C6 /* DateFieldElement.swift in Sources */, + C23C78D87D8E6682F31345CB /* DropdownFieldElement.swift in Sources */, + D7DB5C5724CD47E33245B25A /* Element.swift in Sources */, + FAD790056C7A9E645A6B2C74 /* ElementsUI.swift in Sources */, + 377058C2363FA0348AFBD32E /* AddressSectionElement+DummyAddressLine.swift in Sources */, + BE42104922DCA4DCB3919DD4 /* AddressSectionElement.swift in Sources */, + 0CB675370A06DEC6F23608C4 /* AddressSpec+ElementFactory.swift in Sources */, + E9CA12DAB591AC834CE9539A /* AddressSpec.swift in Sources */, + E94BA0179485AED17D412865 /* AddressSpecProvider.swift in Sources */, + 225B20CEF547BB1F6C6D447E /* BSBNumberProvider.swift in Sources */, + F86769C5CFD9AD3732127951 /* DropdownFieldElement+AddressFactory.swift in Sources */, + 67216EB4E004BDB1D2E49BD4 /* IDNumberTextFieldConfiguration.swift in Sources */, + 65B9A839BD4AAC315231B421 /* TextFieldElement+AccountFactory.swift in Sources */, + 0A3130227F7602524C9824D3 /* TextFieldElement+AddressFactory.swift in Sources */, + FC48FCDC5FD43E5E8AFC32D2 /* TextFieldElement+Factory.swift in Sources */, + 4A5EADAF2F6514299BA4B8D8 /* FormElement.swift in Sources */, + C1CA6209591EDDBBE019FF22 /* FormView.swift in Sources */, + 86427678E119E4AD22410E30 /* PhoneNumberElement.swift in Sources */, + 019C76A03A30A67AE9F1FAEE /* PickerFieldView.swift in Sources */, + 57C288DFD2CC2CFC216E47CC /* PickerTextField.swift in Sources */, + 32971AFF5D0DF9B28E9C464C /* SectionContainerView.swift in Sources */, + 5A9B21FE5A6941713087B94B /* SectionElement+MultiElementRow.swift in Sources */, + 336B882978CA47EE46260774 /* SectionElement.swift in Sources */, + 93CF3CE90B520B4A25208E95 /* SectionView.swift in Sources */, + 4B414F0A0E46D914C89B3741 /* StaticElement.swift in Sources */, + 0FBBB5C7FD2DA9A11E7FE2BC /* FloatingPlaceholderTextFieldView.swift in Sources */, + E40999CDEDEA23451CA89707 /* TextFieldElement+Validation.swift in Sources */, + 9DEAD347B741A53E2F1764B4 /* TextFieldElement.swift in Sources */, + 5936629C4665BC698C3458B1 /* TextFieldElementConfiguration.swift in Sources */, + 39E61B5E6A88E5AF1922EE62 /* TextFieldFormatter.swift in Sources */, + 986924FDA1EEF4146CD81B50 /* TextFieldView.swift in Sources */, + A29B5AC2F03116E2F48970EE /* TextOrDropdownElement.swift in Sources */, + 36376E12AE7ABA21FFC474E6 /* Events.swift in Sources */, + FB33F2F446570394AABB7EC7 /* CompatibleColor.swift in Sources */, + 7B90479C19C30407FC21B228 /* ImageMaker.swift in Sources */, + 343DD093A6095DEAA06D245E /* InputFormColors.swift in Sources */, + 6606DC43D230ADD183AEF5DA /* RegionCodeProvider.swift in Sources */, + 504FA2DE5FE66FDE90842019 /* STPLocalizedString.swift in Sources */, + EDDE1C83333AB1A1F2BD0F3E /* StackViewWithSeparator.swift in Sources */, + 11BD8DFB36FACB6966D0236F /* String+CountryEmoji.swift in Sources */, + 0BA1E7C26903E45912FDE25A /* String+Localized.swift in Sources */, + D083BAAF86707F9865AF2AF4 /* String+RegionCodeProvider.swift in Sources */, + B6107E5E2D6E116417D22DD9 /* StripeUICoreBundleLocator.swift in Sources */, + 68F7D5EEB894A68DDC184ADA /* Image.swift in Sources */, + A3F0D42EB3A3FF2299F2F473 /* BankRoutingNumber.swift in Sources */, + F901303E0B78F2D8E4C8A2F1 /* PhoneNumber.swift in Sources */, + 710D7FB87C39EEB0DB1F3E75 /* STPBlikCodeValidator.swift in Sources */, + 1EB16D8F60923EE9C890CE43 /* STPEmailAddressValidator.swift in Sources */, + 0019E30E7B8189C3DEA719DC /* STPVPANumberValidator.swift in Sources */, + CF9D4CC40A7008DD1E8136A3 /* DoneButtonToolbar.swift in Sources */, + B1B177526E04EE65A9D1C64A /* DynamicHeightContainerView.swift in Sources */, + 11C99D83A1DA88996A45BA47 /* DynamicImageView.swift in Sources */, + 6CB223E48029E6BA6ED48041 /* LinkOpeningTextView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + C47525D30F1B3DA22E5700ED /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = StripeUICore; + target = DE3C3F3D3BB67DD660A44B1E /* StripeUICore */; + targetProxy = 980541F3E23EAD7E20DBCA47 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 71C8163AEB97D2FF8BB3A1C8 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 7D4E60C42E09A23ACD60BCD8 /* bg-BG */, + 7B899349F7EF770D8F19509D /* ca-ES */, + A360D5AE016620B560FB8A77 /* cs-CZ */, + A1A02A2319937F5033495984 /* da */, + 848196B6CCE964A735BFCD46 /* de */, + B2161853A89C273B745B8A60 /* el-GR */, + 02DA3E661669718BD61C702D /* en */, + BA366F6105D04C9136240341 /* en-GB */, + 18C5B42A6D5AF223F0CFB23F /* es */, + D59C1B7B69C92DFC9A25DAB2 /* es-419 */, + C85C015B47A8EBF87E5F662E /* et-EE */, + AE4AB8A1DB70CDE06D9887CF /* fi */, + 94FB3B96E7884F5B5C1C3EA8 /* fil */, + 71DCDDEE36E2CD98E4A03752 /* fr */, + 4F37C42EA28BA31EC5CCDC7C /* fr-CA */, + B081CD063903B2FDBE327A15 /* hr */, + 0ED24D748AEB8B1E0BA1FBAD /* hu */, + 3A99D0F01D29E302C52217FE /* id */, + 74EDF4CC65F409F55E14EB25 /* it */, + 53900894BD0E2FF59029D2B6 /* ja */, + 11C37DE7B064DC9E3F7E55CD /* ko */, + AB2C786B198F91AE124C790A /* lt-LT */, + 00455FBF8F3D7C9B0E65DA54 /* lv-LV */, + 8278020A829AE8C5CB8B6A9C /* ms-MY */, + E0C8DFA9A617506071C59815 /* mt */, + 34EC2F4C025AD1B1DD44CF78 /* nb */, + 1A50D68D24D1DE0459ED9529 /* nl */, + B84B32F86F684EA5233DDED5 /* nn-NO */, + B871CED2EB8A1F7EEC0FE087 /* pl-PL */, + 72EB2DE23B3740D8DA3081E5 /* pt-BR */, + B9CAB799E5645867F2400F0F /* pt-PT */, + 699ED5466892D95ADC151B64 /* ro-RO */, + BD52DCDA490D452AAA4B5E44 /* ru */, + 011872F74559CB0D0D61170C /* sk-SK */, + 2BAA056C8170A4B0E8C763AD /* sl-SI */, + EB942257C8B6B357AD0F6FC9 /* sv */, + A032BB17A6FA85EE4E3D00AE /* tr */, + 2D9B5640C88DD94D17666E0E /* vi */, + 81A4AE07CB9049B6D8C8D7D2 /* zh-Hans */, + 9559041BED6315C71F796A5F /* zh-Hant */, + 70B4BE2ACC46DF88949121CD /* zh-HK */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 0E538ACBF1E137ADF66DB8F8 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4CBAE2B07C5575F19470464D /* StripeiOS-Release.xcconfig */; + buildSettings = { + INFOPLIST_FILE = StripeUICore/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "com.stripe.stripe-ui-core"; + PRODUCT_NAME = StripeUICore; + SDKROOT = iphoneos; + }; + name = Release; + }; + 4C7CEEDE1477774F09EA2E3F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 67C454346BE566F9F689543B /* Project-Debug.xcconfig */; + buildSettings = { + }; + name = Debug; + }; + 6600E0743D5AD7028F6ABC2C /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E395C593AC970A8C79A8109C /* StripeiOS-Debug.xcconfig */; + buildSettings = { + INFOPLIST_FILE = StripeUICore/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "com.stripe.stripe-ui-core"; + PRODUCT_NAME = StripeUICore; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 8D85CFA6FA82B62D41B6313A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D1FB364DF60C1DEE0DA8EE75 /* Project-Release.xcconfig */; + buildSettings = { + }; + name = Release; + }; + 918E06A524F977D2E40E149B /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E242FECC90FE2644CF99692D /* StripeiOS Tests-Release.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeUICoreTests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeUICoreTests; + PRODUCT_NAME = StripeUICoreTests; + SDKROOT = iphoneos; + }; + name = Release; + }; + FDF219D174EA73AC86665131 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9E3254388F5DAB8E8C76AE04 /* StripeiOS Tests-Debug.xcconfig */; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + INFOPLIST_FILE = StripeUICoreTests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.stripe.StripeUICoreTests; + PRODUCT_NAME = StripeUICoreTests; + SDKROOT = iphoneos; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 0F1ADFB3411970E0071C1BC7 /* Build configuration list for PBXNativeTarget "StripeUICore" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6600E0743D5AD7028F6ABC2C /* Debug */, + 0E538ACBF1E137ADF66DB8F8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2331F8341192F01F80C0469D /* Build configuration list for PBXProject "StripeUICore" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4C7CEEDE1477774F09EA2E3F /* Debug */, + 8D85CFA6FA82B62D41B6313A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + FBFE0F03077B5B14BAA451B5 /* Build configuration list for PBXNativeTarget "StripeUICoreTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FDF219D174EA73AC86665131 /* Debug */, + 918E06A524F977D2E40E149B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 538E8F52DECFD138BE60A67D /* XCRemoteSwiftPackageReference "ios-snapshot-test-case" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/uber/ios-snapshot-test-case"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 9B701A244243959A191FF16F /* iOSSnapshotTestCase */ = { + isa = XCSwiftPackageProductDependency; + productName = iOSSnapshotTestCase; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = A39BBBD15F0F6B54725E52AF /* Project object */; +} diff --git a/StripeUICore/StripeUICore.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/StripeUICore/StripeUICore.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/StripeUICore/StripeUICore.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/StripeUICore/StripeUICore.xcodeproj/xcshareddata/xcschemes/StripeUICore.xcscheme b/StripeUICore/StripeUICore.xcodeproj/xcshareddata/xcschemes/StripeUICore.xcscheme new file mode 100644 index 00000000..44095b6c --- /dev/null +++ b/StripeUICore/StripeUICore.xcodeproj/xcshareddata/xcschemes/StripeUICore.xcscheme @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StripeUICore/StripeUICore/Info.plist b/StripeUICore/StripeUICore/Info.plist new file mode 100644 index 00000000..cd4a496b --- /dev/null +++ b/StripeUICore/StripeUICore/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(CURRENT_PROJECT_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/StripeUICore/StripeUICore/Resources/JSON/au_becs_bsb.json b/StripeUICore/StripeUICore/Resources/JSON/au_becs_bsb.json new file mode 100644 index 00000000..74d8dca8 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/JSON/au_becs_bsb.json @@ -0,0 +1,123 @@ +{ + "10": "BankSA (division of Westpac Bank)", + "11": "St George Bank (division of Westpac Bank)", + "12": "Bank of Queensland", + "14": "Rabobank", + "15": "Town & Country Bank", + "18": "Macquarie Bank", + "19": "Bank of Melbourne (division of Westpac Bank)", + "21": "JP Morgan Chase Bank", + "22": "BNP Paribas", + "23": "Bank of America", + "24": "Citibank", + "25": "BNP Paribas Securities", + "26": "Bankers Trust Australia (division of Westpac Bank)", + "29": "Bank of Tokyo-Mitsubishi", + "30": "Bankwest (division of Commonwealth Bank)", + "33": "St George Bank (division of Westpac Bank)", + "34": "HSBC Bank Australia", + "35": "Bank of China", + "40": "Commonwealth Bank of Australia", + "41": "Deutsche Bank", + "42": "Commonwealth Bank of Australia", + "45": "OCBC Bank", + "46": "Advance Bank (division of Westpac Bank)", + "47": "Challenge Bank (division of Westpac Bank)", + "48": "Suncorp-Metway", + "52": "Commonwealth Bank of Australia", + "55": "Bank of Melbourne (division of Westpac Bank)", + "57": "Australian Settlements", + "61": "Adelaide Bank (division of Bendigo and Adelaide Bank)", + "70": "Indue", + "73": "Westpac Banking Corporation", + "76": "Commonwealth Bank of Australia", + "80": "Cuscal", + "90": "Australia Post", + "311": "in1bank", + "313": "Bankmecu", + "323": "KEB Hana Bank", + "325": "Beyond Bank Australia", + "432": "Standard Chartered Bank", + "510": "Citibank N.A.", + "512": "Community First Credit Union", + "514": "QT Mutual Bank", + "517": "Australian Settlements Limited", + "533": "Bananacoast Community Credit Union", + "611": "Select Credit Union", + "630": "ABS Building Society", + "632": "B&E", + "633": "Bendigo Bank", + "634": "Uniting Financial Services", + "636": "Cuscal Limited", + "637": "Greater Building Society", + "638": "Heritage Bank", + "639": "Home Building Society (division of Bank of Queensland)", + "640": "Hume Bank", + "641": "IMB", + "642": "Australian Defence Credit Union", + "645": "Wide Bay Australia", + "646": "Maitland Mutual Building Society", + "647": "IMB", + "650": "Newcastle Permanent Building Society", + "653": "Pioneer Permanent Building Society (division of Bank of Queensland)", + "654": "ECU Australia", + "655": "The Rock Building Society", + "656": "Wide Bay Australia", + "657": "Greater Building Society", + "659": "SGE Credit Union", + "664": "Suncorp-Metway", + "670": "Cuscal Limited", + "676": "Gateway Credit Union", + "680": "Greater Bank Limited", + "721": "Holiday Coast Credit Union", + "722": "Southern Cross Credit", + "723": "Heritage Isle Credit Union", + "724": "Railways Credit Union", + "725": "Judo Bank Pty Ltd", + "728": "Summerland Credit Union", + "775": "Australian Settlements Limited", + "777": "Police & Nurse", + "812": "Teachers Mutual Bank", + "813": "Capricornian", + "814": "Credit Union Australia", + "815": "Police Bank", + "817": "Warwick Credit Union", + "818": "Bank of Communications", + "819": "Industrial & Commercial Bank of China", + "820": "Global Payments Australia 1 Pty Ltd", + "823": "Encompass Credit Union", + "824": "Sutherland Credit Union", + "825": "Big Sky Building Society", + "833": "Defence Bank Limited", + "840": "Split Payments Pty Ltd", + "880": "Heritage Bank", + "882": "Maritime Mining & Power Credit Union", + "888": "China Construction Bank Corporation", + "889": "DBS Bank Ltd.", + "911": "Sumitomo Mitsui Banking Corporation", + "913": "State Street Bank & Trust Company", + "917": "Arab Bank Australia", + "918": "Mizuho Bank", + "922": "United Overseas Bank", + "923": "ING Bank", + "931": "Mega International Commercial Bank", + "932": "Community Mutual", + "936": "ING Bank", + "939": "AMP Bank", + "941": "Delphi Bank (division of Bendigo and Adelaide Bank)", + "942": "Bank of Sydney", + "943": "Taiwan Business Bank", + "944": "Members Equity Bank", + "946": "UBS AG", + "951": "BOQ Specialist Bank", + "952": "Royal Bank of Scotland", + "969": "Tyro Payments", + "980": "Bank of China", + "985": "HSBC Bank Australia", + "01": "Australia and New Zealand Banking Group", + "03": "Westpac Banking Corporation", + "04": "Westpac Banking Corporation", + "06": "Commonwealth Bank of Australia", + "08": "National Australia Bank", + "09": "Reserve Bank of Australia" +} diff --git a/StripeUICore/StripeUICore/Resources/JSON/localized_address_data.json b/StripeUICore/StripeUICore/Resources/JSON/localized_address_data.json new file mode 100644 index 00000000..b2e7d605 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/JSON/localized_address_data.json @@ -0,0 +1,1197 @@ +{ + "AC":{ + "_comment": "This file was adapted from https://git.corp.stripe.com/stripe-internal/stripe-js-v3/blob/bdc2eeed/src/elements/inner/shared/address/addressData.ts", + "fmt":"%N%n%O%n%A%n%C%n%Z", + "zip":"ASCN 1ZZ" + }, + "AD":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"AD[1-7]0\\d" + }, + "AE":{ + "fmt":"%N%n%O%n%A%n%S", + "lfmt":"%N%n%O%n%A%n%S", + "require":"AS", + "state_name_type":"emirate" + }, + "AF":{ + "fmt":"%N%n%O%n%A%n%C%n%Z", + "zip":"\\d{4}" + }, + "AG":{ + "require":"A" + }, + "AI":{ + "fmt":"%N%n%O%n%A%n%C%n%Z", + "zip":"(?:AI-)?2640" + }, + "AL":{ + "fmt":"%N%n%O%n%A%n%Z%n%C", + "zip":"\\d{4}" + }, + "AM":{ + "fmt":"%N%n%O%n%A%n%Z%n%C%n%S", + "lfmt":"%N%n%O%n%A%n%Z%n%C%n%S", + "zip":"(?:37)?\\d{4}" + }, + "AO":{ + "fmt":"%C" + }, + "AR":{ + "fmt":"%N%n%O%n%A%n%Z %C%n%S", + "zip":"((?:[A-HJ-NP-Z])?\\d{4})([A-Z]{3})?" + }, + "AT":{ + "fmt":"%O%n%N%n%A%n%Z %C", + "require":"ACZ", + "zip":"\\d{4}" + }, + "AU":{ + "fmt":"%O%n%N%n%A%n%C %S %Z", + "locality_name_type":"suburb", + "require":"ACSZ", + "state_name_type":"state", + "zip":"\\d{4}" + }, + "AW":{ + "fmt":"%C" + }, + "AX":{ + "fmt":"%O%n%N%n%A%nAX-%Z %C%nÅLAND", + "postprefix":"AX-", + "require":"ACZ", + "zip":"22\\d{3}" + }, + "AZ":{ + "fmt":"%N%n%O%n%A%nAZ %Z %C", + "postprefix":"AZ ", + "zip":"\\d{4}" + }, + "BA":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{5}" + }, + "BB":{ + "fmt":"%N%n%O%n%A%n%C, %S %Z", + "state_name_type":"parish", + "zip":"BB\\d{5}" + }, + "BD":{ + "fmt":"%N%n%O%n%A%n%C - %Z", + "zip":"\\d{4}" + }, + "BE":{ + "fmt":"%O%n%N%n%A%n%Z %C", + "require":"ACZ", + "zip":"\\d{4}" + }, + "BF":{ + "fmt":"%N%n%O%n%A%n%C %X" + }, + "BG":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{4}" + }, + "BH":{ + "fmt":"%N%n%O%n%A%n%C %Z", + "zip":"(?:\\d|1[0-2])\\d{2}" + }, + "BI":{ + "fmt":"%C" + }, + "BJ":{ + "fmt":"%C" + }, + "BL":{ + "fmt":"%O%n%N%n%A%n%Z %C %X", + "require":"ACZ", + "zip":"9[78][01]\\d{2}" + }, + "BM":{ + "fmt":"%N%n%O%n%A%n%C %Z", + "zip":"[A-Z]{2} ?[A-Z0-9]{2}" + }, + "BN":{ + "fmt":"%N%n%O%n%A%n%C %Z", + "zip":"[A-Z]{2} ?\\d{4}" + }, + "BO":{ + "fmt":"%C" + }, + "BQ":{ + "fmt":"%C" + }, + "BR":{ + "fmt":"%O%n%N%n%A%n%D%n%C-%S%n%Z", + "require":"ASCZ", + "state_name_type":"state", + "sublocality_name_type":"neighborhood", + "zip":"\\d{5}-?\\d{3}" + }, + "BS":{ + "fmt":"%N%n%O%n%A%n%C, %S", + "state_name_type":"island" + }, + "BT":{ + "fmt":"%N%n%O%n%A%n%C %Z", + "zip":"\\d{5}" + }, + "BV":{ + "fmt":"%C" + }, + "BW":{ + "fmt":"%C" + }, + "BY":{ + "fmt":"%S%n%Z %C%n%A%n%O%n%N", + "zip":"\\d{6}" + }, + "BZ":{ + "fmt":"%C" + }, + "CA":{ + "fmt":"%N%n%O%n%A%n%C %S %Z", + "require":"ACSZ", + "zip":"[ABCEGHJKLMNPRSTVXY]\\d[ABCEGHJ-NPRSTV-Z] ?\\d[ABCEGHJ-NPRSTV-Z]\\d", + "sub_keys": [ + "AB", + "BC", + "MB", + "NB", + "NL", + "NT", + "NS", + "NU", + "ON", + "PE", + "QC", + "SK", + "YT", + ], + "sub_labels": [ + "Alberta", + "British Columbia", + "Manitoba", + "New Brunswick", + "Newfoundland and Labrador", + "Northwest Territories", + "Nova Scotia", + "Nunavut", + "Ontario", + "Prince Edward Island", + "Quebec", + "Saskatchewan", + "Yukon", + ], + }, + "CD":{ + "fmt":"%C" + }, + "CF":{ + "fmt":"%C" + }, + "CG":{ + "fmt":"%C" + }, + "CH":{ + "fmt":"%O%n%N%n%A%nCH-%Z %C", + "postprefix":"CH-", + "require":"ACZ", + "zip":"\\d{4}" + }, + "CI":{ + "fmt":"%N%n%O%n%X %A %C %X" + }, + "CK":{ + "fmt":"%C" + }, + "CL":{ + "fmt":"%N%n%O%n%A%n%Z %C%n%S", + "zip":"\\d{7}" + }, + "CM":{ + "fmt":"%C" + }, + "CN":{ + "fmt":"%Z%n%S%C%D%n%A%n%O%n%N", + "lfmt":"%N%n%O%n%A%n%D%n%C%n%S, %Z", + "require":"ACSZ", + "sublocality_name_type":"district", + "zip":"\\d{6}" + }, + "CO":{ + "fmt":"%N%n%O%n%A%n%C, %S, %Z", + "require":"AS", + "state_name_type":"department", + "zip":"\\d{6}" + }, + "CR":{ + "fmt":"%N%n%O%n%A%n%S, %C%n%Z", + "require":"ACS", + "zip":"\\d{4,5}|\\d{3}-\\d{4}" + }, + "CV":{ + "fmt":"%N%n%O%n%A%n%Z %C%n%S", + "state_name_type":"island", + "zip":"\\d{4}" + }, + "CW":{ + "fmt":"%C" + }, + "CY":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{4}" + }, + "CZ":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "require":"ACZ", + "zip":"\\d{3} ?\\d{2}" + }, + "DE":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "require":"ACZ", + "zip":"\\d{5}" + }, + "DJ":{ + "fmt":"%C" + }, + "DK":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "require":"ACZ", + "zip":"\\d{4}" + }, + "DM":{ + "fmt":"%C" + }, + "DO":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{5}" + }, + "DZ":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{5}" + }, + "EC":{ + "fmt":"%N%n%O%n%A%n%Z%n%C", + "zip":"\\d{6}" + }, + "EE":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "require":"ACZ", + "zip":"\\d{5}" + }, + "EG":{ + "fmt":"%N%n%O%n%A%n%C%n%S%n%Z", + "lfmt":"%N%n%O%n%A%n%C%n%S%n%Z", + "zip":"\\d{5}" + }, + "EH":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{5}" + }, + "ER":{ + "fmt":"%C" + }, + "ES":{ + "fmt":"%N%n%O%n%A%n%Z %C %S", + "require":"ACSZ", + "zip":"\\d{5}" + }, + "ET":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{4}" + }, + "FI":{ + "fmt":"%O%n%N%n%A%nFI-%Z %C", + "postprefix":"FI-", + "require":"ACZ", + "zip":"\\d{5}" + }, + "FJ":{ + "fmt":"%C" + }, + "FK":{ + "fmt":"%N%n%O%n%A%n%C%n%Z", + "require":"ACZ", + "zip":"FIQQ 1ZZ" + }, + "FO":{ + "fmt":"%N%n%O%n%A%nFO%Z %C", + "postprefix":"FO", + "zip":"\\d{3}" + }, + "FR":{ + "fmt":"%O%n%N%n%A%n%Z %C", + "require":"ACZ", + "zip":"\\d{2} ?\\d{3}" + }, + "GA":{ + "fmt":"%C" + }, + "GB":{ + "fmt":"%N%n%O%n%A%n%C%n%Z", + "locality_name_type":"post_town", + "require":"ACZ", + "zip":"GIR ?0AA|(?:(?:AB|AL|B|BA|BB|BD|BF|BH|BL|BN|BR|BS|BT|BX|CA|CB|CF|CH|CM|CO|CR|CT|CV|CW|DA|DD|DE|DG|DH|DL|DN|DT|DY|E|EC|EH|EN|EX|FK|FY|G|GL|GY|GU|HA|HD|HG|HP|HR|HS|HU|HX|IG|IM|IP|IV|JE|KA|KT|KW|KY|L|LA|LD|LE|LL|LN|LS|LU|M|ME|MK|ML|N|NE|NG|NN|NP|NR|NW|OL|OX|PA|PE|PH|PL|PO|PR|RG|RH|RM|S|SA|SE|SG|SK|SL|SM|SN|SO|SP|SR|SS|ST|SW|SY|TA|TD|TF|TN|TQ|TR|TS|TW|UB|W|WA|WC|WD|WF|WN|WR|WS|WV|YO|ZE)(?:\\d[\\dA-Z]? ?\\d[ABD-HJLN-UW-Z]{2}))|BFPO ?\\d{1,4}", + "zipex":"EC1Y 8SY,GIR 0AA,M2 5BQ,M34 4AB,CR0 2YR,DN16 9AA,W1A 4ZZ,EC1A 1HQ,OX14 4PG,BS18 8HF,NR25 7HG,RH6 0NP,BH23 6AA,B6 5BA,SO23 9AP,PO1 3AX,BFPO 61" + }, + "GD":{ + "fmt":"%C" + }, + "GE":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{4}" + }, + "GF":{ + "fmt":"%O%n%N%n%A%n%Z %C %X", + "require":"ACZ", + "zip":"9[78]3\\d{2}" + }, + "GG":{ + "fmt":"%N%n%O%n%A%n%C%nGUERNSEY%n%Z", + "require":"ACZ", + "zip":"GY\\d[\\dA-Z]? ?\\d[ABD-HJLN-UW-Z]{2}" + }, + "GH":{ + "fmt":"%C" + }, + "GI":{ + "fmt":"%N%n%O%n%A%nGIBRALTAR%n%Z", + "require":"A", + "zip":"GX11 1AA" + }, + "GL":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "require":"ACZ", + "zip":"39\\d{2}" + }, + "GM":{ + "fmt":"%C" + }, + "GN":{ + "fmt":"%N%n%O%n%Z %A %C", + "zip":"\\d{3}" + }, + "GP":{ + "fmt":"%O%n%N%n%A%n%Z %C %X", + "require":"ACZ", + "zip":"9[78][01]\\d{2}" + }, + "GQ":{ + "fmt":"%C" + }, + "GR":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "require":"ACZ", + "zip":"\\d{3} ?\\d{2}" + }, + "GS":{ + "fmt":"%N%n%O%n%A%n%n%C%n%Z", + "require":"ACZ", + "zip":"SIQQ 1ZZ" + }, + "GT":{ + "fmt":"%N%n%O%n%A%n%Z- %C", + "zip":"\\d{5}" + }, + "GU":{ + "fmt":"%N%n%O%n%A%n%C %Z", + "require":"ACZ", + "zip":"(969(?:[12]\\d|3[12]))(?:[ \\-](\\d{4}))?", + "zip_name_type":"zip" + }, + "GW":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{4}" + }, + "GY":{ + "fmt":"%C" + }, + "HK":{ + "fmt":"%S%n%C%n%A%n%O%n%N", + "lfmt":"%N%n%O%n%A%n%C%n%S", + "locality_name_type":"district", + "require":"AS", + "state_name_type":"area" + }, + "HN":{ + "fmt":"%N%n%O%n%A%n%C, %S%n%Z", + "require":"ACS", + "state_name_type":"department", + "zip":"\\d{5}" + }, + "HR":{ + "fmt":"%N%n%O%n%A%nHR-%Z %C", + "postprefix":"HR-", + "zip":"\\d{5}" + }, + "HT":{ + "fmt":"%N%n%O%n%A%nHT%Z %C", + "postprefix":"HT", + "zip":"\\d{4}" + }, + "HU":{ + "fmt":"%N%n%O%n%C%n%A%n%Z", + "require":"ACZ", + "zip":"\\d{4}" + }, + "ID":{ + "fmt":"%N%n%O%n%A%n%C%n%S %Z", + "require":"AS", + "zip":"\\d{5}" + }, + "IE":{ + "fmt":"%N%n%O%n%A%n%D%n%C%n%S %Z", + "state_name_type":"county", + "sublocality_name_type":"townland", + "zip":"[\\dA-Z]{3} ?[\\dA-Z]{4}", + "zip_name_type":"eircode" + }, + "IL":{ + "fmt":"%N%n%O%n%A%n%C %Z", + "zip":"\\d{5}(?:\\d{2})?" + }, + "IM":{ + "fmt":"%N%n%O%n%A%n%C%n%Z", + "require":"ACZ", + "zip":"IM\\d[\\dA-Z]? ?\\d[ABD-HJLN-UW-Z]{2}" + }, + "IN":{ + "fmt":"%N%n%O%n%A%n%C %Z%n%S", + "require":"ACSZ", + "state_name_type":"state", + "zip":"\\d{6}", + "zip_name_type":"pin" + }, + "IO":{ + "fmt":"%N%n%O%n%A%n%C%n%Z", + "require":"ACZ", + "zip":"BBND 1ZZ" + }, + "IQ":{ + "fmt":"%O%n%N%n%A%n%C, %S%n%Z", + "require":"ACS", + "zip":"\\d{5}" + }, + "IS":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{3}" + }, + "IT":{ + "fmt":"%N%n%O%n%A%n%Z %C %S", + "require":"ACSZ", + "zip":"\\d{5}" + }, + "JE":{ + "fmt":"%N%n%O%n%A%n%C%nJERSEY%n%Z", + "require":"ACZ", + "zip":"JE\\d[\\dA-Z]? ?\\d[ABD-HJLN-UW-Z]{2}" + }, + "JM":{ + "fmt":"%N%n%O%n%A%n%C%n%S %X", + "require":"ACS", + "state_name_type":"parish" + }, + "JO":{ + "fmt":"%N%n%O%n%A%n%C %Z", + "zip":"\\d{5}" + }, + "JP":{ + "fmt":"〒%Z%n%S%n%A%n%O%n%N", + "lfmt":"%N%n%O%n%A, %S%n%Z", + "require":"ASZ", + "state_name_type":"prefecture", + "zip":"\\d{3}-?\\d{4}" + }, + "KE":{ + "fmt":"%N%n%O%n%A%n%C%n%Z", + "zip":"\\d{5}" + }, + "KG":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{6}" + }, + "KH":{ + "fmt":"%N%n%O%n%A%n%C %Z", + "zip":"\\d{5}" + }, + "KI":{ + "fmt":"%N%n%O%n%A%n%S%n%C", + "state_name_type":"island" + }, + "KM":{ + "fmt":"%C" + }, + "KN":{ + "fmt":"%N%n%O%n%A%n%C, %S", + "require":"ACS", + "state_name_type":"island" + }, + "KR":{ + "fmt":"%S %C%D%n%A%n%O%n%N%n%Z", + "lfmt":"%N%n%O%n%A%n%D%n%C%n%S%n%Z", + "require":"ACSZ", + "state_name_type":"do_si", + "sublocality_name_type":"district", + "zip":"\\d{5}" + }, + "KW":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{5}" + }, + "KY":{ + "fmt":"%N%n%O%n%A%n%S %Z", + "require":"AS", + "state_name_type":"island", + "zip":"KY\\d-\\d{4}" + }, + "KZ":{ + "fmt":"%Z%n%S%n%C%n%A%n%O%n%N", + "zip":"\\d{6}" + }, + "LA":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{5}" + }, + "LB":{ + "fmt":"%N%n%O%n%A%n%C %Z", + "zip":"(?:\\d{4})(?: ?(?:\\d{4}))?" + }, + "LC":{ + "fmt":"%C" + }, + "LI":{ + "fmt":"%O%n%N%n%A%nFL-%Z %C", + "postprefix":"FL-", + "require":"ACZ", + "zip":"948[5-9]|949[0-8]" + }, + "LK":{ + "fmt":"%N%n%O%n%A%n%C%n%Z", + "zip":"\\d{5}" + }, + "LR":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{4}" + }, + "LS":{ + "fmt":"%N%n%O%n%A%n%C %Z", + "zip":"\\d{3}" + }, + "LT":{ + "fmt":"%O%n%N%n%A%nLT-%Z %C", + "postprefix":"LT-", + "require":"ACZ", + "zip":"\\d{5}" + }, + "LU":{ + "fmt":"%O%n%N%n%A%nL-%Z %C", + "postprefix":"L-", + "require":"ACZ", + "zip":"\\d{4}" + }, + "LV":{ + "fmt":"%N%n%O%n%A%n%C, %Z", + "require":"ACZ", + "zip":"LV-\\d{4}" + }, + "LY":{ + "fmt":"%C" + }, + "MA":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{5}" + }, + "MC":{ + "fmt":"%N%n%O%n%A%nMC-%Z %C %X", + "postprefix":"MC-", + "zip":"980\\d{2}" + }, + "MD":{ + "fmt":"%N%n%O%n%A%nMD-%Z %C", + "postprefix":"MD-", + "zip":"\\d{4}" + }, + "ME":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"8\\d{4}" + }, + "MF":{ + "fmt":"%O%n%N%n%A%n%Z %C %X", + "require":"ACZ", + "zip":"9[78][01]\\d{2}" + }, + "MG":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{3}" + }, + "MK":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{4}" + }, + "ML":{ + "fmt":"%C" + }, + "MM":{ + "fmt":"%N%n%O%n%A%n%C, %Z", + "zip":"\\d{5}" + }, + "MN":{ + "fmt":"%N%n%O%n%A%n%C%n%S %Z", + "zip":"\\d{5}" + }, + "MO":{ + "fmt":"%A%n%O%n%N", + "lfmt":"%N%n%O%n%A", + "require":"A" + }, + "MQ":{ + "fmt":"%O%n%N%n%A%n%Z %C %X", + "require":"ACZ", + "zip":"9[78]2\\d{2}" + }, + "MR":{ + "fmt":"%C" + }, + "MS":{ + "fmt":"%C" + }, + "MT":{ + "fmt":"%N%n%O%n%A%n%C %Z", + "zip":"[A-Z]{3} ?\\d{2,4}" + }, + "MU":{ + "fmt":"%N%n%O%n%A%n%Z%n%C", + "zip":"\\d{3}(?:\\d{2}|[A-Z]{2}\\d{3})" + }, + "MV":{ + "fmt":"%N%n%O%n%A%n%C %Z", + "zip":"\\d{5}" + }, + "MW":{ + "fmt":"%N%n%O%n%A%n%C %X" + }, + "MX":{ + "fmt":"%N%n%O%n%A%n%D%n%Z %C, %S", + "require":"ACSZ", + "state_name_type":"state", + "sublocality_name_type":"neighborhood", + "zip":"\\d{5}" + }, + "MY":{ + "fmt":"%N%n%O%n%A%n%D%n%Z %C%n%S", + "require":"ACZ", + "state_name_type":"state", + "sublocality_name_type":"village_township", + "zip":"\\d{5}" + }, + "MZ":{ + "fmt":"%N%n%O%n%A%n%Z %C%S", + "zip":"\\d{4}" + }, + "NA":{ + "fmt":"%N%n%O%n%A%n%Cn%Z", + "zip":"\\d{5}" + }, + "NC":{ + "fmt":"%O%n%N%n%A%n%Z %C %X", + "require":"ACZ", + "zip":"988\\d{2}" + }, + "NE":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{4}" + }, + "NG":{ + "fmt":"%N%n%O%n%A%n%D%n%C %Z%n%S", + "state_name_type":"state", + "zip":"\\d{6}" + }, + "NI":{ + "fmt":"%N%n%O%n%A%n%Z%n%C, %S", + "state_name_type":"department", + "zip":"\\d{5}" + }, + "NL":{ + "fmt":"%O%n%N%n%A%n%Z %C", + "require":"ACZ", + "zip":"\\d{4} ?[A-Z]{2}" + }, + "NO":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "locality_name_type":"post_town", + "require":"ACZ", + "zip":"\\d{4}" + }, + "NP":{ + "fmt":"%N%n%O%n%A%n%C %Z", + "zip":"\\d{5}" + }, + "NR":{ + "fmt":"%N%n%O%n%A%n%S", + "require":"AS", + "state_name_type":"district" + }, + "NU":{ + "fmt":"%C" + }, + "NZ":{ + "fmt":"%N%n%O%n%A%n%D%n%C %Z", + "require":"ACZ", + "zip":"\\d{4}" + }, + "OM":{ + "fmt":"%N%n%O%n%A%n%Z%n%C", + "zip":"(?:PC )?\\d{3}" + }, + "PA":{ + "fmt":"%N%n%O%n%A%n%C%n%S" + }, + "PE":{ + "fmt":"%N%n%O%n%A%n%C %Z%n%S", + "locality_name_type":"district", + "zip":"(?:LIMA \\d{1,2}|CALLAO 0?\\d)|[0-2]\\d{4}" + }, + "PF":{ + "fmt":"%N%n%O%n%A%n%Z %C %S", + "require":"ACSZ", + "state_name_type":"island", + "zip":"987\\d{2}" + }, + "PG":{ + "fmt":"%N%n%O%n%A%n%C %Z %S", + "require":"ACS", + "zip":"\\d{3}" + }, + "PH":{ + "fmt":"%N%n%O%n%A%n%D, %C%n%Z %S", + "zip":"\\d{4}" + }, + "PK":{ + "fmt":"%N%n%O%n%A%n%C-%Z", + "zip":"\\d{5}" + }, + "PL":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "require":"ACZ", + "zip":"\\d{2}-\\d{3}" + }, + "PM":{ + "fmt":"%O%n%N%n%A%n%Z %C %X", + "require":"ACZ", + "zip":"9[78]5\\d{2}" + }, + "PN":{ + "fmt":"%N%n%O%n%A%n%C%n%Z", + "require":"ACZ", + "zip":"PCRN 1ZZ" + }, + "PR":{ + "fmt":"%N%n%O%n%A%n%C PR %Z", + "postprefix":"PR ", + "require":"ACZ", + "zip":"(00[679]\\d{2})(?:[ \\-](\\d{4}))?", + "zip_name_type":"zip" + }, + "PS":{ + "fmt":"%C" + }, + "PT":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "require":"ACZ", + "zip":"\\d{4}-\\d{3}" + }, + "PY":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{4}" + }, + "QA":{ + "fmt":"%C" + }, + "RE":{ + "fmt":"%O%n%N%n%A%n%Z %C %X", + "require":"ACZ", + "zip":"9[78]4\\d{2}" + }, + "RO":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "require":"ACZ", + "zip":"\\d{6}" + }, + "RS":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{5,6}" + }, + "RU":{ + "fmt":"%N%n%O%n%A%n%C%n%S%n%Z", + "lfmt":"%N%n%O%n%A%n%C%n%S%n%Z", + "require":"ACSZ", + "state_name_type":"oblast", + "zip":"\\d{6}" + }, + "RW":{ + "fmt":"%C" + }, + "SA":{ + "fmt":"%N%n%O%n%A%n%C %Z", + "zip":"\\d{5}" + }, + "SB":{ + "fmt":"%C" + }, + "SC":{ + "fmt":"%N%n%O%n%A%n%C%n%S", + "state_name_type":"island" + }, + "SE":{ + "fmt":"%O%n%N%n%A%nSE-%Z %C", + "locality_name_type":"post_town", + "postprefix":"SE-", + "require":"ACZ", + "zip":"\\d{3} ?\\d{2}" + }, + "SG":{ + "fmt":"%N%n%O%n%A%nSINGAPORE %Z", + "require":"AZ", + "zip":"\\d{6}" + }, + "SH":{ + "fmt":"%N%n%O%n%A%n%C%n%Z", + "require":"ACZ", + "zip":"(?:ASCN|STHL) 1ZZ" + }, + "SI":{ + "fmt":"%N%n%O%n%A%nSI-%Z %C", + "postprefix":"SI-", + "zip":"\\d{4}" + }, + "SJ":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "locality_name_type":"post_town", + "require":"ACZ", + "zip":"\\d{4}" + }, + "SK":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "require":"ACZ", + "zip":"\\d{3} ?\\d{2}" + }, + "SL":{ + "fmt":"%C" + }, + "SM":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "require":"AZ", + "zip":"4789\\d" + }, + "SN":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{5}" + }, + "SO":{ + "fmt":"%N%n%O%n%A%n%C, %S %Z", + "require":"ACS", + "zip":"[A-Z]{2} ?\\d{5}" + }, + "SR":{ + "fmt":"%N%n%O%n%A%n%C%n%S" + }, + "SS":{ + "fmt":"%C" + }, + "ST":{ + "fmt":"%C" + }, + "SV":{ + "fmt":"%N%n%O%n%A%n%Z-%C%n%S", + "require":"ACS", + "zip":"CP [1-3][1-7][0-2]\\d" + }, + "SX":{ + "fmt":"%C" + }, + "SZ":{ + "fmt":"%N%n%O%n%A%n%C%n%Z", + "zip":"[HLMS]\\d{3}" + }, + "TA":{ + "fmt":"%N%n%O%n%A%n%C%n%Z", + "zip":"TDCU 1ZZ" + }, + "TC":{ + "fmt":"%N%n%O%n%A%n%C%n%Z", + "require":"ACZ", + "zip":"TKCA 1ZZ" + }, + "TD":{ + "fmt":"%C" + }, + "TF":{ + "fmt":"%C" + }, + "TG":{ + "fmt":"%C" + }, + "TH":{ + "fmt":"%N%n%O%n%A%n%D %C%n%S %Z", + "lfmt":"%N%n%O%n%A%n%D, %C%n%S %Z", + "zip":"\\d{5}" + }, + "TJ":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{6}" + }, + "TK":{ + "fmt":"%C" + }, + "TL":{ + "fmt":"%C" + }, + "TM":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{6}" + }, + "TN":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{4}" + }, + "TO":{ + "fmt":"%C" + }, + "TR":{ + "fmt":"%N%n%O%n%A%n%Z %C/%S", + "locality_name_type":"district", + "require":"ACZ", + "zip":"\\d{5}" + }, + "TT":{ + "fmt":"%C" + }, + "TV":{ + "fmt":"%N%n%O%n%A%n%C%n%S", + "state_name_type":"island" + }, + "TW":{ + "fmt":"%Z%n%S%C%n%A%n%O%n%N", + "lfmt":"%N%n%O%n%A%n%C, %S %Z", + "require":"ACSZ", + "state_name_type":"county", + "zip":"\\d{3}(?:\\d{2,3})?" + }, + "TZ":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{4,5}" + }, + "UA":{ + "fmt":"%N%n%O%n%A%n%C%n%S%n%Z", + "lfmt":"%N%n%O%n%A%n%C%n%S%n%Z", + "require":"ACZ", + "state_name_type":"oblast", + "zip":"\\d{5}" + }, + "UG":{ + "fmt":"%C" + }, + "US":{ + "fmt":"%N%n%O%n%A%n%C, %S %Z", + "require":"ACSZ", + "state_name_type":"state", + "zip":"\\d{5}", + "zip_name_type":"zip", + "sub_keys": [ + "AL", + "AK", + "AS", + "AZ", + "AR", + "AA", + "AE", + "AP", + "CA", + "CO", + "CT", + "DE", + "DC", + "FL", + "GA", + "GU", + "HI", + "ID", + "IL", + "IN", + "IA", + "KS", + "KY", + "LA", + "ME", + "MH", + "MD", + "MA", + "MI", + "FM", + "MN", + "MS", + "MO", + "MT", + "NE", + "NV", + "NH", + "NJ", + "NM", + "NY", + "NC", + "ND", + "MP", + "OH", + "OK", + "OR", + "PW", + "PA", + "PR", + "RI", + "SC", + "SD", + "TN", + "TX", + "UT", + "VT", + "VI", + "VA", + "WA", + "WV", + "WI", + "WY", + ], + "sub_labels": [ + "Alabama", + "Alaska", + "American Samoa", + "Arizona", + "Arkansas", + "Armed Forces (AA)", + "Armed Forces (AE)", + "Armed Forces (AP)", + "California", + "Colorado", + "Connecticut", + "Delaware", + "District of Columbia", + "Florida", + "Georgia", + "Guam", + "Hawaii", + "Idaho", + "Illinois", + "Indiana", + "Iowa", + "Kansas", + "Kentucky", + "Louisiana", + "Maine", + "Marshall Islands", + "Maryland", + "Massachusetts", + "Michigan", + "Micronesia", + "Minnesota", + "Mississippi", + "Missouri", + "Montana", + "Nebraska", + "Nevada", + "New Hampshire", + "New Jersey", + "New Mexico", + "New York", + "North Carolina", + "North Dakota", + "Northern Mariana Islands", + "Ohio", + "Oklahoma", + "Oregon", + "Palau", + "Pennsylvania", + "Puerto Rico", + "Rhode Island", + "South Carolina", + "South Dakota", + "Tennessee", + "Texas", + "Utah", + "Vermont", + "Virgin Islands", + "Virginia", + "Washington", + "West Virginia", + "Wisconsin", + "Wyoming", + ] + }, + "UY":{ + "fmt":"%N%n%O%n%A%n%Z %C %S", + "zip":"\\d{5}" + }, + "UZ":{ + "fmt":"%N%n%O%n%A%n%Z %C%n%S", + "zip":"\\d{6}" + }, + "VA":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"00120" + }, + "VC":{ + "fmt":"%N%n%O%n%A%n%C %Z", + "zip":"VC\\d{4}" + }, + "VE":{ + "fmt":"%N%n%O%n%A%n%C %Z, %S", + "require":"ACS", + "state_name_type":"state", + "zip":"\\d{4}" + }, + "VG":{ + "fmt":"%N%n%O%n%A%n%C%n%Z", + "require":"A", + "zip":"VG\\d{4}" + }, + "VN":{ + "fmt":"%N%n%O%n%A%n%C%n%S %Z", + "lfmt":"%N%n%O%n%A%n%C%n%S %Z", + "zip":"\\d{5}\\d?" + }, + "VU":{ + "fmt":"%C" + }, + "WF":{ + "fmt":"%O%n%N%n%A%n%Z %C %X", + "require":"ACZ", + "zip":"986\\d{2}" + }, + "WS":{ + "fmt":"%C" + }, + "XK":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"[1-7]\\d{4}" + }, + "YE":{ + "fmt":"%C" + }, + "YT":{ + "fmt":"%O%n%N%n%A%n%Z %C %X", + "require":"ACZ", + "zip":"976\\d{2}" + }, + "ZA":{ + "fmt":"%N%n%O%n%A%n%D%n%C%n%Z", + "require":"ACZ", + "zip":"\\d{4}" + }, + "ZM":{ + "fmt":"%N%n%O%n%A%n%Z %C", + "zip":"\\d{5}" + }, + "ZW":{ + "fmt":"%C" + } +} diff --git a/StripeUICore/StripeUICore/Resources/Localizations/bg-BG.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/bg-BG.lproj/Localizable.strings new file mode 100644 index 00000000..a5b85045 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/bg-BG.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (по желание)"; + +"Account number" = "Номер на сметката"; + +"Address" = "Адрес"; + +"Address line 1" = "Ред 1 на адреса"; + +"Address line 2" = "Ред 2 на адреса"; + +"Area" = "Район"; + +"BLIK code" = "BLIK код"; + +"BSB number" = "BSB номер"; + +"Billing address is same as shipping" = "Адресът за фактуриране е същият като за доставка"; + +"Cancel" = "Отмяна"; + +"City" = "Град"; + +"Code field" = "Поле за код"; + +"Company" = "Компания"; + +"Continue" = "Продължаване"; + +"Country" = "Държава"; + +"Country or region" = "Държава или регион"; + +"County" = "Окръг"; + +"Date is empty." = "Датата е празна."; + +"Department" = "Отдел"; + +"District" = "Окръг"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Искате ли да затворите този формуляр?"; + +"Done" = "Готово"; + +"Double tap to edit" = "Докоснете два пъти, за да редактирате"; + +"Edit" = "Редактиране"; + +"Eircode" = "Eircode"; + +"Email" = "Имейл"; + +"Emirate" = "Емирство"; + +"Error" = "Грешка"; + +"First" = "Собствено"; + +"Full name" = "Трите имена"; + +"Incomplete phone number" = "Непълен телефонен номер"; + +"Invalid UPI ID" = "Невалиден идентификационен номер на UPI"; + +"Island" = "Остров"; + +"Last" = "Фамилия"; + +"Name" = "Име"; + +"Name on account" = "Име на сметката"; + +"OK" = "OK"; + +"Oblast" = "Област"; + +"Other" = "Друга"; + +"Parish" = "Енория"; + +"Phone" = "Телефон"; + +"Postal code" = "Пощенски код"; + +"Prefecture" = "Префектура"; + +"Province" = "Област"; + +"Remove" = "Премахване"; + +"Remove bank account" = "Премахване на банкова сметка"; + +"Remove bank account ending in %@" = "Премахване на банкова сметка, завършваща на %@"; + +"Search" = "Търсене"; + +"State" = "Щат"; + +"Suburb" = "Предградие"; + +"Suburb or city" = "Предградие или град"; + +"The BSB you entered is incomplete." = "BSB, който сте въвели, е непълен."; + +"The ID number you entered is incomplete." = "Идентификационният номер, който сте въвели, е непълен."; + +"The account number you entered is incomplete." = "Въведеният от Вас номер на сметката е невалиден."; + +"Town or city" = "Град"; + +"UPI ID" = "UPI ID"; + +"Unable to parse phone number" = "Неуспешно анализиране на телефонния номер"; + +"Use rotor to access links" = "Използване на ротор за достъп до връзки"; + +"Your BLIK code is incomplete." = "Вашият BLIK код е непълен."; + +"Your BLIK code is invalid." = "Вашият BLIK код е невалиден."; + +"Your ZIP is incomplete." = "Вашият ZIP код е непълен."; + +"Your email is invalid." = "Вашият имейл е невалиден."; + +"Your payment information will not be saved." = "Вашата информация за плащане няма да бъде запазена."; + +"Your postal code is incomplete." = "Вашият пощенски код е непълен."; + +"ZIP" = "п.к."; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/ca-ES.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/ca-ES.lproj/Localizable.strings new file mode 100644 index 00000000..7270cd69 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/ca-ES.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (opcional)"; + +"Account number" = "Número de Compte"; + +"Address" = "Adreça"; + +"Address line 1" = "Línia de l'adreça 1"; + +"Address line 2" = "Línia de l'adreça 2"; + +"Area" = "Àrea"; + +"BLIK code" = "Codi BLIK"; + +"BSB number" = "Número BSB"; + +"Billing address is same as shipping" = "L'adreça de facturació és la mateixa que la d'enviament"; + +"Cancel" = "Cancel·la"; + +"City" = "Ciutat"; + +"Code field" = "Camp pel codi"; + +"Company" = "Empresa"; + +"Continue" = "Continua"; + +"Country" = "País"; + +"Country or region" = "País o regió"; + +"County" = "Comtat"; + +"Date is empty." = "La data és buida."; + +"Department" = "Departament"; + +"District" = "Districte"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Voleu tancar el formulari?"; + +"Done" = "Fet"; + +"Double tap to edit" = "Toca dos cops per editar"; + +"Edit" = "Editar"; + +"Eircode" = "Eircode"; + +"Email" = "Correu electrònic"; + +"Emirate" = "Emirat"; + +"Error" = "Error"; + +"First" = "Nom"; + +"Full name" = "Nom complet"; + +"Incomplete phone number" = "Número de telèfon incomplet"; + +"Invalid UPI ID" = "ID d'UPI no vàlid"; + +"Island" = "Illa"; + +"Last" = "Cognom"; + +"Name" = "Nom"; + +"Name on account" = "Nom al compte"; + +"OK" = "D'acord"; + +"Oblast" = "Óblast"; + +"Other" = "Un altre"; + +"Parish" = "Parròquia"; + +"Phone" = "Telèfon"; + +"Postal code" = "Codi postal"; + +"Prefecture" = "Prefectura"; + +"Province" = "Província"; + +"Remove" = "Eliminar"; + +"Remove bank account" = "Elimina el compte bancari"; + +"Remove bank account ending in %@" = "Elimina el compte bancari que acaba en %@"; + +"Search" = "Cerca"; + +"State" = "País"; + +"Suburb" = "Suburbi"; + +"Suburb or city" = "Suburbi o ciutat"; + +"The BSB you entered is incomplete." = "El BSB que has introduït és incomplet."; + +"The ID number you entered is incomplete." = "El número d'identificació que has introduït és incomplet."; + +"The account number you entered is incomplete." = "El número de compte que heu introduït és incomplet."; + +"Town or city" = "Municipi o ciutat"; + +"UPI ID" = "ID d'UPI"; + +"Unable to parse phone number" = "No s'ha pogut analitzar el número de telèfon"; + +"Use rotor to access links" = "Empreu el rotor per accedir als enllaços"; + +"Your BLIK code is incomplete." = "El codi BLIK no està complet."; + +"Your BLIK code is invalid." = "El codi BLIK no és vàlid."; + +"Your ZIP is incomplete." = "El teu ZIP no està complet."; + +"Your email is invalid." = "L'adreça electrònica no és vàlida."; + +"Your payment information will not be saved." = "La informació de pagament no es desarà."; + +"Your postal code is incomplete." = "El teu codi postal no està complet."; + +"ZIP" = "ZIP"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/cs-CZ.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/cs-CZ.lproj/Localizable.strings new file mode 100644 index 00000000..d296bfbf --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/cs-CZ.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (volitelné)"; + +"Account number" = "Číslo účtu"; + +"Address" = "Adresa"; + +"Address line 1" = "1. řádek adresy"; + +"Address line 2" = "2. řádek adresy"; + +"Area" = "Oblast"; + +"BLIK code" = "Kód BLIK"; + +"BSB number" = "Číslo BSB"; + +"Billing address is same as shipping" = "Fakturační adresa je stejná jako dodací"; + +"Cancel" = "Zrušit"; + +"City" = "Město"; + +"Code field" = "Pole pro kód"; + +"Company" = "Společnost"; + +"Continue" = "Pokračovat"; + +"Country" = "Země"; + +"Country or region" = "Země nebo region"; + +"County" = "Okres"; + +"Date is empty." = "Datum je prázdné."; + +"Department" = "Oddělení"; + +"District" = "Okrsek"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Chcete tento formulář zavřít?"; + +"Done" = "Hotovo"; + +"Double tap to edit" = "Dvakrát klepněte pro úpravu"; + +"Edit" = "Upravit"; + +"Eircode" = "Eircode"; + +"Email" = "E-mail"; + +"Emirate" = "Emirát"; + +"Error" = "Chyba"; + +"First" = "Jméno"; + +"Full name" = "Celé jméno"; + +"Incomplete phone number" = "Neúplné telefonní číslo"; + +"Invalid UPI ID" = "Neplatné UPI ID"; + +"Island" = "Ostrov"; + +"Last" = "Příjmení"; + +"Name" = "Jméno"; + +"Name on account" = "Jméno na účtu"; + +"OK" = "OK"; + +"Oblast" = "Oblast"; + +"Other" = "Jiné"; + +"Parish" = "Obec"; + +"Phone" = "Telefon"; + +"Postal code" = "Poštovní směrovací číslo"; + +"Prefecture" = "Prefektura"; + +"Province" = "Provincie"; + +"Remove" = "Odebrat"; + +"Remove bank account" = "Odebrat bankovní účet"; + +"Remove bank account ending in %@" = "Odebrat bankovní účet končící na %@"; + +"Search" = "Hledat"; + +"State" = "Stát"; + +"Suburb" = "Předměstí"; + +"Suburb or city" = "Předměstí nebo město"; + +"The BSB you entered is incomplete." = "Zadaný BSB je neúplný."; + +"The ID number you entered is incomplete." = "Identifikační číslo, které jste zadali, je neúplné."; + +"The account number you entered is incomplete." = "Zadané číslo účtu je neúplné."; + +"Town or city" = "Město nebo velkoměsto"; + +"UPI ID" = "UPI ID"; + +"Unable to parse phone number" = "Nelze analyzovat telefonní číslo"; + +"Use rotor to access links" = "Přístup k odkazům pomocí rotoru"; + +"Your BLIK code is incomplete." = "Váš kód BLIK je neúplný."; + +"Your BLIK code is invalid." = "Váš kód BLIK je neplatný."; + +"Your ZIP is incomplete." = "PSČ je neúplné."; + +"Your email is invalid." = "Váš e-mail je neplatný."; + +"Your payment information will not be saved." = "Vaše platební údaje nebudou uloženy."; + +"Your postal code is incomplete." = "Vaše poštovní směrovací číslo je neúplné."; + +"ZIP" = "PSČ"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/da.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/da.lproj/Localizable.strings new file mode 100644 index 00000000..eac00dcd --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/da.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (valgfrit)"; + +"Account number" = "Kontonummer"; + +"Address" = "Adresse"; + +"Address line 1" = "Adresselinje 1"; + +"Address line 2" = "Adresselinje 2"; + +"Area" = "Område"; + +"BLIK code" = "BLIK-kode"; + +"BSB number" = "BSB-nummer"; + +"Billing address is same as shipping" = "Faktureringsadresse er den samme som forsendelse"; + +"Cancel" = "Annuller"; + +"City" = "By"; + +"Code field" = "Kodefelt"; + +"Company" = "Virksomhed"; + +"Continue" = "Fortsæt"; + +"Country" = "Land"; + +"Country or region" = "Land eller region"; + +"County" = "Amt"; + +"Date is empty." = "Dato er tom."; + +"Department" = "Afdeling"; + +"District" = "Distrikt"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Vil du lukke denne formular?"; + +"Done" = "Færdig"; + +"Double tap to edit" = "Tryk to gange for at redigere"; + +"Edit" = "Rediger"; + +"Eircode" = "Eircode"; + +"Email" = "E-mailadresse"; + +"Emirate" = "Emirat"; + +"Error" = "Fejl"; + +"First" = "Første"; + +"Full name" = "Fulde navn"; + +"Incomplete phone number" = "Ufuldstændigt telefonnummer"; + +"Invalid UPI ID" = "Ugyldigt UPI ID"; + +"Island" = "Ø"; + +"Last" = "Sidste"; + +"Name" = "Navn"; + +"Name on account" = "Navn på konto"; + +"OK" = "OK"; + +"Oblast" = "Oblast"; + +"Other" = "Andet"; + +"Parish" = "Sogn"; + +"Phone" = "Telefon"; + +"Postal code" = "Postnummer"; + +"Prefecture" = "Præfektur"; + +"Province" = "Provins"; + +"Remove" = "Fjern"; + +"Remove bank account" = "Fjern bankkonto"; + +"Remove bank account ending in %@" = "Fjern bankkonto, der ender på %@"; + +"Search" = "Søg"; + +"State" = "Stat"; + +"Suburb" = "Forstad"; + +"Suburb or city" = "Forstad eller storby"; + +"The BSB you entered is incomplete." = "Den BSB, du angav, er ufuldstændig."; + +"The ID number you entered is incomplete." = "Det indtastede ID-nummer er ikke komplet."; + +"The account number you entered is incomplete." = "Det indtastede kontonummer er ikke komplet."; + +"Town or city" = "Landsby eller by"; + +"UPI ID" = "UPI ID"; + +"Unable to parse phone number" = "Kunne ikke analysere telefonnummer"; + +"Use rotor to access links" = "Brug hjulet til at tilgå links"; + +"Your BLIK code is incomplete." = "Din BLIK-kode er ufuldstændig."; + +"Your BLIK code is invalid." = "Din BLIK-kode er ugyldig."; + +"Your ZIP is incomplete." = "Dit postnummer er ikke fuldendt."; + +"Your email is invalid." = "Din e-mail er ugyldig."; + +"Your payment information will not be saved." = "Dine betalingsoplysninger gemmes ikke."; + +"Your postal code is incomplete." = "Dit postnummer er ikke fuldendt."; + +"ZIP" = "ZIP"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/de.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/de.lproj/Localizable.strings new file mode 100644 index 00000000..22fa4cab --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/de.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (optional)"; + +"Account number" = "Kontonummer"; + +"Address" = "Adresse"; + +"Address line 1" = "Adresszeile 1"; + +"Address line 2" = "Adresszeile 2"; + +"Area" = "Region"; + +"BLIK code" = "BLIK-Code"; + +"BSB number" = "BSB-Nummer"; + +"Billing address is same as shipping" = "Rechnungsadresse und Versandadresse sind identisch."; + +"Cancel" = "Abbrechen"; + +"City" = "Ort"; + +"Code field" = "Code-Feld"; + +"Company" = "Unternehmen"; + +"Continue" = "Weiter"; + +"Country" = "Land"; + +"Country or region" = "Land oder Region"; + +"County" = "Kreis/Bezirk"; + +"Date is empty." = "Datum ist leer."; + +"Department" = "Departement"; + +"District" = "Distrikt"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Möchten Sie dieses Formular schließen?"; + +"Done" = "Fertig"; + +"Double tap to edit" = "Doppelklick zum Bearbeiten"; + +"Edit" = "Bearbeiten"; + +"Eircode" = "Eircode"; + +"Email" = "E-Mail"; + +"Emirate" = "Emirat"; + +"Error" = "Fehler"; + +"First" = "Vorname"; + +"Full name" = "Vollständiger Name"; + +"Incomplete phone number" = "Unvollständige Telefonnummer"; + +"Invalid UPI ID" = "Ungültige UPI-ID"; + +"Island" = "Insel"; + +"Last" = "Name"; + +"Name" = "Name"; + +"Name on account" = "Name des/der Kontoinhaber/in"; + +"OK" = "OK"; + +"Oblast" = "Oblast"; + +"Other" = "Sonstiges"; + +"Parish" = "Parish"; + +"Phone" = "Telefon"; + +"Postal code" = "Postleitzahl"; + +"Prefecture" = "Präfektur"; + +"Province" = "Provinz / Kanton"; + +"Remove" = "Entfernen"; + +"Remove bank account" = "Bankkonto entfernen"; + +"Remove bank account ending in %@" = "Bankkonto mit den Endziffern %@ entfernen"; + +"Search" = "Suchen"; + +"State" = "Bundesland"; + +"Suburb" = "Vorort"; + +"Suburb or city" = "Vorort oder Stadt"; + +"The BSB you entered is incomplete." = "Die eingegebene BSB-Nummer ist unvollständig."; + +"The ID number you entered is incomplete." = "Die eingegebene Ausweisnummer ist unvollständig."; + +"The account number you entered is incomplete." = "Die von Ihnen eingegebene Kontonummer ist unvollständig."; + +"Town or city" = "Stadt oder Ort"; + +"UPI ID" = "UPI-ID"; + +"Unable to parse phone number" = "Telefonnummer kann nicht geparst werden"; + +"Use rotor to access links" = "Rotor zum Zugriff auf Links nutzen"; + +"Your BLIK code is incomplete." = "Ihr BLIK-Code ist unvollständig."; + +"Your BLIK code is invalid." = "Ihr BLIK code ist ungültig."; + +"Your ZIP is incomplete." = "PLZ ist unvollständig."; + +"Your email is invalid." = "Ihre E-Mail-Adresse ist ungültig."; + +"Your payment information will not be saved." = "Ihre Zahlungsinformationen werden nicht gespeichert."; + +"Your postal code is incomplete." = "Postleitzahl ist unvollständig."; + +"ZIP" = "ZIP"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/el-GR.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/el-GR.lproj/Localizable.strings new file mode 100644 index 00000000..9be8f460 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/el-GR.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (προαιρετικό)"; + +"Account number" = "Αριθμός λογαριασμού"; + +"Address" = "Διεύθυνση"; + +"Address line 1" = "Γραμμή διεύθυνσης 1"; + +"Address line 2" = "Γραμμή διεύθυνσης 2"; + +"Area" = "Περιοχή"; + +"BLIK code" = "Κωδικός BLIK"; + +"BSB number" = "Αριθμός BSB"; + +"Billing address is same as shipping" = "Η διεύθυνση τιμολόγησης είναι ίδια με τη διεύθυνση αποστολής"; + +"Cancel" = "Ακύρωση"; + +"City" = "Πόλη"; + +"Code field" = "Πεδίο κωδικού"; + +"Company" = "Εταιρεία"; + +"Continue" = "Συνέχεια"; + +"Country" = "Χώρα"; + +"Country or region" = "Χώρα ή περιοχή"; + +"County" = "Κομητεία"; + +"Date is empty." = "Η ημερομηνία είναι κενή."; + +"Department" = "Τμήμα"; + +"District" = "Περιφέρεια"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Θέλετε να κλείσετε αυτή τη φόρμα;"; + +"Done" = "Τέλος"; + +"Double tap to edit" = "Πατήστε δύο φορές για επεξεργασία"; + +"Edit" = "Επεξεργασία"; + +"Eircode" = "Ταχυδρομικός κώδικας"; + +"Email" = "Διεύθυνση ηλεκτρονικού ταχυδρομείου"; + +"Emirate" = "Εμιράτο"; + +"Error" = "Σφάλμα"; + +"First" = "Όνομα"; + +"Full name" = "Πλήρες όνομα"; + +"Incomplete phone number" = "Ελλιπής αριθμός τηλεφώνου"; + +"Invalid UPI ID" = "Μη έγκυρο αναγνωριστικό UPI"; + +"Island" = "Νησί"; + +"Last" = "Επώνυμο"; + +"Name" = "Όνομα"; + +"Name on account" = "Όνομα στον λογαριασμό"; + +"OK" = "OK"; + +"Oblast" = "Περιφέρεια"; + +"Other" = "Άλλο"; + +"Parish" = "Δήμος"; + +"Phone" = "Τηλέφωνο"; + +"Postal code" = "Ταχυδρομικός κώδικας"; + +"Prefecture" = "Νομός"; + +"Province" = "Επαρχία"; + +"Remove" = "Αφαίρεση"; + +"Remove bank account" = "Αφαίρεση τραπεζικού λογαριασμού"; + +"Remove bank account ending in %@" = "Αφαίρεση του τραπεζικού λογαριασμού που λήγει σε %@"; + +"Search" = "Αναζήτηση"; + +"State" = "Πολιτεία"; + +"Suburb" = "Προάστιο"; + +"Suburb or city" = "Προάστιο ή πόλη"; + +"The BSB you entered is incomplete." = "Ο κωδικός BSB που εισάγατε είναι ατελής."; + +"The ID number you entered is incomplete." = "Ο αριθμός ταυτότητας που εισαγάγατε είναι ελλιπής."; + +"The account number you entered is incomplete." = "Ο αριθμός λογαριασμού που εισαγάγατε είναι ελλιπής."; + +"Town or city" = "Κωμόπολη ή πόλη"; + +"UPI ID" = "Αναγνωριστικό UPI"; + +"Unable to parse phone number" = "Δεν ήταν δυνατή η ανάλυση του αριθμού τηλεφώνου"; + +"Use rotor to access links" = "Χρησιμοποιήστε τον ρότορα για να προσπελάσετε τους συνδέσμους"; + +"Your BLIK code is incomplete." = "Ο κωδικός σας BLIK είναι ελλιπής."; + +"Your BLIK code is invalid." = "Ο κωδικός σας BLIK δεν είναι έγκυρος."; + +"Your ZIP is incomplete." = "Ο ταχυδρομικός σας κώδικας είναι ελλιπής."; + +"Your email is invalid." = "Η διεύθυνση email σας δεν είναι έγκυρη."; + +"Your payment information will not be saved." = "Τα στοιχεία πληρωμής δε θα αποθηκευτούν."; + +"Your postal code is incomplete." = "Ο ταχυδρομικός κώδικάς σας είναι ελλιπής."; + +"ZIP" = "Τ.Κ."; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/en-GB.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/en-GB.lproj/Localizable.strings new file mode 100644 index 00000000..88cb752e --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/en-GB.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (optional)"; + +"Account number" = "Account number"; + +"Address" = "Address"; + +"Address line 1" = "Address line 1"; + +"Address line 2" = "Address line 2"; + +"Area" = "Area"; + +"BLIK code" = "BLIK code"; + +"BSB number" = "BSB number"; + +"Billing address is same as shipping" = "Billing address is same as shipping"; + +"Cancel" = "Cancel"; + +"City" = "City"; + +"Code field" = "Code field"; + +"Company" = "Company"; + +"Continue" = "Continue"; + +"Country" = "Country"; + +"Country or region" = "Country or region"; + +"County" = "County"; + +"Date is empty." = "Date is empty."; + +"Department" = "Department"; + +"District" = "District"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Do you want to close this form?"; + +"Done" = "Done"; + +"Double tap to edit" = "Double tap to edit"; + +"Edit" = "Edit"; + +"Eircode" = "Eircode"; + +"Email" = "Email"; + +"Emirate" = "Emirate"; + +"Error" = "Error"; + +"First" = "First"; + +"Full name" = "Full name"; + +"Incomplete phone number" = "Incomplete phone number"; + +"Invalid UPI ID" = "Invalid UPI ID"; + +"Island" = "Island"; + +"Last" = "Last"; + +"Name" = "Name"; + +"Name on account" = "Name on account"; + +"OK" = "OK"; + +"Oblast" = "Oblast"; + +"Other" = "Other"; + +"Parish" = "Parish"; + +"Phone" = "Phone"; + +"Postal code" = "Postcode"; + +"Prefecture" = "Prefecture"; + +"Province" = "Province"; + +"Remove" = "Remove"; + +"Remove bank account" = "Remove bank account"; + +"Remove bank account ending in %@" = "Remove bank account ending in %@"; + +"Search" = "Search"; + +"State" = "State"; + +"Suburb" = "Suburb"; + +"Suburb or city" = "Suburb or city"; + +"The BSB you entered is incomplete." = "The BSB you entered is incomplete."; + +"The ID number you entered is incomplete." = "The ID number you entered is incomplete."; + +"The account number you entered is incomplete." = "The account number you entered is incomplete."; + +"Town or city" = "Town or city"; + +"UPI ID" = "UPI ID"; + +"Unable to parse phone number" = "Unable to parse phone number"; + +"Use rotor to access links" = "Use rotor to access links"; + +"Your BLIK code is incomplete." = "Your BLIK code is incomplete."; + +"Your BLIK code is invalid." = "Your BLIK code is invalid."; + +"Your ZIP is incomplete." = "Your ZIP is incomplete."; + +"Your email is invalid." = "Your email is invalid."; + +"Your payment information will not be saved." = "Your payment information will not be saved."; + +"Your postal code is incomplete." = "Your postcode is incomplete."; + +"ZIP" = "ZIP"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/en.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/en.lproj/Localizable.strings new file mode 100644 index 00000000..6442b33d --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/en.lproj/Localizable.strings @@ -0,0 +1,214 @@ +/* The label of a text field that is optional. For example, 'Email (optional)' or 'Name (optional) */ +"%@ (optional)" = "%@ (optional)"; + +/* Caption for account number */ +"Account number" = "Account number"; + +/* Caption for Address field on address form +Section header for address fields */ +"Address" = "Address"; + +/* Address line 1 placeholder for billing address form.\nLabel for address line 1 field */ +"Address line 1" = "Address line 1"; + +/* Label for address line 2 field */ +"Address line 2" = "Address line 2"; + +/* Label of an address field */ +"Area" = "Area"; + +/* Label for a checkbox that makes customers billing address same as their shipping address */ +"Billing address is same as shipping" = "Billing address is same as shipping"; + +/* Label for BLIK code number field on form */ +"BLIK code" = "BLIK code"; + +/* Placeholder for AU BECS BSB number */ +"BSB number" = "BSB number"; + +/* Button title to cancel action in an alert */ +"Cancel" = "Cancel"; + +/* Caption for City field on address form */ +"City" = "City"; + +/* Accessibility label describing a field for entering a login code */ +"Code field" = "Code field"; + +/* Label for Company field on form */ +"Company" = "Company"; + +/* Text for continue button */ +"Continue" = "Continue"; + +/* Caption for Country field on address form */ +"Country" = "Country"; + +/* Country selector and postal code entry form header title\nLabel of an address field */ +"Country or region" = "Country or region"; + +/* Caption for County field on address form (only countries that use county, like United Kingdom) +Label of an address field */ +"County" = "County"; + +/* Error message for empty date. */ +"Date is empty." = "Date is empty."; + +/* Label of an address field */ +"Department" = "Department"; + +/* Label for the district field on an address form */ +"District" = "District"; + +/* Label of an address field */ +"Do Si" = "Do Si"; + +/* Used as the title for prompting the user if they want to close the sheet */ +"Do you want to close this form?" = "Do you want to close this form?"; + +/* Done button title */ +"Done" = "Done"; + +/* Accessibility hint for a text field */ +"Double tap to edit" = "Double tap to edit"; + +/* Button title to enter editing mode */ +"Edit" = "Edit"; + +/* Label of an address field */ +"Eircode" = "Eircode"; + +/* Label for Email field on form */ +"Email" = "Email"; + +/* Label of an address field */ +"Emirate" = "Emirate"; + +/* Text for error labels */ +"Error" = "Error"; + +/* Label for first (given) name field */ +"First" = "First"; + +/* Label for Full name field on form */ +"Full name" = "Full name"; + +/* Error description for incomplete phone number */ +"Incomplete phone number" = "Incomplete phone number"; + +/* Error message when UPI ID is invalid */ +"Invalid UPI ID" = "Invalid UPI ID"; + +/* Label of an address field */ +"Island" = "Island"; + +/* Label for last (family) name field */ +"Last" = "Last"; + +/* Label for Name field on form */ +"Name" = "Name"; + +/* Label for Name on account field on form */ +"Name on account" = "Name on account"; + +/* Label of an address field */ +"Oblast" = "Oblast"; + +/* ok button */ +"OK" = "OK"; + +/* An option in a dropdown selector indicating the customer's desired selection is not in the list. e.g., 'Choose your bank: Bank1, Bank2, Other' */ +"Other" = "Other"; + +/* Label of an address field */ +"Parish" = "Parish"; + +/* Caption for Phone field on address form */ +"Phone" = "Phone"; + +/* Label of an address field +Short string for postal code (text used in non-US countries) */ +"Postal code" = "Postal code"; + +/* Label of an address field */ +"Prefecture" = "Prefecture"; + +/* Caption for Province field on address form (only countries that use province, like Canada) +Label of an address field */ +"Province" = "Province"; + +/* Button title for confirmation alert to remove a saved payment method */ +"Remove" = "Remove"; + +/* Title for confirmation alert to remove a saved bank account payment method */ +"Remove bank account" = "Remove bank account"; + +/* Content for alert popup prompting to confirm removing a saved bank account. e.g. 'Remove bank account ending in 4242' */ +"Remove bank account ending in %@" = "Remove bank account ending in %@"; + +/* Title of a button with a 🔍 (magnifying glass) icon that starts a search when tapped */ +"Search" = "Search"; + +/* Message when a user is selecting a card brand in a dropdown */ +"Select card brand (optional)" = "Select card brand (optional)"; + +/* Placeholder for Bacs sort code (a bank routing number used in the UK and Ireland) */ +"Sort code" = "Sort code"; + +/* Caption for State field on address form (only countries that use state , like United States) +Label of an address field */ +"State" = "State"; + +/* Label of an address field */ +"Suburb" = "Suburb"; + +/* Label of an address field */ +"Suburb or city" = "Suburb or city"; + +/* Error description for incomplete account number */ +"The account number you entered is incomplete." = "The account number you entered is incomplete."; + +/* Error string displayed to user when they have entered an incomplete BSB number. */ +"The BSB you entered is incomplete." = "The BSB you entered is incomplete."; + +/* An error message. */ +"The ID number you entered is incomplete." = "The ID number you entered is incomplete."; + +/* Error string displayed to user when they have entered an invalid 'sort code' (a bank routing number used in the UK and Ireland) */ +"The sort code you entered is invalid." = "The sort code you entered is invalid."; + +/* Label of an address field */ +"Town or city" = "Town or city"; + +/* Error string when we can't parse a phone number */ +"Unable to parse phone number" = "Unable to parse phone number"; + +/* Label for UPI ID number field on form */ +"UPI ID" = "UPI ID"; + +/* Accessibility hint indicating to use the accessibility rotor to open links. The word 'rotor' should be localized to match Apple's language here: https://support.apple.com/HT204783 */ +"Use rotor to access links" = "Use rotor to access links"; + +/* Error message when BLIK code is incomplete */ +"Your BLIK code is incomplete." = "Your BLIK code is incomplete."; + +/* Error message when BLIK code is invalid */ +"Your BLIK code is invalid." = "Your BLIK code is invalid."; + +/* Error message when email is invalid */ +"Your email is invalid." = "Your email is invalid."; + +/* Used as the title for prompting the user if they want to close the sheet */ +"Your payment information will not be saved." = "Your payment information will not be saved."; + +/* Error message for when postal code in form is incomplete */ +"Your postal code is incomplete." = "Your postal code is incomplete."; + +/* Error message for when ZIP code in form is incomplete (US only) */ +"Your ZIP is incomplete." = "Your ZIP is incomplete."; + +/* Label of an address field +Short string for zip code (United States only) +Zip code placeholder US only */ +"ZIP" = "ZIP"; + diff --git a/StripeUICore/StripeUICore/Resources/Localizations/es-419.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/es-419.lproj/Localizable.strings new file mode 100644 index 00000000..295296c7 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/es-419.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (opcional)"; + +"Account number" = "Número de cuenta"; + +"Address" = "Dirección"; + +"Address line 1" = "Primera línea de la dirección"; + +"Address line 2" = "Segunda línea de la dirección"; + +"Area" = "Área"; + +"BLIK code" = "Código BLIK"; + +"BSB number" = "Número de BSB"; + +"Billing address is same as shipping" = "La dirección de facturación es la misma que la de envío."; + +"Cancel" = "Cancelar"; + +"City" = "Ciudad"; + +"Code field" = "Campo del código"; + +"Company" = "Empresa"; + +"Continue" = "Continuar"; + +"Country" = "País"; + +"Country or region" = "País o región"; + +"County" = "Condado"; + +"Date is empty." = "La fecha está vacía"; + +"Department" = "Departamento"; + +"District" = "Distrito"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "¿Deseas cerrar este formulario?"; + +"Done" = "¡Listo!"; + +"Double tap to edit" = "Pulsa dos veces para editar"; + +"Edit" = "Editar"; + +"Eircode" = "Eircode"; + +"Email" = "Correo electrónico"; + +"Emirate" = "Emirato"; + +"Error" = "Error"; + +"First" = "Nombre"; + +"Full name" = "Nombre completo"; + +"Incomplete phone number" = "Número de teléfono incompleto"; + +"Invalid UPI ID" = "ID de UPI no válida"; + +"Island" = "Isla"; + +"Last" = "Apellido"; + +"Name" = "Nombre"; + +"Name on account" = "Nombre en la cuenta"; + +"OK" = "Aceptar"; + +"Oblast" = "Óblast"; + +"Other" = "Otro"; + +"Parish" = "Municipio"; + +"Phone" = "Teléfono"; + +"Postal code" = "Código postal"; + +"Prefecture" = "Prefectura"; + +"Province" = "Provincia"; + +"Remove" = "Eliminar"; + +"Remove bank account" = "Eliminar cuenta bancaria"; + +"Remove bank account ending in %@" = "Eliminar la cuenta bancaria que termina en %@"; + +"Search" = "Buscar"; + +"State" = "Estado"; + +"Suburb" = "Suburbio"; + +"Suburb or city" = "Suburbio o ciudad"; + +"The BSB you entered is incomplete." = "El BSB que ingresaste está incompleto."; + +"The ID number you entered is incomplete." = "El número de documento que ingresaste está incompleto."; + +"The account number you entered is incomplete." = "El número de cuenta que ingresaste está incompleto."; + +"Town or city" = "Pueblo o ciudad"; + +"UPI ID" = "ID de UPI"; + +"Unable to parse phone number" = "No se pudo analizar el número de teléfono."; + +"Use rotor to access links" = "Usa el rotor para acceder a los enlaces"; + +"Your BLIK code is incomplete." = "Tu código BLIK está incompleto."; + +"Your BLIK code is invalid." = "El código BLIK no es válido."; + +"Your ZIP is incomplete." = "El código postal está incompleto."; + +"Your email is invalid." = "El correo electrónico no es válido."; + +"Your payment information will not be saved." = "Tus datos de pago no se guardarán."; + +"Your postal code is incomplete." = "El código postal está incompleto."; + +"ZIP" = "ZIP"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/es.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/es.lproj/Localizable.strings new file mode 100644 index 00000000..f8afd1f0 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/es.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (opcional)"; + +"Account number" = "Número de cuenta"; + +"Address" = "Dirección"; + +"Address line 1" = "Primera línea de la dirección"; + +"Address line 2" = "Segunda línea de la dirección"; + +"Area" = "Área"; + +"BLIK code" = "Código BLIK"; + +"BSB number" = "Número de BSB"; + +"Billing address is same as shipping" = "La dirección de facturación es la misma que la de envío."; + +"Cancel" = "Cancelar"; + +"City" = "Ciudad"; + +"Code field" = "Campo del código"; + +"Company" = "Empresa"; + +"Continue" = "Continuar"; + +"Country" = "País"; + +"Country or region" = "País o región"; + +"County" = "Condado"; + +"Date is empty." = "La fecha está vacía."; + +"Department" = "Departamento"; + +"District" = "Distrito"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "¿Deseas cerrar este formulario?"; + +"Done" = "Listo"; + +"Double tap to edit" = "Pulsa dos veces para editar"; + +"Edit" = "Modificar"; + +"Eircode" = "Eircode"; + +"Email" = "Correo electrónico"; + +"Emirate" = "Emirato"; + +"Error" = "Error"; + +"First" = "Nombre"; + +"Full name" = "Nombre completo"; + +"Incomplete phone number" = "Número de teléfono incompleto"; + +"Invalid UPI ID" = "ID de UPI no válida"; + +"Island" = "Isla"; + +"Last" = "Apellidos"; + +"Name" = "Nombre"; + +"Name on account" = "Nombre de la cuenta"; + +"OK" = "Aceptar"; + +"Oblast" = "Óblast"; + +"Other" = "Otro"; + +"Parish" = "Parroquia"; + +"Phone" = "Teléfono"; + +"Postal code" = "Código postal"; + +"Prefecture" = "Prefectura"; + +"Province" = "Provincia"; + +"Remove" = "Eliminar"; + +"Remove bank account" = "Eliminar cuenta bancaria"; + +"Remove bank account ending in %@" = "Eliminar la cuenta bancaria que termina en %@"; + +"Search" = "Buscar"; + +"State" = "Estado"; + +"Suburb" = "Zona residencial"; + +"Suburb or city" = "Zona residencial o ciudad"; + +"The BSB you entered is incomplete." = "El BSB introducido está incompleto."; + +"The ID number you entered is incomplete." = "El número de ID que has introducido está incompleto."; + +"The account number you entered is incomplete." = "El número de cuenta que has introducido está incompleto."; + +"Town or city" = "Pueblo o ciudad"; + +"UPI ID" = "ID de UPI"; + +"Unable to parse phone number" = "No se ha podido analizar el número de teléfono"; + +"Use rotor to access links" = "Utiliza el rotor para acceder a los enlaces"; + +"Your BLIK code is incomplete." = "Tu código BLIK no está completo."; + +"Your BLIK code is invalid." = "El código BLIK no es válido."; + +"Your ZIP is incomplete." = "El código ZIP está incompleto."; + +"Your email is invalid." = "El correo electrónico no es válido."; + +"Your payment information will not be saved." = "Tus datos de pago no se guardarán."; + +"Your postal code is incomplete." = "El código postal está incompleto."; + +"ZIP" = "ZIP"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/et-EE.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/et-EE.lproj/Localizable.strings new file mode 100644 index 00000000..91e3a645 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/et-EE.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (valikuline)"; + +"Account number" = "Kontonumber"; + +"Address" = "Aadress"; + +"Address line 1" = "Aadressirida 1"; + +"Address line 2" = "Aadressirida 2"; + +"Area" = "Piirkond"; + +"BLIK code" = "BLIK-kood"; + +"BSB number" = "BSB-number"; + +"Billing address is same as shipping" = "Arveldusaadress on sama mis tarneaadress"; + +"Cancel" = "Tühista"; + +"City" = "Linn"; + +"Code field" = "Koodi väli"; + +"Company" = "Ettevõte"; + +"Continue" = "Jätka"; + +"Country" = "Riik"; + +"Country or region" = "Riik või regioon"; + +"County" = "Maakond"; + +"Date is empty." = "Kuupäev on tühi."; + +"Department" = "Departemang"; + +"District" = "Regioon"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Kas soovite selle vormi sulgeda?"; + +"Done" = "Valmis"; + +"Double tap to edit" = "Redigeerimiseks topeltklõpsake"; + +"Edit" = "Muutmine"; + +"Eircode" = "Eircode"; + +"Email" = "Meiliaadress"; + +"Emirate" = "Emiraat"; + +"Error" = "Viga"; + +"First" = "Esimene"; + +"Full name" = "Täisnimi"; + +"Incomplete phone number" = "Telefoninumber puudulik"; + +"Invalid UPI ID" = "Kehtetu UPI ID"; + +"Island" = "Saar"; + +"Last" = "Viimane"; + +"Name" = "Nimi"; + +"Name on account" = "Nimi kontol"; + +"OK" = "OK"; + +"Oblast" = "Oblast"; + +"Other" = "Muu"; + +"Parish" = "Vald"; + +"Phone" = "Telefon"; + +"Postal code" = "Sihtnumber"; + +"Prefecture" = "Prefektuur"; + +"Province" = "Maakond"; + +"Remove" = "Eemalda"; + +"Remove bank account" = "Eemaldage pangakonto"; + +"Remove bank account ending in %@" = "Eemaldage pangakonto lõpuga %@"; + +"Search" = "Otsing"; + +"State" = "Osariik"; + +"Suburb" = "Eeslinn"; + +"Suburb or city" = "Eeslinn või linn"; + +"The BSB you entered is incomplete." = "Sisestatud BSB-number on puudulik."; + +"The ID number you entered is incomplete." = "Sisestatud isikukood on puudulik."; + +"The account number you entered is incomplete." = "Sisestatud kontonumber on puudulik."; + +"Town or city" = "Linn"; + +"UPI ID" = "UPI ID"; + +"Unable to parse phone number" = "Telefoninumbri parsimine ebaõnnestus"; + +"Use rotor to access links" = "Kasutage linkidele juurdepääsuks rootorit"; + +"Your BLIK code is incomplete." = "Teie BLIK-kood on puudulik."; + +"Your BLIK code is invalid." = "Teie BLIK-kood on kehtetu."; + +"Your ZIP is incomplete." = "Teie sihtnumber on puudulik."; + +"Your email is invalid." = "Teie meiliaadress on kehtetu."; + +"Your payment information will not be saved." = "Teie makseteavet ei salvestata."; + +"Your postal code is incomplete." = "Sihtnumber on puudulik."; + +"ZIP" = "Sihtnr"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/fi.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/fi.lproj/Localizable.strings new file mode 100644 index 00000000..74693e81 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/fi.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (valinnainen)"; + +"Account number" = "Tilinumero"; + +"Address" = "Osoite"; + +"Address line 1" = "Osoiterivi 1"; + +"Address line 2" = "Osoiterivi 2"; + +"Area" = "Alue"; + +"BLIK code" = "BLIK-koodi"; + +"BSB number" = "BSB-numero"; + +"Billing address is same as shipping" = "Laskutusosoite on sama kuin toimitusosoite"; + +"Cancel" = "Peruuta"; + +"City" = "Paikkakunta"; + +"Code field" = "Koodikenttä"; + +"Company" = "Yritys"; + +"Continue" = "Jatka"; + +"Country" = "Maa"; + +"Country or region" = "Maa tai alue"; + +"County" = "Lääni"; + +"Date is empty." = "Päivämäärä on tyhjä."; + +"Department" = "Osasto"; + +"District" = "Piirikunta"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Haluatko sulkea tämän lomakkeen?"; + +"Done" = "Valmis"; + +"Double tap to edit" = "Napauta kahdesti ja muokkaa"; + +"Edit" = "Muokkaa"; + +"Eircode" = "EIR-koodi"; + +"Email" = "Sähköposti"; + +"Emirate" = "Emiraatti"; + +"Error" = "Virhe"; + +"First" = "Etunimi"; + +"Full name" = "Koko nimi"; + +"Incomplete phone number" = "Puutteellinen puhelinnumero"; + +"Invalid UPI ID" = "Virheellinen UPI-tunnus"; + +"Island" = "Saari"; + +"Last" = "Sukunimi"; + +"Name" = "Nimi"; + +"Name on account" = "Tilin nimi"; + +"OK" = "OK"; + +"Oblast" = "Oblasti"; + +"Other" = "Muu"; + +"Parish" = "Kunta"; + +"Phone" = "Puhelin"; + +"Postal code" = "Postinumero"; + +"Prefecture" = "Prefektuuri"; + +"Province" = "Provinssi"; + +"Remove" = "Poista"; + +"Remove bank account" = "Poista pankkitili"; + +"Remove bank account ending in %@" = "Poista pankkitili, joka päättyy numeroihin %@"; + +"Search" = "Etsi"; + +"State" = "Osavaltio"; + +"Suburb" = "Esikaupunki"; + +"Suburb or city" = "Esikaupunki tai kaupunki"; + +"The BSB you entered is incomplete." = "Antamasi BSB-numero on puutteellinen."; + +"The ID number you entered is incomplete." = "Antamasi tunnusnumero on puutteellinen."; + +"The account number you entered is incomplete." = "Antamasi tilinumero on puutteellinen."; + +"Town or city" = "Kaupunki tai kunta"; + +"UPI ID" = "UPI-tunnus"; + +"Unable to parse phone number" = "Puhelinnumeron jäsennys epäonnistui"; + +"Use rotor to access links" = "Käytä roottoria linkeille"; + +"Your BLIK code is incomplete." = "BLIK-koodisi on puutteellinen."; + +"Your BLIK code is invalid." = "BLIK-koodi ei kelpaa."; + +"Your ZIP is incomplete." = "Postinumero on puutteellinen."; + +"Your email is invalid." = "Sähköposti on virheellinen."; + +"Your payment information will not be saved." = "Maksutietojasi ei tallenneta."; + +"Your postal code is incomplete." = "Postinumero on puutteellinen."; + +"ZIP" = "Postinumero"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/fil.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/fil.lproj/Localizable.strings new file mode 100644 index 00000000..6229e65c --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/fil.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@(opsyonal)"; + +"Account number" = "Numero ng account"; + +"Address" = "Adres"; + +"Address line 1" = "Unang linya ng adres"; + +"Address line 2" = "Linya 2 ng address"; + +"Area" = "Lugar"; + +"BLIK code" = "BLIK code"; + +"BSB number" = "Numero ng BSB"; + +"Billing address is same as shipping" = "Ang address sa billing ay kapareho ng sa pagpapadala"; + +"Cancel" = "Kanselahin"; + +"City" = "Siyudad"; + +"Code field" = "Patlang ng code"; + +"Company" = "Kompanya"; + +"Continue" = "Magpatuloy"; + +"Country" = "Bansa"; + +"Country or region" = "Bansa o rehiyon"; + +"County" = "County"; + +"Date is empty." = "Walang laman ang petsa."; + +"Department" = "Departamento"; + +"District" = "Distrito"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Gusto mo bang isara ang form na ito?"; + +"Done" = "Tapos Na"; + +"Double tap to edit" = "I-double tap para baguhin"; + +"Edit" = "I-edit"; + +"Eircode" = "Eircode"; + +"Email" = "Email"; + +"Emirate" = "Emirate"; + +"Error" = "Error"; + +"First" = "Pangalan"; + +"Full name" = "Buong pangalan"; + +"Incomplete phone number" = "Kulang na numero ng telepono"; + +"Invalid UPI ID" = "Di valid na UPI ID"; + +"Island" = "Isla"; + +"Last" = "Apelyido"; + +"Name" = "Pangalan"; + +"Name on account" = "Pangalan sa account"; + +"OK" = "OK"; + +"Oblast" = "Oblast"; + +"Other" = "Iba Pa"; + +"Parish" = "Parokya"; + +"Phone" = "Telepono"; + +"Postal code" = "Postal code"; + +"Prefecture" = "Prefecture"; + +"Province" = "Probinsiya"; + +"Remove" = "Alisin"; + +"Remove bank account" = "Alisin ang account sa bangko"; + +"Remove bank account ending in %@" = "Alisin ang account sa bangko na nagtatapos sa %@"; + +"Search" = "Maghanap"; + +"State" = "State"; + +"Suburb" = "Suburb"; + +"Suburb or city" = "Suburb o lungsod"; + +"The BSB you entered is incomplete." = "Ang BSB na ipinasok mo ay di kumpleto."; + +"The ID number you entered is incomplete." = "Ang numero ng ID na inilagay mo ay kulang."; + +"The account number you entered is incomplete." = "Ang numero ng account na inilagay mo ay kulang."; + +"Town or city" = "Bayan o siyudad"; + +"UPI ID" = "UPI ID"; + +"Unable to parse phone number" = "Hindi ma=parse ang numero ng telepono"; + +"Use rotor to access links" = "Gumamit ng rotor para i-access ang mga link"; + +"Your BLIK code is incomplete." = "Ang iyong BLIK code ay kulang."; + +"Your BLIK code is invalid." = "Ang iyong BLIK code ay hindi valid."; + +"Your ZIP is incomplete." = "Ang iyong ZIP ay di kumpleto."; + +"Your email is invalid." = "Ang iyong email ay imbalido."; + +"Your payment information will not be saved." = "Hindi mase-save ang iyong impormasyon ng pagbabayad."; + +"Your postal code is incomplete." = "Ang iyong postal code ay di kumpleto."; + +"ZIP" = "ZIP"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/fr-CA.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/fr-CA.lproj/Localizable.strings new file mode 100644 index 00000000..9fe6330b --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/fr-CA.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (facultatif)"; + +"Account number" = "Numéro de compte"; + +"Address" = "Adresse"; + +"Address line 1" = "Ligne d'adresse 1"; + +"Address line 2" = "Ligne d'adresse 2"; + +"Area" = "Région"; + +"BLIK code" = "Code BLIK"; + +"BSB number" = "Numéro BSB"; + +"Billing address is same as shipping" = "L'adresse de facturation et de livraison sont identiques"; + +"Cancel" = "Annuler"; + +"City" = "Ville"; + +"Code field" = "Champ de code"; + +"Company" = "Entreprise"; + +"Continue" = "Continuer"; + +"Country" = "Pays"; + +"Country or region" = "Pays ou région"; + +"County" = "Comté"; + +"Date is empty." = "Le champ date est vide."; + +"Department" = "Département"; + +"District" = "Arrondissement"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Voulez-vous vraiment fermer ce formulaire?"; + +"Done" = "Terminé"; + +"Double tap to edit" = "Touchez deux fois pour saisir votre texte"; + +"Edit" = "Modifier"; + +"Eircode" = "Eircode"; + +"Email" = "Courriel"; + +"Emirate" = "Émirat"; + +"Error" = "Erreur"; + +"First" = "Prénom"; + +"Full name" = "Nom complet"; + +"Incomplete phone number" = "Numéro de téléphone incomplet"; + +"Invalid UPI ID" = "ID d'UPI non valide"; + +"Island" = "Île"; + +"Last" = "Nom"; + +"Name" = "Nom"; + +"Name on account" = "Nom du titulaire du compte"; + +"OK" = "OK"; + +"Oblast" = "Oblast"; + +"Other" = "Autre"; + +"Parish" = "Paroisse"; + +"Phone" = "Téléphone"; + +"Postal code" = "Code postal"; + +"Prefecture" = "Préfecture"; + +"Province" = "Province"; + +"Remove" = "Supprimer"; + +"Remove bank account" = "Supprimer le compte bancaire"; + +"Remove bank account ending in %@" = "Supprimer le compte bancaire se terminant par %@"; + +"Search" = "Rechercher"; + +"State" = "État"; + +"Suburb" = "Banlieue"; + +"Suburb or city" = "Banlieue ou ville"; + +"The BSB you entered is incomplete." = "Le numéro BSB que vous avez saisi est incomplet."; + +"The ID number you entered is incomplete." = "Le numéro d'identification que vous avez saisi est incomplet."; + +"The account number you entered is incomplete." = "Le numéro de compte saisi est incomplet."; + +"Town or city" = "Ville"; + +"UPI ID" = "ID d'UPI"; + +"Unable to parse phone number" = "Impossible d'analyser le numéro de téléphone"; + +"Use rotor to access links" = "Utilisez le rotor pour accéder aux liens"; + +"Your BLIK code is incomplete." = "Votre code BLIK est incomplet."; + +"Your BLIK code is invalid." = "Votre code BLIK n'est pas valide."; + +"Your ZIP is incomplete." = "Votre code postal est incomplet."; + +"Your email is invalid." = "Votre adresse de courriel n'est pas valide."; + +"Your payment information will not be saved." = "Vos informations de paiement ne seront pas enregistrées."; + +"Your postal code is incomplete." = "Votre code postal est incomplet."; + +"ZIP" = "Code postal"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/fr.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/fr.lproj/Localizable.strings new file mode 100644 index 00000000..d3cdb7d5 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/fr.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (facultatif)"; + +"Account number" = "Numéro de compte"; + +"Address" = "Adresse"; + +"Address line 1" = "Adresse - Ligne 1"; + +"Address line 2" = "Adresse - Ligne 2"; + +"Area" = "Zone"; + +"BLIK code" = "Code BLIK"; + +"BSB number" = "Numéro BSB"; + +"Billing address is same as shipping" = "L'adresse de facturation et l'adresse de livraison sont identiques"; + +"Cancel" = "Annuler"; + +"City" = "Ville"; + +"Code field" = "Champ de code"; + +"Company" = "Entreprise"; + +"Continue" = "Continuer"; + +"Country" = "Pays"; + +"Country or region" = "Pays ou région"; + +"County" = "Département"; + +"Date is empty." = "Le champ date est vide."; + +"Department" = "Département"; + +"District" = "Arrondissement"; + +"Do Si" = "Province et ville"; + +"Do you want to close this form?" = "Voulez-vous vraiment fermer ce formulaire ?"; + +"Done" = "Terminé"; + +"Double tap to edit" = "Appuyez deux fois pour saisir votre texte"; + +"Edit" = "Modifier"; + +"Eircode" = "Code postal"; + +"Email" = "E-mail"; + +"Emirate" = "Émirat"; + +"Error" = "Erreur"; + +"First" = "Prénom"; + +"Full name" = "Nom complet"; + +"Incomplete phone number" = "Numéro de téléphone incomplet"; + +"Invalid UPI ID" = "ID d'UPI non valide"; + +"Island" = "Île"; + +"Last" = "Nom"; + +"Name" = "Nom"; + +"Name on account" = "Nom du titulaire du compte"; + +"OK" = "OK"; + +"Oblast" = "Oblast"; + +"Other" = "Autre"; + +"Parish" = "Paroisse"; + +"Phone" = "Téléphone"; + +"Postal code" = "Code postal"; + +"Prefecture" = "Préfecture"; + +"Province" = "Province"; + +"Remove" = "Supprimer"; + +"Remove bank account" = "Supprimer le compte bancaire"; + +"Remove bank account ending in %@" = "Supprimer le compte bancaire se terminant par %@"; + +"Search" = "Rechercher"; + +"State" = "État"; + +"Suburb" = "Banlieue"; + +"Suburb or city" = "Banlieue ou ville"; + +"The BSB you entered is incomplete." = "Le numéro BSB que vous avez saisi est incomplet."; + +"The ID number you entered is incomplete." = "Le numéro d'identification que vous avez saisi est incomplet."; + +"The account number you entered is incomplete." = "Le numéro de compte saisi est incomplet."; + +"Town or city" = "Ville"; + +"UPI ID" = "ID d'UPI"; + +"Unable to parse phone number" = "Impossible d'analyser le numéro de téléphone"; + +"Use rotor to access links" = "Utilisez le rotor pour accéder aux liens"; + +"Your BLIK code is incomplete." = "Votre code BLIK est incomplet."; + +"Your BLIK code is invalid." = "Votre code BLIK n'est pas valide."; + +"Your ZIP is incomplete." = "Votre code postal est incomplet."; + +"Your email is invalid." = "Votre adresse e-mail n'est pas valide."; + +"Your payment information will not be saved." = "Vos informations de paiement ne seront pas enregistrées."; + +"Your postal code is incomplete." = "Votre code postal est incomplet."; + +"ZIP" = "ZIP"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/hr.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/hr.lproj/Localizable.strings new file mode 100644 index 00000000..7ee6b08d --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/hr.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (neobavezno)"; + +"Account number" = "Broj računa"; + +"Address" = "Adresa"; + +"Address line 1" = "Ulica i kućni broj 1"; + +"Address line 2" = "Broj stana, kat ili poštanski pretinac"; + +"Area" = "Područje"; + +"BLIK code" = "BLIK kod"; + +"BSB number" = "BSB broj"; + +"Billing address is same as shipping" = "Adresa računa jednaka je adresi dostave"; + +"Cancel" = "Otkaži"; + +"City" = "Grad"; + +"Code field" = "Polje za kod"; + +"Company" = "Poduzeće"; + +"Continue" = "Nastavi"; + +"Country" = "Zemlja"; + +"Country or region" = "Zemlja ili regija"; + +"County" = "Grofovija"; + +"Date is empty." = "Datum je prazan."; + +"Department" = "Administrativna jedinica"; + +"District" = "Okrug"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Želite li zatvoriti ovaj obrazac?"; + +"Done" = "Gotovo"; + +"Double tap to edit" = "Dodirnite dva puta za uređivanje"; + +"Edit" = "Uredi"; + +"Eircode" = "Eircode"; + +"Email" = "E-mail"; + +"Emirate" = "Emirat"; + +"Error" = "Greška"; + +"First" = "Ime"; + +"Full name" = "Puno ime"; + +"Incomplete phone number" = "Nepotpuni telefonski broj"; + +"Invalid UPI ID" = "Nevažeći UPI ID"; + +"Island" = "Otok"; + +"Last" = "Prezime"; + +"Name" = "Ime"; + +"Name on account" = "Ime na računu"; + +"OK" = "U redu"; + +"Oblast" = "Oblast"; + +"Other" = "Ostalo"; + +"Parish" = "Župa"; + +"Phone" = "Telefon"; + +"Postal code" = "Poštanski broj"; + +"Prefecture" = "Prefektura"; + +"Province" = "Provincija"; + +"Remove" = "Ukloni"; + +"Remove bank account" = "Ukloni bankovni račun"; + +"Remove bank account ending in %@" = "Ukloni bankovni račun koji završava na %@"; + +"Search" = "Pretraži"; + +"State" = "Država"; + +"Suburb" = "Predgrađe"; + +"Suburb or city" = "Predgrađe ili grad"; + +"The BSB you entered is incomplete." = "Uneseni BSB nije kompletan."; + +"The ID number you entered is incomplete." = "Uneseni identifikacijski broj nije potpun."; + +"The account number you entered is incomplete." = "Uneseni broj računa je nepotpun."; + +"Town or city" = "Gradić ili grad"; + +"UPI ID" = "UPI ID"; + +"Unable to parse phone number" = "Broj telefona nije moguće pročitati"; + +"Use rotor to access links" = "Upotrijebite rotor za pristup poveznicama"; + +"Your BLIK code is incomplete." = "Vaš BLIK kod je nepotpun."; + +"Your BLIK code is invalid." = "Vaš BLIK kod je nevažeći."; + +"Your ZIP is incomplete." = "Vaš ZIP broj nije kompletan."; + +"Your email is invalid." = "Vaš e-mail je neispravan."; + +"Your payment information will not be saved." = "Vaši podaci o plaćanju neće biti spremljeni."; + +"Your postal code is incomplete." = "Vaš poštanski broj nije kompletan."; + +"ZIP" = "ZIP"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/hu.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/hu.lproj/Localizable.strings new file mode 100644 index 00000000..31420fc6 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/hu.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (nem kötelező)"; + +"Account number" = "Számlaszám"; + +"Address" = "Cím"; + +"Address line 1" = "Cím 1. sora"; + +"Address line 2" = "2. címsor"; + +"Area" = "Terület"; + +"BLIK code" = "BLIK kód"; + +"BSB number" = "BSB-szám"; + +"Billing address is same as shipping" = "A számlázási cím megegyezik a szállítási címmel"; + +"Cancel" = "Mégse"; + +"City" = "Város"; + +"Code field" = "Kódmező"; + +"Company" = "Vállalat"; + +"Continue" = "Folytatás"; + +"Country" = "Ország"; + +"Country or region" = "Ország vagy régió"; + +"County" = "Ország"; + +"Date is empty." = "A dátum nincs kitöltve."; + +"Department" = "Megye"; + +"District" = "Kerület"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Bezárja ezt az űrlapot?"; + +"Done" = "Kész"; + +"Double tap to edit" = "A szerkesztéshez koppintson kétszer"; + +"Edit" = "Szerkesztés"; + +"Eircode" = "Irányítószám"; + +"Email" = "E-mail"; + +"Emirate" = "Emirátus"; + +"Error" = "Hiba"; + +"First" = "Utónév"; + +"Full name" = "Teljes név"; + +"Incomplete phone number" = "Hiányos telefonszám"; + +"Invalid UPI ID" = "Érvénytelen UPI-azonosító"; + +"Island" = "Sziget"; + +"Last" = "Vezetéknév"; + +"Name" = "Név"; + +"Name on account" = "A számlán szereplő név"; + +"OK" = "OK"; + +"Oblast" = "Terület"; + +"Other" = "Egyéb"; + +"Parish" = "Egyházközség"; + +"Phone" = "Telefon"; + +"Postal code" = "Irányítószám"; + +"Prefecture" = "Prefektúra"; + +"Province" = "Tartomány"; + +"Remove" = "Eltávolítás"; + +"Remove bank account" = "Bankszámla eltávolítása"; + +"Remove bank account ending in %@" = "%@ számra végződő bankszámla eltávolítása"; + +"Search" = "Keresés"; + +"State" = "Állam"; + +"Suburb" = "Külváros"; + +"Suburb or city" = "Külváros vagy város"; + +"The BSB you entered is incomplete." = "A beírt BSB-szám hiányos."; + +"The ID number you entered is incomplete." = "A beírt azonosítószám hiányos."; + +"The account number you entered is incomplete." = "A számlaszám hiányos."; + +"Town or city" = "Kisváros vagy város"; + +"UPI ID" = "UPI-azonosító"; + +"Unable to parse phone number" = "A telefonszám nem értelmezhető"; + +"Use rotor to access links" = "Rotor használata a hivatkozások kezeléséhez"; + +"Your BLIK code is incomplete." = "BLIK kódja hiányos."; + +"Your BLIK code is invalid." = "BLIK kódja érvénytelen."; + +"Your ZIP is incomplete." = "A ZIP hiányos."; + +"Your email is invalid." = "Érvénytelen e-mail-cím."; + +"Your payment information will not be saved." = "Fizetési adatai nem lesznek mentve."; + +"Your postal code is incomplete." = "Az irányítószám hiányos."; + +"ZIP" = "Irányítószám"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/id.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/id.lproj/Localizable.strings new file mode 100644 index 00000000..91c86e9d --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/id.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (opsional)"; + +"Account number" = "Nomor rekening"; + +"Address" = "Alamat"; + +"Address line 1" = "Baris alamat ke-1"; + +"Address line 2" = "Baris alamat ke-2"; + +"Area" = "Area"; + +"BLIK code" = "Kode BLIK"; + +"BSB number" = "Nomor BSB"; + +"Billing address is same as shipping" = "Alamat tagihan sama dengan pengiriman"; + +"Cancel" = "Batalkan"; + +"City" = "Kota"; + +"Code field" = "Bidang kode"; + +"Company" = "Perusahaan"; + +"Continue" = "Lanjutkan"; + +"Country" = "Negara"; + +"Country or region" = "Negara atau wilayah"; + +"County" = "County"; + +"Date is empty." = "Data kosong."; + +"Department" = "Departemen"; + +"District" = "Distrik"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Anda ingin menutup formulir ini?"; + +"Done" = "Selesai"; + +"Double tap to edit" = "Sentuh dua kali untuk mengedit"; + +"Edit" = "Edit"; + +"Eircode" = "Eircode"; + +"Email" = "Email"; + +"Emirate" = "Emirat"; + +"Error" = "Kesalahan"; + +"First" = "Depan"; + +"Full name" = "Nama lengkap"; + +"Incomplete phone number" = "Nomor telepon tidak lengkap"; + +"Invalid UPI ID" = "Identifikasi UPI tidak valid"; + +"Island" = "Pulau"; + +"Last" = "Belakang"; + +"Name" = "Nama"; + +"Name on account" = "Nama pada rekening"; + +"OK" = "OK"; + +"Oblast" = "Oblast"; + +"Other" = "Lainnya"; + +"Parish" = "Parish"; + +"Phone" = "Telepon"; + +"Postal code" = "Kode pos"; + +"Prefecture" = "Prefektur"; + +"Province" = "Provinsi"; + +"Remove" = "Hapus"; + +"Remove bank account" = "Hapus rekening bank"; + +"Remove bank account ending in %@" = "Hapus rekening bank yang berakhiran dengan %@"; + +"Search" = "Cari"; + +"State" = "Negara Bagian"; + +"Suburb" = "Suburb"; + +"Suburb or city" = "Suburb atau kota"; + +"The BSB you entered is incomplete." = "BSB yang Anda masukkan tidak lengkap."; + +"The ID number you entered is incomplete." = "Nomor identifikasi yang Anda masukkan tidak lengkap."; + +"The account number you entered is incomplete." = "Nomor rekening yang Anda masukkan tidak lengkap."; + +"Town or city" = "Kabupaten atau kota"; + +"UPI ID" = "Identifikasi UPI"; + +"Unable to parse phone number" = "Tidak dapat menguraikan nomor telepon"; + +"Use rotor to access links" = "Gunakan rotor untuk mengakses tautan"; + +"Your BLIK code is incomplete." = "Kode BLIK Anda tidak lengkap."; + +"Your BLIK code is invalid." = "Kode BLIK Anda tidak valid."; + +"Your ZIP is incomplete." = "ZIP Anda tidak lengkap."; + +"Your email is invalid." = "Email Anda tidak valid."; + +"Your payment information will not be saved." = "Informasi pembayaran Anda tidak akan disimpan."; + +"Your postal code is incomplete." = "Kode pos Anda tidak lengkap."; + +"ZIP" = "ZIP"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/it.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/it.lproj/Localizable.strings new file mode 100644 index 00000000..e5b261c8 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/it.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (campo facoltativo)"; + +"Account number" = "Numero conto"; + +"Address" = "Indirizzo"; + +"Address line 1" = "Indirizzo riga 1"; + +"Address line 2" = "Indirizzo riga 2"; + +"Area" = "Area"; + +"BLIK code" = "Codice BLIK"; + +"BSB number" = "Numero BSB"; + +"Billing address is same as shipping" = "L'indirizzo di fatturazione è uguale all'indirizzo di spedizione"; + +"Cancel" = "Annulla"; + +"City" = "Città"; + +"Code field" = "Campo codice"; + +"Company" = "Azienda"; + +"Continue" = "Continua"; + +"Country" = "Paese"; + +"Country or region" = "Paese o area geografica"; + +"County" = "Contea"; + +"Date is empty." = "Il campo della data è vuoto."; + +"Department" = "Dipartimento"; + +"District" = "Distretto"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Chiudere il modulo?"; + +"Done" = "Fine"; + +"Double tap to edit" = "Tocca due volte per modificare"; + +"Edit" = "Modifica"; + +"Eircode" = "Eircode"; + +"Email" = "Email"; + +"Emirate" = "Emirato"; + +"Error" = "Errore"; + +"First" = "Nome"; + +"Full name" = "Nome completo"; + +"Incomplete phone number" = "Numero di telefono incompleto"; + +"Invalid UPI ID" = "ID UPI non valido"; + +"Island" = "Isola"; + +"Last" = "Cognome"; + +"Name" = "Nome"; + +"Name on account" = "Nome sul conto"; + +"OK" = "OK"; + +"Oblast" = "Oblast"; + +"Other" = "Altro"; + +"Parish" = "Contea"; + +"Phone" = "Telefono"; + +"Postal code" = "Codice postale"; + +"Prefecture" = "Prefettura"; + +"Province" = "Provincia"; + +"Remove" = "Rimuovi"; + +"Remove bank account" = "Rimuovi conto bancario"; + +"Remove bank account ending in %@" = "Rimuovi conto bancario che termina con %@"; + +"Search" = "Cerca"; + +"State" = "Stato"; + +"Suburb" = "Località"; + +"Suburb or city" = "Località o città"; + +"The BSB you entered is incomplete." = "Il numero BSB inserito è incompleto"; + +"The ID number you entered is incomplete." = "Il numero del documento di identità inserito non è completo."; + +"The account number you entered is incomplete." = "Il numero del conto inserito è incompleto."; + +"Town or city" = "Città"; + +"UPI ID" = "ID UPI"; + +"Unable to parse phone number" = "Impossibile analizzare il numero di telefono"; + +"Use rotor to access links" = "Utilizza l'ingranaggio per accedere ai link"; + +"Your BLIK code is incomplete." = "Il codice BLIK non è completo."; + +"Your BLIK code is invalid." = "Il codice BLIK non è valido."; + +"Your ZIP is incomplete." = "Il CAP è incompleto."; + +"Your email is invalid." = "Indirizzo email non valido."; + +"Your payment information will not be saved." = "I dati di pagamento non verranno salvati."; + +"Your postal code is incomplete." = "Il codice postale è incompleto."; + +"ZIP" = "ZIP"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/ja.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/ja.lproj/Localizable.strings new file mode 100644 index 00000000..80dca41b --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/ja.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (オプション)"; + +"Account number" = "アカウント番号"; + +"Address" = "住所"; + +"Address line 1" = "住所 (1 行目)"; + +"Address line 2" = "住所 (2 行目)"; + +"Area" = "地域"; + +"BLIK code" = "BLIK コード"; + +"BSB number" = "BSB 番号"; + +"Billing address is same as shipping" = "請求先住所は配送先住所と同じ"; + +"Cancel" = "キャンセル"; + +"City" = "市"; + +"Code field" = "コードフィールド"; + +"Company" = "会社"; + +"Continue" = "続ける"; + +"Country" = "国"; + +"Country or region" = "国または地域"; + +"County" = "群"; + +"Date is empty." = "カートは空です。"; + +"Department" = "県"; + +"District" = "地区"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "このフォームを閉じてもよいですか?"; + +"Done" = "完了"; + +"Double tap to edit" = "ダブルタップして編集します"; + +"Edit" = "編集"; + +"Eircode" = "郵便番号 (アイルランド)"; + +"Email" = "メールアドレス"; + +"Emirate" = "首長国"; + +"Error" = "エラー"; + +"First" = "名"; + +"Full name" = "名前"; + +"Incomplete phone number" = "電話番号の不備"; + +"Invalid UPI ID" = "UPI ID が無効です"; + +"Island" = "島しょ"; + +"Last" = "姓"; + +"Name" = "名前"; + +"Name on account" = "口座名義人"; + +"OK" = "OK"; + +"Oblast" = "州 (ロシア)"; + +"Other" = "その他"; + +"Parish" = "郡"; + +"Phone" = "電話番号"; + +"Postal code" = "郵便番号"; + +"Prefecture" = "都道府県"; + +"Province" = "都道府県"; + +"Remove" = "削除"; + +"Remove bank account" = "銀行口座を削除"; + +"Remove bank account ending in %@" = "末尾が %@ の銀行口座を削除する"; + +"Search" = "検索"; + +"State" = "都道府県"; + +"Suburb" = "近郊"; + +"Suburb or city" = "市外または市内"; + +"The BSB you entered is incomplete." = "入力した BSB コードに不備があります。"; + +"The ID number you entered is incomplete." = "入力した ID 番号に不備があります。"; + +"The account number you entered is incomplete." = "入力した口座番号に不備があります。"; + +"Town or city" = "市区町村"; + +"UPI ID" = "UPI ID"; + +"Unable to parse phone number" = "電話番号を解析できません"; + +"Use rotor to access links" = "ローターを使用してリンクにアクセスします"; + +"Your BLIK code is incomplete." = "BLIK コードの入力に不備があります。"; + +"Your BLIK code is invalid." = "BLIK コードが無効です。"; + +"Your ZIP is incomplete." = "郵便番号の入力に不備があります。"; + +"Your email is invalid." = "メールアドレスが無効です。"; + +"Your payment information will not be saved." = "支払い情報は保存されません。"; + +"Your postal code is incomplete." = "郵便番号の入力に不備があります。"; + +"ZIP" = "郵便番号"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/ko.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/ko.lproj/Localizable.strings new file mode 100644 index 00000000..89ff89e0 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/ko.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@(선택 사항)"; + +"Account number" = "계좌 번호"; + +"Address" = "주소"; + +"Address line 1" = "주소란 1"; + +"Address line 2" = "주소란 2"; + +"Area" = "지역"; + +"BLIK code" = "BLIK 코드"; + +"BSB number" = "BSB 번호"; + +"Billing address is same as shipping" = "청구 주소가 배송 주소와 동일함"; + +"Cancel" = "취소"; + +"City" = "시"; + +"Code field" = "코드 필드"; + +"Company" = "회사"; + +"Continue" = "계속"; + +"Country" = "국가"; + +"Country or region" = "국가 또는 지역"; + +"County" = "구/군"; + +"Date is empty." = "날짜가 비어 있습니다."; + +"Department" = "행정구/현"; + +"District" = "지구"; + +"Do Si" = "도시"; + +"Do you want to close this form?" = "이 양식을 닫으시겠습니까?"; + +"Done" = "완료"; + +"Double tap to edit" = "두 번 탭하여 편집"; + +"Edit" = "편집"; + +"Eircode" = "우편번호(Eircode)"; + +"Email" = "이메일"; + +"Emirate" = "에미리트"; + +"Error" = "오류"; + +"First" = "이름"; + +"Full name" = "성명"; + +"Incomplete phone number" = "불완전한 전화번호"; + +"Invalid UPI ID" = "잘못된 UPI ID"; + +"Island" = "섬"; + +"Last" = "성"; + +"Name" = "이름"; + +"Name on account" = "계좌 명의"; + +"OK" = "확인"; + +"Oblast" = "주"; + +"Other" = "기타"; + +"Parish" = "교구"; + +"Phone" = "전화번호"; + +"Postal code" = "우편번호"; + +"Prefecture" = "도/현"; + +"Province" = "도"; + +"Remove" = "제거"; + +"Remove bank account" = "은행 계좌 삭제"; + +"Remove bank account ending in %@" = "%@(으)로 끝나는 은행 계좌 삭제"; + +"Search" = "검색"; + +"State" = "주"; + +"Suburb" = "교외"; + +"Suburb or city" = "교외 또는 도시"; + +"The BSB you entered is incomplete." = "입력하신 BSB가 완료되지 않았습니다."; + +"The ID number you entered is incomplete." = "입력한 ID 번호가 불완전합니다."; + +"The account number you entered is incomplete." = "입력하신 계좌 번호가 불완전합니다."; + +"Town or city" = "마을 또는 도시"; + +"UPI ID" = "UPI ID"; + +"Unable to parse phone number" = "전화번호 구문 분석 불가능"; + +"Use rotor to access links" = "링크 액세스에 로터 사용"; + +"Your BLIK code is incomplete." = "BLIK 코드가 불완전합니다."; + +"Your BLIK code is invalid." = "BLIK 코드가 유효하지 않습니다."; + +"Your ZIP is incomplete." = "우편번호가 불완전합니다."; + +"Your email is invalid." = "이메일이 잘못되었습니다."; + +"Your payment information will not be saved." = "귀하의 결제 정보가 저장되지 않습니다."; + +"Your postal code is incomplete." = "우편번호가 불완전합니다."; + +"ZIP" = "우편번호"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/lt-LT.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/lt-LT.lproj/Localizable.strings new file mode 100644 index 00000000..93737ffe --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/lt-LT.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (pasirenkamas)"; + +"Account number" = "Sąskaitos numeris"; + +"Address" = "Adresas"; + +"Address line 1" = "1 adreso eilutė"; + +"Address line 2" = "2 adreso eilutė"; + +"Area" = "Sritis"; + +"BLIK code" = "BLIK kodas"; + +"BSB number" = "BSB numeris"; + +"Billing address is same as shipping" = "Atsiskaitymo ir siuntimo adresai vienodi"; + +"Cancel" = "Atšaukti"; + +"City" = "Miestas"; + +"Code field" = "Kodo laukas"; + +"Company" = "Bendrovė"; + +"Continue" = "Tęsti"; + +"Country" = "Šalis"; + +"Country or region" = "Šalis arba regionas"; + +"County" = "Apygarda"; + +"Date is empty." = "Data neužpildyta."; + +"Department" = "Skyrius"; + +"District" = "Rajonas"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Ar norite uždaryti šią formą?"; + +"Done" = "Atlikta"; + +"Double tap to edit" = "Norėdami redaguoti, dukart bakstelėkite"; + +"Edit" = "Redaguoti"; + +"Eircode" = "Pašto kodas (EIR)"; + +"Email" = "El. paštas"; + +"Emirate" = "Emyratas"; + +"Error" = "Klaida"; + +"First" = "Vardas"; + +"Full name" = "Vardas, pavardė"; + +"Incomplete phone number" = "Ne visas telefono numeris"; + +"Invalid UPI ID" = "Netinkamas UPI ID"; + +"Island" = "Sala"; + +"Last" = "Pavardė"; + +"Name" = "Vardas, pavardė"; + +"Name on account" = "Sąskaitos turėtojo vardas ir pavardė"; + +"OK" = "GERAI"; + +"Oblast" = "Sritis"; + +"Other" = "Kita"; + +"Parish" = "Parapija"; + +"Phone" = "Telefonas"; + +"Postal code" = "Pašto kodas"; + +"Prefecture" = "Prefektūra"; + +"Province" = "Provincija"; + +"Remove" = "Pašalinti"; + +"Remove bank account" = "Pašalinti banko sąskaitą"; + +"Remove bank account ending in %@" = "Pašalinti banko sąskaitą, kurios paskutiniai skaičiai %@"; + +"Search" = "Paieška"; + +"State" = "Valstija"; + +"Suburb" = "Priemiestis"; + +"Suburb or city" = "Priemiestis arba miestas"; + +"The BSB you entered is incomplete." = "Įvestas neišsamus BSB numeris."; + +"The ID number you entered is incomplete." = "Įvedėte ne visą identifikacinį numerį."; + +"The account number you entered is incomplete." = "Įvedėte ne visą sąskaitos numerį."; + +"Town or city" = "Miestelis arba miestas"; + +"UPI ID" = "UPI ID"; + +"Unable to parse phone number" = "Nepavyko išanalizuoti telefono numerio"; + +"Use rotor to access links" = "Naudoti pasukimo ratuką prieigai prie saitų"; + +"Your BLIK code is incomplete." = "Jūsų BLIK kodas neišsamus."; + +"Your BLIK code is invalid." = "Jūsų BLIK kodas netinkamas."; + +"Your ZIP is incomplete." = "Neišsamus jūsų pašto kodas."; + +"Your email is invalid." = "Jūsų el. paštas yra neteisingas."; + +"Your payment information will not be saved." = "Jūsų mokėjimo informacija nebus įrašyta."; + +"Your postal code is incomplete." = "Pašto kodas neišsamus."; + +"ZIP" = "Pašto kodas"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/lv-LV.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/lv-LV.lproj/Localizable.strings new file mode 100644 index 00000000..f509fb85 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/lv-LV.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (nav obligāti)"; + +"Account number" = "Konta numurs"; + +"Address" = "Adrese"; + +"Address line 1" = "1. adreses rindiņa"; + +"Address line 2" = "2. adreses rindiņa"; + +"Area" = "Apgabals"; + +"BLIK code" = "BLIK kods"; + +"BSB number" = "BSB numurs"; + +"Billing address is same as shipping" = "Norēķinu adrese un piegādes adrese sakrīt"; + +"Cancel" = "Atcelt"; + +"City" = "Pilsēta"; + +"Code field" = "Koda lauks"; + +"Company" = "Uzņēmums"; + +"Continue" = "Turpināt"; + +"Country" = "Valsts"; + +"Country or region" = "Valsts vai reģions"; + +"County" = "Apriņķis"; + +"Date is empty." = "Datums ir tukšs."; + +"Department" = "Departaments"; + +"District" = "Rajons"; + +"Do Si" = "Province un pilsēta (Do Si)"; + +"Do you want to close this form?" = "Vai vēlaties aizvērt šo formu?"; + +"Done" = "Gatavs"; + +"Double tap to edit" = "Pieskarieties divreiz, lai rediģētu"; + +"Edit" = "Rediģēt"; + +"Eircode" = "Pasta indekss (Eirkods)"; + +"Email" = "E-pasts"; + +"Emirate" = "Emirāts"; + +"Error" = "Kļūda"; + +"First" = "Vārds"; + +"Full name" = "Vārds, uzvārds"; + +"Incomplete phone number" = "Nepilnīgs tālruņa numurs"; + +"Invalid UPI ID" = "Nederīgs UPI ID"; + +"Island" = "Sala"; + +"Last" = "Uzvārds"; + +"Name" = "Vārds, uzvārds"; + +"Name on account" = "Konta īpašnieks"; + +"OK" = "Labi"; + +"Oblast" = "Apgabals"; + +"Other" = "Cits"; + +"Parish" = "Pagasts"; + +"Phone" = "Tālrunis"; + +"Postal code" = "Pasta indekss"; + +"Prefecture" = "Prefektūra"; + +"Province" = "Province"; + +"Remove" = "Noņemt"; + +"Remove bank account" = "Noņemt bankas kontu"; + +"Remove bank account ending in %@" = "Noņemt bankas kontu, kas beidzas ar %@"; + +"Search" = "Meklēšana"; + +"State" = "Novads/štats"; + +"Suburb" = "Priekšpilsēta"; + +"Suburb or city" = "Priekšpilsēta vai pilsēta"; + +"The BSB you entered is incomplete." = "Ievadītais BSB ir nepilnīgs."; + +"The ID number you entered is incomplete." = "Ievadītais ID numurs nav pilnīgs."; + +"The account number you entered is incomplete." = "Jūsu ievadītais konta numurs nav pilnīgs."; + +"Town or city" = "Ciems vai pilsēta"; + +"UPI ID" = "UPI ID"; + +"Unable to parse phone number" = "Neizdevās parsēt tālruņa numuru"; + +"Use rotor to access links" = "Izmantot rotoru, lai piekļūtu saitēm"; + +"Your BLIK code is incomplete." = "Jūsu BLIK kods nav pilnīgs."; + +"Your BLIK code is invalid." = "Jūsu BLIK kods nav derīgs."; + +"Your ZIP is incomplete." = "Pasta indekss nav pilnīgs."; + +"Your email is invalid." = "E-pasta adrese nav derīga."; + +"Your payment information will not be saved." = "Jūsu maksājuma informācija nav saglabāta."; + +"Your postal code is incomplete." = "Pasta indekss nav pilnīgs."; + +"ZIP" = "Pasta ind."; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/ms-MY.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/ms-MY.lproj/Localizable.strings new file mode 100644 index 00000000..d46958e5 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/ms-MY.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (opsyenal)"; + +"Account number" = "Nombor akaun"; + +"Address" = "Alamat"; + +"Address line 1" = "Alamat baris 1"; + +"Address line 2" = "Alamat baris 2"; + +"Area" = "Kawasan"; + +"BLIK code" = "Kod BLIK"; + +"BSB number" = "Nombor BSB"; + +"Billing address is same as shipping" = "Alamat pengebilan sama dengan alamat penghantaran"; + +"Cancel" = "Batalkan"; + +"City" = "Bandar"; + +"Code field" = "Medan kod"; + +"Company" = "Syarikat"; + +"Continue" = "Teruskan"; + +"Country" = "Negara"; + +"Country or region" = "Negara atau rantau"; + +"County" = "Kaunti"; + +"Date is empty." = "Tarikh kosong."; + +"Department" = "Jabatan"; + +"District" = "Daerah"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Adakah anda ingin menutup borang ini?"; + +"Done" = "Selesai"; + +"Double tap to edit" = "Ketik dua kali untuk edit"; + +"Edit" = "Edit"; + +"Eircode" = "Eircode"; + +"Email" = "E-mel"; + +"Emirate" = "Amiriah"; + +"Error" = "Ralat"; + +"First" = "Pertama"; + +"Full name" = "Nama penuh"; + +"Incomplete phone number" = "Nombor telefon tidak lengkap"; + +"Invalid UPI ID" = "ID UPI tidak sah"; + +"Island" = "Pulau"; + +"Last" = "Akhir"; + +"Name" = "Nama"; + +"Name on account" = "Nama pada akaun"; + +"OK" = "OK"; + +"Oblast" = "Oblast"; + +"Other" = "Lain-lain"; + +"Parish" = "Kariah"; + +"Phone" = "Telefon"; + +"Postal code" = "Poskod"; + +"Prefecture" = "Wilayah"; + +"Province" = "Wilayah"; + +"Remove" = "Alih Keluar"; + +"Remove bank account" = "Alih keluar akaun bank"; + +"Remove bank account ending in %@" = "Alih keluar akaun bank yang berakhir dengan %@"; + +"Search" = "Cari"; + +"State" = "Negeri"; + +"Suburb" = "Pinggir Bandar"; + +"Suburb or city" = "Pinggir bandar atau bandar"; + +"The BSB you entered is incomplete." = "BSB yang anda masukkan tidak lengkap."; + +"The ID number you entered is incomplete." = "Nombor ID yang anda masukkan tidak lengkap."; + +"The account number you entered is incomplete." = "Nombor akaun yang anda masukkan tidak lengkap."; + +"Town or city" = "Pekan atau bandar"; + +"UPI ID" = "ID UPI"; + +"Unable to parse phone number" = "Nombor telefon tidak dapat dihuraikan"; + +"Use rotor to access links" = "Gunakan rotor untuk mengakses pautan"; + +"Your BLIK code is incomplete." = "Kod BLIK anda tidak lengkap."; + +"Your BLIK code is invalid." = "Kod BLIK anda tidak sah."; + +"Your ZIP is incomplete." = "Kod ZIP anda tidak lengkap."; + +"Your email is invalid." = "E-mel anda tidak sah."; + +"Your payment information will not be saved." = "Maklumat pembayaran anda tidak akan disimpan."; + +"Your postal code is incomplete." = "Poskod anda tidak lengkap."; + +"ZIP" = "ZIP"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/mt.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/mt.lproj/Localizable.strings new file mode 100644 index 00000000..a0d72780 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/mt.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (mhux obbligatorja)"; + +"Account number" = "In-numru tal-kont"; + +"Address" = "Indirizz"; + +"Address line 1" = "Indirizz linja 1"; + +"Address line 2" = "It-tieni linja tal-indirizz"; + +"Area" = "Żona"; + +"BLIK code" = "Kodiċi BLIK"; + +"BSB number" = "Numru BSB"; + +"Billing address is same as shipping" = "L-indirizz tal-fatturazzjoni huwa l-istess bħall-indirizz tal-posta"; + +"Cancel" = "Ikkanċella"; + +"City" = "Belt"; + +"Code field" = "Taqsima tal-kodiċi"; + +"Company" = "Kumpanija"; + +"Continue" = "Kompli"; + +"Country" = "Pajjiż"; + +"Country or region" = "Pajjiż jew reġjun"; + +"County" = "Kontea"; + +"Date is empty." = "Id-data hija battala."; + +"Department" = "Dipartiment"; + +"District" = "Distrett"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Trid tagħlaq din il-formola?"; + +"Done" = "Bil-lest"; + +"Double tap to edit" = "Taptap biex tibdel"; + +"Edit" = "Editja"; + +"Eircode" = "Eircode"; + +"Email" = "Email"; + +"Emirate" = "Emirat"; + +"Error" = "Żball"; + +"First" = "Ismek"; + +"Full name" = "Isem sħiħ"; + +"Incomplete phone number" = "In-numru tat-telefon mhuwiex komplut"; + +"Invalid UPI ID" = "L-ID UPI mhix tajba"; + +"Island" = "Gżira"; + +"Last" = "L-aħħar"; + +"Name" = "Isem"; + +"Name on account" = "Isem sid il-kont"; + +"OK" = "Tajjeb"; + +"Oblast" = "Oblast"; + +"Other" = "Oħrajn"; + +"Parish" = "Parroċċa"; + +"Phone" = "Telefon"; + +"Postal code" = "Kodiċi postali"; + +"Prefecture" = "Prefettura"; + +"Province" = "Provinċja"; + +"Remove" = "Neħħi"; + +"Remove bank account" = "Neħħi l-kont tal-bank"; + +"Remove bank account ending in %@" = "Neħħi l-kont tal-bank li jispiċċa b'%@"; + +"Search" = "Fittex"; + +"State" = "Stat"; + +"Suburb" = "Subborg"; + +"Suburb or city" = "Subborg jew belt"; + +"The BSB you entered is incomplete." = "Il-BSB li daħħalt mhux komplut."; + +"The ID number you entered is incomplete." = "In-numru tal-ID li daħħalt mhuwiex komplut."; + +"The account number you entered is incomplete." = "In-numru tal-kont li daħħalt mhux komplut."; + +"Town or city" = "Belt"; + +"UPI ID" = "ID UPI"; + +"Unable to parse phone number" = "Ma jistax jaqra n-numru tat-telefown"; + +"Use rotor to access links" = "Uża r-rotor biex tiftaħ il-ħoloq"; + +"Your BLIK code is incomplete." = "Il-kodiċi BLIK li ktibt mhux komplut."; + +"Your BLIK code is invalid." = "Il-kodiċi BLIK li ktibt mhux tajjeb."; + +"Your ZIP is incomplete." = "Il-kodiċi postali tiegħek mhux komplut."; + +"Your email is invalid." = "L-indirizz tal-email li daħħalt mhux tajjeb."; + +"Your payment information will not be saved." = "L-informazzjoni tal-pagament tiegħek mhux se tiġi merfugħa."; + +"Your postal code is incomplete." = "Il-kodiċi postali tiegħek mhux komplut."; + +"ZIP" = "Kodiċi Postali"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/nb.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/nb.lproj/Localizable.strings new file mode 100644 index 00000000..4d177ec1 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/nb.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (valgfritt)"; + +"Account number" = "Kontonummer"; + +"Address" = "Adresse"; + +"Address line 1" = "Adresselinje 1"; + +"Address line 2" = "Adresselinje 2"; + +"Area" = "Område"; + +"BLIK code" = "BLIK-kode"; + +"BSB number" = "BSB-nummer"; + +"Billing address is same as shipping" = "Fakturaaddresse er samme som levering"; + +"Cancel" = "Avbryt"; + +"City" = "By"; + +"Code field" = "Kodefelt"; + +"Company" = "Selskap"; + +"Continue" = "Fortsett"; + +"Country" = "Land"; + +"Country or region" = "Land eller region"; + +"County" = "Fylke"; + +"Date is empty." = "Dato er tom."; + +"Department" = "Avdeling"; + +"District" = "Distrikt"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Vil du lukke dette skjemaet?"; + +"Done" = "Ferdig"; + +"Double tap to edit" = "Dobbelttrykk for å redigere"; + +"Edit" = "Rediger"; + +"Eircode" = "Eircode"; + +"Email" = "E-post"; + +"Emirate" = "Emirat"; + +"Error" = "Feil"; + +"First" = "Fornavn"; + +"Full name" = "Fullt navn"; + +"Incomplete phone number" = "Ufullstendig telefonnummer"; + +"Invalid UPI ID" = "Ugyldig UPI-ID"; + +"Island" = "Øy"; + +"Last" = "Etternavn"; + +"Name" = "Navn"; + +"Name on account" = "Navn på kontoen"; + +"OK" = "OK"; + +"Oblast" = "Oblast"; + +"Other" = "Andre"; + +"Parish" = "Sogn"; + +"Phone" = "Telefon"; + +"Postal code" = "Postnummer"; + +"Prefecture" = "Prefektur"; + +"Province" = "Provins"; + +"Remove" = "Fjern"; + +"Remove bank account" = "Fjern bankkonto"; + +"Remove bank account ending in %@" = "Fjern bankkontoen som slutter på %@"; + +"Search" = "Søk"; + +"State" = "Stat"; + +"Suburb" = "Forstad"; + +"Suburb or city" = "Forstad eller by"; + +"The BSB you entered is incomplete." = "Du skrev inn ufullstendig BSB."; + +"The ID number you entered is incomplete." = "ID-nummeret du la inn, er ufullstendig."; + +"The account number you entered is incomplete." = "Kontonummeret du la inn, er ufullstendig."; + +"Town or city" = "Stad eller by"; + +"UPI ID" = "UPI-ID"; + +"Unable to parse phone number" = "Kan ikke analyse mobilnummeret"; + +"Use rotor to access links" = "Bruk rotor for tilgang til lenker"; + +"Your BLIK code is incomplete." = "BLIK-koden er ufullstendig."; + +"Your BLIK code is invalid." = "BLIK-koden er ugyldig."; + +"Your ZIP is incomplete." = "ZIP er ufullstendig."; + +"Your email is invalid." = "E-posten din er ugyldig."; + +"Your payment information will not be saved." = "Betalingsinformasjonen din lagres ikke."; + +"Your postal code is incomplete." = "Postnummeret er ufullstendig."; + +"ZIP" = "ZIP"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/nl.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/nl.lproj/Localizable.strings new file mode 100644 index 00000000..87be7b03 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/nl.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (optioneel)"; + +"Account number" = "Rekeningnummer"; + +"Address" = "Adres"; + +"Address line 1" = "Adresregel 1"; + +"Address line 2" = "Adresregel 2"; + +"Area" = "Regio"; + +"BLIK code" = "BLIK-code"; + +"BSB number" = "BSB-nummer"; + +"Billing address is same as shipping" = "Factuuradres is hetzelfde als het verzendadres"; + +"Cancel" = "Annuleren"; + +"City" = "Plaats"; + +"Code field" = "Codeveld"; + +"Company" = "Bedrijf"; + +"Continue" = "Doorgaan"; + +"Country" = "Land"; + +"Country or region" = "Land of regio"; + +"County" = "Graafschap"; + +"Date is empty." = "Datum is leeg."; + +"Department" = "Departement"; + +"District" = "District"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Wil je dit formulier sluiten?"; + +"Done" = "Gereed"; + +"Double tap to edit" = "Dubbeltikken om te bewerken"; + +"Edit" = "Bewerken"; + +"Eircode" = "Eircode"; + +"Email" = "E-mailadres"; + +"Emirate" = "Emiraat"; + +"Error" = "Fout"; + +"First" = "Voornaam"; + +"Full name" = "Volledige naam"; + +"Incomplete phone number" = "Onvolledig telefoonnummer"; + +"Invalid UPI ID" = "Ongeldige UPI-ID"; + +"Island" = "Eiland"; + +"Last" = "Achternaam"; + +"Name" = "Naam"; + +"Name on account" = "Naam op account"; + +"OK" = "OK"; + +"Oblast" = "Oblast"; + +"Other" = "Overige"; + +"Parish" = "Parochie"; + +"Phone" = "Telefoon"; + +"Postal code" = "Postcode"; + +"Prefecture" = "Prefectuur"; + +"Province" = "Provincie"; + +"Remove" = "Verwijderen"; + +"Remove bank account" = "Bankrekening verwijderen"; + +"Remove bank account ending in %@" = "Bankrekening verwijderen die eindigt op %@"; + +"Search" = "Zoeken"; + +"State" = "Staat"; + +"Suburb" = "Voorstad"; + +"Suburb or city" = "Stad of voorstad"; + +"The BSB you entered is incomplete." = "Het ingevoerde BSB-nummer is onvolledig."; + +"The ID number you entered is incomplete." = "Het opgegeven identificatienummer is onvolledig."; + +"The account number you entered is incomplete." = "Het opgegeven rekeningnummer is onvolledig."; + +"Town or city" = "Stad of plaats"; + +"UPI ID" = "UPI-ID"; + +"Unable to parse phone number" = "Kan telefoonnummer niet parseren"; + +"Use rotor to access links" = "Gebruik de rotor om een link te openen"; + +"Your BLIK code is incomplete." = "Je BLIK-code is onvolledig."; + +"Your BLIK code is invalid." = "Je BLIK-code is ongeldig."; + +"Your ZIP is incomplete." = "Je ZIP is onvolledig."; + +"Your email is invalid." = "Je e-mailadres is ongeldig."; + +"Your payment information will not be saved." = "Je betaalgegevens worden niet opgeslagen."; + +"Your postal code is incomplete." = "Je postcode is onvolledig."; + +"ZIP" = "Postcode"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/nn-NO.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/nn-NO.lproj/Localizable.strings new file mode 100644 index 00000000..4d375e15 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/nn-NO.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (valfritt)"; + +"Account number" = "Kontonummer"; + +"Address" = "Adresse"; + +"Address line 1" = "Adresselinje 1"; + +"Address line 2" = "Adresselinje 2"; + +"Area" = "Område"; + +"BLIK code" = "BLIK-kode"; + +"BSB number" = "BSB-nummer"; + +"Billing address is same as shipping" = "Fakturaadressa er den same som leveringsadressa"; + +"Cancel" = "Avbryt"; + +"City" = "Stad"; + +"Code field" = "Kodefelt"; + +"Company" = "Selskap"; + +"Continue" = "Fortsett"; + +"Country" = "Land"; + +"Country or region" = "Land eller region"; + +"County" = "Fylke"; + +"Date is empty." = "Dato er tom."; + +"Department" = "Område"; + +"District" = "Distrikt"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Vil du lukke dette skjemaet?"; + +"Done" = "Ferdig"; + +"Double tap to edit" = "Dobbeltæpp for å redigere"; + +"Edit" = "Rediger"; + +"Eircode" = "Eircode"; + +"Email" = "E-post"; + +"Emirate" = "Emirat"; + +"Error" = "Feil"; + +"First" = "Fornamn"; + +"Full name" = "Fullt namn"; + +"Incomplete phone number" = "Ufullstendig telefonnummer"; + +"Invalid UPI ID" = "Ugyldig UPI ID"; + +"Island" = "Øy"; + +"Last" = "Etternamn"; + +"Name" = "Namn"; + +"Name on account" = "Namn på konto"; + +"OK" = "OK"; + +"Oblast" = "Oblast"; + +"Other" = "Annan"; + +"Parish" = "Sogn"; + +"Phone" = "Telefonnr."; + +"Postal code" = "Postnummer"; + +"Prefecture" = "Prefektur"; + +"Province" = "Område"; + +"Remove" = "Fjern"; + +"Remove bank account" = "Fjern bankkonto"; + +"Remove bank account ending in %@" = "Fjern bankkonto som sluttar på %@"; + +"Search" = "Søk"; + +"State" = "Stat"; + +"Suburb" = "Forstad"; + +"Suburb or city" = "Forstad eller stad"; + +"The BSB you entered is incomplete." = "BSB-koden du skreiv inn er ufullstendig."; + +"The ID number you entered is incomplete." = "ID-nummeret du skrev inn, er ufullstendig."; + +"The account number you entered is incomplete." = "Kontonummeret du skrev inn, er ufullstendig."; + +"Town or city" = "Stad"; + +"UPI ID" = "UPI ID"; + +"Unable to parse phone number" = "Kan ikkje analysere telefonnummer"; + +"Use rotor to access links" = "Bruk rotor for å opne koplingar"; + +"Your BLIK code is incomplete." = "BLIK-koden er ufullstendig."; + +"Your BLIK code is invalid." = "BLIK-koden er ugyldig."; + +"Your ZIP is incomplete." = "ZIP-koden er ufullstendig."; + +"Your email is invalid." = "E-postadressa di er ugyldig."; + +"Your payment information will not be saved." = "Betalingsinformasjonen din blir ikkje lagra."; + +"Your postal code is incomplete." = "Postnummeret er ufullstendig."; + +"ZIP" = "POSTNR."; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/pl-PL.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/pl-PL.lproj/Localizable.strings new file mode 100644 index 00000000..689a816c --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/pl-PL.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (opcjonalnie)"; + +"Account number" = "Numer konta"; + +"Address" = "Adres"; + +"Address line 1" = "Linia adresu 1"; + +"Address line 2" = "Linia adresu 2"; + +"Area" = "Obszar"; + +"BLIK code" = "Kod BLIK"; + +"BSB number" = "Numer BSB"; + +"Billing address is same as shipping" = "Adres rozliczenia ten sam, co adres wysyłki"; + +"Cancel" = "Anuluj"; + +"City" = "Miejscowość"; + +"Code field" = "Pole kodu"; + +"Company" = "Firma"; + +"Continue" = "Kontynuuj"; + +"Country" = "Kraj"; + +"Country or region" = "Kraj lub region"; + +"County" = "Hrabstwo"; + +"Date is empty." = "Data jest pusta."; + +"Department" = "Departament"; + +"District" = "Dystrykt"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Czy chcesz zamknąć ten formularz?"; + +"Done" = "Gotowe"; + +"Double tap to edit" = "Dotknij dwa razy, aby edytować"; + +"Edit" = "Edytuj"; + +"Eircode" = "Eircode"; + +"Email" = "Adres e-mail"; + +"Emirate" = "Emiraty"; + +"Error" = "Błąd"; + +"First" = "Imię"; + +"Full name" = "Imię i nazwisko"; + +"Incomplete phone number" = "Niekompletny numer telefonu"; + +"Invalid UPI ID" = "Nieprawidłowy UPI ID"; + +"Island" = "Wyspa"; + +"Last" = "Nazwisko"; + +"Name" = "Imię i nazwisko"; + +"Name on account" = "Imię i nazwisko na rachunku"; + +"OK" = "OK"; + +"Oblast" = "Obwód"; + +"Other" = "Inne"; + +"Parish" = "Parafia"; + +"Phone" = "Telefon"; + +"Postal code" = "Kod pocztowy"; + +"Prefecture" = "Prefektura"; + +"Province" = "Prowincja"; + +"Remove" = "Usuń"; + +"Remove bank account" = "Usuń konto bankowe"; + +"Remove bank account ending in %@" = "Usuń konto bankowe kończące się na %@"; + +"Search" = "Wyszukaj"; + +"State" = "Stan"; + +"Suburb" = "Przedmieścia"; + +"Suburb or city" = "Przedmieścia lub miasto"; + +"The BSB you entered is incomplete." = "Wprowadzony numer BSB jest niepełny."; + +"The ID number you entered is incomplete." = "Podany identyfikator jest niekompletny."; + +"The account number you entered is incomplete." = "Podany numer konta jest niekompletny."; + +"Town or city" = "Miejscowość"; + +"UPI ID" = "UPI ID"; + +"Unable to parse phone number" = "Nie można dokonać parsowania numeru telefonu"; + +"Use rotor to access links" = "Użyj pokrętła, aby uzyskać dostęp do łączy"; + +"Your BLIK code is incomplete." = "Kod BLIK jest niekompletny."; + +"Your BLIK code is invalid." = "Nieprawidłowy kod BLIK."; + +"Your ZIP is incomplete." = "Kod pocztowy (ZIP) jest niekompletny."; + +"Your email is invalid." = "Adres e-mail jest nieprawidłowy."; + +"Your payment information will not be saved." = "Informacje dotyczące płatności nie zostaną zapisane."; + +"Your postal code is incomplete." = "Kod pocztowy jest niekompletny."; + +"ZIP" = "Kod p"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/pt-BR.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/pt-BR.lproj/Localizable.strings new file mode 100644 index 00000000..054a7a5a --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/pt-BR.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (opcional)"; + +"Account number" = "Número da conta"; + +"Address" = "Endereço"; + +"Address line 1" = "Endereço – Linha 1"; + +"Address line 2" = "Endereço – Linha 2"; + +"Area" = "Área"; + +"BLIK code" = "Código BLIK"; + +"BSB number" = "Número BSB"; + +"Billing address is same as shipping" = "O endereço de faturamento é igual ao de envio"; + +"Cancel" = "Cancelar"; + +"City" = "Cidade"; + +"Code field" = "Campo do código"; + +"Company" = "Empresa"; + +"Continue" = "Continuar"; + +"Country" = "País"; + +"Country or region" = "País ou região"; + +"County" = "Condado"; + +"Date is empty." = "A data está vazia."; + +"Department" = "Departamento"; + +"District" = "Distrito"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Deseja fechar este formulário?"; + +"Done" = "Concluído"; + +"Double tap to edit" = "Toque duas vezes para editar"; + +"Edit" = "Editar"; + +"Eircode" = "Eircode"; + +"Email" = "E-mail"; + +"Emirate" = "Emirado"; + +"Error" = "Erro"; + +"First" = "Nome"; + +"Full name" = "Nome completo"; + +"Incomplete phone number" = "Número de telefone incompleto"; + +"Invalid UPI ID" = "ID UPI inválido"; + +"Island" = "Ilha"; + +"Last" = "Sobrenome"; + +"Name" = "Nome"; + +"Name on account" = "Nome na conta"; + +"OK" = "OK"; + +"Oblast" = "Oblast"; + +"Other" = "Outro"; + +"Parish" = "Paróquia"; + +"Phone" = "Telefone"; + +"Postal code" = "Código postal"; + +"Prefecture" = "Prefeitura"; + +"Province" = "Província"; + +"Remove" = "Remover"; + +"Remove bank account" = "Remover conta bancária"; + +"Remove bank account ending in %@" = "Remover conta bancária com final %@"; + +"Search" = "Pesquisar"; + +"State" = "Estado"; + +"Suburb" = "Subúrbio"; + +"Suburb or city" = "Subúrbio ou cidade"; + +"The BSB you entered is incomplete." = "O BSB inserido está incompleto."; + +"The ID number you entered is incomplete." = "O número da identificação inserido está incompleto."; + +"The account number you entered is incomplete." = "O número de conta inserido está incompleto."; + +"Town or city" = "Cidade ou município"; + +"UPI ID" = "ID UPI"; + +"Unable to parse phone number" = "Não foi possível analisar o número de telefone"; + +"Use rotor to access links" = "Use o rotor para acessar os links"; + +"Your BLIK code is incomplete." = "Seu código BLIK está incompleto."; + +"Your BLIK code is invalid." = "Seu código BLIK é inválido."; + +"Your ZIP is incomplete." = "Código postal incompleto."; + +"Your email is invalid." = "Seu e-mail é inválido."; + +"Your payment information will not be saved." = "Seus dados de pagamento não serão salvos."; + +"Your postal code is incomplete." = "Código postal incompleto."; + +"ZIP" = "CEP"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/pt-PT.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/pt-PT.lproj/Localizable.strings new file mode 100644 index 00000000..d5cd99b8 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/pt-PT.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (opcional)"; + +"Account number" = "Número de conta"; + +"Address" = "Endereço"; + +"Address line 1" = "Linha de endereço 1"; + +"Address line 2" = "Linha de endereço 2"; + +"Area" = "Área"; + +"BLIK code" = "Código BLIK"; + +"BSB number" = "Número de BSB"; + +"Billing address is same as shipping" = "O endereço de faturação é igual ao endereço de envio"; + +"Cancel" = "Cancelar"; + +"City" = "Cidade"; + +"Code field" = "Campo do código"; + +"Company" = "Empresa"; + +"Continue" = "Continuar"; + +"Country" = "País"; + +"Country or region" = "País ou região"; + +"County" = "Concelho"; + +"Date is empty." = "A data está vazia."; + +"Department" = "Departamento"; + +"District" = "Distrito"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Pretende fechar este formulário?"; + +"Done" = "Concluído"; + +"Double tap to edit" = "Toque duas vezes para editar"; + +"Edit" = "Editar"; + +"Eircode" = "Eircode"; + +"Email" = "E-mail"; + +"Emirate" = "Emirado"; + +"Error" = "Erro"; + +"First" = "Nome próprio"; + +"Full name" = "Nome completo"; + +"Incomplete phone number" = "Número de telefone incompleto"; + +"Invalid UPI ID" = "ID UPI inválida"; + +"Island" = "Ilha"; + +"Last" = "Apelido"; + +"Name" = "Nome"; + +"Name on account" = "Nome na conta"; + +"OK" = "OK"; + +"Oblast" = "Oblast"; + +"Other" = "Outro"; + +"Parish" = "Paróquia"; + +"Phone" = "Telefone"; + +"Postal code" = "Código postal"; + +"Prefecture" = "Câmara municipal"; + +"Province" = "Província"; + +"Remove" = "Remover"; + +"Remove bank account" = "Remover conta bancária"; + +"Remove bank account ending in %@" = "Remover conta bancária a terminar em %@"; + +"Search" = "Pesquisar"; + +"State" = "Estado"; + +"Suburb" = "Subúrbio"; + +"Suburb or city" = "Subúrbio ou cidade"; + +"The BSB you entered is incomplete." = "O BSB que introduziu está incompleto."; + +"The ID number you entered is incomplete." = "O número de identificação que introduziu está incompleto."; + +"The account number you entered is incomplete." = "O número da conta que introduziu está incompleto."; + +"Town or city" = "Localidade ou cidade"; + +"UPI ID" = "ID UPI"; + +"Unable to parse phone number" = "Impossível analisar número de telefone"; + +"Use rotor to access links" = "Utilizar o rotor para aceder a ligações"; + +"Your BLIK code is incomplete." = "O seu código BLIK está incompleto."; + +"Your BLIK code is invalid." = "O seu código BLIK é inválido."; + +"Your ZIP is incomplete." = "O seu código ZIP está incompleto."; + +"Your email is invalid." = "O seu email é inválido."; + +"Your payment information will not be saved." = "As suas informações de pagamento não serão guardadas."; + +"Your postal code is incomplete." = "O seu código postal está incompleto."; + +"ZIP" = "C.P."; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/ro-RO.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/ro-RO.lproj/Localizable.strings new file mode 100644 index 00000000..783b6d63 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/ro-RO.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (opțional)"; + +"Account number" = "Număr cont"; + +"Address" = "Adresa"; + +"Address line 1" = "Rândul 1 pentru adresă"; + +"Address line 2" = "Rândul 2 pentru adresă"; + +"Area" = "Zonă"; + +"BLIK code" = "Cod BLIK"; + +"BSB number" = "Număr BSB"; + +"Billing address is same as shipping" = "Adresa de facturare este la fel cu cea de expediere"; + +"Cancel" = "Anulare"; + +"City" = "Oraș"; + +"Code field" = "Câmp pentru introducerea codului"; + +"Company" = "Companie"; + +"Continue" = "Continuare"; + +"Country" = "Țară"; + +"Country or region" = "Țară sau regiune"; + +"County" = "Județ"; + +"Date is empty." = "Data nu este completată."; + +"Department" = "Departament"; + +"District" = "District"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Doriți să închideți acest formular?"; + +"Done" = "Efectuat"; + +"Double tap to edit" = "Atingeți de două ori pentru a edita"; + +"Edit" = "Editare"; + +"Eircode" = "Eircode"; + +"Email" = "E-mail"; + +"Emirate" = "Emirat"; + +"Error" = "Eroare"; + +"First" = "Prenume"; + +"Full name" = "Nume complet"; + +"Incomplete phone number" = "Număr de telefon incomplet"; + +"Invalid UPI ID" = "ID UPI nevalid"; + +"Island" = "Insulă"; + +"Last" = "Nume de familie"; + +"Name" = "Nume"; + +"Name on account" = "Numele contului"; + +"OK" = "OK"; + +"Oblast" = "Oblast"; + +"Other" = "Altele"; + +"Parish" = "Parohie"; + +"Phone" = "Număr de telefon"; + +"Postal code" = "Cod poștal"; + +"Prefecture" = "Prefectură"; + +"Province" = "Provincie"; + +"Remove" = "Eliminare"; + +"Remove bank account" = "Eliminați contul bancar"; + +"Remove bank account ending in %@" = "Eliminați contul bancar care se termină în %@"; + +"Search" = "Căutare"; + +"State" = "Stat"; + +"Suburb" = "Suburbie"; + +"Suburb or city" = "Suburbie sau oraș"; + +"The BSB you entered is incomplete." = "Numărul BSB pe care l-ați introdus nu este complet."; + +"The ID number you entered is incomplete." = "Numărul de identificare pe care l-ați introdus nu este complet."; + +"The account number you entered is incomplete." = "Numărul de cont pe care l-ați introdus nu este complet."; + +"Town or city" = "Municipalitate sau oraș"; + +"UPI ID" = "ID UPI"; + +"Unable to parse phone number" = "Nu se poate analiza numărul de telefon"; + +"Use rotor to access links" = "Utilizați rotorul pentru a accesa linkurile"; + +"Your BLIK code is incomplete." = "Codul dvs. BLIK nu este complet."; + +"Your BLIK code is invalid." = "Codul dvs. BLIK nu este valid."; + +"Your ZIP is incomplete." = "Codul dvs. poștal nu este complet."; + +"Your email is invalid." = "E-mailul dvs. nu este valid."; + +"Your payment information will not be saved." = "Informațiile dvs. de plată nu vor fi salvate."; + +"Your postal code is incomplete." = "Codul dvs. poștal nu este complet."; + +"ZIP" = "Cod poștal"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/ru.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/ru.lproj/Localizable.strings new file mode 100644 index 00000000..ad59c60a --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/ru.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (необязательно)"; + +"Account number" = "Номер счета"; + +"Address" = "Адрес"; + +"Address line 1" = "Адрес (строка 1)"; + +"Address line 2" = "Адрес (строка 2)"; + +"Area" = "Область"; + +"BLIK code" = "Код BLIK"; + +"BSB number" = "Номер BSB"; + +"Billing address is same as shipping" = "Адрес выставления счетов такой же, как адрес доставки"; + +"Cancel" = "Отмена"; + +"City" = "Город"; + +"Code field" = "Поле кода"; + +"Company" = "Компания"; + +"Continue" = "Продолжить"; + +"Country" = "Страна"; + +"Country or region" = "Страна или регион"; + +"County" = "Графство"; + +"Date is empty." = "Дата не указана."; + +"Department" = "Департамент"; + +"District" = "Район"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Закрыть форму?"; + +"Done" = "Готово"; + +"Double tap to edit" = "Коснитесь дважды, чтобы отредактировать"; + +"Edit" = "Редактировать"; + +"Eircode" = "Почтовый индекс (Eircode)"; + +"Email" = "Эл. почта"; + +"Emirate" = "Эмират"; + +"Error" = "Ошибка"; + +"First" = "Имя"; + +"Full name" = "Имя, фамилия"; + +"Incomplete phone number" = "Номер телефона введен не полностью"; + +"Invalid UPI ID" = "Недействительный идентификатор UPI"; + +"Island" = "Остров"; + +"Last" = "Фамилия"; + +"Name" = "Имя"; + +"Name on account" = "Владелец счета"; + +"OK" = "ОК"; + +"Oblast" = "Область"; + +"Other" = "другой"; + +"Parish" = "Приход"; + +"Phone" = "Телефон"; + +"Postal code" = "Почтовый индекс"; + +"Prefecture" = "Префектура"; + +"Province" = "Провинция"; + +"Remove" = "Удалить"; + +"Remove bank account" = "Удалить банковский счет"; + +"Remove bank account ending in %@" = "Удалить банковский счет, оканчивающийся на %@"; + +"Search" = "Поиск"; + +"State" = "Штат"; + +"Suburb" = "Пригород"; + +"Suburb or city" = "Населенный пункт"; + +"The BSB you entered is incomplete." = "Введенный код BSB неполон."; + +"The ID number you entered is incomplete." = "Номер документа, удостоверяющего личность, введен не полностью."; + +"The account number you entered is incomplete." = "Номер счета введен не полностью."; + +"Town or city" = "Город"; + +"UPI ID" = "Идентификатор UPI"; + +"Unable to parse phone number" = "Не удается разобрать номер телефона"; + +"Use rotor to access links" = "Используйте ротор для доступа к ссылкам"; + +"Your BLIK code is incomplete." = "Ваш код BLIK неполный."; + +"Your BLIK code is invalid." = "Ваш код BLIK недействителен."; + +"Your ZIP is incomplete." = "Почтовый индекс введен не полностью."; + +"Your email is invalid." = "Недействительный адрес эл. почты."; + +"Your payment information will not be saved." = "Ваши платежные реквизиты не будут сохранены."; + +"Your postal code is incomplete." = "Введенный почтовый индекс неполон."; + +"ZIP" = "Почтовый индекс"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/sk-SK.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/sk-SK.lproj/Localizable.strings new file mode 100644 index 00000000..342995e9 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/sk-SK.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (voliteľné)"; + +"Account number" = "Číslo účtu"; + +"Address" = "Adresa"; + +"Address line 1" = "Riadok adresy 1"; + +"Address line 2" = "Riadok adresy 2"; + +"Area" = "Okres"; + +"BLIK code" = "Kód BLIK"; + +"BSB number" = "BSB číslo"; + +"Billing address is same as shipping" = "Fakturačná adresa je totožná s dodacou"; + +"Cancel" = "Zrušiť"; + +"City" = "Mesto"; + +"Code field" = "Pole kódu"; + +"Company" = "Spoločnosť"; + +"Continue" = "Pokračovať"; + +"Country" = "Krajina"; + +"Country or region" = "Krajina alebo región"; + +"County" = "Kraj"; + +"Date is empty." = "Dátum je prázdny."; + +"Department" = "Oddelenie"; + +"District" = "Obvod"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Chcete zatvoriť tento formulár?"; + +"Done" = "Hotovo"; + +"Double tap to edit" = "Dvojitým ťuknutím upravte"; + +"Edit" = "Upraviť"; + +"Eircode" = "Kód Eir"; + +"Email" = "E-mail"; + +"Emirate" = "Emirát"; + +"Error" = "Chyba"; + +"First" = "Meno"; + +"Full name" = "Celé meno"; + +"Incomplete phone number" = "Neúplné telefónne číslo"; + +"Invalid UPI ID" = "Neplatné identifikačné číslo UPI"; + +"Island" = "Island"; + +"Last" = "Priezvisko"; + +"Name" = "Meno"; + +"Name on account" = "Meno uvedené v účte"; + +"OK" = "OK"; + +"Oblast" = "Oblasť"; + +"Other" = "Iné"; + +"Parish" = "Parish"; + +"Phone" = "Telefón"; + +"Postal code" = "PSČ"; + +"Prefecture" = "Prefektúra"; + +"Province" = "Provincia"; + +"Remove" = "Odstrániť"; + +"Remove bank account" = "Odstrániť bankový účet"; + +"Remove bank account ending in %@" = "Odstrániť bankový účet končiaci na %@"; + +"Search" = "Vyhľadať"; + +"State" = "Štát"; + +"Suburb" = "Predmestie"; + +"Suburb or city" = "Predmestie alebo mesto"; + +"The BSB you entered is incomplete." = "Zadané BSB je neúplné."; + +"The ID number you entered is incomplete." = "Zadané identifikačné číslo je neúplné."; + +"The account number you entered is incomplete." = "Zadané číslo účtu je neúplné."; + +"Town or city" = "Obec alebo mesto"; + +"UPI ID" = "Identifikačné číslo UPI"; + +"Unable to parse phone number" = "Nepodarilo sa analyzovať telefónne číslo"; + +"Use rotor to access links" = "Použite funkciu rotora na prístup k odkazom"; + +"Your BLIK code is incomplete." = "Váš kód BLIK je neúplný."; + +"Your BLIK code is invalid." = "Váš kód BLIK je neplatný."; + +"Your ZIP is incomplete." = "Vaše PSČ je neúplné."; + +"Your email is invalid." = "Váš e-mail je neplatný."; + +"Your payment information will not be saved." = "Vaše informácie o platbe nebudú uložené."; + +"Your postal code is incomplete." = "Vaše poštové smerovacie číslo je neúplné."; + +"ZIP" = "PSČ"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/sl-SI.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/sl-SI.lproj/Localizable.strings new file mode 100644 index 00000000..70ae07f3 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/sl-SI.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (izbirno)"; + +"Account number" = "Številka računa"; + +"Address" = "Naslov"; + +"Address line 1" = "Naslov 1"; + +"Address line 2" = "Vrstica naslova 2"; + +"Area" = "Območje"; + +"BLIK code" = "Koda BLIK"; + +"BSB number" = "Številka BSB"; + +"Billing address is same as shipping" = "Naslov za obračunavanje je isti kot dostavni naslov"; + +"Cancel" = "Prekliči"; + +"City" = "Mesto"; + +"Code field" = "Polje za kodo"; + +"Company" = "Podjetje"; + +"Continue" = "Nadaljuj"; + +"Country" = "Država"; + +"Country or region" = "Država ali regija"; + +"County" = "Okraj"; + +"Date is empty." = "Polje z datumom je prazno."; + +"Department" = "Oddelek"; + +"District" = "Okrožje"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Ali želite zapreti ta obrazec?"; + +"Done" = "Dokončano"; + +"Double tap to edit" = "Dvotapnite, da uredite"; + +"Edit" = "Uredi"; + +"Eircode" = "Eircode"; + +"Email" = "E-poštni naslov"; + +"Emirate" = "Emirat"; + +"Error" = "Napaka"; + +"First" = "Ime"; + +"Full name" = "Polno ime"; + +"Incomplete phone number" = "Nepopolna telefonska številka mobilnega telefona."; + +"Invalid UPI ID" = "Neveljaven UPI ID"; + +"Island" = "Otok"; + +"Last" = "Priimek"; + +"Name" = "Ime"; + +"Name on account" = "Ime na računu"; + +"OK" = "V redu"; + +"Oblast" = "Oblast"; + +"Other" = "Drugo"; + +"Parish" = "Župnija"; + +"Phone" = "Telefon"; + +"Postal code" = "Poštna številka"; + +"Prefecture" = "Prefektura"; + +"Province" = "Provinca"; + +"Remove" = "Odstrani"; + +"Remove bank account" = "Odstranjevanje bančnega računa"; + +"Remove bank account ending in %@" = "Odstranite bančni račun, ki se konča s števkami %@"; + +"Search" = "Išči"; + +"State" = "Zvezna država"; + +"Suburb" = "Predmestje"; + +"Suburb or city" = "Predmestje ali mesto"; + +"The BSB you entered is incomplete." = "Vneseni BSB ni popoln."; + +"The ID number you entered is incomplete." = "Vnesena številka osebnega dokumenta ni popolna."; + +"The account number you entered is incomplete." = "Vnesena številka računa ni popolna."; + +"Town or city" = "Kraj ali mesto"; + +"UPI ID" = "UPI ID"; + +"Unable to parse phone number" = "Telefonske številke ni mogoče razčleniti"; + +"Use rotor to access links" = "Uporabite rotor za dostop do povezav"; + +"Your BLIK code is incomplete." = "Vaša koda BLIK ni popolna."; + +"Your BLIK code is invalid." = "Vaša koda BLIK ni veljavna."; + +"Your ZIP is incomplete." = "Vaša poštna številka ni popolna."; + +"Your email is invalid." = "Vaš e-poštni naslov ni veljaven."; + +"Your payment information will not be saved." = "Vaši podatki o plačilo ne bodo shranjeni."; + +"Your postal code is incomplete." = "Vaša poštna številka ni popolna."; + +"ZIP" = "Poštna številka"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/sv.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/sv.lproj/Localizable.strings new file mode 100644 index 00000000..fe2924a1 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/sv.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (valfritt)"; + +"Account number" = "Kontonummer"; + +"Address" = "Adress"; + +"Address line 1" = "Adressrad 1"; + +"Address line 2" = "Adressrad 2"; + +"Area" = "Område"; + +"BLIK code" = "BLIK-kod"; + +"BSB number" = "BSB-nummer"; + +"Billing address is same as shipping" = "Faktureringsadress är samma som leveransadress"; + +"Cancel" = "Avbryt"; + +"City" = "Ort"; + +"Code field" = "Fält för kod"; + +"Company" = "Företag"; + +"Continue" = "Fortsätt"; + +"Country" = "Land"; + +"Country or region" = "Land eller region"; + +"County" = "Landskap"; + +"Date is empty." = "Datumfältet är tomt."; + +"Department" = "Departement"; + +"District" = "Distrikt"; + +"Do Si" = "Provins och stad"; + +"Do you want to close this form?" = "Vill du stänga det här formuläret?"; + +"Done" = "Klar"; + +"Double tap to edit" = "Dubbelklicka för att redigera"; + +"Edit" = "Redigera"; + +"Eircode" = "Eircode"; + +"Email" = "E-post"; + +"Emirate" = "Emirat"; + +"Error" = "Fel"; + +"First" = "Förnamn"; + +"Full name" = "Fullständigt namn"; + +"Incomplete phone number" = "Ofullständigt telefonnummer"; + +"Invalid UPI ID" = "Ogiltigt UPI ID"; + +"Island" = "Ö"; + +"Last" = "Efternamn"; + +"Name" = "Namn"; + +"Name on account" = "Namn på kontot"; + +"OK" = "OK"; + +"Oblast" = "Oblast"; + +"Other" = "Annan"; + +"Parish" = "Parish"; + +"Phone" = "Telefonnummer"; + +"Postal code" = "Postnummer"; + +"Prefecture" = "Prefektur"; + +"Province" = "Provins"; + +"Remove" = "Ta bort"; + +"Remove bank account" = "Ta bort bankkonto"; + +"Remove bank account ending in %@" = "Ta bort bankkonto som slutar på %@"; + +"Search" = "Sök"; + +"State" = "Delstat"; + +"Suburb" = "Förort"; + +"Suburb or city" = "Förort eller stad"; + +"The BSB you entered is incomplete." = "Det BSB du angav är ofullständigt."; + +"The ID number you entered is incomplete." = "ID-numret du angav är ofullständigt."; + +"The account number you entered is incomplete." = "Kontonumret du angav är ofullständigt."; + +"Town or city" = "Stad"; + +"UPI ID" = "UPI ID"; + +"Unable to parse phone number" = "Det gick inte att analysera telefonnumret"; + +"Use rotor to access links" = "Använd rotorn för att komma åt länkar"; + +"Your BLIK code is incomplete." = "Din BLIK-kod är ofullständig."; + +"Your BLIK code is invalid." = "Din BLIK-kod är ogiltig."; + +"Your ZIP is incomplete." = "Ditt postnummer är ofullständigt."; + +"Your email is invalid." = "Din e-postadress är ogiltig."; + +"Your payment information will not be saved." = "Din betalningsinformation kommer inte att sparas."; + +"Your postal code is incomplete." = "Ditt postnummer är ofullständigt."; + +"ZIP" = "Postkod"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/tr.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/tr.lproj/Localizable.strings new file mode 100644 index 00000000..8954c9b6 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/tr.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (isteğe bağlı)"; + +"Account number" = "Hesap numarası"; + +"Address" = "Adres"; + +"Address line 1" = "Adres satırı 1"; + +"Address line 2" = "Adres satırı 2"; + +"Area" = "Bölge"; + +"BLIK code" = "BLIK kodu"; + +"BSB number" = "BSB numarası"; + +"Billing address is same as shipping" = "Faturalama adresi, gönderim adresiyle aynı"; + +"Cancel" = "İptal"; + +"City" = "Şehir"; + +"Code field" = "Kod alanı"; + +"Company" = "Şirket"; + +"Continue" = "Devam et"; + +"Country" = "Ülke"; + +"Country or region" = "Ülke veya bölge"; + +"County" = "Ülke"; + +"Date is empty." = "Tarih boş."; + +"Department" = "İdari Bölüm"; + +"District" = "İlçe"; + +"Do Si" = "Do Si"; + +"Do you want to close this form?" = "Bu formu kapatmak istiyor musunuz?"; + +"Done" = "Tamam"; + +"Double tap to edit" = "Düzenlemek için iki kez dokunun"; + +"Edit" = "Düzenle"; + +"Eircode" = "Eircode"; + +"Email" = "E-posta"; + +"Emirate" = "Emirlik"; + +"Error" = "Hata"; + +"First" = "Ad"; + +"Full name" = "Ad ve soyadınız"; + +"Incomplete phone number" = "Eksik telefon numarası"; + +"Invalid UPI ID" = "Geçersiz UPI Numarası"; + +"Island" = "Ada"; + +"Last" = "Soyadı"; + +"Name" = "Ad"; + +"Name on account" = "Hesaptaki ad"; + +"OK" = "TAMAM"; + +"Oblast" = "Oblast"; + +"Other" = "Diğer"; + +"Parish" = "Dini bölge"; + +"Phone" = "Telefon"; + +"Postal code" = "Posta kodu"; + +"Prefecture" = "İdari bölge"; + +"Province" = "İlçe"; + +"Remove" = "Kaldır"; + +"Remove bank account" = "Banka hesabını kaldır"; + +"Remove bank account ending in %@" = "%@ ile biten banka hesabını kaldır"; + +"Search" = "Ara"; + +"State" = "Eyalet"; + +"Suburb" = "Banliyö"; + +"Suburb or city" = "Banliyö veya şehir"; + +"The BSB you entered is incomplete." = "Girdiğiniz BSB eksik."; + +"The ID number you entered is incomplete." = "Girdiğiniz kimlik numarası eksik."; + +"The account number you entered is incomplete." = "Girdiğiniz hesap numarası eksik."; + +"Town or city" = "Kasaba veya şehir"; + +"UPI ID" = "UPI Numarası"; + +"Unable to parse phone number" = "Telefon numaranızı ayrıştırılamadı"; + +"Use rotor to access links" = "Linklere erişmek için rotoru kullanın."; + +"Your BLIK code is incomplete." = "BLIK kodunuz eksik."; + +"Your BLIK code is invalid." = "BLIK kodunuz geçersiz."; + +"Your ZIP is incomplete." = "Posta kodunuz eksik."; + +"Your email is invalid." = "E-postanız geçersiz."; + +"Your payment information will not be saved." = "Ödeme bilgileriniz kaydedilmeyecek."; + +"Your postal code is incomplete." = "Posta kodunuz eksik."; + +"ZIP" = "Posta Kodu"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/vi.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/vi.lproj/Localizable.strings new file mode 100644 index 00000000..789975ce --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/vi.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@ (tùy chọn)"; + +"Account number" = "Số tài khoản"; + +"Address" = "Địa chỉ"; + +"Address line 1" = "Dòng địa chỉ 1"; + +"Address line 2" = "Dòng địa chỉ 2"; + +"Area" = "Khu vực"; + +"BLIK code" = "Mã BLIK"; + +"BSB number" = "Số BSB"; + +"Billing address is same as shipping" = "Địa chỉ xuất hóa đơn giống địa chỉ chuyển hàng"; + +"Cancel" = "Hủy"; + +"City" = "Thành phố"; + +"Code field" = "Trường mã"; + +"Company" = "Công ty"; + +"Continue" = "Tiếp tục"; + +"Country" = "Quốc gia"; + +"Country or region" = "Quốc gia hoặc khu vực"; + +"County" = "Quận"; + +"Date is empty." = "Ngày trống."; + +"Department" = "Sở ngành"; + +"District" = "Quận/Huyện"; + +"Do Si" = "Tỉnh/Thành phố"; + +"Do you want to close this form?" = "Bạn có muốn đóng biểu mẫu này không?"; + +"Done" = "Xong"; + +"Double tap to edit" = "Nhấp chạm đúp để hiệu đính"; + +"Edit" = "Sửa"; + +"Eircode" = "Mã Eircode"; + +"Email" = "Email"; + +"Emirate" = "Tiểu vương quốc"; + +"Error" = "Lỗi"; + +"First" = "Tên"; + +"Full name" = "Họ tên"; + +"Incomplete phone number" = "Số điện thoại chưa đầy đủ"; + +"Invalid UPI ID" = "Số ID UPI không hợp lệ"; + +"Island" = "Đảo"; + +"Last" = "Họ"; + +"Name" = "Tên"; + +"Name on account" = "Tên trên tài khoản"; + +"OK" = "OK"; + +"Oblast" = "Vùng"; + +"Other" = "Khác"; + +"Parish" = "Giáo xứ"; + +"Phone" = "Điện thoại"; + +"Postal code" = "Mã bưu điện"; + +"Prefecture" = "Tỉnh"; + +"Province" = "Tỉnh"; + +"Remove" = "Xóa"; + +"Remove bank account" = "Bỏ tài khoản ngân hàng"; + +"Remove bank account ending in %@" = "Bỏ tài khoản ngân hàng có các số cuối là %@"; + +"Search" = "Tìm kiếm"; + +"State" = "Tiểu bang"; + +"Suburb" = "Ngoại ô"; + +"Suburb or city" = "Ngoại ô hoặc thành phố"; + +"The BSB you entered is incomplete." = "BSB mà bạn nhập chưa đầy đủ."; + +"The ID number you entered is incomplete." = "Số ID bạn đã nhập chưa đầy đủ."; + +"The account number you entered is incomplete." = "Số tài khoản quý vị đã nhập không đầy đủ."; + +"Town or city" = "Thị trấn hoặc thành phố"; + +"UPI ID" = "Số ID UPI"; + +"Unable to parse phone number" = "Không thể phân tích cú pháp số điện thoại"; + +"Use rotor to access links" = "Sử dụng rô-to để truy cập liên kết"; + +"Your BLIK code is incomplete." = "Mã BLIK của quý vị chưa đầy đủ."; + +"Your BLIK code is invalid." = "Mã BLIK của quý vị không hợp lệ."; + +"Your ZIP is incomplete." = "Mã ZIP chưa đầy đủ."; + +"Your email is invalid." = "Email của quý vị không hợp lệ."; + +"Your payment information will not be saved." = "Thông tin thanh toán của bạn sẽ không lưu lại."; + +"Your postal code is incomplete." = "Mã bưu điện chưa đầy đủ."; + +"ZIP" = "ZIP"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/zh-HK.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/zh-HK.lproj/Localizable.strings new file mode 100644 index 00000000..c02c3bbf --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/zh-HK.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@(可選)"; + +"Account number" = "帳號"; + +"Address" = "地址"; + +"Address line 1" = "地址第 1 行"; + +"Address line 2" = "地址第 2 行"; + +"Area" = "地區"; + +"BLIK code" = "BLIK 代碼"; + +"BSB number" = "BSB 號碼"; + +"Billing address is same as shipping" = "賬單地址與收貨地址相同"; + +"Cancel" = "取消"; + +"City" = "城市"; + +"Code field" = "驗證碼欄位"; + +"Company" = "公司"; + +"Continue" = "繼續"; + +"Country" = "國家"; + +"Country or region" = "國家或地區"; + +"County" = "縣"; + +"Date is empty." = "日期為空。"; + +"Department" = "部門"; + +"District" = "地區"; + +"Do Si" = "道/縣"; + +"Do you want to close this form?" = "您確定要保存此表單嗎?"; + +"Done" = "完成"; + +"Double tap to edit" = "按兩下可編輯"; + +"Edit" = "編輯"; + +"Eircode" = "愛爾蘭郵區編號"; + +"Email" = "電郵地址"; + +"Emirate" = "酋長國"; + +"Error" = "錯誤"; + +"First" = "名字"; + +"Full name" = "全名"; + +"Incomplete phone number" = "電話號碼不完整"; + +"Invalid UPI ID" = "UPI ID 無效"; + +"Island" = "島"; + +"Last" = "姓氏"; + +"Name" = "姓名"; + +"Name on account" = "帳戶戶主名"; + +"OK" = "確定"; + +"Oblast" = "州"; + +"Other" = "其他"; + +"Parish" = "堂區"; + +"Phone" = "電話"; + +"Postal code" = "郵區編號"; + +"Prefecture" = "轄區"; + +"Province" = "省"; + +"Remove" = "移除"; + +"Remove bank account" = "移除銀行帳戶"; + +"Remove bank account ending in %@" = "移除尾號為%@ 的銀行帳戶"; + +"Search" = "搜尋"; + +"State" = "州"; + +"Suburb" = "郊區"; + +"Suburb or city" = "市郊或城市"; + +"The BSB you entered is incomplete." = "您輸入的 BSB 不完整。"; + +"The ID number you entered is incomplete." = "您輸入的證件號碼不完整。"; + +"The account number you entered is incomplete." = "您輸入的帳號不完整。"; + +"Town or city" = "城鎮或城市"; + +"UPI ID" = "UPI ID"; + +"Unable to parse phone number" = "無法粘貼手機號碼"; + +"Use rotor to access links" = "用轉子訪問連結"; + +"Your BLIK code is incomplete." = "您的 BLIK 代碼不完整。"; + +"Your BLIK code is invalid." = "您的 BLIK 代碼無效。"; + +"Your ZIP is incomplete." = "您的郵區編號不完整。"; + +"Your email is invalid." = "您的電郵地址無效。"; + +"Your payment information will not be saved." = "不會保存您的付款資訊。"; + +"Your postal code is incomplete." = "您的郵區編號不完整。"; + +"ZIP" = "郵區編號"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/zh-Hans.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/zh-Hans.lproj/Localizable.strings new file mode 100644 index 00000000..ad1afb9f --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@(可选)"; + +"Account number" = "账号"; + +"Address" = "地址"; + +"Address line 1" = "地址第 1 行"; + +"Address line 2" = "地址第 2 行"; + +"Area" = "地区"; + +"BLIK code" = "BLIK 代码"; + +"BSB number" = "BSB 号码"; + +"Billing address is same as shipping" = "账单地址与收货地址相同"; + +"Cancel" = "取消"; + +"City" = "城市"; + +"Code field" = "代码字段"; + +"Company" = "公司"; + +"Continue" = "继续"; + +"Country" = "国家"; + +"Country or region" = "国家或地区"; + +"County" = "县"; + +"Date is empty." = "日期为空。"; + +"Department" = "部门"; + +"District" = "区"; + +"Do Si" = "道/县"; + +"Do you want to close this form?" = "您想关闭此表单吗?"; + +"Done" = "完成"; + +"Double tap to edit" = "双击可编辑"; + +"Edit" = "编辑"; + +"Eircode" = "爱尔兰邮编"; + +"Email" = "电子邮件"; + +"Emirate" = "酋长国"; + +"Error" = "错误"; + +"First" = "名字"; + +"Full name" = "全名"; + +"Incomplete phone number" = "电话号码不完整"; + +"Invalid UPI ID" = "UPI ID 无效"; + +"Island" = "岛"; + +"Last" = "姓氏"; + +"Name" = "姓名"; + +"Name on account" = "账户名"; + +"OK" = "确定"; + +"Oblast" = "州"; + +"Other" = "其他"; + +"Parish" = "堂区"; + +"Phone" = "电话"; + +"Postal code" = "邮政编码"; + +"Prefecture" = "辖区"; + +"Province" = "省"; + +"Remove" = "移除"; + +"Remove bank account" = "移除银行账户"; + +"Remove bank account ending in %@" = "移除尾号为 %@ 的银行账户"; + +"Search" = "搜索"; + +"State" = "州"; + +"Suburb" = "市郊"; + +"Suburb or city" = "市郊或城市"; + +"The BSB you entered is incomplete." = "您输入的 BSB 不完整。"; + +"The ID number you entered is incomplete." = "您输入的证件号码不完整。"; + +"The account number you entered is incomplete." = "您输入的账号不完整。"; + +"Town or city" = "城镇或城市"; + +"UPI ID" = "UPI ID"; + +"Unable to parse phone number" = "无法粘贴手机号码"; + +"Use rotor to access links" = "用转子访问链接"; + +"Your BLIK code is incomplete." = "您的 BLIK 代码不完整。"; + +"Your BLIK code is invalid." = "您的 BLIK 代码无效。"; + +"Your ZIP is incomplete." = "您的邮编不完整。"; + +"Your email is invalid." = "您的邮件地址无效。"; + +"Your payment information will not be saved." = "不会保存您的付款信息。"; + +"Your postal code is incomplete." = "您的邮编不完整。"; + +"ZIP" = "邮编"; diff --git a/StripeUICore/StripeUICore/Resources/Localizations/zh-Hant.lproj/Localizable.strings b/StripeUICore/StripeUICore/Resources/Localizations/zh-Hant.lproj/Localizable.strings new file mode 100644 index 00000000..6b494222 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/Localizations/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,131 @@ +"%@ (optional)" = "%@(可選)"; + +"Account number" = "帳號"; + +"Address" = "地址"; + +"Address line 1" = "地址第 1 行"; + +"Address line 2" = "地址第 2 行"; + +"Area" = "地區"; + +"BLIK code" = "BLIK 驗證碼"; + +"BSB number" = "BSB 號碼"; + +"Billing address is same as shipping" = "帳單地址與送貨地址相同"; + +"Cancel" = "取消"; + +"City" = "城市"; + +"Code field" = "驗證碼欄位"; + +"Company" = "公司"; + +"Continue" = "繼續"; + +"Country" = "國家"; + +"Country or region" = "國家或地區"; + +"County" = "縣"; + +"Date is empty." = "日期為空值。"; + +"Department" = "部門"; + +"District" = "地區"; + +"Do Si" = "道/縣"; + +"Do you want to close this form?" = "您確定要保存此表單嗎?"; + +"Done" = "完成"; + +"Double tap to edit" = "按兩下可編輯"; + +"Edit" = "編輯"; + +"Eircode" = "愛爾蘭郵遞區號"; + +"Email" = "電郵地址"; + +"Emirate" = "大公國"; + +"Error" = "錯誤"; + +"First" = "名字"; + +"Full name" = "全名"; + +"Incomplete phone number" = "電話號碼不完整"; + +"Invalid UPI ID" = "UPI ID 無效"; + +"Island" = "島"; + +"Last" = "姓氏"; + +"Name" = "姓名"; + +"Name on account" = "帳戶上的姓名"; + +"OK" = "確認"; + +"Oblast" = "州"; + +"Other" = "其他"; + +"Parish" = "堂區"; + +"Phone" = "電話"; + +"Postal code" = "郵遞區號"; + +"Prefecture" = "轄區"; + +"Province" = "省"; + +"Remove" = "移除"; + +"Remove bank account" = "移除銀行帳戶"; + +"Remove bank account ending in %@" = "移除尾號為 %@ 的銀行帳戶"; + +"Search" = "搜尋"; + +"State" = "州"; + +"Suburb" = "郊區"; + +"Suburb or city" = "市郊或城市"; + +"The BSB you entered is incomplete." = "您輸入的 BSB 不完整。"; + +"The ID number you entered is incomplete." = "您輸入的證件號碼不完整。"; + +"The account number you entered is incomplete." = "您輸入的帳號不完整。"; + +"Town or city" = "城鎮或城市"; + +"UPI ID" = "UPI ID"; + +"Unable to parse phone number" = "無法粘貼手機號碼"; + +"Use rotor to access links" = "用轉子訪問連結"; + +"Your BLIK code is incomplete." = "您的 BLIK 驗證碼不完整。"; + +"Your BLIK code is invalid." = "您的 BLIK 驗證碼無效。"; + +"Your ZIP is incomplete." = "您的郵遞區號不完整。"; + +"Your email is invalid." = "您的電郵地址無效。"; + +"Your payment information will not be saved." = "不會保存您的付款資訊。"; + +"Your postal code is incomplete." = "您的郵遞區號不完整。"; + +"ZIP" = "郵遞區號"; diff --git a/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/Contents.json b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/brand_stripe.imageset/Contents.json b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/brand_stripe.imageset/Contents.json new file mode 100644 index 00000000..404dd258 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/brand_stripe.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "brand_stripe.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "brand_stripe@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "brand_stripe@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/brand_stripe.imageset/brand_stripe.png b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/brand_stripe.imageset/brand_stripe.png new file mode 100644 index 00000000..bcdc15b9 Binary files /dev/null and b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/brand_stripe.imageset/brand_stripe.png differ diff --git a/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/brand_stripe.imageset/brand_stripe@2x.png b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/brand_stripe.imageset/brand_stripe@2x.png new file mode 100644 index 00000000..fbd76c38 Binary files /dev/null and b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/brand_stripe.imageset/brand_stripe@2x.png differ diff --git a/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/brand_stripe.imageset/brand_stripe@3x.png b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/brand_stripe.imageset/brand_stripe@3x.png new file mode 100644 index 00000000..c0899b33 Binary files /dev/null and b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/brand_stripe.imageset/brand_stripe@3x.png differ diff --git a/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/form_error_icon.imageset/Contents.json b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/form_error_icon.imageset/Contents.json new file mode 100644 index 00000000..41b08963 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/form_error_icon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "form_error_icon.pdf", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "form_error_icon_dark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/form_error_icon.imageset/form_error_icon.pdf b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/form_error_icon.imageset/form_error_icon.pdf new file mode 100644 index 00000000..99430af6 Binary files /dev/null and b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/form_error_icon.imageset/form_error_icon.pdf differ diff --git a/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/form_error_icon.imageset/form_error_icon_dark.pdf b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/form_error_icon.imageset/form_error_icon_dark.pdf new file mode 100644 index 00000000..954d0410 Binary files /dev/null and b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/form_error_icon.imageset/form_error_icon_dark.pdf differ diff --git a/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/icon_chevron_down.imageset/Contents.json b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/icon_chevron_down.imageset/Contents.json new file mode 100644 index 00000000..82cc3c22 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/icon_chevron_down.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "chevronDown.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/icon_chevron_down.imageset/chevronDown.pdf b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/icon_chevron_down.imageset/chevronDown.pdf new file mode 100644 index 00000000..a7807cbd Binary files /dev/null and b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/icon_chevron_down.imageset/chevronDown.pdf differ diff --git a/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/icon_clear.imageset/Contents.json b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/icon_clear.imageset/Contents.json new file mode 100644 index 00000000..f5226705 --- /dev/null +++ b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/icon_clear.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "icon_clear.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon_clear@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "icon_clear@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/icon_clear.imageset/icon_clear.png b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/icon_clear.imageset/icon_clear.png new file mode 100644 index 00000000..25775d9d Binary files /dev/null and b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/icon_clear.imageset/icon_clear.png differ diff --git a/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/icon_clear.imageset/icon_clear@2x.png b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/icon_clear.imageset/icon_clear@2x.png new file mode 100644 index 00000000..4a330ee0 Binary files /dev/null and b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/icon_clear.imageset/icon_clear@2x.png differ diff --git a/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/icon_clear.imageset/icon_clear@3x.png b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/icon_clear.imageset/icon_clear@3x.png new file mode 100644 index 00000000..a5613ce8 Binary files /dev/null and b/StripeUICore/StripeUICore/Resources/StripeUICore.xcassets/icon_clear.imageset/icon_clear@3x.png differ diff --git a/StripeUICore/StripeUICore/Source/Categories/CALayer+StripeUICore.swift b/StripeUICore/StripeUICore/Source/Categories/CALayer+StripeUICore.swift new file mode 100644 index 00000000..ab502a34 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Categories/CALayer+StripeUICore.swift @@ -0,0 +1,26 @@ +// +// CALayer+StripeUICore.swift +// StripeUICore +// +// Created by Nick Porter on 3/16/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// +import Foundation +import QuartzCore +import UIKit + +@_spi(STP) public extension CALayer { + + func applyShadow(shadow: ElementsUITheme.Shadow?) { + guard let shadow = shadow else { + shadowOpacity = 0 + return + } + + shadowColor = shadow.color.cgColor + shadowOpacity = Float(shadow.opacity) + shadowOffset = shadow.offset + shadowRadius = CGFloat(shadow.radius) + } + +} diff --git a/StripeUICore/StripeUICore/Source/Categories/Enums+CustomStringConvertible.swift b/StripeUICore/StripeUICore/Source/Categories/Enums+CustomStringConvertible.swift new file mode 100644 index 00000000..16a78a03 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Categories/Enums+CustomStringConvertible.swift @@ -0,0 +1,7 @@ +// +// Enums+CustomStringConvertible.swift +// Stripe +// +// Autogenerated by generate_objc_enum_string_values.rb +// Copyright © 2021 Stripe, Inc. All rights reserved. +// diff --git a/StripeUICore/StripeUICore/Source/Categories/Locale+StripeUICore.swift b/StripeUICore/StripeUICore/Source/Categories/Locale+StripeUICore.swift new file mode 100644 index 00000000..64ff1dbf --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Categories/Locale+StripeUICore.swift @@ -0,0 +1,40 @@ +// +// Locale+StripeUICore.swift +// StripeUICore +// +// Created by Mel Ludowise on 9/29/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) public extension Locale { + /// Returns the given array of country/region codes sorted alphabetically by their localized display names + func sortedByTheirLocalizedNames( + _ regionCollection: [T], + thisRegionFirst: Bool = false + ) -> [T] { + var mutableRegionCollection = regionCollection + + // Pull out the current country if needed + var prepend: [T] = [] + if thisRegionFirst, + let regionCode = self.regionCode, + let index = regionCollection.firstIndex(where: { $0.regionCode == regionCode }) { + prepend = [mutableRegionCollection.remove(at: index)] + } + + // Convert to display strings, sort, then map back to value + mutableRegionCollection = mutableRegionCollection.map { ( + value: $0, + display: localizedString(forRegionCode: $0.regionCode) ?? $0.regionCode + ) }.sorted { + $0.display.compare($1.display, options: [.diacriticInsensitive, .caseInsensitive], locale: self) == .orderedAscending + }.map({ + $0.value + }) + + // Prepend current country if needed + return prepend + mutableRegionCollection + } +} diff --git a/StripeUICore/StripeUICore/Source/Categories/NSAttributedString+StripeUICore.swift b/StripeUICore/StripeUICore/Source/Categories/NSAttributedString+StripeUICore.swift new file mode 100644 index 00000000..f9072032 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Categories/NSAttributedString+StripeUICore.swift @@ -0,0 +1,20 @@ +// +// NSAttributedString+StripeUICore.swift +// StripeUICore +// +// Created by Nick Porter on 8/31/23. +// + +import Foundation +import UIKit + +extension NSAttributedString { + + /// Returns true if this attributed string has a text attachment + var hasTextAttachment: Bool { + return attributes(at: 0, longestEffectiveRange: nil, in: NSRange(location: 0, length: length)).contains(where: { (key, value) -> Bool in + return key == NSAttributedString.Key.attachment && value is NSTextAttachment + }) + } + +} diff --git a/StripeUICore/StripeUICore/Source/Categories/NSDirectionalEdgeInsets+StripeUICore.swift b/StripeUICore/StripeUICore/Source/Categories/NSDirectionalEdgeInsets+StripeUICore.swift new file mode 100644 index 00000000..6f022546 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Categories/NSDirectionalEdgeInsets+StripeUICore.swift @@ -0,0 +1,19 @@ +// +// NSDirectionalEdgeInsets+StripeUICore.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 6/14/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) public extension NSDirectionalEdgeInsets { + static func insets(top: CGFloat = 0, leading: CGFloat = 0, bottom: CGFloat = 0, trailing: CGFloat = 0) -> Self { + return .init(top: top, leading: leading, bottom: bottom, trailing: trailing) + } + + static func insets(amount: CGFloat) -> Self { + return .init(top: amount, leading: amount, bottom: amount, trailing: amount) + } +} diff --git a/StripeUICore/StripeUICore/Source/Categories/UIBarButtonItem+StripeUICore.swift b/StripeUICore/StripeUICore/Source/Categories/UIBarButtonItem+StripeUICore.swift new file mode 100644 index 00000000..ae58c527 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Categories/UIBarButtonItem+StripeUICore.swift @@ -0,0 +1,21 @@ +// +// UIBarButtonItem+StripeUICore.swift +// StripeUICore +// +// Created by Ramon Torres on 10/4/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) public extension UIBarButtonItem { + + /// Creates a new flexible width space item. + /// + /// Backport for iOS < 14.0 + /// - Returns: A flexible-width space UIBarButtonItem. + class func flexibleSpace() -> Self { + return .init(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + } + +} diff --git a/StripeUICore/StripeUICore/Source/Categories/UIButton+StripeUICore.swift b/StripeUICore/StripeUICore/Source/Categories/UIButton+StripeUICore.swift new file mode 100644 index 00000000..8653098e --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Categories/UIButton+StripeUICore.swift @@ -0,0 +1,50 @@ +// +// UIButton+StripeUICore.swift +// StripeUICore +// +// Created by Cameron Sabol on 2/17/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) public extension UIButton { + static let minimumTapSize: CGSize = CGSize(width: 44, height: 44) + + class var doneButtonTitle: String { + return STPLocalizedString("Done", "Done button title") + } + + class var editButtonTitle: String { + return STPLocalizedString("Edit", "Button title to enter editing mode") + } + + /// A helper method that returns a UIButton that: + /// 1. Retains the provided `didTap` closure and calls it when the button is tapped. + /// 2. Expands the tap target area to be 44x44 + class func make(type buttonType: UIButton.ButtonType, didTap: @escaping () -> Void) -> UIButton { + class ClosureButton: UIButton { + var didTap: () -> Void = {} + public override init(frame: CGRect) { + super.init(frame: frame) + addTarget(self, action: #selector(didTapSelector), for: .touchUpInside) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + let newArea = bounds.insetBy( + dx: -(Self.minimumTapSize.width - bounds.width) / 2, + dy: -(Self.minimumTapSize.height - bounds.height) / 2) + return newArea.contains(point) + } + @objc func didTapSelector() { + didTap() + } + } + + let button = ClosureButton(type: buttonType) + button.didTap = didTap + return button + } +} diff --git a/StripeUICore/StripeUICore/Source/Categories/UIColor+StripeUICore.swift b/StripeUICore/StripeUICore/Source/Categories/UIColor+StripeUICore.swift new file mode 100644 index 00000000..1572c141 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Categories/UIColor+StripeUICore.swift @@ -0,0 +1,182 @@ +// +// UIColor+StripeUICore.swift +// StripeUICore +// +// Created by Ramon Torres on 11/8/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) public extension UIColor { + + /// Increases the brightness of the color by the given `amount`. + /// + /// The brightness of the resulting color will be clamped to a max value of`1.0`. + /// - Parameter amount: Adjustment amount (range: 0.0 - 1.0.) + /// - Returns: Adjusted color. + func lighten(by amount: CGFloat) -> UIColor { + return byModifyingBrightness { min($0 + amount, 1) } + } + + /// Decreases the brightness of the color by the given `amount`. + /// + /// The brightness of the resulting color will be clamped to a min value of`0.0`. + /// - Parameter amount: Adjustment amount (range: 0.0 - 1.0.) + /// - Returns: Adjusted color. + func darken(by amount: CGFloat) -> UIColor { + return byModifyingBrightness { max($0 - amount, 0) } + } + + static func dynamic(light: UIColor, dark: UIColor) -> UIColor { + return UIColor(dynamicProvider: { (traitCollection) in + switch traitCollection.userInterfaceStyle { + case .light, .unspecified: + return light + case .dark: + return dark + @unknown default: + return light + } + }) + } + + /// The relative luminance of the color. + /// + /// # Reference + /// + /// * [Relative Luminance](https://en.wikipedia.org/wiki/Relative_luminance) + /// * [WCAG 2.2 specification](https://www.w3.org/TR/WCAG21/#dfn-relative-luminance) + /// + var luminance: CGFloat { + var sr: CGFloat = 0 + var sg: CGFloat = 0 + var sb: CGFloat = 0 + + // get the (extended) sRGB components + getRed(&sr, green: &sg, blue: &sb, alpha: nil) + + // Convert from sRGB to linear RGB + let r = sr < 0.04045 ? sr / 12.92 : pow((sr + 0.055) / 1.055, 2.4) + let g = sg < 0.04045 ? sg / 12.92 : pow((sg + 0.055) / 1.055, 2.4) + let b = sb < 0.04045 ? sb / 12.92 : pow((sb + 0.055) / 1.055, 2.4) + + // Calculate luminance (Y) + let y = r * 0.2126 + g * 0.7152 + b * 0.0722 + + return min(max(y, 0), 1) + } + + /// Calculates the contrast ratio to another color as defined by WCAG 2.1. + /// + /// The resulting ratios can range from 1 to 21. + /// + /// # Reference + /// + /// [WCAG 2.1 Contrast Ratio spec](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html#dfn-contrast-ratio) + /// + /// - Parameter other: Color to calculate the contrast against. + /// - Returns: Contrast ratio. + func contrastRatio(to other: UIColor) -> CGFloat { + let luminanceA = self.luminance + let luminanceB = other.luminance + return (max(luminanceA, luminanceB) + 0.05) / (min(luminanceA, luminanceB) + 0.05) + } + + /// Returns a contrasting color to this color + /// - Returns: Either white or black color depending on which will contrast best with this color + var contrastingColor: UIColor { + let contrastRatioToWhite = contrastRatio(to: .white) + let contrastRatioToBlack = contrastRatio(to: .black) + + let isDarkMode = UITraitCollection.current.isDarkMode + + // Prefer using a white foreground as long as a minimum contrast threshold is met. + // Factor the container color to compensate for "local adaptation". + // https://github.com/w3c/wcag/issues/695 + let threshold: CGFloat = isDarkMode ? 3.6 : 2.2 + if contrastRatioToWhite > threshold { + return .white + } + + // Pick the foreground color that offers the best contrast ratio + return contrastRatioToWhite > contrastRatioToBlack ? .white : .black + } + + /// Returns this color in a "disabled" state by reducing the alpha by 60% + var disabledColor: UIColor { + let (_, _, _, alpha) = rgba + return self.withAlphaComponent(alpha * 0.4) + } + + /// Returns this color in a "disabled" state by reducing the alpha by 40% if `isDisabled` is `true`, + /// or the original color if `false`. + func disabled(_ isDisabled: Bool = true) -> UIColor { + guard isDisabled else { return self } + + return disabledColor + } + + /// The rgba space of the color + var rgba: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + return (red, green, blue, alpha) + } + + var perceivedBrightness: CGFloat { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + if getRed(&red, green: &green, blue: &blue, alpha: nil) { + // We're using the luma value from YIQ + // https://en.wikipedia.org/wiki/YIQ#From_RGB_to_YIQ + // recommended by https://www.w3.org/WAI/ER/WD-AERT/#color-contrast + return red * CGFloat(0.299) + green * CGFloat(0.587) + blue * CGFloat(0.114) + } else { + // Couldn't get RGB for this color, device couldn't convert it from whatever + // colorspace it's in. + // Make it "bright", since most of the color space is (based on our current + // formula), but not very bright. + return CGFloat(0.4) + } + } + + var isBright: Bool { perceivedBrightness > 0.3 } + + var isDark: Bool { !isBright } +} + +// MARK: - Helpers + +private extension UIColor { + + /// Transforms the brightness and returns the resulting color. + /// + /// - Parameter transform: A block for transforming the brightness. + /// - Returns: Updated color. + func byModifyingBrightness(_ transform: @escaping (CGFloat) -> CGFloat) -> UIColor { + // Similar to `UIColor.withAlphaComponent()`, the returned color must be dynamic. This ensures + // that the color automatically adapts between light and dark mode. + return UIColor(dynamicProvider: { _ in + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + + self.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + + return UIColor( + hue: hue, + saturation: saturation, + brightness: transform(brightness), + alpha: alpha + ) + }) + } + +} diff --git a/StripeUICore/StripeUICore/Source/Categories/UIFont+StripeUICore.swift b/StripeUICore/StripeUICore/Source/Categories/UIFont+StripeUICore.swift new file mode 100644 index 00000000..cd842dd5 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Categories/UIFont+StripeUICore.swift @@ -0,0 +1,31 @@ +// +// UIFont+StripeUICore.swift +// StripeUICore +// +// Created by Ramon Torres on 11/8/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) public extension UIFont { + + func scaled( + withTextStyle textStyle: UIFont.TextStyle, + maximumPointSize: CGFloat? = nil, + compatibleWith traitCollection: UITraitCollection? = nil + ) -> UIFont { + let fontMetrics = UIFontMetrics(forTextStyle: textStyle) + + if let maximumFontSize = maximumPointSize { + return fontMetrics.scaledFont( + for: self, + maximumPointSize: maximumFontSize, + compatibleWith: traitCollection + ) + } + + return fontMetrics.scaledFont(for: self, compatibleWith: traitCollection) + } + +} diff --git a/StripeUICore/StripeUICore/Source/Categories/UIKeyboardType+StripeUICore.swift b/StripeUICore/StripeUICore/Source/Categories/UIKeyboardType+StripeUICore.swift new file mode 100644 index 00000000..4d8de8e3 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Categories/UIKeyboardType+StripeUICore.swift @@ -0,0 +1,33 @@ +// +// UIKeyboardType+StripeUICore.swift +// StripeUICore +// +// Created by Mel Ludowise on 10/11/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +extension UIKeyboardType { + /// Whether this keyboard has a return key + var hasReturnKey: Bool { + switch self { + case .default, + .asciiCapable, + .numbersAndPunctuation, + .URL, + .namePhonePad, + .emailAddress, + .webSearch: + return true + case .numberPad, + .phonePad, + .decimalPad, + .twitter, + .asciiCapableNumberPad: + return false + @unknown default: + return true + } + } +} diff --git a/StripeUICore/StripeUICore/Source/Categories/UISpringTimingParameters+StripeUICore.swift b/StripeUICore/StripeUICore/Source/Categories/UISpringTimingParameters+StripeUICore.swift new file mode 100644 index 00000000..41c0a594 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Categories/UISpringTimingParameters+StripeUICore.swift @@ -0,0 +1,22 @@ +// +// UISpringTimingParameters+StripeUICore.swift +// StripeUICore +// +// Created by David Estes on 1/19/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) public extension UISpringTimingParameters { + convenience init(mass: CGFloat, dampingRatio: CGFloat, frequencyResponse: CGFloat) { + // h/t https://medium.com/ios-os-x-development/demystifying-uikit-spring-animations-2bb868446773 + let stiffness: CGFloat = pow(2 * .pi / frequencyResponse, 2) * mass + let damping: CGFloat = 4 * .pi * dampingRatio * mass / frequencyResponse + self.init( + mass: mass, + stiffness: stiffness, + damping: damping, + initialVelocity: .zero) + } +} diff --git a/StripeUICore/StripeUICore/Source/Categories/UIStackView+StripeUICore.swift b/StripeUICore/StripeUICore/Source/Categories/UIStackView+StripeUICore.swift new file mode 100644 index 00000000..4ad31dcd --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Categories/UIStackView+StripeUICore.swift @@ -0,0 +1,122 @@ +// +// UIStackView+StripeUICore.swift +// StripeUICore +// +// Created by Ramon Torres on 10/22/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +// MARK: - Animation utilities + +@_spi(STP) public extension UIStackView { + + /// Hides an arranged subview with optional animation. + /// + /// - Parameters: + /// - index: The index of the arranged subview to hide. + /// - animated: Whether or not to animate the transition. + func showArrangedSubview(at index: Int, animated: Bool) { + let view = arrangedSubviews[index] + toggleArrangedSubview(view, shouldShow: true, animated: animated) + } + + /// Hides an arranged subview with optional animation. + /// + /// - Parameters: + /// - index: The index of the arranged subview to hide. + /// - animated: Whether or not to animate the transition. + func hideArrangedSubview(at index: Int, animated: Bool) { + let view = arrangedSubviews[index] + toggleArrangedSubview(view, shouldShow: false, animated: animated) + } + + /// Toggles the visibility of an arranged subview with optional animation. + /// + /// - Parameters: + /// - view: Arranged subview to update. + /// - shouldShow: Whether or not to show the view. + /// - animated: Whether or not to animate the transition. + func toggleArrangedSubview(_ view: UIView, shouldShow: Bool, animated: Bool) { + toggleArrangedSubviews([view], shouldShow: shouldShow, animated: animated) + } + + /// Removes an arranged subview at a given index. + /// + /// - Parameters: + /// - index: The index of the arranged subview to be removed. + /// - animated: Whether or not to animate the removal. + /// - completion: A block to be called after removing the view. + func removeArrangedSubview(at index: Int, animated: Bool, completion: (() -> Void)? = nil) { + removeArrangedSubview(arrangedSubviews[index], animated: animated, completion: completion) + } + + /// Removes the provided view from the arranged subviews with an animation. + /// + /// - Parameters: + /// - view: The view to be removed from the array of views arranged by the stack. + /// - animated: Whether or not to animate the removal. + /// - completion: A block to be called after removing the view. + func removeArrangedSubview(_ view: UIView, animated: Bool, completion: (() -> Void)? = nil) { + toggleArrangedSubviews([view], shouldShow: false, animated: animated) { _ in + view.removeFromSuperview() + view.isHidden = false + view.alpha = 1 + completion?() + } + } + + // MARK: - Helpers + + /// Toggles the visibility of arranged subviews with animation. + /// + /// This method enhances the default constraint based animation by adding fade-in/out as + /// secondary action. Making the animation more correct. + /// + /// - Parameters: + /// - views: The arranged subviews to be toggled. + /// - shouldShow: Whether or not it should show the views. + /// - animated: Whether or not to animate the transition. + /// - completion: A block to be called when the animation finishes. + func toggleArrangedSubviews( + _ views: [UIView], + shouldShow: Bool, + animated: Bool, + completion: ((Bool) -> Void)? = nil + ) { + let viewsToUpdate = views.filter { $0.isHidden == shouldShow } + + if animated { + let outTransform = CGAffineTransform(translationX: 0, y: -10) + + viewsToUpdate.forEach { view in + view.isHidden = shouldShow + view.alpha = shouldShow ? 0 : 1 + view.transform = shouldShow ? outTransform : .identity + } + + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) { + viewsToUpdate.forEach { view in + view.isHidden = !shouldShow + view.alpha = shouldShow ? 1 : 0 + view.transform = shouldShow ? .identity : outTransform + } + + self.setNeedsLayout() + self.layoutIfNeeded() + } completion: { done in + viewsToUpdate.forEach { view in + view.transform = .identity + } + + completion?(done) + } + } else { + viewsToUpdate.forEach { view in + view.isHidden = !shouldShow + } + } + } + +} diff --git a/StripeUICore/StripeUICore/Source/Categories/UITraitCollection+StripeUICore.swift b/StripeUICore/StripeUICore/Source/Categories/UITraitCollection+StripeUICore.swift new file mode 100644 index 00000000..44f196e9 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Categories/UITraitCollection+StripeUICore.swift @@ -0,0 +1,17 @@ +// +// UITraitCollection+StripeUICore.swift +// StripeUICore +// +// Created by Ramon Torres on 10/3/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) public extension UITraitCollection { + + var isDarkMode: Bool { + return userInterfaceStyle == .dark + } + +} diff --git a/StripeUICore/StripeUICore/Source/Categories/UIView+StripeUICore.swift b/StripeUICore/StripeUICore/Source/Categories/UIView+StripeUICore.swift new file mode 100644 index 00000000..28e4b157 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Categories/UIView+StripeUICore.swift @@ -0,0 +1,94 @@ +// +// UIView+StripeUICore.swift +// StripeUICore +// +// Created by Mel Ludowise on 9/16/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +@_spi(STP) public extension UIView { + /// - Note: This variant of `addAndPinSubview` respects the view's `directionalLayoutMargins` property. + /// This is useful if your margins can change dynamically. + func addAndPinSubview(_ view: UIView, directionalLayoutMargins: NSDirectionalEdgeInsets) { + self.directionalLayoutMargins = directionalLayoutMargins + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + view.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + view.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + view.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + ]) + } + + func addAndPinSubview(_ view: UIView, insets: NSDirectionalEdgeInsets = .zero) { + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: topAnchor, constant: insets.top), + view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -insets.bottom), + view.leadingAnchor.constraint(equalTo: leadingAnchor, constant: insets.leading), + view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -insets.trailing), + ]) + } + + func addAndPinSubviewToSafeArea(_ view: UIView, insets: NSDirectionalEdgeInsets = .zero) { + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: insets.top), + view.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -insets.bottom), + view.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: insets.leading), + view.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -insets.trailing), + ]) + } + + /// Animates changes to one or more views alongside the keyboard. + /// + /// - Parameters: + /// - notification: Keyboard change notification. + /// - animations: A block containing the changes to commit to the views. + static func animateAlongsideKeyboard( + _ notification: Notification, + animations: @escaping () -> Void + ) { + let userInfo = notification.userInfo + + guard let duration = userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double else { + animations() + return + } + + // Get keyboard animation info + // TODO(ramont): extract animation curve from `keyboardAnimationCurveUserInfoKey` + // (see: http://www.openradar.me/42609976) + let curve = UIView.AnimationCurve.easeOut + + // Animate the container above the keyboard + // Note: We prefer UIViewPropertyAnimator over UIView.animate because it handles consecutive animation calls better. Sometimes this happens when one text field resigns and another immediately becomes first responder. + let animator = UIViewPropertyAnimator(duration: duration, curve: curve) { + animations() + } + animator.startAnimation() + } + + // Don't set isHidden redundantly or you might hit a bug: http://www.openradar.me/25087688 + func setHiddenIfNecessary(_ shouldHide: Bool) { + if isHidden != shouldHide { + isHidden = shouldHide + } + } + + func firstResponder() -> UIView? { + for subview in subviews { + if let firstResponder = subview.firstResponder() { + return firstResponder + } + } + return isFirstResponder ? self : nil + } +} diff --git a/StripeUICore/StripeUICore/Source/Categories/UIViewController+StripeUICore.swift b/StripeUICore/StripeUICore/Source/Categories/UIViewController+StripeUICore.swift new file mode 100644 index 00000000..e34a1147 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Categories/UIViewController+StripeUICore.swift @@ -0,0 +1,53 @@ +// +// UIViewController+StripeUICore.swift +// StripeUICore +// +// Created by Mel Ludowise on 9/16/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) public extension UIViewController { + /// Use this to animate changes that affect the height of the sheet + func animateHeightChange(forceAnimation: Bool = false, duration: CGFloat = 0.5, _ animations: (() -> Void)? = nil, completion: ((Bool) -> Void)? = nil) + { + guard forceAnimation || !isBeingPresented else { + animations?() + return + } + // Note: For unknown reasons, using `UIViewPropertyAnimator` here caused an infinite layout loop + UIView.animate( + withDuration: duration, + delay: 0, + usingSpringWithDamping: 1, + initialSpringVelocity: 0, + options: [], + animations: { + animations?() + self.rootParent.presentationController?.containerView?.layoutIfNeeded() + }, completion: { f in + completion?(f) + } + ) + } + + var rootParent: UIViewController { + if let parent = parent { + return parent.rootParent + } + return self + } + + /// Walks the presented view controller hierarchy and return the top most presented controller. + /// - Returns: Returns the top most presented view controller, or `nil` if this view controller is not presenting another controller. + func findTopMostPresentedViewController() -> UIViewController? { + var topMostController = self.presentedViewController + + while let presented = topMostController?.presentedViewController { + topMostController = presented + } + + return topMostController + } +} diff --git a/StripeUICore/StripeUICore/Source/Categories/UIWindow+StripeUICore.swift b/StripeUICore/StripeUICore/Source/Categories/UIWindow+StripeUICore.swift new file mode 100644 index 00000000..2b4743c5 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Categories/UIWindow+StripeUICore.swift @@ -0,0 +1,20 @@ +// +// UIWindow+Stripe.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 2/3/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) public extension UIWindow { + + /// Returns the top most presented view controller including the root view controller. + /// - Returns: The top most view controller, or `nil` if the window has no root view controller. + func findTopMostPresentedViewController() -> UIViewController? { + return self.rootViewController?.findTopMostPresentedViewController() + ?? self.rootViewController + } + +} diff --git a/StripeUICore/StripeUICore/Source/Controls/ActivityIndicator.swift b/StripeUICore/StripeUICore/Source/Controls/ActivityIndicator.swift new file mode 100644 index 00000000..afd7a418 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Controls/ActivityIndicator.swift @@ -0,0 +1,263 @@ +// +// ActivityIndicator.swift +// StripeUICore +// +// Created by Ramon Torres on 12/3/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +/// A custom replacement for `UIActivityIndicatorView`. +/// For internal SDK use only +@objc(STP_Internal_ActivityIndicator) +@_spi(STP) public final class ActivityIndicator: UIView { + + #if DEBUG + /// Disables animation. This should be only be modified for snapshot tests. + public static var isAnimationEnabled = true + #endif + + struct Constants { + /// Animation speed in revolutions per second + static let animationSpeed: Double = 1.8 + static let animationKey: String = "spin" + } + + /// Size of the activity indicator. + public enum Size { + /// The default size of an activity indicator (20x20). + case medium + /// A large activity indicator (37x37). + case large + } + + /// If `true`, the activity indicator will hide itself when not animating. + public var hidesWhenStopped: Bool = true { + didSet { + if hidesWhenStopped { + updateVisibility() + } else { + isHidden = false + } + } + } + + /// The color of the activity indicator. + public var color: UIColor { + get { + return tintColor + } + set { + tintColor = newValue + } + } + + public private(set) var isAnimating: Bool = false + + private let size: Size + + private var radius: CGFloat { + switch size { + case .medium: + return 8 + case .large: + return 14.5 + } + } + + private var thickness: CGFloat { + switch size { + case .medium: + return 2 + case .large: + return 4 + } + } + + private lazy var cometLayer: CAGradientLayer = { + let shape = CAShapeLayer() + shape.path = makeArcPath(radius: radius, startAngle: 0.05, endAngle: 0.95) + shape.lineWidth = thickness + shape.lineCap = .round + shape.strokeColor = UIColor.black.cgColor + shape.fillColor = UIColor.clear.cgColor + + let gradientLayer = CAGradientLayer() + gradientLayer.type = .conic + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) + gradientLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5) + gradientLayer.contentsGravity = .center + gradientLayer.mask = shape + return gradientLayer + }() + + private var contentLayer: CALayer { + return cometLayer + } + + public override var intrinsicContentSize: CGSize { + let size: CGFloat = (radius + thickness) * 2 + return CGSize(width: size, height: size) + } + + public convenience init() { + self.init(size: .medium) + } + + /// Creates a new activity indicator. + /// - Parameter size: Size of the activity indicator. + public init(size: Size) { + self.size = size + super.init(frame: .zero) + layer.addSublayer(contentLayer) + + setContentHuggingPriority(.defaultHigh, for: .horizontal) + setContentHuggingPriority(.defaultHigh, for: .vertical) + + updateVisibility() + updateColor() + + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationWillEnterForeground(_:)), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func tintColorDidChange() { + super.tintColorDidChange() + updateColor() + } + + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + updateColor() + } + + public override func layoutSubviews() { + super.layoutSubviews() + + CATransaction.begin() + // `bounds` and `position` are both animatable. Disable actions to turn off + // implicit animations when updating them. + CATransaction.setDisableActions(true) + + contentLayer.bounds = CGRect(origin: .zero, size: intrinsicContentSize) + contentLayer.position = CGPoint(x: bounds.midX, y: bounds.midY) + + CATransaction.commit() + } + + public override func willMove(toWindow newWindow: UIWindow?) { + super.willMove(toWindow: newWindow) + + if let window = newWindow { + contentLayer.shouldRasterize = true + contentLayer.rasterizationScale = window.screen.scale + } + + if isAnimating { + startAnimating() + } else { + stopAnimating() + } + } +} + +// MARK: - Methods + +public extension ActivityIndicator { + + func startAnimating() { + defer { + isAnimating = true + updateVisibility() + } + + #if DEBUG + guard ActivityIndicator.isAnimationEnabled else { return } + #endif + + let rotatingAnimation = CABasicAnimation(keyPath: "transform.rotation.z") + rotatingAnimation.byValue = 2 * Float.pi + rotatingAnimation.duration = 1 / Constants.animationSpeed + rotatingAnimation.isAdditive = true + rotatingAnimation.repeatCount = .infinity + contentLayer.add(rotatingAnimation, forKey: Constants.animationKey) + } + + func stopAnimating() { + contentLayer.removeAnimation(forKey: Constants.animationKey) + + isAnimating = false + updateVisibility() + } +} + +// MARK: - Private methods + +private extension ActivityIndicator { + + func updateColor() { + // Tint color gradient from 0% to 100% alpha + cometLayer.colors = [ + tintColor.withAlphaComponent(0).cgColor, + tintColor.cgColor, + ] + } + + @objc + func applicationWillEnterForeground(_ notification: Notification) { + if isAnimating { + // Resume animations + startAnimating() + } + } + + func updateVisibility() { + if hidesWhenStopped { + isHidden = !isAnimating + } + } + + /// Creates an path containing an arc shape of a given radius and angles. + /// + /// Angles must be expressed in turns. + /// + /// + /// + /// - Parameters: + /// - radius: Arc radius. + /// - startAngle: Start angle. + /// - endAngle: End angle. + /// - Returns: Arc path. + func makeArcPath(radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat) -> CGPath { + let path = CGMutablePath() + + let center = CGPoint( + x: intrinsicContentSize.width / 2, + y: intrinsicContentSize.height / 2 + ) + + path.addArc( + center: center, + radius: radius, + startAngle: CGFloat.pi * 2 * startAngle, + endAngle: CGFloat.pi * 2 * endAngle, + clockwise: false + ) + + return path + } +} diff --git a/StripeUICore/StripeUICore/Source/Controls/Button.swift b/StripeUICore/StripeUICore/Source/Controls/Button.swift new file mode 100644 index 00000000..c8d289d1 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Controls/Button.swift @@ -0,0 +1,557 @@ +// +// Button.swift +// StripeUICore +// +// Created by Ramon Torres on 11/7/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +import UIKit + +/// The custom button used throughout the Stripe SDK. +/// For internal SDK use only +@objc(STP_Internal_Button) +@_spi(STP) public class Button: UIControl { + struct Constants { + // TODO(ramont): move to `Configuration` + static let minTitleLabelHeight: CGFloat = 24 + static let minItemSpacing: CGFloat = 8 + } + + /// Configuration for the button appearance. + /// + /// Most of the time you should use one of the built-in configurations such as `.primary()` or `.secondary()`. For + /// one-off customizations, you can modify the button's configuration once it has been instantiated, as follows: + /// + /// ``` + /// let myButton = Button(configuration: .secondary(), title: "Cancel") + /// myButton.configuration.cornerRadius = 4 + /// ``` + /// + /// If you find yourself applying the same customizations multiple times, you should consider creating a reusable configuration. To create + /// one, simply add an extension and implement a static method that returns the custom configuration: + /// + /// ``` + /// extension Button.Configuration { + /// static func panicButton() -> Self { + /// let configuration: Button.Configuration = .primary() + /// configuration.font = .boldSytemFont(ofSize: 32) + /// configuration.insets = .init(top: 16, leading: 16, bottom: 16, trailing: 16) + /// configuration.cornerRadius = 4 + /// configuration.backgroundColor = .systemRed + /// return configuration + /// } + /// } + /// ``` + /// + /// Then use it the same way you would use any of the built-in configurations: + /// + /// ``` + /// let button = Button(configuration: .panicButton(), title: "Cancel") + /// ``` + public struct Configuration { + /// A special color value that resolves to the button's tint color at runtime. + /// + /// This is equivalent to `UIColor.tintColor` in iOS 15, except that it only works + /// within the `Button` component and relies on identity comparison (`===`). Ideally we will + /// backport `UIColor.tintColor`, but this is not currently possible due to its reliance on + /// private APIs. + public static let tintColor: UIColor = .init(red: 0, green: 0.5, blue: 1, alpha: 1) + + public var font: UIFont = .preferredFont(forTextStyle: .body, weight: .medium) + public var cornerRadius: CGFloat = 10 + + public var borderWidth: CGFloat = 0 + + // Normal state + public var foregroundColor: UIColor? + public var backgroundColor: UIColor? + public var borderColor: UIColor? + + // Disabled state + public var disabledForegroundColor: UIColor? + public var disabledBackgroundColor: UIColor? + public var disabledBorderColor: UIColor? + + // Color transforms + public var colorTransforms: ColorTransformConfiguration = .init() + + /// Attributes to automatically apply to the title. + public var titleAttributes: [NSAttributedString.Key: Any]? + + public var insets: NSDirectionalEdgeInsets = .init(top: 10, leading: 10, bottom: 10, trailing: 10) + } + + public struct ColorTransformConfiguration { + public var disabledForeground: ColorTransform? + public var disabledBackground: ColorTransform? + public var disabledBorder: ColorTransform? + public var highlightedForeground: ColorTransform? + public var highlightedBackground: ColorTransform? + public var highlightedBorder: ColorTransform? + } + + public enum ColorTransform { + case darken(amount: CGFloat) + case lighten(amount: CGFloat) + case setAlpha(amount: CGFloat) + } + + /// Position of the icon. + public enum IconPosition { + /// Leading edge of the button. + case leading + /// Trailing edge of the button. + case trailing + } + + struct CustomStates { + static let loading = State(rawValue: 1 << 16) + } + + public override var state: UIControl.State { + var state = super.state + + if isLoading { + state.insert(CustomStates.loading) + } + + return state + } + + public override var intrinsicContentSize: CGSize { + var contentHeight: CGFloat { + return max( + iconView.intrinsicContentSize.height, + titleLabel.intrinsicContentSize.height, + Constants.minTitleLabelHeight + ) + } + + let height = ( + directionalLayoutMargins.top + + contentHeight + + directionalLayoutMargins.bottom + ) + + return CGSize( + width: UIView.noIntrinsicMetric, + height: height + ) + } + + public override var isEnabled: Bool { + didSet { + updateColors() + updateAccessibilityContent() + } + } + + public override var isHighlighted: Bool { + didSet { + updateColors() + } + } + + public var configuration: Configuration { + didSet { + configurationDidChange(oldValue) + } + } + + public var icon: UIImage? { + didSet { + iconView.image = icon + + let shouldHideIconView = icon == nil + if iconView.isHidden != shouldHideIconView { + iconView.isHidden = shouldHideIconView + setNeedsUpdateConstraints() + } + } + } + + public var iconPosition: IconPosition = .leading { + didSet { + if iconPosition != oldValue { + setNeedsUpdateConstraints() + } + } + } + + public var title: String? { + didSet { + updateTitle() + updateAccessibilityContent() + } + } + + public var isLoading: Bool = false { + didSet { + if isLoading { + iconView.alpha = 0 + titleLabel.alpha = 0 + isUserInteractionEnabled = false + activityIndicator.startAnimating() + } else { + iconView.alpha = 1 + titleLabel.alpha = 1 + isUserInteractionEnabled = true + activityIndicator.stopAnimating() + } + } + } + + /// Whether or not the button should automatically update its font when the device's content size category changes. + public var adjustsFontForContentSizeCategory: Bool { + get { + return titleLabel.adjustsFontForContentSizeCategory + } + set { + titleLabel.adjustsFontForContentSizeCategory = newValue + } + } + + private let titleLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let iconView: UIImageView = { + let iconView = UIImageView() + iconView.translatesAutoresizingMaskIntoConstraints = false + iconView.isHidden = true + return iconView + }() + + private lazy var activityIndicator: ActivityIndicator = { + let activityIndicator = ActivityIndicator() + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + return activityIndicator + }() + + private var dynamicConstraints: [NSLayoutConstraint] = [] + + /// Creates a button with the default configuration. + public convenience init() { + self.init(configuration: .primary()) + } + + /// Creates a button with the default configuration and the given title. + /// - Parameter title: Button title. + public convenience init(title: String) { + self.init(configuration: .primary(), title: title) + } + + /// Creates a button with the specified configuration. + /// - Parameters: + /// - configuration: Button configuration. + public convenience init(configuration: Configuration) { + self.init(configuration: configuration, title: nil) + } + + /// Creates a button with the specified configuration and title. + /// - Parameters + /// - configuration: Button configuration. + /// - title: Button title. + public init(configuration: Configuration, title: String?) { + self.configuration = configuration + self.title = title + super.init(frame: .zero) + + isAccessibilityElement = true + accessibilityTraits = .button + + setup() + configurationDidChange(nil) + updateAccessibilityContent() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup() { + addSubview(activityIndicator) + addSubview(iconView) + addSubview(titleLabel) + + NSLayoutConstraint.activate([ + // Center label + titleLabel.centerXAnchor.constraint(equalTo: layoutMarginsGuide.centerXAnchor), + titleLabel.centerYAnchor.constraint(equalTo: layoutMarginsGuide.centerYAnchor), + + // Center activity indicator + activityIndicator.centerXAnchor.constraint(equalTo: centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + public override func tintColorDidChange() { + super.tintColorDidChange() + updateColors() + } + + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + invalidateIntrinsicContentSize() + updateColors() + } + + public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return bounds.contains(point) ? self : nil + } +} + +public extension Button { + + override func updateConstraints() { + if !dynamicConstraints.isEmpty { + NSLayoutConstraint.deactivate(dynamicConstraints) + dynamicConstraints.removeAll() + } + + let shouldShowIconView = icon != nil + + if shouldShowIconView { + // Center icon vertically + dynamicConstraints.append( + iconView.centerYAnchor.constraint(equalTo: layoutMarginsGuide.centerYAnchor) + ) + + switch iconPosition { + case .leading: + dynamicConstraints.append(contentsOf: [ + iconView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + iconView.trailingAnchor.constraint( + lessThanOrEqualTo: titleLabel.leadingAnchor, + constant: Constants.minItemSpacing + ), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: layoutMarginsGuide.trailingAnchor), + ]) + case .trailing: + dynamicConstraints.append(contentsOf: [ + titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: layoutMarginsGuide.leadingAnchor), + titleLabel.trailingAnchor.constraint( + lessThanOrEqualTo: iconView.leadingAnchor, + constant: Constants.minItemSpacing + ), + iconView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + ]) + } + } else { + // Pin the leading and trailing edges of the label to the edges of the button. + dynamicConstraints.append(contentsOf: [ + titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: layoutMarginsGuide.leadingAnchor), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: layoutMarginsGuide.trailingAnchor), + ]) + } + + NSLayoutConstraint.activate(dynamicConstraints) + + // `super.updateConstraints()` must be called as the final step, as + // suggested by the documentation. + super.updateConstraints() + } + +} + +private extension Button { + + func configurationDidChange(_ previousConfiguration: Configuration?) { + titleLabel.font = configuration.font + layer.cornerRadius = configuration.cornerRadius + layer.borderWidth = configuration.borderWidth + directionalLayoutMargins = configuration.insets + + updateColors() + updateTitle() + + if configuration.shouldInvalidateIntrinsicContentSize(previousConfiguration) { + invalidateIntrinsicContentSize() + } + } + + func updateColors() { + let color = foregroundColor(for: state) + + titleLabel.textColor = color + iconView.tintColor = color + activityIndicator.tintColor = color + + backgroundColor = backgroundColor(for: state) + layer.borderColor = borderColor(for: state)?.cgColor + } + + func updateTitle() { + if let title = title, + let attributes = configuration.titleAttributes { + titleLabel.attributedText = NSAttributedString(string: title, attributes: attributes) + } else { + titleLabel.text = title + } + } + + func foregroundColor(for state: State) -> UIColor? { + switch state { + case .disabled: + return resolveColor( + baseColor: configuration.foregroundColor, + preferredColor: configuration.disabledForegroundColor, + transform: configuration.colorTransforms.disabledForeground + ) + case .highlighted: + return resolveColor( + baseColor: configuration.foregroundColor, + transform: configuration.colorTransforms.highlightedForeground + ) + default: + return resolveColor(baseColor: configuration.foregroundColor) + } + } + + func backgroundColor(for state: State) -> UIColor? { + switch state { + case .disabled: + return resolveColor( + baseColor: configuration.backgroundColor, + preferredColor: configuration.disabledBackgroundColor, + transform: configuration.colorTransforms.disabledBackground + ) + case .highlighted: + return resolveColor( + baseColor: configuration.backgroundColor, + transform: configuration.colorTransforms.highlightedBackground + ) + default: + return resolveColor(baseColor: configuration.backgroundColor) + } + } + + func borderColor(for state: State) -> UIColor? { + switch state { + case .disabled: + return resolveColor( + baseColor: configuration.borderColor, + preferredColor: configuration.disabledBorderColor, + transform: configuration.colorTransforms.disabledBorder + ) + case .highlighted: + return resolveColor( + baseColor: configuration.borderColor, + transform: configuration.colorTransforms.highlightedBorder + ) + default: + return resolveColor(baseColor: configuration.borderColor) + } + } + + /// Determines the best color to use given a base color, a preferred color, and a color transform. + /// + /// If a preferred colors is provided, this method will always return the preferred color. Otherwise, the + /// method will return the base color with an optional color transformation applied to it. + /// + /// - Parameters: + /// - baseColor: Base (untransformed) color. + /// - preferredColor: Preferred color. + /// - transform: Optional color transform to apply to `baseColor`. + /// - Returns: Color to use. + func resolveColor( + baseColor: UIColor?, + preferredColor: UIColor? = nil, + transform: ColorTransform? = nil + ) -> UIColor? { + let resolveToTintColor: (UIColor?) -> UIColor? = { color in + return color === Configuration.tintColor ? self.tintColor : color + } + + if let preferredColor = preferredColor { + return resolveToTintColor(preferredColor) + } + + let color = resolveToTintColor(baseColor) + + switch transform { + case .setAlpha(let amount): + return color?.withAlphaComponent(amount) + case .darken(let amount): + return color?.darken(by: amount) + case .lighten(let amount): + return color?.lighten(by: amount) + case .none: + return color + } + } + + func updateAccessibilityContent() { + accessibilityLabel = title + + if isEnabled { + accessibilityTraits.remove(.notEnabled) + } else { + accessibilityTraits.insert(.notEnabled) + } + } +} + +public extension Button.Configuration { + /// The default button configuration. + static func primary() -> Self { + return .init( + foregroundColor: .white, + backgroundColor: Self.tintColor, + disabledBackgroundColor: .systemGray4, + colorTransforms: .init( + highlightedBackground: .darken(amount: 0.2) + ) + ) + } + + /// A less prominent button. + static func secondary() -> Self { + return .init( + foregroundColor: Self.tintColor, + backgroundColor: .secondarySystemFill, + disabledForegroundColor: .systemGray, + colorTransforms: .init( + highlightedBackground: .darken(amount: 0.2) + ) + ) + } + + /// A plain button. + static func plain() -> Self { + return .init( + font: .preferredFont(forTextStyle: .body, weight: .regular), + foregroundColor: Self.tintColor, + backgroundColor: .clear, + // Match the custom color of UIButton(style: .system) + disabledForegroundColor: .dynamic( + light: UIColor(white: 0.484669, alpha: 0.35), + dark: UIColor(white: 0.484669, alpha: 0.45) + ), + colorTransforms: .init( + highlightedForeground: .setAlpha(amount: 0.5) + ), + // Match the insets of UIButton(style: .system) + insets: .insets(top: 3, leading: 0, bottom: 3, trailing: 0) + ) + } + +} + +// MARK: - Configuration diffing + +extension Button.Configuration { + + func shouldInvalidateIntrinsicContentSize(_ previousConfiguration: Self?) -> Bool { + return ( + self.font != previousConfiguration?.font || + self.insets != previousConfiguration?.insets + ) + } + +} diff --git a/StripeUICore/StripeUICore/Source/Controls/OneTimeCodeTextField-TextStorage.swift b/StripeUICore/StripeUICore/Source/Controls/OneTimeCodeTextField-TextStorage.swift new file mode 100644 index 00000000..3bd0a6ab --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Controls/OneTimeCodeTextField-TextStorage.swift @@ -0,0 +1,217 @@ +// +// OneTimeCodeTextField-TextStorage.swift +// StripePaymentSheet +// +// Created by Ramon Torres on 3/29/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import UIKit + +extension OneTimeCodeTextField { + final class TextStorage { + var value: String = "" + + let capacity: Int + + var start: TextPosition { + return TextPosition(0) + } + + var end: TextPosition { + return TextPosition(value.count) + } + + /// Returns a range for placing the caret at the end of the content. + /// + /// A zero-length range is `UITextInput`'s way of representing the caret position. This property will + /// always return a zero-length range at the end of the content. + var endCaretRange: TextRange { + return TextRange(start: end, end: end) + } + + /// A range that covers from the beginning to the end of the content. + var extent: TextRange { + return TextRange(start: start, end: end) + } + + var isFull: Bool { + return value.count >= capacity + } + + private let allowedCharacters: CharacterSet = .init(charactersIn: "0123456789") + + init(capacity: Int) { + assert(capacity >= 0, "Cannot have a negative capacity") + self.capacity = max(capacity, 0) + } + + func insert(_ text: String, at range: TextRange) -> TextRange { + let sanitizedText = text.filter({ + $0.unicodeScalars.allSatisfy(allowedCharacters.contains(_:)) + }) + + value.replaceSubrange(range.stringRange(for: value), with: sanitizedText) + + if value.count > capacity { + // Truncate to capacity + value = String(value.prefix(capacity)) + } + + let newInsertionPoint = TextPosition(range._start.index + sanitizedText.count) + return TextRange(start: newInsertionPoint, end: newInsertionPoint) + } + + func delete(range: TextRange) -> TextRange { + value.removeSubrange(range.stringRange(for: value)) + return TextRange(start: range._start, end: range._start) + } + + func text(in range: TextRange) -> String? { + guard !range.isEmpty else { + return nil + } + + let stringRange = range.stringRange(for: value) + return String(value[stringRange]) + } + + /// Utility method for creating a text range. + /// + /// Returns `nil` if any of the given positions is out of bounds. + /// + /// - Parameters: + /// - start: Start position of the range. + /// - end: End position of the range. + /// - Returns: Text position. + func makeRange(from start: TextPosition, to end: TextPosition) -> TextRange? { + guard + extent.contains(start.index), + extent.contains(end.index) + else { + return nil + } + + return TextRange(start: start, end: end) + } + } +} + +// MARK: - UITextPosition + +extension OneTimeCodeTextField { + /// Represents a position within our text storage. + /// + /// For internal SDK use only + @objc(STP_Internal_OneTimeCodeTextField_TextPosition) + final class TextPosition: UITextPosition { + let index: Int + + init(_ index: Int) { + self.index = index + } + + override var description: String { + let props: [String] = [ + String(format: "%@: %p", NSStringFromClass(type(of: self)), self), + "index = \(String(describing: index))", + ] + return "<\(props.joined(separator: "; "))>" + } + + override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? TextPosition else { + return false + } + + return self.index == other.index + } + + func compare(_ otherPosition: TextPosition) -> ComparisonResult { + if index < otherPosition.index { + return .orderedAscending + } + + if index > otherPosition.index { + return .orderedDescending + } + + return .orderedSame + } + } + +} + +// MARK: - TextRange + +extension OneTimeCodeTextField { + /// A range within our text storage. + /// + /// For internal SDK use only. + @objc(STP_Internal_OneTimeCodeTextField_TextRange) + final class TextRange: UITextRange { + let _start: TextPosition + let _end: TextPosition + + override var isEmpty: Bool { + return _start.index == _end.index + } + + override var start: UITextPosition { + return _start + } + + override var end: UITextPosition { + return _end + } + + convenience init?(start: UITextPosition, end: UITextPosition) { + guard + let start = start as? TextPosition, + let end = end as? TextPosition + else { + return nil + } + + self.init(start: start, end: end) + } + + init(start: TextPosition, end: TextPosition) { + self._start = start + self._end = end + } + + override var description: String { + let props: [String] = [ + String(format: "%@: %p", NSStringFromClass(type(of: self)), self), + "start = \(String(describing: start))", + "end = \(String(describing: end))", + ] + return "<\(props.joined(separator: "; "))>" + } + + override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? TextRange else { + return false + } + + return self.start == other.start && self.end == other.end + } + + func contains(_ index: Int) -> Bool { + let lowerBound = min(_start.index, _end.index) + let upperBound = max(_start.index, _end.index) + return index >= lowerBound && index <= upperBound + } + + func stringRange(for string: String) -> Range { + let lowerBound = min(_start.index, _end.index) + let upperBound = max(_start.index, _end.index) + + let beginIndex = string.index(string.startIndex, offsetBy: min(lowerBound, string.count)) + let endIndex = string.index(string.startIndex, offsetBy: min(upperBound, string.count)) + + return beginIndex.. 4 && numberOfDigits.isMultiple(of: 2) + } + + private lazy var digitViews: [DigitView] = (0.. Bool { + let result = super.becomeFirstResponder() + + if result { + selectedTextRange = textStorage.endCaretRange + } + + return result + } + + @discardableResult + public override func resignFirstResponder() -> Bool { + let result = super.resignFirstResponder() + + if result { + hideMenu() + update() + } + + return result + } + + public override func touchesEnded(_ touches: Set, with event: UIEvent?) { + super.touchesEnded(touches, with: event) + + guard let point = touches.first?.location(in: self), + bounds.contains(point) else { + return + } + + if isFirstResponder { + toggleMenu() + } else { + becomeFirstResponder() + } + } + +} + +// MARK: - Private methods + +private extension OneTimeCodeTextField { + + func setupUI() { + let stackView = UIStackView(arrangedSubviews: arrangedDigitViews()) + stackView.spacing = shouldGroupDigits ? Constants.groupSpacing : Constants.itemSpacing + stackView.alignment = .center + stackView.distribution = .fillEqually + stackView.semanticContentAttribute = .forceLeftToRight + addAndPinSubview(stackView) + } + + func arrangedDigitViews() -> [UIView] { + guard shouldGroupDigits else { + // No grouping, simply return all the digit views. + return digitViews + } + + // Split the digit views into two groups. + let groupSize = numberOfDigits / 2 + + let groups = stride(from: 0, to: digitViews.count, by: groupSize).map { + Array(digitViews[$0.. Bool { + return action == #selector(paste(_:)) && UIPasteboard.general.hasStrings + } + + override func paste(_ sender: Any?) { + if let string = UIPasteboard.general.string { + insertText(string) + update() + } + } + +} + +// MARK: - Animation + +public extension OneTimeCodeTextField { + + /// Performs a shake animation, useful for indicating a bad code. + /// - Parameter shouldClearValue: Whether or not the field's value should be cleared at the end of the animation. + func performInvalidCodeAnimation(shouldClearValue: Bool = true) { + // Temporarily disables user interaction while the animation plays. + isUserInteractionEnabled = false + + let duration: CFTimeInterval = 0.4 + let beginTime = CACurrentMediaTime() + let staggerDelay: CFTimeInterval = 0.025 + let timingFunction = CAMediaTimingFunction(controlPoints: 0.3, 0.3, 0.3, 1) + + for (index, digitView) in digitViews.enumerated() { + // TODO(ramont): Move this to DigitView + let jumpAnimation = CAKeyframeAnimation(keyPath: "transform.translation.y") + jumpAnimation.beginTime = beginTime + (CFTimeInterval(index) * staggerDelay) + jumpAnimation.duration = duration + jumpAnimation.values = [0, -8, 2, 0] + jumpAnimation.keyTimes = [0.0, 0.33, 0.66, 1.0] + jumpAnimation.timingFunctions = [timingFunction, timingFunction, timingFunction, timingFunction] + + let borderColorAnimation = CABasicAnimation(keyPath: "borderColor") + borderColorAnimation.beginTime = beginTime + (CFTimeInterval(index) * staggerDelay) + borderColorAnimation.duration = duration / 3 + borderColorAnimation.fromValue = theme.colors.border.cgColor + borderColorAnimation.toValue = theme.colors.danger.cgColor + borderColorAnimation.fillMode = .forwards + borderColorAnimation.isRemovedOnCompletion = false + + digitView.layer.add(jumpAnimation, forKey: "jump") + digitView.borderLayer.add(borderColorAnimation, forKey: "borderColor") + } + + feedbackGenerator.notificationOccurred(.error) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.digitViews.forEach { digitView in + digitView.layer.removeAllAnimations() + digitView.borderLayer.removeAllAnimations() + } + + if shouldClearValue { + self?.value = "" + } + + self?.isUserInteractionEnabled = true + self?.becomeFirstResponder() + } + } + +} + +// MARK: - UIKeyInput + +extension OneTimeCodeTextField: UIKeyInput { + + public var hasText: Bool { + return value.count > 0 + } + + public func insertText(_ text: String) { + guard let range = selectedTextRange as? TextRange else { + return + } + + inputDelegate?.textWillChange(self) + selectedTextRange = textStorage.insert(text, at: range) + inputDelegate?.textDidChange(self) + + sendActions(for: [.editingChanged, .valueChanged]) + hideMenu() + update() + } + + public func deleteBackward() { + guard let range = selectedTextRange as? TextRange else { + return + } + + inputDelegate?.textWillChange(self) + selectedTextRange = textStorage.delete(range: range) + inputDelegate?.textDidChange(self) + + sendActions(for: [.editingChanged, .valueChanged]) + hideMenu() + update() + } + +} + +// MARK: - Utils + +extension OneTimeCodeTextField { + + private func clampIndex(_ index: Int) -> Int { + return max(min(index, numberOfDigits - 1), 0) + } + +} + +// MARK: - UITextInput + +extension OneTimeCodeTextField: UITextInput { + + public var markedTextRange: UITextRange? { + // We don't support marked text + return nil + } + + public var markedTextStyle: [NSAttributedString.Key: Any]? { + get { + return nil + } + set(markedTextStyle) { + // We don't support marked text + } + } + + public var beginningOfDocument: UITextPosition { + return textStorage.start + } + + public var endOfDocument: UITextPosition { + return textStorage.end + } + + public func text(in range: UITextRange) -> String? { + guard let range = range as? TextRange else { + return nil + } + + return textStorage.text(in: range) + } + + public func replace(_ range: UITextRange, withText text: String) { + // No-op + } + + public func setMarkedText(_ markedText: String?, selectedRange: NSRange) { + // We don't support marked text + } + + public func unmarkText() { + // We don't support marked text + } + + public func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange? { + guard + let fromPosition = fromPosition as? TextPosition, + let toPosition = toPosition as? TextPosition + else { + return nil + } + + return textStorage.makeRange(from: fromPosition, to: toPosition) + } + + public func position(from position: UITextPosition, offset: Int) -> UITextPosition? { + guard let position = position as? TextPosition else { + return nil + } + + let newIndex = position.index + offset + + guard textStorage.extent.contains(newIndex) else { + // Out of bounds + return nil + } + + return TextPosition(newIndex) + } + + public func position( + from position: UITextPosition, + in direction: UITextLayoutDirection, + offset: Int + ) -> UITextPosition? { + switch direction { + case .right: + return self.position(from: position, offset: offset) + case .left: + return self.position(from: position, offset: -offset) + case .up: + return offset > 0 ? beginningOfDocument : endOfDocument + case .down: + return offset > 0 ? endOfDocument : beginningOfDocument + @unknown default: + return nil + } + } + + public func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult { + guard + let position = position as? TextPosition, + let other = other as? TextPosition + else { + return .orderedSame + } + + return position.compare(other) + } + + public func offset(from: UITextPosition, to toPosition: UITextPosition) -> Int { + guard + let from = from as? TextPosition, + let toPosition = toPosition as? TextPosition + else { + return 0 + } + + return toPosition.index - from.index + } + + public func position(within range: UITextRange, farthestIn direction: UITextLayoutDirection) -> UITextPosition? { + guard let range = range as? TextRange else { + return nil + } + + switch direction { + case .left, .up: + return range.start + case .right, .down: + return range.end + @unknown default: + return nil + } + } + + public func characterRange( + byExtending position: UITextPosition, + in direction: UITextLayoutDirection + ) -> UITextRange? { + switch direction { + case .right: + return self.textRange(from: position, to: endOfDocument) + case .left: + return self.textRange(from: beginningOfDocument, to: position) + case .up, .down: + return nil + @unknown default: + return nil + } + } + + public func baseWritingDirection( + for position: UITextPosition, + in direction: UITextStorageDirection + ) -> NSWritingDirection { + // Numeric input should be left-to-right always. + return .leftToRight + } + + public func setBaseWritingDirection(_ writingDirection: NSWritingDirection, for range: UITextRange) { + // No-op + } + + public func firstRect(for range: UITextRange) -> CGRect { + guard let range = range as? TextRange, !range.isEmpty else { + return .zero + } + + // This method should return a rectangle that contains the digit views that + // fall inside the given TextRange. For example, a [0,2] TextRange should + // return a rectangle that contains digit views 0 and 1: + // + // 0 1 2 3 4 5 6 <- TextPosition + // [*] [*] [*] [*] [*] [*] <- UI + // 0 1 2 3 4 5 <- DigitView index + // ^ ^ + // |_______| <- [0,2] TextRange + + let firstDigitView = digitViews[clampIndex(range._start.index)] + let secondDigitView = digitViews[clampIndex(range._end.index - 1)] + + let firstRect = firstDigitView.convert(firstDigitView.bounds, to: self) + let secondRect = secondDigitView.convert(secondDigitView.bounds, to: self) + + return firstRect.union(secondRect) + } + + public func caretRect(for position: UITextPosition) -> CGRect { + guard let position = position as? TextPosition else { + return .zero + } + + let digitView = digitViews[clampIndex(position.index)] + return digitView.convert(digitView.caretRect, to: self) + } + + public func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { + // No text-selection + return [] + } + + public func closestPosition(to point: CGPoint) -> UITextPosition? { + return closestPosition(to: point, within: textStorage.extent) + } + + public func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition? { + guard + let range = range as? TextRange, + let digitView = hitTest(point, with: nil) as? DigitView, + let index = digitViews.firstIndex(of: digitView) + else { + return nil + } + + return range.contains(index) ? TextPosition(index) : nil + } + + public func characterRange(at point: CGPoint) -> UITextRange? { + guard + let startPosition = closestPosition(to: point) as? TextPosition, + let endPosition = position(from: startPosition, offset: 1) + else { + return nil + } + + return self.textRange(from: startPosition, to: endPosition) + } + +} + +// MARK: - Digit View + +private extension OneTimeCodeTextField { + + final class DigitView: UIView { + struct Constants { + static let dotSize: CGFloat = 8 + static let borderWidth: CGFloat = 1 + static let cornerRadius: CGFloat = 8 + static let focusRingThickness: CGFloat = 2 + // Color is hardcoded for now, as it's not semantically supported by ElementsUI + // TODO(bmelts): Should this be a theme color with a low alpha component? + static let dotColor: UIColor = .dynamic( + light: UIColor(red: 0.922, green: 0.933, blue: 0.945, alpha: 1.0), + dark: UIColor(red: 0.471, green: 0.471, blue: 0.502, alpha: 0.36) + ) + } + + var isActive: Bool = false { + didSet { + updateLayers() + } + } + + var character: Character? { + didSet { + label.text = character.map { String($0) } + updateLayers() + } + } + + var isEnabled: Bool = true { + didSet { + updateColors() + } + } + + private let font: UIFont = .systemFont(ofSize: 20) + + private let theme: ElementsUITheme + + private(set) lazy var borderLayer: CALayer = { + let borderLayer = CALayer() + borderLayer.borderWidth = Constants.borderWidth + borderLayer.cornerRadius = Constants.cornerRadius + return borderLayer + }() + + private lazy var focusRing: CALayer = { + let focusRing = CALayer() + focusRing.borderWidth = Constants.focusRingThickness + focusRing.cornerRadius = Constants.cornerRadius + (Constants.focusRingThickness / 2) + return focusRing + }() + + private lazy var dot: CALayer = { + let dot = CALayer() + dot.frame = CGRect(x: 0, y: 0, width: Constants.dotSize, height: Constants.dotSize) + dot.anchorPoint = CGPoint(x: 0.5, y: 0.5) + dot.cornerRadius = Constants.dotSize / 2 + return dot + }() + + private lazy var caret: CALayer = .init() + + private lazy var label: UILabel = { + let label = UILabel() + label.textAlignment = .center + label.isAccessibilityElement = false + label.textColor = theme.colors.textFieldText + label.font = font + return label + }() + + var caretRect: CGRect { + let caretSize = CGSize(width: 2, height: font.ascender - font.descender) + + return CGRect( + x: (bounds.width - caretSize.width) / 2, + y: (bounds.height - caretSize.height) / 2, + width: caretSize.width, + height: caretSize.height + ) + } + + override var intrinsicContentSize: CGSize { + return CGSize(width: UIView.noIntrinsicMetric, height: 60) + } + + init(theme: ElementsUITheme) { + self.theme = theme + super.init(frame: .zero) + + layer.addSublayer(borderLayer) + layer.addSublayer(dot) + layer.addSublayer(caret) + layer.addSublayer(focusRing) + + addSubview(label) + + updateColors() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + label.frame = bounds + borderLayer.frame = bounds + + // Center dot + dot.position = CGPoint(x: bounds.midX, y: bounds.midY) + + // Update caret + caret.frame = caretRect + caret.cornerRadius = caret.frame.width / 2 + + focusRing.frame = bounds.insetBy( + dx: Constants.focusRingThickness / 2 * -1, + dy: Constants.focusRingThickness / 2 * -1 + ) + } + + private func updateLayers() { + CATransaction.begin() + CATransaction.setDisableActions(true) + + focusRing.isHidden = !isActive + + let isEmpty = character == nil + let shouldShowDot = !isActive && isEmpty + let shouldShowCaret = isActive && isEmpty + + dot.isHidden = !shouldShowDot + + if shouldShowCaret { + showCaret() + } else { + hideCaret() + } + + CATransaction.commit() + } + + private func updateColors() { + borderLayer.backgroundColor = isEnabled ? theme.colors.background.cgColor : theme.colors.disabledBackground.cgColor + borderLayer.borderColor = theme.colors.border.cgColor + dot.backgroundColor = Constants.dotColor.cgColor + caret.backgroundColor = tintColor.cgColor + focusRing.borderColor = tintColor.cgColor + } + + private func showCaret() { + caret.isHidden = false + + let blinkingAnimation = CAKeyframeAnimation(keyPath: "opacity") + // Matches caret animation of iOS >= 13 + blinkingAnimation.keyTimes = [0, 0.5, 0.5375, 0.575, 0.6125, 0.65, 0.85, 0.8875, 0.925, 0.9625, 1] + blinkingAnimation.values = [1, 1, 0.75, 0.5, 0.25, 0, 0, 0.25, 0.5, 0.75, 1] + blinkingAnimation.duration = 1 + blinkingAnimation.repeatCount = .infinity + blinkingAnimation.calculationMode = .discrete + + caret.add(blinkingAnimation, forKey: "blink") + } + + private func hideCaret() { + caret.isHidden = true + caret.removeAllAnimations() + } + + override func tintColorDidChange() { + super.tintColorDidChange() + updateColors() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + updateColors() + } + } + +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Checkbox/CheckboxButton.swift b/StripeUICore/StripeUICore/Source/Elements/Checkbox/CheckboxButton.swift new file mode 100644 index 00000000..c5d31a8d --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Checkbox/CheckboxButton.swift @@ -0,0 +1,332 @@ +// +// CheckboxButton.swift +// StripeUICore +// +// Created by Cameron Sabol on 12/11/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +import UIKit + +@_spi(STP) public protocol CheckboxButtonDelegate: AnyObject { + /// Return `true` to open the URL in the device's default browser. + /// Return `false` to custom handle the URL. + func checkboxButton(_ checkboxButton: CheckboxButton, shouldOpen url: URL) -> Bool +} + +/// For internal SDK use only +@objc(STP_Internal_CheckboxButton) +@_spi(STP) public class CheckboxButton: UIControl { + // MARK: - Properties + + public weak var delegate: CheckboxButtonDelegate? + + private var font: UIFont { + return theme.fonts.footnote + } + + private var emphasisFont: UIFont { + return theme.fonts.footnoteEmphasis + } + + private lazy var textView: UITextView = { + let textView = LinkOpeningTextView() + textView.isEditable = false + textView.isSelectable = false + textView.isScrollEnabled = false + textView.backgroundColor = nil + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.adjustsFontForContentSizeCategory = true + textView.delegate = self + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return textView + }() + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.isAccessibilityElement = false + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return label + }() + + private lazy var checkbox: CheckBox = { + let checkbox = CheckBox(theme: theme) + checkbox.isSelected = true + checkbox.translatesAutoresizingMaskIntoConstraints = false + return checkbox + }() + + private lazy var stackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [textView, descriptionLabel]) + stackView.spacing = 4 + stackView.axis = .vertical + stackView.distribution = .equalSpacing + stackView.alignment = .leading + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + /// Aligns the checkbox vertically to the first baseline of `label`. + private lazy var checkboxAlignmentConstraint: NSLayoutConstraint = { + return checkbox.centerYAnchor.constraint( + equalTo: textView.firstBaselineAnchor, + constant: 0 + ) + }() + + public override var isSelected: Bool { + didSet { + if isSelected { + accessibilityTraits.update(with: .selected) + } else { + accessibilityTraits.remove(.selected) + } + checkbox.isSelected = isSelected + } + } + + public override var isEnabled: Bool { + didSet { + checkbox.isUserInteractionEnabled = isEnabled + textView.isUserInteractionEnabled = isEnabled + } + } + + public private(set) var hasReceivedTap: Bool = false + + public override var isHidden: Bool { + didSet { + checkbox.setNeedsDisplay() + setNeedsDisplay() + } + } + + public var theme: ElementsUITheme { + didSet { + checkbox.theme = theme + updateLabels() + } + } + + // MARK: - Initializers + + public init(description: String? = nil, theme: ElementsUITheme = .default) { + self.theme = theme + super.init(frame: .zero) + + isAccessibilityElement = true + accessibilityHint = description + accessibilityTraits = UISwitch().accessibilityTraits + + descriptionLabel.text = description + + setupUI() + + let didTapGestureRecognizer = UITapGestureRecognizer( + target: self, action: #selector(didTap)) + addGestureRecognizer(didTapGestureRecognizer) + } + + public convenience init(text: String, description: String? = nil, theme: ElementsUITheme = .default) { + self.init(description: description, theme: theme) + setText(text) + } + + public convenience init(attributedText: NSAttributedString, description: String? = nil, theme: ElementsUITheme = .default) { + self.init(description: description, theme: theme) + setAttributedText(attributedText) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func layoutSubviews() { + super.layoutSubviews() + + // Preferred max width sometimes is off when changing font size + descriptionLabel.preferredMaxLayoutWidth = stackView.bounds.width + textView.invalidateIntrinsicContentSize() + } + + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + updateLabels() + } + + public func setText(_ text: String) { + textView.text = text + updateLabels() + updateAccessibility() + } + + public func setAttributedText(_ attributedText: NSAttributedString) { + textView.attributedText = attributedText + updateLabels() + updateAccessibility() + } + + private func setupUI() { + addSubview(checkbox) + addSubview(stackView) + + let minimizeHeight = stackView.heightAnchor.constraint(equalTo: heightAnchor) + minimizeHeight.priority = .defaultLow + NSLayoutConstraint.activate([ + // Checkbox + checkboxAlignmentConstraint, + checkbox.topAnchor.constraint(greaterThanOrEqualTo: topAnchor), + checkbox.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor), + checkbox.leadingAnchor.constraint(equalTo: leadingAnchor), + + // Stack view + stackView.topAnchor.constraint(greaterThanOrEqualTo: topAnchor), + stackView.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor), + stackView.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 6), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + minimizeHeight, + ]) + } + + @objc + private func didTap() { + hasReceivedTap = true + isSelected.toggle() + sendActions(for: .touchUpInside) + } + + private func updateLabels() { + let hasDescription = descriptionLabel.text != nil + + let textFont = hasDescription ? emphasisFont : font + textView.font = textFont + textView.textColor = hasDescription ? theme.colors.bodyText : theme.colors.secondaryText + + descriptionLabel.font = font + descriptionLabel.isHidden = !hasDescription + descriptionLabel.textColor = theme.colors.secondaryText + + // Align checkbox to center of first line of text. The center of the checkbox is already + // pinned to the first baseline via a constraint, so we just need to calculate + // the offset from baseline to line center, and apply the offset to the constraint. + let baselineToLineCenterOffset = (textFont.ascender + textFont.descender) / 2 + checkboxAlignmentConstraint.constant = -baselineToLineCenterOffset + } + + private func updateAccessibility() { + // Copy the text view's accessibilityValue which will describe any links + // contained in the text to the user + accessibilityLabel = textView.accessibilityValue ?? textView.text + + // If the text contains a link, allow links to be opened with the text + // view's link rotor + let linkRotors = textView.accessibilityCustomRotors?.filter({ $0.systemRotorType == .link }) ?? [] + accessibilityCustomRotors = linkRotors + + // iOS 13 automatically includes a hint if there is a link rotor, but + // iOS 14+ do not so we must add one ourselves. + if #available(iOS 14, *) { + var hints = [descriptionLabel.text] + if !linkRotors.isEmpty { + hints.append(.Localized.useRotorToAccessLinks) + } + accessibilityHint = hints.compactMap { $0 }.joined(separator: ", ") + } + } +} + +// MARK: - UITextViewDelegate +extension CheckboxButton: UITextViewDelegate { + public func textView(_ textView: UITextView, shouldInteractWith url: URL, in characterRange: NSRange) -> Bool { + return delegate?.checkboxButton(self, shouldOpen: url) ?? true + } +} + +// MARK: - CheckBox +/// For internal SDK use only +@objc(STP_Internal_CheckBox) +class CheckBox: UIView { + var isSelected: Bool = false { + didSet { + setNeedsDisplay() + } + } + + private var fillColor: UIColor { + if isSelected { + return theme.colors.primary + } + + return theme.colors.background + } + + var theme: ElementsUITheme { + didSet { + layer.applyShadow(shadow: theme.shadow) + setNeedsDisplay() + } + } + + override var intrinsicContentSize: CGSize { + return CGSize(width: 20, height: 20) + } + + init(theme: ElementsUITheme = .default) { + self.theme = theme + super.init(frame: .zero) + + backgroundColor = .clear + layer.applyShadow(shadow: theme.shadow) + + setContentHuggingPriority(.defaultHigh, for: .horizontal) + setContentHuggingPriority(.defaultHigh, for: .vertical) + setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func draw(_ rect: CGRect) { + let rect = rect.inset(by: superview!.alignmentRectInsets) + let borderRectWidth = min(16, rect.width - 2) + let borderRectHeight = min(16, rect.height - 2) + let borderRect = CGRect( + x: max(0, rect.midX - 0.5 * borderRectWidth), + y: max(0, rect.midY - 0.5 * borderRectHeight), width: borderRectWidth, + height: borderRectHeight) + + let borderPath = UIBezierPath(roundedRect: borderRect, cornerRadius: 3) + borderPath.lineWidth = 1 + if isUserInteractionEnabled { + fillColor.setFill() + } else { + fillColor.setFill() + } + borderPath.fill() + theme.colors.border.setStroke() + borderPath.stroke() + + if isSelected { + let checkmarkPath = UIBezierPath() + checkmarkPath.move(to: CGPoint(x: borderRect.minX + 3.5, y: borderRect.minY + 9)) + checkmarkPath.addLine( + to: CGPoint(x: borderRect.minX + 3.5 + 4, y: borderRect.minY + 8 + 4)) + checkmarkPath.addLine(to: CGPoint(x: borderRect.maxX - 3, y: borderRect.minY + 4)) + + checkmarkPath.lineCapStyle = .square + checkmarkPath.lineJoinStyle = .bevel + checkmarkPath.lineWidth = 2 + if isUserInteractionEnabled { + fillColor.contrastingColor.setStroke() + } else { + fillColor.contrastingColor.disabledColor.setStroke() + } + checkmarkPath.stroke() + } + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Checkbox/CheckboxElement.swift b/StripeUICore/StripeUICore/Source/Elements/Checkbox/CheckboxElement.swift new file mode 100644 index 00000000..504362be --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Checkbox/CheckboxElement.swift @@ -0,0 +1,59 @@ +// +// CheckboxElement.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 6/15/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +@_spi(STP) public final class CheckboxElement { + public weak var delegate: ElementDelegate? + public private(set) lazy var checkboxButton: CheckboxButton = { + let checkbox = CheckboxButton( + text: label, + theme: theme + ) + checkbox.addTarget(self, action: #selector(didToggleCheckbox), for: .touchUpInside) + checkbox.isSelected = isSelectedByDefault + return checkbox + }() + let label: String + let isSelectedByDefault: Bool + let theme: ElementsUITheme + var didToggle: (Bool) -> Void + @_spi(STP) public var isSelected: Bool { + get { + return checkboxButton.isSelected + } + set { + checkboxButton.isSelected = newValue + } + } + + @objc func didToggleCheckbox() { + didToggle(checkboxButton.isSelected) + delegate?.didUpdate(element: self) + } + + public init( + theme: ElementsUITheme, + label: String, + isSelectedByDefault: Bool, + didToggle: ((Bool) -> Void)? = nil + ) { + self.label = label + self.isSelectedByDefault = isSelectedByDefault + self.theme = theme + self.didToggle = didToggle ?? { _ in } + } +} + +/// :nodoc: +extension CheckboxElement: Element { + public var view: UIView { + return checkboxButton + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/ContainerElement.swift b/StripeUICore/StripeUICore/Source/Elements/ContainerElement.swift new file mode 100644 index 00000000..516694e1 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/ContainerElement.swift @@ -0,0 +1,63 @@ +// +// ContainerElement.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 3/25/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +/** + A convenience protocol for Elements that contain other Elements. + It offers default implementations for the methods required to participate in the Element hierarchy. + + - Note:You still need to set your sub-element's delegates = self! + */ +@_spi(STP) public protocol ContainerElement: Element, ElementDelegate { + var elements: [Element] { get } +} + +extension ContainerElement { + // MARK: - Element + + public func beginEditing() -> Bool { + guard !view.isHidden else { + // Prevent focusing on a child element if the container is hidden. + return false + } + + return elements.first?.beginEditing() ?? false + } + + public var validationState: ElementValidationState { + elements.first { + if case .valid = $0.validationState { + return false + } + return true + }?.validationState ?? .valid + } + + // MARK: - ElementDelegate + + public func didUpdate(element: Element) { + // Glue: Update the view and our delegate + delegate?.didUpdate(element: self) + } + + public func continueToNextField(element: Element) { + let remainingElements = elements + .drop { $0 !== element } // Drop elements (starting from the first) until we find `element` + .dropFirst() // Drop `element` too + for next in remainingElements { + if next.beginEditing() { + UIAccessibility.post(notification: .screenChanged, argument: next.view) + return + } + } + // Failed to become first responder + delegate?.continueToNextField(element: self) + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/DateFieldElement.swift b/StripeUICore/StripeUICore/Source/Elements/DateFieldElement.swift new file mode 100644 index 00000000..02d9df84 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/DateFieldElement.swift @@ -0,0 +1,194 @@ +// +// DateFieldElement.swift +// StripeUICore +// +// Created by Mel Ludowise on 10/1/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +import UIKit + +/** + A textfield whose input view is a `UIDatePicker` + */ +@_spi(STP) public class DateFieldElement { + public typealias DidUpdateSelectedDate = (Date) -> Void + struct DateEmptyError: ElementValidationError { + var localizedDescription: String = STPLocalizedString( + "Date is empty.", + "Error message for empty date." + ) + } + + weak public var delegate: ElementDelegate? + private(set) lazy var datePickerView: UIDatePicker = { + let picker = UIDatePicker() + if #available(iOS 13.4, *) { + picker.preferredDatePickerStyle = .wheels + } + picker.datePickerMode = .date + picker.addTarget(self, action: #selector(didSelectDate), for: .valueChanged) + return picker + }() + private(set) lazy var pickerFieldView: PickerFieldView = { + let pickerFieldView = PickerFieldView( + label: label, + shouldShowChevron: false, + pickerView: datePickerView, + delegate: self, + theme: theme + ) + return pickerFieldView + }() + + private var dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter + }() + public private(set) var selectedDate: Date? { + didSet { + updateDisplayText() + } + } + private var previouslySelectedDate: Date? + public var validationState: ElementValidationState { + if selectedDate != nil { + return .valid + } else { + return .invalid(error: DateEmptyError(), shouldDisplay: false) + } + } + public var didUpdate: DidUpdateSelectedDate? + + private let label: String? + private let theme: ElementsUITheme + + /** + - Parameters: + - label: The label of this picker + - defaultDate: If this field should be prefilled before the user interacts with it, then provide a default date to display initially. + - minimumDate: The minimum date that can be selected + - maximumDate: The maximum date that can be selected + - locale: The locale to use to format the date into display text and configure the date picker + - timeZone: The timeZone to use to format the date into display text and configure the date picker + - didUpdate: Called when the user has selected a new date. + - theme: Theme for the element + + - Note: + - If a minimum or maximum date is provided and `defaultDate` is outside of of that range, then the given default is ignored. + - `didUpdate` is not called if the user does not change their input before hitting "Done" + */ + public init( + label: String? = nil, + defaultDate: Date? = nil, + minimumDate: Date? = nil, + maximumDate: Date? = nil, + locale: Locale = .current, + timeZone: TimeZone = .current, + theme: ElementsUITheme = .default, + customDateFormatter: DateFormatter? = nil, + didUpdate: DidUpdateSelectedDate? = nil + ) { + self.label = label + self.theme = theme + if let customDateFormatter = customDateFormatter { + self.dateFormatter = customDateFormatter + } + dateFormatter.locale = locale + dateFormatter.timeZone = timeZone + + datePickerView.locale = locale + datePickerView.timeZone = timeZone + datePickerView.minimumDate = minimumDate + datePickerView.maximumDate = maximumDate + if let defaultDate = DateFieldElement.dateWithinBounds(defaultDate, min: minimumDate, max: maximumDate) { + datePickerView.date = defaultDate + selectedDate = defaultDate + updateDisplayText() + } + + self.previouslySelectedDate = defaultDate + self.didUpdate = didUpdate + } + + // MARK: - Internal Methods + + @objc func didSelectDate() { + selectedDate = datePickerView.date + } + + private func updateDisplayText() { + let selectedDate = selectedDate.map { dateFormatter.string(from: $0) } + pickerFieldView.displayText = NSAttributedString(string: selectedDate ?? "") + } +} + +// MARK: Element + +extension DateFieldElement: Element { + public var view: UIView { + return pickerFieldView + } + + public func beginEditing() -> Bool { + return pickerFieldView.becomeFirstResponder() + } +} + +// MARK: - PickerFieldViewDelegate + +extension DateFieldElement: PickerFieldViewDelegate { + func didBeginEditing(_ pickerFieldView: PickerFieldView) { + selectedDate = datePickerView.date + } + + func didFinish(_ pickerFieldView: PickerFieldView, shouldAutoAdvance: Bool) { + if previouslySelectedDate != selectedDate, + let selectedDate = selectedDate + { + didUpdate?(selectedDate) + previouslySelectedDate = selectedDate + delegate?.didUpdate(element: self) + } + if shouldAutoAdvance { + delegate?.continueToNextField(element: self) + } + } + + func didCancel(_ pickerFieldView: PickerFieldView) { + // no-op + } +} + +// MARK: - Private Helpers + +private extension DateFieldElement { + /// Returns the date if it is within the min & max bounds, when applicable. Otherwise returns nil + static func dateWithinBounds( + _ date: Date?, + min: Date?, + max: Date? + ) -> Date? { + guard let date = date else { + return nil + } + + if let min = min, + date < min + { + return nil + } + + if let max = max, + date > max + { + return nil + } + + return date + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/DropdownFieldElement.swift b/StripeUICore/StripeUICore/Source/Elements/DropdownFieldElement.swift new file mode 100644 index 00000000..1f1ad758 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/DropdownFieldElement.swift @@ -0,0 +1,271 @@ +// +// DropdownFieldElement.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 6/17/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +import UIKit + +/** + A textfield whose input view is a `UIPickerView` (on iOS) or a `UIMenu` (on Catalyst) with a list of the strings. + + For internal SDK use only + */ +@objc(STP_Internal_DropdownFieldElement) +@_spi(STP) public class DropdownFieldElement: NSObject { + public typealias DidUpdateSelectedIndex = (Int) -> Void + + public struct DropdownItem { + public init(pickerDisplayName: NSAttributedString, labelDisplayName: NSAttributedString, accessibilityValue: String, rawData: String, isPlaceholder: Bool = false) { + self.pickerDisplayName = pickerDisplayName + self.labelDisplayName = labelDisplayName + self.accessibilityValue = accessibilityValue + self.isPlaceholder = isPlaceholder + self.rawData = rawData + } + + public init(pickerDisplayName: String, labelDisplayName: String, accessibilityValue: String, rawData: String, isPlaceholder: Bool = false) { + self = .init(pickerDisplayName: NSAttributedString(string: pickerDisplayName), + labelDisplayName: NSAttributedString(string: labelDisplayName), + accessibilityValue: accessibilityValue, + rawData: rawData, + isPlaceholder: isPlaceholder) + } + + /// Item label displayed in the picker + public let pickerDisplayName: NSAttributedString + + /// Item label displayed in inline label when item has been selected + public let labelDisplayName: NSAttributedString + + /// Accessibility value to use when this is in the inline label + public let accessibilityValue: String + + /// The underlying data for this dropdown item. + /// e.g., A country dropdown item might display "United States" but its `rawData` is "US". + /// This is ignored by `DropdownFieldElement`, and is intended as a convenience to be used in conjunction with `selectedItem` + public let rawData: String + + /// If true, this item will be styled with greyed out secondary text + public let isPlaceholder: Bool + } + + // MARK: - Public properties + weak public var delegate: ElementDelegate? + public private(set) var items: [DropdownItem] + public var selectedItem: DropdownItem { + return items[selectedIndex] + } + public var selectedIndex: Int { + didSet { + updatePickerField() + } + } + public var didUpdate: DidUpdateSelectedIndex? + public let theme: ElementsUITheme + public let hasPadding: Bool + + /// A label displayed in the dropdown field UI e.g. "Country or region" for a country dropdown + public let label: String? +#if targetEnvironment(macCatalyst) + private(set) lazy var pickerView: UIButton = { + let button = UIButton() + let action = { (action: UIAction) -> Void in + self.selectedIndex = Int(action.identifier.rawValue) ?? 0 + } + + if #available(macCatalyst 14.0, *) { + let menu = UIMenu(children: + items.enumerated().map { (index, item) in + UIAction(title: item.pickerDisplayName.string, identifier: .init(rawValue: String(index)), handler: action) + } + ) + button.menu = menu + button.showsMenuAsPrimaryAction = true + } + + // We don't need to show this button, we're just using it to accept hits and present the menu. + button.isHidden = true + return button + }() +#else + private(set) lazy var pickerView: UIPickerView = { + let picker = UIPickerView() + picker.delegate = self + picker.dataSource = self + return picker + }() +#endif + + private(set) lazy var pickerFieldView: PickerFieldView = { + let pickerFieldView = PickerFieldView( + label: label, + shouldShowChevron: disableDropdownWithSingleElement ? items.count != 1 : true, + pickerView: pickerView, + delegate: self, + theme: theme, + hasPadding: hasPadding + ) + if disableDropdownWithSingleElement && items.count == 1 { + pickerFieldView.isUserInteractionEnabled = false + } + return pickerFieldView + }() + + // MARK: - Private properties + private var previouslySelectedIndex: Int + private let disableDropdownWithSingleElement: Bool + + /** + - Parameters: + - items: Items to populate this dropdown with. + - defaultIndex: Defaults the dropdown to the item with the corresponding index. + - label: Label for the dropdown + - didUpdate: Called when the user has finished selecting a new item. + + - Note: + - Items must contain at least one item. + - If `defaultIndex` is outside of the bounds of the `items` array, then a default of `0` is used. + - `didUpdate` is not called if the user does not change their input before hitting "Done" + */ + public init( + items: [DropdownItem], + defaultIndex: Int = 0, + label: String?, + theme: ElementsUITheme = .default, + hasPadding: Bool = true, + disableDropdownWithSingleElement: Bool = false, + didUpdate: DidUpdateSelectedIndex? = nil + ) { + assert(!items.isEmpty, "`items` must contain at least one item") + + self.label = label + self.theme = theme + self.items = items + self.disableDropdownWithSingleElement = disableDropdownWithSingleElement + self.didUpdate = didUpdate + self.hasPadding = hasPadding + + // Default to defaultIndex, if in bounds + if defaultIndex < 0 || defaultIndex >= items.count { + self.selectedIndex = 0 + } else { + self.selectedIndex = defaultIndex + } + self.previouslySelectedIndex = selectedIndex + super.init() + + if !items.isEmpty { + updatePickerField() + } + } + + public func select(index: Int, shouldAutoAdvance: Bool = true) { + selectedIndex = index + didFinish(pickerFieldView, shouldAutoAdvance: shouldAutoAdvance) + } + + public func update(items: [DropdownItem]) { + assert(!items.isEmpty, "`items` must contain at least one item") + // Try to re-select the same item afer updating, if not possible default to the first item in the list + let newSelectedIndex = items.firstIndex(where: { $0.rawData == self.items[selectedIndex].rawData }) ?? 0 + + self.items = items + self.select(index: newSelectedIndex, shouldAutoAdvance: false) + } +} + +private extension DropdownFieldElement { + + func updatePickerField() { + #if targetEnvironment(macCatalyst) + if #available(macCatalyst 14.0, *) { + // Mark the enabled menu item as selected + pickerView.menu?.children.forEach { ($0 as? UIAction)?.state = .off } + (pickerView.menu?.children[selectedIndex] as? UIAction)?.state = .on + } + #else + if pickerView.selectedRow(inComponent: 0) != selectedIndex { + pickerView.reloadComponent(0) + pickerView.selectRow(selectedIndex, inComponent: 0, animated: false) + } + #endif + + pickerFieldView.displayText = items[selectedIndex].labelDisplayName + pickerFieldView.displayTextAccessibilityValue = items[selectedIndex].accessibilityValue + } + +} + +// MARK: Element + +extension DropdownFieldElement: Element { + public var view: UIView { + return pickerFieldView + } + + public func beginEditing() -> Bool { + return pickerFieldView.becomeFirstResponder() + } +} + +// MARK: UIPickerViewDelegate + +extension DropdownFieldElement: UIPickerViewDelegate { + + public func pickerView(_ pickerView: UIPickerView, attributedTitleForRow row: Int, forComponent component: Int) -> NSAttributedString? { + let item = items[row] + + guard item.isPlaceholder else { return item.pickerDisplayName } + + // If this item is marked as a placeholder, apply placeholder text color + let attributes: [NSAttributedString.Key: Any] = [.foregroundColor: theme.colors.placeholderText] + let placeholderString = NSAttributedString(string: item.pickerDisplayName.string, attributes: attributes) + return placeholderString + } + + public func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + selectedIndex = row + } +} + +extension DropdownFieldElement: UIPickerViewDataSource { + public func numberOfComponents(in pickerView: UIPickerView) -> Int { + return 1 + } + + public func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + return items.count + } +} + +// MARK: - PickerFieldViewDelegate + +extension DropdownFieldElement: PickerFieldViewDelegate { + func didBeginEditing(_ pickerFieldView: PickerFieldView) { + // No-op + } + + func didFinish(_ pickerFieldView: PickerFieldView, shouldAutoAdvance: Bool) { + if previouslySelectedIndex != selectedIndex { + didUpdate?(selectedIndex) + } + previouslySelectedIndex = selectedIndex + + if shouldAutoAdvance { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.delegate?.continueToNextField(element: self) + } + } + } + + func didCancel(_ pickerFieldView: PickerFieldView) { + // Reset to previously selected index when canceling + selectedIndex = previouslySelectedIndex + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Element.swift b/StripeUICore/StripeUICore/Source/Elements/Element.swift new file mode 100644 index 00000000..a6998e37 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Element.swift @@ -0,0 +1,116 @@ +// +// Element.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 6/8/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +// MARK: - Element + +/** + Conform to this protocol to participate in the collection of details for a Payment/SetupIntent. + + Think of this as a light-weight, specialized view controller. + */ +@_spi(STP) public protocol Element: AnyObject { + /** + - Note: This is set by your parent. + */ + var delegate: ElementDelegate? { get set } + + /** + Return your UIView instance. + */ + var view: UIView { get } + + /** + - Returns: Whether or not this Element began editing. + */ + func beginEditing() -> Bool + + /** + Whether this element contains valid user input or not. + */ + var validationState: ElementValidationState { get } + + /** + Text to display to the user under the item, if any. + */ + var subLabelText: String? { get } +} + +public extension Element { + func beginEditing() -> Bool { + return false + } + + var validationState: ElementValidationState { + return .valid + } + + var subLabelText: String? { + return nil + } +} + +// MARK: - ElementDelegate + +/** + An Element uses this delegate to communicate events to its owner, which is typically also an Element. + */ +@_spi(STP) public protocol ElementDelegate: AnyObject { + /** + This method is called whenever your public/internally visable state changes. + Note for implementors: Be sure to chain this call upwards to your own ElementDelegate. + */ + func didUpdate(element: Element) + + /** + This method is called when the user finishes editing the caller e.g., by pressing the 'return' key. + Note for implementors: Be sure to chain this call upwards to your own ElementDelegate. + */ + func continueToNextField(element: Element) +} + +/** + An Element uses this delegate to present a view controller + */ +@_spi(STP) public protocol PresentingViewControllerDelegate: ElementDelegate { + /** + Elements will call this function to delegate presentation of a view controller + */ + func presentViewController(viewController: UIViewController, completion: (() -> Void)?) +} + +extension Element { + /// A poorly named convenience method that returns all Elements underneath this Element, including this Element. + public func getAllSubElements() -> [Element] { + switch self { + case let container as ContainerElement: + return [container] + container.elements.flatMap { $0.getAllSubElements() } + default: + return [self] + } + } +} + +@_spi(STP) @frozen public enum ElementValidationState { + case valid + case invalid(error: ElementValidationError, shouldDisplay: Bool) + + /// A convenience property to check if the state is valid because it's hard to make this type Equatable + public var isValid: Bool { + if case .valid = self { + return true + } + return false + } +} + +@_spi(STP) public protocol ElementValidationError: Error { + var localizedDescription: String { get } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/ElementsUI.swift b/StripeUICore/StripeUICore/Source/Elements/ElementsUI.swift new file mode 100644 index 00000000..e069a0a2 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/ElementsUI.swift @@ -0,0 +1,131 @@ +// +// ElementsUI.swift +// StripeUICore +// +// Created by Mel Ludowise on 9/16/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +import UIKit + +@_spi(STP) public enum ElementsUI { + + /// The distances between an Element's content and its containing view + public static let contentViewInsets: NSDirectionalEdgeInsets = .insets(top: 4, leading: 11, bottom: 4, trailing: 11) + public static let fieldBorderColor: UIColor = .systemGray3 + public static let fieldBorderWidth: CGFloat = 1 + public static let textFieldFont: UIFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14)) + public static let sectionTitleFont: UIFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 13, weight: .semibold)) + /// The spacing between elements of a SectionElement + public static let sectionSpacing: CGFloat = 4 + /// The spacing between elements of a FormElement + public static let formSpacing: CGFloat = 12 + public static let defaultCornerRadius: CGFloat = 6 + public static let backgroundColor: UIColor = { + // systemBackground has a 'base' and 'elevated' state; we don't want this behavior. + return .dynamic(light: .systemBackground, dark: .secondarySystemBackground) + }() + + public static let disabledBackgroundColor: UIColor = { + return .dynamic( + light: UIColor(red: 248.0 / 255.0, green: 248.0 / 255.0, blue: 248.0 / 255.0, alpha: 1), + dark: UIColor(red: 116.0 / 255.0, green: 116.0 / 255.0, blue: 128.0 / 255.0, alpha: 0.18) + ) + }() + + public static func makeErrorLabel(theme: ElementsUITheme = .default) -> UILabel { + let label = UILabel() + label.font = theme.fonts.footnote + label.textColor = theme.colors.danger + label.numberOfLines = 0 + label.setContentHuggingPriority(.required, for: .vertical) + return label + } + + public static func makeNoticeTextField(theme: ElementsUITheme = .default) -> UITextView { + let textView = UITextView() + textView.isScrollEnabled = false + textView.isEditable = false + textView.font = theme.fonts.footnote + textView.backgroundColor = .clear + textView.textColor = theme.colors.secondaryText + textView.linkTextAttributes = [.foregroundColor: theme.colors.primary] + return textView + } + + public static func makeSectionTitleLabel(theme: ElementsUITheme = .default) -> UILabel { + let label = UILabel() + label.font = theme.fonts.sectionHeader + label.textColor = theme.colors.secondaryText + label.accessibilityTraits = [.header] + return label + } +} + +/// Describes the appearance of an Element +@_spi(STP) public struct ElementsUITheme { + + /// The default appearance used for Elements + public static let `default` = ElementsUITheme() + + public var fonts = Font() + public var colors = Color() + + public var borderWidth = ElementsUI.fieldBorderWidth + public var cornerRadius = ElementsUI.defaultCornerRadius + public var shadow: Shadow? = Shadow() + + /// Checks if the theme is bright. + public var isBright: Bool { colors.background.isBright } + + /// Checks if the theme is dark. + public var isDark: Bool { !isBright } + + public struct Font { + public init() {} + + public var subheadline = ElementsUI.textFieldFont + public var subheadlineBold = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14, weight: .bold)) + public var sectionHeader = ElementsUI.sectionTitleFont + public var caption = UIFont.systemFont(ofSize: 12, weight: .regular).scaled( + withTextStyle: .caption1, + maximumPointSize: 20) + public var footnote = UIFont.preferredFont(forTextStyle: .footnote, weight: .regular, maximumPointSize: 20) + public var footnoteEmphasis = UIFont.preferredFont(forTextStyle: .footnote, weight: .medium, maximumPointSize: 20) + } + + public struct Color { + public init() {} + + public var primary = UIColor.systemBlue + public var parentBackground = UIColor.systemBackground + public var background = ElementsUI.backgroundColor + public var disabledBackground = ElementsUI.disabledBackgroundColor + public var border = ElementsUI.fieldBorderColor + public var divider = ElementsUI.fieldBorderColor + public var textFieldText = UIColor.label + public var bodyText = UIColor.label + public var secondaryText = UIColor.secondaryLabel + public var placeholderText = UIColor.secondaryLabel + public var danger = UIColor.systemRed + } + + public struct Shadow { + + public var color = UIColor.black + public var opacity = CGFloat(0.05) + public var offset = CGSize(width: 0, height: 2) + public var radius = CGFloat(4) + + init () {} + + public init(color: UIColor, opacity: CGFloat, offset: CGSize, radius: CGFloat) { + self.color = color + self.opacity = opacity + self.offset = offset + self.radius = radius + } + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Factories/Address/AddressSectionElement+DummyAddressLine.swift b/StripeUICore/StripeUICore/Source/Elements/Factories/Address/AddressSectionElement+DummyAddressLine.swift new file mode 100644 index 00000000..46453164 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Factories/Address/AddressSectionElement+DummyAddressLine.swift @@ -0,0 +1,69 @@ +// +// AddressSectionElement+DummyAddressLine.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 7/21/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import UIKit + +extension AddressSectionElement { + /// Looks like a "Address" text field but with the text field disabled + @_spi(STP) public class DummyAddressLine: NSObject, Element, TextFieldViewDelegate, UIGestureRecognizerDelegate { + public var delegate: ElementDelegate? + public lazy var view: UIView = { + let configuration = TextFieldElement.Address.LineConfiguration(lineType: .autoComplete, defaultValue: nil) + let text = "" + let viewModel = TextFieldElement.ViewModel( + placeholder: configuration.label, + accessibilityLabel: configuration.accessibilityLabel, + attributedText: configuration.makeDisplayText(for: text), + keyboardProperties: configuration.keyboardProperties(for: text), + validationState: configuration.validate(text: text, isOptional: configuration.isOptional), + accessoryView: configuration.accessoryView(for: text, theme: theme), + shouldShowClearButton: configuration.shouldShowClearButton, + theme: theme + ) + let textFieldView = TextFieldView(viewModel: viewModel, delegate: self) + textFieldView.isUserInteractionEnabled = false + let view = UIView() + view.addAndPinSubview(textFieldView) + view.addGestureRecognizer(autocompleteLineTapRecognizer) + return view + }() + public var validationState: ElementValidationState { + return .invalid(error: TextFieldElement.Error.empty, shouldDisplay: false) + } + let didTap: () -> Void + public let theme: ElementsUITheme + private lazy var autocompleteLineTapRecognizer: UITapGestureRecognizer = { + let tap = UITapGestureRecognizer(target: self, action: #selector(_didTap)) + tap.delegate = self + return tap + }() + + @objc func _didTap() { + didTap() + } + + func textFieldViewDidUpdate(view: TextFieldView) { + // no-op + } + + func textFieldViewContinueToNextField(view: TextFieldView) { + // no-op + } + + public func beginEditing() -> Bool { + // no-op but pretend we did begin editing + return true + } + + public init(theme: ElementsUITheme, didTap: @escaping () -> Void = {}) { + self.theme = theme + self.didTap = didTap + super.init() + } + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Factories/Address/AddressSectionElement.swift b/StripeUICore/StripeUICore/Source/Elements/Factories/Address/AddressSectionElement.swift new file mode 100644 index 00000000..29ed7723 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Factories/Address/AddressSectionElement.swift @@ -0,0 +1,432 @@ +// +// AddressSectionElement.swift +// StripeUICore +// +// Created by Mel Ludowise on 10/5/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +import UIKit + +/** + A section that contains a country dropdown and the country-specific address fields. It updates the address fields whenever the country changes to reflect the address format of that country. + + In addition to the physical address, it can collect other related fields like name. + */ +@_spi(STP) public class AddressSectionElement: ContainerElement { + public typealias DidUpdateAddress = (AddressDetails) -> Void + + /// Describes an address to use as a default for AddressSectionElement + public struct AddressDetails: Equatable { + @_spi(STP) public static let empty = AddressDetails() + public var name: String? + public var phone: String? + public var address: Address + + /// Initializes an Address + public init(name: String? = nil, phone: String? = nil, address: Address = .init()) { + self.name = name + self.phone = phone + self.address = address + } + + public struct Address: Equatable { + /// City, district, suburb, town, or village. + public var city: String? + + /// Two-letter country code (ISO 3166-1 alpha-2). + public var country: String? + + /// Address line 1 (e.g., street, PO Box, or company name). + public var line1: String? + + /// Address line 2 (e.g., apartment, suite, unit, or building). + public var line2: String? + + /// ZIP or postal code. + public var postalCode: String? + + /// State, county, province, or region. + public var state: String? + + /// Initializes an Address + public init(city: String? = nil, country: String? = nil, line1: String? = nil, line2: String? = nil, postalCode: String? = nil, state: String? = nil) { + self.city = city + self.country = country + self.line1 = line1 + self.line2 = line2 + self.postalCode = postalCode + self.state = state + } + } + } + + /// Describes which address fields to collect + public enum CollectionMode: Equatable { + /// The default collection mode. + /// - Parameter autocompletableCountries: If non-empty, the line1 field displays an autocomplete accessory button if the current country is in this list. Set the `didTapAutocompleteButton` property to be notified when the button is tapped. + case all(autocompletableCountries: [String] = []) + /// Collects country and postal code if the country is one of `countriesRequiringPostalCollection` + /// - Note: Really only useful for cards, where we only collect postal for a handful of countries + case countryAndPostal(countriesRequiringPostalCollection: [String] = ["US", "GB", "CA"]) + /// Replaces the address line 1 field with `self.autoCompleteLine` + case autoCompletable + /// Special case used by some Payment Methods that collect country separately. + case noCountry + } + /// Fields that this section can collect in addition to the address + public struct AdditionalFields { + public init( + name: FieldConfiguration = .disabled, + phone: FieldConfiguration = .disabled, + billingSameAsShippingCheckbox: FieldConfiguration = .disabled + ) { + self.name = name + self.phone = phone + self.billingSameAsShippingCheckbox = billingSameAsShippingCheckbox + } + + public enum FieldConfiguration { + case disabled + case enabled(isOptional: Bool = false) + } + + public let name: FieldConfiguration + public let phone: FieldConfiguration + public let billingSameAsShippingCheckbox: FieldConfiguration + } + + // MARK: Element protocol + public let elements: [Element] + public weak var delegate: ElementDelegate? + public lazy var view: UIView = { + let vStack = UIStackView(arrangedSubviews: [addressSection.view, sameAsCheckbox.view].compactMap { $0 }) + vStack.axis = .vertical + vStack.spacing = 16 + return vStack + }() + + // MARK: Elements + let addressSection: SectionElement + public let name: TextFieldElement? + public let phone: PhoneNumberElement? + public let country: DropdownFieldElement + public private(set) var autoCompleteLine: DummyAddressLine? + public private(set) var line1: TextFieldElement? + public private(set) var line2: TextFieldElement? + public private(set) var city: TextFieldElement? + public private(set) var state: TextOrDropdownElement? + public private(set) var postalCode: TextFieldElement? + public let sameAsCheckbox: CheckboxElement + + // MARK: Other properties + public var collectionMode: CollectionMode { + didSet { + if oldValue != collectionMode { + updateAddressFields(for: countryCodes[country.selectedIndex], address: nil) + } + } + } + public var selectedCountryCode: String { + get { + return countryCodes[country.selectedIndex] + } + set { + guard let index = countryCodes.firstIndex(of: newValue) else { return } + country.selectedIndex = index + updateAddressFields( + for: countryCodes[index] + ) + } + } + var addressDetails: AddressDetails { + let address = AddressDetails.Address(city: city?.text, country: selectedCountryCode, line1: line1?.text, line2: line2?.text, postalCode: postalCode?.text, state: state?.rawData) + return .init(name: name?.text, phone: phone?.phoneNumber?.string(as: .e164), address: address) + } + + public let countryCodes: [String] + let addressSpecProvider: AddressSpecProvider + let theme: ElementsUITheme + private(set) var defaults: AddressDetails + let didTapAutocompleteButton: () -> Void + public var didUpdate: DidUpdateAddress? + + // MARK: - Implementation + /** + Creates an address section with a country dropdown populated from the given list of countryCodes. + + - Parameters: + - title: The title for this section + - countries: List of region codes to display in the country picker dropdown. If nil, the list of countries from `addressSpecProvider` is used instead. + - locale: Locale used to generate the display names for each country + - addressSpecProvider: Determines the list of address fields to display for a selected country + - defaults: Default address to prepopulate address fields with + */ + public init( + title: String? = nil, + countries: [String]? = nil, + locale: Locale = .current, + addressSpecProvider: AddressSpecProvider = .shared, + defaults: AddressDetails = .empty, + collectionMode: CollectionMode = .all(), + additionalFields: AdditionalFields = .init(), + theme: ElementsUITheme = .default, + presentAutoComplete: @escaping () -> Void = { } + ) { + let dropdownCountries = countries?.map { $0.uppercased() } ?? addressSpecProvider.countries + let countryCodes = locale.sortedByTheirLocalizedNames(dropdownCountries) + self.collectionMode = collectionMode + self.countryCodes = countryCodes + self.country = DropdownFieldElement.Address.makeCountry( + label: String.Localized.country_or_region, + countryCodes: countryCodes, + theme: theme, + defaultCountry: defaults.address.country, + locale: locale + ) + self.defaults = defaults + self.addressSpecProvider = addressSpecProvider + self.theme = theme + self.didTapAutocompleteButton = presentAutoComplete + + let initialCountry = countryCodes[country.selectedIndex] + + // Initialize additional fields + self.name = { + if case .enabled(let isOptional) = additionalFields.name { + return TextFieldElement.NameConfiguration(defaultValue: defaults.name, + isOptional: isOptional).makeElement(theme: theme) + } else { + return nil + } + }() + self.phone = { + if case .enabled(let isOptional) = additionalFields.phone { + return PhoneNumberElement( + allowedCountryCodes: countryCodes, + defaultCountryCode: initialCountry, + defaultPhoneNumber: defaults.phone, + isOptional: isOptional, + locale: locale, + theme: theme + ) + } else { + return nil + } + }() + self.sameAsCheckbox = CheckboxElement(theme: theme, label: String.Localized.billing_same_as_shipping, isSelectedByDefault: true) + if case .enabled = additionalFields.billingSameAsShippingCheckbox, let defaultCountry = defaults.address.country, countryCodes.contains(defaultCountry) { + // Country must exist in the dropdown, otherwise this address can't be same as shipping + sameAsCheckbox.view.isHidden = false + } else { + sameAsCheckbox.view.isHidden = true + } + addressSection = SectionElement(title: title, elements: [], theme: theme) + elements = ([addressSection, sameAsCheckbox] as [Element?]).compactMap { $0 } + elements.forEach { $0.delegate = self } + + self.updateAddressFields( + for: initialCountry, + address: defaults.address + ) + country.didUpdate = { [weak self] index in + guard let self = self else { return } + self.updateAddressFields( + for: self.countryCodes[index] + ) + } + sameAsCheckbox.didToggle = { [weak self] isToggled in + guard let self = self else { return } + if isToggled { + // Set the country to the default country + self.country.selectedIndex = self.country.items.firstIndex { + $0.rawData == self.defaults.address.country ?? "" + } ?? self.country.selectedIndex + // Populate our fields with the provided defaults + self.updateAddressFields(for: self.defaults.address.country ?? self.country.selectedItem.rawData, address: self.defaults.address) + } else { + // Clear the fields + self.updateAddressFields(for: self.country.selectedItem.rawData, address: .init()) + } + } + } + + /// Updates the "Billing same as shipping" checkbox and the default address used. + /// - Note: This is a very specific method to handle the case where the merchant-provided default shipping address is updated after the AddressSectionElement is rendered + public func updateBillingSameAsShippingDefaultAddress(_ defaultAddress: AddressDetails.Address) { + // First, update the default address we use + self.defaults.address = defaultAddress + + // Next, show/hide the checkbox if address is valid/invalid + sameAsCheckbox.view.isHidden = defaultAddress == .init() || !countryCodes.contains(defaultAddress.country ?? "country doesnt exist") + guard !sameAsCheckbox.view.isHidden else { + // We're done if the checkbox is hidden + return + } + + // Finally... + if sameAsCheckbox.isSelected { + // ...update the fields with the default values if billing checkbox is shown and checked + self.country.selectedIndex = self.country.items.firstIndex { + $0.rawData == defaults.address.country ?? "" + } ?? self.country.selectedIndex + updateAddressFields(for: defaults.address.country ?? self.country.selectedItem.rawData, address: defaults.address) + } else { + // ...or select the checkbox if the address matches + sameAsCheckbox.isSelected = displayedAddressEqualTo(address: defaultAddress) + } + } + + /// - Parameter address: Populates the new fields with the provided defaults, or the current fields' text if `nil`. + private func updateAddressFields( + for countryCode: String, + address: AddressDetails.Address? = nil + ) { + // Create the new address fields' default text + let address = address ?? AddressDetails.Address( + city: city?.text, + country: nil, + line1: line1?.text, + line2: line2?.text, + postalCode: postalCode?.text, + state: state?.rawData + ) + + // Get the address spec for the country and filter out unused fields + let spec = addressSpecProvider.addressSpec(for: countryCode) + let fieldOrdering = spec.fieldOrdering.filter { + switch collectionMode { + case .all, .noCountry: + return true + case .countryAndPostal(let countriesRequiringPostalCollection): + if case .postal = $0 { + return countriesRequiringPostalCollection.contains(countryCode) + } else { + return false + } + case .autoCompletable: + return false + } + } + + if collectionMode == .autoCompletable { + autoCompleteLine = autoCompleteLine ?? DummyAddressLine(theme: theme, didTap: didTapAutocompleteButton) + } else { + autoCompleteLine = nil + } + // Re-create the address fields + if fieldOrdering.contains(.line) { + if case .all(let autocompletableCountries) = collectionMode, autocompletableCountries.caseInsensitiveContains(countryCode) { + line1 = TextFieldElement.Address.LineConfiguration( + lineType: .line1Autocompletable(didTapAutocomplete: didTapAutocompleteButton), + defaultValue: address.line1 + ).makeElement(theme: theme) + } else { + line1 = TextFieldElement.Address.makeLine1(defaultValue: address.line1, theme: theme) + } + } + line2 = fieldOrdering.contains(.line) ? + TextFieldElement.Address.makeLine2(defaultValue: address.line2, theme: theme) : nil + city = fieldOrdering.contains(.city) ? + spec.makeCityElement(defaultValue: address.city, theme: theme) : nil + state = fieldOrdering.contains(.state) ? + spec.makeStateElement(defaultValue: address.state, + stateDict: Dictionary(uniqueKeysWithValues: zip(spec.subKeys ?? [], spec.subLabels ?? [])), + theme: theme) : nil + postalCode = fieldOrdering.contains(.postal) ? + spec.makePostalElement(countryCode: countryCode, defaultValue: address.postalCode, theme: theme) : nil + + // Order the address fields according to `fieldOrdering` + let addressFields: [Element?] = fieldOrdering.reduce([]) { partialResult, fieldType in + // This should be a flatMap but I'm having trouble satisfying the compiler + switch fieldType { + case .line: + return partialResult + [line1, line2] + case .city: + return partialResult + [city] + case .state: + return partialResult + [state] + case .postal: + return partialResult + [postalCode] + } + } + + var initialElements: [Element?] = [name] + if collectionMode != .noCountry { + initialElements.append(country) + } + initialElements.append(autoCompleteLine) + let phoneElement: [Element?] = [phone] + addressSection.elements = (initialElements + addressFields + phoneElement).compactMap { $0 } + } + + /// Returns `true` iff all **displayed** address fields match the given `address`, treating `nil` and "" as equal. + func displayedAddressEqualTo(address: AddressDetails.Address) -> Bool { + var allDisplayedFieldsEqual = true + if let city = city, city.text.nonEmpty != address.city?.nonEmpty { + allDisplayedFieldsEqual = false + } + if country.selectedItem.rawData != address.country?.nonEmpty { + allDisplayedFieldsEqual = false + } + if let line1 = line1, line1.text.nonEmpty != address.line1?.nonEmpty { + allDisplayedFieldsEqual = false + } + if let line2 = line2, line2.text.nonEmpty != address.line2?.nonEmpty { + allDisplayedFieldsEqual = false + } + if let postalCode = postalCode, postalCode.text.nonEmpty != address.postalCode?.nonEmpty { + allDisplayedFieldsEqual = false + } + if let state = state, state.rawData.nonEmpty != address.state?.nonEmpty { + allDisplayedFieldsEqual = false + } + return allDisplayedFieldsEqual + } +} + +// MARK: - Element +extension AddressSectionElement: Element { + @discardableResult + public func beginEditing() -> Bool { + let firstInvalidNonDropDownElement = elements.first(where: { + switch $0.validationState { + case .valid: + return false + case .invalid: + return !($0 is DropdownFieldElement) + } + }) + + // If first non-dropdown element is auto complete, don't do anything + if firstInvalidNonDropDownElement === autoCompleteLine { + return false + } + + return firstInvalidNonDropDownElement?.beginEditing() ?? false + } +} + +// MARK: - ElementDelegate +extension AddressSectionElement: ElementDelegate { + public func didUpdate(element: Element) { + if !sameAsCheckbox.view.isHidden, sameAsCheckbox.isSelected, !displayedAddressEqualTo(address: defaults.address) { + // Deselect checkbox if the address != the shipping address (our `defaults`) + sameAsCheckbox.isSelected = false + } + delegate?.didUpdate(element: self) + didUpdate?(addressDetails) + + // Update the selected country in the phone element if the no defaults have been provided + // and the phone number element hasn't been modified + // to match the country picker if they don't match + if let phone = phone, + defaults.phone == nil, + !phone.hasBeenModified + && phone.countryDropdownElement.selectedIndex != country.selectedIndex { + phone.selectCountry(index: country.selectedIndex, shouldUpdateDefaultNumber: true) + } + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Factories/Address/AddressSpec+ElementFactory.swift b/StripeUICore/StripeUICore/Source/Elements/Factories/Address/AddressSpec+ElementFactory.swift new file mode 100644 index 00000000..9a742c49 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Factories/Address/AddressSpec+ElementFactory.swift @@ -0,0 +1,55 @@ +// +// AddressSpec+ElementFactory.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 6/9/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// Convenience methods to create address fields that are localized according to the AddressSpec +extension AddressSpec { + func makeCityElement(defaultValue: String?, theme: ElementsUITheme = .default) -> TextFieldElement { + return TextFieldElement.Address.CityConfiguration( + label: cityNameType.localizedLabel, + defaultValue: defaultValue, + isOptional: !requiredFields.contains(.city) + ).makeElement(theme: theme) + } + + func makeStateElement(defaultValue: String?, stateDict: [String: String], theme: ElementsUITheme = .default) -> TextOrDropdownElement { + // If no state dict just use a textfield for state + if stateDict.isEmpty { + return TextFieldElement.Address.StateConfiguration( + label: stateNameType.localizedLabel, + defaultValue: defaultValue, + isOptional: !requiredFields.contains(.state) + ).makeElement(theme: theme) + } + + // Otherwise create a dropdown with the provided states + let items = stateDict.map({DropdownFieldElement.DropdownItem(pickerDisplayName: $0.value, + labelDisplayName: $0.value, + accessibilityValue: $0.value, + rawData: $0.key)}).sorted { $0.pickerDisplayName.string < $1.pickerDisplayName.string } + + let defaultIndex = items.firstIndex(where: {$0.rawData.lowercased() == defaultValue?.lowercased() + || $0.pickerDisplayName.string.lowercased() == defaultValue?.lowercased()}) ?? 0 + + return DropdownFieldElement(items: items, + defaultIndex: defaultIndex, + label: stateNameType.localizedLabel, + theme: theme, + didUpdate: nil) + } + + func makePostalElement(countryCode: String, defaultValue: String?, theme: ElementsUITheme = .default) -> TextFieldElement { + return TextFieldElement.Address.PostalCodeConfiguration( + countryCode: countryCode, + label: zipNameType.localizedLabel, + defaultValue: defaultValue, + isOptional: !requiredFields.contains(.postal) + ).makeElement(theme: theme) + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Factories/Address/AddressSpec.swift b/StripeUICore/StripeUICore/Source/Elements/Factories/Address/AddressSpec.swift new file mode 100644 index 00000000..b1a68aca --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Factories/Address/AddressSpec.swift @@ -0,0 +1,147 @@ +// +// AddressSpec.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 7/19/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +/** + This represents the format of each country's dictionary in `localized_address_data.json` + */ +struct AddressSpec: Decodable { + enum StateNameType: String, Codable { + case area, county, department, do_si, emirate, island, oblast, parish, prefecture, state, province + var localizedLabel: String { + switch self { + case .area: return String.Localized.area + case .county: return String.Localized.county + case .department: return String.Localized.department + case .do_si: return String.Localized.do_si + case .emirate: return String.Localized.emirate + case .island: return String.Localized.island + case .oblast: return String.Localized.oblast + case .parish: return String.Localized.parish + case .prefecture: return String.Localized.prefecture + case .state: return String.Localized.state + case .province: return String.Localized.province + } + } + + init(from decoder: Decoder) throws { + let state_name_type = try decoder.singleValueContainer().decode(String.self) + self = StateNameType(rawValue: state_name_type) ?? .prefecture + } + } + enum ZipNameType: String, Codable { + case eircode, pin, zip, postal_code + var localizedLabel: String { + switch self { + case .eircode: return String.Localized.eircode + case .pin: return String.Localized.postal_pin + case .zip: return String.Localized.zip + case .postal_code: return String.Localized.postal_code + } + } + + init(from decoder: Decoder) throws { + let zip_name_type = try decoder.singleValueContainer().decode(String.self) + self = ZipNameType(rawValue: zip_name_type) ?? .postal_code + } + } + enum LocalityNameType: String, Codable { + case district, suburb, post_town, suburb_or_city, city + var localizedLabel: String { + switch self { + case .district: return String.Localized.district + case .suburb: return String.Localized.suburb + case .post_town: return String.Localized.post_town + case .suburb_or_city: return String.Localized.suburb_or_city + case .city: return String.Localized.city + } + } + init(from decoder: Decoder) throws { + let locality_name_type = try decoder.singleValueContainer().decode(String.self) + self = LocalityNameType(rawValue: locality_name_type) ?? .suburb_or_city + } + } + /// An enum of the fields that `AddressSpec` describes. + enum FieldType: String { + /// Address lines 1 and 2 + case line = "A" + case city = "C" + case state = "S" + case postal = "Z" + } + + /// The order to display the fields. + let fieldOrdering: [FieldType] + let requiredFields: [FieldType] + let cityNameType: LocalityNameType + let stateNameType: StateNameType + let zip: String? + let zipNameType: ZipNameType + let subKeys: [String]? // e.g. state abbreviations - "CA" + let subLabels: [String]? // e.g. state display names - "California" + + enum CodingKeys: String, CodingKey { + case format = "fmt" + case require = "require" + case localityNameType = "locality_name_type" // e.g. City + case stateNameType = "state_name_type" + case zip = "zip" + case zipNameType = "zip_name_type" + case subKeys = "sub_keys" + case subLabels = "sub_labels" + } + + static var `default`: AddressSpec { + return AddressSpec() + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + format: try? container.decode(String.self, forKey: .format), + require: try? container.decode(String.self, forKey: .require), + cityNameType: try? container.decode(LocalityNameType.self, forKey: .localityNameType), + stateNameType: try? container.decode(StateNameType.self, forKey: .stateNameType), + zip: try? container.decode(String.self, forKey: .zip), + zipNameType: try? container.decode(ZipNameType.self, forKey: .zipNameType), + subKeys: try? container.decode([String].self, forKey: .subKeys), + subLabels: try? container.decode([String].self, forKey: .subLabels) + ) + } + + init( + format: String? = nil, + require: String? = nil, + cityNameType: LocalityNameType? = nil, + stateNameType: StateNameType? = nil, + zip: String? = nil, + zipNameType: ZipNameType? = nil, + subKeys: [String]? = nil, + subLabels: [String]? = nil + ) { + var fieldOrdering: [FieldType] = (format ?? "NACSZ").compactMap { + FieldType(rawValue: String($0)) + } + // We always collect line1 and line2 ("A"), so prepend if it's missing + if !fieldOrdering.contains(FieldType.line) { + fieldOrdering = [.line] + fieldOrdering + } + self.fieldOrdering = fieldOrdering + self.requiredFields = (require ?? "ACSZ").compactMap { + FieldType(rawValue: String($0)) + } + self.cityNameType = cityNameType ?? .city + self.stateNameType = stateNameType ?? .province + self.zip = zip + self.zipNameType = zipNameType ?? .postal_code + self.subKeys = subKeys + self.subLabels = subLabels + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Factories/Address/AddressSpecProvider.swift b/StripeUICore/StripeUICore/Source/Elements/Factories/Address/AddressSpecProvider.swift new file mode 100644 index 00000000..ce3a96e9 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Factories/Address/AddressSpecProvider.swift @@ -0,0 +1,58 @@ +// +// AddressSpecProvider.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 7/19/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +// This file was adapted from https://git.corp.stripe.com/stripe-internal/stripe-js-v3/blob/bdc2eeed/src/elements/inner/shared/address/addressData.ts +let addressDataFilename = "localized_address_data" + +@_spi(STP) public class AddressSpecProvider { + @_spi(STP) public static var shared: AddressSpecProvider = AddressSpecProvider() + var addressSpecs: [String: AddressSpec] = [:] + public var countries: [String] { + return addressSpecs.map { $0.key } + } + private lazy var addressSpecsUpdateQueue: DispatchQueue = { + DispatchQueue(label: addressDataFilename, qos: .userInitiated) + }() + + /// Loads address specs with a completion block + public func loadAddressSpecs(completion: (() -> Void)? = nil) { + addressSpecsUpdateQueue.async { + let bundle = StripeUICoreBundleLocator.resourcesBundle + guard + self.addressSpecs.isEmpty, + let url = bundle.url(forResource: addressDataFilename, withExtension: ".json"), + let data = try? Data(contentsOf: url), + let addressSpecs = try? JSONDecoder().decode([String: AddressSpec].self, from: data) + else { + completion?() + return + } + self.addressSpecs = addressSpecs + completion?() + } + } + + /// Loads address specs with a promise + public func loadAddressSpecs() -> Promise { + let promise = Promise() + loadAddressSpecs { + promise.resolve(with: ()) + } + return promise + } + + func addressSpec(for country: String) -> AddressSpec { + guard let spec = addressSpecs[country] else { + return AddressSpec.default + } + return spec + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Factories/BSB/BSBNumberProvider.swift b/StripeUICore/StripeUICore/Source/Elements/Factories/BSB/BSBNumberProvider.swift new file mode 100644 index 00000000..044de07d --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Factories/BSB/BSBNumberProvider.swift @@ -0,0 +1,53 @@ +// +// BSBNumberProvider.swift +// StripeUICore +// +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +@_spi(STP) public class BSBNumberProvider { + private let bsbDataFilename = "au_becs_bsb" + + @_spi(STP) public static var shared: BSBNumberProvider = BSBNumberProvider() + var bsbNumberToNameMapping: [String: String] = [:] + private lazy var bsbNumberUpdateQueue: DispatchQueue = { + DispatchQueue(label: "com.stripe.BSB.BSBNumberProvider", qos: .userInitiated) + }() + + public func loadBSBData(completion: (() -> Void)? = nil) { + bsbNumberUpdateQueue.async { + let bundle = StripeUICoreBundleLocator.resourcesBundle + guard self.bsbNumberToNameMapping.isEmpty, + let url = bundle.url(forResource: self.bsbDataFilename, withExtension: ".json"), + let data = try? Data(contentsOf: url), + let decodedBSBs = try? JSONDecoder().decode([String: String].self, from: data) else { + completion?() + return + } + #if DEBUG + var accumulator: [String: String] = ["00": "Stripe Test Bank"] + decodedBSBs.forEach { (key, value) in + accumulator[key] = value + } + self.bsbNumberToNameMapping = accumulator + #else + self.bsbNumberToNameMapping = decodedBSBs + #endif + completion?() + return + } + } + + func bsbName(for bsbNumber: String) -> String { + for i in (2...3).reversed() { + let bsbPrefix = String(bsbNumber.prefix(i)) + if let resolvedBSBName = bsbNumberToNameMapping[bsbPrefix] { + return resolvedBSBName + } + } + return "" + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Factories/DropdownFieldElement+AddressFactory.swift b/StripeUICore/StripeUICore/Source/Elements/Factories/DropdownFieldElement+AddressFactory.swift new file mode 100644 index 00000000..3b4650aa --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Factories/DropdownFieldElement+AddressFactory.swift @@ -0,0 +1,54 @@ +// +// DropdownFieldElement+AddressFactory.swift +// StripeUICore +// +// Created by Mel Ludowise on 9/28/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +@_spi(STP) public extension DropdownFieldElement { + // MARK: - Address + + enum Address { + + // MARK: - Country + + public static func makeCountry( + label: String, + countryCodes: [String], + theme: ElementsUITheme = .default, + defaultCountry: String? = nil, + locale: Locale = Locale.current, + disableDropdownWithSingleCountry: Bool = false + ) -> DropdownFieldElement { + let dropdownItems: [DropdownItem] = countryCodes.map { + let flagEmoji = String.countryFlagEmoji(for: $0) ?? "" // 🇺🇸 + let countryName = locale.localizedString(forRegionCode: $0) ?? $0 // United States + #if targetEnvironment(macCatalyst) + // When using UIMenu with a keyboard, type-ahead search is based on the string name. + // This doesn't work if we prepend an emoji, so leave that out on macOS. + let pickerDisplayName = countryName + #else + let pickerDisplayName = "\(flagEmoji) \(countryName)" + #endif + return DropdownItem(pickerDisplayName: pickerDisplayName, + labelDisplayName: countryName, + accessibilityValue: countryName, + rawData: $0) + } + let defaultCountry = defaultCountry ?? locale.regionCode ?? "" + let defaultCountryIndex = countryCodes.firstIndex(of: defaultCountry) ?? 0 + + return DropdownFieldElement( + items: dropdownItems, + defaultIndex: defaultCountryIndex, + label: String.Localized.country_or_region, + theme: theme, + disableDropdownWithSingleElement: disableDropdownWithSingleCountry + ) + } + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Factories/IDNumberTextFieldConfiguration.swift b/StripeUICore/StripeUICore/Source/Elements/Factories/IDNumberTextFieldConfiguration.swift new file mode 100644 index 00000000..b1451da0 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Factories/IDNumberTextFieldConfiguration.swift @@ -0,0 +1,142 @@ +// +// IDNumberTextFieldConfiguration.swift +// StripeUICore +// +// Created by Mel Ludowise on 9/27/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +@_spi(STP) public struct IDNumberTextFieldConfiguration: TextFieldElementConfiguration { + + // TODO(mludowise|IDPROD-2596): Check if these are the formats needed to support IDV. + // When finalized, change enum to String type so we can configure allowed formats from our backend response. + public enum IDNumberType { + case BR_CPF + case BR_CPF_CNPJ + case SG_NRIC_OR_FIN + case US_SSN_LAST4 + } + + let type: IDNumberType? + public let label: String + + /** + - Parameters: + - type: The type of ID number that should be validated in this input field. If the ID type is unknown, passing `nil` will produce a configuration with no restrictions on the input. + - label: The label of the field + */ + public init(type: IDNumberType?, label: String) { + self.type = type + self.label = label + } + + public var disallowedCharacters: CharacterSet { + switch type { + case .BR_CPF, + .BR_CPF_CNPJ, + .US_SSN_LAST4: + return CharacterSet.stp_asciiDigit.inverted + case .SG_NRIC_OR_FIN, + .none: + return .newlines + } + } + + public func maxLength(for text: String) -> Int { + switch type { + case .BR_CPF: + return 11 + case .BR_CPF_CNPJ: + return 14 + case .US_SSN_LAST4: + return 4 + case .SG_NRIC_OR_FIN, + .none: + return .max + } + } + + /** + - Parameters: + - text: The text that will be formatted with this formatter + + - Returns: The format consisting of a string using pound signs `#` for numeric placeholders, and `*` for letters. + */ + func format(text: String) -> String? { + switch type { + case .BR_CPF, + .BR_CPF_CNPJ where text.count <= 11: + return "###.###.###-##" + case .BR_CPF_CNPJ: + return "##.###.###/####-##" + case .US_SSN_LAST4: + return "••• - •• - ####" + case .none: + return nil + case .some(.SG_NRIC_OR_FIN): + return nil + } + } + + public func validate(text: String, isOptional: Bool) -> TextFieldElement.ValidationState { + guard !text.isEmpty else { + return isOptional ? .valid : .invalid(TextFieldElement.Error.empty) + } + + switch type { + // CPF is 11 digits but CNPJ is 14 (maxLength), so we will allow 11 here + case .BR_CPF_CNPJ where text.count == 11, + .none: + return .valid + case .SG_NRIC_OR_FIN: + return .valid + default: + return maxLength(for: text) == text.count + ? .valid + : .invalid( + TextFieldElement.Error.incomplete( + localizedDescription: STPLocalizedString( + "The ID number you entered is incomplete.", + "An error message." + ) + ) + ) + } + } + + public func makeDisplayText(for text: String) -> NSAttributedString { + guard let format = format(text: text), + let formatter = TextFieldFormatter(format: format) + else { + return NSAttributedString(string: text) + } + + let formattedString = formatter.applyFormat( + to: text + ) + + return NSAttributedString(string: formattedString) + } + + public func keyboardProperties(for text: String) -> TextFieldElement.KeyboardProperties { + switch type { + case .BR_CPF, + .BR_CPF_CNPJ, + .US_SSN_LAST4: + return .init( + type: .asciiCapableNumberPad, + textContentType: nil, + autocapitalization: .none + ) + default: + return .init( + type: .default, + textContentType: nil, + autocapitalization: .allCharacters + ) + } + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Factories/TextFieldElement+AccountFactory.swift b/StripeUICore/StripeUICore/Source/Elements/Factories/TextFieldElement+AccountFactory.swift new file mode 100644 index 00000000..5b02d63a --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Factories/TextFieldElement+AccountFactory.swift @@ -0,0 +1,145 @@ +// +// TextFieldElement+AccountFactory.swift +// StripeUICore +// +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +import UIKit + +@_spi(STP) public extension TextFieldElement { + + enum Account { + // MARK: - BSB Number + struct BSBConfiguration: TextFieldElementConfiguration { + static let incompleteError = Error.incomplete(localizedDescription: String.Localized.incompleteBSBEntered) + + let label = STPLocalizedString("BSB number", "Placeholder for AU BECS BSB number") + let disallowedCharacters: CharacterSet = .stp_invertedAsciiDigit + func maxLength(for text: String) -> Int { + return 6 + } + let defaultValue: String? + func subLabel(text: String) -> String? { + return BSBNumberProvider.shared.bsbName(for: text) + } + + public func validate(text: String, isOptional: Bool) -> TextFieldElement.ValidationState { + if text.isEmpty { + return isOptional ? .valid : .invalid(Error.empty) + } + + let bsbNumber = BSBNumber(number: text) + return bsbNumber.isComplete ? .valid : + .invalid(Account.BSBConfiguration.incompleteError) + } + + func keyboardProperties(for text: String) -> TextFieldElement.KeyboardProperties { + return .init(type: .numberPad, textContentType: .none, autocapitalization: .none) + } + + public func makeDisplayText(for text: String) -> NSAttributedString { + let bsbNumber = BSBNumber(number: text) + return NSAttributedString(string: bsbNumber.formattedNumber()) + } + } + + public static func makeBSB(defaultValue: String?, theme: ElementsUITheme = .default) -> TextFieldElement { + return TextFieldElement(configuration: BSBConfiguration(defaultValue: defaultValue), theme: theme) + } + + // MARK: - AUBECS Account Number + struct AUBECSAccountNumberConfiguration: TextFieldElementConfiguration { + static let incompleteError = Error.incomplete(localizedDescription: + String.Localized.incompleteAccountNumber) + let label = String.Localized.accountNumber + let disallowedCharacters: CharacterSet = .stp_invertedAsciiDigit + let numberOfDigitsRequired = 9 + + func maxLength(for text: String) -> Int { + return numberOfDigitsRequired + } + let defaultValue: String? + + public func validate(text: String, isOptional: Bool) -> TextFieldElement.ValidationState { + if text.isEmpty { + return isOptional ? .valid : .invalid(Error.empty) + } + return text.count == numberOfDigitsRequired ? .valid : .invalid(AUBECSAccountNumberConfiguration.incompleteError) + } + + func keyboardProperties(for text: String) -> TextFieldElement.KeyboardProperties { + return .init(type: .numberPad, textContentType: .none, autocapitalization: .none) + } + } + + public static func makeAUBECSAccountNumber(defaultValue: String?, theme: ElementsUITheme = .default) -> TextFieldElement { + return TextFieldElement(configuration: AUBECSAccountNumberConfiguration(defaultValue: defaultValue), theme: theme) + } + + // MARK: - Bacs Sort Code + struct SortCodeConfiguration: TextFieldElementConfiguration { + static let invalidError = Error.incomplete(localizedDescription: String.Localized.invalidSortCodeEntered) + + let label = STPLocalizedString("Sort code", "Placeholder for Bacs sort code (a bank routing number used in the UK and Ireland)") + let disallowedCharacters: CharacterSet = .stp_invertedAsciiDigit + func maxLength(for text: String) -> Int { + return 6 + } + let defaultValue: String? + + public func validate(text: String, isOptional: Bool) -> TextFieldElement.ValidationState { + if text.isEmpty { + return isOptional ? .valid : .invalid(Error.empty) + } + + let sortCode = SortCode(number: text) + return sortCode.isComplete ? .valid : + .invalid(Account.SortCodeConfiguration.invalidError) + } + + func keyboardProperties(for text: String) -> TextFieldElement.KeyboardProperties { + return .init(type: .numberPad, textContentType: .none, autocapitalization: .none) + } + + public func makeDisplayText(for text: String) -> NSAttributedString { + let sortCode = SortCode(number: text) + return NSAttributedString(string: sortCode.formattedNumber()) + } + } + + public static func makeSortCode(defaultValue: String?, theme: ElementsUITheme = .default) -> TextFieldElement { + return TextFieldElement(configuration: SortCodeConfiguration(defaultValue: defaultValue), theme: theme) + } + + // MARK: - Bacs Account Number + struct BacsAccountNumberConfiguration: TextFieldElementConfiguration { + static let incompleteError = Error.incomplete(localizedDescription: String.Localized.incompleteAccountNumber) + let label = String.Localized.accountNumber + let disallowedCharacters: CharacterSet = .stp_invertedAsciiDigit + let numberOfDigitsRequired = 8 + + func maxLength(for text: String) -> Int { + return numberOfDigitsRequired + } + let defaultValue: String? + + public func validate(text: String, isOptional: Bool) -> TextFieldElement.ValidationState { + if text.isEmpty { + return isOptional ? .valid : .invalid(Error.empty) + } + return text.count == numberOfDigitsRequired ? .valid : .invalid(BacsAccountNumberConfiguration.incompleteError) + } + + func keyboardProperties(for text: String) -> TextFieldElement.KeyboardProperties { + return .init(type: .numberPad, textContentType: .none, autocapitalization: .none) + } + } + + public static func makeBacsAccountNumber(defaultValue: String?, theme: ElementsUITheme = .default) -> TextFieldElement { + return TextFieldElement(configuration: BacsAccountNumberConfiguration(defaultValue: defaultValue), theme: theme) + } + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Factories/TextFieldElement+AddressFactory.swift b/StripeUICore/StripeUICore/Source/Elements/Factories/TextFieldElement+AddressFactory.swift new file mode 100644 index 00000000..7bee2256 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Factories/TextFieldElement+AddressFactory.swift @@ -0,0 +1,147 @@ +// +// TextFieldElement+AddressFactory.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 6/8/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +import UIKit + +@_spi(STP) public extension TextFieldElement { + enum Address { + + // MARK: - Line1, Line2 + + struct LineConfiguration: TextFieldElementConfiguration { + + enum LineType { + case line1 + case line2 + // Label is "Address" and shows a clear button + case autoComplete + // Same as .line1, but shows a 􀊫 autocomplete button accessory view + case line1Autocompletable(didTapAutocomplete: () -> Void) + } + let lineType: LineType + var label: String { + switch lineType { + case .line1, .line1Autocompletable: + return String.Localized.address_line1 + case .line2: + return String.Localized.address_line2 + case .autoComplete: + return String.Localized.address + } + } + let defaultValue: String? + var shouldShowClearButton: Bool { + if case .autoComplete = lineType { return true } + return false + } + + func keyboardProperties(for text: String) -> TextFieldElement.KeyboardProperties { + switch lineType { + case .line1, .line1Autocompletable, .autoComplete: + return .init(type: .default, textContentType: .streetAddressLine1, autocapitalization: .words) + case .line2: + return .init(type: .default, textContentType: .streetAddressLine2, autocapitalization: .words) + } + } + + var isOptional: Bool { + if case .line2 = lineType { return true } // Hardcode all line2 as optional + return false + } + + func accessoryView(for text: String, theme: ElementsUITheme) -> UIView? { + if case .line1Autocompletable(let didTapAutocomplete) = lineType { + let autocompleteIconButton = UIButton.make(type: .system, didTap: didTapAutocomplete) + let configuration = UIImage.SymbolConfiguration(pointSize: CGFloat(10), weight: .bold) + let image = UIImage(systemName: "magnifyingglass", withConfiguration: configuration)? + .withTintColor(theme.colors.primary, renderingMode: .alwaysOriginal) + autocompleteIconButton.setImage(image, for: .normal) + autocompleteIconButton.accessibilityLabel = String.Localized.search + autocompleteIconButton.accessibilityIdentifier = "autocomplete_affordance" + return autocompleteIconButton + } + return nil + } + } + + public static func makeLine1(defaultValue: String?, theme: ElementsUITheme) -> TextFieldElement { + return TextFieldElement( + configuration: LineConfiguration(lineType: .line1, defaultValue: defaultValue), theme: theme + ) + } + + static func makeLine2(defaultValue: String?, theme: ElementsUITheme) -> TextFieldElement { + let line2 = TextFieldElement( + configuration: LineConfiguration(lineType: .line2, defaultValue: defaultValue), theme: theme + ) + return line2 + } + + public static func makeAutoCompleteLine(defaultValue: String?, theme: ElementsUITheme) -> TextFieldElement { + return TextFieldElement( + configuration: LineConfiguration(lineType: .autoComplete, defaultValue: defaultValue), theme: theme + ) + } + + // MARK: - City/Locality + + struct CityConfiguration: TextFieldElementConfiguration { + let label: String + let defaultValue: String? + let isOptional: Bool + + func keyboardProperties(for text: String) -> TextFieldElement.KeyboardProperties { + return .init(type: .default, textContentType: .addressCity, autocapitalization: .words) + } + } + + // MARK: - State/Province/Administrative area/etc. + + struct StateConfiguration: TextFieldElementConfiguration { + let label: String + let defaultValue: String? + let isOptional: Bool + + func keyboardProperties(for text: String) -> TextFieldElement.KeyboardProperties { + return .init(type: .default, textContentType: .addressState, autocapitalization: .words) + } + } + + // MARK: - Postal code/Zip code + + struct PostalCodeConfiguration: TextFieldElementConfiguration { + let countryCode: String + let label: String + let defaultValue: String? + let isOptional: Bool + public var disallowedCharacters: CharacterSet { + return countryCode == "US" ? .decimalDigits.inverted : .newlines + } + + func maxLength(for text: String) -> Int { + return countryCode == "US" ? 5 : .max + } + + func validate(text: String, isOptional: Bool) -> ValidationState { + if text.isEmpty { + return isOptional ? .valid : .invalid(Error.empty) + } + if countryCode == "US", text.count < maxLength(for: text) { + return .invalid(Error.incomplete(localizedDescription: String.Localized.your_zip_is_incomplete)) + } + return .valid + } + + func keyboardProperties(for text: String) -> TextFieldElement.KeyboardProperties { + return .init(type: countryCode == "US" ? .numberPad : .default, textContentType: .postalCode, autocapitalization: .allCharacters) + } + } + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Factories/TextFieldElement+Factory.swift b/StripeUICore/StripeUICore/Source/Elements/Factories/TextFieldElement+Factory.swift new file mode 100644 index 00000000..1fc1881c --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Factories/TextFieldElement+Factory.swift @@ -0,0 +1,234 @@ +// +// TextFieldElement+Factory.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 6/17/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +import UIKit + +@_spi(STP) public extension TextFieldElement { + + // MARK: - Name + struct NameConfiguration: TextFieldElementConfiguration { + @frozen public enum NameType { + case given, family, full, onAccount + } + + let type: NameType + public let defaultValue: String? + public let label: String + public let isOptional: Bool + private var textContentType: UITextContentType { + switch type { + case .given: + return .givenName + case .family: + return .familyName + case .full, .onAccount: + return .name + } + } + + /// - Parameter label: If `nil`, defaults to a string on the `type` e.g. "Name" + public init(type: NameType = .full, defaultValue: String?, label: String? = nil, isOptional: Bool = false) { + self.type = type + self.defaultValue = defaultValue + if let label = label { + self.label = label + } else { + self.label = Self.label(for: type) + } + self.isOptional = isOptional + } + + public func keyboardProperties(for text: String) -> TextFieldElement.KeyboardProperties { + return .init(type: .default, textContentType: textContentType, autocapitalization: .words) + } + + private static func label(for type: NameType) -> String { + switch type { + case .given: + return String.Localized.given_name + case .family: + return String.Localized.family_name + case .full: + return String.Localized.full_name + case .onAccount: + return String.Localized.nameOnAccount + } + } + } + + static func makeName(label: String? = nil, defaultValue: String?, theme: ElementsUITheme = .default) -> TextFieldElement { + return TextFieldElement(configuration: NameConfiguration(type: .full, defaultValue: defaultValue, label: label), theme: theme) + } + + // MARK: - Email + + struct EmailConfiguration: TextFieldElementConfiguration { + public let label = String.Localized.email + public let defaultValue: String? + public let disallowedCharacters: CharacterSet = .whitespacesAndNewlines + let invalidError = Error.invalid( + localizedDescription: String.Localized.invalid_email + ) + + public func validate(text: String, isOptional: Bool) -> ValidationState { + if text.isEmpty { + return isOptional ? .valid : .invalid(Error.empty) + } + if STPEmailAddressValidator.stringIsValidEmailAddress(text) { + return .valid + } else { + return .invalid(invalidError) + } + } + + public func keyboardProperties(for text: String) -> TextFieldElement.KeyboardProperties { + return .init(type: .emailAddress, textContentType: .emailAddress, autocapitalization: .none) + } + } + + static func makeEmail(defaultValue: String?, theme: ElementsUITheme = .default) -> TextFieldElement { + return TextFieldElement(configuration: EmailConfiguration(defaultValue: defaultValue), theme: theme) + } + + // MARK: VPA + + struct VPAConfiguration: TextFieldElementConfiguration { + public let label = String.Localized.upi_id + public let disallowedCharacters: CharacterSet = .whitespacesAndNewlines + let invalidError = Error.invalid( + localizedDescription: .Localized.invalid_upi_id + ) + + public func validate(text: String, isOptional: Bool) -> ValidationState { + guard !text.isEmpty else { + return isOptional ? .valid : .invalid(Error.empty) + } + + return STPVPANumberValidator.stringIsValidVPANumber(text) ? .valid : .invalid(invalidError) + } + + public func keyboardProperties(for text: String) -> TextFieldElement.KeyboardProperties { + return .init(type: .emailAddress, textContentType: .emailAddress, autocapitalization: .none) + } + + } + + static func makeVPA(theme: ElementsUITheme = .default) -> TextFieldElement { + return TextFieldElement(configuration: VPAConfiguration(), theme: theme) + } + + // MARK: - Blik code + struct BlikCodeConfiguration: TextFieldElementConfiguration { + public let label = String.Localized.blik_code + public let disallowedCharacters: CharacterSet = .decimalDigits.inverted + let invalidError = Error.invalid( + localizedDescription: .Localized.invalid_blik_code + ) + + public func validate(text: String, isOptional: Bool) -> ValidationState { + guard !text.isEmpty else { + return isOptional ? .valid : .invalid(Error.empty) + } + return STPBlikCodeValidator.stringIsValidBlikCode(text) ?.valid: .invalid(invalidError) + } + + public func keyboardProperties(for text: String) -> TextFieldElement.KeyboardProperties { + return .init(type: .numberPad, textContentType: .none, autocapitalization: .none) + } + + public func maxLength(for text: String) -> Int { + return 6 + } + } + + static func makeBlikCode(theme: ElementsUITheme = .default) -> TextFieldElement { + return TextFieldElement(configuration: BlikCodeConfiguration(), theme: theme) + } + + // MARK: - Konbini confirmation/phone number + + /// An optional 10 to 11 digit numeric-only string determining the confirmation code at applicable convenience stores. This is typically a phone number, so we label it as such. + struct KonbiniPhoneNumberConfiguration: TextFieldElementConfiguration { + public let label = String.Localized.phone + public let disallowedCharacters: CharacterSet = .decimalDigits.inverted + public let isOptional: Bool = true + let incompleteError = Error.incomplete(localizedDescription: .Localized.incomplete_phone_number) + + public func validate(text: String, isOptional: Bool) -> ValidationState { + guard !text.isEmpty else { + return isOptional ? .valid : .invalid(Error.empty) + } + guard text.count > 9 else { + return .invalid(incompleteError) + } + return .valid + } + + public func keyboardProperties(for text: String) -> TextFieldElement.KeyboardProperties { + return .init(type: .numberPad, textContentType: .telephoneNumber, autocapitalization: .none) + } + + public func maxLength(for text: String) -> Int { + return 11 + } + } + + static func makeKonbini(theme: ElementsUITheme) -> TextFieldElement { + return TextFieldElement(configuration: KonbiniPhoneNumberConfiguration(), theme: theme) + } + + // MARK: - Phone number + struct PhoneNumberConfiguration: TextFieldElementConfiguration { + static let incompleteError = Error.incomplete(localizedDescription: .Localized.incomplete_phone_number) + static let invalidError = Error.invalid(localizedDescription: .Localized.invalid_phone_number) + public let label: String = .Localized.phone + /// - Note: Country code helps us format the phone number + public let countryCodeProvider: () -> String + public let defaultValue: String? + public let isOptional: Bool + + public init(defaultValue: String? = nil, isOptional: Bool = false, countryCodeProvider: @escaping () -> String) { + self.countryCodeProvider = countryCodeProvider + self.defaultValue = defaultValue + self.isOptional = isOptional + } + + public func validate(text: String, isOptional: Bool) -> TextFieldElement.ValidationState { + if text.isEmpty { + return isOptional ? .valid : .invalid(Error.empty) + } + + if let phoneNumber = PhoneNumber(number: text, countryCode: countryCodeProvider()) { + return phoneNumber.isComplete ? .valid : + .invalid(PhoneNumberConfiguration.incompleteError) + } else { + // Assume user has entered a format or for a region the SDK doesn't know about. + // Return valid as long as it's non-empty and let the server decide. + return .valid + } + } + + public func keyboardProperties(for text: String) -> TextFieldElement.KeyboardProperties { + return .init(type: .phonePad, textContentType: .telephoneNumber, autocapitalization: .none) + } + + public var disallowedCharacters: CharacterSet { + return .stp_asciiDigit.inverted + } + + public func makeDisplayText(for text: String) -> NSAttributedString { + if let phoneNumber = PhoneNumber(number: text, countryCode: countryCodeProvider()) { + return NSAttributedString(string: phoneNumber.string(as: .national)) + } else { + return NSAttributedString(string: text) + } + } + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Form/FormElement.swift b/StripeUICore/StripeUICore/Source/Elements/Form/FormElement.swift new file mode 100644 index 00000000..f0aa5556 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Form/FormElement.swift @@ -0,0 +1,75 @@ +// +// FormElement.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 6/7/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +/** + The top-most, parent Element Element container. + Displays its views in a vertical stack. + Coordinates focus between its child Elements. + */ +@_spi(STP) public class FormElement: ContainerElement { + weak public var delegate: ElementDelegate? + lazy var formView: FormView = { + return FormView(viewModel: viewModel) + }() + + public let elements: [Element] + public let style: Style + public let theme: ElementsUITheme + + // MARK: - Style + public enum Style { + /// Default element styling in stack view + case plain + /// Form draws borders around each Element + case bordered + } + + // MARK: - ViewModel + public struct ViewModel { + let elements: [UIView] + let bordered: Bool + let theme: ElementsUITheme + public init(elements: [UIView], bordered: Bool, theme: ElementsUITheme = .default) { + self.elements = elements + self.bordered = bordered + self.theme = theme + } + } + + var viewModel: ViewModel { + return ViewModel(elements: elements.map({ $0.view }), bordered: style == .bordered, theme: theme) + } + + // MARK: - Initializer + + public convenience init(elements: [Element?], theme: ElementsUITheme = .default) { + self.init(elements: elements, style: .plain, theme: theme) + } + + public init(elements: [Element?], style: Style, theme: ElementsUITheme = .default) { + self.elements = elements.compactMap { $0 } + self.style = style + self.theme = theme + self.elements.forEach { $0.delegate = self } + } + + public func setElements(_ elements: [Element], hidden: Bool, animated: Bool) { + formView.setViews(elements.map({ $0.view }), hidden: hidden, animated: animated) + } +} + +// MARK: - Element + +extension FormElement: Element { + public var view: UIView { + return formView + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Form/FormView.swift b/StripeUICore/StripeUICore/Source/Elements/Form/FormView.swift new file mode 100644 index 00000000..ebfe4a32 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Form/FormView.swift @@ -0,0 +1,51 @@ +// +// FormView.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 6/7/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +/** + A simple container view that displays its subviews in a vertical stack. + + For internal SDK use only + */ +@objc(STP_Internal_FormView) +@_spi(STP) public class FormView: UIView { + private let stackView: UIStackView + public init(viewModel: FormElement.ViewModel) { + if viewModel.bordered { + let stack = StackViewWithSeparator(arrangedSubviews: viewModel.elements) + self.stackView = stack + stack.drawBorder = true + stack.customBackgroundColor = viewModel.theme.colors.background + stack.separatorColor = viewModel.theme.colors.divider + stack.borderColor = viewModel.theme.colors.border + stack.borderCornerRadius = viewModel.theme.cornerRadius + stack.spacing = viewModel.theme.borderWidth + stack.hideShadow = true + stack.layer.applyShadow(shadow: viewModel.theme.shadow) + stack.axis = .vertical + } else { + let stack = UIStackView(arrangedSubviews: viewModel.elements) + self.stackView = stack + stack.axis = .vertical + stack.spacing = ElementsUI.formSpacing + } + + super.init(frame: .zero) + addAndPinSubview(self.stackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setViews(_ views: [UIView], hidden: Bool, animated: Bool) { + stackView.toggleArrangedSubviews(views, shouldShow: !hidden, animated: animated) + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/PhoneNumber/PhoneNumberElement.swift b/StripeUICore/StripeUICore/Source/Elements/PhoneNumber/PhoneNumberElement.swift new file mode 100644 index 00000000..414eda0d --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/PhoneNumber/PhoneNumberElement.swift @@ -0,0 +1,174 @@ +// +// PhoneNumberElement.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 6/21/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +import UIKit + +/** + A simple hstack of [🇺🇸 + 1] `DropdownElement` and [ Phone number ] `TextFieldElement` + */ +@_spi(STP) public class PhoneNumberElement: ContainerElement { + + // MARK: - ContainerElement protocol + public lazy var elements: [Element] = { [countryDropdownElement, textFieldElement] }() + public var delegate: ElementDelegate? + public lazy var view: UIView = { + countryDropdownElement.view.directionalLayoutMargins.trailing = 0 + let hStackView = UIStackView(arrangedSubviews: [countryDropdownElement.view, textFieldElement.view]) + return hStackView + }() + + // MARK: - sub-Elements + let countryDropdownElement: DropdownFieldElement + let textFieldElement: TextFieldElement + + // MARK: - Public properties + public var phoneNumber: PhoneNumber? { + return PhoneNumber(number: textFieldElement.text, countryCode: countryDropdownElement.selectedItem.rawData) + } + + public var hasBeenModified: Bool { + return defaultPhoneNumber?.number != phoneNumber?.number || + defaultPhoneNumber?.countryCode != phoneNumber?.countryCode + } + + public var selectedCountryCode: String { + countryDropdownElement.selectedItem.rawData + } + + // MARK: - Private properties + private var defaultPhoneNumber: PhoneNumber? + + // MARK: - Initializer + /** + Creates an address section with a country dropdown populated from the given list of countryCodes. + + - Parameters: + - allowedCountryCodes: List of region codes to display in the country picker dropdown. If nil, defaults to ~all countries. + - defaultCountryCode: The country code that's initially selected in the dropdown. **This is ignored** if `defaultPhoneNumber` is in E.164 format in favor of the phone number's country code. + - defaultPhoneNumber:The initial value of the phone number text field. Note: If provided in E.164 format, the country prefix is removed. + - locale: Locale used to generate the display names for each country and as the default country if none is provided. + - theme: Theme used to stylize the phone number element + + - Note: The default parameters are not used as-is - we do extra logic! + */ + public init( + allowedCountryCodes: [String]? = nil, + defaultCountryCode: String? = nil, + defaultPhoneNumber: String? = nil, + isOptional: Bool = false, + locale: Locale = .current, + theme: ElementsUITheme = .default + ) { + let defaults = Self.deriveDefaults(countryCode: defaultCountryCode, phoneNumber: defaultPhoneNumber) + let allowedCountryCodes = allowedCountryCodes ?? PhoneNumber.Metadata.allMetadata.map { $0.regionCode } + let countryDropdownElement = DropdownFieldElement.makeCountryCode( + countryCodes: allowedCountryCodes, + defaultCountry: defaults.countryCode, + locale: locale, + theme: theme + ) + self.countryDropdownElement = countryDropdownElement + self.textFieldElement = TextFieldElement.PhoneNumberConfiguration( + defaultValue: defaults.phoneNumber, + isOptional: isOptional, + countryCodeProvider: { + return countryDropdownElement.selectedItem.rawData + } + ).makeElement(theme: theme) + self.defaultPhoneNumber = phoneNumber + self.countryDropdownElement.delegate = self + self.textFieldElement.delegate = self + } + + public func setSelectedCountryCode(_ countryCode: String, shouldUpdateDefaultNumber: Bool = false) { + guard let index = countryDropdownElement.items.firstIndex(where: { $0.rawData == countryCode }) else { + return + } + selectCountry(index: index, shouldUpdateDefaultNumber: shouldUpdateDefaultNumber) + } + + public func clearPhoneNumber() { + textFieldElement.setText("") + } + + // MARK: - Element protocol + public func beginEditing() -> Bool { + return textFieldElement.beginEditing() + } + + // MARK: - ElementDelegate + public func didUpdate(element: Element) { + if element === textFieldElement && textFieldElement.didReceiveAutofill { + // Autofilled numbers may already include the country code, so check if that's the case. + // Note: We only validate against the currently selected country code, as an autofilled number _without_ a country code can trigger false positives, e.g. "2481234567" could be either "(248) 123-4567" (a phone number from Michigan, USA with no country code) or "+248 1 234 567" (a phone number from Seychelles with a country code). We can assume that generally, a user's autofilled phone number will match their phone's region setting. + // Autofilled numbers can include the + prefix indicating a country code, but we can't tell if they do here, as by the time we get here the input has already been sanitized and the "+" has been removed. + let countryCode = countryDropdownElement.selectedItem.rawData + if let prefix = PhoneNumber.Metadata.metadata(for: countryCode)?.prefix.dropFirst(), textFieldElement.text.hasPrefix(prefix) { + let unprefixedNumber = String(textFieldElement.text.dropFirst(prefix.count)) + // Double check that we actually have a valid phone number without the prefix. + if let phoneNumber = PhoneNumber(number: unprefixedNumber, countryCode: countryCode), phoneNumber.isComplete { + textFieldElement.setText(unprefixedNumber) + textFieldElement.endEditing() + // Setting the text directly triggers another update cycle, so short-circuit here to avoid double updating. + return + } + } + } + delegate?.didUpdate(element: self) + } + + // MARK: - Helpers + static func deriveDefaults(countryCode: String?, phoneNumber: String?) -> (countryCode: String?, phoneNumber: String?) { + // If the phone number is E164, derive defaults from that + if let phoneNumber = phoneNumber, let e164PhoneNumber = PhoneNumber.fromE164(phoneNumber) { + return (e164PhoneNumber.countryCode, e164PhoneNumber.number) + } else { + return (countryCode, phoneNumber) + } + } + + func selectCountry(index: Int, shouldUpdateDefaultNumber: Bool = false) { + countryDropdownElement.select(index: index) + + if shouldUpdateDefaultNumber { + self.defaultPhoneNumber = phoneNumber + } + } +} + +// MARK: - DropdownFieldElement helper +extension DropdownFieldElement { + static func makeCountryCode( + countryCodes: [String], + defaultCountry: String? = nil, + locale: Locale, + theme: ElementsUITheme + ) -> DropdownFieldElement { + let countryCodes = locale.sortedByTheirLocalizedNames(countryCodes) + let countryDisplayStrings: [DropdownFieldElement.DropdownItem] = countryCodes.map { + let flagEmoji = String.countryFlagEmoji(for: $0) ?? "" // 🇺🇸 + let name = locale.localizedString(forRegionCode: $0) ?? $0 // United States + let prefix = PhoneNumber.Metadata.metadata(for: $0)?.prefix ?? "" // +1 + return .init( + pickerDisplayName: "\(flagEmoji) \(name) \(prefix)", // 🇺🇸 United States +1 + labelDisplayName: "\(flagEmoji) \(prefix)", + accessibilityValue: "\(name) \(prefix)", + rawData: $0 + ) + } + let defaultCountry = defaultCountry ?? locale.regionCode ?? "" + let defaultCountryIndex = countryCodes.firstIndex(of: defaultCountry) ?? 0 + return DropdownFieldElement( + items: countryDisplayStrings, + defaultIndex: defaultCountryIndex, + label: nil, + theme: theme + ) + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/PickerField/PickerFieldView.swift b/StripeUICore/StripeUICore/Source/Elements/PickerField/PickerFieldView.swift new file mode 100644 index 00000000..1c899b42 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/PickerField/PickerFieldView.swift @@ -0,0 +1,238 @@ +// +// PickerFieldView.swift +// StripeUICore +// +// Created by Mel Ludowise on 10/1/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +protocol PickerFieldViewDelegate: AnyObject { + func didBeginEditing(_ pickerFieldView: PickerFieldView) + func didFinish(_ pickerFieldView: PickerFieldView, shouldAutoAdvance: Bool) + func didCancel(_ pickerFieldView: PickerFieldView) +} + +/** + An input field that looks like TextFieldView but whose input is another view. + + - Note: This view has padding according to `directionalLayoutMargins`. + For internal SDK use only + */ +@objc(STP_Internal_PickerFieldView) +final class PickerFieldView: UIView { + + // MARK: - Views + private lazy var toolbar = DoneButtonToolbar(delegate: self, showCancelButton: true, theme: theme) + private lazy var textField: PickerTextField = { + let textField = PickerTextField() + // Input views are not supported on Catalyst +#if !targetEnvironment(macCatalyst) + textField.inputView = pickerView +#endif + textField.adjustsFontForContentSizeCategory = true + textField.font = theme.fonts.subheadline + textField.inputAccessoryView = toolbar + textField.delegate = self + return textField + }() + private lazy var floatingPlaceholderTextFieldView: FloatingPlaceholderTextFieldView? = { + guard let label = label else { + return nil + } + let floatingPlaceholderView = FloatingPlaceholderTextFieldView(textField: textField, theme: theme) + floatingPlaceholderView.placeholder = label + return floatingPlaceholderView + }() + private lazy var chevronImageView: UIImageView? = { + guard shouldShowChevron else { + return nil + } + let imageView = UIImageView(image: Image.icon_chevron_down.makeImage().withRenderingMode(.alwaysTemplate)) + imageView.setContentHuggingPriority(.required, for: .horizontal) + imageView.tintColor = theme.colors.textFieldText + return imageView + }() + private lazy var hStackView: UIStackView = { + let hStackView = UIStackView( + arrangedSubviews: [floatingPlaceholderTextFieldView ?? textField, chevronImageView].compactMap { $0 } + ) + hStackView.alignment = .center + hStackView.spacing = 6 + return hStackView + }() + private let pickerView: UIView + + // MARK: - Other private properties + private let label: String? + private let shouldShowChevron: Bool + private weak var delegate: PickerFieldViewDelegate? + private let theme: ElementsUITheme + + // MARK: - Public properties + var displayText: NSAttributedString? { + get { + return textField.attributedText + } + set { + if newValue != textField.attributedPlaceholder { + invalidateIntrinsicContentSize() + } + textField.attributedText = newValue + // Unfortunate hack for card brand choice to show card brand logos + // UITextField doesn't render attributed text with text attachments for some reason + // But it works when setting it's placeholder text + // https://stackoverflow.com/questions/54804809/cant-add-image-as-nstextattachment-to-uitextfield + if (newValue?.hasTextAttachment ?? false) && newValue?.length == 1 { + textField.attributedPlaceholder = newValue + } + } + } + + var displayTextAccessibilityValue: String? { + get { + return textField.accessibilityValue + } + set { + textField.accessibilityValue = newValue + } + } + + // MARK: - Initializers + + /** + - Parameter label: The label of this picker + - Parameter shouldShowChevron: Whether a downward chevron should be displayed in this field + - Parameter pickerView: A `UIPicker` or `UIDatePicker` view that opens when this field becomes first responder + - Parameter delegate: Delegate for this view + - Parameter theme: Theme for the picker field + */ + init( + label: String?, + shouldShowChevron: Bool, + pickerView: UIView, + delegate: PickerFieldViewDelegate, + theme: ElementsUITheme, + hasPadding: Bool = true + ) { + self.label = label + self.shouldShowChevron = shouldShowChevron + self.pickerView = pickerView + self.delegate = delegate + self.theme = theme + super.init(frame: .zero) + addAndPinSubview(hStackView, directionalLayoutMargins: hasPadding ? ElementsUI.contentViewInsets : .zero) +// On Catalyst, add the picker view as a subview instead of an input view. + #if targetEnvironment(macCatalyst) + addAndPinSubview(pickerView, directionalLayoutMargins: ElementsUI.contentViewInsets) + #endif + layer.borderColor = theme.colors.border.cgColor + isUserInteractionEnabled = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Overrides + + override func layoutSubviews() { + super.layoutSubviews() + floatingPlaceholderTextFieldView?.updatePlaceholder(animated: false) + } + + override var isUserInteractionEnabled: Bool { + didSet { + textField.textColor = theme.colors.textFieldText.disabled(!isUserInteractionEnabled) + if frame.size != .zero { + textField.layoutIfNeeded() // Fixes an issue on iOS 15 where setting textField properties causes it to lay out from zero size. + } + } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + layer.borderColor = theme.colors.border.cgColor + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard isUserInteractionEnabled, !isHidden, self.point(inside: point, with: event) else { + return nil + } + #if targetEnvironment(macCatalyst) + // Forward all events within our bounds to the button + return pickerView + #else + // Forward all events within our bounds to the textview + return textField + #endif + } + + override var intrinsicContentSize: CGSize { + // I'm implementing this to disambiguate layout of a horizontal stack view containing this view + let hStackViewSize = hStackView.systemLayoutSizeFitting(.zero) + return CGSize( + width: hStackViewSize.width + directionalLayoutMargins.leading + directionalLayoutMargins.trailing, + height: hStackViewSize.height + directionalLayoutMargins.top + directionalLayoutMargins.bottom + ) + } + + override func becomeFirstResponder() -> Bool { + if super.becomeFirstResponder() { + return true + } + return textField.becomeFirstResponder() + } +} + +// MARK: - EventHandler + +extension PickerFieldView: EventHandler { + func handleEvent(_ event: STPEvent) { + switch event { + case .shouldEnableUserInteraction: + isUserInteractionEnabled = true + case .shouldDisableUserInteraction: + isUserInteractionEnabled = false + default: + break + } + } +} + +// MARK: - UITextFieldDelegate + +extension PickerFieldView: UITextFieldDelegate { + func textFieldDidBeginEditing(_ textField: UITextField) { + UIAccessibility.post(notification: .layoutChanged, argument: pickerView) + floatingPlaceholderTextFieldView?.updatePlaceholder() + delegate?.didBeginEditing(self) + } + + func textField( + _ textField: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + return false + } + + func textFieldDidEndEditing(_ textField: UITextField) { + floatingPlaceholderTextFieldView?.updatePlaceholder() + delegate?.didFinish(self, shouldAutoAdvance: true) + } +} + +// MARK: - DoneButtonToolbarDelegate + +extension PickerFieldView: DoneButtonToolbarDelegate { + func didTapDone(_ toolbar: DoneButtonToolbar) { + _ = textField.resignFirstResponder() + } + + func didTapCancel(_ toolbar: DoneButtonToolbar) { + delegate?.didCancel(self) + _ = textField.resignFirstResponder() + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/PickerField/PickerTextField.swift b/StripeUICore/StripeUICore/Source/Elements/PickerField/PickerTextField.swift new file mode 100644 index 00000000..7793cc47 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/PickerField/PickerTextField.swift @@ -0,0 +1,39 @@ +// +// PickerTextField.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 6/17/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +// MARK: - PickerTextField + +/** + A subclass of `UITextField` that disables manual text entry. + + For internal SDK use only + */ +@objc(STP_Internal_PickerTextField) +class PickerTextField: UITextField { + + // MARK: Overrides + + override func caretRect(for position: UITextPosition) -> CGRect { + // Disallow selection + return .zero + } + + override func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { + // Disallow selection + return [] + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(UIResponderStandardEditActions.paste(_:)) { + return false + } + return super.canPerformAction(action, withSender: sender) + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Section/SectionContainerView.swift b/StripeUICore/StripeUICore/Source/Elements/Section/SectionContainerView.swift new file mode 100644 index 00000000..dd3e921f --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Section/SectionContainerView.swift @@ -0,0 +1,226 @@ +// +// SectionContainerView.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 6/4/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +/** + A rounded, lightly shadowed container view with a thin border. + You can put views like TextFieldView inside it. + + - Note: This class sets the borderWidth, color, cornerRadius, etc. of its subviews. + + For internal SDK use only + */ +@objc(STP_Internal_SectionContainerView) +class SectionContainerView: UIView { + + // MARK: - Views + + lazy var bottomPinningContainerView: DynamicHeightContainerView = { + let view = DynamicHeightContainerView(pinnedDirection: .top) + view.directionalLayoutMargins = .zero + view.addPinnedSubview(stackView) + view.updateHeight() + return view + }() + + lazy var stackView: StackViewWithSeparator = { + let view = buildStackView(views: views, theme: theme) + return view + }() + + private(set) var views: [UIView] + private let theme: ElementsUITheme + + // MARK: - Initializers + + convenience init(view: UIView, theme: ElementsUITheme = .default) { + self.init(views: [view], theme: theme) + } + + /** + - Parameter views: A list of views to display in a row. To display multiple elements in a single row, put them inside a `MultiElementRowView`. + */ + init(views: [UIView], theme: ElementsUITheme = .default) { + self.views = views + self.theme = theme + super.init(frame: .zero) + addAndPinSubview(bottomPinningContainerView) + updateUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Overrides + + override var isUserInteractionEnabled: Bool { + didSet { + updateUI() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + // Set up each subviews border corners + // Do this in layoutSubviews to update when views appear or disappear + let visibleRows = stackView.arrangedSubviews.filter { !$0.isHidden } + // 1. Reset all border corners to be square + for row in visibleRows { + // Pull out any Element views nested inside a MultiElementRowView + for view in (row as? MultiElementRowView)?.views ?? [row] { + view.layer.cornerRadius = theme.cornerRadius + view.layer.maskedCorners = [] + view.layer.shadowOpacity = 0.0 + view.layer.borderWidth = 0 + } + } + // 2. Round the top-most view's top corners + if let multiElementRowView = visibleRows.first as? MultiElementRowView { + multiElementRowView.views.first?.layer.maskedCorners.insert([.layerMinXMinYCorner]) + multiElementRowView.views.last?.layer.maskedCorners.insert([.layerMaxXMinYCorner]) + } else { + visibleRows.first?.layer.maskedCorners.insert([.layerMinXMinYCorner, .layerMaxXMinYCorner]) + } + // 3. Round the bottom-most view's bottom corners + if let multiElementRowView = visibleRows.last as? MultiElementRowView { + multiElementRowView.views.first?.layer.maskedCorners.insert([.layerMinXMaxYCorner]) + multiElementRowView.views.last?.layer.maskedCorners.insert([.layerMaxXMaxYCorner]) + } else { + visibleRows.last?.layer.maskedCorners.insert([.layerMaxXMaxYCorner, .layerMinXMaxYCorner]) + } + + // Improve shadow performance + layer.shadowPath = CGPath( + roundedRect: bounds, + cornerWidth: layer.cornerRadius, + cornerHeight: layer.cornerRadius, + transform: nil + ) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + updateUI() + } + + // MARK: - Internal methods + func updateUI(newViews: [UIView]? = nil) { + layer.applyShadow(shadow: theme.shadow) + layer.cornerRadius = theme.cornerRadius + + if isUserInteractionEnabled || UITraitCollection.current.isDarkMode { + backgroundColor = theme.colors.background + } else { + backgroundColor = .tertiarySystemGroupedBackground + } + + guard let newViews = newViews, views != newViews else { + return + } + // Add new views in a new stack view + let dummyFirstView: UIView? // A hack to preserve the first view during the transition + let newStackViews: [UIView] + if let first = newViews.first, first == views.first { + // Hack: Give the new stack view a dummy view with the same height as the current stack view's first view + let dummy = UIView(frame: first.frame) + dummy.heightAnchor.constraint(equalToConstant: dummy.bounds.height).isActive = true + newStackViews = [dummy] + newViews.dropFirst() + dummyFirstView = dummy + } else { + dummyFirstView = nil + newStackViews = newViews + } + + let oldStackHeight = self.stackView.frame.size.height + let newStack = buildStackView(views: newStackViews, theme: theme) + newStack.arrangedSubviews.forEach { $0.alpha = 0 } + bottomPinningContainerView.addPinnedSubview(newStack) + bottomPinningContainerView.layoutIfNeeded() + let transition = { + // Hack: Swap the dummy first view and real first view + if let dummyFirstView = dummyFirstView, + let firstView = self.views.first + { + self.stackView.insertArrangedSubview(dummyFirstView, at: 0) + newStack.insertArrangedSubview(firstView, at: 0) + } + + // Fade old out + self.stackView.arrangedSubviews.forEach { $0.alpha = 0 } + self.stackView.alpha = 0.0 + // Change height to accommodate new views + self.bottomPinningContainerView.updateHeight() + // Fade new in + newStack.arrangedSubviews.forEach { $0.alpha = 1 } + let oldStackView = self.stackView + self.stackView = newStack + self.views = newViews + self.setNeedsLayout() + self.layoutIfNeeded() + oldStackView.removeFromSuperview() + } + guard let viewController = window?.rootViewController?.presentedViewController else { + transition() + return + } + let shouldAnimate = Int(newStack.frame.size.height) != Int(oldStackHeight) + viewController.animateHeightChange(duration: shouldAnimate ? 0.5 : 0.0, transition) + } +} + +// MARK: - EventHandler + +extension SectionContainerView: EventHandler { + func handleEvent(_ event: STPEvent) { + switch event { + case .shouldEnableUserInteraction: + isUserInteractionEnabled = true + case .shouldDisableUserInteraction: + isUserInteractionEnabled = false + default: + break + } + } +} + +// MARK: - MultiElementRowView + +extension SectionContainerView { + class MultiElementRowView: UIView { + let views: [UIView] + + init(views: [UIView], theme: ElementsUITheme = .default) { + self.views = views + super.init(frame: .zero) + let stackView = buildStackView(views: views, theme: theme) + stackView.axis = .horizontal + stackView.drawBorder = false + stackView.distribution = .fillEqually + addAndPinSubview(stackView) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + } +} + +// MARK: - StackViewWithSeparator + +private func buildStackView(views: [UIView], theme: ElementsUITheme = .default) -> StackViewWithSeparator { + let stackView = StackViewWithSeparator(arrangedSubviews: views) + stackView.axis = .vertical + stackView.spacing = theme.borderWidth + stackView.separatorColor = theme.colors.divider + stackView.borderColor = theme.colors.border + stackView.borderCornerRadius = theme.cornerRadius + stackView.customBackgroundColor = theme.colors.background + stackView.drawBorder = true + stackView.hideShadow = true // Shadow is handled by `SectionContainerView` + return stackView +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Section/SectionElement+MultiElementRow.swift b/StripeUICore/StripeUICore/Source/Elements/Section/SectionElement+MultiElementRow.swift new file mode 100644 index 00000000..49a768ed --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Section/SectionElement+MultiElementRow.swift @@ -0,0 +1,30 @@ +// +// SectionElement+MultiElementRow.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 3/18/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +public extension SectionElement { + /// A simple container element that displays its child elements in a horizontal stackview + @_spi(STP) final class MultiElementRow: ContainerElement { + weak public var delegate: ElementDelegate? + public lazy var view: UIView = { + return SectionContainerView.MultiElementRowView(views: elements.map { $0.view }, theme: theme) + }() + public let elements: [Element] + public let theme: ElementsUITheme + + public init(_ elements: [Element], theme: ElementsUITheme = .default) { + self.elements = elements + self.theme = theme + elements.forEach { + $0.delegate = self + } + } + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Section/SectionElement.swift b/StripeUICore/StripeUICore/Source/Elements/Section/SectionElement.swift new file mode 100644 index 00000000..834088e8 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Section/SectionElement.swift @@ -0,0 +1,129 @@ +// +// SectionElement.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 6/6/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +/** + A simple container element with an optional title and an error, and draws a border around its elements. + Chooses which of its sub-elements' errors to display. + */ +@_spi(STP) public final class SectionElement: ContainerElement { + weak public var delegate: ElementDelegate? + lazy var sectionView: SectionView = { + isViewInitialized = true + return SectionView(viewModel: viewModel) + }() + var isViewInitialized: Bool = false + var errorText: String? { + // Find the first element that's 1. invalid and 2. has a displayable error + for element in elements { + if case let .invalid(error, shouldDisplay) = element.validationState, shouldDisplay { + return error.localizedDescription + } + } + return nil + } + var viewModel: SectionViewModel { + return ViewModel( + views: elements.filter { !($0.view is HiddenElement.HiddenView) }.map({ $0.view }), // filter out hidden views to prevent showing the separator + title: title, + errorText: errorText, + subLabel: subLabel, + theme: theme + ) + } + public var elements: [Element] { + didSet { + elements.forEach { + $0.delegate = self + } + if isViewInitialized { + sectionView.update(with: viewModel) + } + delegate?.didUpdate(element: self) + } + } + let title: String? + + var subLabel: String? { + elements.compactMap({ $0.subLabelText }).first + } + + let theme: ElementsUITheme + + // MARK: - ViewModel + + struct ViewModel { + let views: [UIView] + let title: String? + let errorText: String? + var subLabel: String? + let theme: ElementsUITheme + } + + // MARK: - Initializers + + public init(title: String? = nil, elements: [Element], theme: ElementsUITheme = .default) { + self.title = title + self.elements = elements + self.theme = theme + elements.forEach { + $0.delegate = self + } + } + + public convenience init(_ element: Element, theme: ElementsUITheme = .default) { + self.init(title: nil, elements: [element], theme: theme) + } +} + +// MARK: - Element + +extension SectionElement: Element { + public var view: UIView { + return sectionView + } +} + +// MARK: - ElementDelegate + +extension SectionElement: ElementDelegate { + public func didUpdate(element: Element) { + // Glue: Update the view and our delegate + if isViewInitialized { + sectionView.update(with: viewModel) + } + delegate?.didUpdate(element: self) + } +} + +// MARK: HiddenElement + +extension SectionElement { + /// A simple container element where the element's view is hidden + /// Useful when an element is a part of a section but it's view is embeded into another element + /// E.g. card brand drop down embedded into the PAN textfield + @_spi(STP) public final class HiddenElement: ContainerElement { + final class HiddenView: UIView {} + + weak public var delegate: ElementDelegate? + public lazy var view: UIView = { + return HiddenView(frame: .zero) // Hide the element's view + }() + public let elements: [Element] + + public init?(_ element: Element?) { + guard let element = element else { + return nil + } + self.elements = [element] + element.delegate = self + } + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/Section/SectionView.swift b/StripeUICore/StripeUICore/Source/Elements/Section/SectionView.swift new file mode 100644 index 00000000..821c0d8c --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/Section/SectionView.swift @@ -0,0 +1,72 @@ +// +// SectionView.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 6/4/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +typealias SectionViewModel = SectionElement.ViewModel + +/// For internal SDK use only +@objc(STP_Internal_SectionView) +final class SectionView: UIView { + + // MARK: - Views + + lazy var errorOrSubLabel: UILabel = { + return ElementsUI.makeErrorLabel(theme: viewModel.theme) + }() + let containerView: SectionContainerView + lazy var titleLabel: UILabel = { + return ElementsUI.makeSectionTitleLabel(theme: viewModel.theme) + }() + + let viewModel: SectionViewModel + + // MARK: - Initializers + + init(viewModel: SectionViewModel) { + self.viewModel = viewModel + self.containerView = SectionContainerView(views: viewModel.views, theme: viewModel.theme) + super.init(frame: .zero) + + let stack = UIStackView(arrangedSubviews: [titleLabel, containerView, errorOrSubLabel]) + stack.axis = .vertical + stack.spacing = 4 + addAndPinSubview(stack) + + update(with: viewModel) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Private methods + + func update(with viewModel: SectionViewModel) { + isHidden = viewModel.views.filter({ !$0.isHidden }).isEmpty + guard !isHidden else { + return + } + containerView.updateUI(newViews: viewModel.views) + titleLabel.text = viewModel.title + titleLabel.isHidden = viewModel.title == nil + if let errorText = viewModel.errorText, !errorText.isEmpty { + errorOrSubLabel.text = viewModel.errorText + errorOrSubLabel.isHidden = false + errorOrSubLabel.textColor = viewModel.theme.colors.danger + } else if let subLabel = viewModel.subLabel { + errorOrSubLabel.text = subLabel + errorOrSubLabel.isHidden = false + errorOrSubLabel.textColor = viewModel.theme.colors.secondaryText + } else { + errorOrSubLabel.text = nil + errorOrSubLabel.isHidden = true + } + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/StaticElement.swift b/StripeUICore/StripeUICore/Source/Elements/StaticElement.swift new file mode 100644 index 00000000..3414b721 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/StaticElement.swift @@ -0,0 +1,26 @@ +// +// StaticElement.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 6/18/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +/** + A inert wrapper around a view. + */ +@_spi(STP) public class StaticElement: Element { + weak public var delegate: ElementDelegate? + public let view: UIView + public var isHidden: Bool = false { + didSet { + view.isHidden = isHidden + } + } + + public init(view: UIView) { + self.view = view + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/TextField/FloatingPlaceholderTextFieldView.swift b/StripeUICore/StripeUICore/Source/Elements/TextField/FloatingPlaceholderTextFieldView.swift new file mode 100644 index 00000000..196b3c09 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/TextField/FloatingPlaceholderTextFieldView.swift @@ -0,0 +1,199 @@ +// +// FloatingPlaceholderTextFieldView.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 7/7/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +/** + A helper view that contains a floating placeholder and a user-provided text field + + For internal SDK use only + */ +@objc(STP_Internal_FloatingPlaceholderTextFieldView) +class FloatingPlaceholderTextFieldView: UIView { + + // MARK: - Views + + private let textField: UITextField + private let theme: ElementsUITheme + private lazy var placeholderLabel: UILabel = { + let label = UILabel() + label.textColor = theme.colors.placeholderText + label.font = theme.fonts.subheadline + return label + }() + + public var placeholder: String { + get { + return placeholderLabel.text ?? "" + } + set { + placeholderLabel.text = newValue + } + } + + // MARK: - Initializers + + public init(textField: UITextField, theme: ElementsUITheme = .default) { + self.textField = textField + self.theme = theme + super.init(frame: .zero) + isAccessibilityElement = true + installConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Overrides + + override var isUserInteractionEnabled: Bool { + didSet { + textField.isUserInteractionEnabled = isUserInteractionEnabled + } + } + + override var accessibilityValue: String? { + get { return textField.accessibilityValue } + set { assertionFailure() } // swiftlint:disable:this unused_setter_value + } + + override var accessibilityLabel: String? { + get { return textField.accessibilityLabel ?? placeholderLabel.text } + set { assertionFailure() } // swiftlint:disable:this unused_setter_value + } + + override var accessibilityTraits: UIAccessibilityTraits { + get { return textField.accessibilityTraits } + set { assertionFailure() } // swiftlint:disable:this unused_setter_value + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard isUserInteractionEnabled, !isHidden, self.point(inside: point, with: event) else { + return nil + } + // Forward all events within our bounds to the textfield + return textField + } + + override func becomeFirstResponder() -> Bool { + guard !isHidden else { + return false + } + return textField.becomeFirstResponder() + } + + // MARK: - Private methods + + fileprivate func installConstraints() { + textField.translatesAutoresizingMaskIntoConstraints = false + addSubview(textField) + + // Allow space for the minimized placeholder to sit above the textfield + let minimizedPlaceholderHeight = placeholderLabel.font.lineHeight * Constants.Placeholder.scale + NSLayoutConstraint.activate([ + textField.topAnchor.constraint(equalTo: topAnchor, constant: minimizedPlaceholderHeight + Constants.Placeholder.bottomPadding), + textField.bottomAnchor.constraint(equalTo: bottomAnchor), + textField.leadingAnchor.constraint(equalTo: leadingAnchor), + textField.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + + // Arrange placeholder + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(placeholderLabel) + // Change anchorPoint so scale transforms occur from the leading edge instead of the center + placeholderLabel.layer.anchorPoint = effectiveUserInterfaceLayoutDirection == .leftToRight + ? CGPoint(x: 0, y: 0.5) + : CGPoint(x: 1, y: 0.5) + NSLayoutConstraint.activate([ + // Note placeholder's anchorPoint.x = 0 redefines its 'center' to the left + placeholderLabel.centerXAnchor.constraint(equalTo: textField.leadingAnchor), + placeholderCenterYConstraint, + ]) + } + + // MARK: - Animate placeholder + + fileprivate lazy var animator: UIViewPropertyAnimator = { + let params = UISpringTimingParameters( + mass: 1.0, + dampingRatio: 0.93, + frequencyResponse: 0.22 + ) + let animator = UIViewPropertyAnimator(duration: 0, timingParameters: params) + animator.isInterruptible = true + return animator + }() + + fileprivate lazy var placeholderCenterYConstraint: NSLayoutConstraint = { + placeholderLabel.centerYAnchor.constraint(equalTo: centerYAnchor) + }() + + fileprivate lazy var placeholderTopYConstraint: NSLayoutConstraint = { + placeholderLabel.topAnchor.constraint(equalTo: topAnchor) + }() + + public func updatePlaceholder(animated: Bool = true) { + enum Position { case up, down } + let isEmpty = textField.attributedText?.string.isEmpty ?? true + let position: Position = textField.isEditing || !isEmpty ? .up : .down + let scale = position == .up ? Constants.Placeholder.scale : 1.0 + let transform = CGAffineTransform.identity.scaledBy(x: scale, y: scale) + let updatePlaceholderLocation = { + self.placeholderLabel.transform = transform + self.placeholderCenterYConstraint.isActive = position != .up + self.placeholderTopYConstraint.isActive = position == .up + } + + // Don't update redundantly; this can cause animation issues + guard transform != self.placeholderLabel.transform else { + return + } + + // Note: Only animate if the view is inside of the window hierarchy, + // otherwise calling `layoutIfNeeded` inside the animation block causes + // autolayout errors + guard animated && window != nil else { + updatePlaceholderLocation() + return + } + + animator.stopAnimation(true) + animator.addAnimations { + updatePlaceholderLocation() + self.layoutIfNeeded() + } + animator.startAnimation() + } + +} + +// MARK: - EventHandler + +extension FloatingPlaceholderTextFieldView: EventHandler { + func handleEvent(_ event: STPEvent) { + switch event { + case .shouldEnableUserInteraction: + isUserInteractionEnabled = true + case .shouldDisableUserInteraction: + isUserInteractionEnabled = false + default: + break + } + } +} + +// MARK: - Constants + +private enum Constants { + enum Placeholder { + static let scale: CGFloat = 0.75 + /// The distance between the floating placeholder label and the textfield below it. + static let bottomPadding: CGFloat = 3.0 + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/TextField/TextFieldElement+Validation.swift b/StripeUICore/StripeUICore/Source/Elements/TextField/TextFieldElement+Validation.swift new file mode 100644 index 00000000..37f312f4 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/TextField/TextFieldElement+Validation.swift @@ -0,0 +1,87 @@ +// +// TextFieldElement+Validation.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 6/9/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) public extension TextFieldElement { + @frozen enum ValidationState { + case valid + case invalid(_ error: TextFieldValidationError) + } + + /// A general-purpose TextFieldValidationError. + /// If it doesn't suit your text field's needs, create a new enum instead of modifying this one! + @frozen enum Error: TextFieldValidationError, Equatable { + /// An empty text field differs from incomplete in that it never displays an error. + case empty + case incomplete(localizedDescription: String) + case invalid(localizedDescription: String) + + public func shouldDisplay(isUserEditing: Bool) -> Bool { + switch self { + case .empty: + return false + case .incomplete, .invalid: + // By default, invalid errors aren't displayed. + // This makes sense when input that is invalid may become valid after further user input + return !isUserEditing + } + } + + public var localizedDescription: String { + switch self { + case .incomplete(let localizedDescription): + return localizedDescription + case .invalid(let localizedDescription): + return localizedDescription + case .empty: + return "" + } + } + } +} + +/** + - Seealso: `ElementValidation.swift` + - Seealso: `SectionElement` uses this to determine whether it should show the error or not. + */ +@_spi(STP) public protocol TextFieldValidationError: ElementValidationError { + /** + Some TextFieldElement validation errors should only be displayed to the user if they're finished typing, while others should + always be shown. + + For example, most fields in an "incomplete" state won't display an "incomplete" error until the user has finished typing. + + - Parameter isUserEditing: Whether or not the user is editing the field that is in error. + - Returns: Whether or not to display the error. + - Note: The default implementation always returns `true` + */ + func shouldDisplay(isUserEditing: Bool) -> Bool + + var localizedDescription: String { get } +} + +extension TextFieldValidationError { + func shouldDisplay(isUserEditing: Bool) -> Bool { + return true + } +} + +// MARK: - ElementValidationState +extension ElementValidationState { + /// Converts a `TextFieldElement.ValidationState` to an `ElementValidationState` + /// The only difference between the two is that the latter includes `isUserEditing` as part of its state so that it knows whether the error should display or not. + init(from validationState: TextFieldElement.ValidationState, isUserEditing: Bool) { + switch validationState { + case .valid: + self = .valid + case .invalid(let error): + self = .invalid(error: error, shouldDisplay: error.shouldDisplay(isUserEditing: isUserEditing)) + } + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/TextField/TextFieldElement.swift b/StripeUICore/StripeUICore/Source/Elements/TextField/TextFieldElement.swift new file mode 100644 index 00000000..03e57377 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/TextField/TextFieldElement.swift @@ -0,0 +1,179 @@ +// +// TextFieldElement.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 6/4/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +import UIKit + +/** + A generic text field whose logic is extracted into `TextFieldElementConfiguration`. + + - Seealso: `TextFieldElementConfiguration` + */ +@_spi(STP) public final class TextFieldElement { + + // MARK: - Properties + weak public var delegate: ElementDelegate? + lazy var textFieldView: TextFieldView = { + return TextFieldView(viewModel: viewModel, delegate: self) + }() + var configuration: TextFieldElementConfiguration { + didSet { + setText("") + } + } + public private(set) lazy var text: String = { + sanitize(text: configuration.defaultValue ?? "") + }() + public private(set) var isEditing: Bool = false + private(set) var didReceiveAutofill: Bool = false + public var validationState: ElementValidationState { + return .init( + from: configuration.validate(text: text, isOptional: configuration.isOptional), + isUserEditing: isEditing + ) + } + + private let theme: ElementsUITheme + + public var inputAccessoryView: UIView? { + get { + return textFieldView.textField.inputAccessoryView + } + + set { + textFieldView.textField.inputAccessoryView = newValue + } + } + + // MARK: - ViewModel + public struct KeyboardProperties { + public init(type: UIKeyboardType, textContentType: UITextContentType?, autocapitalization: UITextAutocapitalizationType) { + self.type = type + self.textContentType = textContentType + self.autocapitalization = autocapitalization + } + + let type: UIKeyboardType + let textContentType: UITextContentType? + let autocapitalization: UITextAutocapitalizationType + } + + struct ViewModel { + let placeholder: String + let accessibilityLabel: String + let attributedText: NSAttributedString + let keyboardProperties: KeyboardProperties + let validationState: ValidationState + let accessoryView: UIView? + let shouldShowClearButton: Bool + let theme: ElementsUITheme + } + + var viewModel: ViewModel { + let placeholder: String = { + if !configuration.isOptional { + return configuration.label + } else { + let localized = String.Localized.optional_field + return String(format: localized, configuration.label) + } + }() + return ViewModel( + placeholder: placeholder, + accessibilityLabel: configuration.accessibilityLabel, + attributedText: configuration.makeDisplayText(for: text), + keyboardProperties: configuration.keyboardProperties(for: text), + validationState: configuration.validate(text: text, isOptional: configuration.isOptional), + accessoryView: configuration.accessoryView(for: text, theme: theme), + shouldShowClearButton: configuration.shouldShowClearButton, + theme: theme + ) + } + + // MARK: - Initializer + + public required init(configuration: TextFieldElementConfiguration, theme: ElementsUITheme = .default) { + self.configuration = configuration + self.theme = theme + } + + /// Call this to manually set the text of the text field. + public func setText(_ text: String) { + self.text = sanitize(text: text) + + // Since we're setting the text manually, disable any previous autofill + didReceiveAutofill = false + + // Glue: Update the view and our delegate + textFieldView.updateUI(with: viewModel) + delegate?.didUpdate(element: self) + } + + // MARK: - Helpers + + func sanitize(text: String) -> String { + let sanitizedText = text.stp_stringByRemovingCharacters(from: configuration.disallowedCharacters) + return String(sanitizedText.prefix(configuration.maxLength(for: sanitizedText))) + } +} + +// MARK: - Element + +extension TextFieldElement: Element { + public var view: UIView { + return textFieldView + } + + @discardableResult + public func beginEditing() -> Bool { + return textFieldView.textField.becomeFirstResponder() + } + + @discardableResult + public func endEditing(_ force: Bool = false, continueToNextField: Bool = true) -> Bool { + let didResign = textFieldView.endEditing(force) + isEditing = textFieldView.isEditing + if continueToNextField { + delegate?.continueToNextField(element: self) + } + return didResign + } + + public var subLabelText: String? { + return configuration.subLabel(text: text) + } +} + +// MARK: - TextFieldViewDelegate + +extension TextFieldElement: TextFieldViewDelegate { + func textFieldViewDidUpdate(view: TextFieldView) { + // Update our state + let newText = sanitize(text: view.text) + if text != newText { + text = newText + // Advance to the next field if text is maximum length and valid + if text.count == configuration.maxLength(for: text), case .valid = validationState { + delegate?.continueToNextField(element: self) + view.resignFirstResponder() + } + } + isEditing = view.isEditing + didReceiveAutofill = view.didReceiveAutofill + + // Glue: Update the view and our delegate + view.updateUI(with: viewModel) + delegate?.didUpdate(element: self) + } + + func textFieldViewContinueToNextField(view: TextFieldView) { + isEditing = view.isEditing + delegate?.continueToNextField(element: self) + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/TextField/TextFieldElementConfiguration.swift b/StripeUICore/StripeUICore/Source/Elements/TextField/TextFieldElementConfiguration.swift new file mode 100644 index 00000000..2de15fca --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/TextField/TextFieldElementConfiguration.swift @@ -0,0 +1,135 @@ +// +// TextFieldElementConfiguration.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 6/9/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore +import UIKit + +/** + Contains the business logic for a TextField. + + - Seealso: `TextFieldElement+Factory.swift` + */ +@_spi(STP) public protocol TextFieldElementConfiguration { + var label: String { get } + + /** + Defaults to `label` + */ + var accessibilityLabel: String { get } + var shouldShowClearButton: Bool { get } + var disallowedCharacters: CharacterSet { get } + /** + If `true`, adds " (optional)" to the field's label . Defaults to `false`. + - Note: This value is passed to the `validate(text:isOptional:)` method. + */ + var isOptional: Bool { get } + + /** + - Note: The text field gets a sanitized version of this (i.e. after stripping disallowed characters, applying max length, etc.) + */ + var defaultValue: String? { get } + + /** + Validate the text. + + - Parameter isOptional: Whether or not the text field's value is optional. + */ + func validate(text: String, isOptional: Bool) -> TextFieldElement.ValidationState + + /** + A string to display under the field + */ + func subLabel(text: String) -> String? + + /** + - Parameter text: The user's sanitized input (i.e., removing `disallowedCharacters` and clipping to `maxLength(for:)`) + - Returns: A string as it should be displayed to the user. e.g., Apply kerning between every 4th and 5th number for PANs. + */ + func makeDisplayText(for text: String) -> NSAttributedString + + /** + - Returns: An assortment of properties to apply to the keyboard for the text field. + */ + func keyboardProperties(for text: String) -> TextFieldElement.KeyboardProperties + + /** + The maximum length of text allowed in the text field. + - Note: Text beyond this length is removed before its displayed in the UI or passed to other `TextFieldElementConfiguration` methods. + - Note: Return `Int.max` to indicate there is no maximum + */ + func maxLength(for text: String) -> Int + + /** + An optional accessory view displayed on the trailing side of the text field. + This could be the logo of a network, a bank, etc. + - Returns: a view. + */ + func accessoryView(for text: String, theme: ElementsUITheme) -> UIView? + + /** + Convenience method that creates a TextFieldElement using this Configuration + */ + func makeElement(theme: ElementsUITheme) -> TextFieldElement +} + +// MARK: - Default implementation + +public extension TextFieldElementConfiguration { + var accessibilityLabel: String { + return label + } + + var disallowedCharacters: CharacterSet { + return .newlines + } + + var isOptional: Bool { + return false + } + + var defaultValue: String? { + return nil + } + + // Hide clear button by default + var shouldShowClearButton: Bool { + return false + } + + func makeDisplayText(for text: String) -> NSAttributedString { + return NSAttributedString(string: text) + } + + func keyboardProperties(for text: String) -> TextFieldElement.KeyboardProperties { + return .init(type: .default, textContentType: nil, autocapitalization: .words) + } + + func validate(text: String, isOptional: Bool) -> TextFieldElement.ValidationState { + if text.stp_stringByRemovingCharacters(from: .whitespacesAndNewlines).isEmpty { + return isOptional ? .valid : .invalid(TextFieldElement.Error.empty) + } + return .valid + } + + func subLabel(text: String) -> String? { + return nil + } + + func maxLength(for text: String) -> Int { + return .max + } + + func accessoryView(for text: String, theme: ElementsUITheme) -> UIView? { + return nil + } + + func makeElement(theme: ElementsUITheme) -> TextFieldElement { + return TextFieldElement(configuration: self, theme: theme) + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/TextField/TextFieldFormatter.swift b/StripeUICore/StripeUICore/Source/Elements/TextField/TextFieldFormatter.swift new file mode 100644 index 00000000..d69fc776 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/TextField/TextFieldFormatter.swift @@ -0,0 +1,108 @@ +// +// TextFieldFormatter.swift +// StripeUICore +// +// Created by Mel Ludowise on 9/28/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +struct TextFieldFormatter { + + // NOTE(mludowise): If we ever have a case where we need to display `#` or `*` + // inside a formatted string, we should probably change `format` to something + // more structured other than a `String`. + + static let redactedNumberCharacter: Character = "•" + static let digitPatternCharacter: Character = "#" + static let letterPatternCharacter: Character = "*" + + private let format: String + + /** + - Parameters: + - format: Consists of a string using pound signs `#`for numeric placeholders, and asterisks `*` for letters. + + - Note: Returns nil if the given format is invalid and doesn't contain any `#` or `*` characters. + */ + init?(format: String) { + guard format.contains(TextFieldFormatter.letterPatternCharacter) || + format.contains(TextFieldFormatter.digitPatternCharacter) else { + return nil + } + self.format = format + } + + /** + Applies a format to `input`. + + - Note: + If `input` doesn't contain enough characters to fill-in the placeholders, a partially formatted string + will be returned. In the case of `input` containing more characters than expected, it will be truncated + to the max length allowed by the format. + + - Parameters: + - input: Content to be formatted. + - appendRemaining: Set to true if any remaining characters in input after filling the pattern should be added as a suffix + + - Returns: The resulting formatted string. + */ + func applyFormat(to input: String, shouldAppendRemaining: Bool = false) -> String { + var result: [Character] = [] + + var cursor = input.startIndex + + /* + Buffer of characters that will get appended to the result only if there + are more consumable characters (`*` or `#`). This prevents adding + formatted characters to the end of the string, which can break the + TextFieldView's backspace behavior when updating cursor position after + formatted characters. + */ + var resultBuffer: [Character] = [] + + for token in format { + guard cursor < input.endIndex else { + break + } + + repeat { + var consumeInput = false + if token == TextFieldFormatter.digitPatternCharacter, + (input[cursor].isNumber || input[cursor] == TextFieldFormatter.redactedNumberCharacter) { + consumeInput = true + resultBuffer.append(input[cursor]) + } else if token == TextFieldFormatter.letterPatternCharacter, + input[cursor].isLetter { + consumeInput = true + resultBuffer.append(input[cursor]) + } + + if consumeInput { + // Consume a character from the input + result += resultBuffer + resultBuffer = [] + cursor = input.index(after: cursor) + break + } + + if token == TextFieldFormatter.digitPatternCharacter || + token == TextFieldFormatter.letterPatternCharacter { + // Discard unmatched token + cursor = input.index(after: cursor) + } else { + resultBuffer.append(token) + break + } + } while cursor < input.endIndex + } + + if shouldAppendRemaining, + cursor < input.endIndex { + result += " " + String(input[cursor...]) + } + + return String(result) + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/TextField/TextFieldView.swift b/StripeUICore/StripeUICore/Source/Elements/TextField/TextFieldView.swift new file mode 100644 index 00000000..53d9d78a --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/TextField/TextFieldView.swift @@ -0,0 +1,290 @@ +// +// TextFieldView.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 6/3/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +protocol TextFieldViewDelegate: AnyObject { + func textFieldViewDidUpdate(view: TextFieldView) + func textFieldViewContinueToNextField(view: TextFieldView) +} + +/** + A text input field view with a floating placeholder and images. + - Seealso: `TextFieldElement.ViewModel` + + For internal SDK use only + */ +@objc(STP_Internal_TextFieldView) +class TextFieldView: UIView { + weak var delegate: TextFieldViewDelegate? + private lazy var toolbar = DoneButtonToolbar(delegate: self, theme: viewModel.theme) + var text: String { + return textField.text ?? "" + } + var isEditing: Bool { + return textField.isEditing + } + override var isUserInteractionEnabled: Bool { + didSet { + textField.isUserInteractionEnabled = isUserInteractionEnabled + updateUI(with: viewModel) + } + } + + var didReceiveAutofill = false + + // MARK: - Views + + private(set) lazy var textField: UITextField = { + let textField = UITextField() + textField.delegate = self + textField.addTarget(self, action: #selector(textDidChange), for: .editingChanged) + textField.autocorrectionType = .no + textField.spellCheckingType = .no + textField.adjustsFontForContentSizeCategory = true + textField.font = viewModel.theme.fonts.subheadline + return textField + }() + private lazy var textFieldView: FloatingPlaceholderTextFieldView = { + return FloatingPlaceholderTextFieldView(textField: textField, theme: viewModel.theme) + }() + + let accessoryContainerView = UIView() + + /// This could contain the logos of networks, banks, etc. + var accessoryView: UIView? { + didSet { + // For some reason, the stackview chooses to stretch accessoryContainerView if its + // content is nil instead of the text field, so we hide it. + accessoryContainerView.setHiddenIfNecessary(accessoryView == nil) + + guard oldValue != accessoryView else { + return + } + oldValue?.removeFromSuperview() + if let accessoryView = accessoryView { + accessoryContainerView.addAndPinSubview(accessoryView) + accessoryView.setContentHuggingPriority(.required, for: .horizontal) + } + } + } + + lazy var errorIconView: UIImageView = { + let imageView = UIImageView(image: Image.icon_error.makeImage(template: true)) + imageView.tintColor = viewModel.theme.colors.danger + imageView.contentMode = .scaleAspectFit + return imageView + }() + lazy var clearButton: UIButton = { + let button = UIButton(type: .custom) + button.tintColor = viewModel.theme.colors.placeholderText + button.setImage(Image.icon_clear.makeImage(template: true), for: .normal) + button.isHidden = true + button.addTarget(self, action: #selector(clearText), for: .touchUpInside) + + return button + }() + private var viewModel: TextFieldElement.ViewModel + private var hStack = UIStackView() + + // MARK: - Initializers + + init(viewModel: TextFieldElement.ViewModel, delegate: TextFieldViewDelegate) { + self.viewModel = viewModel + self.delegate = delegate + super.init(frame: .zero) + translatesAutoresizingMaskIntoConstraints = false + isAccessibilityElement = false // false b/c we use `accessibilityElements` + installConstraints() + updateUI(with: viewModel) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Overrides + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard isUserInteractionEnabled, !isHidden, self.point(inside: point, with: event) else { + return nil + } + // We override hitTest to forward all events within our bounds to the textfield + // ...except for these subviews: + for interactableSubview in [clearButton, accessoryView].compactMap({ $0 }) { + let convertedPoint = interactableSubview.convert(point, from: self) + if let hitView = interactableSubview.hitTest(convertedPoint, with: event) { + return hitView + } + } + return textField + } + + // MARK: - Private methods + + fileprivate func installConstraints() { + hStack = UIStackView(arrangedSubviews: [textFieldView, errorIconView, clearButton, accessoryContainerView]) + clearButton.setContentHuggingPriority(.required, for: .horizontal) + clearButton.setContentCompressionResistancePriority(textField.contentCompressionResistancePriority(for: .horizontal) + 1, + for: .horizontal) + errorIconView.setContentHuggingPriority(.required, for: .horizontal) + errorIconView.setContentCompressionResistancePriority(textField.contentCompressionResistancePriority(for: .horizontal) + 1, + for: .horizontal) + accessoryContainerView.setContentHuggingPriority(.required, for: .horizontal) + accessoryContainerView.setContentCompressionResistancePriority(textField.contentCompressionResistancePriority(for: .horizontal) + 1, + for: .horizontal) + hStack.alignment = .center + hStack.spacing = 6 + addAndPinSubview(hStack, insets: ElementsUI.contentViewInsets) + } + + @objc private func clearText() { + textField.text = nil + textField.sendActions(for: .editingChanged) + } + + private func setClearButton(hidden: Bool) { + UIView.performWithoutAnimation { + clearButton.isHidden = hidden + hStack.layoutIfNeeded() + } + } + + // MARK: - Internal methods + + func updateUI(with viewModel: TextFieldElement.ViewModel) { + self.viewModel = viewModel + + // Update accessibility + textField.accessibilityLabel = viewModel.accessibilityLabel + + // Update placeholder, text + textFieldView.placeholder = viewModel.placeholder + + // Setting attributedText moves the cursor to the end, so we grab the cursor position now + // Get the offset of the cursor from the end of the textField so it will keep + // the same relative position in case attributedText adds more characters + let cursorOffsetFromEnd = textField.selectedTextRange.map { textField.offset(from: textField.endOfDocument, to: $0.end) } + + textField.attributedText = viewModel.attributedText + if let cursorOffsetFromEnd = cursorOffsetFromEnd, + let cursor = textField.position(from: textField.endOfDocument, offset: cursorOffsetFromEnd) { + // Re-set the cursor back to where it was + textField.selectedTextRange = textField.textRange(from: cursor, to: cursor) + } + textFieldView.updatePlaceholder(animated: false) + + // Update keyboard + textField.autocapitalizationType = viewModel.keyboardProperties.autocapitalization + textField.textContentType = viewModel.keyboardProperties.textContentType + if viewModel.keyboardProperties.type != textField.keyboardType { + textField.keyboardType = viewModel.keyboardProperties.type + textField.inputAccessoryView = textField.keyboardType.hasReturnKey ? nil : toolbar + textField.reloadInputViews() + } + + // Update text and border color + if case .invalid(let error) = viewModel.validationState, + error.shouldDisplay(isUserEditing: textField.isEditing) { + layer.borderColor = viewModel.theme.colors.danger.cgColor + textField.textColor = viewModel.theme.colors.danger + errorIconView.alpha = 1 + textField.accessibilityValue = viewModel.attributedText.string + ", " + error.localizedDescription + } else { + layer.borderColor = viewModel.theme.colors.border.cgColor + textField.textColor = viewModel.theme.colors.textFieldText.disabled(!isUserInteractionEnabled) + errorIconView.alpha = 0 + textField.accessibilityValue = viewModel.attributedText.string + } + if frame != .zero { + textField.layoutIfNeeded() // Fixes an issue on iOS 15 where setting textField properties cause it to lay out from zero size. + } + + // Update accessory view + accessoryView = viewModel.accessoryView + + accessibilityElements = [textFieldView, accessoryView].compactMap { $0 } + // Manually call layoutIfNeeded to avoid unintentional animations + // in next layout pass + layoutIfNeeded() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + updateUI(with: viewModel) + } +} + +// MARK: - UITextFieldDelegate + +extension TextFieldView: UITextFieldDelegate { + @objc func textDidChange() { + // If the text updates to non-empty, ensure the clear button is visible + if let text = textField.text, !text.isEmpty, viewModel.shouldShowClearButton { + setClearButton(hidden: false) + } else { + // Did update to empty text + setClearButton(hidden: true) + } + + delegate?.textFieldViewDidUpdate(view: self) + } + + func textFieldDidBeginEditing(_ textField: UITextField) { + // If text is already present in the text field we should show the clear button + if let text = textField.text, !text.isEmpty, viewModel.shouldShowClearButton { + setClearButton(hidden: false) + } + textFieldView.updatePlaceholder() + delegate?.textFieldViewDidUpdate(view: self) + } + + func textFieldDidEndEditing(_ textField: UITextField) { + setClearButton(hidden: true) // Hide clear button when not editing + textFieldView.updatePlaceholder() + textField.layoutIfNeeded() // Without this, the text jumps for some reason + delegate?.textFieldViewDidUpdate(view: self) + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + delegate?.textFieldViewContinueToNextField(view: self) + textField.resignFirstResponder() + return false + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + // This detects autofill specifically, which as of iOS 15 Apple only allows on empty text fields. This will also catch pastes into empty text fields. + // This is not a perfect heuristic, but is sufficient for the purposes of being able to process autofilled text specifically (e.g. a phone number with unpredictable formatting that we want to parse) + didReceiveAutofill = (text.isEmpty && range.length == 0 && range.location == 0 && string.count > 1) + return true + } +} + +// MARK: - EventHandler + +extension TextFieldView: EventHandler { + func handleEvent(_ event: STPEvent) { + switch event { + case .shouldEnableUserInteraction: + isUserInteractionEnabled = true + case .shouldDisableUserInteraction: + isUserInteractionEnabled = false + default: + break + } + } +} + +// MARK: - DoneButtonToolbarDelegate + +extension TextFieldView: DoneButtonToolbarDelegate { + func didTapDone(_ toolbar: DoneButtonToolbar) { + textField.resignFirstResponder() + } +} diff --git a/StripeUICore/StripeUICore/Source/Elements/TextOrDropdownElement.swift b/StripeUICore/StripeUICore/Source/Elements/TextOrDropdownElement.swift new file mode 100644 index 00000000..41571496 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Elements/TextOrDropdownElement.swift @@ -0,0 +1,47 @@ +// +// TextOrDropdownElement.swift +// StripeUICore +// +// Created by Nick Porter on 9/2/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// Describes an element that is either a text or dropdown element +@_spi(STP) public protocol TextOrDropdownElement: Element { + + /// The raw data for the element, e.g. the text of a textfield or raw data of a dropdown item + var rawData: String { get } + + /// Sets the raw data for this element + func setRawData(_ rawData: String) +} + +// MARK: Conformance + +extension TextFieldElement: TextOrDropdownElement { + public var rawData: String { + return text + } + + public func setRawData(_ rawData: String) { + setText(rawData) + } + +} + +extension DropdownFieldElement: TextOrDropdownElement { + public var rawData: String { + return items[selectedIndex].rawData + } + + public func setRawData(_ rawData: String) { + guard let itemIndex = items.firstIndex(where: {$0.rawData.lowercased() == rawData.lowercased() + || $0.pickerDisplayName.string.lowercased() == rawData.lowercased()}) else { + return + } + + select(index: itemIndex) + } +} diff --git a/StripeUICore/StripeUICore/Source/Events.swift b/StripeUICore/StripeUICore/Source/Events.swift new file mode 100644 index 00000000..828e803a --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Events.swift @@ -0,0 +1,30 @@ +// +// Events.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 10/22/20. +// Copyright © 2020 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +/// Sends the event down the view hierarchy +@_spi(STP) public func sendEventToSubviews(_ event: STPEvent, from view: UIView) { + if let view = view as? EventHandler { + view.handleEvent(event) + } + for subview in view.subviews { + sendEventToSubviews(event, from: subview) + } +} + +@frozen @_spi(STP) public enum STPEvent { + case shouldEnableUserInteraction + case shouldDisableUserInteraction + case viewDidAppear +} + +@_spi(STP) public protocol EventHandler { + func handleEvent(_ event: STPEvent) +} diff --git a/StripeUICore/StripeUICore/Source/Helpers/CompatibleColor.swift b/StripeUICore/StripeUICore/Source/Helpers/CompatibleColor.swift new file mode 100644 index 00000000..fedba2c2 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Helpers/CompatibleColor.swift @@ -0,0 +1,15 @@ +// +// CompatibleColor.swift +// StripeUICore +// +// Created by Ramon Torres on 10/26/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import UIKit + +// TODO(ramont): Remove. + +/// Alias to help migrating active feature branches. +@available(*, deprecated, renamed: "UIColor") +@_spi(STP) public typealias CompatibleColor = UIColor diff --git a/StripeUICore/StripeUICore/Source/Helpers/ImageMaker.swift b/StripeUICore/StripeUICore/Source/Helpers/ImageMaker.swift new file mode 100644 index 00000000..2aee2b16 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Helpers/ImageMaker.swift @@ -0,0 +1,64 @@ +// +// ImageMaker.swift +// StripeUICore +// +// Created by Mel Ludowise on 9/10/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +@_spi(STP) import StripeCore + +@_spi(STP) public protocol ImageMaker { + associatedtype BundleLocator: BundleLocatorProtocol +} + +@_spi(STP) public extension ImageMaker { + private static func imageNamed( + _ imageName: String, + templateIfAvailable: Bool, + compatibleWith traitCollection: UITraitCollection? = nil + ) -> UIImage? { + + var image = UIImage( + named: imageName, in: BundleLocator.resourcesBundle, compatibleWith: traitCollection) + + if image == nil { + image = UIImage(named: imageName, in: nil, compatibleWith: traitCollection) + } + + if templateIfAvailable { + image = image?.withRenderingMode(.alwaysTemplate) + } + + return image + } + + static func safeImageNamed( + _ imageName: String, + templateIfAvailable: Bool = false, + overrideUserInterfaceStyle: UIUserInterfaceStyle? = nil + ) -> UIImage { + let image: UIImage + if let overrideUserInterfaceStyle = overrideUserInterfaceStyle { + let appearanceTrait = UITraitCollection(userInterfaceStyle: overrideUserInterfaceStyle) + image = imageNamed(imageName, templateIfAvailable: templateIfAvailable, compatibleWith: appearanceTrait) ?? UIImage() + } else { + image = imageNamed(imageName, templateIfAvailable: templateIfAvailable) ?? UIImage() + } + assert(image.size != .zero, "Failed to find an image named \(imageName)") + return image + } +} + +@_spi(STP) public extension ImageMaker where Self: RawRepresentable, RawValue == String { + func makeImage(template: Bool = false, overrideUserInterfaceStyle: UIUserInterfaceStyle? = nil) -> UIImage { + return Self.safeImageNamed( + self.rawValue, + templateIfAvailable: template, + overrideUserInterfaceStyle: overrideUserInterfaceStyle + ) + } +} diff --git a/StripeUICore/StripeUICore/Source/Helpers/InputFormColors.swift b/StripeUICore/StripeUICore/Source/Helpers/InputFormColors.swift new file mode 100644 index 00000000..2d745f83 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Helpers/InputFormColors.swift @@ -0,0 +1,45 @@ +// +// InputFormColors.swift +// StripeUICore +// +// Created by Cameron Sabol on 9/22/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) public enum InputFormColors { + + public static var textColor: UIColor { + return .label + } + + public static var disabledTextColor: UIColor { + return .dynamic( + light: UIColor(red: 60.0 / 255.0, green: 60.0 / 255.0, blue: 67.0 / 255.0, alpha: 0.6), + dark: UIColor(red: 235.0 / 255.0, green: 235.0 / 255.0, blue: 245.0 / 255.0, alpha: 0.6) + ) + } + + public static var errorColor: UIColor { + return .systemRed + } + + public static var outlineColor: UIColor { + return UIColor(red: 120.0 / 255.0, green: 120.0 / 255.0, blue: 128.0 / 255.0, alpha: 0.36) + } + + public static var backgroundColor: UIColor { + return .dynamic( + light: .systemBackground, + dark: UIColor(red: 116.0 / 255.0, green: 116.0 / 255.0, blue: 128.0 / 255.0, alpha: 0.18) + ) + } + + public static var disabledBackgroundColor: UIColor { + return .dynamic( + light: UIColor(red: 248.0 / 255.0, green: 248.0 / 255.0, blue: 248.0 / 255.0, alpha: 1), + dark: UIColor(red: 116.0 / 255.0, green: 116.0 / 255.0, blue: 128.0 / 255.0, alpha: 0.18) + ) + } +} diff --git a/StripeUICore/StripeUICore/Source/Helpers/RegionCodeProvider.swift b/StripeUICore/StripeUICore/Source/Helpers/RegionCodeProvider.swift new file mode 100644 index 00000000..03a5f98c --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Helpers/RegionCodeProvider.swift @@ -0,0 +1,14 @@ +// +// RegionCodeProvider.swift +// StripeUICore +// +// Created by Cameron Sabol on 1/31/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// Internal protocol to represent an object that provides a region code +@_spi(STP) public protocol RegionCodeProvider { + var regionCode: String { get } +} diff --git a/StripeUICore/StripeUICore/Source/Helpers/STPLocalizedString.swift b/StripeUICore/StripeUICore/Source/Helpers/STPLocalizedString.swift new file mode 100644 index 00000000..969ed2c9 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Helpers/STPLocalizedString.swift @@ -0,0 +1,13 @@ +// +// STPLocalizedString.swift +// StripeUICore +// +// Created by Mel Ludowise on 9/8/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore + +@inline(__always) func STPLocalizedString(_ key: String, _ comment: String?) -> String { + return STPLocalizationUtils.localizedStripeString(forKey: key, bundleLocator: StripeUICoreBundleLocator.self) +} diff --git a/StripeUICore/StripeUICore/Source/Helpers/StackViewWithSeparator.swift b/StripeUICore/StripeUICore/Source/Helpers/StackViewWithSeparator.swift new file mode 100644 index 00000000..cd472d1f --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Helpers/StackViewWithSeparator.swift @@ -0,0 +1,213 @@ +// +// StackViewWithSeparator.swift +// StripeUICore +// +// Created by Cameron Sabol on 9/22/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +/// For internal SDK use only +@objc(STP_Internal_StackViewWithSeparator) +@_spi(STP) public class StackViewWithSeparator: UIStackView { + + public static let borderlessInset: CGFloat = 10 + + public enum SeparatoryStyle { + case full + case partial + } + + public var separatorStyle: SeparatoryStyle = .full { + didSet { + for view in arrangedSubviews { + if let substackView = view as? StackViewWithSeparator { + substackView.separatorStyle = separatorStyle + } + } + } + } + + public var separatorColor: UIColor = .clear { + didSet { + separatorLayer.strokeColor = separatorColor.cgColor + backgroundView.layer.borderColor = separatorColor.cgColor + } + } + + /// Commonly referred to as `borderWidth` + public override var spacing: CGFloat { + didSet { + backgroundView.layer.borderWidth = spacing + separatorLayer.lineWidth = spacing + layoutMargins = UIEdgeInsets( + top: spacing, left: spacing, bottom: spacing, right: spacing) + } + } + + public var drawBorder: Bool = false { + didSet { + isLayoutMarginsRelativeArrangement = drawBorder + if drawBorder { + addSubview(backgroundView) + sendSubviewToBack(backgroundView) + } else { + backgroundView.removeFromSuperview() + } + } + } + + public var borderCornerRadius: CGFloat { + get { + return backgroundView.layer.cornerRadius + } + set { + backgroundView.layer.cornerRadius = newValue + } + } + + public var borderColor: UIColor = .systemGray3 { + didSet { + backgroundView.layer.borderColor = borderColor.cgColor + } + } + + @objc + override public var isUserInteractionEnabled: Bool { + didSet { + if isUserInteractionEnabled { + backgroundView.backgroundColor = customBackgroundColor + } else { + backgroundView.backgroundColor = customBackgroundDisabledColor + } + } + } + + public var hideShadow: Bool = false { + didSet { + if hideShadow { + backgroundView.layer.shadowOffset = .zero + backgroundView.layer.shadowColor = UIColor.clear.cgColor + backgroundView.layer.shadowOpacity = 0 + backgroundView.layer.shadowRadius = 0 + backgroundView.layer.shadowOpacity = 0 + } else { + configureDefaultShadow() + } + } + } + + public var customBackgroundColor: UIColor? = InputFormColors.backgroundColor { + didSet { + if isUserInteractionEnabled { + backgroundView.backgroundColor = customBackgroundColor + } + } + } + + public var customBackgroundDisabledColor: UIColor? = InputFormColors.disabledBackgroundColor { + didSet { + if isUserInteractionEnabled { + backgroundView.backgroundColor = customBackgroundColor + } + } + } + + private let separatorLayer = CAShapeLayer() + let backgroundView: UIView = { + let view = UIView() + view.backgroundColor = InputFormColors.backgroundColor + view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + // copied from configureDefaultShadow to avoid recursion on init + view.layer.shadowOffset = CGSize(width: 0, height: 2) + view.layer.shadowColor = UIColor.black.cgColor + view.layer.shadowOpacity = 0.05 + view.layer.shadowRadius = 4 + return view + }() + + func configureDefaultShadow() { + backgroundView.layer.shadowOffset = CGSize(width: 0, height: 2) + backgroundView.layer.shadowColor = UIColor.black.cgColor + backgroundView.layer.shadowOpacity = 0.05 + backgroundView.layer.shadowRadius = 4 + } + + override public func layoutSubviews() { + if backgroundView.superview == self { + sendSubviewToBack(backgroundView) + } + super.layoutSubviews() + if separatorLayer.superlayer == nil { + layer.addSublayer(separatorLayer) + } + separatorLayer.strokeColor = separatorColor.cgColor + + let path = UIBezierPath() + path.lineWidth = spacing + + if spacing > 0 { + // inter-view separators + let nonHiddenArrangedSubviews = arrangedSubviews.filter({ !$0.isHidden }) + + let isRTL = traitCollection.layoutDirection == .rightToLeft + + for view in nonHiddenArrangedSubviews { + + if axis == .vertical { + + switch separatorStyle { + case .full: + if view == nonHiddenArrangedSubviews.last { + continue + } + path.move(to: CGPoint(x: view.frame.minX, y: view.frame.maxY + 0.5 * spacing)) + path.addLine( + to: CGPoint(x: view.frame.maxX, y: view.frame.maxY + 0.5 * spacing)) + case .partial: + // no-op in partial + break + } + + } else { // .horizontal + + switch separatorStyle { + case .full: + if (!isRTL && view == nonHiddenArrangedSubviews.first) + || (isRTL && view == nonHiddenArrangedSubviews.last) + { + continue + } + path.move(to: CGPoint(x: view.frame.minX - 0.5 * spacing, y: view.frame.minY)) + path.addLine( + to: CGPoint(x: view.frame.minX - 0.5 * spacing, y: view.frame.maxY)) + case .partial: + assert(!drawBorder, "Can't combine partial separator style in a horizontal stack with draw border") + if 2 * StackViewWithSeparator.borderlessInset * spacing >= view.frame.width { + continue + } + // These values are chosen to optimize for use in STPCardFormView with borderless style + path.move(to: CGPoint(x: view.frame.minX + StackViewWithSeparator.borderlessInset * spacing, y: view.frame.maxY)) + path.addLine( + to: CGPoint(x: view.frame.maxX - StackViewWithSeparator.borderlessInset * spacing, y: view.frame.maxY)) + } + + } + + } + } + + separatorLayer.path = path.cgPath + backgroundView.layer.shadowPath = hideShadow ? nil : + UIBezierPath(roundedRect: bounds, cornerRadius: borderCornerRadius).cgPath + } + + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + // CGColor's must be manually updated when the trait collection changes + backgroundView.layer.borderColor = borderColor.cgColor + separatorLayer.strokeColor = separatorColor.cgColor + } + +} diff --git a/StripeUICore/StripeUICore/Source/Helpers/String+CountryEmoji.swift b/StripeUICore/StripeUICore/Source/Helpers/String+CountryEmoji.swift new file mode 100644 index 00000000..f9a1ac73 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Helpers/String+CountryEmoji.swift @@ -0,0 +1,26 @@ +// +// String+CountryEmoji.swift +// StripeUICore +// +// Created by Cameron Sabol on 9/30/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +extension String { + static func countryFlagEmoji(for countryCode: String) -> String? { + let capitalized = countryCode.uppercased() + guard Locale.isoRegionCodes.contains(capitalized) else { + return nil + } + + let unicodeScalars = capitalized.unicodeScalars.compactMap({ Unicode.Scalar($0.value + 127397) }) + guard unicodeScalars.count == 2 else { + return nil + } + + return String(String.UnicodeScalarView(unicodeScalars)) + + } +} diff --git a/StripeUICore/StripeUICore/Source/Helpers/String+Localized.swift b/StripeUICore/StripeUICore/Source/Helpers/String+Localized.swift new file mode 100644 index 00000000..378d59b7 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Helpers/String+Localized.swift @@ -0,0 +1,336 @@ +// +// String+Localized.swift +// StripeUICore +// +// Created by Mel Ludowise on 9/16/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +// Localized strings that are used in multiple contexts. Collected here to avoid re-translation +// We use snake case to make long names easier to read. +@_spi(STP) public extension String.Localized { + + static var address: String { + STPLocalizedString( + "Address", + """ + Caption for Address field on address form + Section header for address fields + """ + ) + } + + static var address_line1: String { + STPLocalizedString("Address line 1", "Address line 1 placeholder for billing address form.\nLabel for address line 1 field") + } + + static var address_line2: String { + STPLocalizedString("Address line 2", "Label for address line 2 field") + } + + static var country_or_region: String { + STPLocalizedString("Country or region", "Country selector and postal code entry form header title\nLabel of an address field") + } + + static var country: String { + STPLocalizedString("Country", "Caption for Country field on address form") + } + + static var email: String { + STPLocalizedString("Email", "Label for Email field on form") + } + + static var name: String { + STPLocalizedString("Name", "Label for Name field on form") + } + + static var full_name: String { + STPLocalizedString("Full name", "Label for Full name field on form") + } + + static var given_name: String { + STPLocalizedString("First", "Label for first (given) name field") + } + + static var family_name: String { + STPLocalizedString("Last", "Label for last (family) name field") + } + + static var nameOnAccount: String { + STPLocalizedString("Name on account", "Label for Name on account field on form") + } + + static var company: String { + STPLocalizedString("Company", "Label for Company field on form") + } + + static var invalid_email: String { + STPLocalizedString("Your email is invalid.", "Error message when email is invalid") + } + + static var billing_same_as_shipping: String { + STPLocalizedString("Billing address is same as shipping", "Label for a checkbox that makes customers billing address same as their shipping address") + } + + // MARK: - Phone number + + static var phone: String { + STPLocalizedString("Phone", "Caption for Phone field on address form") + } + + static var incomplete_phone_number: String { + STPLocalizedString("Incomplete phone number", "Error description for incomplete phone number") + } + + static var invalid_phone_number: String { + STPLocalizedString("Unable to parse phone number", "Error string when we can't parse a phone number") + } + + static var optional_field: String { + STPLocalizedString( + "%@ (optional)", + "The label of a text field that is optional. For example, 'Email (optional)' or 'Name (optional)" + ) + } + + static var other: String { + STPLocalizedString("Other", "An option in a dropdown selector indicating the customer's desired selection is not in the list. e.g., 'Choose your bank: Bank1, Bank2, Other'") + } + + // MARK: City field labels + + static var city: String { + STPLocalizedString("City", "Caption for City field on address form") + } + + static var district: String { + STPLocalizedString("District", "Label for the district field on an address form") + } + + static var suburb: String { + STPLocalizedString("Suburb", "Label of an address field") + } + + static var post_town: String { + STPLocalizedString("Town or city", "Label of an address field") + } + + static var suburb_or_city: String { + STPLocalizedString("Suburb or city", "Label of an address field") + } + + // MARK: Postal code field labels + + static var eircode: String { + STPLocalizedString("Eircode", "Label of an address field") + } + + static var postal_pin: String { + "PIN" // Intentionally left as-is + } + + static var postal_code: String { + STPLocalizedString( + "Postal code", + """ + Label of an address field + Short string for postal code (text used in non-US countries) + """ + ) + } + + static var zip: String { + STPLocalizedString( + "ZIP", + """ + Label of an address field + Short string for zip code (United States only) + Zip code placeholder US only + """ + ) + } + + static var your_zip_is_incomplete: String { + STPLocalizedString("Your ZIP is incomplete.", "Error message for when ZIP code in form is incomplete (US only)") + } + + static var your_postal_code_is_incomplete: String { + STPLocalizedString("Your postal code is incomplete.", "Error message for when postal code in form is incomplete") + } + + // MARK: State field labels + + static var area: String { + STPLocalizedString("Area", "Label of an address field") + } + + static var county: String { + STPLocalizedString( + "County", + """ + Caption for County field on address form (only countries that use county, like United Kingdom) + Label of an address field + """ + ) + } + + static var department: String { + STPLocalizedString("Department", "Label of an address field") + } + + static var do_si: String { + STPLocalizedString("Do Si", "Label of an address field") + } + + static var emirate: String { + STPLocalizedString("Emirate", "Label of an address field") + } + + static var island: String { + STPLocalizedString("Island", "Label of an address field") + } + + static var oblast: String { + STPLocalizedString("Oblast", "Label of an address field") + } + + static var parish: String { + STPLocalizedString("Parish", "Label of an address field") + } + + static var prefecture: String { + STPLocalizedString("Prefecture", "Label of an address field") + } + + static var province: String { + STPLocalizedString( + "Province", + """ + Caption for Province field on address form (only countries that use province, like Canada) + Label of an address field + """ + ) + } + + static var state: String { + STPLocalizedString( + "State", + """ + Caption for State field on address form (only countries that use state , like United States) + Label of an address field + """ + ) + } + + // MARK: - Account + static var accountNumber: String { + STPLocalizedString( + "Account number", + """ + Caption for account number + """ + ) + } + static var incompleteBSBEntered: String { + STPLocalizedString( + "The BSB you entered is incomplete.", + "Error string displayed to user when they have entered an incomplete BSB number.") + } + + static var invalidSortCodeEntered: String { + STPLocalizedString( + "The sort code you entered is invalid.", + "Error string displayed to user when they have entered an invalid 'sort code' (a bank routing number used in the UK and Ireland)") + } + + static var incompleteAccountNumber: String { + STPLocalizedString("The account number you entered is incomplete.", "Error description for incomplete account number") + } + + static var removeBankAccountEndingIn: String { + STPLocalizedString( + "Remove bank account ending in %@", + "Content for alert popup prompting to confirm removing a saved bank account. e.g. 'Remove bank account ending in 4242'") + } + + static var removeBankAccount: String { + STPLocalizedString( + "Remove bank account", + "Title for confirmation alert to remove a saved bank account payment method") + } + + // MARK: - Control strings + static var error: String { + return STPLocalizedString("Error", "Text for error labels") + } + + static var cancel: String { + STPLocalizedString("Cancel", "Button title to cancel action in an alert") + } + + static var closeFormTitle: String { + STPLocalizedString("Do you want to close this form?", + "Used as the title for prompting the user if they want to close the sheet") + } + + static var paymentInfoWontBeSaved: String { + STPLocalizedString("Your payment information will not be saved.", + "Used as the title for prompting the user if they want to close the sheet") + } + + static var ok: String { + STPLocalizedString("OK", "ok button") + } + + static var `continue`: String { + STPLocalizedString("Continue", "Text for continue button") + } + + static var remove: String { + STPLocalizedString("Remove", "Button title for confirmation alert to remove a saved payment method") + } + + static var search: String { + STPLocalizedString("Search", "Title of a button with a 🔍 (magnifying glass) icon that starts a search when tapped") + } + + static var useRotorToAccessLinks: String { + STPLocalizedString( + "Use rotor to access links", + "Accessibility hint indicating to use the accessibility rotor to open links. The word 'rotor' should be localized to match Apple's language here: https://support.apple.com/HT204783" + ) + } + + // MARK: - UPI + + static var upi_id: String { + STPLocalizedString("UPI ID", "Label for UPI ID number field on form") + } + + static var invalid_upi_id: String { + STPLocalizedString("Invalid UPI ID", "Error message when UPI ID is invalid") + } + + // MARK: - Blik + + static var blik_code: String { + STPLocalizedString("BLIK code", "Label for BLIK code number field on form") + } + + static var incomplete_blik_code: String { + STPLocalizedString("Your BLIK code is incomplete.", "Error message when BLIK code is incomplete") + } + + static var invalid_blik_code: String { + STPLocalizedString("Your BLIK code is invalid.", "Error message when BLIK code is invalid") + } + + // MARK: - Card brand choice + + static var card_brand_dropdown_placeholder: String { + STPLocalizedString("Select card brand (optional)", "Message when a user is selecting a card brand in a dropdown") + } +} diff --git a/StripeUICore/StripeUICore/Source/Helpers/String+RegionCodeProvider.swift b/StripeUICore/StripeUICore/Source/Helpers/String+RegionCodeProvider.swift new file mode 100644 index 00000000..a6c20262 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Helpers/String+RegionCodeProvider.swift @@ -0,0 +1,16 @@ +// +// String+RegionCodeProvider.swift +// StripeUICore +// +// Created by Cameron Sabol on 1/31/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// Default conformance of String to RegionCodeProvider +@_spi(STP) extension String: RegionCodeProvider { + public var regionCode: String { + return self + } +} diff --git a/StripeUICore/StripeUICore/Source/Helpers/StripeUICoreBundleLocator.swift b/StripeUICore/StripeUICore/Source/Helpers/StripeUICoreBundleLocator.swift new file mode 100644 index 00000000..1414b41e --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Helpers/StripeUICoreBundleLocator.swift @@ -0,0 +1,19 @@ +// +// StripeUICoreBundleLocator.swift +// StripeUICore +// +// Created by Mel Ludowise on 9/8/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeCore + +@_spi(STP) public final class StripeUICoreBundleLocator: BundleLocatorProtocol { + public static let internalClass: AnyClass = StripeUICoreBundleLocator.self + public static let bundleName = "StripeUICore" + #if SWIFT_PACKAGE + public static let spmResourcesBundle = Bundle.module + #endif + public static let resourcesBundle = StripeUICoreBundleLocator.computeResourcesBundle() +} diff --git a/StripeUICore/StripeUICore/Source/Image.swift b/StripeUICore/StripeUICore/Source/Image.swift new file mode 100644 index 00000000..30cbec5c --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Image.swift @@ -0,0 +1,30 @@ +// +// Image.swift +// StripeUICore +// +// Created by Mel Ludowise on 9/10/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import Foundation + +/// The canonical set of all image files in the SDK. +/// This helps us avoid duplicates and automatically test that all images load properly +/// Raw value is the image file name. We use snake case to make long names easier to read. +@_spi(STP) public enum Image: String, ImageMaker, CaseIterable { + public typealias BundleLocator = StripeUICoreBundleLocator + + // Icons/symbols + case icon_chevron_down = "icon_chevron_down" + case icon_clear = "icon_clear" + + // Brand Icons + case brand_stripe = "brand_stripe" + case icon_error = "form_error_icon" +} + +@_spi(STP) public extension Image { + static func brandImage(named name: String) -> Image? { + return Image(rawValue: "brand_\(name)") + } +} diff --git a/StripeUICore/StripeUICore/Source/Validators/BankRoutingNumber.swift b/StripeUICore/StripeUICore/Source/Validators/BankRoutingNumber.swift new file mode 100644 index 00000000..7da5877b --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Validators/BankRoutingNumber.swift @@ -0,0 +1,61 @@ +// +// BankRoutingNumber.swift +// StripeUICore +// +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) import StripeCore + +@_spi(STP) public protocol BankRoutingNumber { + var number: String { get } + var pattern: String { get } +} + +@_spi(STP) public extension BankRoutingNumber { + var isComplete: Bool { + return formattedNumber().count >= pattern.count + } + + func formattedNumber() -> String { + guard let formatter = TextFieldFormatter(format: pattern) else { + return number + } + let allowedCharacterSet = CharacterSet.stp_asciiDigit + + let result = formatter.applyFormat( + to: number.stp_stringByRemovingCharacters(from: allowedCharacterSet.inverted), + shouldAppendRemaining: true + ) + guard !result.isEmpty else { + return "" + } + return result + } + + func bsbNumberText() -> String { + return number.filter { $0 != "-" } + } +} + +@_spi(STP) public struct BSBNumber: BankRoutingNumber { + public var number: String + + public init(number: String) { + self.number = number + } + + public let pattern: String = "###-###" +} + +@_spi(STP) public struct SortCode: BankRoutingNumber { + public var number: String + + public init(number: String) { + self.number = number + } + + public let pattern: String = "##-##-##" +} diff --git a/StripeUICore/StripeUICore/Source/Validators/PhoneNumber.swift b/StripeUICore/StripeUICore/Source/Validators/PhoneNumber.swift new file mode 100644 index 00000000..4b20b15e --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Validators/PhoneNumber.swift @@ -0,0 +1,436 @@ +// +// PhoneNumber.swift +// StripeUICore +// +// Created by Cameron Sabol on 9/22/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) import StripeCore + +@_spi(STP) public struct PhoneNumber { + public enum Format { + /// Formatted according to e164 standard, e.g. +15555555555 + case e164 + /// Formatted for display as non-international number, e.g. (555) 555-555 + case national + /// Formatted for display an an international number with country code, e.g. +1 (555) 555-5555 + case international + + static let e164FormatMaxDigits = 15 + } + + public func string(as format: Format) -> String { + return metadata.formattedNumber(number, format: format) + } + + /// The country that matches this phone number, e.g. "US" + public var countryCode: String { + return metadata.regionCode + } + + /// The phone number prefix for the country of this phone number, e.g. "+1" + public var prefix: String { + return metadata.prefix + } + + /// Whether this represents a complete phone number + public var isComplete: Bool { + return string(as: .national).count >= metadata.pattern.count + } + + /// The phone number without the country prefix and containing only digits + public let number: String + private let metadata: Metadata + + public init?(number: String, countryCode: String?) { + guard let countryCode = countryCode, + let metadata = Metadata.metadata(for: countryCode) else { + return nil + } + + self.number = number + self.metadata = metadata + } + + init(number: String, metadata: Metadata) { + self.number = number + self.metadata = metadata + } + + /// Parses phone numbers in (*globalized*) E.164 format. + /// + /// - Note: Our metadata lacks of national destination code (area code) ranges, because of this we fallback to + /// the device's locale to disambiguate when a number can possibly belong to multiple regions. + /// + /// - Parameters: + /// - number: Phone number to parse. + /// - locale: User's locale. + /// - Returns: `PhoneNumber`, or `nil` if the number is not parsable. + public static func fromE164(_ number: String, locale: Locale = .current) -> PhoneNumber? { + let characters: [Character] = .init(number) + + // Matching regex: ^\+[1-9]\d{2,14}$ + guard + characters.count > 4, + characters.count <= Format.e164FormatMaxDigits + 1, + characters[0] == "+", + characters[1] != "0", + characters[1...].allSatisfy({ + $0.unicodeScalars.allSatisfy(CharacterSet.stp_asciiDigit.contains(_:)) + }) + else { + return nil + } + + let makePhoneNumber: (Metadata) -> PhoneNumber = { metadata in + return PhoneNumber( + number: String(characters[metadata.prefix.count...]), + metadata: metadata + ) + } + + // This filter should narrow down the metadata list to just 1 candidate in most cases, + // as very few countries share country calling codes. Country calling codes are also + // *Prefix codes*, which means that two codes will never overlap. + let candidates = Metadata.allMetadata.filter({ number.hasPrefix($0.prefix) }) + if candidates.count == 1 { + return candidates.first.flatMap(makePhoneNumber) + } + + // This second filter uses the device's locale to pick a winner out of N candidates. + if let winner = candidates.first(where: { $0.regionCode == locale.regionCode }) { + return makePhoneNumber(winner) + } + + // If no winner, we simply return the first candidate. Our metadata is sorted in a way that the + // main country of a prefix is always first. + return candidates.first.flatMap(makePhoneNumber) + } + +} + +@_spi(STP) public extension PhoneNumber { + struct Metadata: RegionCodeProvider { + + private static var metadataByCountryCodeCache: [String: Metadata] = [:] + + public static func metadata(for countryCode: String) -> Metadata? { + if let cached = metadataByCountryCodeCache[countryCode] { + return cached + } + if let metadata = allMetadata.first(where: { $0.regionCode == countryCode }) { + metadataByCountryCodeCache[countryCode] = metadata + return metadata + } + return nil + } + + public let prefix: String + public let regionCode: String + internal let pattern: String + + public var sampleFilledPattern: String { + let numDigitsInPattern = pattern.filter({ $0 == "#" }).count + return formattedNumber(String(repeating: "5", count: numDigitsInPattern), format: .national) + } + + func formattedNumber(_ number: String, format: Format) -> String { + guard let formatter = TextFieldFormatter(format: pattern) else { + return number + } + + let allowedCharacterSet: CharacterSet = CharacterSet.stp_asciiDigit.union(CharacterSet(charactersIn: String(TextFieldFormatter.redactedNumberCharacter))) // allow '•' for redacted numbers + + let result = formatter.applyFormat( + to: number.stp_stringByRemovingCharacters(from: allowedCharacterSet.inverted), + shouldAppendRemaining: true + ) + + guard !result.isEmpty else { + return "" + } + + switch format { + case .e164: + var resultDigits = result.stp_stringByRemovingCharacters(from: allowedCharacterSet.inverted) + // e164 drops leading 0s + if resultDigits.hasPrefix("0") { + resultDigits = String(resultDigits.suffix(resultDigits.count - 1)) + } + + resultDigits = prefix.stp_stringByRemovingCharacters(from: allowedCharacterSet.inverted) + resultDigits + // e164 doesn't accept more than 15 digits + if resultDigits.count > Format.e164FormatMaxDigits { + resultDigits = String(resultDigits.prefix(Format.e164FormatMaxDigits)) + } + + return "+" + resultDigits + case .national: + return result + case .international: + return prefix + " " + result + } + } + + // Note: The patterns here are not complete in some cases, e.g. + // JP where the first group of numbers will sometimes have 3 digits + // for mobile but we only expect 2. In these cases the input should + // allow for entry passed the pattern length + public static let allMetadata: [Metadata] = [ + // NANP member countries and territories (Zone 1) + // https://en.wikipedia.org/wiki/North_American_Numbering_Plan#Countries_and_territories + Metadata(prefix: "+1", regionCode: "US", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "CA", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "AG", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "AS", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "AI", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "BB", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "BM", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "BS", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "DM", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "DO", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "GD", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "GU", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "JM", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "KN", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "KY", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "LC", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "MP", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "MS", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "PR", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "SX", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "TC", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "TT", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "VC", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "VG", pattern: "(###) ###-####"), + Metadata(prefix: "+1", regionCode: "VI", pattern: "(###) ###-####"), + // Rest of the world + Metadata(prefix: "+20", regionCode: "EG", pattern: "### ### ####"), + Metadata(prefix: "+211", regionCode: "SS", pattern: "### ### ###"), + Metadata(prefix: "+212", regionCode: "MA", pattern: "###-######"), + Metadata(prefix: "+212", regionCode: "EH", pattern: "###-######"), + Metadata(prefix: "+213", regionCode: "DZ", pattern: "### ## ## ##"), + Metadata(prefix: "+216", regionCode: "TN", pattern: "## ### ###"), + Metadata(prefix: "+218", regionCode: "LY", pattern: "##-#######"), + Metadata(prefix: "+220", regionCode: "GM", pattern: "### ####"), + Metadata(prefix: "+221", regionCode: "SN", pattern: "## ### ## ##"), + Metadata(prefix: "+222", regionCode: "MR", pattern: "## ## ## ##"), + Metadata(prefix: "+223", regionCode: "ML", pattern: "## ## ## ##"), + Metadata(prefix: "+224", regionCode: "GN", pattern: "### ## ## ##"), + Metadata(prefix: "+225", regionCode: "CI", pattern: "## ## ## ##"), + Metadata(prefix: "+226", regionCode: "BF", pattern: "## ## ## ##"), + Metadata(prefix: "+227", regionCode: "NE", pattern: "## ## ## ##"), + Metadata(prefix: "+228", regionCode: "TG", pattern: "## ## ## ##"), + Metadata(prefix: "+229", regionCode: "BJ", pattern: "## ## ## ##"), + Metadata(prefix: "+230", regionCode: "MU", pattern: "#### ####"), + Metadata(prefix: "+231", regionCode: "LR", pattern: "### ### ###"), + Metadata(prefix: "+232", regionCode: "SL", pattern: "## ######"), + Metadata(prefix: "+233", regionCode: "GH", pattern: "## ### ####"), + Metadata(prefix: "+234", regionCode: "NG", pattern: "### ### ####"), + Metadata(prefix: "+235", regionCode: "TD", pattern: "## ## ## ##"), + Metadata(prefix: "+236", regionCode: "CF", pattern: "## ## ## ##"), + Metadata(prefix: "+237", regionCode: "CM", pattern: "## ## ## ##"), + Metadata(prefix: "+238", regionCode: "CV", pattern: "### ## ##"), + Metadata(prefix: "+239", regionCode: "ST", pattern: "### ####"), + Metadata(prefix: "+240", regionCode: "GQ", pattern: "### ### ###"), + Metadata(prefix: "+241", regionCode: "GA", pattern: "## ## ## ##"), + Metadata(prefix: "+242", regionCode: "CG", pattern: "## ### ####"), + Metadata(prefix: "+243", regionCode: "CD", pattern: "### ### ###"), + Metadata(prefix: "+244", regionCode: "AO", pattern: "### ### ###"), + Metadata(prefix: "+245", regionCode: "GW", pattern: "### ####"), + Metadata(prefix: "+246", regionCode: "IO", pattern: "### ####"), + Metadata(prefix: "+247", regionCode: "AC", pattern: ""), + Metadata(prefix: "+248", regionCode: "SC", pattern: "# ### ###"), + Metadata(prefix: "+250", regionCode: "RW", pattern: "### ### ###"), + Metadata(prefix: "+251", regionCode: "ET", pattern: "## ### ####"), + Metadata(prefix: "+252", regionCode: "SO", pattern: "## #######"), + Metadata(prefix: "+253", regionCode: "DJ", pattern: "## ## ## ##"), + Metadata(prefix: "+254", regionCode: "KE", pattern: "## #######"), + Metadata(prefix: "+255", regionCode: "TZ", pattern: "### ### ###"), + Metadata(prefix: "+256", regionCode: "UG", pattern: "### ######"), + Metadata(prefix: "+257", regionCode: "BI", pattern: "## ## ## ##"), + Metadata(prefix: "+258", regionCode: "MZ", pattern: "## ### ####"), + Metadata(prefix: "+260", regionCode: "ZM", pattern: "## #######"), + Metadata(prefix: "+261", regionCode: "MG", pattern: "## ## ### ##"), + Metadata(prefix: "+262", regionCode: "RE", pattern: ""), + Metadata(prefix: "+262", regionCode: "TF", pattern: ""), + Metadata(prefix: "+262", regionCode: "YT", pattern: "### ## ## ##"), + Metadata(prefix: "+263", regionCode: "ZW", pattern: "## ### ####"), + Metadata(prefix: "+264", regionCode: "NA", pattern: "## ### ####"), + Metadata(prefix: "+265", regionCode: "MW", pattern: "### ## ## ##"), + Metadata(prefix: "+266", regionCode: "LS", pattern: "#### ####"), + Metadata(prefix: "+267", regionCode: "BW", pattern: "## ### ###"), + Metadata(prefix: "+268", regionCode: "SZ", pattern: "#### ####"), + Metadata(prefix: "+269", regionCode: "KM", pattern: "### ## ##"), + Metadata(prefix: "+27", regionCode: "ZA", pattern: "## ### ####"), + Metadata(prefix: "+290", regionCode: "SH", pattern: ""), + Metadata(prefix: "+290", regionCode: "TA", pattern: ""), + Metadata(prefix: "+291", regionCode: "ER", pattern: "# ### ###"), + Metadata(prefix: "+297", regionCode: "AW", pattern: "### ####"), + Metadata(prefix: "+298", regionCode: "FO", pattern: "######"), + Metadata(prefix: "+299", regionCode: "GL", pattern: "## ## ##"), + Metadata(prefix: "+30", regionCode: "GR", pattern: "### ### ####"), + Metadata(prefix: "+31", regionCode: "NL", pattern: "# ########"), + Metadata(prefix: "+32", regionCode: "BE", pattern: "### ## ## ##"), + Metadata(prefix: "+33", regionCode: "FR", pattern: "# ## ## ## ##"), + Metadata(prefix: "+34", regionCode: "ES", pattern: "### ## ## ##"), + Metadata(prefix: "+350", regionCode: "GI", pattern: "### #####"), + Metadata(prefix: "+351", regionCode: "PT", pattern: "### ### ###"), + Metadata(prefix: "+352", regionCode: "LU", pattern: "## ## ## ###"), + Metadata(prefix: "+353", regionCode: "IE", pattern: "## ### ####"), + Metadata(prefix: "+354", regionCode: "IS", pattern: "### ####"), + Metadata(prefix: "+355", regionCode: "AL", pattern: "## ### ####"), + Metadata(prefix: "+356", regionCode: "MT", pattern: "#### ####"), + Metadata(prefix: "+357", regionCode: "CY", pattern: "## ######"), + Metadata(prefix: "+358", regionCode: "FI", pattern: "## ### ## ##"), + Metadata(prefix: "+358", regionCode: "AX", pattern: ""), + Metadata(prefix: "+359", regionCode: "BG", pattern: "### ### ##"), + Metadata(prefix: "+36", regionCode: "HU", pattern: "## ### ####"), + Metadata(prefix: "+370", regionCode: "LT", pattern: "### #####"), + Metadata(prefix: "+371", regionCode: "LV", pattern: "## ### ###"), + Metadata(prefix: "+372", regionCode: "EE", pattern: "#### ####"), + Metadata(prefix: "+373", regionCode: "MD", pattern: "### ## ###"), + Metadata(prefix: "+374", regionCode: "AM", pattern: "## ######"), + Metadata(prefix: "+375", regionCode: "BY", pattern: "## ###-##-##"), + Metadata(prefix: "+376", regionCode: "AD", pattern: "### ###"), + Metadata(prefix: "+377", regionCode: "MC", pattern: "# ## ## ## ##"), + Metadata(prefix: "+378", regionCode: "SM", pattern: "## ## ## ##"), + Metadata(prefix: "+379", regionCode: "VA", pattern: ""), + Metadata(prefix: "+380", regionCode: "UA", pattern: "## ### ####"), + Metadata(prefix: "+381", regionCode: "RS", pattern: "## #######"), + Metadata(prefix: "+382", regionCode: "ME", pattern: "## ### ###"), + Metadata(prefix: "+383", regionCode: "XK", pattern: "## ### ###"), + Metadata(prefix: "+385", regionCode: "HR", pattern: "## ### ####"), + Metadata(prefix: "+386", regionCode: "SI", pattern: "## ### ###"), + Metadata(prefix: "+387", regionCode: "BA", pattern: "## ###-###"), + Metadata(prefix: "+389", regionCode: "MK", pattern: "## ### ###"), + Metadata(prefix: "+39", regionCode: "IT", pattern: "## #### ####"), + Metadata(prefix: "+40", regionCode: "RO", pattern: "## ### ####"), + Metadata(prefix: "+41", regionCode: "CH", pattern: "## ### ## ##"), + Metadata(prefix: "+420", regionCode: "CZ", pattern: "### ### ###"), + Metadata(prefix: "+421", regionCode: "SK", pattern: "### ### ###"), + Metadata(prefix: "+423", regionCode: "LI", pattern: "### ### ###"), + Metadata(prefix: "+43", regionCode: "AT", pattern: "### ######"), + Metadata(prefix: "+44", regionCode: "GB", pattern: "#### ######"), + Metadata(prefix: "+44", regionCode: "GG", pattern: "#### ######"), + Metadata(prefix: "+44", regionCode: "JE", pattern: "#### ######"), + Metadata(prefix: "+44", regionCode: "IM", pattern: "#### ######"), + Metadata(prefix: "+45", regionCode: "DK", pattern: "## ## ## ##"), + Metadata(prefix: "+46", regionCode: "SE", pattern: "##-### ## ##"), + Metadata(prefix: "+47", regionCode: "NO", pattern: "### ## ###"), + Metadata(prefix: "+47", regionCode: "BV", pattern: ""), + Metadata(prefix: "+47", regionCode: "SJ", pattern: "## ## ## ##"), + Metadata(prefix: "+48", regionCode: "PL", pattern: "## ### ## ##"), + Metadata(prefix: "+49", regionCode: "DE", pattern: "### #######"), + Metadata(prefix: "+500", regionCode: "FK", pattern: ""), + Metadata(prefix: "+500", regionCode: "GS", pattern: ""), + Metadata(prefix: "+501", regionCode: "BZ", pattern: "###-####"), + Metadata(prefix: "+502", regionCode: "GT", pattern: "#### ####"), + Metadata(prefix: "+503", regionCode: "SV", pattern: "#### ####"), + Metadata(prefix: "+504", regionCode: "HN", pattern: "####-####"), + Metadata(prefix: "+505", regionCode: "NI", pattern: "#### ####"), + Metadata(prefix: "+506", regionCode: "CR", pattern: "#### ####"), + Metadata(prefix: "+507", regionCode: "PA", pattern: "####-####"), + Metadata(prefix: "+508", regionCode: "PM", pattern: "## ## ##"), + Metadata(prefix: "+509", regionCode: "HT", pattern: "## ## ####"), + Metadata(prefix: "+51", regionCode: "PE", pattern: "### ### ###"), + Metadata(prefix: "+52", regionCode: "MX", pattern: "### ### ### ####"), + Metadata(prefix: "+537", regionCode: "CY", pattern: ""), + Metadata(prefix: "+54", regionCode: "AR", pattern: "## ##-####-####"), + Metadata(prefix: "+55", regionCode: "BR", pattern: "## #####-####"), + Metadata(prefix: "+56", regionCode: "CL", pattern: "# #### ####"), + Metadata(prefix: "+57", regionCode: "CO", pattern: "### #######"), + Metadata(prefix: "+58", regionCode: "VE", pattern: "###-#######"), + Metadata(prefix: "+590", regionCode: "BL", pattern: "### ## ## ##"), + Metadata(prefix: "+590", regionCode: "MF", pattern: ""), + Metadata(prefix: "+590", regionCode: "GP", pattern: "### ## ## ##"), + Metadata(prefix: "+591", regionCode: "BO", pattern: "########"), + Metadata(prefix: "+592", regionCode: "GY", pattern: "### ####"), + Metadata(prefix: "+593", regionCode: "EC", pattern: "## ### ####"), + Metadata(prefix: "+594", regionCode: "GF", pattern: "### ## ## ##"), + Metadata(prefix: "+595", regionCode: "PY", pattern: "## #######"), + Metadata(prefix: "+596", regionCode: "MQ", pattern: "### ## ## ##"), + Metadata(prefix: "+597", regionCode: "SR", pattern: "###-####"), + Metadata(prefix: "+598", regionCode: "UY", pattern: "#### ####"), + Metadata(prefix: "+599", regionCode: "CW", pattern: "# ### ####"), + Metadata(prefix: "+599", regionCode: "BQ", pattern: "### ####"), + Metadata(prefix: "+60", regionCode: "MY", pattern: "##-### ####"), + Metadata(prefix: "+61", regionCode: "AU", pattern: "### ### ###"), + Metadata(prefix: "+62", regionCode: "ID", pattern: "###-###-###"), + Metadata(prefix: "+63", regionCode: "PH", pattern: "#### ######"), + Metadata(prefix: "+64", regionCode: "NZ", pattern: "## ### ####"), + Metadata(prefix: "+65", regionCode: "SG", pattern: "#### ####"), + Metadata(prefix: "+66", regionCode: "TH", pattern: "## ### ####"), + Metadata(prefix: "+670", regionCode: "TL", pattern: "#### ####"), + Metadata(prefix: "+672", regionCode: "AQ", pattern: "## ####"), + Metadata(prefix: "+673", regionCode: "BN", pattern: "### ####"), + Metadata(prefix: "+674", regionCode: "NR", pattern: "### ####"), + Metadata(prefix: "+675", regionCode: "PG", pattern: "### ####"), + Metadata(prefix: "+676", regionCode: "TO", pattern: "### ####"), + Metadata(prefix: "+677", regionCode: "SB", pattern: "### ####"), + Metadata(prefix: "+678", regionCode: "VU", pattern: "### ####"), + Metadata(prefix: "+679", regionCode: "FJ", pattern: "### ####"), + Metadata(prefix: "+681", regionCode: "WF", pattern: "## ## ##"), + Metadata(prefix: "+682", regionCode: "CK", pattern: "## ###"), + Metadata(prefix: "+683", regionCode: "NU", pattern: ""), + Metadata(prefix: "+685", regionCode: "WS", pattern: ""), + Metadata(prefix: "+686", regionCode: "KI", pattern: ""), + Metadata(prefix: "+687", regionCode: "NC", pattern: "########"), + Metadata(prefix: "+688", regionCode: "TV", pattern: ""), + Metadata(prefix: "+689", regionCode: "PF", pattern: "## ## ##"), + Metadata(prefix: "+690", regionCode: "TK", pattern: ""), + Metadata(prefix: "+7", regionCode: "RU", pattern: "### ###-##-##"), + Metadata(prefix: "+7", regionCode: "KZ", pattern: ""), + Metadata(prefix: "+81", regionCode: "JP", pattern: "##-####-####"), + Metadata(prefix: "+82", regionCode: "KR", pattern: "##-####-####"), + Metadata(prefix: "+84", regionCode: "VN", pattern: "## ### ## ##"), + Metadata(prefix: "+852", regionCode: "HK", pattern: "#### ####"), + Metadata(prefix: "+853", regionCode: "MO", pattern: "#### ####"), + Metadata(prefix: "+855", regionCode: "KH", pattern: "## ### ###"), + Metadata(prefix: "+856", regionCode: "LA", pattern: "## ## ### ###"), + Metadata(prefix: "+86", regionCode: "CN", pattern: "### #### ####"), + Metadata(prefix: "+872", regionCode: "PN", pattern: ""), + Metadata(prefix: "+880", regionCode: "BD", pattern: "####-######"), + Metadata(prefix: "+886", regionCode: "TW", pattern: "### ### ###"), + Metadata(prefix: "+90", regionCode: "TR", pattern: "### ### ####"), + Metadata(prefix: "+91", regionCode: "IN", pattern: "## ## ######"), + Metadata(prefix: "+92", regionCode: "PK", pattern: "### #######"), + Metadata(prefix: "+93", regionCode: "AF", pattern: "## ### ####"), + Metadata(prefix: "+94", regionCode: "LK", pattern: "## # ######"), + Metadata(prefix: "+95", regionCode: "MM", pattern: "# ### ####"), + Metadata(prefix: "+960", regionCode: "MV", pattern: "###-####"), + Metadata(prefix: "+961", regionCode: "LB", pattern: "## ### ###"), + Metadata(prefix: "+962", regionCode: "JO", pattern: "# #### ####"), + Metadata(prefix: "+964", regionCode: "IQ", pattern: "### ### ####"), + Metadata(prefix: "+965", regionCode: "KW", pattern: "### #####"), + Metadata(prefix: "+966", regionCode: "SA", pattern: "## ### ####"), + Metadata(prefix: "+967", regionCode: "YE", pattern: "### ### ###"), + Metadata(prefix: "+968", regionCode: "OM", pattern: "#### ####"), + Metadata(prefix: "+970", regionCode: "PS", pattern: "### ### ###"), + Metadata(prefix: "+971", regionCode: "AE", pattern: "## ### ####"), + Metadata(prefix: "+972", regionCode: "IL", pattern: "##-###-####"), + Metadata(prefix: "+973", regionCode: "BH", pattern: "#### ####"), + Metadata(prefix: "+974", regionCode: "QA", pattern: "#### ####"), + Metadata(prefix: "+975", regionCode: "BT", pattern: "## ## ## ##"), + Metadata(prefix: "+976", regionCode: "MN", pattern: "#### ####"), + Metadata(prefix: "+977", regionCode: "NP", pattern: "###-#######"), + Metadata(prefix: "+992", regionCode: "TJ", pattern: "### ## ####"), + Metadata(prefix: "+993", regionCode: "TM", pattern: "## ##-##-##"), + Metadata(prefix: "+994", regionCode: "AZ", pattern: "## ### ## ##"), + Metadata(prefix: "+995", regionCode: "GE", pattern: "### ## ## ##"), + Metadata(prefix: "+996", regionCode: "KG", pattern: "### ### ###"), + Metadata(prefix: "+998", regionCode: "UZ", pattern: "## ### ## ##"), + ] + } +} + +// MARK: - Equatable + +extension PhoneNumber: Equatable { + public static func == (lhs: PhoneNumber, rhs: PhoneNumber) -> Bool { + return lhs.number == rhs.number + } +} diff --git a/StripeUICore/StripeUICore/Source/Validators/STPBlikCodeValidator.swift b/StripeUICore/StripeUICore/Source/Validators/STPBlikCodeValidator.swift new file mode 100644 index 00000000..8d1b94b4 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Validators/STPBlikCodeValidator.swift @@ -0,0 +1,19 @@ +// +// STPBlikCodeValidator.swift +// StripeUICoreTests +// +// Created by Fionn Barrett on 07/07/2023. +// + +import Foundation + +@_spi(STP) public class STPBlikCodeValidator { + public class func stringIsValidBlikCode(_ string: String?) -> Bool { + if string == nil || (string?.count ?? 0) > 6 { + return false + } + let pattern = "^[0-9]{6}$" + let predicate = NSPredicate(format: "SELF MATCHES %@", pattern) + return predicate.evaluate(with: string) + } +} diff --git a/StripeUICore/StripeUICore/Source/Validators/STPEmailAddressValidator.swift b/StripeUICore/StripeUICore/Source/Validators/STPEmailAddressValidator.swift new file mode 100644 index 00000000..d07d6209 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Validators/STPEmailAddressValidator.swift @@ -0,0 +1,29 @@ +// +// STPEmailAddressValidator.swift +// StripeUICore +// +// Created by Jack Flintermann on 3/23/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) public class STPEmailAddressValidator: NSObject { + public class func stringIsValidPartialEmailAddress(_ string: String?) -> Bool { + guard let string = string else { + return true // an empty string isn't *invalid* + } + return (string.components(separatedBy: "@").count - 1) <= 1 + } + + public class func stringIsValidEmailAddress(_ string: String?) -> Bool { + if string == nil { + return false + } + // regex from http://www.regular-expressions.info/email.html + let pattern = + "[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?" + let predicate = NSPredicate(format: "SELF MATCHES %@", pattern) + return predicate.evaluate(with: string?.lowercased()) + } +} diff --git a/StripeUICore/StripeUICore/Source/Validators/STPVPANumberValidator.swift b/StripeUICore/StripeUICore/Source/Validators/STPVPANumberValidator.swift new file mode 100644 index 00000000..f414b025 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Validators/STPVPANumberValidator.swift @@ -0,0 +1,28 @@ +// +// STPVPANumberValidator.swift +// StripeUICore +// +// Created by Nick Porter on 9/8/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation + +@_spi(STP) public class STPVPANumberValidator: NSObject { + public class func stringIsValidPartialVPANumber(_ string: String?) -> Bool { + guard let string = string else { + return true // an empty string isn't *invalid* + } + return (string.components(separatedBy: "@").count - 1) <= 1 + } + + public class func stringIsValidVPANumber(_ string: String?) -> Bool { + if string == nil || (string?.count ?? 0) > 30 { + return false + } + // regex from https://stackoverflow.com/questions/55143204/how-to-validate-a-upi-id-using-regex + let pattern = "[a-zA-Z0-9.\\-_]{2,256}@[a-zA-Z]{2,64}" + let predicate = NSPredicate(format: "SELF MATCHES %@", pattern) + return predicate.evaluate(with: string?.lowercased()) + } +} diff --git a/StripeUICore/StripeUICore/Source/Views/DoneButtonToolbar.swift b/StripeUICore/StripeUICore/Source/Views/DoneButtonToolbar.swift new file mode 100644 index 00000000..9675d8f5 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Views/DoneButtonToolbar.swift @@ -0,0 +1,74 @@ +// +// DoneButtonToolbar.swift +// StripeUICore +// +// Created by Mel Ludowise on 10/11/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +@_spi(STP) public protocol DoneButtonToolbarDelegate: AnyObject { + func didTapDone(_ toolbar: DoneButtonToolbar) + func didTapCancel(_ toolbar: DoneButtonToolbar) +} + +@_spi(STP) public extension DoneButtonToolbarDelegate { + func didTapCancel(_ toolbar: DoneButtonToolbar) { + // no-op, cancel button is hidden by default + } +} + +/// For internal SDK use only +@objc(STP_Internal_DoneButtonToolbar) +@_spi(STP) public final class DoneButtonToolbar: UIToolbar { + + public weak var doneButtonToolbarDelegate: DoneButtonToolbarDelegate? + + // MARK: - Initializers + + public init(delegate: DoneButtonToolbarDelegate?, showCancelButton: Bool = false, theme: ElementsUITheme = .default) { + // Initializing w/ an arbitrary frame stops autolayout from complaining on the first layout pass + super.init(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) + + self.doneButtonToolbarDelegate = delegate + + let doneButton = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(didTapDone) + ) + doneButton.tintColor = theme.colors.primary + let cancelButton = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: self, + action: #selector(didTapCancel) + ) + cancelButton.tintColor = theme.colors.secondaryText + + var items = [.flexibleSpace(), doneButton] + if showCancelButton { + items = [cancelButton] + items + } + + setItems(items, animated: false) + sizeToFit() + setContentHuggingPriority(.defaultLow, for: .horizontal) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Internal Methods + + @objc + private func didTapDone() { + doneButtonToolbarDelegate?.didTapDone(self) + } + + @objc + private func didTapCancel() { + doneButtonToolbarDelegate?.didTapCancel(self) + } +} diff --git a/StripeUICore/StripeUICore/Source/Views/DynamicHeightContainerView.swift b/StripeUICore/StripeUICore/Source/Views/DynamicHeightContainerView.swift new file mode 100644 index 00000000..7ff25ea1 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Views/DynamicHeightContainerView.swift @@ -0,0 +1,77 @@ +// +// DynamicHeightContainerView.swift +// StripeUICore +// +// Created by Yuki Tokuhiro on 7/16/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import UIKit + +/// For internal SDK use only +@objc(STP_Internal_DynamicHeightContainerView) +@_spi(STP) public class DynamicHeightContainerView: UIView { + @frozen public enum PinnedDirection { + case top, bottom + } + let pinnedDirection: PinnedDirection + private var pinnedDirectionConstraint: NSLayoutConstraint? + + // MARK: - Initializers + + public required init(pinnedDirection optionalPinnedDirection: PinnedDirection? = nil) { + // TODO: After switching to Xcode 12.5 (which fixed @_spi default initailizers) + // we can make this into a default initializer instead of an optional. + let pinnedDirection: PinnedDirection = optionalPinnedDirection ?? .bottom + + self.pinnedDirection = pinnedDirection + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Internal methods + + /// Adds a subview and pins it to the top or bottom. It leaves the other end unpinned, thus not affecting the view's height. + public func addPinnedSubview(_ view: UIView) { + // Add new view + view.translatesAutoresizingMaskIntoConstraints = false + super.addSubview(view) + let pinnedDirectionAnchor: NSLayoutConstraint = { + switch pinnedDirection { + case .top: + return view.topAnchor.constraint(equalTo: topAnchor) + case .bottom: + return view.bottomAnchor.constraint(equalTo: bottomAnchor) + } + }() + + NSLayoutConstraint.activate([ + pinnedDirectionAnchor, + view.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + view.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + ]) + } + + /// Changes the view's height to be equal to the last added subview's height. + public func updateHeight() { + guard let mostRecentlyAddedView = subviews.last else { + return + } + // Deactivate old constraint + pinnedDirectionConstraint?.isActive = false + + // Activate the new constraint + pinnedDirectionConstraint = { + switch pinnedDirection { + case .top: + return bottomAnchor.constraint(equalTo: mostRecentlyAddedView.bottomAnchor) + case .bottom: + return topAnchor.constraint(equalTo: mostRecentlyAddedView.topAnchor) + } + }() + pinnedDirectionConstraint?.isActive = true + } +} diff --git a/StripeUICore/StripeUICore/Source/Views/DynamicImageView.swift b/StripeUICore/StripeUICore/Source/Views/DynamicImageView.swift new file mode 100644 index 00000000..9712dc9e --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Views/DynamicImageView.swift @@ -0,0 +1,50 @@ +// +// DynamicImageView.swift +// StripeUICore +// +// Created by Eduardo Urias on 11/10/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import UIKit + +/// A `UIImageView` that dynamically changes it's `image` according to the brightness of the +/// `pairedColor`. +@objc(STP_Internal_DynamicImageView) +@_spi(STP) public class DynamicImageView: UIImageView { + private let pairedColor: UIColor + private let dynamicImage: UIImage? + + private static func makeImage(for traitCollection: UITraitCollection, dynamicImage: UIImage?, pairedColor: UIColor) -> UIImage? { + let userInterfaceStyle: UIUserInterfaceStyle = pairedColor.resolvedColor(with: traitCollection).isDark ? .dark : .light + let traitCollection = UITraitCollection(userInterfaceStyle: userInterfaceStyle) + return dynamicImage?.withConfiguration(traitCollection.imageConfiguration) + } + + /// Initializes a `DynamicImageView`. + /// + /// - Parameters: + /// - image: A UIImage with light and dark variants. + /// - pairedColor: The color brightness to monitor. This should be a + /// `UIColor` initialized with `init(dynamicProvider:)`, otherwise the image will only be + /// choosen on initialization but won't change dynamically. + public init( + dynamicImage: UIImage? = nil, + pairedColor: UIColor + ) { + assert(dynamicImage != nil) + self.dynamicImage = dynamicImage + self.pairedColor = pairedColor + let image = Self.makeImage(for: UITraitCollection.current, dynamicImage: dynamicImage, pairedColor: pairedColor) + super.init(image: image) + } + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + image = Self.makeImage(for: traitCollection, dynamicImage: dynamicImage, pairedColor: pairedColor) + } +} diff --git a/StripeUICore/StripeUICore/Source/Views/LinkOpeningTextView.swift b/StripeUICore/StripeUICore/Source/Views/LinkOpeningTextView.swift new file mode 100644 index 00000000..e58d8894 --- /dev/null +++ b/StripeUICore/StripeUICore/Source/Views/LinkOpeningTextView.swift @@ -0,0 +1,57 @@ +// +// LinkOpeningTextView.swift +// StripeUICore +// +// Created by Mel Ludowise on 5/11/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +import UIKit + +/** + Subclass of UITextView that allows for links to be opened on tap when the text is un-selectable. + */ +@objc(STP_Internal_LinkOpeningTextView) +class LinkOpeningTextView: UITextView { + private var isTextSelectable = true + + /* + UITextView only allows links to be opened on tap if the text is + selectable. Override the `isSelectable` property such that + `super.isSelectable` is always true to enable the links to be tappable + but track internally whether the user should be able to select text in + the view using `isTextSelectable`. + */ + override var isSelectable: Bool { + get { + return isTextSelectable + } + set { + super.isSelectable = true + isTextSelectable = newValue + } + } + + /* + Override to only enable events if either: + - The text should be selectable. + - The user tapped on a link. + */ + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + // Only override the default behavior if the view should not be selectable + guard !isTextSelectable else { + return super.point(inside: point, with: event) + } + + guard let pos = closestPosition(to: point), + let range = tokenizer.rangeEnclosingPosition(pos, with: .character, inDirection: .layout(.left)) + else { + return false + } + + let startIndex = offset(from: beginningOfDocument, to: range.start) + + return attributedText.attribute(.link, at: startIndex, effectiveRange: nil) != nil + } +} diff --git a/StripeUICore/StripeUICore/StripeUICore.h b/StripeUICore/StripeUICore/StripeUICore.h new file mode 100644 index 00000000..5f1f38a2 --- /dev/null +++ b/StripeUICore/StripeUICore/StripeUICore.h @@ -0,0 +1,18 @@ +// +// StripeUICore.h +// StripeUICore +// +// Created by Mel Ludowise on 9/8/21. +// + +#import + +//! Project version number for StripeUICore. +FOUNDATION_EXPORT double StripeUICoreVersionNumber; + +//! Project version string for StripeUICore. +FOUNDATION_EXPORT const unsigned char StripeUICoreVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/StripeUICore/StripeUICoreTests/Info.plist b/StripeUICore/StripeUICoreTests/Info.plist new file mode 100644 index 00000000..64d65ca4 --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/StripeUICore/StripeUICoreTests/Snapshot/Controls/ButtonSnapshotTest.swift b/StripeUICore/StripeUICoreTests/Snapshot/Controls/ButtonSnapshotTest.swift new file mode 100644 index 00000000..ae4efaab --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Snapshot/Controls/ButtonSnapshotTest.swift @@ -0,0 +1,105 @@ +// +// ButtonSnapshotTest.swift +// StripeUICoreTests +// +// Created by Ramon Torres on 11/9/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase +import StripeCoreTestUtils +@_spi(STP) import StripeUICore + +final class ButtonSnapshotTest: FBSnapshotTestCase { + + override func setUp() { + super.setUp() +// recordMode = true + } + + func testPrimary() { + let button = Button(title: "Send") + verify(button) + + button.isHighlighted = true + verify(button, identifier: "Highlighted") + + button.isHighlighted = false + button.isEnabled = false + verify(button, identifier: "Disabled") + } + + func testSecondary() { + let button = Button(configuration: .secondary(), title: "Send") + verify(button) + + button.isHighlighted = true + verify(button, identifier: "Highlighted") + + button.isHighlighted = false + button.isEnabled = false + verify(button, identifier: "Disabled") + } + + func testPlain() { + let button = Button(configuration: .plain(), title: "Cancel") + verify(button) + + button.isHighlighted = true + verify(button, identifier: "Highlighted") + + button.isHighlighted = false + button.isEnabled = false + verify(button, identifier: "Disabled") + } + + func testIcon() { + let button = Button(title: "Add") + button.configuration.insets = .insets(top: 16, leading: 16, bottom: 16, trailing: 16) + + button.icon = .mockIcon() + verify(button, identifier: "Leading") + + button.iconPosition = .trailing + verify(button, identifier: "Trailing") + } + + func testColorCustomization() { + let primaryButton = Button(configuration: .primary(), title: "Delete") + primaryButton.tintColor = .red + verify(primaryButton, identifier: "Primary") + + let secondaryButton = Button(configuration: .secondary(), title: "Delete") + secondaryButton.tintColor = .red + verify(secondaryButton, identifier: "Secondary") + } + + func testDisabledColorCustomization() { + let button = Button(configuration: .primary(), title: "Delete") + button.configuration.disabledBackgroundColor = .black + button.isEnabled = false + verify(button) + } + + func testAttributedTitle() { + let button = Button(title: "Hello") + button.configuration.titleAttributes = [.underlineStyle: NSUnderlineStyle.single.rawValue] + verify(button) + } + + func testLoading() { + let button = Button(title: "Save") + button.isLoading = true + verify(button) + } + + func verify( + _ button: Button, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + button.autosizeHeight(width: 300) + STPSnapshotVerifyView(button, identifier: identifier, file: file, line: line) + } +} diff --git a/StripeUICore/StripeUICoreTests/Snapshot/Elements/AddressSectionElementSnapshotTest.swift b/StripeUICore/StripeUICoreTests/Snapshot/Elements/AddressSectionElementSnapshotTest.swift new file mode 100644 index 00000000..857bbbb2 --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Snapshot/Elements/AddressSectionElementSnapshotTest.swift @@ -0,0 +1,38 @@ +// +// AddressSectionElementSnapshotTest.swift +// StripeUICoreTests +// +// Created by Yuki Tokuhiro on 7/28/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase +import StripeCoreTestUtils +@_spi(STP) @testable import StripeUICore + +class AddressSectionElementSnapshotTest: FBSnapshotTestCase { + let dummyAddressSpecProvider: AddressSpecProvider = { + let specProvider = AddressSpecProvider() + specProvider.addressSpecs = [ + "US": AddressSpec(format: "ACSZP", require: "AZ", cityNameType: .post_town, stateNameType: .state, zip: "", zipNameType: .pin), + ] + return specProvider + }() + + override func setUp() { + super.setUp() +// recordMode = true + } + + func test_billing_address_same_as_shipping() throws { + let sut = AddressSectionElement( + addressSpecProvider: dummyAddressSpecProvider, + defaults: .init(address: .init(city: "San Francisco", country: "US", line1: "510 Townsend St.", line2: nil, postalCode: "94102", state: "California")), + additionalFields: .init( + billingSameAsShippingCheckbox: .enabled(isOptional: false) + ) + ) + sut.view.autosizeHeight(width: 300) + STPSnapshotVerifyView(sut.view) + } +} diff --git a/StripeUICore/StripeUICoreTests/Snapshot/Elements/CheckboxButtonSnapshotTests.swift b/StripeUICore/StripeUICoreTests/Snapshot/Elements/CheckboxButtonSnapshotTests.swift new file mode 100644 index 00000000..deb3e50b --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Snapshot/Elements/CheckboxButtonSnapshotTests.swift @@ -0,0 +1,107 @@ +// +// CheckboxButtonSnapshotTests.swift +// StripeUICoreTests +// +// Created by Ramon Torres on 12/14/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase +import StripeCoreTestUtils +import UIKit + +@testable @_spi(STP) import StripeUICore + +class CheckboxButtonSnapshotTests: FBSnapshotTestCase { + + let attributedLinkText: NSAttributedString = { + let attributedText = NSMutableAttributedString(string: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum auctor justo sit amet luctus egestas. Sed id urna dolor.") + attributedText.addAttributes([.link: URL(string: "https://stripe.com")!], range: NSRange(location: 0, length: 26)) + return attributedText + }() + + override func setUp() { + super.setUp() +// recordMode = true + } + + func testShortText() { + let checkbox = CheckboxButton(text: "Save this card for future [Merchant] payments") + verify(checkbox) + } + + func testLongText() { + let checkbox = CheckboxButton( + text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum auctor justo sit amet luctus egestas. Sed id urna dolor." + ) + verify(checkbox) + } + + func testMultiline() { + let checkbox = CheckboxButton( + text: "Save my info for secure 1-click checkout", + description: "Pay faster at [Merchant] and thousands of merchants." + ) + + verify(checkbox) + } + + func testCustomFont() throws { + var theme = ElementsUITheme.default + theme.fonts.footnote = try XCTUnwrap(UIFont(name: "AmericanTypewriter", size: 13.0)) + theme.fonts.footnoteEmphasis = try XCTUnwrap(UIFont(name: "AmericanTypewriter-Semibold", size: 13.0)) + + let checkbox = CheckboxButton( + text: "Save my info for secure 1-click checkout", + description: "Pay faster at [Merchant] and thousands of merchants.", + theme: theme + ) + + verify(checkbox) + } + + func testLocalization() { + let greekCheckbox = CheckboxButton(text: "Αποθηκεύστε αυτή την κάρτα για μελλοντικές [Merchant] πληρωμές") + verify(greekCheckbox, identifier: "Greek") + + let chineseCheckbox = CheckboxButton( + text: "保存我的信息以便一键结账", + description: "在[Merchant]及千万商家使用快捷支付") + verify(chineseCheckbox, identifier: "Chinese") + + let hindiCheckbox = CheckboxButton( + text: "सुरक्षित 1-क्लिक चेकआउट के लिए मेरी जानकारी सहेजें", + description: "[Merchant] और हज़ारों व्यापारियों पर तेज़ी से भुगतान करें।") + verify(hindiCheckbox, identifier: "Hindi") + } + + func testAttributedText() { + let checkbox = CheckboxButton( + attributedText: attributedLinkText + ) + verify(checkbox) + } + + func testAttributedTextCustomFont() throws { + var theme = ElementsUITheme.default + theme.fonts.footnote = try XCTUnwrap(UIFont(name: "AmericanTypewriter", size: 13.0)) + theme.fonts.footnoteEmphasis = try XCTUnwrap(UIFont(name: "AmericanTypewriter-Semibold", size: 13.0)) + let checkbox = CheckboxButton( + attributedText: attributedLinkText, + theme: theme + ) + verify(checkbox) + } + + func verify( + _ view: UIView, + identifier: String? = nil, + file: StaticString = #filePath, + line: UInt = #line + ) { + view.autosizeHeight(width: 340) + view.backgroundColor = .white + STPSnapshotVerifyView(view, identifier: identifier, file: file, line: line) + } + +} diff --git a/StripeUICore/StripeUICoreTests/Snapshot/Elements/DateFieldElementSnapshotTest.swift b/StripeUICore/StripeUICoreTests/Snapshot/Elements/DateFieldElementSnapshotTest.swift new file mode 100644 index 00000000..e174f1df --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Snapshot/Elements/DateFieldElementSnapshotTest.swift @@ -0,0 +1,86 @@ +// +// DateFieldElementSnapshotTest.swift +// StripeUICoreTests +// +// Created by Mel Ludowise on 10/1/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase +import StripeCoreTestUtils +@_spi(STP) @testable import StripeUICore + +final class DateFieldElementSnapshotTest: FBSnapshotTestCase { + + // Use consistent locale and timezone for consistent test results + let locale_enUS = Locale(identifier: "en_US") + let timeZone_GMT = TimeZone(secondsFromGMT: 0)! + + // Mock dates + let oct1_2021 = Date(timeIntervalSince1970: 1633046400) + let oct3_2021 = Date(timeIntervalSince1970: 1633219200) + + override func setUp() { + super.setUp() +// recordMode = true + } + + func testNoDefaultUnfocused() { + let dateFieldElement = makeDateFieldElement() + verify(dateFieldElement) + } + + func testNoDefaultFocused() { + // Setting a max date to the past makes the UIDatePicker default to that + // date instead of the current date, giving us consistent UI to test. + let dateFieldElement = makeDateFieldElement( + maximumDate: oct1_2021 + ) + dateFieldElement.didBeginEditing(dateFieldElement.pickerFieldView) + verify(dateFieldElement) + } + + func testDefault() { + let dateFieldElement = makeDateFieldElement( + defaultDate: oct1_2021 + ) + verify(dateFieldElement) + } + + func testChangeInput() { + let dateFieldElement = makeDateFieldElement( + defaultDate: oct1_2021 + ) + dateFieldElement.datePickerView.date = oct3_2021 + + // Emulate a user changing the picker + dateFieldElement.didSelectDate() + + verify(dateFieldElement) + } +} + +// MARK: - Helpers + +private extension DateFieldElementSnapshotTest { + func makeDateFieldElement( + defaultDate: Date? = nil, + maximumDate: Date? = nil + ) -> DateFieldElement { + return DateFieldElement( + label: "Label", + defaultDate: defaultDate, + maximumDate: maximumDate, + locale: locale_enUS, + timeZone: timeZone_GMT + ) + } + + func verify(_ dateFieldElement: DateFieldElement, + file: StaticString = #filePath, + line: UInt = #line) { + let view = dateFieldElement.view + view.autosizeHeight(width: 200) + STPSnapshotVerifyView(view, file: file, line: line) + } +} diff --git a/StripeUICore/StripeUICoreTests/Snapshot/Elements/DropdownFieldElementSnapshotTest.swift b/StripeUICore/StripeUICoreTests/Snapshot/Elements/DropdownFieldElementSnapshotTest.swift new file mode 100644 index 00000000..f04b6803 --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Snapshot/Elements/DropdownFieldElementSnapshotTest.swift @@ -0,0 +1,63 @@ +// +// DropdownFieldElementSnapshotTest.swift +// StripeUICoreTests +// +// Created by Mel Ludowise on 10/8/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase +import StripeCoreTestUtils +@_spi(STP) @testable import StripeUICore + +final class DropdownFieldElementSnapshotTest: FBSnapshotTestCase { + let items = ["A", "B", "C", "D"].map { DropdownFieldElement.DropdownItem(pickerDisplayName: $0, labelDisplayName: $0, accessibilityValue: $0, rawData: $0) } + + override func setUp() { + super.setUp() +// recordMode = true + } + + func testDefault0() { + let dropdownFieldElement = makeDropdownFieldElement( + defaultIndex: 0 + ) + verify(dropdownFieldElement) + } + + func testDefault3() { + let dropdownFieldElement = makeDropdownFieldElement( + defaultIndex: 3 + ) + verify(dropdownFieldElement) + } + + func testChangeInput() { + let dropdownFieldElement = makeDropdownFieldElement( + defaultIndex: 0 + ) + // Emulate a user changing the picker + dropdownFieldElement.pickerView(dropdownFieldElement.pickerView, didSelectRow: 3, inComponent: 0) + verify(dropdownFieldElement) + } +} + +private extension DropdownFieldElementSnapshotTest { + func makeDropdownFieldElement( + defaultIndex: Int + ) -> DropdownFieldElement { + return DropdownFieldElement( + items: items, + defaultIndex: defaultIndex, + label: "Label" + ) + } + + func verify(_ dropdownFieldElement: DropdownFieldElement, + file: StaticString = #filePath, + line: UInt = #line) { + let view = dropdownFieldElement.view + view.autosizeHeight(width: 200) + STPSnapshotVerifyView(view, file: file, line: line) + } +} diff --git a/StripeUICore/StripeUICoreTests/Snapshot/Elements/PhoneNumberElementSnapshotTests.swift b/StripeUICore/StripeUICoreTests/Snapshot/Elements/PhoneNumberElementSnapshotTests.swift new file mode 100644 index 00000000..01413d1a --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Snapshot/Elements/PhoneNumberElementSnapshotTests.swift @@ -0,0 +1,69 @@ +// +// PhoneNumberElementSnapshotTests.swift +// StripeUICoreTests +// +// Created by Yuki Tokuhiro on 6/23/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import iOSSnapshotTestCase +import StripeCoreTestUtils +@_spi(STP) @testable import StripeUICore + +class PhoneNumberElementSnapshotTests: FBSnapshotTestCase { + + override func setUp() { + super.setUp() +// recordMode = true + } + + func testEmptyUS() { + let sut = PhoneNumberElement( + allowedCountryCodes: ["US"], + defaultCountryCode: "US", + locale: Locale(identifier: "en_US") + ) + verify(sut) + } + + func testEmptyGB() { + let sut = PhoneNumberElement( + allowedCountryCodes: ["GB"], + defaultCountryCode: "GB", + locale: Locale(identifier: "en_GB") + ) + verify(sut) + } + + func testFilledUS() { + let sut = PhoneNumberElement( + allowedCountryCodes: ["US"], + defaultCountryCode: "US", + defaultPhoneNumber: "3105551234", + locale: Locale(identifier: "en_US") + ) + verify(sut) + } + + func testFilledGB() { + let sut = PhoneNumberElement( + allowedCountryCodes: ["GB"], + defaultCountryCode: "GB", + defaultPhoneNumber: "442071838750", + locale: Locale(identifier: "en_GB") + ) + verify(sut) + } + + func verify( + _ sut: PhoneNumberElement, + file: StaticString = #filePath, + line: UInt = #line + ) { + let section = SectionElement(elements: [sut]) + let view = section.view + view.autosizeHeight(width: 320) + STPSnapshotVerifyView(view, file: file, line: line) + } + +} diff --git a/StripeUICore/StripeUICoreTests/Unit/Categories/Locale+StripeUICoreTests.swift b/StripeUICore/StripeUICoreTests/Unit/Categories/Locale+StripeUICoreTests.swift new file mode 100644 index 00000000..0ff8836a --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Unit/Categories/Locale+StripeUICoreTests.swift @@ -0,0 +1,73 @@ +// +// Locale+StripeUICoreTests.swift +// StripeUICoreTests +// +// Created by Mel Ludowise on 9/28/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) @testable import StripeUICore +import XCTest + +final class LocaleStripeUICoreTests: XCTestCase { + // English, United States + let localeEN_US = Locale(identifier: "en_US") + + // Spanish, El Salvador + let localeES_SV = Locale(identifier: "es_SV") + + let regions = [ + "IT", // Italy (Italia) + "CA", // Canada (Canadá) + "US", // United States (Estados Unidos) + "DZ", // Algeria (Argelia) + "SV", // El Salvador (El Salvador) + ] + + // Sort countries by English localization + func testSortRegionsEnglish() { + let sorted = localeEN_US.sortedByTheirLocalizedNames( + regions, + thisRegionFirst: false + ) + XCTAssertEqual(sorted, ["DZ", "CA", "SV", "IT", "US"]) + } + + // Sort countries by Spanish localization + func testSortRegionsSpanish() { + let sorted = localeES_SV.sortedByTheirLocalizedNames( + regions, + thisRegionFirst: false + ) + XCTAssertEqual(sorted, ["DZ", "CA", "SV", "US", "IT"]) + } + + // Sort countries by English localization, with current country (US) first + func testSortCountriesUSFirst() { + let sorted = localeEN_US.sortedByTheirLocalizedNames( + regions, + thisRegionFirst: true + ) + XCTAssertEqual(sorted, ["US", "DZ", "CA", "SV", "IT"]) + } + + // Sort countries by English localization, with current country (SV) first + func testSortCountriesSVFirst() { + let sorted = localeES_SV.sortedByTheirLocalizedNames( + regions, + thisRegionFirst: true + ) + XCTAssertEqual(sorted, ["SV", "DZ", "CA", "US", "IT"]) + } + + // Ask for current country to be first when the list of countries doesn't contain it + func testSortCountriesMissingCurrent() { + var missingUS = regions + missingUS.removeAll(where: { $0 == "US" }) + let sorted = localeEN_US.sortedByTheirLocalizedNames( + missingUS, + thisRegionFirst: true + ) + XCTAssertEqual(sorted, ["DZ", "CA", "SV", "IT"]) + } +} diff --git a/StripeUICore/StripeUICoreTests/Unit/Categories/NSAttributedString+StripeUICoreTests.swift b/StripeUICore/StripeUICoreTests/Unit/Categories/NSAttributedString+StripeUICoreTests.swift new file mode 100644 index 00000000..1679c93b --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Unit/Categories/NSAttributedString+StripeUICoreTests.swift @@ -0,0 +1,25 @@ +// +// NSAttributedString+StripeUICoreTests.swift +// StripeUICoreTests +// +// Created by Nick Porter on 9/1/23. +// + +@_spi(STP) @testable import StripeUICore +import XCTest + +final class NSAttributedStringStripeUICoreTests: XCTestCase { + + func hasTextAttachment_shouldReturnTrue() { + let brandImageAttachment = NSTextAttachment() + brandImageAttachment.image = UIImage() + + let attrString = NSAttributedString(attachment: brandImageAttachment) + XCTAssertTrue(attrString.hasTextAttachment) + } + + func hasTextAttachment_shouldReturnFalse() { + let attrString = NSAttributedString(string: "no text attachments") + XCTAssertFalse(attrString.hasTextAttachment) + } +} diff --git a/StripeUICore/StripeUICoreTests/Unit/Categories/UIColor+StripeUICoreTests.swift b/StripeUICore/StripeUICoreTests/Unit/Categories/UIColor+StripeUICoreTests.swift new file mode 100644 index 00000000..4482ae7f --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Unit/Categories/UIColor+StripeUICoreTests.swift @@ -0,0 +1,222 @@ +// +// UIColor+StripeUICoreTests.swift +// StripeUICoreTests +// +// Created by Ramon Torres on 11/9/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) @testable import StripeUICore +import XCTest + +final class UIColorStripeUICoreTests: XCTestCase { + + func testLighten() { + XCTAssertEqual( + UIColor.black.lighten(by: 0.5).cgColor, + UIColor(hue: 0, saturation: 0, brightness: 0.5, alpha: 1).cgColor + ) + + XCTAssertEqual( + UIColor.gray.lighten(by: 1).cgColor, + UIColor(hue: 0, saturation: 0, brightness: 1, alpha: 1).cgColor + ) + + XCTAssertEqual( + UIColor(hue: 0, saturation: 0.5, brightness: 0.5, alpha: 1).lighten(by: 0.3).cgColor, + UIColor(hue: 0, saturation: 0.5, brightness: 0.8, alpha: 1).cgColor + ) + } + + func testDarken() { + XCTAssertEqual( + UIColor.white.darken(by: 0.5).cgColor, + UIColor(hue: 0, saturation: 0, brightness: 0.5, alpha: 1).cgColor + ) + + XCTAssertEqual( + UIColor.gray.darken(by: 1).cgColor, + UIColor(hue: 0, saturation: 0, brightness: 0, alpha: 1).cgColor + ) + + XCTAssertEqual( + UIColor(hue: 0, saturation: 0.5, brightness: 0.5, alpha: 1).darken(by: 0.2).cgColor, + UIColor(hue: 0, saturation: 0.5, brightness: 0.3, alpha: 1).cgColor + ) + } + + func testLuminance() { + // Well-known color-luminance values + let testCases: [(UIColor, CGFloat)] = [ + // Grays + (UIColor(white: 0, alpha: 1), 0.0), + (UIColor(white: 0.25, alpha: 1), 0.05), + (UIColor(white: 0.5, alpha: 1), 0.21), + (UIColor(white: 0.75, alpha: 1), 0.52), + (UIColor(white: 1, alpha: 1), 1.0), + // Colors (Extract Rec. 709 coefficients) + (UIColor(red: 1, green: 0, blue: 0, alpha: 1), 0.2126), + (UIColor(red: 0, green: 1, blue: 0, alpha: 1), 0.7152), + (UIColor(red: 0, green: 0, blue: 1, alpha: 1), 0.0722), + ] + + for (color, expectedLuminance) in testCases { + XCTAssertEqual(color.luminance, expectedLuminance, accuracy: 0.01) + } + } + + func testContrastRatio() { + // Highest contrast ratio + XCTAssertEqual(UIColor.black.contrastRatio(to: .white), 21) + XCTAssertEqual(UIColor.white.contrastRatio(to: .black), 21) + + // Lowest contrast ratio (identical colors) + XCTAssertEqual(UIColor.red.contrastRatio(to: .red), 1) + + // Black to 50% gray + XCTAssertEqual(UIColor.black.contrastRatio(to: .gray), 5.28, accuracy: 0.01) + + // Red to black + XCTAssertEqual(UIColor.red.contrastRatio(to: .black), 5.25, accuracy: 0.01) + } + + func testGrayscaleColorsIsBright() { + let space = CGColorSpaceCreateDeviceGray() + var components: [CGFloat] = [0.0, 1.0] + + // Using 0.3 as the cutoff from bright/non-bright because that's what + // the current implementation does. + + var white: CGFloat = 0.0 + while white < 0.3 { + components[0] = white + let cgcolor = CGColor(colorSpace: space, components: &components)! + let color = UIColor(cgColor: cgcolor) + + XCTAssertFalse(color.isBright) + white += CGFloat(0.05) + } + + white = CGFloat(0.3001) + while white < 2 { + components[0] = white + let cgcolor = CGColor(colorSpace: space, components: &components)! + let color = UIColor(cgColor: cgcolor) + + XCTAssertTrue(color.isBright) + white += CGFloat(0.1) + } + } + + func testBuiltinColorsIsBright() { + // This is primarily to document what colors are considered bright/dark + let brightColors = [ + UIColor.brown, + UIColor.cyan, + UIColor.darkGray, + UIColor.gray, + UIColor.green, + UIColor.lightGray, + UIColor.magenta, + UIColor.orange, + UIColor.white, + UIColor.yellow, + ] + let darkColors = [ + UIColor.black, + UIColor.blue, + UIColor.clear, + UIColor.purple, + UIColor.red, + ] + + for color in brightColors { + XCTAssertTrue(color.isBright) + } + + for color in darkColors { + XCTAssertFalse(color.isBright) + } + } + + func testAllColorSpaces() { + // block to create & check brightness of color in a given color space + let testColorSpace: ((CFString, Bool) -> Void)? = { colorSpaceName, expectedToBeBright in + // this a bright color in almost all color spaces + let components: [CGFloat] = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0] + + var color: UIColor? + let colorSpace = CGColorSpace(name: colorSpaceName) + + if let colorSpace = colorSpace { + let cgcolor = CGColor(colorSpace: colorSpace, components: components) + + if let cgcolor = cgcolor { + color = UIColor(cgColor: cgcolor) + } + } + + if let color = color { + if expectedToBeBright { + XCTAssertTrue(color.isBright) + } else { + XCTAssertFalse(color.isBright) + } + } else { + XCTFail("Could not create color for \(colorSpaceName)") + } + } + + let colorSpaceNames = [ + CGColorSpace.sRGB, CGColorSpace.dcip3, CGColorSpace.rommrgb, CGColorSpace.itur_709, + CGColorSpace.displayP3, CGColorSpace.itur_2020, CGColorSpace.genericXYZ, + CGColorSpace.linearSRGB, CGColorSpace.genericCMYK, CGColorSpace.acescgLinear, + CGColorSpace.adobeRGB1998, CGColorSpace.extendedGray, CGColorSpace.extendedSRGB, + CGColorSpace.genericRGBLinear, CGColorSpace.extendedLinearSRGB, + CGColorSpace.genericGrayGamma2_2, + ] + + let colorSpaceCount = + MemoryLayout.size(ofValue: colorSpaceNames) + / MemoryLayout.size(ofValue: colorSpaceNames[0]) + for i in 0.. 14 digits + verifyInvalidIncomplete(config.validate(text: "1234567890123456", isOptional: false)) + } + + func testValidationUnspecifiedType() { + let config = IDNumberTextFieldConfiguration(type: nil, label: "") + // Anything but empty string is valid + verifyInvalidEmpty(config.validate(text: "", isOptional: false)) + verifyValid(config.validate(text: "a", isOptional: false)) + verifyValid(config.validate(text: "1", isOptional: false)) + verifyValid(config.validate(text: "/;'", isOptional: false)) + verifyValid(config.validate(text: "asdfghjklqwertyuiopzxcvbnm1234567890", isOptional: false)) + // Empty string is okay if optional + verifyValid(config.validate(text: "", isOptional: true)) + } + + func testDisplayTextBR_CPF_CNPJ() { + let config = IDNumberTextFieldConfiguration(type: .BR_CPF_CNPJ, label: "") + + XCTAssertEqual(config.makeDisplayText(for: "").string, "") + + // Format as CPF if <= 11 characters + XCTAssertEqual(config.makeDisplayText(for: "123").string, "123") + XCTAssertEqual(config.makeDisplayText(for: "123456789").string, "123.456.789") + XCTAssertEqual(config.makeDisplayText(for: "12345678901").string, "123.456.789-01") + + // Format as CNPJ if > 11 characters + XCTAssertEqual(config.makeDisplayText(for: "123456789012").string, "12.345.678/9012") + XCTAssertEqual(config.makeDisplayText(for: "12345678901234").string, "12.345.678/9012-34") + XCTAssertEqual(config.makeDisplayText(for: "12345678901234567").string, "12.345.678/9012-34") + } +} + +private extension IDNumberTextFieldConfigurationTest { + func verifyValid(_ validationState: TextFieldElement.ValidationState, + file: StaticString = #filePath, + line: UInt = #line) { + XCTAssertEqual(validationState, .valid, file: file, line: line) + } + + func getTextFieldError(_ validationState: TextFieldElement.ValidationState, + file: StaticString = #filePath, + line: UInt = #line) -> TextFieldElement.Error? { + guard case let .invalid(error) = validationState else { + XCTFail("Expected `.invalid` but was `.valid`", file: file, line: line) + return nil + } + guard let textFieldError = error as? TextFieldElement.Error else { + XCTFail("Expected `TextFieldElement.Error` but was `\(type(of: error))`", file: file, line: line) + return nil + } + return textFieldError + } + + func verifyInvalidIncomplete(_ validationState: TextFieldElement.ValidationState, + file: StaticString = #filePath, + line: UInt = #line) { + guard let textFieldError = getTextFieldError(validationState, file: file, line: line) else { + return + } + guard case .incomplete = textFieldError else { + return XCTFail("Expected `.incomplete` but was `\(textFieldError)`", file: file, line: line) + } + } + + func verifyInvalidEmpty(_ validationState: TextFieldElement.ValidationState, + file: StaticString = #filePath, + line: UInt = #line) { + guard let textFieldError = getTextFieldError(validationState, file: file, line: line) else { + return + } + guard case .empty = textFieldError else { + return XCTFail("Expected `.empty` but was `\(textFieldError)`", file: file, line: line) + } + } +} diff --git a/StripeUICore/StripeUICoreTests/Unit/Elements/PhoneNumberElementTests.swift b/StripeUICore/StripeUICoreTests/Unit/Elements/PhoneNumberElementTests.swift new file mode 100644 index 00000000..c9301e1c --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Unit/Elements/PhoneNumberElementTests.swift @@ -0,0 +1,177 @@ +// +// PhoneNumberElementTests.swift +// StripeUICoreTests +// +// Created by Yuki Tokuhiro on 6/23/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@testable @_spi(STP) import StripeUICore +import XCTest + +class PhoneNumberElementTests: XCTestCase { + + func test_init_with_defaults() { + let sut = PhoneNumberElement( + allowedCountryCodes: ["PR"], + defaultCountryCode: "PR", + defaultPhoneNumber: "3105551234", + locale: Locale(identifier: "en_US") + ) + // + XCTAssertEqual(sut.textFieldElement.text, "3105551234") + XCTAssertEqual(sut.phoneNumber?.countryCode, "PR") + XCTAssertEqual(sut.phoneNumber?.number, "3105551234") + } + + func test_init_with_default_e164_phone_number() { + // Initializing a PhoneNumberElement.... + let sut = PhoneNumberElement( + allowedCountryCodes: ["US", "PR"], + defaultCountryCode: "PR", // ...with a default country code... + defaultPhoneNumber: "+13105551234", // ...and a phone number that also contains a country code... + locale: Locale(identifier: "en_US") + ) + // ...should favor the phone number's country code... + XCTAssertEqual(sut.countryDropdownElement.selectedItem.rawData, "US") + // ...and remove the country prefix from the number + XCTAssertEqual(sut.textFieldElement.text, "3105551234") + XCTAssertEqual(sut.phoneNumber?.countryCode, "US") + XCTAssertEqual(sut.phoneNumber?.number, "3105551234") + } + + func test_no_default_country_and_locale_in_allowed_countries() { + // A PhoneNumberElement initialized without a default country... + // ...where the user's locale is in `allowedCountryCodes`... + let sut = PhoneNumberElement( + allowedCountryCodes: ["PR"], + defaultPhoneNumber: "3105551234", + locale: Locale(identifier: "es_PR") + ) + // ...should default to the locale + XCTAssertEqual(sut.textFieldElement.text, "3105551234") + XCTAssertEqual(sut.phoneNumber?.countryCode, "PR") + XCTAssertEqual(sut.phoneNumber?.number, "3105551234") + } + + func test_no_default_country_and_locale_not_in_allowed_countries() { + // A PhoneNumberElement initialized without a default country... + // ...where the user's locale is **not** in `allowedCountryCodes`... + let sut = PhoneNumberElement( + allowedCountryCodes: ["US"], + defaultPhoneNumber: "3105551234", + locale: Locale(identifier: "es_PR") + ) + // ...should default to the first country in the list + XCTAssertEqual(sut.textFieldElement.text, "3105551234") + XCTAssertEqual(sut.phoneNumber?.countryCode, "US") + XCTAssertEqual(sut.phoneNumber?.number, "3105551234") + } + + func test_autofill_removesMatchingCountryCode() { + let sut = PhoneNumberElement( + allowedCountryCodes: ["US"], + locale: Locale(identifier: "en_US") + ) + simulateAutofill(sut, autofilledPhoneNumber: "+1 (310) 555-1234") + XCTAssertEqual(sut.textFieldElement.text, "3105551234") + XCTAssertEqual(sut.phoneNumber?.number, "3105551234") + } + + func test_autofill_preservesNonMatchingCountryCode() { + let sut = PhoneNumberElement( + allowedCountryCodes: ["US"], + locale: Locale(identifier: "en_US") + ) + simulateAutofill(sut, autofilledPhoneNumber: "+44 12 3456 7890") + XCTAssertEqual(sut.textFieldElement.text, "441234567890") + XCTAssertEqual(sut.phoneNumber?.number, "441234567890") + } + + func test_hasBeenModified_noDefaults_noModification() { + let sut = PhoneNumberElement( + allowedCountryCodes: ["US"], + locale: Locale(identifier: "en_US") + ) + XCTAssertFalse(sut.hasBeenModified) + } + + func test_hasBeenModified_defaultNumber() { + let sut = PhoneNumberElement( + allowedCountryCodes: ["US"], + defaultPhoneNumber: "3105551234", + locale: Locale(identifier: "en_US") + ) + XCTAssertFalse(sut.hasBeenModified) + } + + func test_hasBeenModified_isModified() { + let sut = PhoneNumberElement( + allowedCountryCodes: ["US"], + locale: Locale(identifier: "en_US") + ) + simulateAutofill(sut, autofilledPhoneNumber: "3") + XCTAssertTrue(sut.hasBeenModified) + } + + func test_hasBeenModified_defaultNumber_isModified() { + let sut = PhoneNumberElement( + allowedCountryCodes: ["US"], + defaultPhoneNumber: "3105551234", + locale: Locale(identifier: "en_US") + ) + simulateAutofill(sut, autofilledPhoneNumber: "3") + XCTAssertTrue(sut.hasBeenModified) + } + + func test_hasBeenModified_isNotModified() { + let sut = PhoneNumberElement( + allowedCountryCodes: ["US"], + locale: Locale(identifier: "en_US") + ) + simulateAutofill(sut, autofilledPhoneNumber: "3") + simulateAutofill(sut, autofilledPhoneNumber: "") + XCTAssertFalse(sut.hasBeenModified) + } + + func test_hasBeenModified_defaultNumber_isNotModified() { + let sut = PhoneNumberElement( + allowedCountryCodes: ["US"], + defaultPhoneNumber: "3105551234", + locale: Locale(identifier: "en_US") + ) + simulateAutofill(sut, autofilledPhoneNumber: "3") + simulateAutofill(sut, autofilledPhoneNumber: "3105551234") + XCTAssertFalse(sut.hasBeenModified) + } + + func test_selectCountry_dontUpdateDefault() { + let sut = PhoneNumberElement( + allowedCountryCodes: ["US", "CA"], + locale: Locale(identifier: "en_US") + ) + + sut.selectCountry(index: 0, shouldUpdateDefaultNumber: false) // select CA + XCTAssertEqual(sut.countryDropdownElement.selectedIndex, 0) + XCTAssert(sut.hasBeenModified) + } + + func test_selectCountry_updateDefault() { + let sut = PhoneNumberElement( + allowedCountryCodes: ["US", "CA"], + locale: Locale(identifier: "en_US") + ) + + sut.selectCountry(index: 0, shouldUpdateDefaultNumber: true) // select CA + XCTAssertEqual(sut.countryDropdownElement.selectedIndex, 0) + XCTAssertFalse(sut.hasBeenModified) + } + + private func simulateAutofill(_ sut: PhoneNumberElement, autofilledPhoneNumber: String) { + let textField = sut.textFieldElement.textFieldView.textField + _ = sut.textFieldElement.textFieldView.textField(textField, shouldChangeCharactersIn: NSRange(location: 0, length: 0), replacementString: autofilledPhoneNumber) + textField.text = autofilledPhoneNumber + sut.textFieldElement.textFieldView.textDidChange() + + } +} diff --git a/StripeUICore/StripeUICoreTests/Unit/Elements/SectionElementTest.swift b/StripeUICore/StripeUICoreTests/Unit/Elements/SectionElementTest.swift new file mode 100644 index 00000000..3ea4e8f7 --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Unit/Elements/SectionElementTest.swift @@ -0,0 +1,61 @@ +// +// SectionElementTest.swift +// StripeUICoreTests +// +// Created by Yuki Tokuhiro on 6/14/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@testable @_spi(STP) import StripeUICore +import XCTest + +class SectionElementTest: XCTestCase { + struct DummyTextFieldElementConfiguration: TextFieldElementConfiguration { + let validationState: ValidationState + let label = "foo" + func validate(text: String, isOptional: Bool) -> ValidationState { + return validationState + } + } + + enum Error: TextFieldValidationError { + case undisplayableError + case displayableError + + var localizedDescription: String { + switch self { + case .undisplayableError: + return "undisplayable error" + case .displayableError: + return "displayable error" + } + } + + func shouldDisplay(isUserEditing: Bool) -> Bool { + switch self { + case .undisplayableError: + return false + case .displayableError: + return true + } + } + } + + func testValidationStateAndError() { + // Given an invalid element whose error shouldn't be displayed... + let element1 = TextFieldElement( + configuration: DummyTextFieldElementConfiguration(validationState: .invalid(Error.undisplayableError)) + ) + + // ...and an invalid element whose error *should* be displayed... + let element2 = TextFieldElement( + configuration: DummyTextFieldElementConfiguration(validationState: .invalid(Error.displayableError)) + ) + + // ...a section with these two elements.... + let section = SectionElement(title: "Foo", elements: [element1, element2]) + + // ...should display the first invalid element with a *displayable* error + XCTAssertEqual(section.errorText, "displayable error") + } +} diff --git a/StripeUICore/StripeUICoreTests/Unit/Elements/TestFieldElement+AccountFactoryTest.swift b/StripeUICore/StripeUICoreTests/Unit/Elements/TestFieldElement+AccountFactoryTest.swift new file mode 100644 index 00000000..f55b1a54 --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Unit/Elements/TestFieldElement+AccountFactoryTest.swift @@ -0,0 +1,62 @@ +// +// TestFieldElement+AccountFactoryTest.swift +// StripeUICoreTests +// +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +@_spi(STP) @testable import StripeUICore +import XCTest + +class TextFieldElementAccountFactoryTest: XCTestCase { + // MARK: - BSB + func testBSBConfiguration_validBSB() { + let bsb = TextFieldElement.Account.BSBConfiguration(defaultValue: nil) + + bsb.test(text: "000000", isOptional: false, matches: .valid) + bsb.test(text: "082902", isOptional: false, matches: .valid) + } + + func testBSBConfiguration_empty() { + let bsb = TextFieldElement.Account.BSBConfiguration(defaultValue: nil) + + bsb.test(text: "", isOptional: false, matches: .invalid(TextFieldElement.Error.empty)) + } + + func testBSBConfiguration_incomplete() { + let bsb = TextFieldElement.Account.BSBConfiguration(defaultValue: nil) + + bsb.test(text: "0", isOptional: false, matches: .invalid(TextFieldElement.Account.BSBConfiguration.incompleteError)) + bsb.test(text: "00", isOptional: false, matches: .invalid(TextFieldElement.Account.BSBConfiguration.incompleteError)) + bsb.test(text: "000", isOptional: false, matches: .invalid(TextFieldElement.Account.BSBConfiguration.incompleteError)) + bsb.test(text: "0000", isOptional: false, matches: .invalid(TextFieldElement.Account.BSBConfiguration.incompleteError)) + bsb.test(text: "00000", isOptional: false, matches: .invalid(TextFieldElement.Account.BSBConfiguration.incompleteError)) + } + + // MARK: - AU BECS Account Number + func testAUBECSAccountNumberConfiguration_validAccountNumber() { + let bsb = TextFieldElement.Account.AUBECSAccountNumberConfiguration(defaultValue: nil) + + bsb.test(text: "000123456", isOptional: false, matches: .valid) + } + + func testAUBECSAccountNumberConfiguration_empty() { + let bsb = TextFieldElement.Account.AUBECSAccountNumberConfiguration(defaultValue: nil) + + bsb.test(text: "", isOptional: false, matches: .invalid(TextFieldElement.Error.empty)) + } + + func testAUBECSAccountNumberConfiguration_incomplete() { + let bsb = TextFieldElement.Account.AUBECSAccountNumberConfiguration(defaultValue: nil) + + bsb.test(text: "0", isOptional: false, matches: .invalid(TextFieldElement.Account.AUBECSAccountNumberConfiguration.incompleteError)) + bsb.test(text: "00", isOptional: false, matches: .invalid(TextFieldElement.Account.AUBECSAccountNumberConfiguration.incompleteError)) + bsb.test(text: "000", isOptional: false, matches: .invalid(TextFieldElement.Account.AUBECSAccountNumberConfiguration.incompleteError)) + bsb.test(text: "0001", isOptional: false, matches: .invalid(TextFieldElement.Account.AUBECSAccountNumberConfiguration.incompleteError)) + bsb.test(text: "00012", isOptional: false, matches: .invalid(TextFieldElement.Account.AUBECSAccountNumberConfiguration.incompleteError)) + bsb.test(text: "000123", isOptional: false, matches: .invalid(TextFieldElement.Account.AUBECSAccountNumberConfiguration.incompleteError)) + bsb.test(text: "0001234", isOptional: false, matches: .invalid(TextFieldElement.Account.AUBECSAccountNumberConfiguration.incompleteError)) + bsb.test(text: "00012345", isOptional: false, matches: .invalid(TextFieldElement.Account.AUBECSAccountNumberConfiguration.incompleteError)) + } + +} diff --git a/StripeUICore/StripeUICoreTests/Unit/Elements/TextFieldElement+AddressFactoryTest.swift b/StripeUICore/StripeUICoreTests/Unit/Elements/TextFieldElement+AddressFactoryTest.swift new file mode 100644 index 00000000..e3d20481 --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Unit/Elements/TextFieldElement+AddressFactoryTest.swift @@ -0,0 +1,137 @@ +// +// TextFieldElement+AddressFactoryTest.swift +// StripeUICoreTests +// +// Created by Yuki Tokuhiro on 6/14/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeCore +@_spi(STP) @testable import StripeUICore +import XCTest + +typealias ValidationState = TextFieldElement.ValidationState + +class TextFieldElementAddressFactoryTest: XCTestCase { + // MARK: - Name + + func testNameConfigurationValidation() { + let name = TextFieldElement.NameConfiguration(type: .full, defaultValue: nil) + + // MARK: Required + let requiredTestcases: [String: ValidationState] = [ + "": .invalid(TextFieldElement.Error.empty), + "0": .valid, + "A": .valid, + "; foo": .valid, + ] + requiredTestcases.forEach { testcase, expected in + name.test(text: testcase, isOptional: false, matches: expected) + } + + // MARK: Optional + // Overwrite the required test cases with the ones whose expected value differs when the field is optional + let optionalTestcases: [String: ValidationState] = requiredTestcases.merging([ + "": .valid, + ]) { _, new in new } + for (testcase, expected) in optionalTestcases { + name.test(text: testcase, isOptional: true, matches: expected) + } + } + + // MARK: - Email + + func testEmailConfigurationValidation() { + let email = TextFieldElement.EmailConfiguration(defaultValue: nil) + + // MARK: Required + let requiredTestcases: [String: ValidationState] = [ + "": .invalid(TextFieldElement.Error.empty), + "f": .invalid(email.invalidError), + "f@": .invalid(email.invalidError), + "f@z": .invalid(email.invalidError), + "f@z.c": .valid, + ] + for (testcase, expected) in requiredTestcases { + email.test(text: testcase, isOptional: false, matches: expected) + } + + // MARK: Optional + // Overwrite the required test cases with the ones whose expected value differs when the field is optional + let optionalTestcases: [String: ValidationState] = requiredTestcases.merging([ + "": .valid, + ]) { _, new in new } + for (testcase, expected) in optionalTestcases { + email.test(text: testcase, isOptional: true, matches: expected) + } + } + + // MARK: - Postal Code + + func testPostalCodeConfigurationValidation() { + let US_config = TextFieldElement.Address.PostalCodeConfiguration(countryCode: "US", label: "ZIP", defaultValue: nil, isOptional: false) + XCTAssertEqual(US_config.keyboardProperties(for: "").type, .numberPad) + US_config.test(text: "9411", isOptional: false, matches: .invalid(TextFieldElement.Error.incomplete(localizedDescription: String.Localized.your_zip_is_incomplete))) + US_config.test(text: "94115", isOptional: false, matches: .valid) + + // PostalCodeConfiguration only special cases US, so we can test any other country for full code coverage + let UK_config = TextFieldElement.Address.PostalCodeConfiguration(countryCode: "UK", label: "Postal", defaultValue: nil, isOptional: false) + XCTAssertEqual(UK_config.keyboardProperties(for: "").type, .default) + UK_config.test(text: "SW1A 1AA", isOptional: false, matches: .valid) + } + + // MARK: - Phone Number + func testPhoneNumberConfigurationValidation() { + // US formatting + let usConfiguration = TextFieldElement.PhoneNumberConfiguration { + return "US" + } + + // valid numbers + for number in [ + "555-555-5555", + "5555555555", + "(555) 555-5555", + ] { + usConfiguration.test(text: number, isOptional: false, matches: .valid) + } + + // incomplete + for number in [ + "555-555-555", + "555-555-A555", // the formatter should remove the A here + ] { + usConfiguration.test(text: number, + isOptional: false, + matches: .invalid(TextFieldElement.PhoneNumberConfiguration.incompleteError)) + } + } +} + +// MARK: - Helpers + +// TODO(mludowise): These should get migrated to a shared StripeUICoreTestUtils target + +extension TextFieldElementConfiguration { + func test(text: String, isOptional: Bool, matches expected: ValidationState, file: StaticString = #filePath, line: UInt = #line) { + let actual = validate(text: text, isOptional: isOptional) + XCTAssertEqual(actual, expected, "\(text), \(isOptional): Expected \(expected) but got \(actual)", file: file, line: line) + } +} + +extension TextFieldElement.ValidationState: Equatable { + public static func == (lhs: TextFieldElement.ValidationState, rhs: TextFieldElement.ValidationState) -> Bool { + switch (lhs, rhs) { + case (.valid, .valid): + return true + case let (.invalid(lhsError), .invalid(rhsError)): + return lhsError == rhsError + default: + return false + } + } +} + +func == (lhs: TextFieldValidationError, rhs: TextFieldValidationError) -> Bool { + return (lhs as NSError).isEqual(rhs as NSError) +} diff --git a/StripeUICore/StripeUICoreTests/Unit/Elements/TextFieldElementTest.swift b/StripeUICore/StripeUICoreTests/Unit/Elements/TextFieldElementTest.swift new file mode 100644 index 00000000..5d926cb0 --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Unit/Elements/TextFieldElementTest.swift @@ -0,0 +1,78 @@ +// +// TextFieldElementTest.swift +// StripeUICoreTests +// +// Created by Yuki Tokuhiro on 8/23/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@testable @_spi(STP) import StripeUICore +import XCTest + +class TextFieldElementTest: XCTestCase { + struct Configuration: TextFieldElementConfiguration { + var defaultValue: String? + var label: String = "label" + func maxLength(for text: String) -> Int { "default value".count } + } + + func testNoDefaultValue() { + let element = TextFieldElement(configuration: Configuration(defaultValue: nil)) + XCTAssertTrue(element.textFieldView.text.isEmpty) + XCTAssertTrue(element.text.isEmpty) + } + + func testDefaultValue() { + let element = TextFieldElement(configuration: Configuration(defaultValue: "default value")) + XCTAssertEqual(element.textFieldView.text, "default value") + XCTAssertEqual(element.text, "default value") + } + + func testInvalidDefaultValueIsSanitized() { + let element = TextFieldElement(configuration: Configuration( + defaultValue: "\ndefault\n value that is too long and contains disallowed characters") + ) + XCTAssertEqual(element.textFieldView.text, "default value") + XCTAssertEqual(element.text, "default value") + } + + func testEmptyStringsFailDefaultConfigurationValidation() { + let sut = Configuration() + XCTAssertEqual(sut.validate(text: "", isOptional: false), .invalid(TextFieldElement.Error.empty)) + XCTAssertEqual(sut.validate(text: " ", isOptional: false), .invalid(TextFieldElement.Error.empty)) + XCTAssertEqual(sut.validate(text: " \n", isOptional: false), .invalid(TextFieldElement.Error.empty)) + + } + + func testMultipleCharacterChangeInEmptyFieldIsAutofill() { + let element = TextFieldElement(configuration: Configuration(defaultValue: nil)) + XCTAssertEqual(element.didReceiveAutofill, false) + _ = element.textFieldView.textField(element.textFieldView.textField, shouldChangeCharactersIn: NSRange(location: 0, length: 0), replacementString: "This is autofill") + element.textFieldView.textDidChange() + XCTAssertEqual(element.didReceiveAutofill, true) + } + + func testSingleCharacterChangeInEmptyFieldIsNotAutofill() { + let element = TextFieldElement(configuration: Configuration(defaultValue: nil)) + XCTAssertEqual(element.didReceiveAutofill, false) + _ = element.textFieldView.textField(element.textFieldView.textField, shouldChangeCharactersIn: NSRange(location: 0, length: 0), replacementString: "T") + element.textFieldView.textDidChange() + XCTAssertEqual(element.didReceiveAutofill, false) + } + + func testMultipleCharacterChangeInPopulatedFieldIsNotAutofill() { + let element = TextFieldElement(configuration: Configuration(defaultValue: "default value")) + XCTAssertEqual(element.didReceiveAutofill, false) + _ = element.textFieldView.textField(element.textFieldView.textField, shouldChangeCharactersIn: NSRange(location: 0, length: 0), replacementString: "This is autofill") + element.textFieldView.textDidChange() + XCTAssertEqual(element.didReceiveAutofill, false) + } + + func testSingleCharacterChangeInPopulatedFieldIsNotAutofill() { + let element = TextFieldElement(configuration: Configuration(defaultValue: "default value")) + XCTAssertEqual(element.didReceiveAutofill, false) + _ = element.textFieldView.textField(element.textFieldView.textField, shouldChangeCharactersIn: NSRange(location: 0, length: 0), replacementString: "T") + element.textFieldView.textDidChange() + XCTAssertEqual(element.didReceiveAutofill, false) + } +} diff --git a/StripeUICore/StripeUICoreTests/Unit/Elements/TextFieldFormatterTest.swift b/StripeUICore/StripeUICoreTests/Unit/Elements/TextFieldFormatterTest.swift new file mode 100644 index 00000000..b0a80914 --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Unit/Elements/TextFieldFormatterTest.swift @@ -0,0 +1,70 @@ +// +// TextFieldFormatterTest.swift +// StripeUICoreTests +// +// Created by Mel Ludowise on 9/28/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +@_spi(STP) @testable import StripeUICore +import XCTest + +final class TextFieldFormatterTest: XCTestCase { + // TODO: Test that we don't get lagging characters (e.g. `###-##-` should always drop the last `-` when formatted + + func testInvalidFormat() { + // Formats are required to contain at least one `#` or `*` + XCTAssertNil(TextFieldFormatter(format: "")) + XCTAssertNil(TextFieldFormatter(format: "•••")) + } + + func testApplyFormat() { + // Don't format empty string + verifyFormat(format: "###-###-###", input: "", expectedOutput: "") + // Discard unwanted characters + verifyFormat(format: "###-###-###", input: "12ab3", expectedOutput: "123") + // Trim to size + verifyFormat(format: "###-###-###", input: "1234567890000", expectedOutput: "123-456-789") + // Partial format + verifyFormat(format: "###-###-###", input: "12345", expectedOutput: "123-45") + // Don't display lagging format characters + verifyFormat(format: "###-###-###", input: "123456", expectedOutput: "123-456") + // Already formatted + verifyFormat(format: "###-###-###", input: "123-456-789", expectedOutput: "123-456-789") + + // Letters + verifyFormat(format: "**####-###", input: "", expectedOutput: "") + verifyFormat(format: "**####-###", input: "12ab3", expectedOutput: "ab3") + verifyFormat(format: "**####-###", input: "ab123456789", expectedOutput: "ab1234-567") + verifyFormat(format: "**####-###", input: "ab12345", expectedOutput: "ab1234-5") + verifyFormat(format: "**####-###", input: "ab1234", expectedOutput: "ab1234") + + // Leading formatting + verifyFormat(format: "••• - •• - ####", input: "", expectedOutput: "") + verifyFormat(format: "••• - •• - ####", input: "abc123", expectedOutput: "••• - •• - 123") + verifyFormat(format: "••• - •• - ####", input: "123456", expectedOutput: "••• - •• - 1234") + } + + func testNoLaggingFormatCharacters() { + // Note: If a format has non `*` or `#` characters at the end, they will + // never be displayed. This is by design since it makes it impossible to + // use the backspace key because TextFieldView will keep reformatting + // and moving the cursor. We should never use a format like this, but + // if we inadvertantly do, we want to ensure we don't break the + // backspace key behavior. + verifyFormat(format: "### - ## - •••", input: "12345", expectedOutput: "123 - 45") + verifyFormat(format: "### - ## - •••", input: "123456789", expectedOutput: "123 - 45") + } +} + +// MARK: - Helpers + +private extension TextFieldFormatterTest { + func verifyFormat(format: String, + input: String, + expectedOutput: String, + file: StaticString = #filePath, + line: UInt = #line) { + XCTAssertEqual(TextFieldFormatter(format: format)?.applyFormat(to: input), expectedOutput, file: file, line: line) + } +} diff --git a/StripeUICore/StripeUICoreTests/Unit/Validators/BSBNumberTests.swift b/StripeUICore/StripeUICoreTests/Unit/Validators/BSBNumberTests.swift new file mode 100644 index 00000000..f48ca973 --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Unit/Validators/BSBNumberTests.swift @@ -0,0 +1,81 @@ +// +// BSBNumberTests.swift +// StripeUICoreTests +// +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import XCTest + +@_spi(STP) @testable import StripeUICore + +class BSBNumberTests: XCTestCase { + func testBSBNumber_0() { + let bsbNumber = BSBNumber(number: "0") + XCTAssertFalse(bsbNumber.isComplete) + XCTAssertEqual(bsbNumber.formattedNumber(), "0") + XCTAssertEqual(bsbNumber.bsbNumberText(), "0") + } + func testBSBNumber_01() { + let bsbNumber = BSBNumber(number: "00") + XCTAssertFalse(bsbNumber.isComplete) + XCTAssertEqual(bsbNumber.formattedNumber(), "00") + XCTAssertEqual(bsbNumber.bsbNumberText(), "00") + } + + func testBSBNumber_012() { + let bsbNumber = BSBNumber(number: "012") + XCTAssertFalse(bsbNumber.isComplete) + XCTAssertEqual(bsbNumber.formattedNumber(), "012") + XCTAssertEqual(bsbNumber.bsbNumberText(), "012") + } + + func testBSBNumber_0123() { + let bsbNumber = BSBNumber(number: "0123") + XCTAssertFalse(bsbNumber.isComplete) + XCTAssertEqual(bsbNumber.formattedNumber(), "012-3") + XCTAssertEqual(bsbNumber.bsbNumberText(), "0123") + } + + func testBSBNumber_01234() { + let bsbNumber = BSBNumber(number: "01234") + XCTAssertFalse(bsbNumber.isComplete) + XCTAssertEqual(bsbNumber.formattedNumber(), "012-34") + XCTAssertEqual(bsbNumber.bsbNumberText(), "01234") + } + + func testBSBNumber_012345() { + let bsbNumber = BSBNumber(number: "012345") + XCTAssert(bsbNumber.isComplete) + XCTAssertEqual(bsbNumber.formattedNumber(), "012-345") + XCTAssertEqual(bsbNumber.bsbNumberText(), "012345") + } + + func testBSBNumber_012_withdash() { + let bsbNumber = BSBNumber(number: "012-") + XCTAssertFalse(bsbNumber.isComplete) + XCTAssertEqual(bsbNumber.formattedNumber(), "012") + XCTAssertEqual(bsbNumber.bsbNumberText(), "012") + } + + func testBSBNumber_0123_withdash() { + let bsbNumber = BSBNumber(number: "012-3") + XCTAssertFalse(bsbNumber.isComplete) + XCTAssertEqual(bsbNumber.formattedNumber(), "012-3") + XCTAssertEqual(bsbNumber.bsbNumberText(), "0123") + } + + func testBSBNumber_01234_withdash() { + let bsbNumber = BSBNumber(number: "012-34") + XCTAssertFalse(bsbNumber.isComplete) + XCTAssertEqual(bsbNumber.formattedNumber(), "012-34") + XCTAssertEqual(bsbNumber.bsbNumberText(), "01234") + } + + func testBSBNumber_012345_withdash() { + let bsbNumber = BSBNumber(number: "012-345") + XCTAssert(bsbNumber.isComplete) + XCTAssertEqual(bsbNumber.formattedNumber(), "012-345") + XCTAssertEqual(bsbNumber.bsbNumberText(), "012345") + } +} diff --git a/StripeUICore/StripeUICoreTests/Unit/Validators/PhoneNumberTests.swift b/StripeUICore/StripeUICoreTests/Unit/Validators/PhoneNumberTests.swift new file mode 100644 index 00000000..27afb0c1 --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Unit/Validators/PhoneNumberTests.swift @@ -0,0 +1,179 @@ +// +// PhoneNumberTests.swift +// StripeUICoreTests +// +// Created by Cameron Sabol on 10/11/21. +// Copyright © 2021 Stripe, Inc. All rights reserved. +// + +import XCTest + +@_spi(STP) @testable import StripeUICore + +class PhoneNumberTests: XCTestCase { + + func testFormats() { + let cases: [(number: String, country: String, format: PhoneNumber.Format, formattedNumber: String)] = [ + ( + number: "", + country: "US", + format: .national, + formattedNumber: "" + ), + ( + number: "4", + country: "US", + format: .national, + formattedNumber: "(4" + ), + ( + number: "+", + country: "US", + format: .national, + formattedNumber: "" // doesn't include + in national format + ), + ( + number: "+", + country: "US", + format: .international, + formattedNumber: "" // empty input shouldn't get formatted + ), + ( + number: "a", + country: "US", + format: .national, + formattedNumber: "" + ), + ( + number: "(", // PhoneNumberFormat only formats digits, +, and • + country: "US", + format: .national, + formattedNumber: "" + ), + ( + number: "+49", + country: "DE", + format: .international, + formattedNumber: "+49 49" // never treats input as country code + ), + ( + number: "160 1234567", + country: "DE", + format: .international, + formattedNumber: "+49 160 1234567" + ), + ( + number: "5551231234", + country: "US", + format: .international, + formattedNumber: "+1 (555) 123-1234" + ), + ( + number: "(555) 123-1234", + country: "US", + format: .international, + formattedNumber: "+1 (555) 123-1234" + ), + ( + number: "555", + country: "US", + format: .international, + formattedNumber: "+1 (555" + ), + ( + number: "(555) a", + country: "US", + format: .international, + formattedNumber: "+1 (555" + ), + ( + number: "(403) 123-1234", + country: "CA", + format: .international, + formattedNumber: "+1 (403) 123-1234" + ), + ( + number: "(403) 123-1234", + country: "CA", + format: .national, + formattedNumber: "(403) 123-1234" + ), + ( + number: "4031231234", + country: "CA", + format: .national, + formattedNumber: "(403) 123-1234" + ), + ( + number: "6711231234", + country: "GU", + format: .international, + formattedNumber: "+1 (671) 123-1234" + ), + ( + number: "6711231234", + country: "GU", + format: .national, + formattedNumber: "(671) 123-1234" + ), + ] + + for c in cases { + guard let phoneNumber = PhoneNumber(number: c.number, countryCode: c.country) else { + XCTFail("Could not create phone number for \(c.country), \(c.number)") + continue + } + XCTAssertEqual(phoneNumber.string(as: c.format), c.formattedNumber) + } + } + + func teste164FormatDropsLeadingZeros() { + guard let phoneNumber = PhoneNumber(number: "08022223333", countryCode: "JP") else { + XCTFail("Could not create phone number") + return + } + XCTAssertEqual(phoneNumber.string(as: .e164), "+818022223333") + } + + func teste164MaxLength() { + guard let phoneNumber = PhoneNumber(number: "123456789123456789", countryCode: "US") else { + XCTFail("Could not create phone number") + return + } + XCTAssertEqual(phoneNumber.string(as: .e164), "+112345678912345") + } + + func testFromE164() { + let gbPhone = PhoneNumber.fromE164("+445555555555") + XCTAssertEqual(gbPhone?.countryCode, "GB") + XCTAssertEqual(gbPhone?.number, "5555555555") + + let brPhone = PhoneNumber.fromE164("+5591155256325") + XCTAssertEqual(brPhone?.countryCode, "BR") + XCTAssertEqual(brPhone?.number, "91155256325") + } + + func testFromE164_shouldHandleInvalidInput() { + XCTAssertNil(PhoneNumber.fromE164("")) + XCTAssertNil(PhoneNumber.fromE164("++")) + XCTAssertNil(PhoneNumber.fromE164("+13")) + XCTAssertNil(PhoneNumber.fromE164("1 (555) 555 5555")) + XCTAssertNil(PhoneNumber.fromE164("+1555555555555555")) // too long + } + + func testFromE164_shouldDisambiguateUsingLocale() { + // This test number is very ambiguous, it can belong to ~25 countries/territories due to + // the "+1" calling code/prefix being shared by many countries. + let number = "+15555555555" + + XCTAssertEqual(PhoneNumber.fromE164(number, locale: .init(identifier: "en_US"))?.countryCode, "US") + XCTAssertEqual(PhoneNumber.fromE164(number, locale: .init(identifier: "en_CA"))?.countryCode, "CA") + XCTAssertEqual(PhoneNumber.fromE164(number, locale: .init(identifier: "es_DO"))?.countryCode, "DO") + XCTAssertEqual(PhoneNumber.fromE164(number, locale: .init(identifier: "en_PR"))?.countryCode, "PR") + XCTAssertEqual(PhoneNumber.fromE164(number, locale: .init(identifier: "en_JM"))?.countryCode, "JM") + + XCTAssertEqual(PhoneNumber.fromE164(number, locale: .init(identifier: "ja_JP"))?.countryCode, "US") + XCTAssertEqual(PhoneNumber.fromE164(number, locale: .init(identifier: "ar_LB"))?.countryCode, "US") + } + +} diff --git a/StripeUICore/StripeUICoreTests/Unit/Validators/STPBlikCodeValidatorTest.swift b/StripeUICore/StripeUICoreTests/Unit/Validators/STPBlikCodeValidatorTest.swift new file mode 100644 index 00000000..2656c69d --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Unit/Validators/STPBlikCodeValidatorTest.swift @@ -0,0 +1,37 @@ +// +// STPBlikCodeValidator.swift +// StripeUICoreTests +// +// Created by Fionn Barrett on 07/07/2023. +// + +import Foundation +@_spi(STP) import StripeUICore +import XCTest + +class STPBlikCodeValidatorTest: XCTestCase { + func testBlikCode_0() { + XCTAssertFalse(STPBlikCodeValidator.stringIsValidBlikCode("0")) + } + func testBlikCode_valid() { + XCTAssertTrue(STPBlikCodeValidator.stringIsValidBlikCode("123456")) + } + + func testBlikCode_lessThanSixDigits() { + XCTAssertFalse(STPBlikCodeValidator.stringIsValidBlikCode("1234")) + } + + func testBlikCode_moreThanSixDigits() { + XCTAssertFalse(STPBlikCodeValidator.stringIsValidBlikCode("1234567")) + } + + func testBlikCode_nil() { + XCTAssertFalse(STPBlikCodeValidator.stringIsValidBlikCode(nil)) + } + + func testBlikCode_nonNumeric() { + XCTAssertFalse(STPBlikCodeValidator.stringIsValidBlikCode("12a456")) + XCTAssertFalse(STPBlikCodeValidator.stringIsValidBlikCode("abcdef")) + XCTAssertFalse(STPBlikCodeValidator.stringIsValidBlikCode("stripe.com")) + } +} diff --git a/StripeUICore/StripeUICoreTests/Unit/Validators/STPEmailAddressValidatorTest.swift b/StripeUICore/StripeUICoreTests/Unit/Validators/STPEmailAddressValidatorTest.swift new file mode 100644 index 00000000..da01621d --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Unit/Validators/STPEmailAddressValidatorTest.swift @@ -0,0 +1,26 @@ +// +// STPEmailAddressValidatorTest.swift +// StripeUICoreTests +// +// Created by Jack Flintermann on 3/23/16. +// Copyright © 2016 Stripe, Inc. All rights reserved. +// + +@_spi(STP) import StripeUICore +import XCTest + +class STPEmailAddressValidatorTest: XCTestCase { + func testValidEmails() { + let validEmails = ["test@test.com", "test+thing@test.com.nz", "a@b.c", "A@b.c"] + for email in validEmails { + XCTAssert(STPEmailAddressValidator.stringIsValidEmailAddress(email)) + } + } + + func testInvalidEmails() { + let invalidEmails = ["", "google.com", "asdf", "asdg@c"] + for email in invalidEmails { + XCTAssertFalse(STPEmailAddressValidator.stringIsValidEmailAddress(email)) + } + } +} diff --git a/StripeUICore/StripeUICoreTests/Unit/Validators/STPVPANumberValidatorTest.swift b/StripeUICore/StripeUICoreTests/Unit/Validators/STPVPANumberValidatorTest.swift new file mode 100644 index 00000000..43f0dd77 --- /dev/null +++ b/StripeUICore/StripeUICoreTests/Unit/Validators/STPVPANumberValidatorTest.swift @@ -0,0 +1,31 @@ +// +// STPVPANumberValidatorTest.swift +// StripeUICoreTests +// +// Created by Nick Porter on 9/15/22. +// Copyright © 2022 Stripe, Inc. All rights reserved. +// + +import Foundation +@_spi(STP) import StripeUICore +import XCTest + +class STPVPANumberValidatorTest: XCTestCase { + func testValidVPAs() { + XCTAssert(STPVPANumberValidator.stringIsValidVPANumber("stripe@icici")) + XCTAssert(STPVPANumberValidator.stringIsValidVPANumber("stripe@okaxis")) + XCTAssert(STPVPANumberValidator.stringIsValidVPANumber("stripe.9897605011@paytm")) + XCTAssert(STPVPANumberValidator.stringIsValidVPANumber("payment.pending@stripeupi")) + XCTAssert(STPVPANumberValidator.stringIsValidVPANumber("test30c_123@numberofcharacters")) + XCTAssert(STPVPANumberValidator.stringIsValidVPANumber("test29c_12@numberofcharacters")) + } + + func testInvalidVPAs() { + XCTAssertFalse(STPVPANumberValidator.stringIsValidVPANumber("")) + XCTAssertFalse(STPVPANumberValidator.stringIsValidVPANumber("test@stripe.com")) + XCTAssertFalse(STPVPANumberValidator.stringIsValidVPANumber("stripe")) + XCTAssertFalse(STPVPANumberValidator.stringIsValidVPANumber("stripe@gmail.com")) + XCTAssertFalse(STPVPANumberValidator.stringIsValidVPANumber("this-vpa-id-is-too-long-30-chars")) + XCTAssertFalse(STPVPANumberValidator.stringIsValidVPANumber("test31c_1234@numberofcharacters")) + } +} diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..818f1ddf --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +23.17.2 \ No newline at end of file