Adyen / adyen-react-native

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

Android: 3DS redirect flow not triggered after redirect opens app back up again #334

Closed StefanWallin closed 8 months ago

StefanWallin commented 8 months ago

Describe the bug I've gotten payment method tokenization (because we use card on file to process payments in backend due to external events) to work in my app in iOS and android with the drop-in components. While pennytesting in beta we found out that we might need redirect-support in addition to 3DS2 native. So while implementing that I'm having some issues to get didProvide callback getting called when coming back to app from redirect challenge flow on android only.

What I've done: 1. Minimal reproduction repo I've tried to create a minimal reproduction repo but failed. So I'm wondering if you know of anything to look at to debug the issue that the DropInActivity is not binding back when resuming the app via link-open.

2. Separate launch activitiy I've tried to implement this separate launch activity mentioned by pinpong in #163 without success.

3. Double and triple checked

4. Test installing expo modules I thought expo modules might be my bad guy because it lifecycle handlers injects:

// in onCreate()
ApplicationLifecycleDispatcher.onApplicationCreate(this);
// in onConfigurationChanged()
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig);

5. Test rerender components My component rerenders, but that's covered by useCallbacks and caching paymentMethods

None of these things solve it in my production app or makes the problem reproducible in my reproduction repo.

To Reproduce

Annoyingly I'm not able to reproduce the issue outside my app.

Steps to reproduce the behavior:

  1. git clone https://github.com/StefanWallin/AdyenAndroidRedirectBugRepro/ (see commits for detailed changes)
  2. cd AdyenAndroidRedirectBugRepro
  3. npm install
  4. npm run android
  5. Press key a in console to start android on emulator
  6. Configure src/Configuration.js
  7. Reload app
  8. Press ADVANCED CASE
  9. Press DROP IN
  10. Select card and enter any card details, for example 5454 5454 5454 5454, 330/737, 1234
  11. Press pay-button
  12. Redirect pre-page displays, respond to challenge in UI

Expected behavior I expect to see these loglines in my app. But for some reason I don't which makes it such that we never get the intent fired and thus no didProvide callback causing the payment to fail.

bindService - DropInActivity
onNewIntent
handleIntent: action - android.intent.action.VIEW

Screenshots Here below are two logcats, from my reproduction app attempt and from our app.

Screenshot 2024-01-10 at 15 47 48

In the image above the part within the turqoise box and above is very similar between my test-apps, but the purple box does not show up on my real app.

Smartphone:

Additional context Add any other context about the problem here.

My PSP references are listed in case 00064305 with adyen support

StefanWallin commented 8 months ago

Thought I might as well add my relevant files so that there are no questions about those taking time:

