// 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.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 mTopLevelOrigin; private final String mPaymentRequestOrigin; 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; mWebContents = WebContentsStatics.fromRenderFrameHost(renderFrameHost); mPaymentRequestOrigin = UrlFormatter.formatUrlForSecurityDisplay( mRenderFrameHost.getLastCommittedURL(), true); mTopLevelOrigin = UrlFormatter.formatUrlForSecurityDisplay(mWebContents.getLastCommittedUrl(), true); 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<>(); } @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) { mJourneyLogger.setAborted(AbortReason.INVALID_DATA_FROM_RENDERER); disconnectFromClientWithDebugMessage("Renderer should never call init() twice"); return; } if (client == null) { mJourneyLogger.setAborted(AbortReason.INVALID_DATA_FROM_RENDERER); disconnectFromClientWithDebugMessage("Invalid mojo client"); return; } mClient = client; mMethodData = new HashMap<>(); if (!OriginSecurityChecker.isOriginSecure(mWebContents.getLastCommittedUrl())) { mJourneyLogger.setAborted(AbortReason.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; } mMethodData = getValidatedMethodData(methodData, mCardEditor); if (mMethodData == null) { mJourneyLogger.setAborted(AbortReason.INVALID_DATA_FROM_RENDERER); disconnectFromClientWithDebugMessage("Invalid payment methods or data"); return; } if (!parseAndValidateDetailsOrDisconnectFromClient(details)) return; if (mRawTotal == null) { mJourneyLogger.setAborted(AbortReason.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, mTopLevelOrigin, 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 (mClient != null && bitmap == null) mClient.warnNoFavicon(); 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.setEditorDialog(mUI.getEditorDialog()); mCardEditor.setEditorDialog(mUI.getCardEditorDialog()); if (mContactEditor != null) mContactEditor.setEditorDialog(mUI.getEditorDialog()); } 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().loadRulesForAddressNormalization(countryCode); } } // Log the number of suggested shipping addresses. mJourneyLogger.setNumberOfSuggestionsShown(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. mJourneyLogger.setAborted(AbortReason.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. mJourneyLogger.setNotShown(NotShownReason.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) { mJourneyLogger.setNotShown(NotShownReason.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; mJourneyLogger.setEventOccurred(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(mPaymentRequestOrigin); 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(), mTopLevelOrigin, mPaymentRequestOrigin, mCertificateChain, mRawTotal, 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) { mJourneyLogger.setAborted(AbortReason.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)) { mJourneyLogger.setAborted(AbortReason.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)); if (mUI != null) 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; // Makes a copy to ensure it is modifiable. Set<String> methodNames = new HashSet<>(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) { if (modifier.total != null) 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; mJourneyLogger.setEventOccurred(Event.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(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(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(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(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(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(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(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(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(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, mTopLevelOrigin, mPaymentRequestOrigin, mCertificateChain, Collections.unmodifiableMap(methodData), mRawTotal, mRawLineItems, Collections.unmodifiableMap(modifiers), this); mJourneyLogger.setEventOccurred(Event.PAY_CLICKED); return !(instrument instanceof AutofillPaymentInstrument); } @Override public void onDismiss() { mJourneyLogger.setAborted(AbortReason.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); mJourneyLogger.setAborted(AbortReason.ABORTED_BY_MERCHANT); } } /** * Called when the merchant website has processed the payment. */ @Override public void complete(int result) { if (mClient == null) return; mJourneyLogger.setCompleted(); 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) { mJourneyLogger.setAborted(AbortReason.OTHER); disconnectFromClientWithDebugMessage("Unable to find Chrome activity"); return; } Intent intent = PreferencesLauncher.createIntentForSettingsPage( context, AutofillAndPaymentsPreferences.class.getName()); context.startActivity(intent); mJourneyLogger.setAborted(AbortReason.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(mPaymentRequestOrigin); 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(mPaymentRequestOrigin, query); mHandler.postDelayed(new Runnable() { @Override public void run() { sCanMakePaymentQueries.remove(mPaymentRequestOrigin); } }, 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(mPaymentRequestOrigin); // 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); mJourneyLogger.setAborted(AbortReason.MOJO_RENDERER_CLOSING); } /** * Called when the Mojo connection encounters an error. */ @Override public void onConnectionError(MojoException e) { if (mClient == null) return; closeClient(); closeUI(true); mJourneyLogger.setAborted(AbortReason.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().loadRulesForAddressNormalization(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( 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(mPaymentRequestOrigin); 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. mJourneyLogger.setNotShown(mArePaymentMethodsSupported ? NotShownReason.NO_MATCHING_PAYMENT_METHOD : NotShownReason.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()); } mJourneyLogger.setSelectedPaymentMethod(SelectedPaymentMethod.CREDIT_CARD); } else if (methodName.equals(ANDROID_PAY_METHOD_NAME) || methodName.equals(PAY_WITH_GOOGLE_METHOD_NAME)) { mJourneyLogger.setSelectedPaymentMethod(SelectedPaymentMethod.ANDROID_PAY); } else { mJourneyLogger.setSelectedPaymentMethod(SelectedPaymentMethod.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(); mJourneyLogger.setEventOccurred(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) { mJourneyLogger.setAborted(AbortReason.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; } /** * 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); } }