chromium/components/payments/content/android/java/src/org/chromium/components/payments/intent/WebPaymentIntentHelper.java

// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.components.payments.intent;

import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.os.Parcelable;
import android.text.TextUtils;
import android.util.JsonWriter;

import androidx.annotation.Nullable;

import org.chromium.components.payments.Address;
import org.chromium.components.payments.ErrorStrings;
import org.chromium.components.payments.PayerData;
import org.chromium.components.payments.intent.WebPaymentIntentHelperType.PaymentDetailsModifier;
import org.chromium.components.payments.intent.WebPaymentIntentHelperType.PaymentItem;
import org.chromium.components.payments.intent.WebPaymentIntentHelperType.PaymentMethodData;
import org.chromium.components.payments.intent.WebPaymentIntentHelperType.PaymentOptions;
import org.chromium.components.payments.intent.WebPaymentIntentHelperType.PaymentShippingOption;

import java.io.IOException;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;

/** The helper that handles intent for AndroidPaymentApp. */
public class WebPaymentIntentHelper {
    /** The action name for the Pay Intent. */
    public static final String ACTION_PAY = "org.chromium.intent.action.PAY";

    // Freshest parameters sent to the payment app.
    public static final String EXTRA_CERTIFICATE = "certificate";
    public static final String EXTRA_MERCHANT_NAME = "merchantName";
    public static final String EXTRA_METHOD_DATA = "methodData";
    public static final String EXTRA_METHOD_NAMES = "methodNames";
    public static final String EXTRA_MODIFIERS = "modifiers";
    public static final String EXTRA_PAYMENT_REQUEST_ID = "paymentRequestId";
    public static final String EXTRA_PAYMENT_REQUEST_ORIGIN = "paymentRequestOrigin";
    public static final String EXTRA_TOP_CERTIFICATE_CHAIN = "topLevelCertificateChain";
    public static final String EXTRA_TOP_ORIGIN = "topLevelOrigin";
    public static final String EXTRA_TOTAL = "total";
    public static final String EXTRA_PAYMENT_OPTIONS = "paymentOptions";
    public static final String EXTRA_PAYMENT_OPTIONS_REQUEST_PAYER_NAME = "requestPayerName";
    public static final String EXTRA_PAYMENT_OPTIONS_REQUEST_PAYER_PHONE = "requestPayerPhone";
    public static final String EXTRA_PAYMENT_OPTIONS_REQUEST_PAYER_EMAIL = "requestPayerEmail";
    public static final String EXTRA_PAYMENT_OPTIONS_REQUEST_SHIPPING = "requestShipping";
    public static final String EXTRA_PAYMENT_OPTIONS_SHIPPING_TYPE = "shippingType";
    public static final String EXTRA_SHIPPING_OPTIONS = "shippingOptions";

    // Deprecated parameters sent to the payment app for backward compatibility.
    // TODO(crbug.com/40849135): Remove these parameters.
    public static final String EXTRA_DEPRECATED_CERTIFICATE_CHAIN = "certificateChain";
    public static final String EXTRA_DEPRECATED_DATA = "data";
    public static final String EXTRA_DEPRECATED_DATA_MAP = "dataMap";
    public static final String EXTRA_DEPRECATED_DETAILS = "details";
    public static final String EXTRA_DEPRECATED_ID = "id";
    public static final String EXTRA_DEPRECATED_IFRAME_ORIGIN = "iframeOrigin";
    public static final String EXTRA_DEPRECATED_METHOD_NAME = "methodName";
    public static final String EXTRA_DEPRECATED_ORIGIN = "origin";

    // Response from the payment app.
    public static final String EXTRA_DEPRECATED_RESPONSE_INSTRUMENT_DETAILS = "instrumentDetails";
    public static final String EXTRA_RESPONSE_DETAILS = "details";
    public static final String EXTRA_RESPONSE_METHOD_NAME = "methodName";
    public static final String EXTRA_RESPONSE_PAYER_NAME = "payerName";
    public static final String EXTRA_RESPONSE_PAYER_EMAIL = "payerEmail";
    public static final String EXTRA_RESPONSE_PAYER_PHONE = "payerPhone";
    public static final String EXTRA_SHIPPING_OPTION_ID = "shippingOptionId";

