chromium/chrome/browser/autofill/android/java/src/org/chromium/chrome/browser/autofill/editors/TextFieldView.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.editors;

import static org.chromium.chrome.browser.autofill.editors.EditorProperties.FieldProperties.ERROR_MESSAGE;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.FieldProperties.FOCUSED;
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.VALUE;

import android.content.Context;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityEvent;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager;
import android.widget.ArrayAdapter;
import android.widget.AutoCompleteTextView;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView.OnEditorActionListener;

import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;

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

import org.chromium.base.ResettersForTesting;
import org.chromium.chrome.browser.autofill.R;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.text.EmptyTextWatcher;

import java.util.List;

/** Handles validation and display of one field from the {@link EditorProperties.ItemType}. */
// TODO(b/173103628): Re-enable this
// @VisibleForTesting
class TextFieldView extends FrameLayout implements FieldView {
    // TODO(crbug.com/40824084): Replace with EditorDialog field once migrated.
    /** The indicator for input fields that are required. */
    public static final String REQUIRED_FIELD_INDICATOR = "*";

    @Nullable private static EditorObserverForTest sObserverForTest;

    @Nullable private Runnable mDoneRunnable;

    @SuppressWarnings("WrongConstant") // https://crbug.com/1038784
    private final OnEditorActionListener mEditorActionListener =
            (view, actionId, event) -> {
                if (actionId == EditorInfo.IME_ACTION_DONE && mDoneRunnable != null) {
                    mDoneRunnable.run();
                    return true;
                } else if (actionId != EditorInfo.IME_ACTION_NEXT) {
                    return false;
                }
                View next = view.focusSearch(View.FOCUS_FORWARD);
                if (next == null) {
                    return false;
                }
                next.requestFocus();
                return true;
            };

    private PropertyModel mEditorFieldModel;
    private TextInputLayout mInputLayout;
    private AutoCompleteTextView mInput;
    private View mIconsLayer;
    private ImageView mActionIcon;
    private boolean mShowRequiredIndicator;
    @Nullable private EditorFieldValidator mValidator;
    @Nullable private TextWatcher mTextFormatter;
    private boolean mInFocusChange;
    private boolean mInValueChange;

    public TextFieldView(Context context, final PropertyModel fieldModel) {
        super(context);
        mEditorFieldModel = fieldModel;

        LayoutInflater.from(context).inflate(R.layout.payments_request_editor_textview, this, true);
        mInputLayout = (TextInputLayout) findViewById(R.id.text_input_layout);

        mInput = (AutoCompleteTextView) mInputLayout.findViewById(R.id.text_view);
        mInput.setOnEditorActionListener(mEditorActionListener);
        // AutoCompleteTextView requires and explicit onKeyListener to show the OSK upon receiving
        // a KEYCODE_DPAD_CENTER.
        mInput.setOnKeyListener(
                (v, keyCode, event) -> {
                    if (!(keyCode == KeyEvent.KEYCODE_DPAD_CENTER
                            && event.getAction() == KeyEvent.ACTION_UP)) {
                        return false;
                    }
                    InputMethodManager imm =
                            (InputMethodManager)
                                    v.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
                    imm.viewClicked(v);
                    imm.showSoftInput(v, 0);
                    return true;
                });

        setShowRequiredIndicator(/* showRequiredIndicator= */ false);

        mIconsLayer = findViewById(R.id.icons_layer);
        mIconsLayer.addOnLayoutChangeListener(
                new View.OnLayoutChangeListener() {
                    @Override
                    public void onLayoutChange(
                            View v,
                            int left,
                            int top,
                            int right,
                            int bottom,
                            int oldLeft,
                            int oldTop,
                            int oldRight,
                            int oldBottom) {
                        // Padding at the end of mInput to preserve space for mIconsLayer.
                        mInput.setPaddingRelative(
                                ViewCompat.getPaddingStart(mInput),
                                mInput.getPaddingTop(),
                                mIconsLayer.getWidth(),
                                mInput.getPaddingBottom());
                    }
                });

        mInput.setOnFocusChangeListener(
                new OnFocusChangeListener() {
                    @Override
                    public void onFocusChange(View v, boolean hasFocus) {
                        mInFocusChange = true;
                        mEditorFieldModel.set(FOCUSED, hasFocus);
                        mInFocusChange = false;

                        if (!hasFocus && mValidator != null) {
                            // Validate the field when the user de-focuses it.
                            // We do not validate the form initially when all of the fields are
                            // empty to avoid showing error messages in all of the fields.
                            mValidator.validate(mEditorFieldModel);
                        }
                    }
                });

        // Update the model as the user edits the field.
        mInput.addTextChangedListener(
                new EmptyTextWatcher() {
                    @Override
                    public void afterTextChanged(Editable s) {
                        fieldModel.set(VALUE, s.toString());
                        if (sObserverForTest != null) {
                            sObserverForTest.onEditorTextUpdate();
                        }
                    }

                    @Override
                    public void onTextChanged(CharSequence s, int start, int before, int count) {
                        if (mInput.hasFocus() && !mInValueChange) {
                            if (mValidator != null) {
                                mValidator.onUserEditedField();
                            }

                            // Hide the error message and wait till the user finishes editing the
                            // field to re-show the error label.
                            mEditorFieldModel.set(ERROR_MESSAGE, null);
                        }
                    }
                });
    }

