chromium/components/payments/content/android/java/src/org/chromium/components/payments/AndroidPaymentApp.java

// Copyright 2017 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;

import android.content.Context;
import android.content.DialogInterface.OnClickListener;
import android.content.Intent;
import android.graphics.drawable.Drawable;
import android.os.Handler;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.app.AlertDialog;

import org.chromium.base.Callback;
import org.chromium.base.ContextUtils;
import org.chromium.base.ThreadUtils;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.components.payments.intent.IsReadyToPayServiceHelper;
import org.chromium.components.payments.intent.WebPaymentIntentHelper;
import org.chromium.components.payments.intent.WebPaymentIntentHelperType;
import org.chromium.components.payments.intent.WebPaymentIntentHelperTypeConverter;
import org.chromium.components.url_formatter.SchemeDisplay;
import org.chromium.components.url_formatter.UrlFormatter;
import org.chromium.content_public.browser.WebContents;
import org.chromium.payments.mojom.PaymentDetailsModifier;
import org.chromium.payments.mojom.PaymentItem;
import org.chromium.payments.mojom.PaymentMethodData;
import org.chromium.payments.mojom.PaymentOptions;
import org.chromium.payments.mojom.PaymentRequestDetailsUpdate;
import org.chromium.payments.mojom.PaymentShippingOption;
import org.chromium.ui.base.WindowAndroid;

import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * The point of interaction with a locally installed 3rd party native Android payment app.
 * https://developers.google.com/web/fundamentals/payments/payment-apps-developer-guide/android-payment-apps
 */
