From af65ddd479d6d677a0fc5e326fcc1cd566f712a4 Mon Sep 17 00:00:00 2001 From: adyen-git-manager Date: Mon, 11 Mar 2019 14:47:54 +0100 Subject: [PATCH] 2.4.0 --- README.md | 4 +- build.gradle | 12 +- .../checkout/base/internal/Base64Coder.java | 97 +++++++ .../checkout/base/internal/JsonDecodable.java | 33 +++ .../checkout/base/internal/JsonEncodable.java | 31 ++ .../checkout/base/internal/JsonObject.java | 2 +- .../checkout/core/AuthenticationDetails.java | 52 ++++ .../adyen/checkout/core/PaymentHandler.java | 18 ++ .../core/handler/AuthenticationHandler.java | 22 ++ .../core/internal/AuthenticationManager.java | 26 ++ .../core/internal/PaymentHandlerImpl.java | 57 +++- .../model/ChallengeAuthentication.java | 39 +++ .../internal/model/DeviceFingerprint.java | 10 +- .../model/FingerprintAuthentication.java | 39 +++ .../model/PaymentInitiationResponse.java | 135 ++++++++- .../internal/model/PaymentSessionImpl.java | 18 +- .../checkout/core/model/Authentication.java | 15 + .../checkout/core/model/ChallengeDetails.java | 57 ++++ .../core/model/FingerprintDetails.java | 57 ++++ .../core/model/PaymentResultCode.java | 10 + checkout-threeds/.gitignore | 1 + checkout-threeds/build.gradle | 36 +++ checkout-threeds/proguard-rules.pro | 21 ++ checkout-threeds/src/main/AndroidManifest.xml | 2 + .../threeds/Card3DS2Authenticator.java | 272 ++++++++++++++++++ .../checkout/threeds/ChallengeResult.java | 25 ++ .../checkout/threeds/ThreeDS2Exception.java | 32 +++ .../threeds/internal/ChallengeResultImpl.java | 59 ++++ .../threeds/internal/model/Challenge.java | 87 ++++++ .../threeds/internal/model/Fingerprint.java | 44 +++ .../internal/model/FingerprintToken.java | 57 ++++ checkout-ui/build.gradle | 1 + .../ui/internal/card/CardDetailsActivity.java | 79 ++++- config/lint/lint.xml | 3 + example-app/build.gradle | 9 +- .../androidtest/auto/AutoPaymentAppTest.java | 80 ++++++ gradle/wrapper/gradle-wrapper.properties | 2 +- settings.gradle | 1 + 38 files changed, 1502 insertions(+), 43 deletions(-) create mode 100644 checkout-base/src/main/java/com/adyen/checkout/base/internal/Base64Coder.java create mode 100644 checkout-base/src/main/java/com/adyen/checkout/base/internal/JsonDecodable.java create mode 100644 checkout-base/src/main/java/com/adyen/checkout/base/internal/JsonEncodable.java create mode 100644 checkout-core/src/main/java/com/adyen/checkout/core/AuthenticationDetails.java create mode 100644 checkout-core/src/main/java/com/adyen/checkout/core/handler/AuthenticationHandler.java create mode 100644 checkout-core/src/main/java/com/adyen/checkout/core/internal/AuthenticationManager.java create mode 100644 checkout-core/src/main/java/com/adyen/checkout/core/internal/model/ChallengeAuthentication.java create mode 100644 checkout-core/src/main/java/com/adyen/checkout/core/internal/model/FingerprintAuthentication.java create mode 100644 checkout-core/src/main/java/com/adyen/checkout/core/model/Authentication.java create mode 100644 checkout-core/src/main/java/com/adyen/checkout/core/model/ChallengeDetails.java create mode 100644 checkout-core/src/main/java/com/adyen/checkout/core/model/FingerprintDetails.java create mode 100644 checkout-threeds/.gitignore create mode 100644 checkout-threeds/build.gradle create mode 100644 checkout-threeds/proguard-rules.pro create mode 100644 checkout-threeds/src/main/AndroidManifest.xml create mode 100644 checkout-threeds/src/main/java/com/adyen/checkout/threeds/Card3DS2Authenticator.java create mode 100644 checkout-threeds/src/main/java/com/adyen/checkout/threeds/ChallengeResult.java create mode 100644 checkout-threeds/src/main/java/com/adyen/checkout/threeds/ThreeDS2Exception.java create mode 100644 checkout-threeds/src/main/java/com/adyen/checkout/threeds/internal/ChallengeResultImpl.java create mode 100644 checkout-threeds/src/main/java/com/adyen/checkout/threeds/internal/model/Challenge.java create mode 100644 checkout-threeds/src/main/java/com/adyen/checkout/threeds/internal/model/Fingerprint.java create mode 100644 checkout-threeds/src/main/java/com/adyen/checkout/threeds/internal/model/FingerprintToken.java diff --git a/README.md b/README.md index 6f9fda766a..cbaf1e9c1b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ To give you as much flexibility as possible, our Android SDK can be integrated i Import the quick integration modules by adding these lines to your build.gradle file. ```groovy -final checkoutVersion = "2.3.3" +final checkoutVersion = "2.4.0" implementation "com.adyen.checkout:ui:${checkoutVersion}" implementation "com.adyen.checkout:nfc:${checkoutVersion}" // Optional; Integrates NFC card reader in card UI implementation "com.adyen.checkout:wechatpay:${checkoutVersion}" // Optional; Integrates support for WeChat Pay @@ -136,7 +136,7 @@ By default, we use the font that is declared in the theme that is used for check #### Installation Import the following modules by adding these line to your `build.gradle` file. ```groovy -final checkoutVersion = "2.3.3" +final checkoutVersion = "2.4.0" implementation "com.adyen.checkout:core:${checkoutVersion}" implementation "com.adyen.checkout:core-card:${checkoutVersion}" // Optional; Required for processing card payments. implementation "com.adyen.checkout:nfc:${checkoutVersion}" // Optional; Enables reading of card information with the device"s NFC chip. diff --git a/build.gradle b/build.gradle index b7910fe0d6..d211be2db2 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ ext { - compileSdkVersion = 27 - targetSdkVersion = 27 + compileSdkVersion = 28 + targetSdkVersion = 28 minSdkVersion = 16 supportLibVersion = "27.1.1" @@ -28,8 +28,8 @@ ext { "com.tencent.mm.opensdk:wechat-sdk-android-without-mta:9a15154c07c05eadba8351c110647c1754316e32d8f12f55e24679891b52739c:SHA-256", ] - versionCode = 209 - versionName = "2.3.3" + versionCode = 210 + versionName = "2.4.0" testCoverageEnabled = true } @@ -40,8 +40,8 @@ buildscript { jcenter() } dependencies { - classpath "com.android.tools.build:gradle:3.2.0" - classpath "com.jfrog.bintray.gradle:gradle-bintray-plugin:1.8.2" + classpath "com.android.tools.build:gradle:3.3.2" + classpath "com.jfrog.bintray.gradle:gradle-bintray-plugin:1.8.4" } } diff --git a/checkout-base/src/main/java/com/adyen/checkout/base/internal/Base64Coder.java b/checkout-base/src/main/java/com/adyen/checkout/base/internal/Base64Coder.java new file mode 100644 index 0000000000..5050d64508 --- /dev/null +++ b/checkout-base/src/main/java/com/adyen/checkout/base/internal/Base64Coder.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2017 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by Ran Haveshush on 22/11/2018. + */ + +package com.adyen.checkout.base.internal; + +import android.support.annotation.NonNull; +import android.util.Base64; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.nio.charset.Charset; + +public final class Base64Coder { + + private static final Charset DEFAULT_CHARSET = Api.CHARSET; + + public static final int DEFAULT_FLAGS = Base64.DEFAULT; + + @NonNull + public static D decode(@NonNull String encodedData, @NonNull Class decodableClass) throws JSONException { + return decode(encodedData, decodableClass, DEFAULT_FLAGS); + } + + @NonNull + public static D decode(@NonNull String encodedData, @NonNull Class decodableClass, int flags) throws JSONException { + D decodeable = JsonDecodable.decodeFrom(encodedData, decodableClass, flags); + + return decodeable; + } + + @NonNull + public static String encode(@NonNull E encodable) throws JSONException { + return encode(encodable, DEFAULT_FLAGS); + } + + @NonNull + public static String encode(@NonNull E encodable, int flags) throws JSONException { + return JsonEncodable.encodeFrom(encodable, flags); + } + + @NonNull + public static String encodeToString(@NonNull JSONObject jsonObject) { + return encodeToString(jsonObject, DEFAULT_FLAGS); + } + + @NonNull + public static String encodeToString(@NonNull JSONObject jsonObject, int flags) { + return encodeToString(jsonObject.toString(), flags); + } + + @NonNull + public static String encodeToString(@NonNull String decodedData) { + return encodeToString(decodedData, DEFAULT_FLAGS); + } + + @NonNull + public static String encodeToString(@NonNull String decodedData, int flags) { + byte[] decodedBytes = decodedData.getBytes(DEFAULT_CHARSET); + + return Base64.encodeToString(decodedBytes, flags); + } + + @NonNull + public static JSONObject decodeToJSONObject(@NonNull String encodedData) throws JSONException { + return decodeToJSONObject(encodedData, DEFAULT_FLAGS); + } + + @NonNull + public static JSONObject decodeToJSONObject(@NonNull String encodedData, int flags) throws JSONException { + String decodedData = decodeToString(encodedData, flags); + + return new JSONObject(decodedData); + } + + @NonNull + public static String decodeToString(@NonNull String encodedData) { + return decodeToString(encodedData, DEFAULT_FLAGS); + } + + @NonNull + public static String decodeToString(@NonNull String encodedData, int flags) { + byte[] decodedBytes = Base64.decode(encodedData, flags); + String decodedData = new String(decodedBytes, DEFAULT_CHARSET); + + return decodedData; + } + + private Base64Coder() { + throw new IllegalStateException("No instances."); + } +} diff --git a/checkout-base/src/main/java/com/adyen/checkout/base/internal/JsonDecodable.java b/checkout-base/src/main/java/com/adyen/checkout/base/internal/JsonDecodable.java new file mode 100644 index 0000000000..6d073467d4 --- /dev/null +++ b/checkout-base/src/main/java/com/adyen/checkout/base/internal/JsonDecodable.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2017 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by Ran Haveshush on 21/11/2018. + */ + +package com.adyen.checkout.base.internal; + +import android.support.annotation.NonNull; + +import org.json.JSONException; +import org.json.JSONObject; + +public abstract class JsonDecodable extends JsonObject { + + @NonNull + public static T decodeFrom(@NonNull String encodedData, @NonNull Class clazz) throws JSONException { + return decodeFrom(encodedData, clazz, Base64Coder.DEFAULT_FLAGS); + } + + @NonNull + public static T decodeFrom(@NonNull String encodedData, @NonNull Class clazz, int flags) throws JSONException { + JSONObject jsonObject = Base64Coder.decodeToJSONObject(encodedData, flags); + + return parseFrom(jsonObject, clazz); + } + + protected JsonDecodable(@NonNull JSONObject jsonObject) throws JSONException { + super(jsonObject); + } +} diff --git a/checkout-base/src/main/java/com/adyen/checkout/base/internal/JsonEncodable.java b/checkout-base/src/main/java/com/adyen/checkout/base/internal/JsonEncodable.java new file mode 100644 index 0000000000..dda8b3ca9c --- /dev/null +++ b/checkout-base/src/main/java/com/adyen/checkout/base/internal/JsonEncodable.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2017 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by Ran Haveshush on 21/11/2018. + */ + +package com.adyen.checkout.base.internal; + +import android.support.annotation.NonNull; + +import org.json.JSONException; + +public abstract class JsonEncodable implements JsonSerializable { + + @NonNull + static String encodeFrom(@NonNull E encodable) throws JSONException { + return encodeFrom(encodable, Base64Coder.DEFAULT_FLAGS); + } + + @NonNull + static String encodeFrom(@NonNull E encodable, int flags) throws JSONException { + return encodable.encode(flags); + } + + @NonNull + String encode(int flags) throws JSONException { + return Base64Coder.encodeToString(serialize(), flags); + } +} diff --git a/checkout-base/src/main/java/com/adyen/checkout/base/internal/JsonObject.java b/checkout-base/src/main/java/com/adyen/checkout/base/internal/JsonObject.java index 322f6647c1..a36d6d447b 100644 --- a/checkout-base/src/main/java/com/adyen/checkout/base/internal/JsonObject.java +++ b/checkout-base/src/main/java/com/adyen/checkout/base/internal/JsonObject.java @@ -64,7 +64,7 @@ public static > T parseEnumValue(@NonNull String enumValue, @N SerializedName serializedName = field.getAnnotation(SerializedName.class); - if (serializedName != null && enumValue.equals(serializedName.value())) { + if (serializedName != null && enumValue.equalsIgnoreCase(serializedName.value())) { //noinspection RedundantTypeArguments, type arguments need to be present for compiler return JsonObject.getEnumValueFromField(field); } diff --git a/checkout-core/src/main/java/com/adyen/checkout/core/AuthenticationDetails.java b/checkout-core/src/main/java/com/adyen/checkout/core/AuthenticationDetails.java new file mode 100644 index 0000000000..287d90f597 --- /dev/null +++ b/checkout-core/src/main/java/com/adyen/checkout/core/AuthenticationDetails.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2018 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by Ran Haveshush on 09/05/2018. + */ + +package com.adyen.checkout.core; + +import android.os.Parcelable; +import android.support.annotation.NonNull; + +import com.adyen.checkout.core.model.Authentication; +import com.adyen.checkout.core.model.InputDetail; +import com.adyen.checkout.core.model.PaymentResultCode; + +import java.util.List; + +/** + * The {@link AuthenticationDetails} class describes all required parameters for an authentication. + */ +public interface AuthenticationDetails extends Parcelable { + /** + * @return The type of payment method for which authentication details are needed. + */ + @NonNull + String getPaymentMethodType(); + + /** + * Get authentication data that might be needed for the shopper authentication. + * + * @param authenticationClass The {@link Authentication} {@link Class}. + * @param The {@link Authentication} type. + * @return The parsed {@link Authentication}. + * @throws CheckoutException If the data does not match the provided {@link Authentication} {@link Class}. + */ + @NonNull + T getAuthentication(@NonNull Class authenticationClass) throws CheckoutException; + + /** + * @return The {@link List} of authentication {@link InputDetail InputDetails}. + */ + @NonNull + List getInputDetails(); + + /** + * @return The payment result code {@link PaymentResultCode}. + */ + @NonNull + PaymentResultCode getResultCode(); +} diff --git a/checkout-core/src/main/java/com/adyen/checkout/core/PaymentHandler.java b/checkout-core/src/main/java/com/adyen/checkout/core/PaymentHandler.java index 6dbada6a23..ff45c8936e 100644 --- a/checkout-core/src/main/java/com/adyen/checkout/core/PaymentHandler.java +++ b/checkout-core/src/main/java/com/adyen/checkout/core/PaymentHandler.java @@ -17,6 +17,7 @@ import com.adyen.checkout.core.handler.AdditionalDetailsHandler; import com.adyen.checkout.core.handler.ErrorHandler; import com.adyen.checkout.core.handler.RedirectHandler; +import com.adyen.checkout.core.handler.AuthenticationHandler; import com.adyen.checkout.core.model.PaymentMethod; import com.adyen.checkout.core.model.PaymentMethodDetails; import com.adyen.checkout.core.model.PaymentSession; @@ -49,6 +50,16 @@ public interface PaymentHandler { @NonNull Observable getPaymentResultObservable(); + /** + * Sets an {@link Activity} scoped {@link AuthenticationHandler} for this {@link PaymentHandler}. Setting this {@link AuthenticationHandler} + * is required for {@link PaymentMethod PaymentMethods} that might require {@link AuthenticationDetails} after calling + * {@link #initiatePayment(PaymentMethod, PaymentMethodDetails)} the first time. + * + * @param activity The current {@link Activity}. + * @param authenticationHandler The {@link AuthenticationHandler} responsible for handling {@link AuthenticationDetails}. + */ + void setAuthenticationHandler(@NonNull Activity activity, @NonNull AuthenticationHandler authenticationHandler); + /** * Sets an {@link Activity} scoped {@link RedirectHandler} for this {@link PaymentHandler}. Setting this {@link RedirectHandler} is required for * {@link PaymentMethod PaymentMethods} that require a redirect to an external party to complete the payment. @@ -86,6 +97,13 @@ public interface PaymentHandler { */ void initiatePayment(@NonNull PaymentMethod paymentMethod, @Nullable PaymentMethodDetails paymentMethodDetails); + /** + * Submits authentication details for a payment that was previously initiated. + * + * @param paymentMethodDetails The {@link PaymentMethodDetails} containing the authentication details needed for the payment. + */ + void submitAuthenticationDetails(@NonNull PaymentMethodDetails paymentMethodDetails); + /** * Submits additional details for a payment that was previously initiated. * diff --git a/checkout-core/src/main/java/com/adyen/checkout/core/handler/AuthenticationHandler.java b/checkout-core/src/main/java/com/adyen/checkout/core/handler/AuthenticationHandler.java new file mode 100644 index 0000000000..c2ae811409 --- /dev/null +++ b/checkout-core/src/main/java/com/adyen/checkout/core/handler/AuthenticationHandler.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2018 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by Ran Haveshush on 16/11/2018. + */ + +package com.adyen.checkout.core.handler; + +import android.support.annotation.NonNull; + +import com.adyen.checkout.core.AuthenticationDetails; + +public interface AuthenticationHandler { + /** + * Called when authentication details are required to continue with the payment. + * + * @param authenticationDetails The required authentication details. + */ + void onAuthenticationDetailsRequired(@NonNull AuthenticationDetails authenticationDetails); +} diff --git a/checkout-core/src/main/java/com/adyen/checkout/core/internal/AuthenticationManager.java b/checkout-core/src/main/java/com/adyen/checkout/core/internal/AuthenticationManager.java new file mode 100644 index 0000000000..30ed706d49 --- /dev/null +++ b/checkout-core/src/main/java/com/adyen/checkout/core/internal/AuthenticationManager.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2018 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by Ran Haveshush on 16/11/2018. + */ + +package com.adyen.checkout.core.internal; + +import android.support.annotation.NonNull; + +import com.adyen.checkout.core.AuthenticationDetails; +import com.adyen.checkout.core.handler.AuthenticationHandler; + +final class AuthenticationManager extends BaseManager { + + AuthenticationManager(@NonNull Listener listener) { + super(listener); + } + + @Override + void dispatch(@NonNull AuthenticationHandler handler, @NonNull AuthenticationDetails data) { + handler.onAuthenticationDetailsRequired(data); + } +} diff --git a/checkout-core/src/main/java/com/adyen/checkout/core/internal/PaymentHandlerImpl.java b/checkout-core/src/main/java/com/adyen/checkout/core/internal/PaymentHandlerImpl.java index 876c2e7966..d0ab70a860 100644 --- a/checkout-core/src/main/java/com/adyen/checkout/core/internal/PaymentHandlerImpl.java +++ b/checkout-core/src/main/java/com/adyen/checkout/core/internal/PaymentHandlerImpl.java @@ -25,6 +25,7 @@ import com.adyen.checkout.core.PaymentReference; import com.adyen.checkout.core.PaymentResult; import com.adyen.checkout.core.handler.AdditionalDetailsHandler; +import com.adyen.checkout.core.handler.AuthenticationHandler; import com.adyen.checkout.core.handler.ErrorHandler; import com.adyen.checkout.core.handler.RedirectHandler; import com.adyen.checkout.core.internal.model.AdditionalPaymentMethodDetails; @@ -50,6 +51,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +@SuppressWarnings("OverloadMethodsDeclarationOrder") public final class PaymentHandlerImpl implements PaymentHandler { private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper()); @@ -65,6 +67,8 @@ public final class PaymentHandlerImpl implements PaymentHandler { private final ObservableImpl mPaymentResultObservable; + private final AuthenticationManager mAuthenticationManager; + private final RedirectManager mRedirectManager; private final AdditionalDetailsManager mAdditionalDetailsManager; @@ -142,11 +146,13 @@ public void onHandled() { } }; + mAuthenticationManager = new AuthenticationManager(listener); mRedirectManager = new RedirectManager(listener); mAdditionalDetailsManager = new AdditionalDetailsManager(listener); mErrorManager = new ErrorManager(new BaseManager.Listener() { @Override - public void onHandled() { } + public void onHandled() { + } }); if (mPaymentInitiationResponseEntity != null) { @@ -178,6 +184,11 @@ public Observable getPaymentResultObservable() { return mPaymentResultObservable; } + @Override + public void setAuthenticationHandler(@NonNull Activity activity, @NonNull AuthenticationHandler authenticationHandler) { + mAuthenticationManager.addHandler(activity, authenticationHandler); + } + @Override public void setRedirectHandler(@NonNull Activity activity, @NonNull RedirectHandler redirectHandler) { mRedirectManager.addHandler(activity, redirectHandler); @@ -196,7 +207,27 @@ public void setErrorHandler(@NonNull Activity activity, @NonNull ErrorHandler er @Override public void initiatePayment(@NonNull PaymentMethod paymentMethod, @Nullable PaymentMethodDetails paymentMethodDetails) { PaymentSessionImpl paymentSession = mPaymentSessionEntity.paymentSession; - initiatePaymentInternal(paymentSession, (PaymentMethodImpl) paymentMethod, paymentMethodDetails); + initiatePayment(paymentSession, (PaymentMethodImpl) paymentMethod, paymentMethodDetails); + } + + @Override + public void submitAuthenticationDetails(@NonNull PaymentMethodDetails paymentMethodDetails) { + PaymentSessionImpl paymentSession = mPaymentSessionEntity.paymentSession; + PaymentInitiationResponse.AuthenticationFields authenticationFields = mPaymentInitiationResponseEntity.paymentInitiationResponse + .getAuthenticationFields(); + + if (authenticationFields == null) { + CheckoutException checkoutException = new CheckoutException.Builder( + "Could not submit authentication details, AuthenticationFields == null.", + null + ).build(); + handleCheckoutException(checkoutException); + return; + } + + PaymentMethodImpl paymentMethod = mPaymentInitiationResponseEntity.paymentMethod; + + initiatePayment(paymentSession, authenticationFields.getPaymentData(), paymentMethod, paymentMethodDetails); } @Override @@ -224,7 +255,7 @@ public void submitAdditionalDetails(@NonNull PaymentMethodDetails paymentMethodD } } - initiatePaymentInternal(paymentSession, paymentMethod, paymentMethodDetails); + initiatePayment(paymentSession, paymentMethod, paymentMethodDetails); } @Override @@ -245,7 +276,7 @@ public void handleRedirectResult(@NonNull final Uri redirectResult) { if (redirectFields.isSubmitPaymentMethodReturnData()) { AppResponseDetails appResponseDetails = new AppResponseDetails.Builder(redirectResult.getQuery()).build(); - initiatePaymentInternal(paymentSession, paymentMethod, appResponseDetails); + initiatePayment(paymentSession, paymentMethod, appResponseDetails); } else { try { JSONObject jsonObject = new JSONObject(); @@ -310,12 +341,20 @@ public void run() { }); } - private void initiatePaymentInternal( + private void initiatePayment( + @NonNull PaymentSessionImpl paymentSession, + @NonNull PaymentMethodImpl paymentMethod, + @Nullable PaymentMethodDetails paymentMethodDetails + ) { + initiatePayment(paymentSession, paymentSession.getPaymentData(), paymentMethod, paymentMethodDetails); + } + + private void initiatePayment( @NonNull PaymentSessionImpl paymentSession, + @NonNull String paymentData, @NonNull final PaymentMethodImpl paymentMethod, @Nullable PaymentMethodDetails paymentMethodDetails ) { - String paymentData = paymentSession.getPaymentData(); String paymentMethodData = paymentMethod.getPaymentMethodData(); PaymentInitiation paymentInitiation = new PaymentInitiation.Builder(paymentData, paymentMethodData) .setPaymentMethodDetails(paymentMethodDetails) @@ -419,6 +458,12 @@ public void run() { mRedirectManager.setData(paymentInitiationResponse.getRedirectFields()); } break; + case IDENTIFY_SHOPPER: + case CHALLENGE_SHOPPER: + if (!paymentInitiationResponseEntity.handled) { + mAuthenticationManager.setData(paymentInitiationResponse.getAuthenticationFields()); + } + break; case DETAILS: if (!paymentInitiationResponseEntity.handled) { mAdditionalDetailsManager.setData(paymentInitiationResponse.getDetailFields()); diff --git a/checkout-core/src/main/java/com/adyen/checkout/core/internal/model/ChallengeAuthentication.java b/checkout-core/src/main/java/com/adyen/checkout/core/internal/model/ChallengeAuthentication.java new file mode 100644 index 0000000000..40313baf8c --- /dev/null +++ b/checkout-core/src/main/java/com/adyen/checkout/core/internal/model/ChallengeAuthentication.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2018 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by Ran Haveshush on 15/11/2018. + */ + +package com.adyen.checkout.core.internal.model; + +import android.os.Parcelable; +import android.support.annotation.NonNull; + +import com.adyen.checkout.base.internal.JsonObject; +import com.adyen.checkout.core.internal.ProvidedBy; +import com.adyen.checkout.core.model.Authentication; + +import org.json.JSONException; +import org.json.JSONObject; + +@ProvidedBy(ChallengeAuthentication.class) +public final class ChallengeAuthentication extends JsonObject implements Authentication { + public static final Parcelable.Creator CREATOR = new DefaultCreator<>(ChallengeAuthentication.class); + + private static final String KEY_THREE_DS_CHALLENGE_TOKEN = "threeds2.challengeToken"; + + private final String mChallengeToken; + + protected ChallengeAuthentication(@NonNull JSONObject jsonObject) throws JSONException { + super(jsonObject); + + mChallengeToken = jsonObject.getString(KEY_THREE_DS_CHALLENGE_TOKEN); + } + + @NonNull + public String getChallengeToken() { + return mChallengeToken; + } +} diff --git a/checkout-core/src/main/java/com/adyen/checkout/core/internal/model/DeviceFingerprint.java b/checkout-core/src/main/java/com/adyen/checkout/core/internal/model/DeviceFingerprint.java index 5f2cae277b..158c23a71f 100644 --- a/checkout-core/src/main/java/com/adyen/checkout/core/internal/model/DeviceFingerprint.java +++ b/checkout-core/src/main/java/com/adyen/checkout/core/internal/model/DeviceFingerprint.java @@ -15,9 +15,9 @@ import android.support.annotation.Nullable; import android.util.Base64; -import com.adyen.checkout.base.internal.Api; +import com.adyen.checkout.base.internal.Base64Coder; import com.adyen.checkout.base.internal.Json; -import com.adyen.checkout.base.internal.JsonSerializable; +import com.adyen.checkout.base.internal.JsonEncodable; import com.adyen.checkout.core.BuildConfig; import com.adyen.checkout.core.CheckoutException; import com.adyen.checkout.base.internal.HashUtils; @@ -28,7 +28,7 @@ import java.util.Date; import java.util.Locale; -public final class DeviceFingerprint implements JsonSerializable { +public final class DeviceFingerprint extends JsonEncodable { private static final String DEVICE_FINGERPRINT_VERSION = "1.0"; private static final String PLATFORM = "Android"; @@ -52,9 +52,7 @@ public static String generateSdkToken(@NonNull Context context, @NonNull String DeviceFingerprint deviceFingerprint = new DeviceFingerprint(context, integrationType); try { - String json = deviceFingerprint.serialize().toString(); - - return Base64.encodeToString(json.getBytes(Api.CHARSET), Base64.NO_WRAP); + return Base64Coder.encode(deviceFingerprint, Base64.NO_WRAP); } catch (JSONException e) { throw new CheckoutException.Builder("Error generating SDK token.", e) .setFatal(true) diff --git a/checkout-core/src/main/java/com/adyen/checkout/core/internal/model/FingerprintAuthentication.java b/checkout-core/src/main/java/com/adyen/checkout/core/internal/model/FingerprintAuthentication.java new file mode 100644 index 0000000000..aa7256d857 --- /dev/null +++ b/checkout-core/src/main/java/com/adyen/checkout/core/internal/model/FingerprintAuthentication.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2018 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by Ran Haveshush on 15/11/2018. + */ + +package com.adyen.checkout.core.internal.model; + +import android.os.Parcelable; +import android.support.annotation.NonNull; + +import com.adyen.checkout.base.internal.JsonObject; +import com.adyen.checkout.core.internal.ProvidedBy; +import com.adyen.checkout.core.model.Authentication; + +import org.json.JSONException; +import org.json.JSONObject; + +@ProvidedBy(FingerprintAuthentication.class) +public final class FingerprintAuthentication extends JsonObject implements Authentication { + public static final Parcelable.Creator CREATOR = new DefaultCreator<>(FingerprintAuthentication.class); + + private static final String KEY_THREE_DS_FINGERPRINT_TOKEN = "threeds2.fingerprintToken"; + + private final String mFingerprintToken; + + protected FingerprintAuthentication(@NonNull JSONObject jsonObject) throws JSONException { + super(jsonObject); + + mFingerprintToken = jsonObject.getString(KEY_THREE_DS_FINGERPRINT_TOKEN); + } + + @NonNull + public String getFingerprintToken() { + return mFingerprintToken; + } +} diff --git a/checkout-core/src/main/java/com/adyen/checkout/core/internal/model/PaymentInitiationResponse.java b/checkout-core/src/main/java/com/adyen/checkout/core/internal/model/PaymentInitiationResponse.java index 4d4af8cd51..526555e2bb 100644 --- a/checkout-core/src/main/java/com/adyen/checkout/core/internal/model/PaymentInitiationResponse.java +++ b/checkout-core/src/main/java/com/adyen/checkout/core/internal/model/PaymentInitiationResponse.java @@ -16,10 +16,12 @@ import com.adyen.checkout.base.internal.JsonObject; import com.adyen.checkout.core.AdditionalDetails; import com.adyen.checkout.core.CheckoutException; +import com.adyen.checkout.core.AuthenticationDetails; import com.adyen.checkout.core.PaymentResult; import com.adyen.checkout.core.RedirectDetails; import com.adyen.checkout.base.internal.HashUtils; import com.adyen.checkout.core.internal.ProvidedBy; +import com.adyen.checkout.core.model.Authentication; import com.adyen.checkout.core.model.InputDetail; import com.adyen.checkout.core.model.PaymentResultCode; import com.adyen.checkout.core.model.RedirectData; @@ -40,6 +42,8 @@ public final class PaymentInitiationResponse extends JsonObject { private final CompleteFields mCompleteFields; + private final AuthenticationFields mAuthenticationFields; + private final DetailFields mDetailFields; private final RedirectFields mRedirectFields; @@ -54,25 +58,37 @@ private PaymentInitiationResponse(@NonNull JSONObject jsonObject) throws JSONExc switch (mType) { case COMPLETE: mCompleteFields = parseFrom(jsonObject, CompleteFields.class); + mAuthenticationFields = null; mDetailFields = null; mRedirectFields = null; mErrorFields = null; break; case DETAILS: mCompleteFields = null; + mAuthenticationFields = null; mDetailFields = parseFrom(jsonObject, DetailFields.class); mRedirectFields = null; mErrorFields = null; break; case REDIRECT: mCompleteFields = null; + mAuthenticationFields = null; mDetailFields = null; mRedirectFields = parseFrom(jsonObject, RedirectFields.class); mErrorFields = null; break; + case IDENTIFY_SHOPPER: + case CHALLENGE_SHOPPER: + mCompleteFields = null; + mAuthenticationFields = parseFrom(jsonObject, AuthenticationFields.class); + mDetailFields = null; + mRedirectFields = null; + mErrorFields = null; + break; case ERROR: case VALIDATION: mCompleteFields = null; + mAuthenticationFields = null; mDetailFields = null; mRedirectFields = null; mErrorFields = parseFrom(jsonObject, ErrorFields.class); @@ -99,6 +115,9 @@ public boolean equals(@Nullable Object o) { if (mCompleteFields != null ? !mCompleteFields.equals(that.mCompleteFields) : that.mCompleteFields != null) { return false; } + if (mAuthenticationFields != null ? !mAuthenticationFields.equals(that.mAuthenticationFields) : that.mAuthenticationFields != null) { + return false; + } if (mDetailFields != null ? !mDetailFields.equals(that.mDetailFields) : that.mDetailFields != null) { return false; } @@ -128,6 +147,11 @@ public CompleteFields getCompleteFields() { return mCompleteFields; } + @Nullable + public AuthenticationFields getAuthenticationFields() { + return mAuthenticationFields; + } + @Nullable public DetailFields getDetailFields() { return mDetailFields; @@ -147,6 +171,10 @@ public enum Type { COMPLETE, DETAILS, REDIRECT, + @SerializedName("identifyShopper") + IDENTIFY_SHOPPER, + @SerializedName("challengeShopper") + CHALLENGE_SHOPPER, ERROR, VALIDATION } @@ -163,10 +191,10 @@ public static final class CompleteFields extends JsonObject implements PaymentRe private final String mPayload; - private final PaymentResultCode mResultCode; - private final PaymentMethodBase mPaymentMethod; + private final PaymentResultCode mResultCode; + private CompleteFields(@NonNull JSONObject jsonObject) throws JSONException { super(jsonObject); @@ -409,6 +437,109 @@ public boolean isSubmitPaymentMethodReturnData() { } } + public static final class AuthenticationFields extends JsonObject implements AuthenticationDetails { + public static final Parcelable.Creator CREATOR = new DefaultCreator<>(AuthenticationFields.class); + + private static final String KEY_AUTHENTICATION = "authentication"; + + private static final String KEY_PAYMENT_DATA = "paymentData"; + + private static final String KEY_PAYMENT_METHOD = "paymentMethod"; + + private static final String KEY_RESPONSE_DETAILS = "responseDetails"; + + private static final String KEY_RESULT_CODE = "resultCode"; + + private final JSONObject mAuthentication; + + private final String mPaymentData; + + private final PaymentMethodBase mPaymentMethod; + + private final List mResponseDetails; + + private final PaymentResultCode mResultCode; + + protected AuthenticationFields(@NonNull JSONObject jsonObject) throws JSONException { + super(jsonObject); + + mPaymentData = jsonObject.getString(KEY_PAYMENT_DATA); + mPaymentMethod = parse(KEY_PAYMENT_METHOD, PaymentMethodBase.class); + mAuthentication = jsonObject.getJSONObject(KEY_AUTHENTICATION); + mResponseDetails = parseList(KEY_RESPONSE_DETAILS, InputDetailImpl.class); + mResultCode = parseEnum(KEY_RESULT_CODE, PaymentResultCode.class); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof AuthenticationFields)) { + return false; + } + + AuthenticationFields that = (AuthenticationFields) o; + + if (mAuthentication != null ? !mAuthentication.equals(that.mAuthentication) : that.mAuthentication != null) { + return false; + } + if (mPaymentData != null ? !mPaymentData.equals(that.mPaymentData) : that.mPaymentData != null) { + return false; + } + if (mPaymentMethod != null ? !mPaymentMethod.equals(that.mPaymentMethod) : that.mPaymentMethod != null) { + return false; + } + if (mResponseDetails != null ? !mResponseDetails.equals(that.mResponseDetails) : that.mResponseDetails != null) { + return false; + } + return mResultCode == that.mResultCode; + } + + @Override + public int hashCode() { + int result = mAuthentication != null ? mAuthentication.hashCode() : 0; + result = HashUtils.MULTIPLIER * result + (mPaymentData != null ? mPaymentData.hashCode() : 0); + result = HashUtils.MULTIPLIER * result + (mPaymentMethod != null ? mPaymentMethod.hashCode() : 0); + result = HashUtils.MULTIPLIER * result + (mResponseDetails != null ? mResponseDetails.hashCode() : 0); + result = HashUtils.MULTIPLIER * result + (mResultCode != null ? mResultCode.hashCode() : 0); + return result; + } + + @NonNull + @Override + public String getPaymentMethodType() { + return mPaymentMethod.getType(); + } + + @NonNull + @Override + public List getInputDetails() { + return new ArrayList(mResponseDetails); + } + + @NonNull + @Override + public T getAuthentication(@NonNull Class authenticationClass) throws CheckoutException { + if (authenticationClass == null) { + throw new CheckoutException.Builder("No Authentication is available.", null).build(); + } + + return ProvidedBy.Util.parse(mAuthentication, authenticationClass); + } + + @NonNull + @Override + public PaymentResultCode getResultCode() { + return mResultCode; + } + + @NonNull + public String getPaymentData() { + return mPaymentData; + } + } + public static final class ErrorFields extends JsonObject { @NonNull public static final Creator CREATOR = new DefaultCreator<>(ErrorFields.class); diff --git a/checkout-core/src/main/java/com/adyen/checkout/core/internal/model/PaymentSessionImpl.java b/checkout-core/src/main/java/com/adyen/checkout/core/internal/model/PaymentSessionImpl.java index 95029a0e68..97f7194d30 100644 --- a/checkout-core/src/main/java/com/adyen/checkout/core/internal/model/PaymentSessionImpl.java +++ b/checkout-core/src/main/java/com/adyen/checkout/core/internal/model/PaymentSessionImpl.java @@ -11,10 +11,10 @@ import android.os.Parcelable; import android.support.annotation.NonNull; import android.support.annotation.Nullable; -import android.util.Base64; import com.adyen.checkout.base.HostProvider; -import com.adyen.checkout.base.internal.Api; +import com.adyen.checkout.base.internal.Base64Coder; +import com.adyen.checkout.base.internal.JsonDecodable; import com.adyen.checkout.base.internal.JsonObject; import com.adyen.checkout.core.CheckoutException; import com.adyen.checkout.base.internal.HashUtils; @@ -30,7 +30,7 @@ import java.util.Iterator; import java.util.List; -public final class PaymentSessionImpl extends JsonObject implements PaymentSession { +public final class PaymentSessionImpl extends JsonDecodable implements PaymentSession { @NonNull public static final Parcelable.Creator CREATOR = new DefaultCreator<>(PaymentSessionImpl.class); @@ -80,19 +80,11 @@ public static PaymentSessionImpl decode(@NonNull String encodedPaymentSession) t // Check if the whole PaymentSessionResponse was forwarded. JSONObject jsonObjectWrapper = new JSONObject(encodedPaymentSession); PaymentSessionResponse paymentSessionResponse = JsonObject.parseFrom(jsonObjectWrapper, PaymentSessionResponse.class); - byte[] decodedPaymentSession = Base64.decode(paymentSessionResponse.getPaymentSession(), Base64.DEFAULT); - String paymentSessionJson = new String(decodedPaymentSession, Api.CHARSET); - JSONObject jsonObject = new JSONObject(paymentSessionJson); - - return parseFrom(jsonObject, PaymentSessionImpl.class); + return Base64Coder.decode(paymentSessionResponse.getPaymentSession(), PaymentSessionImpl.class); } catch (JSONException | IllegalArgumentException e1) { try { // Check if only the paymentSession value was forwarded. - byte[] decodedPaymentSession = Base64.decode(encodedPaymentSession, Base64.DEFAULT); - String paymentSessionJson = new String(decodedPaymentSession, Api.CHARSET); - JSONObject jsonObject = new JSONObject(paymentSessionJson); - - return parseFrom(jsonObject, PaymentSessionImpl.class); + return Base64Coder.decode(encodedPaymentSession, PaymentSessionImpl.class); } catch (IllegalArgumentException | JSONException e2) { throw new CheckoutException.Builder("Error parsing payment session data.", e2) .setFatal(true) diff --git a/checkout-core/src/main/java/com/adyen/checkout/core/model/Authentication.java b/checkout-core/src/main/java/com/adyen/checkout/core/model/Authentication.java new file mode 100644 index 0000000000..0e7df0a976 --- /dev/null +++ b/checkout-core/src/main/java/com/adyen/checkout/core/model/Authentication.java @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2017 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by Ran Haveshush on 15/11/2018. + */ + +package com.adyen.checkout.core.model; + +import android.os.Parcelable; + +public interface Authentication extends Parcelable { + // Marker interface for Authentication. Authentication implementations must be annotated with @ProvidedBy. +} diff --git a/checkout-core/src/main/java/com/adyen/checkout/core/model/ChallengeDetails.java b/checkout-core/src/main/java/com/adyen/checkout/core/model/ChallengeDetails.java new file mode 100644 index 0000000000..ab68563fac --- /dev/null +++ b/checkout-core/src/main/java/com/adyen/checkout/core/model/ChallengeDetails.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2017 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by Ran Haveshush on 19/11/2018. + */ + +package com.adyen.checkout.core.model; + +import android.os.Parcel; +import android.support.annotation.NonNull; + +import org.json.JSONException; +import org.json.JSONObject; + +public final class ChallengeDetails extends PaymentMethodDetails { + public static final Creator CREATOR = new Creator() { + @Override + public ChallengeDetails createFromParcel(Parcel source) { + return new ChallengeDetails(source); + } + + @Override + public ChallengeDetails[] newArray(int size) { + return new ChallengeDetails[size]; + } + }; + + public static final String KEY_THREE_DS_CHALLENGE_RESULT = "threeds2.challengeResult"; + + private final String mChallengeResult; + + public ChallengeDetails(@NonNull String challengeResult) { + mChallengeResult = challengeResult; + } + + protected ChallengeDetails(@NonNull Parcel in) { + super(in); + + this.mChallengeResult = in.readString(); + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeString(this.mChallengeResult); + } + + @NonNull + @Override + public JSONObject serialize() throws JSONException { + JSONObject jsonObject = new JSONObject(); + jsonObject.put(KEY_THREE_DS_CHALLENGE_RESULT, mChallengeResult); + + return jsonObject; + } +} diff --git a/checkout-core/src/main/java/com/adyen/checkout/core/model/FingerprintDetails.java b/checkout-core/src/main/java/com/adyen/checkout/core/model/FingerprintDetails.java new file mode 100644 index 0000000000..7136279239 --- /dev/null +++ b/checkout-core/src/main/java/com/adyen/checkout/core/model/FingerprintDetails.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2017 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by Ran Haveshush on 19/11/2018. + */ + +package com.adyen.checkout.core.model; + +import android.os.Parcel; +import android.support.annotation.NonNull; + +import org.json.JSONException; +import org.json.JSONObject; + +public final class FingerprintDetails extends PaymentMethodDetails { + public static final Creator CREATOR = new Creator() { + @Override + public FingerprintDetails createFromParcel(Parcel source) { + return new FingerprintDetails(source); + } + + @Override + public FingerprintDetails[] newArray(int size) { + return new FingerprintDetails[size]; + } + }; + + public static final String KEY_THREE_DS_FINGERPRINT = "threeds2.fingerprint"; + + private final String mFingerprint; + + public FingerprintDetails(@NonNull String fingerprint) { + mFingerprint = fingerprint; + } + + protected FingerprintDetails(@NonNull Parcel in) { + super(in); + + this.mFingerprint = in.readString(); + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeString(this.mFingerprint); + } + + @NonNull + @Override + public JSONObject serialize() throws JSONException { + JSONObject jsonObject = new JSONObject(); + jsonObject.put(KEY_THREE_DS_FINGERPRINT, mFingerprint); + + return jsonObject; + } +} diff --git a/checkout-core/src/main/java/com/adyen/checkout/core/model/PaymentResultCode.java b/checkout-core/src/main/java/com/adyen/checkout/core/model/PaymentResultCode.java index d53b03ba8a..d58265e5c1 100644 --- a/checkout-core/src/main/java/com/adyen/checkout/core/model/PaymentResultCode.java +++ b/checkout-core/src/main/java/com/adyen/checkout/core/model/PaymentResultCode.java @@ -28,6 +28,16 @@ public enum PaymentResultCode { */ @SerializedName("authorised") AUTHORIZED, + /** + * Indicates that the shopper needs to be identified by 3DS authentication. + */ + @SerializedName("identifyShopper") + IDENTIFY_SHOPPER, + /** + * Indicates that the shopper needs to pass a 3DS challenge. + */ + @SerializedName("challengeShopper") + CHALLENGE_SHOPPER, /** * Indicates that an error occurred while processing the payment. */ diff --git a/checkout-threeds/.gitignore b/checkout-threeds/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/checkout-threeds/.gitignore @@ -0,0 +1 @@ +/build diff --git a/checkout-threeds/build.gradle b/checkout-threeds/build.gradle new file mode 100644 index 0000000000..7c68b91ff2 --- /dev/null +++ b/checkout-threeds/build.gradle @@ -0,0 +1,36 @@ +ext.releaseArtifactId = "threeds" + +apply plugin: 'com.android.library' +// TODO: uncomment this line after adyen-3ds2 will have an md5 file. +//apply from: "$rootProject.rootDir/checkDependencies.gradle" +apply from: "$rootProject.rootDir/quality.gradle" +apply from: "$rootProject.rootDir/release.gradle" + +android { + compileSdkVersion rootProject.compileSdkVersion + + defaultConfig { + minSdkVersion rootProject.minSdkVersion + targetSdkVersion rootProject.targetSdkVersion + versionCode rootProject.versionCode + versionName rootProject.versionName + } + + buildTypes { + debug { + testCoverageEnabled rootProject.testCoverageEnabled + } + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + implementation "com.android.support:support-annotations:${rootProject.supportLibVersion}" + + implementation "com.adyen.threeds:adyen-3ds2:0.9.3" + + implementation project(":checkout-base") +} diff --git a/checkout-threeds/proguard-rules.pro b/checkout-threeds/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/checkout-threeds/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/checkout-threeds/src/main/AndroidManifest.xml b/checkout-threeds/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..f60d746aec --- /dev/null +++ b/checkout-threeds/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/checkout-threeds/src/main/java/com/adyen/checkout/threeds/Card3DS2Authenticator.java b/checkout-threeds/src/main/java/com/adyen/checkout/threeds/Card3DS2Authenticator.java new file mode 100644 index 0000000000..e786dd3d51 --- /dev/null +++ b/checkout-threeds/src/main/java/com/adyen/checkout/threeds/Card3DS2Authenticator.java @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2019 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by Ran Haveshush on 16/11/2018. + */ + +package com.adyen.checkout.threeds; + +import android.app.Activity; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.adyen.checkout.base.internal.Base64Coder; +import com.adyen.checkout.threeds.internal.ChallengeResultImpl; +import com.adyen.checkout.threeds.internal.model.Challenge; +import com.adyen.checkout.threeds.internal.model.Fingerprint; +import com.adyen.checkout.threeds.internal.model.FingerprintToken; +import com.adyen.threeds2.AuthenticationRequestParameters; +import com.adyen.threeds2.ChallengeStatusReceiver; +import com.adyen.threeds2.CompletionEvent; +import com.adyen.threeds2.ErrorMessage; +import com.adyen.threeds2.ProtocolErrorEvent; +import com.adyen.threeds2.RuntimeErrorEvent; +import com.adyen.threeds2.ThreeDS2Service; +import com.adyen.threeds2.Transaction; +import com.adyen.threeds2.customization.UiCustomization; +import com.adyen.threeds2.exception.SDKAlreadyInitializedException; +import com.adyen.threeds2.exception.SDKNotInitializedException; +import com.adyen.threeds2.parameters.ChallengeParameters; +import com.adyen.threeds2.parameters.ConfigParameters; +import com.adyen.threeds2.util.AdyenConfigParameters; + +import org.json.JSONException; + +public final class Card3DS2Authenticator { + + private static final int DEFAULT_CHALLENGE_TIME_OUT = 10; + + private Activity mActivity; + + private ListenerDelegate mListenerDelegate; + + private Transaction mTransaction; + + private UiCustomization mUiCustomization; + + /** + * Initializes the 3DS2 card authenticator. + *

