chromium/components/payments/content/android/java/src/org/chromium/components/payments/secure_payment_confirmation/SecurePaymentConfirmationAuthnController.java

// Copyright 2021 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.secure_payment_confirmation;

import android.content.Context;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.util.Pair;
import android.view.View;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.core.content.res.ResourcesCompat;

import org.chromium.base.Callback;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetContent;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetController;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetControllerProvider;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetObserver;
import org.chromium.components.browser_ui.bottomsheet.EmptyBottomSheetObserver;
import org.chromium.components.payments.CurrencyFormatter;
import org.chromium.components.payments.InputProtector;
import org.chromium.components.payments.R;
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.PaymentItem;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.modelutil.PropertyModelChangeProcessor;
import org.chromium.url.Origin;

import java.util.Locale;

/**
 * The controller of the SecurePaymentConfirmation Authn UI, which owns the component overall, i.e.,
 * creates other objects in the component and connects them. It decouples the implementation of this
 * component from other components and acts as the point of contact between them. Any code in this
 * component that needs to interact with another component does that through this controller.
 */
public class SecurePaymentConfirmationAuthnController {
    private final WebContents mWebContents;
    private Runnable mHider;
    private Callback<Boolean> mResponseCallback;
    private Runnable mOptOutCallback;
    private SecurePaymentConfirmationAuthnView mView;

    private InputProtector mInputProtector = new InputProtector();

    private final BottomSheetObserver mBottomSheetObserver =
            new EmptyBottomSheetObserver() {
                @Override
                public void onSheetStateChanged(int newState, int reason) {
                    switch (newState) {
                        case BottomSheetController.SheetState.HIDDEN:
                            onCancel();
                            break;
                    }
                }
            };

    private final BottomSheetContent mBottomSheetContent =
            new BottomSheetContent() {
                @Override
                public View getContentView() {
                    return mView.getContentView();
                }

                @Override
                public View getToolbarView() {
                    return null;
                }

                @Override
                public int getVerticalScrollOffset() {
                    if (mView != null) {
                        return mView.getScrollY();
                    }

                    return 0;
                }

                @Override
                public float getFullHeightRatio() {
                    return HeightMode.WRAP_CONTENT;
                }

                @Override
                public float getHalfHeightRatio() {
                    return HeightMode.DISABLED;
                }

                @Override
                public void destroy() {}

                @Override
                public int getPriority() {
                    return ContentPriority.HIGH;
                }

                @Override
                public int getPeekHeight() {
                    return HeightMode.DISABLED;
                }

                @Override
                public boolean swipeToDismissEnabled() {
                    return false;
                }

                @Override
                public int getSheetContentDescriptionStringId() {
                    return R.string.secure_payment_confirmation_authentication_sheet_description;
                }

                @Override
                public int getSheetHalfHeightAccessibilityStringId() {
                    assert false : "This method should not be called";
                    return 0;
                }

                @Override
                public int getSheetFullHeightAccessibilityStringId() {
                    return R.string.secure_payment_confirmation_authentication_sheet_opened;
                }

                @Override
                public int getSheetClosedAccessibilityStringId() {
                    return R.string.secure_payment_confirmation_authentication_sheet_closed;
                }
            };

    /**
     * Constructs the SPC Authn UI component controller.
     *
     * @param webContents The WebContents of the merchant.
     */
    public static SecurePaymentConfirmationAuthnController create(WebContents webContents) {
        return webContents != null
                ? new SecurePaymentConfirmationAuthnController(webContents)
                : null;
    }

    private SecurePaymentConfirmationAuthnController(WebContents webContents) {
        mWebContents = webContents;
    }

