chromium/chrome/browser/autofill/android/java/src/org/chromium/chrome/browser/autofill/editors/AddressEditorMediator.java

// Copyright 2023 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.editors;

import static org.chromium.chrome.browser.autofill.editors.AddressEditorCoordinator.UserFlow.CREATE_NEW_ADDRESS_PROFILE;
import static org.chromium.chrome.browser.autofill.editors.AddressEditorCoordinator.UserFlow.MIGRATE_EXISTING_ADDRESS_PROFILE;
import static org.chromium.chrome.browser.autofill.editors.AddressEditorCoordinator.UserFlow.SAVE_NEW_ADDRESS_PROFILE;
import static org.chromium.chrome.browser.autofill.editors.AddressEditorCoordinator.UserFlow.UPDATE_EXISTING_ADDRESS_PROFILE;
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.CUSTOM_DONE_BUTTON_TEXT;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.DELETE_CONFIRMATION_TEXT;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.DELETE_CONFIRMATION_TITLE;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.DELETE_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_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.FOOTER_MESSAGE;
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.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.content.Context;
import android.text.TextUtils;

import androidx.annotation.Nullable;

import org.chromium.base.Callback;
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.R;
import org.chromium.chrome.browser.autofill.editors.AddressEditorCoordinator.Delegate;
import org.chromium.chrome.browser.autofill.editors.AddressEditorCoordinator.UserFlow;
import org.chromium.chrome.browser.autofill.editors.EditorProperties.DropdownKeyValue;
import org.chromium.chrome.browser.autofill.editors.EditorProperties.FieldItem;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.components.autofill.AutofillProfile;
import org.chromium.components.autofill.FieldType;
import org.chromium.components.autofill.RecordType;
import org.chromium.components.signin.base.CoreAccountInfo;
import org.chromium.components.signin.identitymanager.ConsentLevel;
import org.chromium.components.signin.identitymanager.IdentityManager;
import org.chromium.components.sync.SyncService;
import org.chromium.components.sync.UserSelectableType;
import org.chromium.ui.modelutil.ListModel;
import org.chromium.ui.modelutil.PropertyModel;

import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.function.Predicate;

/**
 * Contains the logic for the autofill address editor component. It sets the state of the model and
 * reacts to events like address country selection.
 */
class AddressEditorMediator {
    private final PhoneNumberUtil.CountryAwareFormatTextWatcher mPhoneFormatter =
            new PhoneNumberUtil.CountryAwareFormatTextWatcher();
    private final AutofillProfileBridge mAutofillProfileBridge = new AutofillProfileBridge();
    private final Context mContext;
    private final Delegate mDelegate;
    private final IdentityManager mIdentityManager;
    private final @Nullable SyncService mSyncService;
    private final PersonalDataManager mPersonalDataManager;
    private final AutofillProfile mProfileToEdit;
    private final AutofillAddress mAddressToEdit;
    private final @UserFlow int mUserFlow;
    private final boolean mSaveToDisk;
    private final Map<Integer, PropertyModel> mAddressFields = new HashMap<>();
    private final PropertyModel mCountryField;
    private final PropertyModel mPhoneField;
    private final PropertyModel mEmailField;
    private final @Nullable PropertyModel mNicknameField;

    private List<AutofillAddressUiComponent> mVisibleEditorFields;
    @Nullable private String mCustomDoneButtonText;
    private boolean mAllowDelete;
    private boolean mShouldTriggerDoneCallbackBeforeCloseAnimation;

    @Nullable private PropertyModel mEditorModel;

    private PropertyModel getFieldForFieldType(@FieldType int fieldType) {
        if (!mAddressFields.containsKey(fieldType)) {
            // Address fields are cached.
            mAddressFields.put(
                    fieldType,
                    new PropertyModel.Builder(TEXT_ALL_KEYS)
                            .with(TEXT_FIELD_TYPE, fieldType)
                            .with(VALUE, mProfileToEdit.getInfo(fieldType))
                            .build());
        }
        return mAddressFields.get(fieldType);
    }

