// 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.os.AsyncTask;
import android.os.Handler;
import android.text.TextUtils;
import android.util.Pair;
import org.chromium.base.Callback;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.autofill.CreditCardScanner;
import org.chromium.chrome.browser.autofill.PersonalDataManager;
import org.chromium.chrome.browser.autofill.PersonalDataManager.AutofillProfile;
import org.chromium.chrome.browser.autofill.PersonalDataManager.CreditCard;
import org.chromium.chrome.browser.payments.PaymentRequestImpl.PaymentRequestServiceObserverForTest;
import org.chromium.chrome.browser.payments.ui.EditorFieldModel;
import org.chromium.chrome.browser.payments.ui.EditorFieldModel.EditorFieldValidator;
import org.chromium.chrome.browser.payments.ui.EditorModel;
import org.chromium.chrome.browser.preferences.autofill.AutofillProfileBridge.DropdownKeyValue;
import org.chromium.content.browser.ContentViewCore;
import org.chromium.content_public.browser.WebContents;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
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 java.util.concurrent.ExecutionException;
import javax.annotation.Nullable;
/**
* A credit card editor. Can be used for editing both local and server credit cards. Everything in
* local cards can be edited. For server cards, only the billing address is editable.
*/
public class CardEditor extends EditorBase<AutofillPaymentInstrument>
implements CreditCardScanner.Delegate {
/** Description of a card type. */
private static class CardTypeInfo {
/** The identifier for the drawable resource of the card type, e.g., R.drawable.pr_visa. */
public final int icon;
/**
* The identifier for the localized description string for accessibility, e.g.,
* R.string.autofill_cc_visa.
*/
public final int description;
/**
* Builds a description of a card type.
*
* @param icon The identifier for the drawable resource of the card type.
* @param description The identifier for the localized description string for accessibility.
*/
public CardTypeInfo(int icon, int description) {
this.icon = icon;
this.description = description;
}
}
/** The dropdown key that indicates absence of billing address. */
private static final String BILLING_ADDRESS_NONE = "";
/** The dropdown key that triggers the address editor to add a new billing address. */
private static final String BILLING_ADDRESS_ADD_NEW = "add";
/** The web contents where the web payments API is invoked. */
private final WebContents mWebContents;
/**
* The map from GUIDs to profiles that can be used for billing address. This cache avoids
* re-reading profiles from disk, which may have changed due to sync, for example.
* updateBillingAddress() updates this cache.
*/
private final Map<String, AutofillProfile> mProfilesForBillingAddress;
/** Used for verifying billing address completeness and also editing billing addresses. */
private final AddressEditor mAddressEditor;
/** An optional observer used by tests. */
@Nullable private final PaymentRequestServiceObserverForTest mObserverForTest;
/**
* A mapping from all card types recognized in Chrome to information about these card types. The
* card types (e.g., "visa") are defined in:
* https://w3c.github.io/webpayments-methods-card/#method-id
*/
private final Map<String, CardTypeInfo> mCardTypes;
/**
* The card types accepted by the merchant website. This is a subset of recognized cards. Used
* in the validator.
*/
private final Set<String> mAcceptedCardTypes;
/**
* The information about the accepted card types. Used in the editor as a hint to the user about
* the valid card types. This is important to keep in a list, because the display order matters.
*/
private final List<CardTypeInfo> mAcceptedCardTypeInfos;
private final Handler mHandler;
private final EditorFieldValidator mCardNumberValidator;
private final AsyncTask<Void, Void, Calendar> mCalendar;
@Nullable private EditorFieldModel mIconHint;
@Nullable private EditorFieldModel mNumberField;
@Nullable private EditorFieldModel mNameField;
@Nullable private EditorFieldModel mMonthField;
@Nullable private EditorFieldModel mYearField;
@Nullable private EditorFieldModel mBillingAddressField;
@Nullable private EditorFieldModel mSaveCardCheckbox;
@Nullable private CreditCardScanner mCardScanner;
@Nullable private EditorFieldValidator mCardExpirationMonthValidator;
private boolean mCanScan;
private boolean mIsScanning;
private int mCurrentMonth;
private int mCurrentYear;
/**
* Builds a credit card editor.
*
* @param webContents The web contents where the web payments API is invoked.
* @param addressEditor Used for verifying billing address completeness and also editing
* billing addresses.
* @param observerForTest Optional observer for test.
*/
public CardEditor(WebContents webContents, AddressEditor addressEditor,
@Nullable PaymentRequestServiceObserverForTest observerForTest) {
assert webContents != null;
assert addressEditor != null;
mWebContents = webContents;
mAddressEditor = addressEditor;
mObserverForTest = observerForTest;
List<AutofillProfile> profiles = PersonalDataManager.getInstance().getProfilesToSuggest(
true /* includeName */);
mProfilesForBillingAddress = new HashMap<>();
for (int i = 0; i < profiles.size(); i++) {
AutofillProfile profile = profiles.get(i);
// 1) Include only local profiles, because GUIDs of server profiles change on every
// browser restart. Server profiles are not supported as billing addresses.
// 2) Include only complete profiles, so that user launches the editor only when
// explicitly selecting [+ ADD ADDRESS] in the dropdown.
if (profile.getIsLocal() && mAddressEditor.isProfileComplete(profile)) {
mProfilesForBillingAddress.put(profile.getGUID(), profile);
}
}
mCardTypes = new HashMap<>();
mCardTypes.put("amex",
new CardTypeInfo(R.drawable.pr_amex, R.string.autofill_cc_amex));
mCardTypes.put("diners",
new CardTypeInfo(R.drawable.pr_dinersclub, R.string.autofill_cc_diners));
mCardTypes.put("discover",
new CardTypeInfo(R.drawable.pr_discover, R.string.autofill_cc_discover));
mCardTypes.put("jcb",
new CardTypeInfo(R.drawable.pr_jcb, R.string.autofill_cc_jcb));
mCardTypes.put("mastercard",
new CardTypeInfo(R.drawable.pr_mc, R.string.autofill_cc_mastercard));
mCardTypes.put("unionpay",
new CardTypeInfo(R.drawable.pr_unionpay, R.string.autofill_cc_union_pay));
mCardTypes.put("visa",
new CardTypeInfo(R.drawable.pr_visa, R.string.autofill_cc_visa));
mAcceptedCardTypes = new HashSet<>();
mAcceptedCardTypeInfos = new ArrayList<>();
mHandler = new Handler();
mCardNumberValidator = new EditorFieldValidator() {
@Override
public boolean isValid(@Nullable CharSequence value) {
return value != null && mAcceptedCardTypes.contains(
PersonalDataManager.getInstance().getBasicCardPaymentTypeIfValid(
value.toString()));
}
};
mCalendar = new AsyncTask<Void, Void, Calendar>() {
@Override
protected Calendar doInBackground(Void... unused) {
return Calendar.getInstance();
}
};
mCalendar.execute();
}
/**
* Returns whether the given credit card is complete, i.e., can be sent to the merchant as-is
* without editing first.
*
* For both local and server cards, verifies that the billing address is complete. For local
* cards also verifies that the card number is valid and the name on card is not empty.
*
* Does not check the expiration date. If the card is expired, the user has the opportunity
* update the expiration date when providing their CVC in the card unmask dialog.
*
* Does not check that the card type is accepted by the merchant. This is done elsewhere to
* filter out such cards from view entirely. Cards that are not accepted by the merchant should
* not be edited.
*
* @param card The card to check.
* @return Whether the card is complete.
*/
public boolean isCardComplete(CreditCard card) {
if (card == null || !mProfilesForBillingAddress.containsKey(card.getBillingAddressId())) {
return false;
}
if (!card.getIsLocal()) return true;
return !TextUtils.isEmpty(card.getName()) && mCardNumberValidator.isValid(card.getNumber());
}
/**
* Adds accepted payment methods to the editor, if they are recognized credit card types.
*
* @param acceptedMethods The accepted method payments.
*/
public void addAcceptedPaymentMethodsIfRecognized(String[] acceptedMethods) {
assert acceptedMethods != null;
for (int i = 0; i < acceptedMethods.length; i++) {
String method = acceptedMethods[i];
if (mCardTypes.containsKey(method)) {
assert !mAcceptedCardTypes.contains(method);
mAcceptedCardTypes.add(method);
mAcceptedCardTypeInfos.add(mCardTypes.get(method));
}
}
}
/**
* Builds and shows an editor model with the following fields for local cards.
*
* [ accepted card types hint images ]
* [ card number ]
* [ name on card ]
* [ expiration month ][ expiration year ]
* [ billing address dropdown ]
* [ save this card checkbox ] <-- Shown only for new cards.
*
* Server cards have the following fields instead.
*
* [ card's obfuscated number ]
* [ billing address dropdown ]
*/
@Override
public void edit(@Nullable final AutofillPaymentInstrument toEdit,
final Callback<AutofillPaymentInstrument> callback) {
super.edit(toEdit, callback);
// If |toEdit| is null, we're creating a new credit card.
final boolean isNewCard = toEdit == null;
// Ensure that |instrument| and |card| are never null.
final AutofillPaymentInstrument instrument = isNewCard
? new AutofillPaymentInstrument(mWebContents, new CreditCard(), null)
: toEdit;
final CreditCard card = instrument.getCard();
// The title of the editor depends on whether we're adding a new card or editing an existing
// card.
final EditorModel editor = new EditorModel(mContext.getString(
isNewCard ? R.string.payments_create_card : R.string.payments_edit_card));
if (card.getIsLocal()) {
Calendar calendar = null;
try {
calendar = mCalendar.get();
} catch (InterruptedException | ExecutionException e) {
mHandler.post(new Runnable() {
@Override
public void run() {
callback.onResult(null);
}
});
return;
}
assert calendar != null;
// Let user edit any part of the local card.
addLocalCardInputs(editor, card, calendar);
} else {
// Display some information about the server card.
editor.addField(EditorFieldModel.createLabel(card.getObfuscatedNumber(), card.getName(),
card.getFormattedExpirationDate(mContext), card.getIssuerIconDrawableId()));
}
// Always show the billing address dropdown.
addBillingAddressDropdown(editor, card);
// Allow saving new cards on disk.
if (isNewCard) addSaveCardCheckbox(editor);
// If the user clicks [Cancel], send a null card back to the caller.
editor.setCancelCallback(new Runnable() {
@Override
public void run() {
callback.onResult(null);
}
});
// If the user clicks [Done], save changes on disk, mark the card "complete," and send it
// back to the caller.
editor.setDoneCallback(new Runnable() {
@Override
public void run() {
commitChanges(card, isNewCard);
instrument.completeInstrument(
card, mProfilesForBillingAddress.get(card.getBillingAddressId()));
callback.onResult(instrument);
}
});
mEditorView.show(editor);
}
/**
* Adds the given billing address to the list of billing addresses. If the address is already
* known, then updates the existing address. Should be called before opening the card editor.
*
* @param billingAddress The billing address to add or update. Should not be null. Should be
* complete.
*/
public void updateBillingAddress(AutofillAddress billingAddress) {
mProfilesForBillingAddress.put(billingAddress.getIdentifier(), billingAddress.getProfile());
}
/**
* Adds the following fields to the editor.
*
* [ accepted card types hint images ]
* [ card number [ocr icon] ]
* [ name on card ]
* [ expiration month ][ expiration year ]
*/
private void addLocalCardInputs(EditorModel editor, CreditCard card, Calendar calendar) {
// Local card editor shows a card icon hint.
if (mIconHint == null) {
List<Integer> icons = new ArrayList<>();
List<Integer> descriptions = new ArrayList<>();
for (int i = 0; i < mAcceptedCardTypeInfos.size(); i++) {
icons.add(mAcceptedCardTypeInfos.get(i).icon);
descriptions.add(mAcceptedCardTypeInfos.get(i).description);
}
mIconHint = EditorFieldModel.createIconList(
mContext.getString(R.string.payments_accepted_cards_label), icons,
descriptions);
}
editor.addField(mIconHint);
// Card scanner is expensive to query.
if (mCardScanner == null) {
mCardScanner = CreditCardScanner.create(mContext,
ContentViewCore.fromWebContents(mWebContents).getWindowAndroid(),
this);
mCanScan = mCardScanner.canScan();
}
// Card number is validated.
if (mNumberField == null) {
mNumberField = EditorFieldModel.createTextInput(
EditorFieldModel.INPUT_TYPE_HINT_CREDIT_CARD,
mContext.getString(R.string.autofill_credit_card_editor_number),
null, mCardNumberValidator,
mContext.getString(R.string.payments_field_required_validation_message),
mContext.getString(R.string.payments_card_number_invalid_validation_message),
null);
if (mCanScan) {
mNumberField.addActionIcon(R.drawable.ic_photo_camera,
R.string.autofill_scan_credit_card, new Runnable() {
@Override
public void run() {
if (mIsScanning) return;
mIsScanning = true;
mCardScanner.scan();
}
});
}
}
mNumberField.setValue(card.getNumber());
editor.addField(mNumberField);
// Name on card is required.
if (mNameField == null) {
mNameField = EditorFieldModel.createTextInput(
EditorFieldModel.INPUT_TYPE_HINT_PERSON_NAME,
mContext.getString(R.string.autofill_credit_card_editor_name), null, null,
mContext.getString(R.string.payments_field_required_validation_message),
null, null);
}
mNameField.setValue(card.getName());
editor.addField(mNameField);
// Expiration month dropdown.
if (mMonthField == null) {
mCurrentYear = calendar.get(Calendar.YEAR);
// The month in calendar is 0 based but the month value is 1 based.
mCurrentMonth = calendar.get(Calendar.MONTH) + 1;
if (mCardExpirationMonthValidator == null) {
mCardExpirationMonthValidator = new EditorFieldValidator() {
@Override
public boolean isValid(@Nullable CharSequence monthValue) {
CharSequence yearValue = mYearField.getValue();
if (monthValue == null || yearValue == null) return false;
int month = Integer.parseInt(monthValue.toString());
int year = Integer.parseInt(yearValue.toString());
return year > mCurrentYear
|| (year == mCurrentYear && month >= mCurrentMonth);
}
};
}
mMonthField = EditorFieldModel.createDropdown(
mContext.getString(R.string.autofill_credit_card_editor_expiration_date),
buildMonthDropdownKeyValues(calendar),
mCardExpirationMonthValidator,
mContext.getString(
R.string.payments_card_expiration_invalid_validation_message));
mMonthField.setIsFullLine(false);
if (mObserverForTest != null) {
mMonthField.setDropdownCallback(new Callback<Pair<String, Runnable>>() {
@Override
public void onResult(final Pair<String, Runnable> eventData) {
mObserverForTest.onPaymentRequestServiceExpirationMonthChange();
}
});
}
}
if (mMonthField.getDropdownKeys().contains(card.getMonth())) {
mMonthField.setValue(card.getMonth());
} else {
mMonthField.setValue(mMonthField.getDropdownKeyValues().get(0).getKey());
}
editor.addField(mMonthField);
// Expiration year dropdown is side-by-side with the expiration year dropdown. The dropdown
// should include the card's expiration year, so it's not cached.
mYearField = EditorFieldModel.createDropdown(
null /* label */, buildYearDropdownKeyValues(calendar, card.getYear()));
mYearField.setIsFullLine(false);
if (mYearField.getDropdownKeys().contains(card.getYear())) {
mYearField.setValue(card.getYear());
} else {
mYearField.setValue(mYearField.getDropdownKeyValues().get(0).getKey());
}
editor.addField(mYearField);
}
/** Builds the key-value pairs for the month dropdown. */
private static List<DropdownKeyValue> buildMonthDropdownKeyValues(Calendar calendar) {
List<DropdownKeyValue> result = new ArrayList<>();
Locale locale = Locale.getDefault();
SimpleDateFormat keyFormatter = new SimpleDateFormat("M", locale);
SimpleDateFormat valueFormatter = new SimpleDateFormat("MMMM (MM)", locale);
calendar.set(Calendar.DAY_OF_MONTH, 1);
for (int month = 0; month < 12; month++) {
calendar.set(Calendar.MONTH, month);
Date date = calendar.getTime();
result.add(
new DropdownKeyValue(keyFormatter.format(date), valueFormatter.format(date)));
}
return result;
}
/** Builds the key-value pairs for the year dropdown. */
private static List<DropdownKeyValue> buildYearDropdownKeyValues(
Calendar calendar, String alwaysIncludedYear) {
List<DropdownKeyValue> result = new ArrayList<>();
int initialYear = calendar.get(Calendar.YEAR);
boolean foundAlwaysIncludedYear = false;
for (int year = initialYear; year < initialYear + 10; year++) {
String yearString = Integer.toString(year);
if (yearString.equals(alwaysIncludedYear)) foundAlwaysIncludedYear = true;
result.add(new DropdownKeyValue(yearString, yearString));
}
if (!foundAlwaysIncludedYear && !TextUtils.isEmpty(alwaysIncludedYear)) {
result.add(0, new DropdownKeyValue(alwaysIncludedYear, alwaysIncludedYear));
}
return result;
}
/**
* Adds the billing address dropdown to the editor with the following items.
*
* | "select" |
* | complete address 1 |
* | complete address 2 |
* ...
* | complete address n |
* | "add address" |
*/
private void addBillingAddressDropdown(EditorModel editor, final CreditCard card) {
final List<DropdownKeyValue> billingAddresses = new ArrayList<>();
billingAddresses.add(new DropdownKeyValue(BILLING_ADDRESS_NONE,
mContext.getString(R.string.select)));
for (Map.Entry<String, AutofillProfile> address : mProfilesForBillingAddress.entrySet()) {
// Key is profile GUID. Value is profile label.
billingAddresses.add(
new DropdownKeyValue(address.getKey(), address.getValue().getLabel()));
}
billingAddresses.add(new DropdownKeyValue(BILLING_ADDRESS_ADD_NEW,
mContext.getString(R.string.autofill_create_profile)));
// Don't cache the billing address dropdown, because the user may have added or removed
// profiles.
mBillingAddressField = EditorFieldModel.createDropdown(
mContext.getString(R.string.autofill_credit_card_editor_billing_address),
billingAddresses);
// The billing address is required.
mBillingAddressField.setRequiredErrorMessage(
mContext.getString(R.string.payments_field_required_validation_message));
mBillingAddressField.setDropdownCallback(new Callback<Pair<String, Runnable>>() {
@Override
public void onResult(final Pair<String, Runnable> eventData) {
if (!BILLING_ADDRESS_ADD_NEW.equals(eventData.first)) {
if (mObserverForTest != null) {
mObserverForTest.onPaymentRequestServiceBillingAddressChangeProcessed();
}
return;
}
mAddressEditor.edit(null, new Callback<AutofillAddress>() {
@Override
public void onResult(AutofillAddress billingAddress) {
if (billingAddress == null) {
// User has cancelled the address editor.
mBillingAddressField.setValue(null);
} else {
// User has added a new complete address. Add it to the top of the
// dropdown, under the "Select" prompt.
mProfilesForBillingAddress.put(
billingAddress.getIdentifier(), billingAddress.getProfile());
billingAddresses.add(1, new DropdownKeyValue(
billingAddress.getIdentifier(), billingAddress.getSublabel()));
mBillingAddressField.setDropdownKeyValues(billingAddresses);
mBillingAddressField.setValue(billingAddress.getIdentifier());
}
// Let the card editor UI re-read the model and re-create UI elements.
mHandler.post(eventData.second);
}
});
}
});
if (mBillingAddressField.getDropdownKeys().contains(card.getBillingAddressId())) {
mBillingAddressField.setValue(card.getBillingAddressId());
}
editor.addField(mBillingAddressField);
}
/** Adds the "save this card" checkbox to the editor. */
private void addSaveCardCheckbox(EditorModel editor) {
if (mSaveCardCheckbox == null) {
mSaveCardCheckbox = EditorFieldModel.createCheckbox(
mContext.getString(R.string.payments_save_card_to_device_checkbox));
}
mSaveCardCheckbox.setIsChecked(true);
editor.addField(mSaveCardCheckbox);
}
/**
* Saves the edited credit card.
*
* If this is a server card, then only its billing address identifier is updated.
*
* If this is a new local card, then it's saved on this device only if the user has checked the
* "save this card" checkbox.
*/
private void commitChanges(CreditCard card, boolean isNewCard) {
card.setBillingAddressId(mBillingAddressField.getValue().toString());
PersonalDataManager pdm = PersonalDataManager.getInstance();
if (!card.getIsLocal()) {
pdm.updateServerCardBillingAddress(card.getServerId(), card.getBillingAddressId());
return;
}
card.setNumber(mNumberField.getValue().toString().replace(" ", "").replace("-", ""));
card.setName(mNameField.getValue().toString());
card.setMonth(mMonthField.getValue().toString());
card.setYear(mYearField.getValue().toString());
// Calculate the basic card payment type, obfuscated number, and the icon for this card.
// All of these depend on the card number. The type is sent to the merchant website. The
// obfuscated number and the icon are displayed in the user interface.
CreditCard displayableCard = pdm.getCreditCardForNumber(card.getNumber());
card.setBasicCardPaymentType(displayableCard.getBasicCardPaymentType());
card.setObfuscatedNumber(displayableCard.getObfuscatedNumber());
card.setIssuerIconDrawableId(displayableCard.getIssuerIconDrawableId());
if (!isNewCard) {
pdm.setCreditCard(card);
return;
}
if (mSaveCardCheckbox != null && mSaveCardCheckbox.isChecked()) {
card.setGUID(pdm.setCreditCard(card));
}
}
@Override
public void onScanCompleted(
String cardHolderName, String cardNumber, int expirationMonth, int expirationYear) {
if (!TextUtils.isEmpty(cardHolderName)) mNameField.setValue(cardHolderName);
if (!TextUtils.isEmpty(cardNumber)) mNumberField.setValue(cardNumber);
if (expirationYear >= 2000) mYearField.setValue(Integer.toString(expirationYear));
if (expirationMonth >= 1 && expirationMonth <= 12) {
mMonthField.setValue(Integer.toString(expirationMonth));
}
mEditorView.update();
mIsScanning = false;
}
@Override
public void onScanCancelled() {
mIsScanning = false;
}
}