Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BrowserSwitchResult receiving UserCanceledException #409

Closed
mikkoville opened this issue Jun 18, 2021 · 11 comments · Fixed by #410
Closed

BrowserSwitchResult receiving UserCanceledException #409

mikkoville opened this issue Jun 18, 2021 · 11 comments · Fixed by #410

Comments

@mikkoville
Copy link

General information

  • SDK/Library version: 4.0.1
  • Environment: Sandbox but should behave the same in prod
  • Android Version and Device: Android 10, 11, Pixel 3, Pixel 4
    com.braintreepayments.api:data-collector
    com.braintreepayments.api:paypal

Issue description

I migrated from SDK version 3 to 4 and implemented PayPal flow according to your documentation.

I think I have hit one case that is not handled by the SDK.
Steps:

  1. Implement PayPal flow according to docs
  2. While in in the PayPal flow (BrowserSwithch / Chromium custom tabs) click under the three dots on top right
  3. Select open in Chrome

What happens is that immediately when the new Chrome process is started I receive in my PayPalActivity (identical to your docs example) callback from payPalClient.onBrowserSwitchResult which is placed in the onResume() of the activity. The callback receives UserCanceledException which I handle as the user cancelled the flow (exiting the activity and not receiving the payPalAccountNonce). This means that the flow does not work if user opens the custom tab in new Chrome process.

Do you have ideas how we could make this work?

@sshropshire
Copy link
Contributor

Hi @mikkoville thanks for using the Braintree SDK for Android. I was able to reproduce this issue on Android 10. It does work fine in Android 11, for the record. We'll look into this and hopefully get back to you soon.

@sshropshire
Copy link
Contributor

sshropshire commented Jun 23, 2021

Hey @mikkoville we provided a fix for this in version 4.2.0.

@mikkoville
Copy link
Author

@sshropshire Hello and thanks for the fix. It unfortunately does not help in my case. I am still little bit confused of my issue but I think it is because our PayPalActivity is defined as launchMode=singleTask (we need this due to how our application is architected). So my problem is that when user clicks open in Chrome under the three dots we get PayPalActivity#onResume() called the second time and since this is the second time we call braintreeClient.deliverBrowserSwitchResult() we will receive the UserCanceledException even though the user did not cancel the flow.

So to me it seems if we call braintreeClient.deliverBrowserSwitchResult() multiple times on the same instance of BraintreeClient we will receive the UserCanceledException. The reason I am getting onResume twice seems to be because of the launchMode. I am not that familiar how does Chrome custom tabs work under the hood but I guess it triggers the onResume the second time.

@mikkoville
Copy link
Author

@sshropshire I think I have a working solution for our case where our applications single main activity has launchMode=singleTask and also the PayPalActivity has launchMode=singleTask. I moved deliverBrowserSwitchResult() and listening of the callback to onNewIntent() of the activity. This way it works both ways. Through the embedded Chrome tab and one opened in new Chrome process. What do you think of the solution? Do you see any possible problems of having it here instead of onResume()? Here is the meat of our PayPalActivity

internal class PayPalActivity : AppCompatActivity() {

    private lateinit var braintreeClient: BraintreeClient
    private lateinit var payPalClient: PayPalClient
    private var didTryToTokenize = false

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val payPalToken = intent.extras?.getString(KEY_PAYPAL_TOKEN) ?: error("Must provide PayPal client token")
        braintreeClient = BraintreeClient(this, payPalToken)
        payPalClient = PayPalClient(braintreeClient)
    }

    override fun onResume() {
        super.onResume()
        tokenizePayPalAccountVault()
    }

    override fun onNewIntent(intent: Intent?) {
        super.onNewIntent(intent)
        // required if your activity's launch mode is "singleTop", "singleTask", or "singleInstance"
        setIntent(intent)

        val browserSwitchResult = braintreeClient.deliverBrowserSwitchResult(this)
        if (browserSwitchResult != null && browserSwitchResult.requestCode == BraintreeRequestCodes.PAYPAL) {
            payPalClient.onBrowserSwitchResult(browserSwitchResult) { payPalAccountNonce, error ->
                if (payPalAccountNonce != null) {
                    val nonce: String = payPalAccountNonce.string
                    setSuccessResult(nonce)
                } else if (error != null) {
                    when (error) {
                        is UserCanceledException -> {
                            setErrorResult(error, cancelled = true)
                        }
                        else -> {
                            setErrorResult(error)
                        }
                    }
                }
            }
        }
    }

    private fun tokenizePayPalAccountVault() {
        if (didTryToTokenize) return
        didTryToTokenize = true
        val request = PayPalVaultRequest()
        request.billingAgreementDescription = "test"
        payPalClient.tokenizePayPalAccount(this, request) { error ->
            if (error != null) {
                setErrorResult(error)
            }
        }
    }

    private fun setSuccessResult(payPalAccountNonce: String) {
        val intent = Intent().apply {
            putExtra(KEY_RESULT_SUCCESS_NONCE, payPalAccountNonce)
        }
        goBack(RESULT_OK, intent)
    }

    private fun setErrorResult(error: Exception?, cancelled: Boolean = false) {
        val intent = Intent().apply {
            putExtra(KEY_RESULT_CANCELLED, cancelled)
        }
        goBack(RESULT_CANCELED, intent)
    }

    private fun goBack(resultCode: Int, intent: Intent) {
        setResult(resultCode, intent)
        finish()
    }

}