    // TODO(crbug.com/40263955): remove temporary unsupported countries filtering.
    private static List<DropdownKeyValue> getSupportedCountries(
            PersonalDataManager personalDataManager, boolean filterOutUnsupportedCountries) {
        List<DropdownKeyValue> supportedCountries = AutofillProfileBridge.getSupportedCountries();
        if (filterOutUnsupportedCountries) {
            supportedCountries.removeIf(
                    entry ->
                            !personalDataManager.isCountryEligibleForAccountStorage(
                                    entry.getKey()));
        }

        return supportedCountries;
    }

    AddressEditorMediator(
            Context context,
            Delegate delegate,
            IdentityManager identityManager,
            @Nullable SyncService syncService,
            PersonalDataManager personalDataManager,
            AutofillAddress addressToEdit,
            @UserFlow int userFlow,
            boolean saveToDisk) {
        mContext = context;
        mDelegate = delegate;
        mIdentityManager = identityManager;
        mSyncService = syncService;
        mPersonalDataManager = personalDataManager;
        mProfileToEdit = addressToEdit.getProfile();
        mAddressToEdit = addressToEdit;
        mUserFlow = userFlow;
        mSaveToDisk = saveToDisk;

        // The country dropdown is always present on the editor.
        mCountryField =
                new PropertyModel.Builder(DROPDOWN_ALL_KEYS)
                        .with(LABEL, mContext.getString(R.string.autofill_profile_editor_country))
                        .with(
                                DROPDOWN_KEY_VALUE_LIST,
                                getSupportedCountries(
                                        mPersonalDataManager,
                                        isAccountAddressProfile()
                                                && mUserFlow != CREATE_NEW_ADDRESS_PROFILE))
                        .with(IS_REQUIRED, false)
                        .with(
                                VALUE,
                                AutofillAddress.getCountryCode(
                                        mProfileToEdit, mPersonalDataManager))
                        .build();

        // Phone number is present for all countries.
        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_FORMATTER, mPhoneFormatter)
                        .with(VALUE, mProfileToEdit.getInfo(FieldType.PHONE_HOME_WHOLE_NUMBER))
                        .build();

        // Phone number is present for all countries.
        mEmailField =
                new PropertyModel.Builder(TEXT_ALL_KEYS)
                        .with(TEXT_FIELD_TYPE, FieldType.EMAIL_ADDRESS)
                        .with(
                                LABEL,
                                mContext.getString(R.string.autofill_profile_editor_email_address))
                        .with(VALIDATOR, getEmailValidator())
                        .with(VALUE, mProfileToEdit.getInfo(FieldType.EMAIL_ADDRESS))
                        .build();

        // TODO(crbug.com/40267973): Use localized string.
        mNicknameField =
                ChromeFeatureList.isEnabled(
                                ChromeFeatureList
                                        .AUTOFILL_ADDRESS_PROFILE_SAVE_PROMPT_NICKNAME_SUPPORT)
                        ? new PropertyModel.Builder(TEXT_ALL_KEYS)
                                .with(TEXT_FIELD_TYPE, FieldType.UNKNOWN_TYPE)
                                .with(LABEL, "Label")
                                .with(IS_REQUIRED, false)
                                .build()
                        : null;