    // Shipping address bundle used in payment response and shippingAddressChange.
    public static final String EXTRA_SHIPPING_ADDRESS = "shippingAddress";

    private static final String EMPTY_JSON_DATA = "{}";

    /** Invoked to report error for {@link #parsePaymentResponse}. */
    public interface PaymentErrorCallback {
        /** @param errorString The string that explains the error. */
        void onPaymentError(String errorString);
    }

    /** Invoked to receive parsed data for {@link #parsePaymentResponse}. */
    public interface PaymentSuccessCallback {
        /**
         * @param methodName The method name parsed from the intent response.
         * @param details The instrument details parsed from the intent response.
         * @param payerData The payer data parsed from the intent response.
         */
        void onPaymentSuccess(String methodName, String details, PayerData payerData);
    }

    /**
     * Get stringified payment details from a payment intent.
     *
     * @param data The payment intent data.
     * @return The stringified payment details, if any.
     */
    @Nullable
    public static String getPaymentIntentDetails(Intent data) {
        String details = data.getExtras().getString(EXTRA_RESPONSE_DETAILS);
        if (details == null) {
            // try to get deprecated details rather than early returning.
            details = data.getExtras().getString(EXTRA_DEPRECATED_RESPONSE_INSTRUMENT_DETAILS);
        }
        return details;
    }

    /**
     * Parse the Payment Intent response.
     * @param resultCode Result code of the requested intent.
     * @param data The intent response data.
     * @param requestedPaymentOptions The merchant required payment options. This is used to
     *          populate relevant fields in payerData.
     * @param errorCallback Callback to handle parsing errors. Invoked synchronously.
     * @param successCallback Callback to receive the parsed data. Invoked synchronously.
     **/
    public static void parsePaymentResponse(
            int resultCode,
            Intent data,
            PaymentOptions requestedPaymentOptions,
            PaymentErrorCallback errorCallback,
            PaymentSuccessCallback successCallback) {
        if (data == null) {
            errorCallback.onPaymentError(ErrorStrings.MISSING_INTENT_DATA);
            return;
        }
        if (data.getExtras() == null) {
            errorCallback.onPaymentError(ErrorStrings.MISSING_INTENT_EXTRAS);
            return;
        }
        if (resultCode == Activity.RESULT_CANCELED) {
            errorCallback.onPaymentError(ErrorStrings.RESULT_CANCELED);
            return;
        }
        if (resultCode != Activity.RESULT_OK) {
            errorCallback.onPaymentError(
                    String.format(
                            Locale.US, ErrorStrings.UNRECOGNIZED_ACTIVITY_RESULT, resultCode));
            return;
        }

        String details = getPaymentIntentDetails(data);
        if (TextUtils.isEmpty(details)) {
            errorCallback.onPaymentError(ErrorStrings.MISSING_DETAILS_FROM_PAYMENT_APP);
            return;
        }

        String methodName = data.getExtras().getString(EXTRA_RESPONSE_METHOD_NAME);
        if (TextUtils.isEmpty(methodName)) {
            errorCallback.onPaymentError(ErrorStrings.MISSING_METHOD_NAME_FROM_PAYMENT_APP);
            return;
        }

        if (requestedPaymentOptions == null) {
            successCallback.onPaymentSuccess(
                    /* methodName= */ methodName, /* details= */ details, new PayerData());
            return;
        }

        Address shippingAddress;
        if (requestedPaymentOptions.requestShipping) {
            Bundle addressBundle = data.getExtras().getBundle(EXTRA_SHIPPING_ADDRESS);
            if (addressBundle == null || addressBundle.isEmpty()) {
                errorCallback.onPaymentError(ErrorStrings.SHIPPING_ADDRESS_INVALID);
                return;
            }
            shippingAddress = Address.createFromBundle(addressBundle);
        } else { // !requestedPaymentOptions.requestShipping
            shippingAddress = new Address();
        }

        String payerName =
                requestedPaymentOptions.requestPayerName
                        ? getStringOrEmpty(data, EXTRA_RESPONSE_PAYER_NAME)
                        : "";
        if (requestedPaymentOptions.requestPayerName && TextUtils.isEmpty(payerName)) {
            errorCallback.onPaymentError(ErrorStrings.PAYER_NAME_EMPTY);
            return;
        }

        String payerPhone =
                requestedPaymentOptions.requestPayerPhone
                        ? getStringOrEmpty(data, EXTRA_RESPONSE_PAYER_PHONE)
                        : "";
        if (requestedPaymentOptions.requestPayerPhone && TextUtils.isEmpty(payerPhone)) {
            errorCallback.onPaymentError(ErrorStrings.PAYER_PHONE_EMPTY);
            return;
        }

        String payerEmail =
                requestedPaymentOptions.requestPayerEmail
                        ? getStringOrEmpty(data, EXTRA_RESPONSE_PAYER_EMAIL)
                        : "";
        if (requestedPaymentOptions.requestPayerEmail && TextUtils.isEmpty(payerEmail)) {
            errorCallback.onPaymentError(ErrorStrings.PAYER_EMAIL_EMPTY);
            return;
        }

        String selectedShippingOptionId =
                requestedPaymentOptions.requestShipping
                        ? getStringOrEmpty(data, EXTRA_SHIPPING_OPTION_ID)
                        : "";
        if (requestedPaymentOptions.requestShipping
                && TextUtils.isEmpty(selectedShippingOptionId)) {
            errorCallback.onPaymentError(ErrorStrings.SHIPPING_OPTION_EMPTY);
            return;
        }

        successCallback.onPaymentSuccess(
                /* methodName= */ methodName,
                /* details= */ details,
                /* payerData= */ new PayerData(
                        payerName,
                        payerPhone,
                        payerEmail,
                        shippingAddress,
                        selectedShippingOptionId));
    }

