Adyen / adyen-react-native

Adyen React Native
https://docs.adyen.com/checkout
MIT License
41 stars 32 forks source link

[Android] Activity single task. Continuing payment from launcher kills DropIn Activity. #163

Open Cattari opened 1 year ago

Cattari commented 1 year ago

Hello, guys!

We are facing a problem with our custom bridge solution for RN + adyen-android module (the same version you use here). That RN uses singleTask launch mode for the MainActivity. In that case, if a user is in the middle of the payment and goes back to the app from the launcher - it kills the DropInActivity and user doesn't finish the payment. It became very painful after Adyen Checkout API v68 when a user is charged right after they finish a transaction on the banking app.

Several issues are also reported in adyen drop-in and adyen 3ds2 component repositories, but they are closed. Here are they:

https://github.com/Adyen/adyen-android/issues/711#issuecomment-1142319516 https://github.com/Adyen/adyen-3ds2-android/issues/17

To Reproduce

Reproduces only for Android.

Steps to reproduce the behavior:

  1. Start DropIn
  2. Start payment by card for example. Enter 5201 2895 0084 3268 3ds2 card from https://docs.adyen.com/development-resources/testing/test-card-numbers
  3. Receive a confirmation window that requires you to move app to the background and then back again to foreground.
  4. Go back to the app via the app launcher.

Expected behavior

  1. User sees the DropIn.

Actual behavior

  1. User sees the MainActivity, DropIn is killed.

Smartphone (please complete the following information):

Additional context We want to know if you handled the mentioned issue in this solution for RN. Because it's directly connected to launchMode=singleTask which is used in RN.

descorp commented 1 year ago

Hey @Cattari

Thanks for reaching out!

We are aware of this unfortunate behavior. None of our tests and attempts to resolve it were fruitful. This is not a problem for native Android but only affects React Native. The only reference to the problem we were able to find is not fruitful either.

For now, the only advice we have - is always to be prepared for the worst. In LIVE transactions the "happy flow" with successful redirect may not occur due to numerous factors on both iOS and Android (even on web). Always treat your transactions as "pending" until webhook with the actual status received.

For example, you can keep track of a payment state: store the flag when 'onSubmit' is called and when your App leaves the foreground, so when the next time the App is open, no redirect is detected and the flag is set - there is a high chance a payment was made and your backend is the only source of truth now.

pinpong commented 1 year ago

Indeed this is another blocker.

@Cattari can you please try poc

We need to check if there are some unknown unwanted behaviors e.g. google pay, google login etc.

Cattari commented 1 year ago

hi @pinpong yeah, we actually tried that before a long time ago it solves the issue with the payment flow, but it brings a new issue