MainActivity.java ```java package se.myapp.app; import android.content.Intent; import android.os.Bundle; import com.facebook.react.ReactActivity; import org.devio.rn.splashscreen.SplashScreen; import expo.modules.ReactActivityDelegateWrapper; import com.adyenreactnativesdk.AdyenCheckout; import com.facebook.react.ReactActivityDelegate; import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint; import com.facebook.react.defaults.DefaultReactActivityDelegate; public class MainActivity extends ReactActivity { /** * Returns the instance of the {@link ReactActivityDelegate}. Here we use a util class {@link * DefaultReactActivityDelegate} which allows you to easily enable Fabric and Concurrent React * (aka React 18) with two boolean flags. */ @Override protected String getMainComponentName() { return "myapp"; } @Override protected void onCreate(Bundle savedInstanceState) { SplashScreen.show(this); super.onCreate(null); // See commit message for details, also https://github.com/Adyen/adyen-react-native/pull/237 AdyenCheckout.setLauncherActivity(this); } @Override public void onNewIntent(Intent intent) { super.onNewIntent(intent); AdyenCheckout.handleIntent(intent); } //To enable GooglePay, onActivityResult() must be overridden and call AdyenCheckout.handleActivityResult as below: @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); AdyenCheckout.handleActivityResult(requestCode, resultCode, data); } /** * Returns the instance of the {@link ReactActivityDelegate}. There the RootView is created and * you can specify the renderer you wish to use - the new renderer (Fabric) or the old renderer * (Paper). */ @Override protected ReactActivityDelegate createReactActivityDelegate() { return new ReactActivityDelegateWrapper(this, BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, new DefaultReactActivityDelegate( this, getMainComponentName(), // If you opted-in for the New Architecture, we enable the Fabric Renderer. DefaultNewArchitectureEntryPoint.getFabricEnabled() )); } } ```
MainApplication.java ```java package se.myapp.app; import android.content.res.Configuration; import expo.modules.ApplicationLifecycleDispatcher; import expo.modules.ReactNativeHostWrapper; import android.app.Application; import com.facebook.react.PackageList; import com.facebook.react.ReactApplication; import com.facebook.react.ReactNativeHost; import com.facebook.react.ReactPackage; import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint; import com.facebook.react.defaults.DefaultReactNativeHost; import com.facebook.soloader.SoLoader; import java.util.List; public class MainApplication extends Application implements ReactApplication { private final ReactNativeHost mReactNativeHost = new ReactNativeHostWrapper(this, new DefaultReactNativeHost(this) { @Override public boolean getUseDeveloperSupport() { return BuildConfig.DEBUG; } @Override protected List getPackages() { @SuppressWarnings("UnnecessaryLocalVariable") List packages = new PackageList(this).getPackages(); // Packages that cannot be autolinked yet can be added manually here, for example: // packages.add(new MyReactNativePackage()); return packages; } @Override protected String getJSMainModuleName() { return "index"; } @Override protected boolean isNewArchEnabled() { return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; } @Override protected Boolean isHermesEnabled() { return BuildConfig.IS_HERMES_ENABLED; } }); @Override public ReactNativeHost getReactNativeHost() { return mReactNativeHost; } @Override public void onCreate() { super.onCreate(); SoLoader.init(this, /* native exopackage */ false); if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { // If you opted-in for the New Architecture, we load the native entry point for this app. DefaultNewArchitectureEntryPoint.load(); } ReactNativeFlipper.initializeFlipper(this, getReactNativeHost().getReactInstanceManager()); ApplicationLifecycleDispatcher.onApplicationCreate(this); } @Override public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig); } } ```
android/app/build.gradle ```gradle apply plugin: 'com.google.gms.google-services' apply plugin: "com.android.application" apply plugin: "com.facebook.react" apply plugin: 'com.google.firebase.crashlytics' apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle" project.ext.vectoricons = [ iconFontNames: [ 'MaterialIcons.ttf', 'MaterialCommunityIcons.ttf' ] // Name of the font files you want to copy ] apply from: "../../node_modules/react-native-vector-icons/fonts.gradle" apply from: '../versioning.gradle' react { } def enableProguardInReleaseBuilds = true def jscFlavor = 'org.webkit:android-jsc:+' android { ndkVersion rootProject.ext.ndkVersion compileSdkVersion rootProject.ext.compileSdkVersion namespace "se.myapp.app" defaultConfig { applicationId "se.myapp.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode (System.env.VERSION_CODE ? Integer.parseInt(System.env.VERSION_CODE) : 1) versionName System.env.VERSION_NAME def properties = new Properties() file("../local.properties").withInputStream { properties.load(it) } manifestPlaceholders = [ MAPS_API_KEY:"${properties.getProperty('MAPS_API_KEY')}", redirectScheme: rootProject.ext.adyenReactNativeRedirectScheme ] } signingConfigs { debug { storeFile file('debug.keystore') storePassword 'android' keyAlias 'androiddebugkey' keyPassword 'android' } release { storeFile file('my-upload-key.keystore') storePassword "${System.env.SIGNING_STORE_PASSWORD}" keyAlias 'my-key-alias' keyPassword "${System.env.ALIAS_KEY_PASSWORD}" } } buildTypes { debug { signingConfig signingConfigs.debug } release { signingConfig signingConfigs.release minifyEnabled enableProguardInReleaseBuilds shrinkResources true proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" debuggable false ndk { // Note: There is a 300 MB limit for the native debug symbols file. // If your debug symbols footprint is too large, use SYMBOL_TABLE // instead of FULL to decrease the file size. // Ref: https://developer.android.com/build/shrink-code#native-crash-support debugSymbolLevel 'FULL' } } } } dependencies { // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") { exclude group:'com.squareup.okhttp3', module:'okhttp' } debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") if (hermesEnabled.toBoolean()) { implementation("com.facebook.react:hermes-android") } else { implementation jscFlavor } } apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project) ```
AndroidManifest.xml ```xml ```
package.json ```json { "name": "myapp", "version": "0.0.2", "private": true, "scripts": { "folderTest": "test -e app.json && echo OK || `echo 'Only allowed to run in the react native root folder'; exit 1`", "clean": "npm run folderTest && rm -rf yarn.lock package-lock.json node_modules ios/Pods ios/Podfile.lock android/app/build && npm install && cd ios && pod update && cd .. && watchman watch-del `pwd` && watchman watch-project `pwd`", "android": "react-native run-android", "android-prod": "npm run folderTest && ENVS_TO_INCLUDE='REACT_APP_API_URL' ./scripts/generate_env_file.sh .env-generated && ENVFILE=.env-generated react-native run-android --variant=release --no-packager", "ios": "react-native run-ios", "ios-prod": "npm run folderTest && ENVS_TO_INCLUDE='REACT_APP_API_URL' ./scripts/generate_env_file.sh .env-generated && ENVFILE=.env-generated react-native run-ios --mode=Release --no-packager --device \"Cherry\"", "start": "react-native start --resetCache", "dev": "npm run start", "test": "jest", "lint": "eslint . --ext .js,.jsx,.ts,.tsx", "e2e": "maestro test .maestro/main.yaml", "adb": "adb reverse tcp:8081 tcp:8081 && adb reverse tcp:9090 tcp:9090 && adb reverse tcp:3001 tcp:3001", "adb-menu": "adb shell input keyevent 82", "adb-kill": "adb shell am force-stop se.myapp.app", "adb-night-on": "adb shell 'cmd uimode night yes'", "adb-night-off": "adb shell 'cmd uimode night no'", "postinstall": "npx pod-install" }, "engines": { "node": "18.*", "npm": "9.6.3" }, "dependencies": { "@adyen/react-native": "^1.2.0", "@hookform/resolvers": "^3.1.0", "@react-native-clipboard/clipboard": "^1.12.1", "@react-native-community/netinfo": "^9.3.7", "@react-native-firebase/app": "^18.6.1", "@react-native-firebase/app-check": "^18.6.1", "@react-native-firebase/crashlytics": "^18.6.1", "@react-native-firebase/messaging": "^18.6.1", "@react-native-masked-view/masked-view": "^0.2.9", "@react-navigation/bottom-tabs": "6.5.3", "@react-navigation/material-bottom-tabs": "6.2.11", "@react-navigation/native": "^6.1.2", "@react-navigation/native-stack": "^6.9.12", "@react-navigation/stack": "^6.3.11", "@tanstack/react-query": "4.22.0", "@types/luxon": "^3.3.2", "axios": "1.2.3", "currency.js": "^2.0.4", "expo": "^49.0.0", "expo-barcode-scanner": "~12.5.3", "expo-camera": "~13.4.2", "expo-constants": "~14.4.2", "expo-linear-gradient": "^12.5.0", "libphonenumber-js": "^1.10.24", "luxon": "^3.4.3", "mobx": "^6.10.2", "mobx-react-lite": "^4.0.4", "mobx-state-tree": "^5.2.0", "nanoid": "^4.0.2", "react": "18.2.0", "react-hook-form": "^7.43.9", "react-native": "^0.72.9", "react-native-config": "1.5.1", "react-native-country-flag": "^2.0.2", "react-native-device-info": "^10.3.0", "react-native-emoji": "^1.8.0", "react-native-geolocation-service": "5.3.1", "react-native-gesture-handler": "^2.12.0", "react-native-get-random-values": "^1.8.0", "react-native-keyboard-aware-scroll-view": "^0.9.5", "react-native-localization": "2.3.1", "react-native-localize": "2.2.4", "react-native-map-clustering": "3.4.2", "react-native-maps": "2.0.0-beta.14", "react-native-mmkv": "^2.10.1", "react-native-otp-verify": "^1.1.6", "react-native-paper": "5.8.0", "react-native-permissions": "3.6.1", "react-native-restart": "^0.0.27", "react-native-safe-area-context": ">=4.5.3 <4.8", "react-native-screens": "^3.21.0", "react-native-splash-screen": "^3.3.0", "react-native-svg": "^13.14.0", "react-native-svg-transformer": "^1.0.0", "react-native-vector-icons": "9.2.0", "react-native-webview": "^13.3.1", "uuid": "^9.0.0", "zod": "3.21.4" }, "devDependencies": { "@babel/core": "7.20.12", "@babel/runtime": "7.20.7", "@react-native/eslint-config": "^0.72.2", "@react-native/metro-config": "^0.72.11", "@tsconfig/react-native": "^3.0.0", "@types/jest": "29.2.1", "@types/react": "18.0.27", "@types/react-native": "0.70.9", "@types/react-native-vector-icons": "6.4.13", "@types/react-test-renderer": "18.0.0", "@types/uuid": "9.0.0", "@typescript-eslint/eslint-plugin": "^5.45.1", "@typescript-eslint/parser": "^5.45.1", "babel-jest": "29.2.1", "babel-plugin-module-resolver": "^5.0.0", "eslint": "8.37.0", "eslint-plugin-import": "^2.27.5", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.31.11", "eslint-plugin-react-hooks": "4.6.0", "fishery": "^2.2.2", "jest": "29.3.1", "metro-react-native-babel-preset": "0.76.8", "nock": "13.3.0", "prettier": "2.8.3", "reactotron-react-native": "^5.0.4", "reactotron-mst": "^3.1.4", "reactotron-react-native-mmkv": "^0.1.3", "reactotron-react-query": "^1.0.2", "typescript": "^5.1.9" } } ```
descorp commented 8 months ago