    /**
     * Create an intent to invoke a native payment app. This method throws IllegalArgumentException
     * for invalid arguments.
     *
     * @param packageName The name of the package of the payment app. Only non-empty string is
     *         allowed.
     * @param activityName The name of the payment activity in the payment app. Only non-empty
     *         string is allowed.
     * @param id The unique identifier of the PaymentRequest. Only non-empty string is allowed.
     * @param merchantName The name of the merchant. Cannot be null..
     * @param schemelessOrigin The schemeless origin of this merchant. Only non-empty string is
     *         allowed.
     * @param schemelessIframeOrigin The schemeless origin of the iframe that invoked
     *         PaymentRequest. Only non-empty string is allowed.
     * @param certificateChain The site certificate chain of the merchant. Can be null for
     *         localhost or local file, which are secure contexts without SSL. Each byte array
     *         cannot be null.
     * @param methodDataMap The payment-method specific data for all applicable payment methods,
     *         e.g., whether the app should be invoked in test or production, a merchant identifier,
     *         or a public key. The map and its values cannot be null. The map should have at
     *         least one entry.
     * @param total The total amount. Cannot be null..
     * @param displayItems The shopping cart items. OK to be null.
     * @param modifiers The relevant payment details modifiers. OK to be null.
     * @param paymentOptions The relevant merchant requested payment options. OK to be null.
     * @param shippingOptions Merchant specified available shipping options. Should be non-empty
     *          when paymentOptions.requestShipping is true.
     * @return The intent to invoke the payment app.
     */
    public static Intent createPayIntent(
            String packageName,
            String activityName,
            String id,
            String merchantName,
            String schemelessOrigin,
            String schemelessIframeOrigin,
            @Nullable byte[][] certificateChain,
            Map<String, PaymentMethodData> methodDataMap,
            PaymentItem total,
            @Nullable List<PaymentItem> displayItems,
            @Nullable Map<String, PaymentDetailsModifier> modifiers,
            @Nullable PaymentOptions paymentOptions,
            @Nullable List<PaymentShippingOption> shippingOptions) {
        Intent payIntent = new Intent();
        checkStringNotEmpty(activityName, "activityName");
        checkStringNotEmpty(packageName, "packageName");
        payIntent.setClassName(packageName, activityName);
        payIntent.setAction(ACTION_PAY);
        payIntent.putExtras(
                buildPayIntentExtras(
                        id,
                        merchantName,
                        schemelessOrigin,
                        schemelessIframeOrigin,
                        certificateChain,
                        methodDataMap,
                        total,
                        displayItems,
                        modifiers,
                        paymentOptions,
                        shippingOptions));
        return payIntent;
    }