+ * + * @param activity The current activity + * @param listener {@link AuthenticationListener} the 3DS2 authentication listener + */ + public Card3DS2Authenticator(@NonNull Activity activity, @NonNull AuthenticationListener listener) { + this(activity, null, listener); + } + + /** + * Initializes the 3DS2 card authenticator. + *

+ * + * @param activity The current activity. + * @param uiCustomization (optional) The {@link UiCustomization}, UI configuration information that is used to specify the UI layout and theme. + * @param listener {@link AuthenticationListener} which will be invoked on authentication success with the challenge result or failure. + */ + @SuppressWarnings("WeakerAccess") + public Card3DS2Authenticator(@NonNull Activity activity, @Nullable UiCustomization uiCustomization, @NonNull AuthenticationListener listener) { + mActivity = activity; + mUiCustomization = uiCustomization; + mListenerDelegate = new ListenerDelegate(listener); + } + + /** + * Creates a fingerprint using a fingerprint token received from the Checkout API. + *

+ * + * @param encodedFingerprintToken The fingerprint token received from the Checkout API. + * @return The encoded device fingerprint. + */ + @SuppressWarnings("WeakerAccess") + @NonNull + public String createFingerprint(@NonNull String encodedFingerprintToken) throws ThreeDS2Exception { + FingerprintToken fingerprintToken; + try { + fingerprintToken = Base64Coder.decode(encodedFingerprintToken, FingerprintToken.class); + } catch (JSONException e) { + throw ThreeDS2Exception.from("Fingerprint token decoding failure.", e); + } + + return createFingerprint(fingerprintToken.getDirectoryServerId(), fingerprintToken.getDirectoryServerPublicKey()); + } + + /** + * Creates a fingerprint using a directory server identifier and public key. + *

+ * + * @param directoryServerId The directory server identifier. + * @param directoryServerPublicKey The directory server public key. + * @return The encoded device fingerprint. + */ + @SuppressWarnings("WeakerAccess") + @NonNull + public String createFingerprint(@NonNull String directoryServerId, @NonNull String directoryServerPublicKey) throws ThreeDS2Exception { + ConfigParameters configParameters = AdyenConfigParameters.from(directoryServerId, directoryServerPublicKey); + + try { + ThreeDS2Service.INSTANCE.initialize(mActivity, configParameters, null, mUiCustomization); + } catch (SDKAlreadyInitializedException e) { + // Do nothing. + } + + try { + mTransaction = ThreeDS2Service.INSTANCE.createTransaction(null, null); + } catch (SDKNotInitializedException e) { + throw ThreeDS2Exception.from("Transaction creation failure, 3DS service isn't initialized.", e); + } + + AuthenticationRequestParameters authenticationRequestParameters = mTransaction.getAuthenticationRequestParameters(); + Fingerprint fingerprint = new Fingerprint(authenticationRequestParameters); + + try { + return Base64Coder.encode(fingerprint); + } catch (JSONException e) { + throw ThreeDS2Exception.from("Fingerprint encoding failure.", e); + } + } + + /** + * Presents a challenge with a default challenge timeout of 10 minutes. + *

+ * + * @param encodedChallengeToken The challenge token, as received from the Checkout API. + */ + public void presentChallenge(@NonNull String encodedChallengeToken) throws ThreeDS2Exception { + presentChallenge(encodedChallengeToken, DEFAULT_CHALLENGE_TIME_OUT); + } + + /** + * Presents a challenge. + *

+ * + * @param encodedChallengeToken The challenge token, as received from the Checkout API. + * @param challengeTimeOut The challenge timeout in minutes, the default is 10 minutes, minimum is 5 minutes. + */ + @SuppressWarnings("WeakerAccess") + public void presentChallenge(@NonNull String encodedChallengeToken, int challengeTimeOut) throws ThreeDS2Exception { + Challenge challenge; + try { + challenge = Base64Coder.decode(encodedChallengeToken, Challenge.class); + } catch (JSONException e) { + throw ThreeDS2Exception.from("Challenge token decoding failure.", e); + } + + ChallengeParameters challengeParameters = createChallengeParameters(challenge); + + mTransaction.doChallenge(mActivity, challengeParameters, mListenerDelegate, challengeTimeOut); + } + + // TODO: 21/11/2018 replace the release with lifecycle aware logic. + public void release() { + if (mTransaction != null) { + mTransaction.close(); + mTransaction = null; + } + + try { + ThreeDS2Service.INSTANCE.cleanup(mActivity); + } catch (SDKNotInitializedException e) { + // Do nothing. + } + + mActivity = null; + mListenerDelegate = null; + } + + @NonNull + private ChallengeParameters createChallengeParameters(@NonNull Challenge challenge) { + ChallengeParameters challengeParameters = new ChallengeParameters(); + challengeParameters.set3DSServerTransactionID(challenge.getThreeDSServerTransID()); + challengeParameters.setAcsTransactionID(challenge.getAcsTransID()); + challengeParameters.setAcsRefNumber(challenge.getAcsReferenceNumber()); + challengeParameters.setAcsSignedContent(challenge.getAcsSignedContent()); + + return challengeParameters; + } + + private final class ListenerDelegate implements ChallengeStatusReceiver { + + private static final String PROTOCOL_ERROR_FORMAT = "Error [code: %s, description: %s, details: %s]"; + + private static final String RUNTIME_ERROR_FORMAT = "Error [code: %s, message: %s]"; + + private final AuthenticationListener mDelegate; + + /** + * Initializes the ListenerDelegate. + */ + ListenerDelegate(@NonNull AuthenticationListener listener) { + mDelegate = listener; + } + + /** + * Get ChallengeResult from CompletionEvent. + */ + @Override + public void completed(CompletionEvent completionEvent) { + try { + ChallengeResult challengeResult = ChallengeResultImpl.from(completionEvent); + mDelegate.onSuccess(challengeResult); + } catch (JSONException e) { + mDelegate.onFailure(ThreeDS2Exception.from("Challenge result creation failure.", e)); + } + } + + @Override + public void cancelled() { + mDelegate.onFailure(ThreeDS2Exception.from("Challenge was canceled.")); + } + + @Override + public void timedout() { + mDelegate.onFailure(ThreeDS2Exception.from("Challenge was timed out.")); + } + + /** + * Generate error from ProtocolErrorEvent. + */ + @Override + public void protocolError(ProtocolErrorEvent protocolErrorEvent) { + ErrorMessage errorMessage = protocolErrorEvent.getErrorMessage(); + + String message = String.format(PROTOCOL_ERROR_FORMAT, + errorMessage.getErrorCode(), + errorMessage.getErrorDescription(), + errorMessage.getErrorDetails()); + + mDelegate.onFailure(ThreeDS2Exception.from(message)); + } + + /** + * Generate error from RuntimeErrorEvent. + */ + @Override + public void runtimeError(RuntimeErrorEvent runtimeErrorEvent) { + String message = String.format(RUNTIME_ERROR_FORMAT, + runtimeErrorEvent.getErrorCode(), + runtimeErrorEvent.getErrorMessage()); + + mDelegate.onFailure(ThreeDS2Exception.from(message)); + } + } + + public interface AuthenticationListener { + /** + * Invoked on challenge finish without a failure. + *

+ * + * @param challengeResult {@link ChallengeResult} contains the challlgen authentication state and payload. + */ + void onSuccess(@NonNull ChallengeResult challengeResult); + + /** + * Invoked on challenge failure. + *

+ * + * @param e {@link ThreeDS2Exception} contains the failure metadata. + */ + void onFailure(@NonNull ThreeDS2Exception e); + } +} diff --git a/checkout-threeds/src/main/java/com/adyen/checkout/threeds/ChallengeResult.java b/checkout-threeds/src/main/java/com/adyen/checkout/threeds/ChallengeResult.java new file mode 100644 index 0000000000..78d7f6671d --- /dev/null +++ b/checkout-threeds/src/main/java/com/adyen/checkout/threeds/ChallengeResult.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2019 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by Ran Haveshush on 20/11/2018. + */ + +package com.adyen.checkout.threeds; + +import android.support.annotation.NonNull; + +public interface ChallengeResult { + /** + * @return true if the shopper is authenticated by the 3DS challenge, + * false otherwise. + */ + boolean isAuthenticated(); + + /** + * @return The 3DS challenge result payload. + */ + @NonNull + String getPayload(); +} diff --git a/checkout-threeds/src/main/java/com/adyen/checkout/threeds/ThreeDS2Exception.java b/checkout-threeds/src/main/java/com/adyen/checkout/threeds/ThreeDS2Exception.java new file mode 100644 index 0000000000..13967fc94b --- /dev/null +++ b/checkout-threeds/src/main/java/com/adyen/checkout/threeds/ThreeDS2Exception.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2019 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by Ran Haveshush on 21/11/2018. + */ + +package com.adyen.checkout.threeds; + +import android.support.annotation.NonNull; + +public final class ThreeDS2Exception extends Exception { + + private ThreeDS2Exception(@NonNull String message) { + super(message); + } + + private ThreeDS2Exception(@NonNull String message, @NonNull Throwable cause) { + super(message, cause); + } + + @NonNull + public static ThreeDS2Exception from(@NonNull String message) { + return new ThreeDS2Exception(message); + } + + @NonNull + public static ThreeDS2Exception from(@NonNull String message, @NonNull Throwable cause) { + return new ThreeDS2Exception(message, cause); + } +} diff --git a/checkout-threeds/src/main/java/com/adyen/checkout/threeds/internal/ChallengeResultImpl.java b/checkout-threeds/src/main/java/com/adyen/checkout/threeds/internal/ChallengeResultImpl.java new file mode 100644 index 0000000000..e5c30c73e1 --- /dev/null +++ b/checkout-threeds/src/main/java/com/adyen/checkout/threeds/internal/ChallengeResultImpl.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2019 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by Ran Haveshush on 20/11/2018. + */ + +package com.adyen.checkout.threeds.internal; + +import android.support.annotation.NonNull; + +import com.adyen.checkout.base.internal.Base64Coder; +import com.adyen.checkout.threeds.ChallengeResult; +import com.adyen.threeds2.CompletionEvent; + +import org.json.JSONException; +import org.json.JSONObject; + +public final class ChallengeResultImpl implements ChallengeResult { + + private static final String KEY_TRANSACTION_STATUS = "transStatus"; + + private static final String VALUE_TRANSACTION_STATUS = "Y"; + + private final boolean mIsAuthenticated; + + private final String mPayload; + + @NonNull + public static ChallengeResult from(@NonNull CompletionEvent completionEvent) throws JSONException { + String transactionStatus = completionEvent.getTransactionStatus(); + + boolean isAuthenticated = VALUE_TRANSACTION_STATUS.equals(transactionStatus); + + JSONObject jsonObject = new JSONObject(); + jsonObject.put(KEY_TRANSACTION_STATUS, transactionStatus); + + String payload = Base64Coder.encodeToString(jsonObject); + + return new ChallengeResultImpl(isAuthenticated, payload); + } + + private ChallengeResultImpl(boolean isAuthenticated, @NonNull String payload) { + mIsAuthenticated = isAuthenticated; + mPayload = payload; + } + + @Override + public boolean isAuthenticated() { + return mIsAuthenticated; + } + + @NonNull + @Override + public String getPayload() { + return mPayload; + } +} diff --git a/checkout-threeds/src/main/java/com/adyen/checkout/threeds/internal/model/Challenge.java b/checkout-threeds/src/main/java/com/adyen/checkout/threeds/internal/model/Challenge.java new file mode 100644 index 0000000000..24f3e63e82 --- /dev/null +++ b/checkout-threeds/src/main/java/com/adyen/checkout/threeds/internal/model/Challenge.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2019 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by Ran Haveshush on 20/11/2018. + */ + +package com.adyen.checkout.threeds.internal.model; + +import android.os.Parcelable; +import android.support.annotation.NonNull; + +import com.adyen.checkout.base.internal.JsonDecodable; + +import org.json.JSONException; +import org.json.JSONObject; + +@SuppressWarnings("AbbreviationAsWordInName") +public final class Challenge extends JsonDecodable { + public static final Parcelable.Creator CREATOR = new DefaultCreator<>(Challenge.class); + + private static final String KEY_MESSAGE_VERSION = "messageVersion"; + + private static final String KEY_THREE_DS_SERVER_TRANSACTION_ID = "threeDSServerTransID"; + + private static final String KEY_ACS_TRANSACTION_ID = "acsTransID"; + + private static final String KEY_ACS_REFERENCE_NUMBER = "acsReferenceNumber"; + + private static final String KEY_ACS_SIGNED_CONTENT = "acsSignedContent"; + + private static final String KEY_ACS_URL = "acsURL"; + + private final String mMessageVersion; + + private final String mThreeDSServerTransID; + + private final String mAcsTransID; + + private final String mAcsReferenceNumber; + + private final String mAcsSignedContent; + + private final String mAcsURL; + + public Challenge(@NonNull JSONObject jsonObject) throws JSONException { + super(jsonObject); + + mMessageVersion = jsonObject.getString(KEY_MESSAGE_VERSION); + mThreeDSServerTransID = jsonObject.getString(KEY_THREE_DS_SERVER_TRANSACTION_ID); + mAcsTransID = jsonObject.getString(KEY_ACS_TRANSACTION_ID); + mAcsReferenceNumber = jsonObject.getString(KEY_ACS_REFERENCE_NUMBER); + mAcsSignedContent = jsonObject.getString(KEY_ACS_SIGNED_CONTENT); + mAcsURL = jsonObject.getString(KEY_ACS_URL); + } + + @NonNull + public String getMessageVersion() { + return mMessageVersion; + } + + @NonNull + public String getThreeDSServerTransID() { + return mThreeDSServerTransID; + } + + @NonNull + public String getAcsTransID() { + return mAcsTransID; + } + + @NonNull + public String getAcsReferenceNumber() { + return mAcsReferenceNumber; + } + + @NonNull + public String getAcsSignedContent() { + return mAcsSignedContent; + } + + @NonNull + public String getAcsURL() { + return mAcsURL; + } +} diff --git a/checkout-threeds/src/main/java/com/adyen/checkout/threeds/internal/model/Fingerprint.java b/checkout-threeds/src/main/java/com/adyen/checkout/threeds/internal/model/Fingerprint.java new file mode 100644 index 0000000000..2a0f7ae8f6 --- /dev/null +++ b/checkout-threeds/src/main/java/com/adyen/checkout/threeds/internal/model/Fingerprint.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2019 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by Ran Haveshush on 19/11/2018. + */ + +package com.adyen.checkout.threeds.internal.model; + +import android.support.annotation.NonNull; + +import com.adyen.checkout.base.internal.JsonEncodable; +import com.adyen.threeds2.AuthenticationRequestParameters; + +import org.json.JSONException; +import org.json.JSONObject; + +public final class Fingerprint extends JsonEncodable { + + private final AuthenticationRequestParameters mAuthenticationRequestParameters; + + public Fingerprint(@NonNull AuthenticationRequestParameters authenticationRequestParameters) { + mAuthenticationRequestParameters = authenticationRequestParameters; + } + + @NonNull + @Override + public JSONObject serialize() throws JSONException { + JSONObject jsonObject = new JSONObject(); + jsonObject.put("sdkAppID", mAuthenticationRequestParameters.getSDKAppID()); + jsonObject.put("sdkEncData", mAuthenticationRequestParameters.getDeviceData()); + jsonObject.put("sdkEphemPubKey", new JSONObject(mAuthenticationRequestParameters.getSDKEphemeralPublicKey())); + jsonObject.put("sdkReferenceNumber", mAuthenticationRequestParameters.getSDKReferenceNumber()); + jsonObject.put("sdkTransID", mAuthenticationRequestParameters.getSDKTransactionID()); + + return jsonObject; + } + + @NonNull + public AuthenticationRequestParameters getAuthenticationRequestParameters() { + return mAuthenticationRequestParameters; + } +} diff --git a/checkout-threeds/src/main/java/com/adyen/checkout/threeds/internal/model/FingerprintToken.java b/checkout-threeds/src/main/java/com/adyen/checkout/threeds/internal/model/FingerprintToken.java new file mode 100644 index 0000000000..5d32bacc92 --- /dev/null +++ b/checkout-threeds/src/main/java/com/adyen/checkout/threeds/internal/model/FingerprintToken.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2019 Adyen N.V. + * + * This file is open source and available under the MIT license. See the LICENSE file for more info. + * + * Created by Ran Haveshush on 16/11/2018. + */ + +package com.adyen.checkout.threeds.internal.model; + +import android.os.Parcelable; +import android.support.annotation.NonNull; + +import com.adyen.checkout.base.internal.JsonDecodable; + +import org.json.JSONException; +import org.json.JSONObject; + +@SuppressWarnings("AbbreviationAsWordInName") +public final class FingerprintToken extends JsonDecodable { + public static final Parcelable.Creator CREATOR = new DefaultCreator<>(FingerprintToken.class); + + private static final String KEY_THREE_DS_SERVER_TRANSACTION_ID = "threeDSServerTransID"; + + private static final String KEY_DIRECTORY_SERVER_ID = "directoryServerId"; + + private static final String KEY_DIRECTORY_SERVER_PUBLIC_KEY = "directoryServerPublicKey"; + + private final String mThreeDSServerTransID; + + private final String mDirectoryServerId; + + private final String mDirectoryServerPublicKey; + + public FingerprintToken(@NonNull JSONObject jsonObject) throws JSONException { + super(jsonObject); + + mThreeDSServerTransID = jsonObject.getString(KEY_THREE_DS_SERVER_TRANSACTION_ID); + mDirectoryServerId = jsonObject.getString(KEY_DIRECTORY_SERVER_ID); + mDirectoryServerPublicKey = jsonObject.getString(KEY_DIRECTORY_SERVER_PUBLIC_KEY); + } + + @NonNull + public String getThreeDSServerTransID() { + return mThreeDSServerTransID; + } + + @NonNull + public String getDirectoryServerId() { + return mDirectoryServerId; + } + + @NonNull + public String getDirectoryServerPublicKey() { + return mDirectoryServerPublicKey; + } +} diff --git a/checkout-ui/build.gradle b/checkout-ui/build.gradle index 5f1f59b653..135a1da179 100644 --- a/checkout-ui/build.gradle +++ b/checkout-ui/build.gradle @@ -43,6 +43,7 @@ dependencies { implementation project(":checkout-util") // Plugins + compileOnly project(":checkout-threeds") compileOnly project(":checkout-nfc") compileOnly project(":checkout-googlepay") compileOnly project(":checkout-wechatpay") diff --git a/checkout-ui/src/main/java/com/adyen/checkout/ui/internal/card/CardDetailsActivity.java b/checkout-ui/src/main/java/com/adyen/checkout/ui/internal/card/CardDetailsActivity.java index f2125ff995..e9164c6407 100644 --- a/checkout-ui/src/main/java/com/adyen/checkout/ui/internal/card/CardDetailsActivity.java +++ b/checkout-ui/src/main/java/com/adyen/checkout/ui/internal/card/CardDetailsActivity.java @@ -41,6 +41,8 @@ import com.adyen.checkout.base.internal.Objects; import com.adyen.checkout.core.AdditionalDetails; +import com.adyen.checkout.core.AuthenticationDetails; +import com.adyen.checkout.core.CheckoutException; import com.adyen.checkout.core.Observable; import com.adyen.checkout.core.PaymentHandler; import com.adyen.checkout.core.PaymentReference; @@ -51,15 +53,23 @@ import com.adyen.checkout.core.card.EncryptedCard; import com.adyen.checkout.core.card.EncryptionException; import com.adyen.checkout.core.handler.AdditionalDetailsHandler; +import com.adyen.checkout.core.handler.AuthenticationHandler; +import com.adyen.checkout.core.internal.model.ChallengeAuthentication; +import com.adyen.checkout.core.internal.model.FingerprintAuthentication; import com.adyen.checkout.core.internal.model.InputDetailImpl; import com.adyen.checkout.core.internal.model.PaymentMethodImpl; import com.adyen.checkout.core.model.CardDetails; +import com.adyen.checkout.core.model.ChallengeDetails; import com.adyen.checkout.core.model.CupSecurePlusDetails; +import com.adyen.checkout.core.model.FingerprintDetails; import com.adyen.checkout.core.model.InputDetail; import com.adyen.checkout.core.model.Item; import com.adyen.checkout.core.model.PaymentMethod; import com.adyen.checkout.core.model.PaymentSession; import com.adyen.checkout.nfc.NfcCardReader; +import com.adyen.checkout.threeds.Card3DS2Authenticator; +import com.adyen.checkout.threeds.ChallengeResult; +import com.adyen.checkout.threeds.ThreeDS2Exception; import com.adyen.checkout.ui.R; import com.adyen.checkout.ui.internal.common.activity.CheckoutDetailsActivity; import com.adyen.checkout.ui.internal.common.fragment.ErrorDialogFragment; @@ -123,6 +133,8 @@ public class CardDetailsActivity extends CheckoutDetailsActivity private NfcCardReader mNfcCardReader; + private Card3DS2Authenticator mCard3DS2Authenticator; + private ConnectivityDelegate mConnectivityDelegate; @NonNull @@ -206,6 +218,24 @@ public boolean isValid() { mNfcCardReader = null; } + try { + mCard3DS2Authenticator = new Card3DS2Authenticator(this, new Card3DS2Authenticator.AuthenticationListener() { + @Override + public void onSuccess(@NonNull ChallengeResult challengeResult) { + mCard3DS2Authenticator.release(); + ChallengeDetails challengeDetails = new ChallengeDetails(challengeResult.getPayload()); + getPaymentHandler().submitAuthenticationDetails(challengeDetails); + } + + @Override + public void onFailure(@NonNull ThreeDS2Exception e) { + mCard3DS2Authenticator.release(); + } + }); + } catch (NoClassDefFoundError e) { + mCard3DS2Authenticator = null; + } + mConnectivityDelegate = new ConnectivityDelegate(this, new Observer() { @Override public void onChanged(@Nullable NetworkInfo networkInfo) { @@ -237,6 +267,43 @@ public void onChanged(@NonNull PaymentSession paymentSession) { paymentSessionObservable.removeObserver(this); } }); + + if (mCard3DS2Authenticator != null) { + paymentHandler.setAuthenticationHandler(this, new AuthenticationHandler() { + @Override + public void onAuthenticationDetailsRequired(@NonNull AuthenticationDetails authenticationDetails) { + try { + switch (authenticationDetails.getResultCode()) { + case IDENTIFY_SHOPPER: { + FingerprintAuthentication authentication = authenticationDetails.getAuthentication(FingerprintAuthentication.class); + String encodedFingerprintToken = authentication.getFingerprintToken(); + String encodedFingerprint = mCard3DS2Authenticator.createFingerprint(encodedFingerprintToken); + FingerprintDetails fingerprintDetails = new FingerprintDetails(encodedFingerprint); + getPaymentHandler().submitAuthenticationDetails(fingerprintDetails); + break; + } + case CHALLENGE_SHOPPER: { + ChallengeAuthentication authentication = authenticationDetails.getAuthentication(ChallengeAuthentication.class); + String encodedChallengeToken = authentication.getChallengeToken(); + mCard3DS2Authenticator.presentChallenge(encodedChallengeToken); + break; + } + default: + ErrorDialogFragment + .newInstance(CardDetailsActivity.this, + new IllegalStateException("Unsupported result code: " + authenticationDetails.getResultCode())) + .showIfNotShown(getSupportFragmentManager()); + break; + } + } catch (CheckoutException | ThreeDS2Exception e) { + ErrorDialogFragment + .newInstance(CardDetailsActivity.this, e) + .showIfNotShown(getSupportFragmentManager()); + } + } + }); + } + paymentHandler.setAdditionalDetailsHandler(this, new AdditionalDetailsHandler() { @Override public void onAdditionalDetailsRequired(@NonNull AdditionalDetails additionalDetails) { @@ -282,6 +349,14 @@ protected void onPause() { } @Override + protected void onDestroy() { + super.onDestroy(); + + if (mCard3DS2Authenticator != null) { + mCard3DS2Authenticator.release(); + } + } + public boolean onCreateOptionsMenu(@NonNull Menu menu) { getMenuInflater().inflate(R.menu.menu_card_details, menu); @@ -417,7 +492,7 @@ private void setupCardLogoViews() { @Override public void setImageDrawable(@Nullable Drawable drawable) { - Drawable drawableLeft = new InsetDrawable(drawable, 0, 0, mInsetRight, 0); + Drawable drawableLeft = new InsetDrawable(drawable, 0, 0, mInsetRight, 0); Resources resources = getResources(); int width = resources.getDimensionPixelSize(R.dimen.payment_method_logo_width) + mInsetRight; int height = resources.getDimensionPixelSize(R.dimen.payment_method_logo_height); @@ -510,7 +585,7 @@ public void afterTextChanged(Editable s) { if (securityCodeValidationResult.getValidity() == CardValidator.Validity.VALID && PaymentMethodUtil.getRequirementForInputDetail(CardDetails.KEY_PHONE_NUMBER, mAllowedPaymentMethods) - != PaymentMethodUtil.Requirement.NONE) { + != PaymentMethodUtil.Requirement.NONE) { KeyboardUtil.showAndSelect(mPhoneNumberEditText); } } diff --git a/config/lint/lint.xml b/config/lint/lint.xml index 48c6cd71d4..2fbfc1ae64 100644 --- a/config/lint/lint.xml +++ b/config/lint/lint.xml @@ -6,6 +6,9 @@ + + + diff --git a/example-app/build.gradle b/example-app/build.gradle index 50e3e07864..fed4388010 100644 --- a/example-app/build.gradle +++ b/example-app/build.gradle @@ -37,16 +37,16 @@ dependencies { implementation "com.android.support:appcompat-v7:${rootProject.supportLibVersion}" - implementation "io.reactivex.rxjava2:rxjava:2.1.6" - implementation "io.reactivex.rxjava2:rxandroid:2.0.1" + implementation "io.reactivex.rxjava2:rxjava:2.2.2" + implementation "io.reactivex.rxjava2:rxandroid:2.1.0" implementation "com.squareup.retrofit2:retrofit:2.4.0" implementation "com.squareup.retrofit2:converter-moshi:2.4.0" implementation "com.squareup.retrofit2:adapter-rxjava2:2.4.0" - implementation "com.squareup.okhttp3:logging-interceptor:3.10.0" + implementation "com.squareup.okhttp3:logging-interceptor:3.11.0" - implementation "com.squareup.moshi:moshi:1.5.0" + implementation "com.squareup.moshi:moshi:1.6.0" implementation project(":checkout-base") implementation project(":checkout-core") @@ -54,6 +54,7 @@ dependencies { implementation project(":checkout-ui") // Plugins + implementation project(":checkout-threeds") implementation project(":checkout-nfc") implementation project(":checkout-googlepay") implementation project(":checkout-wechatpay") diff --git a/example-app/src/androidTest/java/com/adyen/example/androidtest/auto/AutoPaymentAppTest.java b/example-app/src/androidTest/java/com/adyen/example/androidtest/auto/AutoPaymentAppTest.java index 1ce6d2d549..b3dc7949c1 100644 --- a/example-app/src/androidTest/java/com/adyen/example/androidtest/auto/AutoPaymentAppTest.java +++ b/example-app/src/androidTest/java/com/adyen/example/androidtest/auto/AutoPaymentAppTest.java @@ -18,6 +18,7 @@ import com.adyen.example.MainActivity; import com.adyen.example.R; import com.adyen.example.androidtest.EspressoTestUtils; +import com.adyen.example.androidtest.PaymentSetup; import org.junit.After; import org.junit.Rule; @@ -25,18 +26,25 @@ import org.junit.runner.RunWith; import static android.support.test.InstrumentationRegistry.getInstrumentation; +import static android.support.test.espresso.Espresso.onData; import static android.support.test.espresso.Espresso.onView; import static android.support.test.espresso.action.ViewActions.clearText; import static android.support.test.espresso.action.ViewActions.click; import static android.support.test.espresso.action.ViewActions.typeText; import static android.support.test.espresso.matcher.ViewMatchers.isClickable; import static android.support.test.espresso.matcher.ViewMatchers.withId; +import static android.support.test.espresso.web.sugar.Web.onWebView; +import static com.adyen.example.androidtest.PaymentAppTestUtils.changePaymentSetup; import static com.adyen.example.androidtest.PaymentAppTestUtils.confirmPaymentAndWaitForResult; import static com.adyen.example.androidtest.PaymentAppTestUtils.goToPaymentMethodsOverview; import static com.adyen.example.androidtest.PaymentAppTestUtils.selectPaymentMethodByName; +import static org.hamcrest.Matchers.anything; @RunWith(AndroidJUnit4.class) public class AutoPaymentAppTest { + + private String creditCardNumberThreeds = "4111111111111111"; + @Rule public ActivityTestRule mMainActivityTestRule = new ActivityTestRule<>(MainActivity.class); @@ -47,6 +55,55 @@ public void tearDown() throws Exception { @Test public void testCardPayment() throws Exception { + mMainActivityTestRule.launchActivity(null); + this.performCreditCardPayment("5555444433331111"); + confirmPaymentAndWaitForResult(); + } + + @Test + public void testCardPaymentThreedsText() throws Exception { + this.performCreditCardPayment(this.creditCardNumberThreeds); + Thread.sleep(500); + onView(withId(com.adyen.threeds2.R.id.editText_text)).perform(clearText(), typeText("1234")); + onView(withId(com.adyen.threeds2.R.id.button_continue)).perform(click()); + confirmPaymentAndWaitForResult(); + } + + @Test + public void testCardPaymentThreedsSingleSelect() throws Exception { + changeAmount(12120L); + this.performCreditCardPayment(this.creditCardNumberThreeds); + Thread.sleep(500); + onView(withId(com.adyen.threeds2.R.id.button_next)).perform(click()); + confirmPaymentAndWaitForResult(); + } + + @Test + public void testCardPaymentThreedsMultiSelect() throws Exception { + changeAmount(12120L); + this.performCreditCardPayment(this.creditCardNumberThreeds); + Thread.sleep(500); + onData(anything()).inAdapterView(withId(com.adyen.threeds2.R.id.listView_selectInfoItems)) + .atPosition(0).perform(click()); + onView(withId(com.adyen.threeds2.R.id.button_next)).perform(click()); + confirmPaymentAndWaitForResult(); + } + + @Test + public void testCardPaymentThreedsOutOfBand() throws Exception { + changeAmount(12130L); + this.performCreditCardPayment(this.creditCardNumberThreeds); + Thread.sleep(500); + onView(withId(com.adyen.threeds2.R.id.button_continue)).perform(click()); + confirmPaymentAndWaitForResult(); + } + + + @Test + public void testCardPaymentWith3dth() throws Exception { + + mMainActivityTestRule.launchActivity(null); + goToPaymentMethodsOverview(); selectPaymentMethodByName("Credit Card"); @@ -78,4 +135,27 @@ public void testSddPayment() throws Exception { confirmPaymentAndWaitForResult(); } + + private void performCreditCardPayment(String creditCardNumber) throws Exception { + goToPaymentMethodsOverview(); + + selectPaymentMethodByName("Credit Card"); + + onView(withId(com.adyen.checkout.ui.R.id.editText_cardNumber)).perform(clearText(), typeText(creditCardNumber)); + onView(withId(com.adyen.checkout.ui.R.id.editText_expiryDate)).perform(clearText(), typeText("1020")); + onView(withId(com.adyen.checkout.ui.R.id.editText_securityCode)).perform(clearText(), typeText("737")); + KeyboardUtil.hide(EspressoTestUtils.waitForActivity(CardDetailsActivity.class).findViewById(R.id.button_pay)); + Thread.sleep(500); + onView(withId(com.adyen.checkout.ui.R.id.button_pay)).check(ViewAssertions.matches(isClickable())); + onView(withId(com.adyen.checkout.ui.R.id.button_pay)).perform(click()); + } + + private void changeAmount(long amount) { + PaymentSetup paymentSetup = new PaymentSetup.Builder() + .setAmount(new PaymentSetup.Amount(amount, "EUR")) + .setCountryCode("NL") + .build(); + + changePaymentSetup(paymentSetup); + } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5327edc393..588b7d2732 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,5 +3,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip distributionSha256Sum=9af7345c199f1731c187c96d3fe3d31f5405192a42046bafa71d846c3d9adacb diff --git a/settings.gradle b/settings.gradle index 8a67a04ec2..fe1dfb9376 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,6 +3,7 @@ include ':checkout-base', ':checkout-core-card', ':checkout-googlepay', ':checkout-nfc', + ':checkout-threeds', ':checkout-ui', ':checkout-util', ':checkout-wechatpay',