    void setLabel(String label, boolean isRequired) {
        // Build up the label. Required fields are indicated by appending a '*'.
        if (isRequired && mShowRequiredIndicator) {
            label += REQUIRED_FIELD_INDICATOR;
        }
        mInputLayout.setHint(label);
        mInput.setContentDescription(label);
    }

    void setValidator(@Nullable EditorFieldValidator validator) {
        mValidator = validator;
    }

    void setErrorMessage(@Nullable String errorMessage) {
        mInputLayout.setError(errorMessage);
        if (sObserverForTest != null && errorMessage != null) {
            sObserverForTest.onEditorValidationError();
        }
    }

    void setValue(@Nullable String value) {
        value = value == null ? "" : value;
        if (mInput.getText().toString().equals(value)) {
            return;
        }
        // {@link mTextFormatter#afterTextChanged()} can trigger a nested {@link setValue()}
        // call.
        boolean inNestedValueChange = mInValueChange;
        mInValueChange = true;
        mInput.setText(value);
        if (mTextFormatter != null) {
            mTextFormatter.afterTextChanged(mInput.getText());
        }
        mInValueChange = inNestedValueChange;
    }

    void setTextInputType(int textInputType) {
        mInput.setInputType(textInputType);
    }

    void setTextSuggestions(@Nullable List<String> suggestions) {
        // Display any autofill suggestions.
        if (suggestions != null && !suggestions.isEmpty()) {
            mInput.setAdapter(
                    new ArrayAdapter<>(
                            getContext(),
                            android.R.layout.simple_spinner_dropdown_item,
                            suggestions));
            mInput.setThreshold(0);
        }
    }

    void setTextFormatter(@Nullable TextWatcher formatter) {
        mTextFormatter = formatter;
        if (mTextFormatter != null) {
            mInput.addTextChangedListener(mTextFormatter);
            mTextFormatter.afterTextChanged(mInput.getText());
        }
    }

    void setDoneRunnable(@Nullable Runnable doneRunnable) {
        mDoneRunnable = doneRunnable;
    }

    @Override
    public void setShowRequiredIndicator(boolean showRequiredIndicator) {
        mShowRequiredIndicator = showRequiredIndicator;
        setLabel(mEditorFieldModel.get(LABEL), mEditorFieldModel.get(IS_REQUIRED));
    }

    @Override
    public void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);

        if (changed) {
            // Align the bottom of mIconsLayer to the bottom of mInput (mIconsLayer overlaps
            // mInput).
            // Note one:   mIconsLayer can not be put inside mInputLayout to display on top of
            // mInput since mInputLayout is LinearLayout in essential.
            // Note two:   mIconsLayer and mInput can not be put in ViewGroup to display over each
            // other inside mInputLayout since mInputLayout must contain an instance of EditText
            // child view.
            // Note three: mInputLayout's bottom changes when displaying error.
            float offset =
                    mInputLayout.getY()
                            + mInput.getY()
                            + (float) mInput.getHeight()
                            - (float) mIconsLayer.getHeight()
                            - mIconsLayer.getTop();
            mIconsLayer.setTranslationY(offset);
        }
    }

    /**
     * @return The AutoCompleteTextView this field associates
     */
    public AutoCompleteTextView getEditText() {
        return mInput;
    }

    public TextInputLayout getInputLayoutForTesting() {
        return mInputLayout;
    }

    @Override
    public boolean validate() {
        if (mValidator != null) {
            mValidator.validate(mEditorFieldModel);
        }
        return mInputLayout.getError() == null;
    }

    @Override
    public boolean isRequired() {
        return mEditorFieldModel.get(IS_REQUIRED);
    }

    @Override
    public void scrollToAndFocus() {
        if (mInFocusChange) return;

        ViewGroup parent = (ViewGroup) getParent();
        if (parent != null) parent.requestChildFocus(this, this);
        requestFocus();
        sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED);
    }

    public void removeTextChangedListeners() {
        if (mTextFormatter != null) {
            mInput.removeTextChangedListener(mTextFormatter);
        }
    }

    public static void setEditorObserverForTest(EditorObserverForTest observerForTest) {
        sObserverForTest = observerForTest;
        ResettersForTesting.register(() -> sObserverForTest = null);
    }
}