chromium/chrome/android/java/src/org/chromium/chrome/browser/autofill/settings/AutofillLocalCardEditor.java

// Copyright 2016 The Chromium Authors
// 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.autofill.settings;

import android.os.Bundle;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.RelativeLayout;
import android.widget.Spinner;
import android.widget.TextView;

import androidx.annotation.VisibleForTesting;
import androidx.fragment.app.Fragment;

import com.google.android.material.textfield.TextInputLayout;

import org.chromium.base.Callback;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.version_info.VersionInfo;
import org.chromium.build.annotations.UsedByReflection;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.autofill.PersonalDataManager;
import org.chromium.chrome.browser.autofill.PersonalDataManager.CreditCard;
import org.chromium.chrome.browser.autofill.PersonalDataManagerFactory;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.components.autofill.AutofillProfile;
import org.chromium.ui.text.EmptyTextWatcher;

import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/** Local credit card settings. */
public class AutofillLocalCardEditor extends AutofillCreditCardEditor {
    private static Callback<Fragment> sObserverForTest;
    private static final String EXPIRATION_DATE_SEPARATOR = "/";
    private static final String EXPIRATION_DATE_REGEX = "^(0[1-9]|1[0-2])\\/(\\d{2})$";
    // TODO(crbug.com/40945216): Leverage the value from C++ code to have a single source of truth.
    private static final String AMEX_NETWORK_NAME = "amex";
    static final String CARD_COUNT_BEFORE_ADDING_NEW_CARD_HISTOGRAM =
            "Autofill.PaymentMethods.SettingsPage.StoredCreditCardCountBeforeCardAdded";

    protected Button mDoneButton;
    private TextInputLayout mNameLabel;
    private EditText mNameText;
    protected TextInputLayout mNicknameLabel;
    protected EditText mNicknameText;
    private TextInputLayout mNumberLabel;
    protected EditText mNumberText;
    protected Spinner mExpirationMonth;
    protected Spinner mExpirationYear;
    // Since the nickname field is optional, an empty nickname is a valid nickname.
    private boolean mIsValidNickname = true;
    private boolean mIsCvcStorageEnabled;
    private int mInitialExpirationMonthPos;
    protected EditText mExpirationDate;
    protected EditText mCvc;
    protected ImageView mCvcHintImage;
    private boolean mIsValidExpirationDate;
    private int mInitialExpirationYearPos;

    @UsedByReflection("AutofillPaymentMethodsFragment.java")
    public AutofillLocalCardEditor() {}

    @Override
    public View onCreateView(
            LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        // Allow screenshots of the credit card number in Canary, Dev, and developer builds.
        if (VersionInfo.isBetaBuild() || VersionInfo.isStableBuild()) {
            WindowManager.LayoutParams attributes = getActivity().getWindow().getAttributes();
            attributes.flags |= WindowManager.LayoutParams.FLAG_SECURE;
            getActivity().getWindow().setAttributes(attributes);
        }

        View v = super.onCreateView(inflater, container, savedInstanceState);

        mDoneButton = v.findViewById(R.id.button_primary);
        mNameLabel = v.findViewById(R.id.credit_card_name_label);
        mNameText = v.findViewById(R.id.credit_card_name_edit);
        mNicknameLabel = v.findViewById(R.id.credit_card_nickname_label);
        mNicknameText = v.findViewById(R.id.credit_card_nickname_edit);
        mNumberLabel = v.findViewById(R.id.credit_card_number_label);
        mNumberText = v.findViewById(R.id.credit_card_number_edit);

        mNicknameText.addTextChangedListener(nicknameTextWatcher());
        mNicknameText.setOnFocusChangeListener(
                (view, hasFocus) -> mNicknameLabel.setCounterEnabled(hasFocus));
        // Set text watcher to format credit card number
        mNumberText.addTextChangedListener(new CreditCardNumberFormattingTextWatcher());

        mIsCvcStorageEnabled =
                ChromeFeatureList.isEnabled(ChromeFeatureList.AUTOFILL_ENABLE_CVC_STORAGE);

        if (mIsCvcStorageEnabled) {
            LinearLayout creditCardExpirationSpinnerContainer =
                    v.findViewById(R.id.credit_card_expiration_spinner_container);
            TextView creditCardExpirationLabel = v.findViewById(R.id.credit_card_expiration_label);
            creditCardExpirationSpinnerContainer.setVisibility(View.GONE);
            creditCardExpirationLabel.setVisibility(View.GONE);

            mExpirationDate = v.findViewById(R.id.expiration_month_and_year);
            mExpirationDate.addTextChangedListener(expirationDateTextWatcher());

            mCvc = v.findViewById(R.id.cvc);
            mCvcHintImage = v.findViewById(R.id.cvc_hint_image);
            mNumberText.addTextChangedListener(creditCardNumberTextWatcherForCvc());
        } else {
            RelativeLayout creditCardExpirationAndCvcLayout =
                    v.findViewById(R.id.credit_card_expiration_and_cvc_layout);
            creditCardExpirationAndCvcLayout.setVisibility(View.GONE);

            mExpirationMonth = v.findViewById(R.id.autofill_credit_card_editor_month_spinner);
            mExpirationYear = v.findViewById(R.id.autofill_credit_card_editor_year_spinner);

            addSpinnerAdapters();
        }

        addCardDataToEditFields();
        initializeButtons(v);
        if (sObserverForTest != null) {
            sObserverForTest.onResult(this);
        }
        return v;
    }