    /**
     * Create an intent to invoke a service that can answer "is ready to pay" query.
     *
     * @param packageName The name of the package of the payment app. Only non-empty string is
     *         allowed.
     * @param serviceName The name of the service. Only non-empty string is allowed.
     * @param schemelessOrigin The schemeless origin of this merchant. Only non-empty string is
     *         allowed.
     * @param schemelessIframeOrigin The schemeless origin of the iframe that invoked
     *         PaymentRequest. Only non-empty string is allowed.
     * @param certificateChain The site certificate chain of the merchant. Can be null for localhost
     *         or local file, which are secure contexts without SSL. Each byte array
     *         cannot be null.
     * @param methodDataMap The payment-method specific data for all applicable payment methods,
     *         e.g., whether the app should be invoked in test or production, a merchant identifier,
     *         or a public key. The map should have at least one entry.
     * @param clearIdFields When this feature flag is enabled, the IS_READY_TO_PAY
     *        intent should NOT pass merchant and user identity to the payment app.
     * @return The intent to invoke the service.
     */
    public static Intent createIsReadyToPayIntent(
            String packageName,
            String serviceName,
            String schemelessOrigin,
            String schemelessIframeOrigin,
            @Nullable byte[][] certificateChain,
            Map<String, PaymentMethodData> methodDataMap,
            boolean clearIdFields) {
        Intent isReadyToPayIntent = new Intent();
        checkStringNotEmpty(serviceName, "serviceName");
        checkStringNotEmpty(packageName, "packageName");
        isReadyToPayIntent.setClassName(packageName, serviceName);
        Bundle extras = new Bundle();
        if (!clearIdFields) {
            addCommonExtrasWithIdentity(
                    schemelessOrigin,
                    schemelessIframeOrigin,
                    certificateChain,
                    methodDataMap,
                    extras);
        }
        isReadyToPayIntent.putExtras(extras);
        return isReadyToPayIntent;
    }

    private static void checkNotEmpty(Map map, String name) {
        if (map == null || map.isEmpty()) {
            throw new IllegalArgumentException(name + " should not be null or empty.");
        }
    }

    private static void checkStringNotEmpty(String value, String name) {
        if (TextUtils.isEmpty(value)) {
            throw new IllegalArgumentException(name + " should not be null or empty.");
        }
    }

    private static void checkNotNull(Object value, String name) {
        if (value == null) throw new IllegalArgumentException(name + " should not be null.");
    }