    /**
     * Shows the SPC Authn UI.
     *
     * @param paymentIcon The icon of the payment instrument.
     * @param paymentInstrumentLabel The label to display for the payment instrument.
     * @param total The total amount of the transaction.
     * @param responseCallback The function to call on sheet dismiss; false if it failed.
     * @param optOutCallback The function to call on user opt out.
     * @param payeeName The name of the payee, or null if not specified.
     * @param payeeOrigin The origin of the payee, or null if not specified.
     * @param showOptOut Whether to show the opt out UX to the user.
     * @param rpId The relying party ID for the SPC credential.
     */
    public boolean show(
            Drawable paymentIcon,
            String paymentInstrumentLabel,
            PaymentItem total,
            Callback<Boolean> responseCallback,
            Runnable optOutCallback,
            @Nullable String payeeName,
            @Nullable Origin payeeOrigin,
            boolean showOptOut,
            String rpId) {
        if (mHider != null) return false;

        WindowAndroid windowAndroid = mWebContents.getTopLevelNativeWindow();
        if (windowAndroid == null) return false;
        Context context = windowAndroid.getContext().get();
        if (context == null) return false;

        BottomSheetController bottomSheet = BottomSheetControllerProvider.from(windowAndroid);
        if (bottomSheet == null) return false;

        mInputProtector.markShowTime();

        // The instrument icon may be empty, if it couldn't be downloaded/decoded
        // and iconMustBeShown was set to false. In that case, use a default icon.
        // The actual display color is set based on the theme in OnThemeChanged.
        boolean usingDefaultIcon = false;
        assert paymentIcon instanceof BitmapDrawable;
        if (((BitmapDrawable) paymentIcon).getBitmap() == null) {
            paymentIcon =
                    ResourcesCompat.getDrawable(
                            context.getResources(), R.drawable.credit_card, context.getTheme());
            usingDefaultIcon = true;
        }

        SecurePaymentConfirmationAuthnView.OptOutInfo optOutInfo =
                new SecurePaymentConfirmationAuthnView.OptOutInfo(
                        showOptOut, rpId, this::onOptOutPressed);

        PropertyModel model =
                new PropertyModel.Builder(SecurePaymentConfirmationAuthnProperties.ALL_KEYS)
                        .with(
                                SecurePaymentConfirmationAuthnProperties.STORE_LABEL,
                                getStoreLabel(payeeName, payeeOrigin))
                        .with(
                                SecurePaymentConfirmationAuthnProperties.PAYMENT_ICON,
                                Pair.create(paymentIcon, usingDefaultIcon))
                        .with(
                                SecurePaymentConfirmationAuthnProperties.PAYMENT_INSTRUMENT_LABEL,
                                paymentInstrumentLabel)
                        .with(
                                SecurePaymentConfirmationAuthnProperties.TOTAL,
                                formatPaymentItem(total))
                        .with(
                                SecurePaymentConfirmationAuthnProperties.CURRENCY,
                                total.amount.currency)
                        .with(SecurePaymentConfirmationAuthnProperties.OPT_OUT_INFO, optOutInfo)
                        .with(
                                SecurePaymentConfirmationAuthnProperties.CONTINUE_BUTTON_CALLBACK,
                                this::onConfirmPressed)
                        .with(
                                SecurePaymentConfirmationAuthnProperties.CANCEL_BUTTON_CALLBACK,
                                this::onCancelPressed)
                        .build();

        bottomSheet.addObserver(mBottomSheetObserver);

        mView = new SecurePaymentConfirmationAuthnView(context);
        PropertyModelChangeProcessor changeProcessor =
                PropertyModelChangeProcessor.create(
                        model, mView, SecurePaymentConfirmationAuthnViewBinder::bind);

        mHider =
                () -> {
                    changeProcessor.destroy();
                    bottomSheet.removeObserver(mBottomSheetObserver);
                    bottomSheet.hideContent(
                            /* content= */ mBottomSheetContent, /* animate= */ true);
                };

        mResponseCallback = responseCallback;
        mOptOutCallback = showOptOut ? optOutCallback : null;

        boolean isShowSuccess =
                bottomSheet.requestShowContent(mBottomSheetContent, /* animate= */ true);

        if (!isShowSuccess) {
            hide();
            return false;
        }

        return true;
    }

    /** Hides the SPC Authn UI. */
    public void hide() {
        if (mHider == null) return;
        mHider.run();
        mHider = null;
    }

    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
    public SecurePaymentConfirmationAuthnView getView() {
        return mView;
    }

    @VisibleForTesting(otherwise = VisibleForTesting.NONE)
    public boolean isHidden() {
        return mHider == null;
    }

    private String getStoreLabel(@Nullable String payeeName, @Nullable Origin payeeOrigin) {
        // At least one of the payeeName and payeeOrigin must be non-null in SPC; this should be
        // enforced by PaymentRequestService.isValidSecurePaymentConfirmationRequest.
        assert payeeName != null || payeeOrigin != null;

        if (payeeOrigin == null) return payeeName;

        String origin =
                UrlFormatter.formatOriginForSecurityDisplay(
                        payeeOrigin, SchemeDisplay.OMIT_HTTP_AND_HTTPS);
        return payeeName == null ? origin : String.format("%s (%s)", payeeName, origin);
    }

    private String formatPaymentItem(PaymentItem paymentItem) {
        CurrencyFormatter formatter =
                new CurrencyFormatter(paymentItem.amount.currency, Locale.getDefault());
        String result = formatter.format(paymentItem.amount.value);
        formatter.destroy();
        return result;
    }

    private void onConfirm() {
        hide();
        mResponseCallback.onResult(true);
    }

    private void onConfirmPressed() {
        if (mInputProtector.shouldInputBeProcessed()) onConfirm();
    }

    private void onCancel() {
        hide();
        mResponseCallback.onResult(false);
    }

    private void onCancelPressed() {
        if (mInputProtector.shouldInputBeProcessed()) onCancel();
    }

    private void onOptOut() {
        assert mOptOutCallback != null;
        hide();
        mOptOutCallback.run();
    }

    private void onOptOutPressed() {
        if (mInputProtector.shouldInputBeProcessed()) onOptOut();
    }

    void setInputProtectorForTesting(InputProtector inputProtector) {
        mInputProtector = inputProtector;
    }

    /**
     * Called by PaymentRequestTestBridge for cross-platform browsertests, the following methods
     * bypass the input protector. The Java unit tests simulate clicking the button and therefore
     * test the input protector.
     */
    public boolean cancelForTest() {
        onCancel();
        return true;
    }

    public boolean optOutForTest() {
        if (mOptOutCallback == null) return false;
        onOptOut();
        return true;
    }
}