if universal/deep links are used, they start a new instance (a copy) of the app, and we didn't manage to avoid that. unfortunately, for us it's an unacceptable behaviour :(

momentiris commented 5 months ago

We are facing the same issue. Do you have a proposed workaround?

Thanks

StefanWallin commented 5 months ago

We are aware of this unfortunate behavior. None of our tests and attempts to resolve it were fruitful. This is not a problem for native Android but only affects React Native. The only reference to the problem we were able to find is not fruitful either.

How about if react-native-adyen tried to serialize it's internal state before handing of, and then when resuming restoring from the serialized state or offering the implementing app to try and resume the checkout session?

For now, the only advice we have - is always to be prepared for the worst. In LIVE transactions the "happy flow" with successful redirect may not occur due to numerous factors on both iOS and Android (even on web). Always treat your transactions as "pending" until webhook with the actual status received.

For example, you can keep track of a payment state: store the flag when 'onSubmit' is called and when your App leaves the foreground, so when the next time the App is open, no redirect is detected and the flag is set - there is a high chance a payment was made and your backend is the only source of truth now.

I don't see how Webhooks can solve the fact that for a 3DS OOB challenge, additionalDetails cannot be submitted so our offers are "stuck" in an "open" state.

descorp commented 5 months ago

@StefanWallin

You have posted previously:

A potential workaround would be for us to store data from our api-request in the onSubmit-callback in local storage, then on component rerender upon activity restart, check for that data and somehow be able to call nativeComponent.handle(data), but we don't have access to nativeComponent in that scope.

To be able to do that we would need to be able to check if the adyen drop-in component is active so as to not trigger multiple nativeComponent.handle(data).

in v2 BETA it is possible to call AdyenAction.handle(data) independently from the payment flow. Maybe this will be a workaround 🤔

StefanWallin commented 5 months ago

@descorp Would you consider v2 BETA production ready yet?

StefanWallin commented 5 months ago

And also, my suggestion above is based on the assumption that the adyen-library can resume the session and call the onAdditionalDetails-callback even though it did not receive any additionalDetails from redirectUri, or display the challenge page again (which might have a button from issuer that triggers additionalDetails)

descorp commented 5 months ago

@StefanWallin

We expect it to be stable enough for existing merchant, but can't officially call it one, until we have several brave pilot merchants to go LIVE with it.

It will be published to NPM this week, so you can give it a go.

StefanWallin commented 5 months ago

So after two long days (involving RN 0.73-upgrade with kotlin-migration repercussions) I'm finally at a stage to try this out.

I have managed to narrow down this to happen in the correct moment on the right platform, might have som false positives still, but when I do the AdyenAction.handle(paymentAction) fails and crashes the android app. I might be missing something here.

const onAppStateChange = useCallback(
    (appState: AppStateStatus) => {
      const storedpaymentData = Storage.loadString(TEMP_PAYMENT_DATA_KEY)

      const active = appState === 'active'
      const midProcess = status === 'awaiting3DSecure' && storedpaymentData
      const android = Platform.OS === 'android'

      if (active && midProcess && android) {
        const paymentAction: PaymentAction = JSON.parse(storedpaymentData)
        AdyenAction.handle(paymentAction, adyenConfiguration)
      }
    },
    [status]
  )

The contents of paymentAction is the same that I in my onSubmit-handler in the normal case use to call nativeComponent.handle(paymentAction) with. It's contents look like this:

{
  "paymentData": "Ab0...",
  "paymentMethodType": "scheme",
  "authorisationToken": "Ab0...",
  "subtype": "fingerprint",
  "token": "eyJ...",
  "type": "threeDS2"
}

Am I missing some detail on how you think this could end up working for resuming a 3DS challenge session?

StefanWallin commented 5 months ago

Missed the StackTrace:

E  FATAL EXCEPTION: main
    Process: se.myapp.app, PID: 4266
    android.view.InflateException: Binary XML file line #53 in se.myapp.app:layout/view_payment_in_progress: Binary XML file line #53 in se.myapp.app:layout/view_payment_in_progress: Error inflating class <unknown>
        Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@18f1660, Dispatchers.Main.immediate]
    Caused by: android.view.InflateException: Binary XML file line #53 in se.myapp.app:layout/view_payment_in_progress: Error inflating class <unknown>
    Caused by: java.lang.reflect.InvocationTargetException
        at java.lang.reflect.Constructor.newInstance0(Native Method)
        at java.lang.reflect.Constructor.newInstance(Constructor.java:343)
        at android.view.LayoutInflater.createView(LayoutInflater.java:852)
        at android.view.LayoutInflater.createViewFromTag(LayoutInflater.java:1004)
        at android.view.LayoutInflater.createViewFromTag(LayoutInflater.java:959)
        at android.view.LayoutInflater.rInflate(LayoutInflater.java:1121)
        at android.view.LayoutInflater.inflate(LayoutInflater.java:654)
        at android.view.LayoutInflater.inflate(LayoutInflater.java:532)
        at android.view.LayoutInflater.inflate(LayoutInflater.java:479)
        at com.adyen.checkout.ui.core.databinding.ViewPaymentInProgressBinding.inflate(ViewPaymentInProgressBinding.java:58)
        at com.adyen.checkout.ui.core.internal.ui.view.PaymentInProgressView.<init>(PaymentInProgressView.kt:39)
        at com.adyen.checkout.ui.core.internal.ui.view.PaymentInProgressView.<init>(PaymentInProgressView.kt:27)
        at com.adyen.checkout.adyen3ds2.internal.ui.Adyen3DS2ViewProvider.getView(Adyen3DS2ViewProvider.kt:23)
        at com.adyen.checkout.ui.core.AdyenComponentView.loadView(AdyenComponentView.kt:121)
        at com.adyen.checkout.ui.core.AdyenComponentView.access$loadView(AdyenComponentView.kt:48)
        at com.adyen.checkout.ui.core.AdyenComponentView$attach$1.invokeSuspend(AdyenComponentView.kt:104)
        at com.adyen.checkout.ui.core.AdyenComponentView$attach$1.invoke(Unknown Source:8)
        at com.adyen.checkout.ui.core.AdyenComponentView$attach$1.invoke(Unknown Source:4)
        at kotlinx.coroutines.flow.FlowKt__TransformKt$onEach$$inlined$unsafeTransform$1$2.emit(Emitters.kt:223)
        at kotlinx.coroutines.flow.StateFlowImpl.collect(StateFlow.kt:396)
        at kotlinx.coroutines.flow.StateFlowImpl$collect$1.invokeSuspend(Unknown Source:15)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
        at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
        at kotlinx.coroutines.EventLoop.processUnconfinedEvent(EventLoop.common.kt:68)
        at kotlinx.coroutines.internal.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:373)
        at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:30)
        at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable$default(Cancellable.kt:25)
        at kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:110)
        at kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:126)
        at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch(Builders.common.kt:56)
        at kotlinx.coroutines.BuildersKt.launch(Unknown Source:1)
        at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch$default(Builders.common.kt:47)
        at kotlinx.coroutines.BuildersKt.launch$default(Unknown Source:1)
        at kotlinx.coroutines.flow.FlowKt__CollectKt.launchIn(Collect.kt:49)
        at kotlinx.coroutines.flow.FlowKt.launchIn(Unknown Source:1)
        at com.adyen.checkout.action.core.internal.ui.DefaultGenericActionDelegate.observeViewFlow(DefaultGenericActionDelegate.kt:152)
        at com.adyen.checkout.action.core.internal.ui.DefaultGenericActionDelegate.handleAction(DefaultGenericActionDelegate.kt:122)
        at com.adyen.checkout.action.core.internal.DefaultActionHandlingComponent.handleAction(DefaultActionHandlingComponent.kt:35)
        at com.adyen.checkout.action.core.GenericActionComponent.handleAction(Unknown Source:12)
E   at com.adyenreactnativesdk.cse.ActionFragment.handle(ActionFragment.kt:64)
        at com.adyenreactnativesdk.cse.ActionFragment.setupComponent(ActionFragment.kt:58)
        at com.adyenreactnativesdk.cse.ActionFragment.onStart(ActionFragment.kt:40)
        at androidx.fragment.app.Fragment.performStart(Fragment.java:3187)
        at androidx.fragment.app.FragmentStateManager.start(FragmentStateManager.java:628)
        at androidx.fragment.app.FragmentStateManager.moveToExpectedState(FragmentStateManager.java:290)
        at androidx.fragment.app.FragmentManager.executeOpsTogether(FragmentManager.java:1943)
        at androidx.fragment.app.FragmentManager.removeRedundantOperationsAndExecute(FragmentManager.java:1845)
        at androidx.fragment.app.FragmentManager.execPendingActions(FragmentManager.java:1782)
        at androidx.fragment.app.FragmentManager$5.run(FragmentManager.java:565)
        at android.os.Handler.handleCallback(Handler.java:938)
        at android.os.Handler.dispatchMessage(Handler.java:99)
        at android.os.Looper.loop(Looper.java:223)
        at android.app.ActivityThread.main(ActivityThread.java:7656)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
    Caused by: java.lang.IllegalArgumentException: The style on this component requires your app theme to be Theme.MaterialComponents (or a descendant).
        at com.google.android.material.internal.ThemeEnforcement.checkTheme(ThemeEnforcement.java:247)
        at com.google.android.material.internal.ThemeEnforcement.checkMaterialTheme(ThemeEnforcement.java:216)
        at com.google.android.material.internal.ThemeEnforcement.checkCompatibleTheme(ThemeEnforcement.java:144)
        at com.google.android.material.internal.ThemeEnforcement.obtainStyledAttributes(ThemeEnforcement.java:76)
        at com.google.android.material.button.MaterialButton.<init>(MaterialButton.java:234)
        at com.google.android.material.button.MaterialButton.<init>(MaterialButton.java:225)
        ... 56 more
StefanWallin commented 5 months ago

Okay, I solved this style issue with changing the parent of the style my android-app uses, like this:

<resources>
    <!-- Base application theme. -->
-    <style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
+    <style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
        <!-- Customize your theme here. -->
        <item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
        <item name="android:windowIsTranslucent">true</item>
    </style>
</resources>

Now, when I try again, I've changed the code to look like this..

const onAppStateChange = useCallback(
    (appState: AppStateStatus) => {
      const storedpaymentData = Storage.loadString(TEMP_PAYMENT_DATA_KEY)

      const active = appState === 'active'
      const midProcess = status === 'awaiting3DSecure' && storedpaymentData
      const android = Platform.OS === 'android'

      if (active && midProcess && android) {
        const paymentAction: PaymentAction = JSON.parse(storedpaymentData)

        try {
          const data = await AdyenAction.handle(
            paymentAction,
            adyenConfiguration
          )
          console.log('DATA', data)
        } catch (error) {
          console.log('ERROR', JSON.stringify(error, null, 2))
          AdyenAction.hide(false)
        }
      }
    },
    [status]
  )

So this enters the catch-clause which spits the following into the terminal:

