stripe / stripe-ios

Stripe iOS SDK
https://stripe.com
MIT License
2.09k stars 976 forks source link

Freeze screen after authentication failed #1383

Closed joaoppedrosa closed 4 years ago

joaoppedrosa commented 4 years ago

Summary

At the second try in the safari freezes and doesn't respond anymore. And I'm receiving the URL callback.

Code to reproduce

1 - Insert a 3D Secure card (like 4000 0082 6000 3178), make the confirm and failed the authentication 2 - Request a new confirm and the screen freezes

iOS version

iOS 12.4 | iOS 12.3.1

Installation method

Cocoapods

SDK version

17.0.1

Other information

1- IMG_0039

2- IMG_0040

csabol-stripe commented 4 years ago

Hi @joaoppedrosa, could you provide any additional steps or code you are using to reproduce? I just tried using the test card number in our Custom Integration sample app, declining the authorization, then trying the same again but didn't hit this behavior.

Also is it just the web content that is not responsive or is the SFSafariViewController UI unresponsive as well (e.g. you are unable to dismiss)?

joaoppedrosa commented 4 years ago

Hi @csabol-stripe, thanks for the reply. Actually is a very odd behavior, because sometimes at the second, third try everything works fine, and when we are debugging everything works perfectly. Sometimes appear the "Return to Merchant" button, but most of the time at the second try after a failure, appear this white and freeze screen, whit no cancel or dismiss button, just completely freeze.

I think that is the SFSafariViewController itself because we cannot dismiss or go back after this white screen appears, the only solution is to restart the application.

csabol-stripe commented 4 years ago

😬 that doesn't sound good! I'll leave this issue open for a while to see if we get any other reports. If you are able to isolate any more steps/setup to reproduce please add them here!

joaoppedrosa commented 4 years ago

@csabol-stripe I will leave here our implementation, if you can spot or see anything that we are doing wrong or that can be making this bug.

We are using the version 17.0.1 of Stripe SDK.

RNStripeWrapper.swift

//
//  RNStripeWrapper.swift
//

import Foundation
import Stripe

// Converts HEX to UIColor used for Stripe themeing
extension UIColor {
    convenience init(hexString: String) {
        let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
        var int = UInt32()
        Scanner(string: hex).scanHexInt32(&int)
        let a, r, g, b: UInt32
        switch hex.count {
        case 3: // RGB (12-bit)
            (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
        case 6: // RGB (24-bit)
            (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
        case 8: // ARGB (32-bit)
            (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
        default:
            (a, r, g, b) = (255, 0, 0, 0)
        }
        self.init(red: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a) / 255)
    }
}

// Create an AuthContext with a initializer for defining a view controller
// This is probably not needed if we are using self UIViewController
class AuthContext: NSObject, STPAuthenticationContext {

    var viewController: UIViewController

    init(viewController: UIViewController) {
        self.viewController = viewController
    }

    func authenticationPresentingViewController() -> UIViewController {
        return self.viewController
    }
}

// Converts PaymentOption to React including label and base64 image of the card
func convertPaymentMethodObject(_ paymentMethod: STPPaymentOption) -> [AnyHashable : Any]? {
    var result: [AnyHashable : Any] = [:]
    let data: Data = paymentMethod.image.pngData()!

    let img = "data:image/png;base64,"

    result["label"] = paymentMethod.label
    result["image"] = img + (data.base64EncodedString(options: .endLineWithCarriageReturn))

    return result
}

@objc(RNStripeWrapper)
class RNStripeWrapper: NSObject, STPPaymentOptionsViewControllerDelegate {
    // Configs
    var stripePublishableKey = ""
    var backendBaseURL: String? = nil
    var headers: [String: String] = [:]
    var returnUrl: String? = nil

    // Themes
    var mainTheme: STPTheme = STPTheme.default()
    var navigationTheme : STPTheme = STPTheme.default()

    // Contexts
    var paymentConfiguration: STPPaymentConfiguration! = nil
    var customerContext: STPCustomerContext! = nil
    var paymentContext: STPPaymentContext! = nil

    var companyName = "Company, lda"
    var authenticationType = "manual" // TODO: implement automatic integration

    // Views
    var authenticationView: UIViewController! = nil

    // Resolvers
    var promiseResolver: RCTPromiseResolveBlock? = nil
    var promiseRejector: RCTPromiseRejectBlock? = nil
    var paymentInProgress: Bool = false

    // User input
    var selectedPaymentMethod: STPPaymentMethod? = nil
    var paymentAmount: Int? = nil // Should not be used alone! Use payment metadata to check if the amount matches your back-end.
    var paymentCurrency: String = "EUR" // Move to config??
    var paymentMetadata: [AnyHashable: AnyObject] = [:]

    // Setup main queue
    @objc static func requiresMainQueueSetup() -> Bool {
        return true
    }

    // Clears promises
    func clearPromises() {
        self.promiseResolver = nil
        self.promiseRejector = nil
    }

    // Intializes module with options and themes
    // Sets the contexts needed and themes
    @objc func initialize(_ options: [String: AnyObject], mainTheme: [String: String] = [:], navigationTheme: [String: String] = [:], resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
        // Initialize class with options
        self.stripePublishableKey = (options["publishableKey"] as! String)
        self.backendBaseURL = (options["backendBaseURL"]  as! String)
        self.headers = (options["headers"]  as! [String:String])
        self.returnUrl = (options["returnUrl"]  as! String)
        self.companyName = (options["companyName"]  as! String)
        self.authenticationType = "manual"

        // Update APIClient base url
        APIClient.sharedClient.baseURLString = self.backendBaseURL
        APIClient.sharedClient.headers = self.headers

        // Set themes
        if (mainTheme.count > 0) {
            self.mainTheme = STPTheme()
            self.mainTheme.accentColor = UIColor(hexString: mainTheme["accentColor"]!)
            self.mainTheme.secondaryBackgroundColor = UIColor(hexString: mainTheme["secondaryBackgroundColor"]!)
            self.mainTheme.secondaryForegroundColor = UIColor(hexString: mainTheme["secondaryForegroundColor"]!)
            self.mainTheme.primaryBackgroundColor = UIColor(hexString: mainTheme["primaryBackgroundColor"]!)
            self.mainTheme.primaryForegroundColor = UIColor(hexString: mainTheme["primaryForegroundColor"]!)
            self.mainTheme.emphasisFont = UIFont(name: mainTheme["emphasisFont"]!, size: 16)
        }

        if (navigationTheme.count > 0) {
            self.navigationTheme = STPTheme()
            self.navigationTheme.accentColor = UIColor(hexString: navigationTheme["accentColor"]!)
            self.navigationTheme.secondaryBackgroundColor = UIColor(hexString: navigationTheme["secondaryBackgroundColor"]!)
            self.navigationTheme.secondaryForegroundColor = UIColor(hexString: navigationTheme["secondaryForegroundColor"]!)
            self.navigationTheme.primaryBackgroundColor = UIColor(hexString: navigationTheme["primaryBackgroundColor"]!)
            self.navigationTheme.primaryForegroundColor = UIColor(hexString: navigationTheme["primaryForegroundColor"]!)
            self.navigationTheme.emphasisFont = UIFont(name: navigationTheme["emphasisFont"]!, size: 16)
        }

        // Setup payment configuration
        // TODO: allow changes via options
        self.paymentConfiguration = STPPaymentConfiguration.shared()
        self.paymentConfiguration.publishableKey = self.stripePublishableKey
        self.paymentConfiguration.companyName = self.companyName
        self.paymentConfiguration.canDeletePaymentOptions = true

        // Setup costumer context
        self.customerContext = STPCustomerContext(keyProvider: APIClient.sharedClient)

        // Create authentication view
        self.authenticationView = UIViewController()
        authenticationView.modalPresentationStyle = .overCurrentContext

        resolve(nil)
    }

    /// Opens the paymentMethods selector
    ///
    /// - Parameters:
    ///   - resolve: JS Promise resolver
    ///   - reject: JS Promise rejecter
    @objc func openPaymentMethods(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
        self.promiseResolver = resolve
        self.promiseRejector = reject

        // Create payment method view controller
        let paymentMethodsViewController = STPPaymentOptionsViewController(
            configuration: self.paymentConfiguration,
            theme: self.mainTheme,
            customerContext: self.customerContext,
            delegate: self
        )

        // Set the default selected method to the current selected
        paymentMethodsViewController.defaultPaymentMethod = self.selectedPaymentMethod?.stripeId

        // Create a navigation controoler and set the navigation theme
        let navigationController = UINavigationController(rootViewController: paymentMethodsViewController)
        navigationController.navigationBar.stp_theme = self.navigationTheme

        // Dispatch the view to the main view controller
        DispatchQueue.main.async {
            RCTPresentedViewController()?.present(navigationController, animated: true)
        }
    }

    // Updates the payment context
    @objc func updatePaymentContext(_ amount: NSInteger, currency: String, metadata: [String: AnyObject] = [:]) {
        // Updates payment context
        self.paymentAmount = amount
        self.paymentCurrency = currency
        self.paymentMetadata = metadata
    }

    /// Requests a payment
    ///
    /// - Parameters:
    ///   - resolve:  JS Promise resolver
    ///   - reject:  JS Promise rejecter
    @objc func requestPayment(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
        self.promiseResolver = resolve
        self.promiseRejector = reject
        self.paymentInProgress = true
        self.createPaymentIntent()
    }

    // On error callback
    func onAuthenticationError(error: Error) {
        self.paymentInProgress = false
        self.authenticationView.dismiss(animated: true)
        self.promiseRejector!("", error.localizedDescription, error)
        self.clearPromises()
    }

    // On success callback
    func onAuthenticationSuccess() {
        self.paymentInProgress = false
        self.authenticationView.dismiss(animated: true)
        self.promiseResolver!(true)
        self.clearPromises()
    }

    // Creates the payment intent
    func createPaymentIntent() {
        APIClient.sharedClient.createAndConfirmPaymentIntent(
            self.selectedPaymentMethod!,
            metadata: self.paymentMetadata,
            amount: self.paymentAmount!,
            returnURL: self.returnUrl!,
            shippingAddress: nil,
            shippingMethod: nil
        ) {
            (clientSecret, error) in
                guard let clientSecret = clientSecret else {
                    self.onAuthenticationError(error: NSError(domain: StripeDomain, code: 123, userInfo: [NSLocalizedDescriptionKey: "Unable to parse clientSecret from response"]))
                    return
                }

                self.authenticatePaymentIntent(clientSecret: clientSecret)
        }
    }

    // Confirms payment intent on the server
    @objc func confirmPaymentIntent(handledPaymentIntent: STPPaymentIntent) {
        // Confirm again on the backend
        APIClient.sharedClient.confirmPaymentIntent(handledPaymentIntent) {
            clientSecret, error in
            guard let clientSecret = clientSecret else {
                self.onAuthenticationError(error: error ?? NSError(domain: StripeDomain, code: 123, userInfo: [NSLocalizedDescriptionKey: "Unable to parse clientSecret from response"]))
                return
            }

            // Retrieve the Payment Intent and check the status for success
            STPAPIClient.shared().retrievePaymentIntent(withClientSecret: clientSecret) { (paymentIntent, retrieveError) in
                guard let paymentIntent = paymentIntent else {
                    self.onAuthenticationError(error: retrieveError ?? NSError(domain: StripeDomain, code: 123, userInfo: [NSLocalizedDescriptionKey: "Unable to parse payment intent from response"]) )
                    return
                }

                if paymentIntent.status == .succeeded || paymentIntent.status == .requiresCapture {
                    self.onAuthenticationSuccess()
                }
                else {
                    self.onAuthenticationError(error: NSError(domain: StripeDomain, code: 123, userInfo: [NSLocalizedDescriptionKey: "Authentication failed."]))
                }
            }
        }
    }

    // Authenticates payment
    @objc func authenticatePaymentIntent(clientSecret: String) {
        // Create payment handler and authcontext with the current view controller
        let paymentHandler = STPPaymentHandler.shared()
        let authContext = AuthContext(viewController: self.authenticationView)

        // Present the authentication view controller
        DispatchQueue.main.async {
            RCTPresentedViewController()?.present(self.authenticationView, animated: true)
        }

        paymentHandler.handleNextAction(forPayment: clientSecret, authenticationContext: authContext, returnURL: self.returnUrl) {
            (status, handledPaymentIntent, actionError) in
            switch (status) {
            case .succeeded:
                guard let handledPaymentIntent = handledPaymentIntent else {
                    self.onAuthenticationError(error: actionError ?? NSError(domain: StripeDomain, code: 123, userInfo: [NSLocalizedDescriptionKey: "Unknown failure"]))
                    return
                }

                if (handledPaymentIntent.status == .requiresConfirmation) {
                    self.confirmPaymentIntent(handledPaymentIntent: handledPaymentIntent)
                } else {
                    // Success
                    self.onAuthenticationSuccess()
                }
            case .failed:
                self.onAuthenticationError(error: actionError!)
            case .canceled:
                self.onAuthenticationError(error: NSError(domain: StripeDomain, code: 123, userInfo: [NSLocalizedDescriptionKey: "User canceled authentication."]))
            }
        }
    }

    // MARK: STPPaymentOptionsViewControllerDelegate

    // STPPaymentOptionsViewController fail
    func paymentOptionsViewController(_ paymentOptionsViewController: STPPaymentOptionsViewController, didFailToLoadWithError error: Error) {
        if (self.promiseRejector != nil) {
            self.promiseRejector!("", "", error)
        }
        self.clearPromises()
    }

    // STPPaymentOptionsViewController finish
    func paymentOptionsViewControllerDidFinish(_ paymentOptionsViewController: STPPaymentOptionsViewController) {
        paymentOptionsViewController.dismiss()
    }

    // STPPaymentOptionsViewController cancel
    func paymentOptionsViewControllerDidCancel(_ paymentOptionsViewController: STPPaymentOptionsViewController) {
        if (self.promiseRejector != nil) {
            self.promiseRejector!("", "User cancelled", NSError(domain: StripeDomain, code: 123, userInfo: [NSLocalizedDescriptionKey: "User canceled selecting payment."]))
        }

        paymentOptionsViewController.dismiss()
        self.clearPromises()
    }

    // On select Payment Method
    func paymentOptionsViewController(_ paymentOptionsViewController: STPPaymentOptionsViewController, didSelect paymentOption: STPPaymentOption) {

        // Convert STPPaymentOption option to STPPaymentMethod to obtain the stripeId later
        let selectedPaymentMethod = (paymentOption as! STPPaymentMethod)

        // For some reason this is called on start so just check if the paymentMethod is different
        if (self.selectedPaymentMethod?.stripeId != selectedPaymentMethod.stripeId) {

            // Set selected
            self.selectedPaymentMethod = selectedPaymentMethod

            // Resolve promise with the paymentOption because we don't need any more info
            var result: [AnyHashable : Any] = [:]
            let selectedPaymentMethod = convertPaymentMethodObject(paymentOption)
            result["selectedPaymentMethod"] = selectedPaymentMethod

            if (self.promiseResolver != nil) {
                self.promiseResolver!(result)
            }

            self.clearPromises()
        }
    }
}

AppDelegate.m

- (BOOL)application:(UIApplication *)application
            openURL:(NSURL *)url
            options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {

  NSLog( @"STIPRE URL: %@", url.absoluteString );
  BOOL stripeHandled = [Stripe handleStripeURLCallbackWithURL:url];
  if (stripeHandled) {
    return YES;
  }

  BOOL fbhandled = [[FBSDKApplicationDelegate sharedInstance] application:application
                                                                openURL:url
                                                      sourceApplication:options[UIApplicationOpenURLOptionsSourceApplicationKey]
                                                             annotation:options[UIApplicationOpenURLOptionsAnnotationKey]
                  ];
  if (fbhandled) {
        return YES;
  }

  return NO;
}

// This method handles opening universal link URLs (e.g., "https://example.com/stripe_ios_callback")
- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler {
  if (userActivity.activityType == NSUserActivityTypeBrowsingWeb) {
    if (userActivity.webpageURL) {
      BOOL stripeHandled = [Stripe handleStripeURLCallbackWithURL:userActivity.webpageURL];
      if (stripeHandled) {
        return YES;
      }

      return NO;
    }
  }
  return NO;
}
csabol-stripe commented 4 years ago

thanks @joaoppedrosa this is really helpful. Looking at the code, I'm wondering if maybe the view that is left frozen on the screen is actually authenticationView. Can you confirm the view hierarchy when you hit this bug? (Fastest way would probably be to user Xcodes UI Inspector). Can you also confirm whether your completion handlers onAuthenticationSuccess and onAuthenticationError are being called as expected?

I think this is fine, but would also recommend against asynchronously presenting a view before calling handleNextAction i.e.

        DispatchQueue.main.async {
            RCTPresentedViewController()?.present(self.authenticationView, animated: true)
        }

I don't think it is causing an issue now because the first step of handleNextAction is a network request but it may cause issues in the future

joaoppedrosa commented 4 years ago

Hi @csabol-stripe, I check the onAuthenticationSuccess and onAuthenticationError and they are being called as expected. Thanks for the tip about the asynchronously presenting a view, it's already resolved.

About the bug it's still occurring, I use the XCode UI Inspector and the block it's happening in SFSafariViewController

Captura de ecrã 2019-09-25, às 11 30 07
csabol-stripe commented 4 years ago

hmm, to help debug could you implement - (void)authenticationContextWillDismissViewController:(UIViewController *)viewController? This should be called before the Stripe SDK dismisses the safari view controller. I would expect the view hierarchy at that point to be the SFSafariViewController instance on top of self.authenticationView. By the time the handleNextAction completion block is called I would expect the SFSafariViewController to be dismissed and [[STPPaymentHandler sharedHandler] safariViewController] == nil

joaoppedrosa commented 4 years ago

Hi @csabol-stripe , I update the SDK to the latest version (17.0.2) and add the authenticationContextWillDismissViewController and for now it seems to be correctly working, the screen doesn't freeze anymore. I will be continuing testing, and if anything comes up I will reach with you again.

Thank you for your support :)

diegoalex commented 4 years ago

Hi Guys, I'm having a similar issue, but in my case, it's been replacing the view I have in the background. The solution for the issue is to set the SFSafariViewController modal presentation to be "overCurrentContext".

And I'm also having the same issue @csabol-stripe is having with the authenticationContextWillDismissViewController method... But in my case, it's not been called.

I've tried this:

}



The "logger" is printing only the "prepare" message, so it means the delegate is fine.. but none of those next two methods were been called.

Am I implementing the method properly? do I have to config something different for them to work?

Stripe SDK version: 18.0.0

Thanks in advance!
csabol-stripe commented 4 years ago

@diegoalex Does your completion handler get called? Just looking at the code it seems correct, but we do some checks before calling configureSafariViewController or presenting to make sure the presentation context is valid (see STPPaymentHander#_canPresentWithAuthenticationContext:err:), but if that's the case you should be getting a completion callback with an error

csabol-stripe commented 4 years ago

@diegoalex could you clarify what you mean by "replacing the view I have in the background"? To clarify, sliding/pushing the safari view controller in from the side is the iOS default, but you can override that as you've described :)

diegoalex commented 4 years ago

Hi @csabol-stripe , the completion handler is working fine, actually, the whole process is working. But for some reason, those two methods (configureSafariViewController and authenticationContextWillDismiss) were not been called.

I've done a pod update and pod install and closed xcode/restart computer and now the app is going into the function configureSafariViewController and setting the modalPresentationStyle.. SO the problem is fixed.. Probably just some Xcode cache issue.

But to clarify about the "replacing view in the background" , my app structure is like this: -Tab view -- payment modal view (modalPresentationStyle = .overCurrentContext) --- stripe safari view

For some reason when the safari view opens, my tab view reloads.. for example, if I'm in tab 3 when I close the safari view it keeps the payment modal, but the tab view refreshes and go back to tab 1. It also happened before with other modal views but setting "modalPresentationStyle = .overCurrentContext" fixed this issue.

But thanks anyway ;)