        assert mCountryField.get(VALUE) != null;
        mPhoneFormatter.setCountryCode(mCountryField.get(VALUE));
    }

    public void setAllowDelete(boolean allowDelete) {
        mAllowDelete = allowDelete;
    }

    public void setShouldTriggerDoneCallbackBeforeCloseAnimation(boolean shouldTrigger) {
        mShouldTriggerDoneCallbackBeforeCloseAnimation = shouldTrigger;
    }

    void setCustomDoneButtonText(@Nullable String customDoneButtonText) {
        mCustomDoneButtonText = customDoneButtonText;
    }

    /**
     * Builds 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.
     * [ email address field ] <----- only present if purpose is Purpose.AUTOFILL_SETTINGS.
     * [ address nickname    ] <----- only present if nickname support is enabled.
     */
    PropertyModel getEditorModel() {
        if (mEditorModel != null) {
            return mEditorModel;
        }

        mEditorModel =
                new PropertyModel.Builder(ALL_KEYS)
                        .with(EDITOR_TITLE, getEditorTitle())
                        .with(CUSTOM_DONE_BUTTON_TEXT, mCustomDoneButtonText)
                        .with(FOOTER_MESSAGE, getSourceNoticeText())
                        .with(DELETE_CONFIRMATION_TITLE, getDeleteConfirmationTitle())
                        .with(DELETE_CONFIRMATION_TEXT, getDeleteConfirmationText())
                        .with(SHOW_REQUIRED_INDICATOR, false)
                        .with(
                                EDITOR_FIELDS,
                                buildEditorFieldList(
                                        AutofillAddress.getCountryCode(
                                                mProfileToEdit, mPersonalDataManager),
                                        mProfileToEdit.getLanguageCode()))
                        .with(DONE_RUNNABLE, this::onCommitChanges)
                        // If the user clicks [Cancel], send |toEdit| address back to the caller,
                        // which was the original state (could be null, a complete address, a
                        // partial address).
                        .with(CANCEL_RUNNABLE, this::onCancelEditing)
                        .with(ALLOW_DELETE, mAllowDelete)
                        .with(DELETE_RUNNABLE, () -> mDelegate.onDelete(mAddressToEdit))
                        .with(VALIDATE_ON_SHOW, mUserFlow != CREATE_NEW_ADDRESS_PROFILE)
                        .build();

        mCountryField.set(
                DROPDOWN_CALLBACK,
                new Callback<String>() {
                    /** Update the list of fields according to the selected country. */
                    @Override
                    public void onResult(String countryCode) {
                        mEditorModel.set(
                                EDITOR_FIELDS,
                                buildEditorFieldList(
                                        countryCode, Locale.getDefault().getLanguage()));

                        mPhoneFormatter.setCountryCode(countryCode);
                    }
                });

        return mEditorModel;
    }

    private boolean shouldDisplayRequiredErrorIfFieldEmpty(AutofillAddressUiComponent component) {
        if (!isAccountAddressProfile()) {
            return false; // Required fields shouldn't be enforced for non-account address profiles.
        }

        if (!component.isRequired) return false;

        boolean isContentEmpty = TextUtils.isEmpty(mProfileToEdit.getInfo(component.id));
        // Already empty fields in existing address profiles are made optional even if they
        // are required by account storage rules. This allows users to save address profiles
        // as is without making them more complete during the process.
        return mUserFlow == CREATE_NEW_ADDRESS_PROFILE || !isContentEmpty;
    }

    /**
     * Creates a list of editor based on the country and language code of the profile that's being
     * edited.
     *
     * For example, "US" will not add dependent locality to the list. A "JP" address will start
     * with a person's full name or with a prefecture name, depending on whether the language code
     * is "ja-Latn" or "ja".
     *
     * @param countryCode The country for which fields are to be added.
     * @param languageCode The language in which localized strings (e.g. label) are presented.
     */
    private ListModel<FieldItem> buildEditorFieldList(String countryCode, String languageCode) {
        ListModel<FieldItem> editorFields = new ListModel<>();
        mVisibleEditorFields =
                mAutofillProfileBridge.getAddressUiComponents(
                        countryCode, languageCode, AddressValidationType.ACCOUNT);

        // In terms of order, country must be the first field.
        editorFields.add(new FieldItem(DROPDOWN, mCountryField, /* isFullLine= */ true));

        for (AutofillAddressUiComponent component : mVisibleEditorFields) {
            PropertyModel field = getFieldForFieldType(component.id);

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

            if (shouldDisplayRequiredErrorIfFieldEmpty(component)) {
                String message =
                        mContext.getString(R.string.autofill_edit_address_required_field_error)
                                .replace("$1", component.label);
                // Note: the error message itself will be displayed only if the field is or
                // becomes empty, this just marks "candidate" fields that should be taken
                // into account for the error.
                field.set(IS_REQUIRED, true);
                field.set(
                        VALIDATOR,
                        EditorFieldValidator.builder().withRequiredErrorMessage(message).build());
            } else {
                field.set(IS_REQUIRED, false);
            }

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

        return editorFields;
    }

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

        commitChanges(mProfileToEdit);
        // The address cannot be marked "complete" because it has not been
        // checked for all required fields.
        mAddressToEdit.updateAddress(mProfileToEdit);

        mDelegate.onDone(mAddressToEdit);
    }

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

        mDelegate.onCancel();
    }

    /** Saves the edited profile on disk. */
    private void commitChanges(AutofillProfile profile) {
        String country = mCountryField.get(VALUE);
        if (willBeSavedInAccount()
                && mUserFlow == CREATE_NEW_ADDRESS_PROFILE
                && mPersonalDataManager.isCountryEligibleForAccountStorage(country)) {
            profile.setRecordType(RecordType.ACCOUNT);
        }
        // Country code and phone number are always required and are always collected from the
        // editor model.
        profile.setInfo(FieldType.ADDRESS_HOME_COUNTRY, country);
        if (mPhoneField != null) {
            profile.setInfo(FieldType.PHONE_HOME_WHOLE_NUMBER, mPhoneField.get(VALUE));
        }
        if (mEmailField != null) {
            profile.setInfo(FieldType.EMAIL_ADDRESS, mEmailField.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 : mVisibleEditorFields) {
            if (component.id != FieldType.ADDRESS_HOME_COUNTRY) {
                assert mAddressFields.containsKey(component.id);
                profile.setInfo(component.id, mAddressFields.get(component.id).get(VALUE));
            }
        }

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

        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 boolean willBeSavedInAccount() {
        switch (mUserFlow) {
            case MIGRATE_EXISTING_ADDRESS_PROFILE:
                return true;
            case UPDATE_EXISTING_ADDRESS_PROFILE:
                return false;
            case SAVE_NEW_ADDRESS_PROFILE:
                return mProfileToEdit.getRecordType() == RecordType.ACCOUNT;
            case CREATE_NEW_ADDRESS_PROFILE:
                return mPersonalDataManager.isEligibleForAddressAccountStorage();
        }
        assert false : String.format(Locale.US, "Missing account target for flow %d", mUserFlow);
        return false;
    }

    private boolean isAccountAddressProfile() {
        return willBeSavedInAccount() || isAlreadySavedInAccount();
    }

    private String getEditorTitle() {
        return mUserFlow == CREATE_NEW_ADDRESS_PROFILE
                ? mContext.getString(R.string.autofill_create_profile)
                : mContext.getString(R.string.autofill_edit_address_dialog_title);
    }

    private @Nullable String getUserEmail() {
        CoreAccountInfo accountInfo = mIdentityManager.getPrimaryAccountInfo(ConsentLevel.SIGNIN);
        return CoreAccountInfo.getEmailFrom(accountInfo);
    }

    private @Nullable String getDeleteConfirmationText() {
        if (isAccountAddressProfile()) {
            @Nullable String email = getUserEmail();
            if (email == null) return null;
            return mContext.getString(R.string.autofill_delete_account_address_source_notice)
                    .replace("$1", email);
        }
        if (isAddressSyncOn()) {
            return mContext.getString(R.string.autofill_delete_sync_address_source_notice);
        }
        return mContext.getString(R.string.autofill_delete_local_address_source_notice);
    }

    private @Nullable String getSourceNoticeText() {
        if (!isAccountAddressProfile()) return null;
        @Nullable String email = getUserEmail();
        if (email == null) return null;

        if (isAlreadySavedInAccount()) {
            return mContext.getString(
                            R.string.autofill_address_already_saved_in_account_source_notice)
                    .replace("$1", email);
        }

        return mContext.getString(R.string.autofill_address_will_be_saved_in_account_source_notice)
                .replace("$1", email);
    }

    private String getDeleteConfirmationTitle() {
        return mContext.getString(R.string.autofill_delete_address_confirmation_dialog_title);
    }

    private boolean isAlreadySavedInAccount() {
        // User edits an account address profile either from Chrome settings or upon form
        // submission.
        return mUserFlow == UPDATE_EXISTING_ADDRESS_PROFILE
                && mProfileToEdit.getRecordType() == RecordType.ACCOUNT;
    }

    private boolean isAddressSyncOn() {
        if (mSyncService == null) return false;
        return mSyncService.getSelectedTypes().contains(UserSelectableType.AUTOFILL);
    }

    private EditorFieldValidator getEmailValidator() {
        return EditorFieldValidator.builder()
                .withValidationPredicate(
                        unused -> true,
                        mContext.getString(R.string.payments_email_invalid_validation_message))
                .build();
    }

    private EditorFieldValidator getPhoneValidator(String countryCode) {
        // 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()
                .withValidationPredicate(
                        validationPredicate,
                        mContext.getString(R.string.payments_phone_invalid_validation_message))
                .build();
    }
}