@mikkoville
Copy link
Author

Ah.. just realized my workaround above does not work for the simple case where user just decides to press back or X button in the embedded Chrome custom tabs since we dont get onNewIntent in that case and no callback for UserCanceledException...back to the drawing board 🤦

@kukadiajayesh
Copy link

The same issue happening in my case, paypal return this UserCanceledException any time. Even payment success it first return this exception then return success

@kukadiajayesh
Copy link

@mikkoville
This may be useful, I fixed it by doing some tweaks.

class PaypalActivity : AbstractBaseActivity() {

    private var braintreeClient: BraintreeClient? = null
    private lateinit var payPalClient: PayPalClient

    // Let's wait for paypal all responses
    private val RESPONCE_DELAY = 5L

    private val source = BehaviorSubject.create<Pair<Boolean, String?>>()
    private val compositeDisposable = CompositeDisposable()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_paypal)

        toolbar_app_name_txt.text = intent.getStringExtra(EXTRA_TITLE)
        toolbar.showBackArrow(this)

        compositeDisposable.add(
            source
                .toFlowable(BackpressureStrategy.LATEST)
                .debounce(RESPONCE_DELAY, TimeUnit.SECONDS)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe({
                    Timber.i("Success: $it")
                    setResult(it.first, it.second)
                }, {
                    it.printStackTrace()
                })
        )

        intent.getStringExtra(EXTRA_PAYPAL_TOKEN)?.let { token ->
            braintreeClient = BraintreeClient(this, token)
            payPalClient = PayPalClient(braintreeClient!!)

            initPayPalDropRequest()

        } ?: run {
            Timber.e("Payment cancelled")
            Toast.makeText(this, "Payment cancelled", Toast.LENGTH_LONG).show()
            source.onNext(false to null)
        }

    }

    override fun onResume() {
        super.onResume()
        braintreeClient?.let {
            val result = braintreeClient!!.deliverBrowserSwitchResult(this)
            result?.let { browserSwitchResult ->
                payPalClient.onBrowserSwitchResult(browserSwitchResult) { payPalAccountNonce, error ->
                    Timber.i("Success: result: $payPalAccountNonce $error")
                    /*if (payPalAccountNonce != null) setResult(true, payPalAccountNonce.string)
                    if (error != null) {
                        setResult(false, error.message)
                    }*/

                    if (payPalAccountNonce != null) {
                        source.onNext(true to payPalAccountNonce.string)
                    }
                    if (error != null) {
                        source.onNext(false to error.message)
                    }
                }
            }
        }
    }

    override fun onNewIntent(newIntent: Intent?) {
        super.onNewIntent(newIntent)
        intent = newIntent
    }

    private fun initPayPalDropRequest() {
        val total = intent.getDoubleExtra(EXTRA_TOTAL, 0.0)
        val request = PayPalCheckoutRequest(total.toString())
        request.currencyCode = PAYMENT_CURRENCY_CODE
        request.intent = PayPalPaymentIntent.AUTHORIZE

        payPalClient.tokenizePayPalAccount(this, request) { error ->
            //if (error != null) setResult(false, error.message)
            if (error != null) {
                source.onNext(false to error.message)
            }
        }
    }

    private fun setResult(success: Boolean, message: String?) {
        setResult(
            Activity.RESULT_OK,
            Intent().apply {
                putExtra(EXTRA_SUCCESS, success)
                putExtra(EXTRA_MESSAGE, message)
            }
        )
        finish()
    }

    override fun onDestroy() {
        super.onDestroy()
        compositeDisposable.dispose()
    }
}


@martinrehder
Copy link

martinrehder commented Apr 9, 2022

Has the UserCanceledException issue for Paypal been resolved? I've noticed that when the exception occurs, I can tap the 'Overview' button on the phone and see there is still a paypal activity on the stack , still spinning its progress indicator. In fact, there's an activity still running for the last several attempts I've made to use PayPal. Seems like the webview is not finishing before going back to the app to complete the purchase and therefore shows as canceled. I've attached a short video showing the paypal activities still running

user-canceled-exception.mp4

@9tilov
Copy link

9tilov commented May 23, 2022

Maybe you are using the wrong token?

@sshropshire
Copy link
Contributor

@ All thank you for providing this feedback on the Android SDK. If anyone is still experiencing this particular issue, I would first recommend upgrading to the latest version of the Android SDK.

If the issue still exists, please open another GitHub issue with steps on how we can reproduce with the most current SDK version.

@mikkoville
Copy link
Author

@sshropshire I have re opened this again since it seems to still happen quite a lot for our users:
#557

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants