// Copyright 2016 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package org.chromium.chrome.browser.payments.ui; import static org.chromium.chrome.browser.payments.ui.PaymentRequestSection.EDIT_BUTTON_GONE; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.animation.ValueAnimator; import android.animation.ValueAnimator.AnimatorUpdateListener; import android.app.Activity; import android.app.Dialog; import android.content.Context; import android.content.DialogInterface; import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.drawable.ColorDrawable; import android.os.Handler; import android.support.annotation.IntDef; import android.support.v4.view.animation.FastOutLinearInInterpolator; import android.support.v4.view.animation.LinearOutSlowInInterpolator; import android.text.TextUtils.TruncateAt; import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; import android.view.View.OnLayoutChangeListener; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; import android.view.Window; import android.widget.Button; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.ScrollView; import android.widget.TextView; import org.chromium.base.ApiCompatibilityUtils; import org.chromium.base.Callback; import org.chromium.base.VisibleForTesting; import org.chromium.chrome.R; import org.chromium.chrome.browser.payments.ui.PaymentRequestSection.ExtraTextSection; import org.chromium.chrome.browser.payments.ui.PaymentRequestSection.LineItemBreakdownSection; import org.chromium.chrome.browser.payments.ui.PaymentRequestSection.OptionSection; import org.chromium.chrome.browser.payments.ui.PaymentRequestSection.SectionSeparator; import org.chromium.chrome.browser.widget.AlwaysDismissedDialog; import org.chromium.chrome.browser.widget.DualControlLayout; import org.chromium.chrome.browser.widget.animation.AnimatorProperties; import org.chromium.chrome.browser.widget.animation.FocusAnimator; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.List; /** * The PaymentRequest UI. */ public class PaymentRequestUI implements DialogInterface.OnDismissListener, View.OnClickListener, PaymentRequestSection.SectionDelegate { public static final int TYPE_SHIPPING_ADDRESSES = 1; public static final int TYPE_SHIPPING_OPTIONS = 2; public static final int TYPE_CONTACT_DETAILS = 3; public static final int TYPE_PAYMENT_METHODS = 4; public static final int SELECTION_RESULT_ASYNCHRONOUS_VALIDATION = 1; public static final int SELECTION_RESULT_EDITOR_LAUNCH = 2; public static final int SELECTION_RESULT_NONE = 3; @Retention(RetentionPolicy.SOURCE) @IntDef({ TYPE_SHIPPING_ADDRESSES, TYPE_SHIPPING_OPTIONS, TYPE_CONTACT_DETAILS, TYPE_PAYMENT_METHODS }) public @interface DataType {} @Retention(RetentionPolicy.SOURCE) @IntDef({ SELECTION_RESULT_ASYNCHRONOUS_VALIDATION, SELECTION_RESULT_EDITOR_LAUNCH, SELECTION_RESULT_NONE, }) public @interface SelectionResult {} /** * The interface to be implemented by the consumer of the PaymentRequest UI. */ public interface Client { /** * Asynchronously returns the default payment information. */ void getDefaultPaymentInformation(Callback<PaymentInformation> callback); /** * Asynchronously returns the full bill. Includes the total price and its breakdown into * individual line items. */ void getShoppingCart(Callback<ShoppingCart> callback); /** * Asynchronously returns the full list of options for the given type. * * @param optionType Data being updated. * @param callback Callback to run when the data has been fetched. */ void getSectionInformation( @DataType int optionType, Callback<SectionInformation> callback); /** * Called when the user changes one of their payment options. * * If this method returns {@link SELECTION_RESULT_ASYNCHRONOUS_VALIDATION}, then: * + The added option should be asynchronously verified. * + The section should be disabled and a progress spinny should be shown while the option * is being verified. * + The checkedCallback will be invoked with the results of the check and updated * information. * * If this method returns {@link SELECTION_RESULT_EDITOR_LAUNCH}, then: * + Interaction with UI should be disabled until updateSection() is called. * * For example, if the website needs a shipping address to calculate shipping options, then * calling onSectionOptionSelected(TYPE_SHIPPING_ADDRESS, option, checkedCallback) will * return true. When the website updates the shipping options, the checkedCallback will be * invoked. * * @param optionType Data being updated. * @param option Value of the data being updated. * @param checkedCallback The callback after an asynchronous check has completed. * @return The result of the selection. */ @SelectionResult int onSectionOptionSelected(@DataType int optionType, PaymentOption option, Callback<PaymentInformation> checkedCallback); /** * Called when the user clicks on the "Add" button for a section. * * If this method returns {@link SELECTION_RESULT_ASYNCHRONOUS_VALIDATION}, then: * + The added option should be asynchronously verified. * + The section should be disabled and a progress spinny should be shown while the option * is being verified. * + The checkedCallback will be invoked with the results of the check and updated * information. * * If this method returns {@link SELECTION_RESULT_EDITOR_LAUNCH}, then: * + Interaction with UI should be disabled until updateSection() is called. * * @param optionType Data being updated. * @param checkedCallback The callback after an asynchronous check has completed. * @return The result of the selection. */ @SelectionResult int onSectionAddOption( @DataType int optionType, Callback<PaymentInformation> checkedCallback); /** * Called when the user clicks on the “Pay” button. If this method returns true, the UI is * disabled and is showing a spinner. Otherwise, the UI is hidden. */ boolean onPayClicked(PaymentOption selectedShippingAddress, PaymentOption selectedShippingOption, PaymentOption selectedPaymentMethod); /** * Called when the user dismisses the UI via the “back” button on their phone * or the “X” button in UI. */ void onDismiss(); } /** * A test-only observer for PaymentRequest UI. */ public interface PaymentRequestObserverForTest { /** * Called when clicks on the UI are possible. */ void onPaymentRequestReadyForInput(PaymentRequestUI ui); /** * Called when clicks on the PAY button are possible. */ void onPaymentRequestReadyToPay(PaymentRequestUI ui); /** * Called when the UI has been updated to reflect checking a selected option. */ void onPaymentRequestSelectionChecked(PaymentRequestUI ui); /** * Called when edit dialog is showing. */ void onPaymentRequestReadyToEdit(); /** * Called when editor validation completes with error. This can happen, for example, when * user enters an invalid email address. */ void onPaymentRequestEditorValidationError(); /** * Called when an editor field text has changed. */ void onPaymentRequestEditorTextUpdate(); /** * Called when the result UI is showing. */ void onPaymentRequestResultReady(PaymentRequestUI ui); /** * Called when the UI is gone. */ void onPaymentRequestDismiss(); } /** Helper to notify tests of an event only once. */ private static class NotifierForTest { private final Handler mHandler; private final Runnable mNotification; private boolean mNotificationPending; /** * Constructs the helper to notify tests for an event. * * @param notification The callback that notifies the test of an event. */ public NotifierForTest(final Runnable notification) { mHandler = new Handler(); mNotification = new Runnable() { @Override public void run() { notification.run(); mNotificationPending = false; } }; } /** Schedules a single notification for test, even if called only once. */ public void run() { if (mNotificationPending) return; mNotificationPending = true; mHandler.post(mNotification); } } /** Length of the animation to either show the UI or expand it to full height. */ private static final int DIALOG_ENTER_ANIMATION_MS = 225; /** Length of the animation to hide the bottom sheet UI. */ private static final int DIALOG_EXIT_ANIMATION_MS = 195; private static PaymentRequestObserverForTest sObserverForTest; /** Notifies tests that the [PAY] button can be clicked. */ private final NotifierForTest mReadyToPayNotifierForTest; private final Context mContext; private final Client mClient; private final boolean mRequestShipping; private final boolean mRequestContactDetails; private final Dialog mDialog; private final EditorView mEditorView; private final EditorView mCardEditorView; private final ViewGroup mFullContainer; private final ViewGroup mRequestView; private final PaymentRequestUiErrorView mErrorView; private final Callback<PaymentInformation> mUpdateSectionsCallback; private ScrollView mPaymentContainer; private LinearLayout mPaymentContainerLayout; private DualControlLayout mButtonBar; private Button mEditButton; private Button mPayButton; private View mCloseButton; private View mSpinnyLayout; private LineItemBreakdownSection mOrderSummarySection; private ExtraTextSection mShippingSummarySection; private OptionSection mShippingAddressSection; private OptionSection mShippingOptionSection; private OptionSection mContactDetailsSection; private OptionSection mPaymentMethodSection; private List<SectionSeparator> mSectionSeparators; private PaymentRequestSection mSelectedSection; private boolean mIsShowingEditDialog; private boolean mIsProcessingPayClicked; private boolean mIsClientClosing; private boolean mIsClientCheckingSelection; private boolean mIsShowingSpinner; private boolean mIsEditingPaymentItem; private boolean mIsClosing; private SectionInformation mPaymentMethodSectionInformation; private SectionInformation mShippingAddressSectionInformation; private SectionInformation mShippingOptionsSectionInformation; private SectionInformation mContactDetailsSectionInformation; private Animator mSheetAnimator; private FocusAnimator mSectionAnimator; private int mAnimatorTranslation; private boolean mIsInitialLayoutComplete; /** * Builds the UI for PaymentRequest. * * @param activity The activity on top of which the UI should be displayed. * @param client The consumer of the PaymentRequest UI. * @param requestShipping Whether the UI should show the shipping address and option selection. * @param requestContact Whether the UI should show the email address and phone number * selection. * @param canAddCards Whether the UI should show the [+ADD CARD] button. This can be false, * for example, when the merchant does not accept credit cards, so * there's no point in adding cards within PaymentRequest UI. * @param title The title to show at the top of the UI. This can be, for example, the * <title> of the merchant website. If the string is too long for * UI, it elides at the end. * @param origin The origin (part of URL) to show under the title. For example, * "https://shop.momandpop.com". If the origin is too long for the UI, it * should elide according to: * https://www.chromium.org/Home/chromium-security/enamel#TOC-Eliding-Origin-Names-And-Hostnames */ public PaymentRequestUI(Activity activity, Client client, boolean requestShipping, boolean requestContact, boolean canAddCards, String title, String origin) { mContext = activity; mClient = client; mRequestShipping = requestShipping; mRequestContactDetails = requestContact; mAnimatorTranslation = activity.getResources().getDimensionPixelSize( R.dimen.payments_ui_translation); mErrorView = (PaymentRequestUiErrorView) LayoutInflater.from(mContext).inflate( R.layout.payment_request_error, null); mErrorView.initialize(title, origin); mReadyToPayNotifierForTest = new NotifierForTest(new Runnable() { @Override public void run() { if (sObserverForTest != null && isAcceptingUserInput() && mPayButton.isEnabled()) { sObserverForTest.onPaymentRequestReadyToPay(PaymentRequestUI.this); } } }); // This callback will be fired if mIsClientCheckingSelection is true. mUpdateSectionsCallback = new Callback<PaymentInformation>() { @Override public void onResult(PaymentInformation result) { mIsClientCheckingSelection = false; updateOrderSummarySection(result.getShoppingCart()); if (mRequestShipping) { updateSection(TYPE_SHIPPING_ADDRESSES, result.getShippingAddresses()); updateSection(TYPE_SHIPPING_OPTIONS, result.getShippingOptions()); } if (mRequestContactDetails) { updateSection(TYPE_CONTACT_DETAILS, result.getContactDetails()); } updateSection(TYPE_PAYMENT_METHODS, result.getPaymentMethods()); if (mShippingAddressSectionInformation.getSelectedItem() == null) { expand(mShippingAddressSection); } else { expand(null); } updatePayButtonEnabled(); notifySelectionChecked(); } }; mRequestView = (ViewGroup) LayoutInflater.from(mContext).inflate(R.layout.payment_request, null); prepareRequestView(activity, title, origin, canAddCards); // To handle the specced animations, the dialog is entirely contained within a translucent // FrameLayout. This could eventually be converted to a real BottomSheetDialog, but that // requires exploration of how interactions would work when the dialog can be sent back and // forth between the peeking and expanded state. mFullContainer = new FrameLayout(mContext); mFullContainer.setBackgroundColor( ApiCompatibilityUtils.getColor(mContext.getResources(), R.color.payments_ui_scrim)); FrameLayout.LayoutParams bottomSheetParams = new FrameLayout.LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); bottomSheetParams.gravity = Gravity.CENTER_HORIZONTAL | Gravity.BOTTOM; mFullContainer.addView(mRequestView, bottomSheetParams); mEditorView = new EditorView(activity, sObserverForTest); mCardEditorView = new EditorView(activity, sObserverForTest); // Set up the dialog. mDialog = new AlwaysDismissedDialog(activity, R.style.DialogWhenLarge); mDialog.setOnDismissListener(this); mDialog.addContentView(mFullContainer, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); Window dialogWindow = mDialog.getWindow(); dialogWindow.setGravity(Gravity.CENTER); dialogWindow.setLayout(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); dialogWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); } /** * Shows the PaymentRequest UI. */ public void show() { mDialog.show(); mClient.getDefaultPaymentInformation(new Callback<PaymentInformation>() { @Override public void onResult(PaymentInformation result) { updateOrderSummarySection(result.getShoppingCart()); if (mRequestShipping) { updateSection(TYPE_SHIPPING_ADDRESSES, result.getShippingAddresses()); updateSection(TYPE_SHIPPING_OPTIONS, result.getShippingOptions()); String selectedShippingAddress = result.getSelectedShippingAddressLabel(); String selectedShippingName = result.getSelectedShippingAddressSublabel(); String selectedShippingOptionLabel = result.getSelectedShippingOptionLabel(); if (selectedShippingAddress == null || selectedShippingOptionLabel == null) { // Let the summary display a SELECT/ADD button for the first subsection // that needs it. mShippingSummarySection.setSummaryText(null, null); mShippingSummarySection.setSummaryProperties(null, false, null, false); PaymentRequestSection section = mShippingAddressSection.getEditButtonState() == EDIT_BUTTON_GONE ? mShippingOptionSection : mShippingAddressSection; mShippingSummarySection.setEditButtonState(section.getEditButtonState()); } else { // Show the shipping address and the name in the summary section. mShippingSummarySection.setSummaryText( selectedShippingAddress, selectedShippingName); mShippingSummarySection.setSummaryProperties( TruncateAt.MIDDLE, true, null, true); // Indicate the shipping option below the address. mShippingSummarySection.setExtraText(selectedShippingOptionLabel); } } if (mRequestContactDetails) { updateSection(TYPE_CONTACT_DETAILS, result.getContactDetails()); } updateSection(TYPE_PAYMENT_METHODS, result.getPaymentMethods()); updatePayButtonEnabled(); // Hide the loading indicators and show the real sections. mPaymentContainer.setVisibility(View.VISIBLE); mButtonBar.setVisibility(View.VISIBLE); mRequestView.removeView(mSpinnyLayout); mRequestView.addOnLayoutChangeListener(new SheetEnlargingAnimator(false)); } }); } /** * Prepares the PaymentRequestUI for initial display. * * TODO(dfalcantara): Ideally, everything related to the request and its views would just be put * into its own class but that'll require yanking out a lot of this class. * * @param activity Activity displaying the UI. * @param title Title of the page. * @param origin Host of the page. * @param canAddCards Whether new cards can be added. */ private void prepareRequestView( Activity activity, String title, String origin, boolean canAddCards) { mSpinnyLayout = mRequestView.findViewById(R.id.payment_request_spinny); // Indicate that we're preparing the dialog for display. TextView messageView = (TextView) mRequestView.findViewById(R.id.message); messageView.setText(R.string.payments_loading_message); ((TextView) mRequestView.findViewById(R.id.page_title)).setText(title); ((TextView) mRequestView.findViewById(R.id.hostname)).setText(origin); // Set up the buttons. mCloseButton = mRequestView.findViewById(R.id.close_button); mCloseButton.setOnClickListener(this); mPayButton = DualControlLayout.createButtonForLayout( activity, true, activity.getString(R.string.payments_pay_button), this); mEditButton = DualControlLayout.createButtonForLayout( activity, false, activity.getString(R.string.payments_edit_button), this); mButtonBar = (DualControlLayout) mRequestView.findViewById(R.id.button_bar); mButtonBar.setAlignment(DualControlLayout.ALIGN_END); mButtonBar.setStackedMargin(activity.getResources().getDimensionPixelSize( R.dimen.infobar_margin_between_stacked_buttons)); mButtonBar.addView(mPayButton); mButtonBar.addView(mEditButton); // Create all the possible sections. mSectionSeparators = new ArrayList<>(); mPaymentContainer = (ScrollView) mRequestView.findViewById(R.id.option_container); mPaymentContainerLayout = (LinearLayout) mRequestView.findViewById(R.id.payment_container_layout); mOrderSummarySection = new LineItemBreakdownSection( activity, activity.getString(R.string.payments_order_summary_label), this); mShippingSummarySection = new ExtraTextSection( activity, activity.getString(R.string.payments_shipping_summary_label), this); mShippingAddressSection = new OptionSection( activity, activity.getString(R.string.payments_shipping_address_label), this); mShippingOptionSection = new OptionSection( activity, activity.getString(R.string.payments_shipping_option_label), this); mContactDetailsSection = new OptionSection( activity, activity.getString(R.string.payments_contact_details_label), this); mPaymentMethodSection = new OptionSection( activity, activity.getString(R.string.payments_method_of_payment_label), this); // Some sections conditionally allow adding new options. mShippingOptionSection.setCanAddItems(false); mPaymentMethodSection.setCanAddItems(canAddCards); // Add the necessary sections to the layout. mPaymentContainerLayout.addView(mOrderSummarySection, new LinearLayout.LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); mSectionSeparators.add(new SectionSeparator(mPaymentContainerLayout)); if (mRequestShipping) { // The shipping breakout sections are only added if they are needed. mPaymentContainerLayout.addView(mShippingSummarySection, new LinearLayout.LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); mSectionSeparators.add(new SectionSeparator(mPaymentContainerLayout)); } mPaymentContainerLayout.addView(mPaymentMethodSection, new LinearLayout.LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); if (mRequestContactDetails) { // Contact details are optional, depending on the merchant website. mSectionSeparators.add(new SectionSeparator(mPaymentContainerLayout)); mPaymentContainerLayout.addView(mContactDetailsSection, new LinearLayout.LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); } mRequestView.addOnLayoutChangeListener(new FadeInAnimator()); mRequestView.addOnLayoutChangeListener(new PeekingAnimator()); // Enabled in updatePayButtonEnabled() when the user has selected all payment options. mPayButton.setEnabled(false); } /** * Closes the UI. Can be invoked in response to, for example: * <ul> * <li>Successfully processing the payment.</li> * <li>Failure to process the payment.</li> * <li>The JavaScript calling the abort() method in PaymentRequest API.</li> * <li>The PaymentRequest JavaScript object being destroyed.</li> * </ul> * * Does not call Client.onDismissed(). * * Should not be called multiple times. * * @param shouldCloseImmediately If true, this function will immediately dismiss the dialog * without describing the error. * @param callback The callback to notify of finished animations. */ public void close(boolean shouldCloseImmediately, final Runnable callback) { mIsClientClosing = true; Runnable dismissRunnable = new Runnable() { @Override public void run() { dismissDialog(false); if (callback != null) callback.run(); } }; if (shouldCloseImmediately) { // The shouldCloseImmediately boolean is true when the merchant calls // instrumentResponse.complete("success") or instrumentResponse.complete("") // in JavaScript. dismissRunnable.run(); } else { // Animate the bottom sheet going away. new DisappearingAnimator(false); // Show the error dialog. mErrorView.show(mFullContainer, dismissRunnable); } if (sObserverForTest != null) sObserverForTest.onPaymentRequestResultReady(this); } /** * Sets the icon in the top left of the UI. This can be, for example, the favicon of the * merchant website. This is not a part of the constructor because favicon retrieval is * asynchronous. * * @param bitmap The bitmap to show next to the title. */ public void setTitleBitmap(Bitmap bitmap) { ((ImageView) mRequestView.findViewById(R.id.icon_view)).setImageBitmap(bitmap); mErrorView.setBitmap(bitmap); } /** * Updates the line items in response to a changed shipping address or option. * * @param cart The shopping cart, including the line items and the total. */ public void updateOrderSummarySection(ShoppingCart cart) { if (cart == null || cart.getTotal() == null) { mOrderSummarySection.setVisibility(View.GONE); } else { mOrderSummarySection.setVisibility(View.VISIBLE); mOrderSummarySection.update(cart); } } /** * Updates the UI to account for changes in payment information. * * @param section The shipping options. */ public void updateSection(@DataType int whichSection, SectionInformation section) { if (whichSection == TYPE_SHIPPING_ADDRESSES) { mShippingAddressSectionInformation = section; mShippingAddressSection.update(section); } else if (whichSection == TYPE_SHIPPING_OPTIONS) { mShippingOptionsSectionInformation = section; mShippingOptionSection.update(section); } else if (whichSection == TYPE_CONTACT_DETAILS) { mContactDetailsSectionInformation = section; mContactDetailsSection.update(section); } else if (whichSection == TYPE_PAYMENT_METHODS) { mPaymentMethodSectionInformation = section; mPaymentMethodSection.update(section); } mIsEditingPaymentItem = false; updateSectionButtons(); updatePayButtonEnabled(); } @Override public void onPaymentOptionChanged(final PaymentRequestSection section, PaymentOption option) { @SelectionResult int result = SELECTION_RESULT_NONE; if (section == mShippingAddressSection && mShippingAddressSectionInformation.getSelectedItem() != option) { mShippingAddressSectionInformation.setSelectedItem(option); result = mClient.onSectionOptionSelected( TYPE_SHIPPING_ADDRESSES, option, mUpdateSectionsCallback); } else if (section == mShippingOptionSection && mShippingOptionsSectionInformation.getSelectedItem() != option) { mShippingOptionsSectionInformation.setSelectedItem(option); result = mClient.onSectionOptionSelected( TYPE_SHIPPING_OPTIONS, option, mUpdateSectionsCallback); } else if (section == mContactDetailsSection) { mContactDetailsSectionInformation.setSelectedItem(option); result = mClient.onSectionOptionSelected(TYPE_CONTACT_DETAILS, option, null); } else if (section == mPaymentMethodSection) { mPaymentMethodSectionInformation.setSelectedItem(option); result = mClient.onSectionOptionSelected(TYPE_PAYMENT_METHODS, option, null); } updateStateFromResult(section, result); } @Override public void onAddPaymentOption(PaymentRequestSection section) { assert section != mShippingOptionSection; // There's no way to add new shipping options, so users adding an option via the shipping // summary's button have to be adding an address. Expand the sheet when this happens so // that the shipping address section properly appears afterward. if (section == mShippingSummarySection) { expand(null); section = mShippingAddressSection; } @SelectionResult int result = SELECTION_RESULT_NONE; if (section == mShippingAddressSection) { result = mClient.onSectionAddOption(TYPE_SHIPPING_ADDRESSES, mUpdateSectionsCallback); } else if (section == mContactDetailsSection) { result = mClient.onSectionAddOption(TYPE_CONTACT_DETAILS, null); } else if (section == mPaymentMethodSection) { result = mClient.onSectionAddOption(TYPE_PAYMENT_METHODS, null); } updateStateFromResult(section, result); } void updateStateFromResult(PaymentRequestSection section, @SelectionResult int result) { mIsClientCheckingSelection = result == SELECTION_RESULT_ASYNCHRONOUS_VALIDATION; mIsEditingPaymentItem = result == SELECTION_RESULT_EDITOR_LAUNCH; if (mIsClientCheckingSelection) { mSelectedSection = section; updateSectionVisibility(); section.setDisplayMode(PaymentRequestSection.DISPLAY_MODE_CHECKING); } else { expand(null); } updatePayButtonEnabled(); } @Override public boolean isBoldLabelNeeded(PaymentRequestSection section) { return section == mShippingAddressSection; } /** @return The common editor user interface. */ public EditorView getEditorView() { return mEditorView; } /** @return The card editor user interface. Distinct from the common editor user interface, * because the credit card editor can launch the address editor. */ public EditorView getCardEditorView() { return mCardEditorView; } /** * Called when user clicks anything in the dialog. */ @Override public void onClick(View v) { if (!isAcceptingCloseButton()) return; if (v == mCloseButton) { dismissDialog(true); return; } if (!isAcceptingUserInput()) return; // Users can only expand incomplete sections by clicking on their edit buttons. if (v instanceof PaymentRequestSection) { PaymentRequestSection section = (PaymentRequestSection) v; if (section.getEditButtonState() != EDIT_BUTTON_GONE) return; } if (v == mOrderSummarySection) { expand(mOrderSummarySection); } else if (v == mShippingSummarySection || v == mShippingAddressSection) { expand(mShippingAddressSection); } else if (v == mShippingOptionSection) { expand(mShippingOptionSection); } else if (v == mContactDetailsSection) { expand(mContactDetailsSection); } else if (v == mPaymentMethodSection) { expand(mPaymentMethodSection); } else if (v == mPayButton) { processPayButton(); } else if (v == mEditButton) { if (mIsShowingEditDialog) { dismissDialog(true); } else { expand(mOrderSummarySection); } } updatePayButtonEnabled(); } /** * Dismiss the dialog. * * @param isAnimated If true, the dialog dismissal is animated. */ private void dismissDialog(boolean isAnimated) { mIsClosing = true; if (mDialog.isShowing()) { if (isAnimated) { new DisappearingAnimator(true); } else { mDialog.dismiss(); } } } private void processPayButton() { assert !mIsShowingSpinner; mIsProcessingPayClicked = true; boolean shouldShowSpinner = mClient.onPayClicked( mShippingAddressSectionInformation == null ? null : mShippingAddressSectionInformation.getSelectedItem(), mShippingOptionsSectionInformation == null ? null : mShippingOptionsSectionInformation.getSelectedItem(), mPaymentMethodSectionInformation.getSelectedItem()); if (shouldShowSpinner) { changeSpinnerVisibility(true); } else { mDialog.hide(); } } /** * Called when user cancelled out of the UI that was shown after they clicked [PAY] button. */ public void onPayButtonProcessingCancelled() { assert mIsProcessingPayClicked; mIsProcessingPayClicked = false; changeSpinnerVisibility(false); mDialog.show(); updatePayButtonEnabled(); } /** * Called when the user has sent the payment information to the website, which is now processing * the payment. */ public void showProcessingMessage() { assert mIsProcessingPayClicked; mIsProcessingPayClicked = false; changeSpinnerVisibility(true); mDialog.show(); } private void changeSpinnerVisibility(boolean showSpinner) { if (mIsShowingSpinner == showSpinner) return; mIsShowingSpinner = showSpinner; if (showSpinner) { mRequestView.removeView(mPaymentContainer); mRequestView.removeView(mButtonBar); mRequestView.addView(mSpinnyLayout); // Turn the bottom sheet back into a collapsed bottom sheet showing only the spinner. // TODO(dfalcantara): Animate this: https://crbug.com/621955 FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) mRequestView.getLayoutParams(); params.width = LayoutParams.MATCH_PARENT; params.height = LayoutParams.WRAP_CONTENT; params.gravity = Gravity.CENTER_HORIZONTAL | Gravity.BOTTOM; mRequestView.requestLayout(); } else { mRequestView.removeView(mSpinnyLayout); mRequestView.addView(mPaymentContainer); mRequestView.addView(mButtonBar); if (mIsShowingEditDialog) expand(mSelectedSection); } } private void updatePayButtonEnabled() { boolean contactInfoOk = !mRequestContactDetails || (mContactDetailsSectionInformation != null && mContactDetailsSectionInformation.getSelectedItem() != null); boolean shippingInfoOk = !mRequestShipping || (mShippingAddressSectionInformation != null && mShippingAddressSectionInformation.getSelectedItem() != null && mShippingOptionsSectionInformation != null && mShippingOptionsSectionInformation.getSelectedItem() != null); mPayButton.setEnabled(contactInfoOk && shippingInfoOk && mPaymentMethodSectionInformation != null && mPaymentMethodSectionInformation.getSelectedItem() != null && !mIsClientCheckingSelection && !mIsEditingPaymentItem && !mIsClosing); mReadyToPayNotifierForTest.run(); } /** @return Whether or not the dialog can be closed via the X close button. */ private boolean isAcceptingCloseButton() { return mSheetAnimator == null && mSectionAnimator == null && mIsInitialLayoutComplete && !mIsProcessingPayClicked && !mIsEditingPaymentItem && !mIsClosing; } /** @return Whether or not the dialog is accepting user input. */ @Override public boolean isAcceptingUserInput() { return isAcceptingCloseButton() && mPaymentMethodSectionInformation != null && !mIsClientCheckingSelection; } private void expand(PaymentRequestSection section) { if (!mIsShowingEditDialog) { // Container now takes the full height of the screen, animating towards it. mRequestView.getLayoutParams().height = LayoutParams.MATCH_PARENT; mRequestView.addOnLayoutChangeListener(new SheetEnlargingAnimator(true)); // Swap out Views that combine multiple fields with individual fields. if (mRequestShipping && mShippingSummarySection.getParent() != null) { int summaryIndex = mPaymentContainerLayout.indexOfChild(mShippingSummarySection); mPaymentContainerLayout.removeView(mShippingSummarySection); mPaymentContainerLayout.addView(mShippingAddressSection, summaryIndex, new LinearLayout.LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); mSectionSeparators.add( new SectionSeparator(mPaymentContainerLayout, summaryIndex + 1)); mPaymentContainerLayout.addView(mShippingOptionSection, summaryIndex + 2, new LinearLayout.LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); } // New separators appear at the top and bottom of the list. mSectionSeparators.add(new SectionSeparator(mPaymentContainerLayout, 0)); mSectionSeparators.add(new SectionSeparator(mPaymentContainerLayout, -1)); // Expand all the dividers. for (int i = 0; i < mSectionSeparators.size(); i++) mSectionSeparators.get(i).expand(); mPaymentContainerLayout.requestLayout(); // Switch the 'edit' button to a 'cancel' button. mEditButton.setText(mContext.getString(R.string.cancel)); // Make the dialog take the whole screen. mDialog.getWindow().setLayout( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); // Disable all but the first button. updateSectionButtons(); mIsShowingEditDialog = true; } // Update the section contents when they're selected. mSelectedSection = section; assert mSelectedSection != mShippingSummarySection; if (mSelectedSection == mOrderSummarySection) { mClient.getShoppingCart(new Callback<ShoppingCart>() { @Override public void onResult(ShoppingCart result) { updateOrderSummarySection(result); updateSectionVisibility(); } }); } else if (mSelectedSection == mShippingAddressSection) { mClient.getSectionInformation( TYPE_SHIPPING_ADDRESSES, createUpdateSectionCallback(TYPE_SHIPPING_ADDRESSES)); } else if (mSelectedSection == mShippingOptionSection) { mClient.getSectionInformation( TYPE_SHIPPING_OPTIONS, createUpdateSectionCallback(TYPE_SHIPPING_OPTIONS)); } else if (mSelectedSection == mContactDetailsSection) { mClient.getSectionInformation( TYPE_CONTACT_DETAILS, createUpdateSectionCallback(TYPE_CONTACT_DETAILS)); } else if (mSelectedSection == mPaymentMethodSection) { mClient.getSectionInformation( TYPE_PAYMENT_METHODS, createUpdateSectionCallback(TYPE_PAYMENT_METHODS)); } else { updateSectionVisibility(); } } private Callback<SectionInformation> createUpdateSectionCallback(@DataType final int type) { return new Callback<SectionInformation>() { @Override public void onResult(SectionInformation result) { updateSection(type, result); updateSectionVisibility(); } }; } /** Update the display status of each expandable section in the full dialog. */ private void updateSectionVisibility() { startSectionResizeAnimation(); mOrderSummarySection.focusSection(mSelectedSection == mOrderSummarySection); mShippingAddressSection.focusSection(mSelectedSection == mShippingAddressSection); mShippingOptionSection.focusSection(mSelectedSection == mShippingOptionSection); mContactDetailsSection.focusSection(mSelectedSection == mContactDetailsSection); mPaymentMethodSection.focusSection(mSelectedSection == mPaymentMethodSection); updateSectionButtons(); } /** * Updates the enabled/disbled state of each section's edit button. * * Only the top-most button is enabled -- the others are disabled so the user is directed * through the form from top to bottom. */ private void updateSectionButtons() { boolean mayEnableButton = true; for (int i = 0; i < mPaymentContainerLayout.getChildCount(); i++) { View child = mPaymentContainerLayout.getChildAt(i); if (!(child instanceof PaymentRequestSection)) continue; PaymentRequestSection section = (PaymentRequestSection) child; section.setIsEditButtonEnabled(mayEnableButton); if (section.getEditButtonState() != EDIT_BUTTON_GONE) mayEnableButton = false; } } /** * Called when the dialog is dismissed. Can be caused by: * <ul> * <li>User click on the "back" button on the phone.</li> * <li>User click on the "X" button in the top-right corner of the dialog.</li> * <li>User click on the "CANCEL" button on the bottom of the dialog.</li> * <li>Successfully processing the payment.</li> * <li>Failure to process the payment.</li> * <li>The JavaScript calling the abort() method in PaymentRequest API.</li> * <li>The PaymentRequest JavaScript object being destroyed.</li> * <li>User closing all incognito windows with PaymentRequest UI open in an incognito * window.</li> * </ul> */ @Override public void onDismiss(DialogInterface dialog) { mIsClosing = true; if (mEditorView.isShowing()) mEditorView.dismiss(); if (mCardEditorView.isShowing()) mCardEditorView.dismiss(); if (sObserverForTest != null) sObserverForTest.onPaymentRequestDismiss(); if (!mIsClientClosing) mClient.onDismiss(); } @Override public String getAdditionalText(PaymentRequestSection section) { if (section == mShippingAddressSection) { int selectedItemIndex = mShippingAddressSectionInformation.getSelectedItemIndex(); boolean isNecessary = selectedItemIndex == SectionInformation.NO_SELECTION || selectedItemIndex == SectionInformation.INVALID_SELECTION; return isNecessary ? mContext.getString(selectedItemIndex == SectionInformation.NO_SELECTION ? R.string.payments_select_shipping_address_for_shipping_methods : R.string.payments_unsupported_shipping_address) : null; } return null; } @Override public boolean isAdditionalTextDisplayingWarning(PaymentRequestSection section) { return section == mShippingAddressSection && mShippingAddressSectionInformation != null && mShippingAddressSectionInformation.getSelectedItemIndex() == SectionInformation.INVALID_SELECTION; } @Override public void onSectionClicked(PaymentRequestSection section) { if (section == mShippingSummarySection) { // Clicking the summary section focuses one of its subsections. section = mShippingAddressSection.getEditButtonState() == EDIT_BUTTON_GONE ? mShippingOptionSection : mShippingAddressSection; } expand(section); } /** * Animates the different sections of the dialog expanding and contracting into their final * positions. */ private void startSectionResizeAnimation() { Runnable animationEndRunnable = new Runnable() { @Override public void run() { mSectionAnimator = null; notifyReadyForInput(); mReadyToPayNotifierForTest.run(); } }; mSectionAnimator = new FocusAnimator(mPaymentContainerLayout, mSelectedSection, animationEndRunnable); } /** * Animates the whole dialog fading in and darkening everything else on screen. * This particular animation is not tracked because it is not meant to be cancellable. */ private class FadeInAnimator extends AnimatorListenerAdapter implements OnLayoutChangeListener { @Override public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { mRequestView.removeOnLayoutChangeListener(this); Animator scrimFader = ObjectAnimator.ofInt(mFullContainer.getBackground(), AnimatorProperties.DRAWABLE_ALPHA_PROPERTY, 0, 127); Animator alphaAnimator = ObjectAnimator.ofFloat(mFullContainer, View.ALPHA, 0f, 1f); AnimatorSet alphaSet = new AnimatorSet(); alphaSet.playTogether(scrimFader, alphaAnimator); alphaSet.setDuration(DIALOG_ENTER_ANIMATION_MS); alphaSet.setInterpolator(new LinearOutSlowInInterpolator()); alphaSet.start(); } } /** * Animates the bottom sheet UI translating upwards from the bottom of the screen. * Can be canceled when a {@link SheetEnlargingAnimator} starts and expands the dialog. */ private class PeekingAnimator implements OnLayoutChangeListener { @Override public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { mRequestView.removeOnLayoutChangeListener(this); mSheetAnimator = ObjectAnimator.ofFloat( mRequestView, View.TRANSLATION_Y, mAnimatorTranslation, 0); mSheetAnimator.setDuration(DIALOG_ENTER_ANIMATION_MS); mSheetAnimator.setInterpolator(new LinearOutSlowInInterpolator()); mSheetAnimator.start(); } } /** Animates the bottom sheet expanding to a larger sheet. */ private class SheetEnlargingAnimator extends AnimatorListenerAdapter implements OnLayoutChangeListener { private final boolean mIsButtonBarLockedInPlace; private int mContainerHeightDifference; public SheetEnlargingAnimator(boolean isButtonBarLockedInPlace) { mIsButtonBarLockedInPlace = isButtonBarLockedInPlace; } /** * Updates the animation. * * @param progress How far along the animation is. In the range [0,1], with 1 being done. */ private void update(float progress) { // The dialog container initially starts off translated downward, gradually decreasing // the translation until it is in the right place on screen. float containerTranslation = mContainerHeightDifference * progress; mRequestView.setTranslationY(containerTranslation); if (mIsButtonBarLockedInPlace) { // The button bar is translated along the dialog so that is looks like it stays in // place at the bottom while the entire bottom sheet is translating upwards. mButtonBar.setTranslationY(-containerTranslation); // The payment container is sandwiched between the header and the button bar. // Expansion animates by changing where its "bottom" is, letting its shadows appear // and disappear as it changes size. int paymentContainerBottom = Math.min( mPaymentContainer.getTop() + mPaymentContainer.getMeasuredHeight(), mButtonBar.getTop()); mPaymentContainer.setBottom(paymentContainerBottom); } } @Override public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { if (mSheetAnimator != null) mSheetAnimator.cancel(); mRequestView.removeOnLayoutChangeListener(this); mContainerHeightDifference = (bottom - top) - (oldBottom - oldTop); ValueAnimator containerAnimator = ValueAnimator.ofFloat(1f, 0f); containerAnimator.addUpdateListener(new AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float alpha = (Float) animation.getAnimatedValue(); update(alpha); } }); mSheetAnimator = containerAnimator; mSheetAnimator.setDuration(DIALOG_ENTER_ANIMATION_MS); mSheetAnimator.setInterpolator(new LinearOutSlowInInterpolator()); mSheetAnimator.addListener(this); mSheetAnimator.start(); } @Override public void onAnimationEnd(Animator animation) { // Reset the layout so that everything is in the expected place. mRequestView.setTranslationY(0); mButtonBar.setTranslationY(0); mRequestView.requestLayout(); // Indicate that the dialog is ready to use. mSheetAnimator = null; mIsInitialLayoutComplete = true; notifyReadyForInput(); mReadyToPayNotifierForTest.run(); } } /** Animates the bottom sheet (and optionally, the scrim) disappearing off screen. */ private class DisappearingAnimator extends AnimatorListenerAdapter { private final boolean mIsDialogClosing; public DisappearingAnimator(boolean removeDialog) { mIsDialogClosing = removeDialog; Animator sheetFader = ObjectAnimator.ofFloat( mRequestView, View.ALPHA, mRequestView.getAlpha(), 0f); Animator sheetTranslator = ObjectAnimator.ofFloat( mRequestView, View.TRANSLATION_Y, 0f, mAnimatorTranslation); AnimatorSet current = new AnimatorSet(); current.setDuration(DIALOG_EXIT_ANIMATION_MS); current.setInterpolator(new FastOutLinearInInterpolator()); if (mIsDialogClosing) { Animator scrimFader = ObjectAnimator.ofInt(mFullContainer.getBackground(), AnimatorProperties.DRAWABLE_ALPHA_PROPERTY, 127, 0); current.playTogether(sheetFader, sheetTranslator, scrimFader); } else { current.playTogether(sheetFader, sheetTranslator); } mSheetAnimator = current; mSheetAnimator.addListener(this); mSheetAnimator.start(); } @Override public void onAnimationEnd(Animator animation) { mSheetAnimator = null; mFullContainer.removeView(mRequestView); if (mIsDialogClosing && mDialog.isShowing()) mDialog.dismiss(); } } @VisibleForTesting public static void setObserverForTest(PaymentRequestObserverForTest observerForTest) { sObserverForTest = observerForTest; } @VisibleForTesting public Dialog getDialogForTest() { return mDialog; } @VisibleForTesting public PaymentRequestSection getShippingSummarySectionForTest() { return mShippingSummarySection; } @VisibleForTesting public ViewGroup getShippingAddressSectionForTest() { return mShippingAddressSection; } @VisibleForTesting public ViewGroup getPaymentMethodSectionForTest() { return mPaymentMethodSection; } @VisibleForTesting public ViewGroup getContactDetailsSectionForTest() { return mContactDetailsSection; } private void notifyReadyForInput() { if (sObserverForTest != null && isAcceptingUserInput()) { sObserverForTest.onPaymentRequestReadyForInput(this); } } private void notifySelectionChecked() { if (sObserverForTest != null) { sObserverForTest.onPaymentRequestSelectionChecked(this); } } }