chromium/chrome/android/java/src/org/chromium/chrome/browser/payments/AddressEditor.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.payments;

import static org.chromium.chrome.browser.autofill.editors.EditorProperties.ALLOW_DELETE;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.ALL_KEYS;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.CANCEL_RUNNABLE;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.DONE_RUNNABLE;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.DropdownFieldProperties.DROPDOWN_ALL_KEYS;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.DropdownFieldProperties.DROPDOWN_CALLBACK;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.DropdownFieldProperties.DROPDOWN_HINT;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.DropdownFieldProperties.DROPDOWN_KEY_VALUE_LIST;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.EDITOR_FIELDS;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.EDITOR_TITLE;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.FieldProperties.IS_REQUIRED;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.FieldProperties.LABEL;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.FieldProperties.VALIDATOR;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.FieldProperties.VALUE;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.ItemType.DROPDOWN;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.ItemType.TEXT_INPUT;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.SHOW_REQUIRED_INDICATOR;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.TextFieldProperties.TEXT_ALL_KEYS;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.TextFieldProperties.TEXT_FIELD_TYPE;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.TextFieldProperties.TEXT_FORMATTER;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.TextFieldProperties.TEXT_SUGGESTIONS;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.VALIDATE_ON_SHOW;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.VISIBLE;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.scrollToFieldWithErrorMessage;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.validateForm;

import android.app.ProgressDialog;
import android.text.TextUtils;

import androidx.annotation.Nullable;

import org.chromium.base.Callback;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.autofill.AddressValidationType;
import org.chromium.chrome.browser.autofill.AutofillAddress;
import org.chromium.chrome.browser.autofill.AutofillProfileBridge;
import org.chromium.chrome.browser.autofill.AutofillProfileBridge.AutofillAddressUiComponent;
import org.chromium.chrome.browser.autofill.PersonalDataManager;
import org.chromium.chrome.browser.autofill.PhoneNumberUtil;
import org.chromium.chrome.browser.autofill.SubKeyRequesterFactory;
import org.chromium.chrome.browser.autofill.editors.EditorBase;
import org.chromium.chrome.browser.autofill.editors.EditorDialogViewBinder;
import org.chromium.chrome.browser.autofill.editors.EditorFieldValidator;
import org.chromium.chrome.browser.autofill.editors.EditorProperties.FieldItem;
import org.chromium.chrome.browser.autofill.editors.EditorProperties.ItemType;
import org.chromium.components.autofill.AutofillProfile;
import org.chromium.components.autofill.FieldType;
import org.chromium.components.autofill.SubKeyRequester;
import org.chromium.components.autofill.SubKeyRequester.GetSubKeysRequestDelegate;
import org.chromium.payments.mojom.AddressErrors;
import org.chromium.ui.modelutil.ListModel;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.modelutil.PropertyModelChangeProcessor;

import java.util.ArrayList;
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.UUID;
import java.util.function.Predicate;

/**
 * An address editor. Can be used for either shipping or billing address editing.
 *
 * Note that this class is used by PaymentRequest only and will be removed when not needed any more.
 * Please use {@link org.chromium.chrome.browser.autofill.settings.AddressEditor} instead.
 */