    @Override
    protected int getLayoutId() {
        return R.layout.autofill_local_card_editor;
    }

    @Override
    protected int getTitleResourceId(boolean isNewEntry) {
        return isNewEntry
                ? R.string.autofill_create_credit_card
                : R.string.autofill_edit_credit_card;
    }

    @Override
    public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
        boolean isAddressSpinnerUpdated =
                parent == mBillingAddress && position != mInitialBillingAddressPos;
        if (isAddressSpinnerUpdated) {
            updateSaveButtonEnabled();
        }
        if (!mIsCvcStorageEnabled) {
            boolean isYearSpinnerUpdated =
                    parent == mExpirationYear && position != mInitialExpirationYearPos;
            boolean isMonthSpinnerUpdated =
                    parent == mExpirationMonth && position != mInitialExpirationMonthPos;
            if (isYearSpinnerUpdated || isMonthSpinnerUpdated) {
                updateSaveButtonEnabled();
            }
        }
    }

    @Override
    public void afterTextChanged(Editable s) {
        updateSaveButtonEnabled();
    }

    public static void setObserverForTest(Callback<Fragment> observerForTest) {
        sObserverForTest = observerForTest;
        ResettersForTesting.register(() -> sObserverForTest = null);
    }

    @SuppressWarnings("DuplicateDateFormatField") // There's probably a bug here...
    void addSpinnerAdapters() {
        ArrayAdapter<CharSequence> adapter =
                new ArrayAdapter<CharSequence>(getActivity(), android.R.layout.simple_spinner_item);

        // Populate the month dropdown.
        Calendar calendar = Calendar.getInstance();
        calendar.set(Calendar.DAY_OF_MONTH, 1);
        SimpleDateFormat formatter = new SimpleDateFormat("MMMM (MM)", Locale.getDefault());

        for (int month = 0; month < 12; month++) {
            calendar.set(Calendar.MONTH, month);
            adapter.add(formatter.format(calendar.getTime()));
        }
        adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
        mExpirationMonth.setAdapter(adapter);

        // Populate the year dropdown.
        adapter =
                new ArrayAdapter<CharSequence>(getActivity(), android.R.layout.simple_spinner_item);
        int initialYear = calendar.get(Calendar.YEAR);
        for (int year = initialYear; year < initialYear + 10; year++) {
            adapter.add(Integer.toString(year));
        }
        adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
        mExpirationYear.setAdapter(adapter);
    }

    private void addCardDataToEditFields() {
        if (mCard == null) {
            mNumberLabel.requestFocus();
            return;
        }

        if (!TextUtils.isEmpty(mCard.getName())) {
            mNameLabel.getEditText().setText(mCard.getName());
        }
        if (!TextUtils.isEmpty(mCard.getNumber())) {
            mNumberLabel.getEditText().setText(mCard.getNumber());
        }

        // Make the name label focusable in touch mode so that mNameText doesn't get focused.
        mNameLabel.setFocusableInTouchMode(true);

        if (mIsCvcStorageEnabled) {
            if (!mCard.getMonth().isEmpty() && !mCard.getYear().isEmpty()) {
                mExpirationDate.setText(
                        String.format("%s/%s", mCard.getMonth(), mCard.getYear().substring(2)));
            }

            if (!mCard.getCvc().isEmpty()) {
                mCvc.setText(mCard.getCvc());
            }
        } else {
            int monthAsInt = 1;
            if (!mCard.getMonth().isEmpty()) {
                monthAsInt = Integer.parseInt(mCard.getMonth());
            }
            mInitialExpirationMonthPos = monthAsInt - 1;
            mExpirationMonth.setSelection(mInitialExpirationMonthPos);

            mInitialExpirationYearPos = 0;
            boolean foundYear = false;
            for (int i = 0; i < mExpirationYear.getAdapter().getCount(); i++) {
                if (mCard.getYear().equals(mExpirationYear.getAdapter().getItem(i))) {
                    mInitialExpirationYearPos = i;
                    foundYear = true;
                    break;
                }
            }
            // Maybe your card expired years ago? Add the card's year
            // to the spinner adapter if not found.
            if (!foundYear && !mCard.getYear().isEmpty()) {
                @SuppressWarnings("unchecked")
                ArrayAdapter<CharSequence> adapter =
                        (ArrayAdapter<CharSequence>) mExpirationYear.getAdapter();
                adapter.insert(mCard.getYear(), 0);
                mInitialExpirationYearPos = 0;
            }
            mExpirationYear.setSelection(mInitialExpirationYearPos);
        }

        if (!mCard.getNickname().isEmpty()) {
            mNicknameText.setText(mCard.getNickname());
        }
    }

    @Override
    protected boolean saveEntry() {
        // Remove all spaces in editText.
        String cardNumber = mNumberText.getText().toString().replaceAll("\\s+", "");
        // Issuer network will be empty if credit card number is not valid.
        if (TextUtils.isEmpty(
                PersonalDataManager.getBasicCardIssuerNetwork(
                        cardNumber, /* emptyIfInvalid= */ true))) {
            mNumberLabel.setError(
                    mContext.getString(R.string.payments_card_number_invalid_validation_message));
            return false;
        }

        PersonalDataManager personalDataManager =
                PersonalDataManagerFactory.getForProfile(getProfile());
        CreditCard card = personalDataManager.getCreditCardForNumber(cardNumber);
        card.setGUID(mGUID);
        card.setOrigin(SETTINGS_ORIGIN);
        card.setName(mNameText.getText().toString().trim());

        if (mIsCvcStorageEnabled) {
            String expirationDate = mExpirationDate.getText().toString().trim();
            if (TextUtils.isEmpty(expirationDate)) {
                mExpirationDate.setError(
                        mContext.getResources()
                                .getString(
                                        R.string
                                                .autofill_credit_card_editor_invalid_expiration_date));
                return false;
            }
            card.setMonth(AutofillLocalCardEditor.getExpirationMonth(expirationDate));
            card.setYear(AutofillLocalCardEditor.getExpirationYear(expirationDate));
            card.setCvc(mCvc.getText().toString().trim());
            // TODO(crbug.com/41483891): Move metric logging to a separate class.
            if (mIsNewEntry) {
                if (!card.getCvc().isEmpty()) {
                    RecordUserAction.record("AutofillCreditCardsAddedWithCvc");
                }
            } else {
                // Verify if the CVC value for the existing card is absent.
                if (mCard.getCvc().isEmpty()) {
                    // Verify if the CVC value is absent for the new card that is replacing the
                    // existing card.
                    if (card.getCvc().isEmpty()) {
                        // Record when an existing card without CVC is edited and no CVC was
                        // added.
                        RecordUserAction.record("AutofillCreditCardsEditedAndCvcWasLeftBlank");
                    } else {
                        // Record when an existing card without CVC is edited and CVC was added.
                        RecordUserAction.record("AutofillCreditCardsEditedAndCvcWasAdded");
                    }
                } else {
                    if (card.getCvc().isEmpty()) {
                        // Record when an existing card with CVC is edited and CVC was removed.
                        RecordUserAction.record("AutofillCreditCardsEditedAndCvcWasRemoved");
                    } else if (!card.getCvc().equals(mCard.getCvc())) {
                        // Record when an existing card with CVC is edited and CVC was updated.
                        RecordUserAction.record("AutofillCreditCardsEditedAndCvcWasUpdated");
                    } else {
                        // Record when an existing card with CVC is edited and CVC was
                        // unchanged.
                        RecordUserAction.record("AutofillCreditCardsEditedAndCvcWasUnchanged");
                    }
                }
            }
        } else {
            card.setMonth(String.valueOf(mExpirationMonth.getSelectedItemPosition() + 1));
            card.setYear((String) mExpirationYear.getSelectedItem());
        }

        card.setBillingAddressId(((AutofillProfile) mBillingAddress.getSelectedItem()).getGUID());
        card.setNickname(mNicknameText.getText().toString().trim());

        // Get the current card count before setting the new card.
        int currentCardCount = personalDataManager.getCreditCardCountForSettings();

        // Set GUID for adding a new card.
        card.setGUID(personalDataManager.setCreditCard(card));
        if (mIsNewEntry) {
            RecordUserAction.record("AutofillCreditCardsAdded");
            if (!card.getNickname().isEmpty()) {
                RecordUserAction.record("AutofillCreditCardsAddedWithNickname");
            }
            RecordHistogram.recordCount100Histogram(
                    CARD_COUNT_BEFORE_ADDING_NEW_CARD_HISTOGRAM, currentCardCount);
        }
        return true;
    }

    @Override
    protected void deleteEntry() {
        if (mGUID != null) {
            PersonalDataManagerFactory.getForProfile(getProfile()).deleteCreditCard(mGUID);
        }
    }

    @Override
    protected void initializeButtons(View v) {
        super.initializeButtons(v);

        // Listen for change to inputs. Enable the save button after something has changed.
        mNameText.addTextChangedListener(this);
        mNumberText.addTextChangedListener(this);

        if (mIsCvcStorageEnabled) {
            mExpirationDate.addTextChangedListener(this);
            mCvc.addTextChangedListener(this);
        } else {
            mExpirationMonth.setOnItemSelectedListener(this);
            mExpirationYear.setOnItemSelectedListener(this);
            // Listen for touch events for drop down menus. We clear the keyboard when user touches
            // any of these fields.
            mExpirationMonth.setOnTouchListener(this);
            mExpirationYear.setOnTouchListener(this);
        }
    }

    private void updateSaveButtonEnabled() {
        // Enable save button if credit card number is not empty and the nickname is valid
        // and the expiration date is valid. We validate the credit card number when the user
        // presses the save button.
        boolean enabled =
                !TextUtils.isEmpty(mNumberText.getText())
                        && mIsValidNickname
                        && (!mIsCvcStorageEnabled || mIsValidExpirationDate);
        mDoneButton.setEnabled(enabled);
    }

    private TextWatcher nicknameTextWatcher() {
        return new EmptyTextWatcher() {
            @Override
            public void afterTextChanged(Editable s) {
                // Show an error message if nickname contains any digits.
                mIsValidNickname = !s.toString().matches(".*\\d.*");
                mNicknameLabel.setError(
                        mIsValidNickname
                                ? ""
                                : mContext.getResources()
                                        .getString(
                                                R.string
                                                        .autofill_credit_card_editor_invalid_nickname));
                updateSaveButtonEnabled();
            }
        };
    }

    private TextWatcher expirationDateTextWatcher() {
        return new EmptyTextWatcher() {
            private static final int SEPARATOR_INDEX = 2;
            private static final int VALID_DATE_LENGTH = 5;

            @Override
            public void afterTextChanged(Editable s) {
                if (TextUtils.indexOf(s, EXPIRATION_DATE_SEPARATOR) < 0
                        && s.length() > SEPARATOR_INDEX) {
                    s.insert(SEPARATOR_INDEX, EXPIRATION_DATE_SEPARATOR);
                }
                if (s.length() == VALID_DATE_LENGTH) {
                    if (!validExpirationDate(s.toString())) {
                        mExpirationDate.setError(
                                mContext.getResources()
                                        .getString(
                                                R.string
                                                        .autofill_credit_card_editor_invalid_expiration_date));
                    } else if (!validFutureExpirationDate(s.toString())) {
                        mExpirationDate.setError(
                                mContext.getResources()
                                        .getString(
                                                R.string.autofill_credit_card_editor_expired_card));
                    } else if (mExpirationDate.getError() != null) {
                        // Removes error message if a previous error exists and the user inputs
                        // a valid date.
                        mExpirationDate.setError(null);
                    }
                }
                mIsValidExpirationDate =
                        validExpirationDate(s.toString())
                                && validFutureExpirationDate(s.toString());
                updateSaveButtonEnabled();
            }
        };
    }

    private TextWatcher creditCardNumberTextWatcherForCvc() {
        return new EmptyTextWatcher() {
            private boolean mUsingAmExCvcHintImage;

            @Override
            public void afterTextChanged(Editable s) {
                String cardNumber = s.toString().replaceAll("\\s+", "");
                if (isAmExCard(cardNumber)) {
                    if (!mUsingAmExCvcHintImage) {
                        mUsingAmExCvcHintImage = true;
                        mCvcHintImage.setImageResource(R.drawable.cvc_icon_amex);
                    }
                } else {
                    if (mUsingAmExCvcHintImage) {
                        mUsingAmExCvcHintImage = false;
                        mCvcHintImage.setImageResource(R.drawable.cvc_icon);
                    }
                }
            }
        };
    }

    @VisibleForTesting
    public static String getExpirationMonth(String expirationDate) {
        String month = expirationDate.split(EXPIRATION_DATE_SEPARATOR)[0];
        if (month.startsWith("0")) {
            return month.substring(1);
        }
        return month;
    }

    @VisibleForTesting
    public static String getExpirationYear(String expirationDate) {
        String year = expirationDate.split(EXPIRATION_DATE_SEPARATOR)[1];
        return "20" + year;
    }

    private boolean validExpirationDate(String expirationDate) {
        return expirationDate.matches(EXPIRATION_DATE_REGEX);
    }

    private boolean validFutureExpirationDate(String expirationMonthAndYear) {
        Pattern pattern = Pattern.compile(EXPIRATION_DATE_REGEX);
        Matcher matcher = pattern.matcher(expirationMonthAndYear);
        if (matcher.find()) {
            Calendar today = Calendar.getInstance(Locale.getDefault());
            Calendar expirationDate = Calendar.getInstance(Locale.getDefault());
            expirationDate.set(Calendar.MONTH, Integer.parseInt(matcher.group(1)));
            expirationDate.set(
                    Calendar.YEAR, Integer.parseInt(String.format("20%s", matcher.group(2))));
            expirationDate.set(
                    Calendar.DAY_OF_MONTH, expirationDate.getActualMaximum(Calendar.DAY_OF_MONTH));
            return !expirationDate.before(today);
        }
        return false;
    }

    public static boolean isAmExCard(String cardNumber) {
        return PersonalDataManager.getBasicCardIssuerNetwork(
                        cardNumber, /* emptyIfInvalid= */ false)
                .equals(AMEX_NETWORK_NAME);
    }
}