public class AndroidPaymentApp extends PaymentApp
        implements IsReadyToPayServiceHelper.ResultHandler {
    private final Handler mHandler;
    private final Launcher mLauncher;
    private final Set<String> mMethodNames;
    private final boolean mIsIncognito;
    private final String mPackageName;
    private final String mPayActivityName;
    private final String mIsReadyToPayServiceName;
    private IsReadyToPayCallback mIsReadyToPayCallback;
    private InstrumentDetailsCallback mInstrumentDetailsCallback;
    private IsReadyToPayServiceHelper mIsReadyToPayServiceHelper;
    @Nullable private String mApplicationIdentifierToHide;
    private boolean mBypassIsReadyToPayServiceInTest;
    private final SupportedDelegations mSupportedDelegations;
    private boolean mIsPreferred;

    // Set inside launchPaymentApp and used to validate the received response.
    @Nullable private WebPaymentIntentHelperType.PaymentOptions mPaymentOptions;

    /**
     * The interface for launching Android payment apps and for showing a warning about leaving
     * incognito mode when launching an Android payment app.
     */
    public interface Launcher {
        /**
         * Show a warning about leaving incognito mode with a prompt to continue into the payment
         * app.
         * @param denyCallback The callback invoked when the user denies or dismisses the prompt.
         * @param approveCallback The callback invoked when the user approves the prompt.
         */
        default void showLeavingIncognitoWarning(
                Callback<String> denyCallback, Runnable approveCallback) {}

        /**
         * Launch the payment app via an intent.
         * @param intent The intent that includes the payment app identification and parameters.
         * @param errorCallback The callback invoked when invoking the payment app fails.
         * @param intentCallback The callback invoked when the payment app responds to the intent.
         */
        default void launchPaymentApp(
                Intent intent,
                Callback<String> errorCallback,
                Callback<IntentResult> intentCallback) {}
    }

    /** The result of invoking an Android app. */
    public static class IntentResult {
        /** Activity result, either Activity.RESULT_OK or Activity.RESULT_CANCELED. */
        public int resultCode;

        /** The data returned from the payment app. */
        public Intent data;
    }

    /**
     * The default implementation of payment app launcher that uses WindowAndroid for invoking
     * Android apps.
     */
    public static class LauncherImpl implements Launcher, WindowAndroid.IntentCallback {
        private final WebContents mWebContents;
        private Callback<IntentResult> mIntentCallback;

        /**
         * @param webContents The web contents whose WindowAndroid should be used for invoking
         * Android payment apps and receiving the result.
         */
        public LauncherImpl(WebContents webContents) {
            mWebContents = webContents;
        }

        @Nullable
        private Context getActivityContext() {
            WindowAndroid window = mWebContents.getTopLevelNativeWindow();
            return window == null ? null : window.getActivity().get();
        }

        // Launcher implementation.
        @Override
        public void showLeavingIncognitoWarning(
                Callback<String> denyCallback, Runnable approveCallback) {
            Context context = getActivityContext();
            if (context == null) {
                denyCallback.onResult(ErrorStrings.ACTIVITY_NOT_FOUND);
                return;
            }
            new AlertDialog.Builder(context, R.style.ThemeOverlay_BrowserUI_AlertDialog)
                    .setTitle(R.string.external_app_leave_incognito_warning_title)
                    .setMessage(R.string.external_payment_app_leave_incognito_warning)
                    .setPositiveButton(
                            R.string.ok, (OnClickListener) (dialog, which) -> approveCallback.run())
                    .setNegativeButton(
                            R.string.cancel,
                            (OnClickListener)
                                    (dialog, which) ->
                                            denyCallback.onResult(ErrorStrings.USER_CANCELLED))
                    .setOnCancelListener(
                            dialog -> denyCallback.onResult(ErrorStrings.USER_CANCELLED))
                    .show();
        }

        // Launcher implementation.
        @Override
        public void launchPaymentApp(
                Intent intent,
                Callback<String> errorCallback,
                Callback<IntentResult> intentCallback) {
            assert mIntentCallback == null;

            if (mWebContents.isDestroyed()) {
                errorCallback.onResult(ErrorStrings.PAYMENT_APP_LAUNCH_FAIL);
                return;
            }

            WindowAndroid window = mWebContents.getTopLevelNativeWindow();
            if (window == null) {
                errorCallback.onResult(ErrorStrings.PAYMENT_APP_LAUNCH_FAIL);
                return;
            }

            mIntentCallback = intentCallback;
            try {
                if (!window.showIntent(
                        intent, /* callback= */ this, R.string.payments_android_app_error)) {
                    errorCallback.onResult(ErrorStrings.PAYMENT_APP_LAUNCH_FAIL);
                }
            } catch (SecurityException e) {
                // Payment app does not have android:exported="true" on the PAY activity.
                errorCallback.onResult(ErrorStrings.PAYMENT_APP_PRIVATE_ACTIVITY);
            }
        }

        // WindowAndroid.IntentCallback implementation.
        @Override
        public void onIntentCompleted(int resultCode, Intent data) {
            assert mIntentCallback != null;
            IntentResult intentResult = new IntentResult();
            intentResult.resultCode = resultCode;
            intentResult.data = data;
            mIntentCallback.onResult(intentResult);
            mIntentCallback = null;
        }
    }

    /**
     * Builds the point of interaction with a locally installed 3rd party native Android payment
     * app.
     *
     * @param launcher Helps querying and launching the Android payment app. Overridden in unit
     *         tests.
     * @param packageName The name of the package of the payment app.
     * @param activity The name of the payment activity in the payment app.
     * @param isReadyToPayService The name of the service that can answer "is ready to pay"
     *         query, or null of none.
     * @param label The UI label to use for the payment app.
     * @param icon The icon to use in UI for the payment app.
     * @param isIncognito Whether the user is in incognito mode.
     * @param appToHide The identifier of the application that this app can hide.
     * @param supportedDelegations Delegations which this app can support.
     */
    public AndroidPaymentApp(
            Launcher launcher,
            String packageName,
            String activity,
            @Nullable String isReadyToPayService,
            String label,
            Drawable icon,
            boolean isIncognito,
            @Nullable String appToHide,
            SupportedDelegations supportedDelegations) {
        super(packageName, label, null, icon);
        ThreadUtils.assertOnUiThread();
        mHandler = new Handler();
        mLauncher = launcher;

        mPackageName = packageName;
        mPayActivityName = activity;
        mIsReadyToPayServiceName = isReadyToPayService;

        if (mIsReadyToPayServiceName != null) {
            assert !isIncognito;
        }

        mMethodNames = new HashSet<>();
        mIsIncognito = isIncognito;
        mApplicationIdentifierToHide = appToHide;
        mSupportedDelegations = supportedDelegations;
        mIsPreferred = false;
    }

    /** @param methodName A payment method that this app supports, e.g., "https://bobpay.com". */
    public void addMethodName(String methodName) {
        mMethodNames.add(methodName);
    }

    /** Callback for receiving responses to IS_READY_TO_PAY queries. */
    public interface IsReadyToPayCallback {
        /**
         * Called after it is known whether the given app is ready to pay.
         * @param app          The app that has been queried.
         * @param isReadyToPay Whether the app is ready to pay.
         */
        void onIsReadyToPayResponse(AndroidPaymentApp app, boolean isReadyToPay);
    }

    /** Queries the IS_READY_TO_PAY service. */
    public void maybeQueryIsReadyToPayService(
            Map<String, PaymentMethodData> methodDataMap,
            String origin,
            String iframeOrigin,
            @Nullable byte[][] certificateChain,
            Map<String, PaymentDetailsModifier> modifiers,
            IsReadyToPayCallback callback) {
        ThreadUtils.assertOnUiThread();
        assert mMethodNames.containsAll(methodDataMap.keySet());
        assert mIsReadyToPayCallback == null
                : "Have not responded to previous IS_READY_TO_PAY request";

        mIsReadyToPayCallback = callback;
        if (mIsReadyToPayServiceName == null) {
            respondToIsReadyToPayQuery(true);
            return;
        }

        assert !mIsIncognito;

        Intent isReadyToPayIntent =
                WebPaymentIntentHelper.createIsReadyToPayIntent(
                        /* packageName= */ mPackageName,
                        /* serviceName= */ mIsReadyToPayServiceName,
                        removeUrlScheme(origin),
                        removeUrlScheme(iframeOrigin),
                        certificateChain,
                        WebPaymentIntentHelperTypeConverter.fromMojoPaymentMethodDataMap(
                                methodDataMap),
                        // TODO(crbug.com/40212375): Re-enable clearing of identity for
                        // IS_READY_TO_PAY
                        /* clearIdFields= */ false);
        if (mBypassIsReadyToPayServiceInTest) {
            respondToIsReadyToPayQuery(true);
            return;
        }
        mIsReadyToPayServiceHelper =
                new IsReadyToPayServiceHelper(
                        ContextUtils.getApplicationContext(),
                        isReadyToPayIntent,
                        /* resultHandler= */ this);
        mIsReadyToPayServiceHelper.query();
    }

    @VisibleForTesting
    public void bypassIsReadyToPayServiceInTest() {
        mBypassIsReadyToPayServiceInTest = true;
    }

    private void respondToIsReadyToPayQuery(boolean isReadyToPay) {
        ThreadUtils.assertOnUiThread();
        if (mIsReadyToPayCallback == null) return;
        mIsReadyToPayCallback.onIsReadyToPayResponse(/* app= */ this, isReadyToPay);
        mIsReadyToPayCallback = null;
    }

    @Override
    @Nullable
    public String getApplicationIdentifierToHide() {
        return mApplicationIdentifierToHide;
    }

    @Override
    public Set<String> getInstrumentMethodNames() {
        return Collections.unmodifiableSet(mMethodNames);
    }

    @Override
    public void invokePaymentApp(
            final String id,
            final String merchantName,
            String origin,
            String iframeOrigin,
            final byte[][] certificateChain,
            final Map<String, PaymentMethodData> methodDataMap,
            final PaymentItem total,
            final List<PaymentItem> displayItems,
            final Map<String, PaymentDetailsModifier> modifiers,
            final PaymentOptions paymentOptions,
            final List<PaymentShippingOption> shippingOptions,
            InstrumentDetailsCallback callback) {
        mInstrumentDetailsCallback = callback;

        String schemelessOrigin = removeUrlScheme(origin);
        String schemelessIframeOrigin = removeUrlScheme(iframeOrigin);
        Runnable launchRunnable =
                () ->
                        launchPaymentApp(
                                id,
                                merchantName,
                                schemelessOrigin,
                                schemelessIframeOrigin,
                                certificateChain,
                                methodDataMap,
                                total,
                                displayItems,
                                modifiers,
                                paymentOptions,
                                shippingOptions);
        if (!mIsIncognito) {
            launchRunnable.run();
            return;
        }

        mLauncher.showLeavingIncognitoWarning(this::notifyErrorInvokingPaymentApp, launchRunnable);
    }

    @Override
    public void updateWith(PaymentRequestDetailsUpdate response) {
        ThreadUtils.assertOnUiThread();
        PaymentDetailsUpdateServiceHelper.getInstance()
                .updateWith(
                        WebPaymentIntentHelperTypeConverter.fromMojoPaymentRequestDetailsUpdate(
                                response));
    }

    @Override
    public void onPaymentDetailsNotUpdated() {
        ThreadUtils.assertOnUiThread();
        PaymentDetailsUpdateServiceHelper.getInstance().onPaymentDetailsNotUpdated();
    }

    @Override
    public boolean isWaitingForPaymentDetailsUpdate() {
        ThreadUtils.assertOnUiThread();
        return PaymentDetailsUpdateServiceHelper.getInstance().isWaitingForPaymentDetailsUpdate();
    }

    @Override
    public boolean handlesShippingAddress() {
        return mSupportedDelegations.getShippingAddress();
    }

    @Override
    public boolean handlesPayerName() {
        return mSupportedDelegations.getPayerName();
    }

    @Override
    public boolean handlesPayerEmail() {
        return mSupportedDelegations.getPayerEmail();
    }

    @Override
    public boolean handlesPayerPhone() {
        return mSupportedDelegations.getPayerPhone();
    }

    private static String removeUrlScheme(String url) {
        return UrlFormatter.formatUrlForSecurityDisplay(url, SchemeDisplay.OMIT_HTTP_AND_HTTPS);
    }

    private void launchPaymentApp(
            String id,
            String merchantName,
            String origin,
            String iframeOrigin,
            byte[][] certificateChain,
            Map<String, PaymentMethodData> methodDataMap,
            PaymentItem total,
            List<PaymentItem> displayItems,
            Map<String, PaymentDetailsModifier> modifiers,
            PaymentOptions paymentOptions,
            List<PaymentShippingOption> shippingOptions) {
        assert mMethodNames.containsAll(methodDataMap.keySet());
        assert mInstrumentDetailsCallback != null;
        mPaymentOptions =
                WebPaymentIntentHelperTypeConverter.fromMojoPaymentOptions(paymentOptions);

        Intent payIntent =
                WebPaymentIntentHelper.createPayIntent(
                        mPackageName,
                        mPayActivityName,
                        id,
                        merchantName,
                        origin,
                        iframeOrigin,
                        certificateChain,
                        WebPaymentIntentHelperTypeConverter.fromMojoPaymentMethodDataMap(
                                methodDataMap),
                        WebPaymentIntentHelperTypeConverter.fromMojoPaymentItem(total),
                        WebPaymentIntentHelperTypeConverter.fromMojoPaymentItems(displayItems),
                        WebPaymentIntentHelperTypeConverter.fromMojoPaymentDetailsModifierMap(
                                modifiers),
                        mPaymentOptions,
                        WebPaymentIntentHelperTypeConverter.fromMojoShippingOptionList(
                                shippingOptions));

        mLauncher.launchPaymentApp(
                payIntent, this::notifyErrorInvokingPaymentApp, this::onIntentCompleted);
    }

    private void notifyErrorInvokingPaymentApp(String errorMessage) {
        assert mInstrumentDetailsCallback != null : "Callback should be invoked only once";
        mHandler.post(
                () -> {
                    assert mInstrumentDetailsCallback != null
                            : "Callback should be invoked only once";
                    mInstrumentDetailsCallback.onInstrumentDetailsError(errorMessage);
                    mInstrumentDetailsCallback = null;
                });
    }

    public void onIntentCompletedForTesting(IntentResult intentResult) {
        onIntentCompleted(intentResult);
    }

    private void onIntentCompleted(IntentResult intentResult) {
        assert mInstrumentDetailsCallback != null;
        ThreadUtils.assertOnUiThread();
        WebPaymentIntentHelper.parsePaymentResponse(
                intentResult.resultCode,
                intentResult.data,
                mPaymentOptions,
                this::notifyErrorInvokingPaymentApp,
                this::onPaymentSuccess);
    }

    private void onPaymentSuccess(String methodName, String details, PayerData payerData) {
        assert mInstrumentDetailsCallback != null : "Callback should be invoked only once";
        mInstrumentDetailsCallback.onInstrumentDetailsReady(methodName, details, payerData);
        mInstrumentDetailsCallback = null;
    }

    @Override
    public void dismissInstrument() {}

    // IsReadyToPayServiceHelper.ResultHandler:
    @Override
    public void onIsReadyToPayServiceError() {
        PostTask.runOrPostTask(TaskTraits.UI_DEFAULT, () -> respondToIsReadyToPayQuery(false));
    }

    @Override
    public void onIsReadyToPayServiceResponse(boolean isReadyToPay) {
        PostTask.runOrPostTask(
                TaskTraits.UI_DEFAULT, () -> respondToIsReadyToPayQuery(isReadyToPay));
    }

    @Override
    public @PaymentAppType int getPaymentAppType() {
        return PaymentAppType.NATIVE_MOBILE_APP;
    }

    @Override
    public boolean isPreferred() {
        return mIsPreferred;
    }

    public void setIsPreferred(boolean isPreferred) {
        mIsPreferred = isPreferred;
    }

    /** @return The package name of the invoked native app. */
    public String packageName() {
        return mPackageName;
    }
}