@Deprecated
public class AddressEditor extends EditorBase<AutofillAddress>
        implements GetSubKeysRequestDelegate {
    private final Map<Integer, PropertyModel> mAddressFields = new HashMap<>();
    private final Set<String> mPhoneNumbers = new HashSet<>();
    private final PersonalDataManager mPersonalDataManager;
    private final boolean mSaveToDisk;
    private final PhoneNumberUtil.CountryAwareFormatTextWatcher mPhoneFormatter;
    @Nullable private AutofillProfileBridge mAutofillProfileBridge;
    @Nullable private PropertyModel mCountryField;
    @Nullable private PropertyModel mPhoneField;
    @Nullable private PropertyModel mAdminAreaField;
    private @ItemType int mAdminAreaFieldType;
    @Nullable private List<AutofillAddressUiComponent> mAddressUiComponents;
    private boolean mAdminAreasLoaded;
    private String mRecentlySelectedCountry;
    private Callback<AutofillAddress> mDoneCallback;
    private Callback<AutofillAddress> mCancelCallback;
    private boolean mAddressNew;
    private AutofillAddress mAddress;
    private AutofillProfile mProfile;
    private ProgressDialog mProgressDialog;
    @Nullable private AddressErrors mAddressErrors;

    private PropertyModel getFieldForFieldType(@FieldType int fieldType) {
        if (!mAddressFields.containsKey(fieldType)) {
            mAddressFields.put(
                    fieldType,
                    new PropertyModel.Builder(TEXT_ALL_KEYS)
                            .with(TEXT_FIELD_TYPE, fieldType)
                            .build());
        }

        return mAddressFields.get(fieldType);
    }

    /**
     * Builds an address editor.
     *
     * @param personalDataManager
     * @param saveToDisk Whether to save changes to disk after editing.
     */
    public AddressEditor(PersonalDataManager personalDataManager, boolean saveToDisk) {
        mPersonalDataManager = personalDataManager;
        mSaveToDisk = saveToDisk;
        mPhoneFormatter = new PhoneNumberUtil.CountryAwareFormatTextWatcher();
    }

    /**
     * Adds the given phone number to the autocomplete set, if it's valid.
     * Note that here we consider all non-null and non-empty numbers as valid
     * since we are doing strict validation of Autofill data.
     *
     * @param phoneNumber The phone number to possibly add.
     */
    public void addPhoneNumberIfValid(@Nullable String phoneNumber) {
        if (!TextUtils.isEmpty(phoneNumber)) mPhoneNumbers.add(phoneNumber);
    }

    /**
     * Sets the address errors to indicate error messages from merchant's retry() call.
     *
     * @param errors The address errors from merchant's retry() call.
     */
    public void setAddressErrors(@Nullable AddressErrors errors) {
        mAddressErrors = errors;
    }

    private String getAddressError(int field) {
        if (mAddressErrors == null) return null;

        switch (field) {
            case FieldType.ADDRESS_HOME_COUNTRY:
                return mAddressErrors.country;
            case FieldType.ADDRESS_HOME_STATE:
                return mAddressErrors.region;
            case FieldType.ADDRESS_HOME_CITY:
                return mAddressErrors.city;
            case FieldType.ADDRESS_HOME_DEPENDENT_LOCALITY:
                return mAddressErrors.dependentLocality;
            case FieldType.ADDRESS_HOME_SORTING_CODE:
                return mAddressErrors.sortingCode;
            case FieldType.ADDRESS_HOME_ZIP:
                return mAddressErrors.postalCode;
            case FieldType.ADDRESS_HOME_STREET_ADDRESS:
                return mAddressErrors.addressLine;
            case FieldType.COMPANY_NAME:
                return mAddressErrors.organization;
            case FieldType.NAME_FULL:
                return mAddressErrors.recipient;
            default:
                assert false : "Unrecognized server field type: " + field;
                return null;
        }
    }

    /**
     * Allows calling |edit| with a single callback used for both 'done' and 'cancel'.
     * @see #edit(AutofillAddress, Callback, Callback)
     */
    public void edit(
            @Nullable final AutofillAddress toEdit, final Callback<AutofillAddress> callback) {
        edit(toEdit, callback, callback);
    }

    /**
     * Builds and shows an editor model with the following fields.
     *
     * [ country dropdown    ] <----- country dropdown is always present.
     * [ an address field    ] \
     * [ an address field    ]  \
     *         ...                <-- field order, presence, required, and labels depend on country.
     * [ an address field    ]  /
     * [ an address field    ] /
     * [ phone number field  ] <----- phone is always present.
     */
    @Override
    public void edit(
            @Nullable final AutofillAddress toEdit,
            final Callback<AutofillAddress> doneCallback,
            final Callback<AutofillAddress> cancelCallback) {
        super.edit(toEdit, doneCallback, cancelCallback);
        if (mAutofillProfileBridge == null) mAutofillProfileBridge = new AutofillProfileBridge();
        mDoneCallback = doneCallback;
        mCancelCallback = cancelCallback;

        // If |toEdit| is null, we're creating a new autofill profile with the country code of the
        // default locale on this device.
        final String editTitle;
        mAddressNew = toEdit == null;
        if (mAddressNew) {
            mAddress =
                    new AutofillAddress(
                            mContext, AutofillProfile.builder().build(), mPersonalDataManager);
            editTitle = mContext.getString(R.string.autofill_create_profile);
        } else {
            mAddress = toEdit;
            editTitle = toEdit.getEditTitle();
        }

        mProfile = mAddress.getProfile();

        // When edit is called, a new form is started, so the country on the
        // dropdown list is not changed. => mRecentlySelectedCountry should be null.
        mRecentlySelectedCountry = null;

        // The country dropdown is always present on the editor.
        if (mCountryField == null) {
            mCountryField =
                    new PropertyModel.Builder(DROPDOWN_ALL_KEYS)
                            .with(
                                    LABEL,
                                    mContext.getString(R.string.autofill_profile_editor_country))
                            .with(
                                    DROPDOWN_KEY_VALUE_LIST,
                                    AutofillProfileBridge.getSupportedCountries())
                            .with(IS_REQUIRED, false)
                            .build();
        }

        mCountryField.set(
                DROPDOWN_CALLBACK,
                new Callback<String>() {
                    /** Load admin areas for the selected country. */
                    @Override
                    public void onResult(String countryCode) {
                        showProgressDialog();
                        mRecentlySelectedCountry = countryCode;
                        mPhoneFormatter.setCountryCode(mRecentlySelectedCountry);
                        loadAdminAreasForCountry(mRecentlySelectedCountry);
                    }
                });

        // Country dropdown is cached, so the selected item needs to be updated for the new profile
        // that's being edited. This will not fire the dropdown callback.
        mCountryField.set(VALUE, AutofillAddress.getCountryCode(mProfile, mPersonalDataManager));

        // Phone number validator and formatter are cached, so their contry code needs to be updated
        // for the new profile that's being edited.
        final String countryValue = mCountryField.get(VALUE);
        assert countryValue != null;
        mPhoneFormatter.setCountryCode(countryValue);

        // Phone number is present for all countries.
        if (mPhoneField == null) {
            mPhoneField =
                    new PropertyModel.Builder(TEXT_ALL_KEYS)
                            .with(TEXT_FIELD_TYPE, FieldType.PHONE_HOME_WHOLE_NUMBER)
                            .with(
                                    LABEL,
                                    mContext.getString(
                                            R.string.autofill_profile_editor_phone_number))
                            .with(TEXT_SUGGESTIONS, new ArrayList<>(mPhoneNumbers))
                            .with(TEXT_FORMATTER, mPhoneFormatter)
                            .with(IS_REQUIRED, true)
                            .build();
        }

        // Phone number field is cached, so its value needs to be updated for every new profile
        // that's being edited.
        mPhoneField.set(VALUE, mProfile.getInfo(FieldType.PHONE_HOME_WHOLE_NUMBER));

        mEditorModel =
                new PropertyModel.Builder(ALL_KEYS)
                        .with(EDITOR_TITLE, editTitle)
                        .with(SHOW_REQUIRED_INDICATOR, true)
                        .with(EDITOR_FIELDS, new ListModel())
                        .with(DONE_RUNNABLE, this::onDone)
                        .with(CANCEL_RUNNABLE, this::onCancel)
                        .with(ALLOW_DELETE, false)
                        // Form validation must be performed only for non-empty address profiles.
                        .with(VALIDATE_ON_SHOW, !mAddressNew)
                        .build();
        mEditorMCP =
                PropertyModelChangeProcessor.create(
                        mEditorModel, mEditorDialog, EditorDialogViewBinder::bindEditorDialogView);

        loadAdminAreasForCountry(mCountryField.get(VALUE));
    }

    private void onDone() {
        if (!validateForm(mEditorModel)) {
            scrollToFieldWithErrorMessage(mEditorModel);
            return;
        }
        mEditorModel.set(VISIBLE, false);

        // This makes sure that onSubKeysReceived returns early if it's
        // ever called when Done has already occurred.
        mAdminAreasLoaded = true;
        SubKeyRequesterFactory.getInstance().cancelPendingGetSubKeys();

        // Commit changes to the address and send modified address to the caller.
        commitChanges(mProfile);
        mAddress.completeAddress(mProfile);
        mDoneCallback.onResult(mAddress);

        // Clean up the state of this editor.
        reset();
    }

    private void onCancel() {
        mEditorModel.set(VISIBLE, false);

        // This makes sure that onSubKeysReceived returns early if it's
        // ever called when Cancel has already occurred.
        mAdminAreasLoaded = true;
        SubKeyRequesterFactory.getInstance().cancelPendingGetSubKeys();

        // Send unchanged address to the caller.
        mCancelCallback.onResult(mAddressNew ? null : mAddress);

        // Clean up the state of this editor.
        reset();
    }

    private void showProgressDialog() {
        mProgressDialog = new ProgressDialog(mContext);
        mProgressDialog.setMessage(mContext.getText(R.string.payments_loading_message));
        mProgressDialog.show();
    }

    private void dismissProgressDialog() {
        if (mProgressDialog != null && mProgressDialog.isShowing()) {
            mProgressDialog.dismiss();
        }
        mProgressDialog = null;
    }

    /** Saves the edited profile on disk. */
    private void commitChanges(AutofillProfile profile) {
        // Country code and phone number are always required and are always collected from the
        // editor model.
        profile.setInfo(FieldType.ADDRESS_HOME_COUNTRY, mCountryField.get(VALUE));
        profile.setInfo(FieldType.PHONE_HOME_WHOLE_NUMBER, mPhoneField.get(VALUE));

        // Autofill profile bridge normalizes the language code for the autofill profile.
        profile.setLanguageCode(mAutofillProfileBridge.getCurrentBestLanguageCode());

        // Collect data from all visible fields and store it in the autofill profile.
        for (AutofillAddressUiComponent component : mAddressUiComponents) {
            PropertyModel fieldModel =
                    component.id == FieldType.ADDRESS_HOME_STATE
                            ? mAdminAreaField
                            : mAddressFields.get(component.id);
            if (component.id != FieldType.ADDRESS_HOME_COUNTRY) {
                profile.setInfo(component.id, fieldModel.get(VALUE));
            }
        }

        // Save the edited autofill profile locally.
        if (mSaveToDisk) {
            profile.setGUID(mPersonalDataManager.setProfileToLocal(mProfile));
        }

        if (profile.getGUID().isEmpty()) {
            assert !mSaveToDisk;

            // Set a fake guid for a new temp AutofillProfile to be used in CardEditor. Note that
            // this temp AutofillProfile should not be saved to disk.
            profile.setGUID(UUID.randomUUID().toString());
        }
    }

    private void setAddressFieldValuesFromCache() {
        // Address fields are cached, so their values need to be updated for every new profile
        // that's being edited.
        for (Map.Entry<Integer, PropertyModel> entry : mAddressFields.entrySet()) {
            entry.getValue().set(VALUE, mProfile.getInfo(entry.getKey()));
        }
        mAdminAreaField.set(VALUE, mProfile.getInfo(FieldType.ADDRESS_HOME_STATE));
    }

    @Override
    public void onSubKeysReceived(
            @Nullable String[] adminAreaCodes, @Nullable String[] adminAreaNames) {
        if (mAdminAreasLoaded) return;
        mAdminAreasLoaded = true;
        // If the dialog is already dismissed, it doesn't make sense to show it.
        // This can happen if the dialog is dismissed while we are getting the
        // subkeys.
        if (mEditorDialog.isDismissed()) return;

        initializeAdminAreaField(adminAreaCodes, adminAreaNames);

        // Admin areas need to be fetched in two cases:
        // 1. Initial loading of the form.
        // 2. When the selected country is changed in the form.
        // mRecentlySelectedCountry is not null if and only if it's the second case
        if (mRecentlySelectedCountry != null) {
            dismissProgressDialog();
            // Both country code and language code dictate which fields should be added to the
            // editor.
            // For example, "US" will not add dependent locality to the editor. A "JP" address will
            // start with a person's full name or a with a prefecture name, depending on whether the
            // language code is "ja-Latn" or "ja".
            addAddressFieldsToEditor(mRecentlySelectedCountry, Locale.getDefault().getLanguage());
        } else {
            addAddressFieldsToEditor(mCountryField.get(VALUE), mProfile.getLanguageCode());
            // Populate fields with values once they've been created.
            setAddressFieldValuesFromCache();
            mEditorModel.set(VISIBLE, true);
        }
    }

    private void initializeAdminAreaField(
            @Nullable String[] adminAreaCodes, @Nullable String[] adminAreaNames) {
        // When there is a timeout in the subkey request process, the admin area codes/names will be
        // null.
        if (adminAreaCodes == null
                || adminAreaNames == null
                || adminAreaCodes.length == 0
                || adminAreaCodes.length != adminAreaNames.length) {
            mAdminAreaField =
                    new PropertyModel.Builder(TEXT_ALL_KEYS)
                            .with(TEXT_FIELD_TYPE, FieldType.ADDRESS_HOME_STATE)
                            .build();
            mAdminAreaFieldType = TEXT_INPUT;
            return;
        }
        mAdminAreaField =
                new PropertyModel.Builder(DROPDOWN_ALL_KEYS)
                        .with(
                                DROPDOWN_KEY_VALUE_LIST,
                                AutofillProfileBridge.getAdminAreaDropdownList(
                                        adminAreaCodes, adminAreaNames))
                        .with(DROPDOWN_HINT, mContext.getString(R.string.select))
                        .build();
        mAdminAreaFieldType = DROPDOWN;
    }

    /** Requests the list of admin areas. */
    private void loadAdminAreasForCountry(String countryCode) {
        // Used to check if the callback is called (for the cancellation).
        mAdminAreasLoaded = false;
        mEditorDialog.setAsNotDismissed();

        // For tests, the time-out is set to 0. In this case, we should not
        // fetch the admin-areas, and show a text-field instead.
        // This is to have the tests independent of the network status.
        if (SubKeyRequester.getRequestTimeoutMS() == 0) {
            onSubKeysReceived(null, null);
            return;
        }

        // In each rule, admin area keys are saved under sub-keys of country.
        SubKeyRequesterFactory.getInstance().loadRulesForSubKeys(countryCode);
        SubKeyRequesterFactory.getInstance().getRegionSubKeys(countryCode, this);
    }

    /**
     * Adds fields to the editor model based on the country and language code of
     * the profile that's being edited.
     */
    private void addAddressFieldsToEditor(String countryCode, String languageCode) {
        ListModel<FieldItem> editorFields = new ListModel<>();
        mAddressUiComponents =
                mAutofillProfileBridge.getAddressUiComponents(
                        countryCode, languageCode, AddressValidationType.PAYMENT_REQUEST);
        // In terms of order, country must be the first field.
        editorFields.add(new FieldItem(DROPDOWN, mCountryField, /* isFullLine= */ true));
        for (AutofillAddressUiComponent component : mAddressUiComponents) {
            final PropertyModel field;
            final @ItemType int fieldType;
            if (component.id == FieldType.ADDRESS_HOME_STATE) {
                field = mAdminAreaField;
                fieldType = mAdminAreaFieldType;
            } else {
                field = getFieldForFieldType(component.id);
                fieldType = TEXT_INPUT;
            }

            // Labels depend on country, e.g., state is called province in some countries. These are
            // already localized.
            field.set(LABEL, component.label);

            field.set(
                    VALIDATOR,
                    EditorFieldValidator.builder()
                            .withInitialErrorMessage(getAddressError(component.id))
                            .build());
            // Libaddressinput formats do not always require the full name (RECIPIENT), but
            // PaymentRequest does.
            if (component.isRequired || component.id == FieldType.NAME_FULL) {
                field.set(IS_REQUIRED, true);
                field.get(VALIDATOR)
                        .setRequiredErrorMessage(
                                mContext.getString(
                                        R.string
                                                .pref_edit_dialog_field_required_validation_message));
            } else {
                field.set(IS_REQUIRED, false);
            }

            boolean isFullLine =
                    component.isFullLine
                            || component.id == FieldType.ADDRESS_HOME_CITY
                            || component.id == FieldType.ADDRESS_HOME_DEPENDENT_LOCALITY;
            editorFields.add(new FieldItem(fieldType, field, isFullLine));
        }
        // Phone number (and email if applicable) are the last fields of the address.
        mPhoneField.set(VALIDATOR, getPhoneValidator(countryCode));
        editorFields.add(new FieldItem(TEXT_INPUT, mPhoneField, /* isFullLine= */ true));
        mEditorModel.set(EDITOR_FIELDS, editorFields);
    }

    private EditorFieldValidator getPhoneValidator(String countryCode) {
        // TODO(crbug.com/40527351): Warn users when the phone number is a possible number but may
        // be
        // invalid.
        // Note that isPossibleNumber is used since the metadata in libphonenumber has to be
        // updated frequently (daily) to do more strict validation.
        Predicate<String> validationPredicate =
                value ->
                        !TextUtils.isEmpty(value)
                                && PhoneNumberUtil.isPossibleNumber(value, countryCode);

        return EditorFieldValidator.builder()
                .withRequiredErrorMessage(
                        mContext.getString(
                                R.string.pref_edit_dialog_field_required_validation_message))
                .withValidationPredicate(
                        validationPredicate,
                        mContext.getString(R.string.payments_phone_invalid_validation_message))
                .withInitialErrorMessage(mAddressErrors != null ? mAddressErrors.phone : null)
                .build();
    }
}