// 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);
}
}
}