blob: 6c78fa4cf750fec6802d214b05bec19454241756 [file] [log] [blame]
// 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;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.os.Handler;
import android.support.v4.util.ArrayMap;
import android.text.TextUtils;
import org.chromium.base.Callback;
import org.chromium.base.Log;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ChromeActivity;
import org.chromium.chrome.browser.ChromeFeatureList;
import org.chromium.chrome.browser.UrlConstants;
import org.chromium.chrome.browser.autofill.PersonalDataManager;
import org.chromium.chrome.browser.autofill.PersonalDataManager.AutofillProfile;
import org.chromium.chrome.browser.autofill.PersonalDataManager.NormalizedAddressRequestDelegate;
import org.chromium.chrome.browser.favicon.FaviconHelper;
import org.chromium.chrome.browser.page_info.CertificateChainHelper;
import org.chromium.chrome.browser.payments.ui.Completable;
import org.chromium.chrome.browser.payments.ui.ContactDetailsSection;
import org.chromium.chrome.browser.payments.ui.LineItem;
import org.chromium.chrome.browser.payments.ui.PaymentInformation;
import org.chromium.chrome.browser.payments.ui.PaymentOption;
import org.chromium.chrome.browser.payments.ui.PaymentRequestSection.OptionSection.FocusChangedObserver;
import org.chromium.chrome.browser.payments.ui.PaymentRequestUI;
import org.chromium.chrome.browser.payments.ui.SectionInformation;
import org.chromium.chrome.browser.payments.ui.ShoppingCart;
import org.chromium.chrome.browser.preferences.PreferencesLauncher;
import org.chromium.chrome.browser.preferences.autofill.AutofillAndPaymentsPreferences;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.ssl.SecurityStateModel;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tabmodel.EmptyTabModelObserver;
import org.chromium.chrome.browser.tabmodel.EmptyTabModelSelectorObserver;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModel.TabSelectionType;
import org.chromium.chrome.browser.tabmodel.TabModelObserver;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.tabmodel.TabModelSelectorObserver;
import org.chromium.components.payments.CurrencyFormatter;
import org.chromium.components.payments.OriginSecurityChecker;
import org.chromium.components.payments.PaymentValidator;
import org.chromium.components.url_formatter.UrlFormatter;
import org.chromium.content_public.browser.RenderFrameHost;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.WebContentsStatics;
import org.chromium.mojo.system.MojoException;
import org.chromium.payments.mojom.CanMakePaymentQueryResult;
import org.chromium.payments.mojom.PaymentComplete;
import org.chromium.payments.mojom.PaymentCurrencyAmount;
import org.chromium.payments.mojom.PaymentDetails;
import org.chromium.payments.mojom.PaymentDetailsModifier;
import org.chromium.payments.mojom.PaymentErrorReason;
import org.chromium.payments.mojom.PaymentItem;
import org.chromium.payments.mojom.PaymentMethodData;
import org.chromium.payments.mojom.PaymentOptions;
import org.chromium.payments.mojom.PaymentRequest;
import org.chromium.payments.mojom.PaymentRequestClient;
import org.chromium.payments.mojom.PaymentResponse;
import org.chromium.payments.mojom.PaymentShippingOption;
import org.chromium.payments.mojom.PaymentShippingType;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import javax.annotation.Nullable;
/**
* Android implementation of the PaymentRequest service defined in
* components/payments/content/payment_request.mojom.
*/
public class PaymentRequestImpl implements PaymentRequest, PaymentRequestUI.Client,
PaymentApp.InstrumentsCallback,
PaymentInstrument.InstrumentDetailsCallback,
PaymentAppFactory.PaymentAppCreatedCallback,
PaymentResponseHelper.PaymentResponseRequesterDelegate,
FocusChangedObserver, NormalizedAddressRequestDelegate {
/**
* A test-only observer for the PaymentRequest service implementation.
*/
public interface PaymentRequestServiceObserverForTest {
/**
* Called when an abort request was denied.
*/
void onPaymentRequestServiceUnableToAbort();
/**
* Called when the controller is notified of billing address change, but does not alter the
* editor UI.
*/
void onPaymentRequestServiceBillingAddressChangeProcessed();
/**
* Called when the controller is notified of an expiration month change.
*/
void onPaymentRequestServiceExpirationMonthChange();
/**
* Called when a show request failed. This can happen when:
* <ul>
* <li>The merchant requests only unsupported payment methods.</li>
* <li>The merchant requests only payment methods that don't have instruments and are not
* able to add instruments from PaymentRequest UI.</li>
* </ul>
*/
void onPaymentRequestServiceShowFailed();
/**
* Called when the canMakePayment() request has been responded.
*/
void onPaymentRequestServiceCanMakePaymentQueryResponded();
}
/** The object to keep track of payment queries. */
private static class CanMakePaymentQuery {
private final Set<PaymentRequestImpl> mObservers = new HashSet<>();
private final Map<String, String> mMethods;
/**
* Keeps track of a payment query.
*
* @param methods The map of the payment methods that are being queried to the corresponding
* payment method data.
*/
public CanMakePaymentQuery(Map<String, PaymentMethodData> methods) {
assert methods != null;
mMethods = new HashMap<>();
for (Map.Entry<String, PaymentMethodData> method : methods.entrySet()) {
mMethods.put(method.getKey(),
method.getValue() == null ? "" : method.getValue().stringifiedData);
}
}
/**
* Checks whether the given payment methods and data match the previously queried payment
* methods and data.
*
* @param methods The map of the payment methods that are being queried to the corresponding
* payment method data.
* @return True if the given methods and data match the previously queried payment methods
* and data.
*/
public boolean matchesPaymentMethods(Map<String, PaymentMethodData> methods) {
if (!mMethods.keySet().equals(methods.keySet())) return false;
for (Map.Entry<String, String> thisMethod : mMethods.entrySet()) {
PaymentMethodData otherMethod = methods.get(thisMethod.getKey());
String otherData = otherMethod == null ? "" : otherMethod.stringifiedData;
if (!thisMethod.getValue().equals(otherData)) return false;
}
return true;
}
/** @param response Whether payment can be made. */
public void notifyObserversOfResponse(boolean response) {
for (PaymentRequestImpl observer : mObservers) {
observer.respondCanMakePaymentQuery(response);
}
mObservers.clear();
}
/** @param observer The observer to notify when the query response is known. */
public void addObserver(PaymentRequestImpl observer) {
mObservers.add(observer);
}
};
/** Limit in the number of suggested items in a section. */
public static final int SUGGESTIONS_LIMIT = 4;
private static final String TAG = "cr_PaymentRequest";
private static final String ANDROID_PAY_METHOD_NAME = "https://android.com/pay";
private static final String PAY_WITH_GOOGLE_METHOD_NAME = "https://google.com/pay";
private static final Comparator<Completable> COMPLETENESS_COMPARATOR =
new Comparator<Completable>() {
@Override
public int compare(Completable a, Completable b) {
return (b.isComplete() ? 1 : 0) - (a.isComplete() ? 1 : 0);
}
};
/**
* Comparator to sort payment apps by maximum frecency score of the contained instruments. Note
* that the first instrument in the list must have the maximum frecency score.
*/
private static final Comparator<List<PaymentInstrument>> APP_FRECENCY_COMPARATOR =
new Comparator<List<PaymentInstrument>>() {
@Override
public int compare(List<PaymentInstrument> a, List<PaymentInstrument> b) {
return compareInstrumentsByFrecency(b.get(0), a.get(0));
}
};
/** Comparator to sort instruments in payment apps by frecency. */
private static final Comparator<PaymentInstrument> INSTRUMENT_FRECENCY_COMPARATOR =
new Comparator<PaymentInstrument>() {
@Override
public int compare(PaymentInstrument a, PaymentInstrument b) {
return compareInstrumentsByFrecency(b, a);
}
};
/** Every origin can call canMakePayment() every 30 minutes. */
private static final int CAN_MAKE_PAYMENT_QUERY_PERIOD_MS = 30 * 60 * 1000;
private static PaymentRequestServiceObserverForTest sObserverForTest;
private static boolean sIsLocalCanMakePaymentQueryQuotaEnforcedForTest;
/**
* True if show() was called in any PaymentRequestImpl object. Used to prevent showing more than
* one PaymentRequest UI per browser process.
*/
private static boolean sIsAnyPaymentRequestShowing;
/**
* In-memory mapping of the origins of iframes that have recently called canMakePayment() to the
* list of the payment methods that were being queried. Used for throttling the usage of this
* call. The mapping is shared among all instances of PaymentRequestImpl in the browser process
* on UI thread. The user can reset the throttling mechanism by restarting the browser.
*/
private static Map<String, CanMakePaymentQuery> sCanMakePaymentQueries;
/** Monitors changes in the TabModelSelector. */
private final TabModelSelectorObserver mSelectorObserver = new EmptyTabModelSelectorObserver() {
@Override
public void onTabModelSelected(TabModel newModel, TabModel oldModel) {
onDismiss();
}
};
/** Monitors changes in the current TabModel. */
private final TabModelObserver mTabModelObserver = new EmptyTabModelObserver() {
@Override
public void didSelectTab(Tab tab, TabSelectionType type, int lastId) {
if (tab == null || tab.getId() != lastId) onDismiss();
}
};
private final Handler mHandler = new Handler();
private final RenderFrameHost mRenderFrameHost;
private final WebContents mWebContents;
private final String mSchemelessOriginForPaymentApp;
private final String mOriginForDisplay;
private final String mSchemelessIFrameOriginForPaymentApp;
private final String mMerchantName;
@Nullable
private final byte[][] mCertificateChain;
private final AddressEditor mAddressEditor;
private final CardEditor mCardEditor;
private final JourneyLogger mJourneyLogger;
private final boolean mIsIncognito;
private PaymentRequestClient mClient;
private boolean mIsCurrentPaymentRequestShowing;
/**
* The raw total amount being charged, as it was received from the website. This data is passed
* to the payment app.
*/
private PaymentItem mRawTotal;
/**
* The raw items in the shopping cart, as they were received from the website. This data is
* passed to the payment app.
*/
private List<PaymentItem> mRawLineItems;
/**
* A mapping from method names to modifiers, which include modified totals and additional line
* items. Used to display modified totals for each payment instrument, modified total in order
* summary, and additional line items in order summary.
*/
private Map<String, PaymentDetailsModifier> mModifiers;
/**
* The UI model of the shopping cart, including the total. Each item includes a label and a
* price string. This data is passed to the UI.
*/
private ShoppingCart mUiShoppingCart;
/**
* The UI model for the shipping options. Includes the label and sublabel for each shipping
* option. Also keeps track of the selected shipping option. This data is passed to the UI.
*/
private SectionInformation mUiShippingOptions;
private String mId;
private Map<String, PaymentMethodData> mMethodData;
private boolean mRequestShipping;
private boolean mRequestPayerName;
private boolean mRequestPayerPhone;
private boolean mRequestPayerEmail;
private int mShippingType;
private SectionInformation mShippingAddressesSection;
private ContactDetailsSection mContactSection;
private List<PaymentApp> mApps;
private List<PaymentApp> mPendingApps;
private List<List<PaymentInstrument>> mPendingInstruments;
private List<PaymentInstrument> mPendingAutofillInstruments;
private SectionInformation mPaymentMethodsSection;
private PaymentRequestUI mUI;
private Callback<PaymentInformation> mPaymentInformationCallback;
private boolean mPaymentAppRunning;
private boolean mMerchantSupportsAutofillPaymentInstruments;
private ContactEditor mContactEditor;
private boolean mHasRecordedAbortReason;
private Map<String, CurrencyFormatter> mCurrencyFormatterMap;
private TabModelSelector mObservedTabModelSelector;
private TabModel mObservedTabModel;
/** Aborts should only be recorded if the Payment Request was shown to the user. */
private boolean mShouldRecordAbortReason;
/**
* There are a few situations were the Payment Request can appear, from a code perspective, to
* be shown more than once. This boolean is used to make sure it is only logged once.
*/
private boolean mDidRecordShowEvent;
/** True if any of the requested payment methods are supported. */
private boolean mArePaymentMethodsSupported;
/**
* True after at least one usable payment instrument has been found. Should be read only after
* all payment apps have been queried.
*/
private boolean mCanMakePayment;
/**
* True if we should skip showing PaymentRequest UI.
*
* <p>In cases where there is a single payment app and the merchant does not request shipping
* or billing, we can skip showing UI as Payment Request UI is not benefiting the user at all.
*/
private boolean mShouldSkipShowingPaymentRequestUi;
/** The helper to create and fill the response to send to the merchant. */
private PaymentResponseHelper mPaymentResponseHelper;
/**
* Builds the PaymentRequest service implementation.
*
* @param renderFrameHost The host of the frame that has invoked the PaymentRequest API.
*/
public PaymentRequestImpl(RenderFrameHost renderFrameHost) {
assert renderFrameHost != null;
mRenderFrameHost = renderFrameHost;
mSchemelessIFrameOriginForPaymentApp = UrlFormatter.formatUrlForSecurityDisplay(
mRenderFrameHost.getLastCommittedURL(), false /* omit scheme for payment apps. */);
mWebContents = WebContentsStatics.fromRenderFrameHost(renderFrameHost);
mSchemelessOriginForPaymentApp = UrlFormatter.formatUrlForSecurityDisplay(
mWebContents.getLastCommittedUrl(), false /* omit scheme for payment apps. */);
mOriginForDisplay = UrlFormatter.formatUrlForSecurityDisplay(
mWebContents.getLastCommittedUrl(), true /* include scheme in display */);
mMerchantName = mWebContents.getTitle();
mCertificateChain = CertificateChainHelper.getCertificateChain(mWebContents);
mApps = new ArrayList<>();
mAddressEditor = new AddressEditor();
mCardEditor = new CardEditor(mWebContents, mAddressEditor, sObserverForTest);
ChromeActivity activity = ChromeActivity.fromWebContents(mWebContents);
mIsIncognito = activity != null && activity.getCurrentTabModel() != null
&& activity.getCurrentTabModel().isIncognito();
mJourneyLogger = new JourneyLogger(mIsIncognito, mWebContents.getLastCommittedUrl());
if (sCanMakePaymentQueries == null) sCanMakePaymentQueries = new ArrayMap<>();
mCurrencyFormatterMap = new HashMap<>();
recordSuccessFunnelHistograms("Initiated");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
for (CurrencyFormatter formatter : mCurrencyFormatterMap.values()) {
assert formatter != null;
// Ensures the native implementation of currency formatter does not leak.
formatter.destroy();
}
}
/**
* Called by the merchant website to initialize the payment request data.
*/
@Override
public void init(PaymentRequestClient client, PaymentMethodData[] methodData,
PaymentDetails details, PaymentOptions options) {
if (mClient != null || client == null) return;
mClient = client;
if (!OriginSecurityChecker.isOriginSecure(mWebContents.getLastCommittedUrl())) {
recordAbortReasonHistogram(
PaymentRequestMetrics.ABORT_REASON_INVALID_DATA_FROM_RENDERER);
disconnectFromClientWithDebugMessage("Not in a secure context");
return;
}
mRequestShipping = options != null && options.requestShipping;
mRequestPayerName = options != null && options.requestPayerName;
mRequestPayerPhone = options != null && options.requestPayerPhone;
mRequestPayerEmail = options != null && options.requestPayerEmail;
mShippingType = options == null ? PaymentShippingType.SHIPPING : options.shippingType;
if (!OriginSecurityChecker.isSchemeCryptographic(mWebContents.getLastCommittedUrl())
&& !OriginSecurityChecker.isOriginLocalhostOrFile(
mWebContents.getLastCommittedUrl())) {
Log.d(TAG, "Only localhost, file://, and cryptographic scheme origins allowed");
// Don't show any UI. Resolve .canMakePayment() with "false". Reject .show() with
// "NotSupportedError".
onAllPaymentAppsCreated();
return;
}
PaymentRequestMetrics.recordRequestedInformationHistogram(
mRequestPayerEmail, mRequestPayerPhone, mRequestShipping, mRequestPayerName);
if (OriginSecurityChecker.isSchemeCryptographic(mWebContents.getLastCommittedUrl())
&& !SslValidityChecker.isSslCertificateValid(mWebContents)) {
Log.d(TAG, "SSL certificate is not valid");
// Don't show any UI. Resolve .canMakePayment() with "false". Reject .show() with
// "NotSupportedError".
onAllPaymentAppsCreated();
return;
}
if (mMethodData != null) {
recordAbortReasonHistogram(
PaymentRequestMetrics.ABORT_REASON_INVALID_DATA_FROM_RENDERER);
disconnectFromClientWithDebugMessage("Renderer should never call init() twice");
return;
}
mMethodData = getValidatedMethodData(methodData, mCardEditor);
if (mMethodData == null) {
recordAbortReasonHistogram(
PaymentRequestMetrics.ABORT_REASON_INVALID_DATA_FROM_RENDERER);
disconnectFromClientWithDebugMessage("Invalid payment methods or data");
return;
}
if (!parseAndValidateDetailsOrDisconnectFromClient(details)) return;
if (mRawTotal == null) {
recordAbortReasonHistogram(
PaymentRequestMetrics.ABORT_REASON_INVALID_DATA_FROM_RENDERER);
disconnectFromClientWithDebugMessage("Missing total");
return;
}
mId = details.id;
PaymentAppFactory.getInstance().create(mWebContents,
Collections.unmodifiableSet(mMethodData.keySet()), this /* callback */);
// If there is a single payment method and the merchant has not requested any other
// information, we can safely go directly to the payment app instead of showing
// Payment Request UI.
mShouldSkipShowingPaymentRequestUi =
ChromeFeatureList.isEnabled(ChromeFeatureList.WEB_PAYMENTS_SINGLE_APP_UI_SKIP)
&& mMethodData.size() == 1 && !mRequestShipping && !mRequestPayerName
&& !mRequestPayerPhone && !mRequestPayerEmail
// Only allowing payment apps that own their own UIs.
// This excludes AutofillPaymentApp as its UI is rendered inline in
// the payment request UI, thus can't be skipped.
&& mMethodData.keySet().iterator().next() != null
&& mMethodData.keySet().iterator().next().startsWith(UrlConstants.HTTPS_URL_PREFIX);
}
private void buildUI(Activity activity) {
assert activity != null;
List<AutofillProfile> profiles = null;
if (mRequestShipping || mRequestPayerName || mRequestPayerPhone || mRequestPayerEmail) {
profiles = PersonalDataManager.getInstance().getProfilesToSuggest(
false /* includeNameInLabel */);
}
if (mRequestShipping) {
createShippingSection(activity, Collections.unmodifiableList(profiles));
}
if (mRequestPayerName || mRequestPayerPhone || mRequestPayerEmail) {
mContactEditor =
new ContactEditor(mRequestPayerName, mRequestPayerPhone, mRequestPayerEmail);
mContactSection = new ContactDetailsSection(
activity, Collections.unmodifiableList(profiles), mContactEditor);
}
setIsAnyPaymentRequestShowing(true);
mUI = new PaymentRequestUI(activity, this, mRequestShipping,
mRequestPayerName || mRequestPayerPhone || mRequestPayerEmail,
mMerchantSupportsAutofillPaymentInstruments,
!PaymentPreferencesUtil.isPaymentCompleteOnce(), mMerchantName, mOriginForDisplay,
SecurityStateModel.getSecurityLevelForWebContents(mWebContents),
new ShippingStrings(mShippingType));
final FaviconHelper faviconHelper = new FaviconHelper();
faviconHelper.getLocalFaviconImageForURL(Profile.getLastUsedProfile(),
mWebContents.getLastCommittedUrl(),
activity.getResources().getDimensionPixelSize(R.dimen.payments_favicon_size),
new FaviconHelper.FaviconImageCallback() {
@Override
public void onFaviconAvailable(Bitmap bitmap, String iconUrl) {
if (mUI != null && bitmap != null) mUI.setTitleBitmap(bitmap);
faviconHelper.destroy();
}
});
// Add the callback to change the label of shipping addresses depending on the focus.
if (mRequestShipping) mUI.setShippingAddressSectionFocusChangedObserver(this);
mAddressEditor.setEditorView(mUI.getEditorView());
mCardEditor.setEditorView(mUI.getCardEditorView());
if (mContactEditor != null) mContactEditor.setEditorView(mUI.getEditorView());
}
private void createShippingSection(
Context context, List<AutofillProfile> unmodifiableProfiles) {
List<AutofillAddress> addresses = new ArrayList<>();
for (int i = 0; i < unmodifiableProfiles.size(); i++) {
AutofillProfile profile = unmodifiableProfiles.get(i);
mAddressEditor.addPhoneNumberIfValid(profile.getPhoneNumber());
// Only suggest addresses that have a street address.
if (!TextUtils.isEmpty(profile.getStreetAddress())) {
addresses.add(new AutofillAddress(context, profile));
}
}
// Suggest complete addresses first.
Collections.sort(addresses, COMPLETENESS_COMPARATOR);
// Limit the number of suggestions.
addresses = addresses.subList(0, Math.min(addresses.size(), SUGGESTIONS_LIMIT));
// Load the validation rules for each unique region code.
Set<String> uniqueCountryCodes = new HashSet<>();
for (int i = 0; i < addresses.size(); ++i) {
String countryCode = AutofillAddress.getCountryCode(addresses.get(i).getProfile());
if (!uniqueCountryCodes.contains(countryCode)) {
uniqueCountryCodes.add(countryCode);
PersonalDataManager.getInstance().loadRulesForRegion(countryCode);
}
}
// Log the number of suggested shipping addresses.
mJourneyLogger.setNumberOfSuggestionsShown(
JourneyLogger.SECTION_SHIPPING_ADDRESS, addresses.size());
// Automatically select the first address if one is complete and if the merchant does
// not require a shipping address to calculate shipping costs.
int firstCompleteAddressIndex = SectionInformation.NO_SELECTION;
if (mUiShippingOptions.getSelectedItem() != null && !addresses.isEmpty()
&& addresses.get(0).isComplete()) {
firstCompleteAddressIndex = 0;
// The initial label for the selected shipping address should not include the
// country.
addresses.get(firstCompleteAddressIndex).setShippingAddressLabelWithoutCountry();
}
mShippingAddressesSection = new SectionInformation(
PaymentRequestUI.TYPE_SHIPPING_ADDRESSES, firstCompleteAddressIndex, addresses);
}
/**
* Called by the merchant website to show the payment request to the user.
*/
@Override
public void show() {
if (mClient == null) return;
if (mUI != null) {
// Can be triggered only by a compromised renderer. In normal operation, calling show()
// twice on the same instance of PaymentRequest in JavaScript is rejected at the
// renderer level.
recordAbortReasonHistogram(
PaymentRequestMetrics.ABORT_REASON_INVALID_DATA_FROM_RENDERER);
disconnectFromClientWithDebugMessage("Renderer should never invoke show() twice");
return;
}
if (getIsAnyPaymentRequestShowing()) {
// The renderer can create multiple instances of PaymentRequest and call show() on each
// one. Only the first one will be shown. This also prevents multiple tabs and windows
// from showing PaymentRequest UI at the same time.
recordNoShowReasonHistogram(PaymentRequestMetrics.NO_SHOW_CONCURRENT_REQUESTS);
disconnectFromClientWithDebugMessage("A PaymentRequest UI is already showing");
if (sObserverForTest != null) sObserverForTest.onPaymentRequestServiceShowFailed();
return;
}
mIsCurrentPaymentRequestShowing = true;
if (disconnectIfNoPaymentMethodsSupported()) return;
ChromeActivity chromeActivity = ChromeActivity.fromWebContents(mWebContents);
if (chromeActivity == null) {
recordNoShowReasonHistogram(PaymentRequestMetrics.NO_SHOW_REASON_OTHER);
disconnectFromClientWithDebugMessage("Unable to find Chrome activity");
if (sObserverForTest != null) sObserverForTest.onPaymentRequestServiceShowFailed();
return;
}
// Catch any time the user switches tabs. Because the dialog is modal, a user shouldn't be
// allowed to switch tabs, which can happen if the user receives an external Intent.
mObservedTabModelSelector = chromeActivity.getTabModelSelector();
mObservedTabModel = chromeActivity.getCurrentTabModel();
mObservedTabModelSelector.addObserver(mSelectorObserver);
mObservedTabModel.addObserver(mTabModelObserver);
buildUI(chromeActivity);
if (!mShouldSkipShowingPaymentRequestUi) mUI.show();
triggerPaymentAppUiSkipIfApplicable();
}
private void triggerPaymentAppUiSkipIfApplicable() {
// If we are skipping showing the Payment Request UI, we should call into the
// PaymentApp immediately after we determine the instruments are ready and UI is shown.
if (mShouldSkipShowingPaymentRequestUi && isFinishedQueryingPaymentApps()
&& mIsCurrentPaymentRequestShowing) {
assert !mPaymentMethodsSection.isEmpty();
mDidRecordShowEvent = true;
mShouldRecordAbortReason = true;
recordSuccessFunnelHistograms("SkippedShow");
mJourneyLogger.setEventOccurred(JourneyLogger.EVENT_SKIPPED_SHOW);
mJourneyLogger.setShowCalled();
onPayClicked(null /* selectedShippingAddress */, null /* selectedShippingOption */,
mPaymentMethodsSection.getItem(0));
}
}
private static Map<String, PaymentMethodData> getValidatedMethodData(
PaymentMethodData[] methodData, CardEditor paymentMethodsCollector) {
// Payment methodData are required.
if (methodData == null || methodData.length == 0) return null;
Map<String, PaymentMethodData> result = new ArrayMap<>();
for (int i = 0; i < methodData.length; i++) {
String[] methods = methodData[i].supportedMethods;
// Payment methods are required.
if (methods == null || methods.length == 0) return null;
for (int j = 0; j < methods.length; j++) {
// Payment methods should be non-empty.
if (TextUtils.isEmpty(methods[j])) return null;
result.put(methods[j], methodData[i]);
}
paymentMethodsCollector.addAcceptedPaymentMethodsIfRecognized(methodData[i]);
}
return Collections.unmodifiableMap(result);
}
@Override
public void onPaymentAppCreated(PaymentApp paymentApp) {
mApps.add(paymentApp);
}
@Override
public void onAllPaymentAppsCreated() {
if (mClient == null) return;
assert mPendingApps == null;
mPendingApps = new ArrayList<>(mApps);
mPendingInstruments = new ArrayList<>();
mPendingAutofillInstruments = new ArrayList<>();
Map<PaymentApp, Map<String, PaymentMethodData>> queryApps = new ArrayMap<>();
for (int i = 0; i < mApps.size(); i++) {
PaymentApp app = mApps.get(i);
Map<String, PaymentMethodData> appMethods = filterMerchantMethodData(mMethodData,
app.getAppMethodNames());
if (appMethods == null || !app.supportsMethodsAndData(appMethods)) {
mPendingApps.remove(app);
} else {
mArePaymentMethodsSupported = true;
mMerchantSupportsAutofillPaymentInstruments |= app instanceof AutofillPaymentApp;
queryApps.put(app, appMethods);
}
}
if (queryApps.isEmpty()) {
CanMakePaymentQuery query =
sCanMakePaymentQueries.get(mSchemelessIFrameOriginForPaymentApp);
if (query != null && query.matchesPaymentMethods(mMethodData)) {
query.notifyObserversOfResponse(mCanMakePayment);
}
}
if (disconnectIfNoPaymentMethodsSupported()) return;
// Query instruments after mMerchantSupportsAutofillPaymentInstruments has been initialized,
// so a fast response from a non-autofill payment app at the front of the app list does not
// cause NOT_SUPPORTED payment rejection.
for (Map.Entry<PaymentApp, Map<String, PaymentMethodData>> q : queryApps.entrySet()) {
q.getKey().getInstruments(q.getValue(), mSchemelessOriginForPaymentApp,
mSchemelessIFrameOriginForPaymentApp, mCertificateChain, this);
}
}
/** Filter out merchant method data that's not relevant to a payment app. Can return null. */
private static Map<String, PaymentMethodData> filterMerchantMethodData(
Map<String, PaymentMethodData> merchantMethodData, Set<String> appMethods) {
Map<String, PaymentMethodData> result = null;
for (String method : appMethods) {
if (merchantMethodData.containsKey(method)) {
if (result == null) result = new ArrayMap<>();
result.put(method, merchantMethodData.get(method));
}
}
return result == null ? null : Collections.unmodifiableMap(result);
}
/**
* Called by merchant to update the shipping options and line items after the user has selected
* their shipping address or shipping option.
*/
@Override
public void updateWith(PaymentDetails details) {
if (mClient == null) return;
if (mUI == null) {
recordAbortReasonHistogram(
PaymentRequestMetrics.ABORT_REASON_INVALID_DATA_FROM_RENDERER);
disconnectFromClientWithDebugMessage(
"PaymentRequestUpdateEvent.updateWith() called without PaymentRequest.show()");
return;
}
if (!parseAndValidateDetailsOrDisconnectFromClient(details)) return;
if (mUiShippingOptions.isEmpty() && mShippingAddressesSection.getSelectedItem() != null) {
mShippingAddressesSection.getSelectedItem().setInvalid();
mShippingAddressesSection.setSelectedItemIndex(SectionInformation.INVALID_SELECTION);
mShippingAddressesSection.setErrorMessage(details.error);
}
if (mPaymentInformationCallback != null) {
providePaymentInformation();
} else {
mUI.updateOrderSummarySection(mUiShoppingCart);
mUI.updateSection(PaymentRequestUI.TYPE_SHIPPING_OPTIONS, mUiShippingOptions);
}
}
/**
* Sets the total, display line items, and shipping options based on input and returns the
* status boolean. That status is true for valid data, false for invalid data. If the input is
* invalid, disconnects from the client. Both raw and UI versions of data are updated.
*
* @param details The total, line items, and shipping options to parse, validate, and save in
* member variables.
* @return True if the data is valid. False if the data is invalid.
*/
private boolean parseAndValidateDetailsOrDisconnectFromClient(PaymentDetails details) {
if (!PaymentValidator.validatePaymentDetails(details)) {
recordAbortReasonHistogram(
PaymentRequestMetrics.ABORT_REASON_INVALID_DATA_FROM_RENDERER);
disconnectFromClientWithDebugMessage("Invalid payment details");
return false;
}
if (details.total != null) {
mRawTotal = details.total;
}
loadCurrencyFormattersForPaymentDetails(details);
if (mRawTotal != null) {
// Total is never pending.
CurrencyFormatter formatter = getOrCreateCurrencyFormatter(mRawTotal.amount);
LineItem uiTotal = new LineItem(mRawTotal.label, formatter.getFormattedCurrencyCode(),
formatter.format(mRawTotal.amount.value), /* isPending */ false);
List<LineItem> uiLineItems = getLineItems(details.displayItems);
mUiShoppingCart = new ShoppingCart(uiTotal, uiLineItems);
}
mRawLineItems = Collections.unmodifiableList(Arrays.asList(details.displayItems));
mUiShippingOptions = getShippingOptions(details.shippingOptions);
for (int i = 0; i < details.modifiers.length; i++) {
PaymentDetailsModifier modifier = details.modifiers[i];
String[] methods = modifier.methodData.supportedMethods;
for (int j = 0; j < methods.length; j++) {
if (mModifiers == null) mModifiers = new ArrayMap<>();
mModifiers.put(methods[j], modifier);
}
}
updateInstrumentModifiedTotals();
return true;
}
/** Updates the modifiers for payment instruments and order summary. */
private void updateInstrumentModifiedTotals() {
if (!ChromeFeatureList.isEnabled(ChromeFeatureList.WEB_PAYMENTS_MODIFIERS)) return;
if (mModifiers == null) return;
if (mPaymentMethodsSection == null) return;
for (int i = 0; i < mPaymentMethodsSection.getSize(); i++) {
PaymentInstrument instrument = (PaymentInstrument) mPaymentMethodsSection.getItem(i);
PaymentDetailsModifier modifier = getModifier(instrument);
instrument.setModifiedTotal(modifier == null || modifier.total == null
? null
: getOrCreateCurrencyFormatter(modifier.total.amount)
.format(modifier.total.amount.value));
}
updateOrderSummary((PaymentInstrument) mPaymentMethodsSection.getSelectedItem());
}
/** Sets the modifier for the order summary based on the given instrument, if any. */
private void updateOrderSummary(@Nullable PaymentInstrument instrument) {
if (!ChromeFeatureList.isEnabled(ChromeFeatureList.WEB_PAYMENTS_MODIFIERS)) return;
PaymentDetailsModifier modifier = getModifier(instrument);
PaymentItem total = modifier == null ? null : modifier.total;
if (total == null) total = mRawTotal;
CurrencyFormatter formatter = getOrCreateCurrencyFormatter(total.amount);
mUiShoppingCart.setTotal(new LineItem(total.label, formatter.getFormattedCurrencyCode(),
formatter.format(total.amount.value), false /* isPending */));
mUiShoppingCart.setAdditionalContents(
modifier == null ? null : getLineItems(modifier.additionalDisplayItems));
mUI.updateOrderSummarySection(mUiShoppingCart);
}
/** @return The first modifier that matches the given instrument, or null. */
@Nullable private PaymentDetailsModifier getModifier(@Nullable PaymentInstrument instrument) {
if (mModifiers == null || instrument == null) return null;
Set<String> methodNames = instrument.getInstrumentMethodNames();
methodNames.retainAll(mModifiers.keySet());
return methodNames.isEmpty() ? null : mModifiers.get(methodNames.iterator().next());
}
/**
* Converts a list of payment items and returns their parsed representation.
*
* @param items The payment items to parse. Can be null.
* @return A list of valid line items.
*/
private List<LineItem> getLineItems(@Nullable PaymentItem[] items) {
// Line items are optional.
if (items == null) return new ArrayList<>();
List<LineItem> result = new ArrayList<>(items.length);
for (int i = 0; i < items.length; i++) {
PaymentItem item = items[i];
CurrencyFormatter formatter = getOrCreateCurrencyFormatter(item.amount);
result.add(new LineItem(item.label,
isMixedOrChangedCurrency() ? formatter.getFormattedCurrencyCode() : "",
formatter.format(item.amount.value), item.pending));
}
return Collections.unmodifiableList(result);
}
/**
* Converts a list of shipping options and returns their parsed representation.
*
* @param options The raw shipping options to parse. Can be null.
* @return The UI representation of the shipping options.
*/
private SectionInformation getShippingOptions(@Nullable PaymentShippingOption[] options) {
// Shipping options are optional.
if (options == null || options.length == 0) {
return new SectionInformation(PaymentRequestUI.TYPE_SHIPPING_OPTIONS);
}
List<PaymentOption> result = new ArrayList<>();
int selectedItemIndex = SectionInformation.NO_SELECTION;
for (int i = 0; i < options.length; i++) {
PaymentShippingOption option = options[i];
CurrencyFormatter formatter = getOrCreateCurrencyFormatter(option.amount);
String currencyPrefix = isMixedOrChangedCurrency()
? formatter.getFormattedCurrencyCode() + "\u0020"
: "";
result.add(new PaymentOption(option.id, option.label,
currencyPrefix + formatter.format(option.amount.value), null));
if (option.selected) selectedItemIndex = i;
}
return new SectionInformation(PaymentRequestUI.TYPE_SHIPPING_OPTIONS, selectedItemIndex,
Collections.unmodifiableList(result));
}
/**
* Load required currency formatter for a given PaymentDetails.
*
* Note that the cache (mCurrencyFormatterMap) is not cleared for
* updated payment details so as to indicate the currency has been changed.
*
* @param details The given payment details.
*/
private void loadCurrencyFormattersForPaymentDetails(PaymentDetails details) {
if (details.total != null) {
getOrCreateCurrencyFormatter(details.total.amount);
}
if (details.displayItems != null) {
for (PaymentItem item : details.displayItems) {
getOrCreateCurrencyFormatter(item.amount);
}
}
if (details.shippingOptions != null) {
for (PaymentShippingOption option : details.shippingOptions) {
getOrCreateCurrencyFormatter(option.amount);
}
}
if (details.modifiers != null) {
for (PaymentDetailsModifier modifier : details.modifiers) {
getOrCreateCurrencyFormatter(modifier.total.amount);
for (PaymentItem displayItem : modifier.additionalDisplayItems) {
getOrCreateCurrencyFormatter(displayItem.amount);
}
}
}
}
private boolean isMixedOrChangedCurrency() {
return mCurrencyFormatterMap.size() > 1;
}
/**
* Gets currency formatter for a given PaymentCurrencyAmount,
* creates one if no existing instance is found.
*
* @amount The given payment amount.
*/
private CurrencyFormatter getOrCreateCurrencyFormatter(PaymentCurrencyAmount amount) {
String key = amount.currency + amount.currencySystem;
CurrencyFormatter formatter = mCurrencyFormatterMap.get(key);
if (formatter == null) {
formatter = new CurrencyFormatter(
amount.currency, amount.currencySystem, Locale.getDefault());
mCurrencyFormatterMap.put(key, formatter);
}
return formatter;
}
/**
* Called to retrieve the data to show in the initial PaymentRequest UI.
*/
@Override
public void getDefaultPaymentInformation(Callback<PaymentInformation> callback) {
mPaymentInformationCallback = callback;
if (mPaymentMethodsSection == null) return;
mHandler.post(new Runnable() {
@Override
public void run() {
if (mUI != null) providePaymentInformation();
}
});
}
private void providePaymentInformation() {
mPaymentInformationCallback.onResult(
new PaymentInformation(mUiShoppingCart, mShippingAddressesSection,
mUiShippingOptions, mContactSection, mPaymentMethodsSection));
mPaymentInformationCallback = null;
if (!mDidRecordShowEvent) {
mDidRecordShowEvent = true;
mShouldRecordAbortReason = true;
recordSuccessFunnelHistograms("Shown");
mJourneyLogger.setShowCalled();
}
}
@Override
public void getShoppingCart(final Callback<ShoppingCart> callback) {
mHandler.post(new Runnable() {
@Override
public void run() {
callback.onResult(mUiShoppingCart);
}
});
}
@Override
public void getSectionInformation(@PaymentRequestUI.DataType final int optionType,
final Callback<SectionInformation> callback) {
mHandler.post(new Runnable() {
@Override
public void run() {
if (optionType == PaymentRequestUI.TYPE_SHIPPING_ADDRESSES) {
callback.onResult(mShippingAddressesSection);
} else if (optionType == PaymentRequestUI.TYPE_SHIPPING_OPTIONS) {
callback.onResult(mUiShippingOptions);
} else if (optionType == PaymentRequestUI.TYPE_CONTACT_DETAILS) {
callback.onResult(mContactSection);
} else if (optionType == PaymentRequestUI.TYPE_PAYMENT_METHODS) {
assert mPaymentMethodsSection != null;
callback.onResult(mPaymentMethodsSection);
}
}
});
}
@Override
@PaymentRequestUI.SelectionResult
public int onSectionOptionSelected(@PaymentRequestUI.DataType int optionType,
PaymentOption option, Callback<PaymentInformation> callback) {
if (optionType == PaymentRequestUI.TYPE_SHIPPING_ADDRESSES) {
assert option instanceof AutofillAddress;
// Log the change of shipping address.
mJourneyLogger.incrementSelectionChanges(JourneyLogger.SECTION_SHIPPING_ADDRESS);
AutofillAddress address = (AutofillAddress) option;
if (address.isComplete()) {
mShippingAddressesSection.setSelectedItem(option);
startShippingAddressChangeNormalization(address);
} else {
editAddress(address);
}
mPaymentInformationCallback = callback;
return PaymentRequestUI.SELECTION_RESULT_ASYNCHRONOUS_VALIDATION;
} else if (optionType == PaymentRequestUI.TYPE_SHIPPING_OPTIONS) {
// This may update the line items.
mUiShippingOptions.setSelectedItem(option);
mClient.onShippingOptionChange(option.getIdentifier());
mPaymentInformationCallback = callback;
return PaymentRequestUI.SELECTION_RESULT_ASYNCHRONOUS_VALIDATION;
} else if (optionType == PaymentRequestUI.TYPE_CONTACT_DETAILS) {
assert option instanceof AutofillContact;
// Log the change of contact info.
mJourneyLogger.incrementSelectionChanges(JourneyLogger.SECTION_CONTACT_INFO);
AutofillContact contact = (AutofillContact) option;
if (contact.isComplete()) {
mContactSection.setSelectedItem(option);
} else {
editContact(contact);
return PaymentRequestUI.SELECTION_RESULT_EDITOR_LAUNCH;
}
} else if (optionType == PaymentRequestUI.TYPE_PAYMENT_METHODS) {
assert option instanceof PaymentInstrument;
if (option instanceof AutofillPaymentInstrument) {
// Log the change of credit card.
mJourneyLogger.incrementSelectionChanges(JourneyLogger.SECTION_CREDIT_CARDS);
AutofillPaymentInstrument card = (AutofillPaymentInstrument) option;
if (!card.isComplete()) {
editCard(card);
return PaymentRequestUI.SELECTION_RESULT_EDITOR_LAUNCH;
}
}
updateOrderSummary((PaymentInstrument) option);
mPaymentMethodsSection.setSelectedItem(option);
}
return PaymentRequestUI.SELECTION_RESULT_NONE;
}
@Override
@PaymentRequestUI.SelectionResult
public int onSectionEditOption(@PaymentRequestUI.DataType int optionType, PaymentOption option,
Callback<PaymentInformation> callback) {
if (optionType == PaymentRequestUI.TYPE_SHIPPING_ADDRESSES) {
assert option instanceof AutofillAddress;
editAddress((AutofillAddress) option);
mPaymentInformationCallback = callback;
return PaymentRequestUI.SELECTION_RESULT_ASYNCHRONOUS_VALIDATION;
}
if (optionType == PaymentRequestUI.TYPE_CONTACT_DETAILS) {
assert option instanceof AutofillContact;
editContact((AutofillContact) option);
return PaymentRequestUI.SELECTION_RESULT_EDITOR_LAUNCH;
}
if (optionType == PaymentRequestUI.TYPE_PAYMENT_METHODS) {
assert option instanceof AutofillPaymentInstrument;
editCard((AutofillPaymentInstrument) option);
return PaymentRequestUI.SELECTION_RESULT_EDITOR_LAUNCH;
}
assert false;
return PaymentRequestUI.SELECTION_RESULT_NONE;
}
@Override
@PaymentRequestUI.SelectionResult
public int onSectionAddOption(
@PaymentRequestUI.DataType int optionType, Callback<PaymentInformation> callback) {
if (optionType == PaymentRequestUI.TYPE_SHIPPING_ADDRESSES) {
editAddress(null);
mPaymentInformationCallback = callback;
// Log the add of shipping address.
mJourneyLogger.incrementSelectionAdds(JourneyLogger.SECTION_SHIPPING_ADDRESS);
return PaymentRequestUI.SELECTION_RESULT_ASYNCHRONOUS_VALIDATION;
} else if (optionType == PaymentRequestUI.TYPE_CONTACT_DETAILS) {
editContact(null);
// Log the add of contact info.
mJourneyLogger.incrementSelectionAdds(JourneyLogger.SECTION_CONTACT_INFO);
return PaymentRequestUI.SELECTION_RESULT_EDITOR_LAUNCH;
} else if (optionType == PaymentRequestUI.TYPE_PAYMENT_METHODS) {
editCard(null);
// Log the add of credit card.
mJourneyLogger.incrementSelectionAdds(JourneyLogger.SECTION_CREDIT_CARDS);
return PaymentRequestUI.SELECTION_RESULT_EDITOR_LAUNCH;
}
return PaymentRequestUI.SELECTION_RESULT_NONE;
}
private void editAddress(final AutofillAddress toEdit) {
if (toEdit != null) {
// Log the edit of a shipping address.
mJourneyLogger.incrementSelectionEdits(JourneyLogger.SECTION_SHIPPING_ADDRESS);
}
mAddressEditor.edit(toEdit, new Callback<AutofillAddress>() {
@Override
public void onResult(AutofillAddress editedAddress) {
if (mUI == null) return;
if (editedAddress != null) {
// Sets or updates the shipping address label.
editedAddress.setShippingAddressLabelWithCountry();
mCardEditor.updateBillingAddressIfComplete(editedAddress);
// A partial or complete address came back from the editor (could have been from
// adding/editing or cancelling out of the edit flow).
if (!editedAddress.isComplete()) {
// If the address is not complete, unselect it (editor can return incomplete
// information when cancelled).
mShippingAddressesSection.setSelectedItemIndex(
SectionInformation.NO_SELECTION);
providePaymentInformation();
} else {
if (toEdit == null) {
// Address is complete and user was in the "Add flow": add an item to
// the list.
mShippingAddressesSection.addAndSelectItem(editedAddress);
}
if (mContactSection != null) {
// Update |mContactSection| with the new/edited address, which will
// update an existing item or add a new one to the end of the list.
mContactSection.addOrUpdateWithAutofillAddress(editedAddress);
mUI.updateSection(
PaymentRequestUI.TYPE_CONTACT_DETAILS, mContactSection);
}
startShippingAddressChangeNormalization(editedAddress);
}
} else {
providePaymentInformation();
}
}
});
}
private void editContact(final AutofillContact toEdit) {
if (toEdit != null) {
// Log the edit of a contact info.
mJourneyLogger.incrementSelectionEdits(JourneyLogger.SECTION_CONTACT_INFO);
}
mContactEditor.edit(toEdit, new Callback<AutofillContact>() {
@Override
public void onResult(AutofillContact editedContact) {
if (mUI == null) return;
if (editedContact != null) {
// A partial or complete contact came back from the editor (could have been from
// adding/editing or cancelling out of the edit flow).
if (!editedContact.isComplete()) {
// If the contact is not complete according to the requirements of the flow,
// unselect it (editor can return incomplete information when cancelled).
mContactSection.setSelectedItemIndex(SectionInformation.NO_SELECTION);
} else if (toEdit == null) {
// Contact is complete and we were in the "Add flow": add an item to the
// list.
mContactSection.addAndSelectItem(editedContact);
}
// If contact is complete and (toEdit != null), no action needed: the contact
// was already selected in the UI.
}
// If |editedContact| is null, the user has cancelled out of the "Add flow". No
// action to take (if a contact was selected in the UI, it will stay selected).
mUI.updateSection(PaymentRequestUI.TYPE_CONTACT_DETAILS, mContactSection);
}
});
}
private void editCard(final AutofillPaymentInstrument toEdit) {
if (toEdit != null) {
// Log the edit of a credit card.
mJourneyLogger.incrementSelectionEdits(JourneyLogger.SECTION_CREDIT_CARDS);
}
mCardEditor.edit(toEdit, new Callback<AutofillPaymentInstrument>() {
@Override
public void onResult(AutofillPaymentInstrument editedCard) {
if (mUI == null) return;
if (editedCard != null) {
// A partial or complete card came back from the editor (could have been from
// adding/editing or cancelling out of the edit flow).
if (!editedCard.isComplete()) {
// If the card is not complete, unselect it (editor can return incomplete
// information when cancelled).
mPaymentMethodsSection.setSelectedItemIndex(
SectionInformation.NO_SELECTION);
} else if (toEdit == null) {
// Card is complete and we were in the "Add flow": add an item to the list.
mPaymentMethodsSection.addAndSelectItem(editedCard);
}
// If card is complete and (toEdit != null), no action needed: the card was
// already selected in the UI.
}
// If |editedCard| is null, the user has cancelled out of the "Add flow". No action
// to take (if another card was selected prior to the add flow, it will stay
// selected).
updateInstrumentModifiedTotals();
mUI.updateSection(PaymentRequestUI.TYPE_PAYMENT_METHODS, mPaymentMethodsSection);
}
});
}
@Override
public void onInstrumentDetailsLoadingWithoutUI() {
if (mClient == null || mUI == null || mPaymentResponseHelper == null) return;
assert mPaymentMethodsSection.getSelectedItem() instanceof AutofillPaymentInstrument;
mUI.showProcessingMessage();
}
@Override
public boolean onPayClicked(PaymentOption selectedShippingAddress,
PaymentOption selectedShippingOption, PaymentOption selectedPaymentMethod) {
assert selectedPaymentMethod instanceof PaymentInstrument;
PaymentInstrument instrument = (PaymentInstrument) selectedPaymentMethod;
mPaymentAppRunning = true;
PaymentOption selectedContact = mContactSection != null ? mContactSection.getSelectedItem()
: null;
mPaymentResponseHelper = new PaymentResponseHelper(
selectedShippingAddress, selectedShippingOption, selectedContact, this);
// Create maps that are subsets of mMethodData and mModifiers, that contain
// the payment methods supported by the selected payment instrument. If the
// intersection of method data contains more than one payment method, the
// payment app is at liberty to choose (or have the user choose) one of the
// methods.
Map<String, PaymentMethodData> methodData = new HashMap<>();
Map<String, PaymentDetailsModifier> modifiers = new HashMap<>();
for (String instrumentMethodName : instrument.getInstrumentMethodNames()) {
if (mMethodData.containsKey(instrumentMethodName)) {
methodData.put(instrumentMethodName, mMethodData.get(instrumentMethodName));
}
if (mModifiers != null && mModifiers.containsKey(instrumentMethodName)) {
modifiers.put(instrumentMethodName, mModifiers.get(instrumentMethodName));
}
}
instrument.invokePaymentApp(mId, mMerchantName, mSchemelessOriginForPaymentApp,
mSchemelessIFrameOriginForPaymentApp, mCertificateChain,
Collections.unmodifiableMap(methodData), mRawTotal, mRawLineItems,
Collections.unmodifiableMap(modifiers), this);
recordSuccessFunnelHistograms("PayClicked");
mJourneyLogger.setEventOccurred(JourneyLogger.EVENT_PAY_CLICKED);
return !(instrument instanceof AutofillPaymentInstrument);
}
@Override
public void onDismiss() {
recordAbortReasonHistogram(PaymentRequestMetrics.ABORT_REASON_ABORTED_BY_USER);
disconnectFromClientWithDebugMessage("Dialog dismissed");
}
private void disconnectFromClientWithDebugMessage(String debugMessage) {
disconnectFromClientWithDebugMessage(debugMessage, PaymentErrorReason.USER_CANCEL);
}
private void disconnectFromClientWithDebugMessage(String debugMessage, int reason) {
Log.d(TAG, debugMessage);
if (mClient != null) mClient.onError(reason);
closeClient();
closeUI(true);
}
/**
* Called by the merchant website to abort the payment.
*/
@Override
public void abort() {
if (mClient == null) return;
mClient.onAbort(!mPaymentAppRunning);
if (mPaymentAppRunning) {
if (sObserverForTest != null) sObserverForTest.onPaymentRequestServiceUnableToAbort();
} else {
closeClient();
closeUI(true);
recordAbortReasonHistogram(PaymentRequestMetrics.ABORT_REASON_ABORTED_BY_MERCHANT);
}
}
/**
* Called when the merchant website has processed the payment.
*/
@Override
public void complete(int result) {
if (mClient == null) return;
recordSuccessFunnelHistograms("Completed");
if (!PaymentPreferencesUtil.isPaymentCompleteOnce()) {
PaymentPreferencesUtil.setPaymentCompleteOnce();
}
/**
* Update records of the used payment instrument for sorting payment apps and instruments
* next time.
*/
PaymentOption selectedPaymentMethod = mPaymentMethodsSection.getSelectedItem();
PaymentPreferencesUtil.increasePaymentInstrumentUseCount(
selectedPaymentMethod.getIdentifier());
PaymentPreferencesUtil.setPaymentInstrumentLastUseDate(
selectedPaymentMethod.getIdentifier(), System.currentTimeMillis());
closeUI(PaymentComplete.FAIL != result);
}
@Override
public void onCardAndAddressSettingsClicked() {
Context context = ChromeActivity.fromWebContents(mWebContents);
if (context == null) {
recordAbortReasonHistogram(PaymentRequestMetrics.ABORT_REASON_OTHER);
disconnectFromClientWithDebugMessage("Unable to find Chrome activity");
return;
}
Intent intent = PreferencesLauncher.createIntentForSettingsPage(
context, AutofillAndPaymentsPreferences.class.getName());
context.startActivity(intent);
recordAbortReasonHistogram(PaymentRequestMetrics.ABORT_REASON_ABORTED_BY_USER);
disconnectFromClientWithDebugMessage("Card and address settings clicked");
}
/**
* Called by the merchant website to check if the user has complete payment instruments.
*/
@Override
public void canMakePayment() {
if (mClient == null) return;
CanMakePaymentQuery query =
sCanMakePaymentQueries.get(mSchemelessIFrameOriginForPaymentApp);
if (query == null) {
// If there has not been a canMakePayment() query in the last 30 minutes, take a note
// that one has happened just now. Remember the payment method names and the
// corresponding data for the next 30 minutes. Forget about it after the 30 minute
// period expires.
query = new CanMakePaymentQuery(Collections.unmodifiableMap(mMethodData));
sCanMakePaymentQueries.put(mSchemelessIFrameOriginForPaymentApp, query);
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
sCanMakePaymentQueries.remove(mSchemelessIFrameOriginForPaymentApp);
}
}, CAN_MAKE_PAYMENT_QUERY_PERIOD_MS);
} else if (shouldEnforceCanMakePaymentQueryQuota()
&& !query.matchesPaymentMethods(Collections.unmodifiableMap(mMethodData))) {
// If there has been a canMakePayment() query in the last 30 minutes, but the previous
// payment method names and the corresponding data don't match, enforce the
// canMakePayment() query quota (unless the quota is turned off).
mClient.onCanMakePayment(CanMakePaymentQueryResult.QUERY_QUOTA_EXCEEDED);
if (sObserverForTest != null) {
sObserverForTest.onPaymentRequestServiceCanMakePaymentQueryResponded();
}
return;
}
query.addObserver(this);
if (isFinishedQueryingPaymentApps()) query.notifyObserversOfResponse(mCanMakePayment);
}
private void respondCanMakePaymentQuery(boolean response) {
if (mClient == null) return;
boolean isIgnoringQueryQuota = false;
if (!shouldEnforceCanMakePaymentQueryQuota()) {
CanMakePaymentQuery query =
sCanMakePaymentQueries.get(mSchemelessIFrameOriginForPaymentApp);
// The cached query may have expired between instantiation of PaymentRequest and
// finishing the query of the payment apps.
if (query != null) {
isIgnoringQueryQuota =
!query.matchesPaymentMethods(Collections.unmodifiableMap(mMethodData));
}
}
if (mIsIncognito) {
mClient.onCanMakePayment(CanMakePaymentQueryResult.CAN_MAKE_PAYMENT);
} else if (isIgnoringQueryQuota) {
mClient.onCanMakePayment(response
? CanMakePaymentQueryResult.WARNING_CAN_MAKE_PAYMENT
: CanMakePaymentQueryResult.WARNING_CANNOT_MAKE_PAYMENT);
} else {
mClient.onCanMakePayment(response ? CanMakePaymentQueryResult.CAN_MAKE_PAYMENT
: CanMakePaymentQueryResult.CANNOT_MAKE_PAYMENT);
}
mJourneyLogger.setCanMakePaymentValue(response || mIsIncognito);
if (sObserverForTest != null) {
sObserverForTest.onPaymentRequestServiceCanMakePaymentQueryResponded();
}
}
/**
* @return Whether canMakePayment() query quota should be enforced. By default, the quota is
* enforced only on https:// scheme origins. However, the tests also enable the quota on
* localhost and file:// scheme origins to verify its behavior.
*/
private boolean shouldEnforceCanMakePaymentQueryQuota() {
return !OriginSecurityChecker.isOriginLocalhostOrFile(mWebContents.getLastCommittedUrl())
|| sIsLocalCanMakePaymentQueryQuotaEnforcedForTest;
}
/**
* Called when the renderer closes the Mojo connection.
*/
@Override
public void close() {
if (mClient == null) return;
closeClient();
closeUI(true);
recordAbortReasonHistogram(PaymentRequestMetrics.ABORT_REASON_MOJO_RENDERER_CLOSING);
}
/**
* Called when the Mojo connection encounters an error.
*/
@Override
public void onConnectionError(MojoException e) {
if (mClient == null) return;
closeClient();
closeUI(true);
recordAbortReasonHistogram(PaymentRequestMetrics.ABORT_REASON_MOJO_CONNECTION_ERROR);
}
/**
* Called after retrieving the list of payment instruments in an app.
*/
@Override
public void onInstrumentsReady(PaymentApp app, List<PaymentInstrument> instruments) {
if (mClient == null) return;
mPendingApps.remove(app);
// Place the instruments into either "autofill" or "non-autofill" list to be displayed when
// all apps have responded.
if (instruments != null) {
List<PaymentInstrument> nonAutofillInstruments = new ArrayList<>();
for (int i = 0; i < instruments.size(); i++) {
PaymentInstrument instrument = instruments.get(i);
Set<String> instrumentMethodNames = new HashSet<>(
instrument.getInstrumentMethodNames());
instrumentMethodNames.retainAll(mMethodData.keySet());
if (!instrumentMethodNames.isEmpty()) {
if (instrument instanceof AutofillPaymentInstrument) {
mPendingAutofillInstruments.add(instrument);
} else {
nonAutofillInstruments.add(instrument);
}
} else {
instrument.dismissInstrument();
}
}
if (!nonAutofillInstruments.isEmpty()) {
Collections.sort(nonAutofillInstruments, INSTRUMENT_FRECENCY_COMPARATOR);
mPendingInstruments.add(nonAutofillInstruments);
}
}
// Some payment apps still have not responded. Continue waiting for them.
if (!mPendingApps.isEmpty()) return;
if (disconnectIfNoPaymentMethodsSupported()) return;
// Load the validation rules for each unique region code in the credit card billing
// addresses and check for validity.
Set<String> uniqueCountryCodes = new HashSet<>();
for (int i = 0; i < mPendingAutofillInstruments.size(); ++i) {
assert mPendingAutofillInstruments.get(i) instanceof AutofillPaymentInstrument;
AutofillPaymentInstrument creditCard =
(AutofillPaymentInstrument) mPendingAutofillInstruments.get(i);
String countryCode = AutofillAddress.getCountryCode(creditCard.getBillingAddress());
if (!uniqueCountryCodes.contains(countryCode)) {
uniqueCountryCodes.add(countryCode);
PersonalDataManager.getInstance().loadRulesForRegion(countryCode);
}
// If there's a card on file with a valid number and a name, then
// PaymentRequest.canMakePayment() returns true.
mCanMakePayment |= creditCard.isValidCard();
}
// List order:
// > Non-autofill instruments.
// > Complete autofill instruments.
// > Incomplete autofill instruments.
Collections.sort(mPendingAutofillInstruments, COMPLETENESS_COMPARATOR);
Collections.sort(mPendingInstruments, APP_FRECENCY_COMPARATOR);
if (!mPendingAutofillInstruments.isEmpty()) {
mPendingInstruments.add(mPendingAutofillInstruments);
}
// Log the number of suggested credit cards.
mJourneyLogger.setNumberOfSuggestionsShown(
JourneyLogger.SECTION_CREDIT_CARDS, mPendingAutofillInstruments.size());
// Possibly pre-select the first instrument on the list.
int selection = SectionInformation.NO_SELECTION;
if (!mPendingInstruments.isEmpty()) {
PaymentInstrument first = mPendingInstruments.get(0).get(0);
if (first instanceof AutofillPaymentInstrument) {
AutofillPaymentInstrument creditCard = (AutofillPaymentInstrument) first;
if (creditCard.isComplete()) selection = 0;
} else {
// If a payment app is available, then PaymentRequest.canMakePayment() returns true.
mCanMakePayment = true;
selection = 0;
}
}
CanMakePaymentQuery query =
sCanMakePaymentQueries.get(mSchemelessIFrameOriginForPaymentApp);
if (query != null && query.matchesPaymentMethods(mMethodData)) {
query.notifyObserversOfResponse(mCanMakePayment);
}
// The list of payment instruments is ready to display.
List<PaymentInstrument> sortedInstruments = new ArrayList<>();
for (List<PaymentInstrument> a : mPendingInstruments) {
sortedInstruments.addAll(a);
}
mPaymentMethodsSection = new SectionInformation(
PaymentRequestUI.TYPE_PAYMENT_METHODS, selection, sortedInstruments);
mPendingInstruments.clear();
updateInstrumentModifiedTotals();
// UI has requested the full list of payment instruments. Provide it now.
if (mPaymentInformationCallback != null) providePaymentInformation();
triggerPaymentAppUiSkipIfApplicable();
}
/**
* If no payment methods are supported, disconnect from the client and return true.
*
* @return True if no payment methods are supported
*/
private boolean disconnectIfNoPaymentMethodsSupported() {
if (!isFinishedQueryingPaymentApps() || !mIsCurrentPaymentRequestShowing) return false;
boolean foundPaymentMethods = mPaymentMethodsSection != null
&& !mPaymentMethodsSection.isEmpty();
boolean userCanAddCreditCard = mMerchantSupportsAutofillPaymentInstruments
&& !ChromeFeatureList.isEnabled(ChromeFeatureList.NO_CREDIT_CARD_ABORT);
if (!mArePaymentMethodsSupported || (!foundPaymentMethods && !userCanAddCreditCard)) {
// All payment apps have responded, but none of them have instruments. It's possible to
// add credit cards, but the merchant does not support them either. The payment request
// must be rejected.
recordNoShowReasonHistogram(mArePaymentMethodsSupported
? PaymentRequestMetrics.NO_SHOW_NO_MATCHING_PAYMENT_METHOD
: PaymentRequestMetrics.NO_SHOW_NO_SUPPORTED_PAYMENT_METHOD);
disconnectFromClientWithDebugMessage("Requested payment methods have no instruments",
mIsIncognito ? PaymentErrorReason.USER_CANCEL
: PaymentErrorReason.NOT_SUPPORTED);
if (sObserverForTest != null) sObserverForTest.onPaymentRequestServiceShowFailed();
return true;
}
return false;
}
/** @return True after payment apps have been queried. */
private boolean isFinishedQueryingPaymentApps() {
return mPendingApps != null && mPendingApps.isEmpty() && mPendingInstruments.isEmpty();
}
/**
* Called after retrieving instrument details.
*/
@Override
public void onInstrumentDetailsReady(String methodName, String stringifiedDetails) {
if (mClient == null || mPaymentResponseHelper == null) return;
// Record the payment method used to complete the transaction. If the payment method was an
// Autofill credit card with an identifier, record its use.
PaymentOption selectedPaymentMethod = mPaymentMethodsSection.getSelectedItem();
if (selectedPaymentMethod instanceof AutofillPaymentInstrument) {
if (!selectedPaymentMethod.getIdentifier().isEmpty()) {
PersonalDataManager.getInstance().recordAndLogCreditCardUse(
selectedPaymentMethod.getIdentifier());
}
PaymentRequestMetrics.recordSelectedPaymentMethodHistogram(
PaymentRequestMetrics.SELECTED_METHOD_CREDIT_CARD);
} else if (methodName.equals(ANDROID_PAY_METHOD_NAME)
|| methodName.equals(PAY_WITH_GOOGLE_METHOD_NAME)) {
PaymentRequestMetrics.recordSelectedPaymentMethodHistogram(
PaymentRequestMetrics.SELECTED_METHOD_ANDROID_PAY);
} else {
PaymentRequestMetrics.recordSelectedPaymentMethodHistogram(
PaymentRequestMetrics.SELECTED_METHOD_OTHER_PAYMENT_APP);
}
// Showing the payment request UI if we were previously skipping it so the loading
// spinner shows up until the merchant notifies that payment was completed.
if (mShouldSkipShowingPaymentRequestUi) mUI.showProcessingMessageAfterUiSkip();
recordSuccessFunnelHistograms("ReceivedInstrumentDetails");
mJourneyLogger.setEventOccurred(JourneyLogger.EVENT_RECEIVED_INSTRUMENT_DETAILS);
mPaymentResponseHelper.onInstrumentDetailsReceived(methodName, stringifiedDetails);
}
@Override
public void onPaymentResponseReady(PaymentResponse response) {
mClient.onPaymentResponse(response);
mPaymentResponseHelper = null;
}
/**
* Called if unable to retrieve instrument details.
*/
@Override
public void onInstrumentDetailsError() {
if (mClient == null) return;
mPaymentAppRunning = false;
// When skipping UI, any errors/cancel from fetching instrument details should be
// equivalent to a cancel.
if (mShouldSkipShowingPaymentRequestUi) {
onDismiss();
} else {
mUI.onPayButtonProcessingCancelled();
}
}
@Override
public void onFocusChanged(@PaymentRequestUI.DataType int dataType, boolean willFocus) {
assert dataType == PaymentRequestUI.TYPE_SHIPPING_ADDRESSES;
if (mShippingAddressesSection.getSelectedItem() == null) return;
assert mShippingAddressesSection.getSelectedItem() instanceof AutofillAddress;
AutofillAddress selectedAddress = (AutofillAddress) mShippingAddressesSection
.getSelectedItem();
// The label should only include the country if the view is focused.
if (willFocus) {
selectedAddress.setShippingAddressLabelWithCountry();
} else {
selectedAddress.setShippingAddressLabelWithoutCountry();
}
mUI.updateSection(PaymentRequestUI.TYPE_SHIPPING_ADDRESSES, mShippingAddressesSection);
}
@Override
public void onAddressNormalized(AutofillProfile profile) {
ChromeActivity chromeActivity = ChromeActivity.fromWebContents(mWebContents);
// Can happen if the tab is closed during the normalization process.
if (chromeActivity == null) {
recordAbortReasonHistogram(PaymentRequestMetrics.ABORT_REASON_OTHER);
disconnectFromClientWithDebugMessage("Unable to find Chrome activity");
if (sObserverForTest != null) sObserverForTest.onPaymentRequestServiceShowFailed();
return;
}
// Don't reuse the selected address because it is formatted for display.
AutofillAddress shippingAddress = new AutofillAddress(chromeActivity, profile);
// This updates the line items and the shipping options asynchronously.
mClient.onShippingAddressChange(shippingAddress.toPaymentAddress());
}
@Override
public void onCouldNotNormalize(AutofillProfile profile) {
// Since the phone number is formatted in either case, this profile should be used.
onAddressNormalized(profile);
}
/** Starts the normalization of the new shipping address. Will call back into either
* onAddressNormalized or onCouldNotNormalize which will send the result to the merchant. */
private void startShippingAddressChangeNormalization(AutofillAddress address) {
PersonalDataManager.getInstance().normalizeAddress(
address.getProfile(), AutofillAddress.getCountryCode(address.getProfile()), this);
}
/**
* Closes the UI. If the client is still connected, then it's notified of UI hiding.
*
* @param immediateClose If true, then UI immediately closes. If false, the UI shows the error
* message "There was an error processing your order." This message
* implies that the merchant attempted to process the order, failed, and
* called complete("fail") to notify the user. Therefore, this parameter
* may be "false" only when called from
* {@link PaymentRequestImpl#complete(int)}. All other callers should
* always pass "true."
*/
private void closeUI(boolean immediateClose) {
if (mUI != null) {
mUI.close(immediateClose, new Runnable() {
@Override
public void run() {
if (mClient != null) mClient.onComplete();
closeClient();
}
});
mUI = null;
mIsCurrentPaymentRequestShowing = false;
setIsAnyPaymentRequestShowing(false);
}
if (mPaymentMethodsSection != null) {
for (int i = 0; i < mPaymentMethodsSection.getSize(); i++) {
PaymentOption option = mPaymentMethodsSection.getItem(i);
assert option instanceof PaymentInstrument;
((PaymentInstrument) option).dismissInstrument();
}
mPaymentMethodsSection = null;
}
if (mObservedTabModelSelector != null) {
mObservedTabModelSelector.removeObserver(mSelectorObserver);
mObservedTabModelSelector = null;
}
if (mObservedTabModel != null) {
mObservedTabModel.removeObserver(mTabModelObserver);
mObservedTabModel = null;
}
}
private void closeClient() {
if (mClient != null) mClient.close();
mClient = null;
}
/**
* @return Whether any instance of PaymentRequest has received a show() call. Don't use this
* function to check whether the current instance has received a show() call.
*/
private static boolean getIsAnyPaymentRequestShowing() {
return sIsAnyPaymentRequestShowing;
}
/** @param isShowing Whether any instance of PaymentRequest has received a show() call. */
private static void setIsAnyPaymentRequestShowing(boolean isShowing) {
sIsAnyPaymentRequestShowing = isShowing;
}
@VisibleForTesting
public static void setObserverForTest(PaymentRequestServiceObserverForTest observerForTest) {
sObserverForTest = observerForTest;
}
@VisibleForTesting
public static void setIsLocalCanMakePaymentQueryQuotaEnforcedForTest() {
sIsLocalCanMakePaymentQueryQuotaEnforcedForTest = true;
}
/**
* Records specific histograms related to the different steps of a successful checkout.
*/
private void recordSuccessFunnelHistograms(String funnelPart) {
RecordHistogram.recordBooleanHistogram("PaymentRequest.CheckoutFunnel." + funnelPart, true);
if (funnelPart.equals("Completed")) {
mJourneyLogger.recordJourneyStatsHistograms(JourneyLogger.COMPLETION_STATUS_COMPLETED);
}
}
/**
* Adds an entry to the aborted Payment Request histogram in the bucket corresponding to the
* reason for aborting. Only records the initial reason for aborting, as some closing code calls
* other closing code that can log too.
*/
private void recordAbortReasonHistogram(int abortReason) {
assert abortReason < PaymentRequestMetrics.ABORT_REASON_MAX;
if (mHasRecordedAbortReason || !mShouldRecordAbortReason) return;
mHasRecordedAbortReason = true;
RecordHistogram.recordEnumeratedHistogram(
"PaymentRequest.CheckoutFunnel.Aborted", abortReason,
PaymentRequestMetrics.ABORT_REASON_MAX);
if (abortReason == PaymentRequestMetrics.ABORT_REASON_ABORTED_BY_USER) {
mJourneyLogger.recordJourneyStatsHistograms(
JourneyLogger.COMPLETION_STATUS_USER_ABORTED);
} else {
mJourneyLogger.recordJourneyStatsHistograms(JourneyLogger.COMPLETION_STATUS_COMPLETED);
}
}
/**
* Adds an entry to the NoShow Payment Request histogram in the bucket corresponding to the
* reason for not showing the Payment Request.
*/
private void recordNoShowReasonHistogram(int reason) {
assert reason < PaymentRequestMetrics.NO_SHOW_REASON_MAX;
RecordHistogram.recordEnumeratedHistogram("PaymentRequest.CheckoutFunnel.NoShow", reason,
PaymentRequestMetrics.NO_SHOW_REASON_MAX);
}
/**
* Compares two payment instruments by frecency.
* Return negative value if a has strictly lower frecency score than b.
* Return zero if a and b have the same frecency score.
* Return positive value if a has strictly higher frecency score than b.
*/
private static int compareInstrumentsByFrecency(PaymentInstrument a, PaymentInstrument b) {
int aCount = PaymentPreferencesUtil.getPaymentInstrumentUseCount(a.getIdentifier());
int bCount = PaymentPreferencesUtil.getPaymentInstrumentUseCount(b.getIdentifier());
long aDate = PaymentPreferencesUtil.getPaymentInstrumentLastUseDate(a.getIdentifier());
long bDate = PaymentPreferencesUtil.getPaymentInstrumentLastUseDate(a.getIdentifier());
return Double.compare(getFrecencyScore(aCount, aDate), getFrecencyScore(bCount, bDate));
}
/**
* The frecency score is calculated according to use count and last use date. The formula is
* the same as the one used in GetFrecencyScore in autofill_data_model.cc.
*/
private static final double getFrecencyScore(int count, long date) {
long currentTime = System.currentTimeMillis();
return -Math.log((currentTime - date) / (24 * 60 * 60 * 1000) + 2) / Math.log(count + 2);
}
}