ERROR {
  "nativeStackAndroid": [
    {
      "lineNumber": 298,
      "file": "DefaultAdyen3DS2Delegate.kt",
      "methodName": "submitFingerprintAutomatically",
      "class": "com.adyen.checkout.adyen3ds2.internal.ui.DefaultAdyen3DS2Delegate"
    },
    {
      "lineNumber": 67,
      "file": "DefaultAdyen3DS2Delegate.kt",
      "methodName": "access$submitFingerprintAutomatically",
      "class": "com.adyen.checkout.adyen3ds2.internal.ui.DefaultAdyen3DS2Delegate"
    },
    {
      "lineNumber": 15,
      "file": null,
      "methodName": "invokeSuspend",
      "class": "com.adyen.checkout.adyen3ds2.internal.ui.DefaultAdyen3DS2Delegate$submitFingerprintAutomatically$1"
    },
    {
      "lineNumber": 33,
      "file": "ContinuationImpl.kt",
      "methodName": "resumeWith",
      "class": "kotlin.coroutines.jvm.internal.BaseContinuationImpl"
    },
    {
      "lineNumber": 104,
      "file": "DispatchedTask.kt",
      "methodName": "run",
      "class": "kotlinx.coroutines.DispatchedTask"
    },
    {
      "lineNumber": 584,
      "file": "CoroutineScheduler.kt",
      "methodName": "runSafely",
      "class": "kotlinx.coroutines.scheduling.CoroutineScheduler"
    },
    {
      "lineNumber": 793,
      "file": "CoroutineScheduler.kt",
      "methodName": "executeTask",
      "class": "kotlinx.coroutines.scheduling.CoroutineScheduler$Worker"
    },
    {
      "lineNumber": 697,
      "file": "CoroutineScheduler.kt",
      "methodName": "runWorker",
      "class": "kotlinx.coroutines.scheduling.CoroutineScheduler$Worker"
    },
    {
      "lineNumber": 684,
      "file": "CoroutineScheduler.kt",
      "methodName": "run",
      "class": "kotlinx.coroutines.scheduling.CoroutineScheduler$Worker"
    }
  ],
  "userInfo": null,
  "message": "Unable to submit fingerprint",
  "code": "actionError"
}

Other notes

https://github.com/Adyen/adyen-react-native/assets/457653/5b361179-6367-455d-b33c-9d0f50487200

One effect of this is that you have to know to hit the blue key on the keyboard to go to the next field instead of clicking the next field.

StefanWallin commented 4 months ago

@descorp If it wasn't clear I'm at the end of my understanding here. Is it supposed to be possible to resume a 3DS-challenge in this way?

descorp commented 4 months ago

Hey @StefanWallin

Am I missing some detail on how you think this could end up working for resuming a 3DS challenge session?

Sorry you have to go thought all of this research all by yourself 💚

This approach would perfectly works with redirect action, but for Native 3DS - it is not supported out of the box. Each 3DS transaction is unique and when you use the previous action - our backend returns an error cause this request was already made.

There was potential workaround on API v66, I am verifying if it is possible to do on modern v69+ versions..


I confirmed that "old way" is possible on any modern API version. I'll do some tests locally and will get back to you if it turns out to be successful.

StefanWallin commented 4 months ago

How did your testing go?

descorp commented 4 months ago

Hey @StefanWallin

Unfortunately it is not possible. 3DS flow is "one-time" thing by design. The only 100% solution here is to rely on web-hooks.

StefanWallin commented 4 months ago

@descorp, okay. that's unfortunate but understandable. I guess we might have to keep poking at this from the activity-perspective.

  1. It's unclear to us how web-hooks would help our case out here since the data from the 3DS-verification will not be passed to us. Can you explain in more detail what web-hook we should look at to get this working?
  2. Another thing that could potentially solve this as I undestand, would be to enable adyen-react-native to pass along the configuration parameter configuration.actionComponent.threeDS.requestorAppURL which needs to be a universal link if I understand it correctly. Will track this separately in issue #374
descorp commented 4 months ago

Hey @StefanWallin

1) Web-hook is an ultimate source of truth about transaction state. Rule of thumb here - always consider the transaction as "pending" until it is concluded by the shopper (happy flow, canceled, component error) or webhook(broken redirect, delays, "icon click" or any other unexpected behavior). This means that your UI should be transparent with shoppers about the current status. It could be helpful to check with the shopper about their experience - whether they completed the payment or not.

2) requestorAppURL is just an alternative for "Custom URL Scheme" to return to the app - in latest 3DS2 specification only "https" protocol is allowed. It have the same problems, since shopper still can abandon payment flow or redirect can be broken.

descorp commented 4 months ago

The good news is that now we have the ability to setOnRedirectListener on the Native side to notify about shopper being redirected to an external app/browser.

This callback for ReactNative code is in our backlog.

stuartmcvean commented 2 months ago

Is there any workaround for this? Does this mean that for Android there is no way to successfully make a payment with 3DS2.2 App<>App redirection? So you get redirected from our apps 3DS screen to the authenticator app (banking app) then when they are redirected back to our app with a deeplink, they will be unable to finish the payment?

descorp commented 1 month ago

@stuartmcvean

On v2.0.0-rc1 we have provided threeDS2.requestorAppUrl configuration that should help with this. Keep in mind that this feature depend on issuer bank and platform.

We are interested in any feedback you may have on this to improve the flow.