Hey @StefanWallin

Unfortunately logs screenshot is not clickable (odd GitHub behaviour). Could you attach it or add as text?


I see that in your repo's package.json you are pointing to

"@adyen/react-native": "1.2.0",

But code snippets above is develop branch (a.k.a v2.0.0 BETA). It should not be the problem, since "advanced flow" on v2 expected to be compatible with "default" v1 approach.


git clone https://github.com/StefanWallin/AdyenAndroidRedirectBugRepro/ (see commits for detailed changes)

I also wan't able to reproduce this behaviour with repo above 😅

StefanWallin commented 8 months ago

I found the issue, it was unclear from documentation on this packages README that the returnURL value must come from the callback data, we had it passed as it is defined for ios to the payments endpoint.

https://github.com/StefanWallin/AdyenAndroidRedirectBugRepro/commit/a40c85a4523ff8e97d05980ce111b966bf527f25

So I guess the resolution to this bug is better readme 🤣

StefanWallin commented 8 months ago

Hey @StefanWallin

Unfortunately logs screenshot is not clickable (odd GitHub behaviour). Could you attach it or add as text?

Sure!

Screenshot 2024-01-10 at 15 47 48
descorp commented 8 months ago

Hey @StefanWallin

Thanks for feedback! We will improve our documentation 💚

the returnURL value must come from the callback data

Indeed. On Android, DropIn is launched as an Activity with its own Intent Filter. This intent filter's scheme is calculated automatically per app to improve security and avoid collisions with other apps using Adyen SDK. To handle redirect back properly we provide the correct returnURL in PaymentMethodData.returnURL based on the chosen payment method.

Also, under the hood, we are using DropIn to power some of the Components. This can lead to a situation when within one integration merchant will need to provide one of two return URLs to Adyen API. Relying on PaymentMethodData.returnURL allows your backend to stay mobile-platform-agnostic :)

descorp commented 8 months ago

Thanks again @StefanWallin

I am going to close this one. Feel free to re-open or continue any further discussions