    private static Bundle buildPayIntentExtras(
            String id,
            String merchantName,
            String schemelessOrigin,
            String schemelessIframeOrigin,
            @Nullable byte[][] certificateChain,
            Map<String, PaymentMethodData> methodDataMap,
            PaymentItem total,
            @Nullable List<PaymentItem> displayItems,
            @Nullable Map<String, PaymentDetailsModifier> modifiers,
            @Nullable PaymentOptions paymentOptions,
            @Nullable List<PaymentShippingOption> shippingOptions) {
        Bundle extras = new Bundle();
        checkStringNotEmpty(id, "id");
        extras.putString(EXTRA_PAYMENT_REQUEST_ID, id);

        checkNotNull(merchantName, "merchantName");
        extras.putString(EXTRA_MERCHANT_NAME, merchantName);

        checkNotNull(total, "total");
        String serializedTotalAmount = total.amount.serialize();
        extras.putString(
                EXTRA_TOTAL,
                serializedTotalAmount == null ? EMPTY_JSON_DATA : serializedTotalAmount);

        // modifiers is ok to be null.
        if (modifiers != null) {
            extras.putString(
                    EXTRA_MODIFIERS, PaymentDetailsModifier.serializeModifiers(modifiers.values()));
        }

        // shippingOptions should not be null when shipping is requested.
        if (paymentOptions != null
                && paymentOptions.requestShipping
                && (shippingOptions == null || shippingOptions.isEmpty())) {
            throw new IllegalArgumentException(
                    "shippingOptions should not be null or empty when shipping is requested.");
        }

        if (paymentOptions != null) {
            extras.putBundle(EXTRA_PAYMENT_OPTIONS, buildPaymentOptionsBundle(paymentOptions));
        }

        // ShippingOptions are populated only when shipping is requested.
        if (paymentOptions != null && paymentOptions.requestShipping) {
            Parcelable[] serializedShippingOptionList =
                    PaymentShippingOption.buildPaymentShippingOptionList(shippingOptions);
            extras.putParcelableArray(EXTRA_SHIPPING_OPTIONS, serializedShippingOptionList);
        }

        addCommonExtrasWithIdentity(
                schemelessOrigin, schemelessIframeOrigin, certificateChain, methodDataMap, extras);

        return addDeprecatedPayIntentExtras(id, total, displayItems, extras);
    }

    // Adds to the given `extras` bundle the common fields for both the IS_READY_TO_PAY (if identity
    // in can-make-payment feature is enabled) and the PAY intents.
    private static Bundle addCommonExtrasWithIdentity(
            String schemelessOrigin,
            String schemelessIframeOrigin,
            @Nullable byte[][] certificateChain,
            Map<String, PaymentMethodData> methodDataMap,
            Bundle extras) {
        checkStringNotEmpty(schemelessOrigin, "schemelessOrigin");
        extras.putString(EXTRA_TOP_ORIGIN, schemelessOrigin);

        checkStringNotEmpty(schemelessIframeOrigin, "schemelessIframeOrigin");
        extras.putString(EXTRA_PAYMENT_REQUEST_ORIGIN, schemelessIframeOrigin);

        // certificateChain is ok to be null.
        Parcelable[] serializedCertificateChain = null;
        if (certificateChain != null && certificateChain.length > 0) {
            serializedCertificateChain = buildCertificateChain(certificateChain);
            extras.putParcelableArray(EXTRA_TOP_CERTIFICATE_CHAIN, serializedCertificateChain);
        }

        checkNotEmpty(methodDataMap, "methodDataMap");
        extras.putStringArrayList(EXTRA_METHOD_NAMES, new ArrayList<>(methodDataMap.keySet()));

        Bundle methodDataBundle = new Bundle();
        for (Map.Entry<String, PaymentMethodData> methodData : methodDataMap.entrySet()) {
            checkNotNull(methodData.getValue(), "methodDataMap's entry value");
            methodDataBundle.putString(methodData.getKey(), methodData.getValue().stringifiedData);
        }
        extras.putParcelable(EXTRA_METHOD_DATA, methodDataBundle);

        return addDeprecatedCommonExtrasWithIdentity(
                schemelessOrigin,
                schemelessIframeOrigin,
                serializedCertificateChain,
                methodDataMap,
                methodDataBundle,
                extras);
    }

    // TODO(crbug.com/40849135): Remove this method.
    private static Bundle addDeprecatedCommonExtrasWithIdentity(
            String schemelessOrigin,
            String schemelessIframeOrigin,
            @Nullable Parcelable[] serializedCertificateChain,
            Map<String, PaymentMethodData> methodDataMap,
            Bundle methodDataBundle,
            Bundle extras) {
        extras.putString(EXTRA_DEPRECATED_ORIGIN, schemelessOrigin);

        extras.putString(EXTRA_DEPRECATED_IFRAME_ORIGIN, schemelessIframeOrigin);

        if (serializedCertificateChain != null) {
            extras.putParcelableArray(
                    EXTRA_DEPRECATED_CERTIFICATE_CHAIN, serializedCertificateChain);
        }

        String methodName = methodDataMap.entrySet().iterator().next().getKey();
        extras.putString(EXTRA_DEPRECATED_METHOD_NAME, methodName);

        PaymentMethodData firstMethodData = methodDataMap.get(methodName);
        extras.putString(
                EXTRA_DEPRECATED_DATA,
                firstMethodData == null ? EMPTY_JSON_DATA : firstMethodData.stringifiedData);

        extras.putParcelable(EXTRA_DEPRECATED_DATA_MAP, methodDataBundle);

        return extras;
    }

    // TODO(crbug.com/40849135): Remove this method.
    private static Bundle addDeprecatedPayIntentExtras(
            String id, PaymentItem total, @Nullable List<PaymentItem> displayItems, Bundle extras) {
        extras.putString(EXTRA_DEPRECATED_ID, id);

        // displayItems is ok to be null.
        String details = deprecatedSerializeDetails(total, displayItems);
        extras.putString(EXTRA_DEPRECATED_DETAILS, details == null ? EMPTY_JSON_DATA : details);

        return extras;
    }

    private static Parcelable[] buildCertificateChain(byte[][] certificateChain) {
        Parcelable[] result = new Parcelable[certificateChain.length];
        for (int i = 0; i < certificateChain.length; i++) {
            Bundle bundle = new Bundle();
            checkNotNull(certificateChain[i], "certificateChain[" + i + "]");
            bundle.putByteArray(EXTRA_CERTIFICATE, certificateChain[i]);
            result[i] = bundle;
        }
        return result;
    }

    private static Bundle buildPaymentOptionsBundle(PaymentOptions paymentOptions) {
        Bundle bundle = new Bundle();
        bundle.putBoolean(
                EXTRA_PAYMENT_OPTIONS_REQUEST_PAYER_NAME, paymentOptions.requestPayerName);
        bundle.putBoolean(
                EXTRA_PAYMENT_OPTIONS_REQUEST_PAYER_EMAIL, paymentOptions.requestPayerEmail);
        bundle.putBoolean(
                EXTRA_PAYMENT_OPTIONS_REQUEST_PAYER_PHONE, paymentOptions.requestPayerPhone);
        bundle.putBoolean(EXTRA_PAYMENT_OPTIONS_REQUEST_SHIPPING, paymentOptions.requestShipping);
        if (paymentOptions.shippingType != null) {
            bundle.putString(EXTRA_PAYMENT_OPTIONS_SHIPPING_TYPE, paymentOptions.shippingType);
        }
        return bundle;
    }

    private static String deprecatedSerializeDetails(
            @Nullable PaymentItem total, @Nullable List<PaymentItem> displayItems) {
        StringWriter stringWriter = new StringWriter();
        JsonWriter json = new JsonWriter(stringWriter);
        try {
            // details {{{
            json.beginObject();

            if (total != null) {
                // total {{{
                json.name("total");
                total.serializeAndRedact(json);
                // }}} total
            }

            // displayitems {{{
            if (displayItems != null) {
                json.name("displayItems").beginArray();
                // Do not pass any display items to the payment app.
                json.endArray();
            }
            // }}} displayItems

            json.endObject();
            // }}} details
        } catch (IOException e) {
            return null;
        }

        return stringWriter.toString();
    }

    private static String getStringOrEmpty(Intent data, String key) {
        return data.getExtras().getString(key, /* defaultValue= */ "");
    }
}