// 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.ItemType.DROPDOWN;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.ItemType.TEXT_INPUT;
import static org.chromium.chrome.browser.autofill.editors.EditorProperties.isDropdownField;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.app.Activity;
import android.content.DialogInterface;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.RelativeLayout.LayoutParams;
import android.widget.Spinner;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.core.view.MarginLayoutParamsCompat;
import org.chromium.chrome.browser.autofill.R;
import org.chromium.chrome.browser.autofill.editors.EditorProperties.FieldItem;
import org.chromium.chrome.browser.feedback.HelpAndFeedbackLauncherFactory;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.util.ChromeAccessibilityUtil;
import org.chromium.components.browser_ui.settings.SettingsUtils;
import org.chromium.components.browser_ui.styles.SemanticColorUtils;
import org.chromium.components.browser_ui.widget.AlwaysDismissedDialog;
import org.chromium.components.browser_ui.widget.FadingEdgeScrollView;
import org.chromium.components.browser_ui.widget.FadingEdgeScrollView.EdgeType;
import org.chromium.components.browser_ui.widget.TintedDrawable;
import org.chromium.components.browser_ui.widget.displaystyle.UiConfig;
import org.chromium.components.browser_ui.widget.displaystyle.ViewResizer;
import org.chromium.ui.KeyboardVisibilityDelegate;
import org.chromium.ui.interpolators.Interpolators;
import org.chromium.ui.modelutil.ListModel;
import org.chromium.ui.modelutil.PropertyKey;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.modelutil.PropertyModelChangeProcessor;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* The editor dialog. Can be used for editing contact information, shipping address, billing
* address.
*
* <p>TODO(crbug.com/41363594): Move payment specific functionality to separate class.
*/
public class EditorDialogView extends AlwaysDismissedDialog
implements OnClickListener,
DialogInterface.OnShowListener,
DialogInterface.OnDismissListener {
/** The indicator for input fields that are required. */
public static final String REQUIRED_FIELD_INDICATOR = "*";
/** Duration of the animation to show the UI to full height. */
private static final int DIALOG_ENTER_ANIMATION_MS = 300;
/** Duration of the animation to hide the UI. */
private static final int DIALOG_EXIT_ANIMATION_MS = 195;
@Nullable private static EditorObserverForTest sObserverForTest;
private final Activity mActivity;
private final Profile mProfile;
private final Handler mHandler;
private final int mHalfRowMargin;
private final List<FieldView> mFieldViews;
// TODO(crbug.com/40265078): substitute this with SimpleRecyclerViewMCP.
private final List<PropertyModelChangeProcessor<PropertyModel, TextFieldView, PropertyKey>>
mTextFieldMCPs;
private final List<PropertyModelChangeProcessor<PropertyModel, DropdownFieldView, PropertyKey>>
mDropdownFieldMCPs;
private final List<EditText> mEditableTextFields;
private final List<Spinner> mDropdownFields;
private final View mContainerView;
private final ViewGroup mContentView;
private final View mFooter;
private Button mDoneButton;
private Animator mDialogInOutAnimator;
private boolean mIsDismissed;
@Nullable private UiConfig mUiConfig;
@Nullable private AlertDialog mConfirmationDialog;
@Nullable private String mDeleteConfirmationTitle;
@Nullable private String mDeleteConfirmationText;
private Runnable mDeleteRunnable;
private Runnable mDoneRunnable;
private Runnable mCancelRunnable;
private boolean mValidateOnShow;
/**
* Builds the editor dialog.
*
* @param activity The activity on top of which the UI should be displayed.
* @param profile The Profile being edited.
*/
public EditorDialogView(Activity activity, Profile profile) {
super(activity, R.style.ThemeOverlay_BrowserUI_Fullscreen);
// Sets transparent background for animating content view.
getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
mActivity = activity;
mProfile = profile;
mHandler = new Handler();
mIsDismissed = false;
mHalfRowMargin =
activity.getResources()
.getDimensionPixelSize(R.dimen.editor_dialog_section_large_spacing);
mFieldViews = new ArrayList<>();
mTextFieldMCPs = new ArrayList<>();
mDropdownFieldMCPs = new ArrayList<>();
mEditableTextFields = new ArrayList<>();
mDropdownFields = new ArrayList<>();
setOnShowListener(this);
setOnDismissListener(this);
mContainerView =
LayoutInflater.from(mActivity).inflate(R.layout.payment_request_editor, null);
setContentView(mContainerView);
prepareToolbar();
mContentView = mContainerView.findViewById(R.id.contents);
mFooter =
LayoutInflater.from(mActivity)
.inflate(R.layout.editable_option_editor_footer, null, false);
mFooter.findViewById(R.id.button_primary).setId(R.id.editor_dialog_done_button);
mFooter.findViewById(R.id.button_secondary).setId(R.id.payments_edit_cancel_button);
prepareButtons();
}
public void setEditorTitle(String editorTitle) {
assert editorTitle != null : "Editor title can't be null";
EditorDialogToolbar toolbar = mContainerView.findViewById(R.id.action_bar);
toolbar.setTitle(editorTitle);
}
public void setCustomDoneButtonText(@Nullable String customDoneButtonText) {
if (customDoneButtonText != null) {
mDoneButton.setText(customDoneButtonText);
} else {
mDoneButton.setText(mActivity.getString(R.string.done));
}
}
public void setFooterMessage(@Nullable String footerMessage) {
TextView footerText = mFooter.findViewById(R.id.footer_message);
if (footerMessage != null) {
footerText.setText(footerMessage);
footerText.setVisibility(View.VISIBLE);
} else {
footerText.setVisibility(View.GONE);
}
}
public void setDeleteConfirmationTitle(@Nullable String deleteConfirmationTitle) {
mDeleteConfirmationTitle = deleteConfirmationTitle;
}
public void setDeleteConfirmationText(@Nullable String deleteConfirmationText) {
mDeleteConfirmationText = deleteConfirmationText;
}
public void setShowRequiredIndicator(boolean showRequiredIndicator) {
for (FieldView view : mFieldViews) {
view.setShowRequiredIndicator(showRequiredIndicator);
}
TextView requiredFieldsNotice = mFooter.findViewById(R.id.required_fields_notice);
int requiredFieldsNoticeVisibility = View.GONE;
if (showRequiredIndicator) {
for (int i = 0; i < mFieldViews.size(); i++) {
if (mFieldViews.get(i).isRequired()) {
requiredFieldsNoticeVisibility = View.VISIBLE;
break;
}
}
}
requiredFieldsNotice.setVisibility(requiredFieldsNoticeVisibility);
}
public void setAllowDelete(boolean allowDelete) {
EditorDialogToolbar toolbar = mContainerView.findViewById(R.id.action_bar);
toolbar.setShowDeleteMenuItem(allowDelete);
}
public void setDeleteRunnable(Runnable deleteRunnable) {
mDeleteRunnable = deleteRunnable;
}
public void setDoneRunnable(Runnable doneRunnable) {
mDoneRunnable = doneRunnable;
setDoneRunnableToFields(doneRunnable);
}
private void setDoneRunnableToFields(Runnable doneRunnable) {
for (FieldView view : mFieldViews) {
if (view instanceof TextFieldView) {
((TextFieldView) view).setDoneRunnable(doneRunnable);
}
}
}
public void setCancelRunnable(Runnable cancelRunnable) {
mCancelRunnable = cancelRunnable;
}
public void setValidateOnShow(boolean validateOnShow) {
mValidateOnShow = validateOnShow;
}
public void setVisible(boolean visible) {
if (visible) {
showDialog();
} else {
animateOutDialog();
}
}
public void setEditorFields(
ListModel<FieldItem> editorFields, boolean shouldShowRequiredIndicator) {
prepareEditor(editorFields, shouldShowRequiredIndicator);
}
/** Prevents screenshots of this editor. */
public void disableScreenshots() {
WindowManager.LayoutParams attributes = getWindow().getAttributes();
attributes.flags |= WindowManager.LayoutParams.FLAG_SECURE;
getWindow().setAttributes(attributes);
}
/**
* Prepares the toolbar for use.
*
* Many of the things that would ideally be set as attributes don't work and need to be set
* programmatically. This is likely due to how we compile the support libraries.
*/
private void prepareToolbar() {
EditorDialogToolbar toolbar =
(EditorDialogToolbar) mContainerView.findViewById(R.id.action_bar);
toolbar.setBackgroundColor(SemanticColorUtils.getDefaultBgColor(toolbar.getContext()));
toolbar.setTitleTextAppearance(
toolbar.getContext(), R.style.TextAppearance_Headline_Primary);
// Show the help article when the help icon is clicked on, or delete
// the profile and go back when the delete icon is clicked on.
toolbar.setOnMenuItemClickListener(
item -> {
if (item.getItemId() == R.id.delete_menu_id) {
if (mDeleteConfirmationTitle != null || mDeleteConfirmationText != null) {
handleDeleteWithConfirmation(
mDeleteConfirmationTitle, mDeleteConfirmationText);
} else {
handleDelete();
}
} else if (item.getItemId() == R.id.help_menu_id) {
HelpAndFeedbackLauncherFactory.getForProfile(mProfile)
.show(
mActivity,
mActivity.getString(R.string.help_context_autofill),
null);
}
return true;
});
// Cancel editing when the user hits the back arrow.
toolbar.setNavigationContentDescription(R.string.cancel);
toolbar.setNavigationIcon(getTintedBackIcon());
toolbar.setNavigationOnClickListener(view -> mCancelRunnable.run());
// The top shadow is handled by the toolbar, so hide the one used in the field editor.
FadingEdgeScrollView scrollView =
(FadingEdgeScrollView) mContainerView.findViewById(R.id.scroll_view);
scrollView.setEdgeVisibility(EdgeType.NONE, EdgeType.FADING);
// The shadow's top margin doesn't get picked up in the xml; set it programmatically.
View shadow = mContainerView.findViewById(R.id.shadow);
LayoutParams params = (LayoutParams) shadow.getLayoutParams();
params.topMargin = toolbar.getLayoutParams().height;
shadow.setLayoutParams(params);
scrollView
.getViewTreeObserver()
.addOnScrollChangedListener(
SettingsUtils.getShowShadowOnScrollListener(scrollView, shadow));
}
/** @return The validatable item for the given view. */
@Nullable
private FieldView getTextFieldView(View v) {
if (v instanceof TextView && v.getParent() != null && v.getParent() instanceof FieldView) {
return (FieldView) v.getParent();
} else if (v instanceof Spinner && v.getTag() != null) {
return (FieldView) v.getTag();
} else {
return null;
}
}
@Override
public void onClick(View view) {
// Disable interaction during animation.
if (mDialogInOutAnimator != null) return;
if (view.getId() == R.id.editor_dialog_done_button) {
mDoneRunnable.run();
} else if (view.getId() == R.id.payments_edit_cancel_button) {
mCancelRunnable.run();
}
}
private void animateOutDialog() {
if (mDialogInOutAnimator != null || !isShowing()) return;
if (getCurrentFocus() != null) {
KeyboardVisibilityDelegate.getInstance().hideKeyboard(getCurrentFocus());
}
Animator dropDown =
ObjectAnimator.ofFloat(
mContainerView, View.TRANSLATION_Y, 0f, mContainerView.getHeight());
Animator fadeOut =
ObjectAnimator.ofFloat(mContainerView, View.ALPHA, mContainerView.getAlpha(), 0f);
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.playTogether(dropDown, fadeOut);
mDialogInOutAnimator = animatorSet;
mDialogInOutAnimator.setDuration(DIALOG_EXIT_ANIMATION_MS);
mDialogInOutAnimator.setInterpolator(Interpolators.FAST_OUT_LINEAR_IN_INTERPOLATOR);
mDialogInOutAnimator.addListener(
new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
mDialogInOutAnimator = null;
dismiss();
}
});
mDialogInOutAnimator.start();
}
public void setAsNotDismissed() {
mIsDismissed = false;
}
public boolean isDismissed() {
return mIsDismissed;
}
@Override
public void onDismiss(DialogInterface dialog) {
mIsDismissed = true;
removeTextChangedListeners();
}
private void prepareButtons() {
mDoneButton = mFooter.findViewById(R.id.editor_dialog_done_button);
mDoneButton.setOnClickListener(this);
Button cancelButton = mFooter.findViewById(R.id.payments_edit_cancel_button);
cancelButton.setOnClickListener(this);
}
/**
* Create the visual representation of the PropertyModel defined by {@link EditorProperties}.
*
* This would be more optimal as a RelativeLayout, but because it's dynamically generated, it's
* much more human-parsable with inefficient LinearLayouts for half-width controls sharing rows.
*
* @param editorFields the list of fields this editor should display.
*/
private void prepareEditor(ListModel<FieldItem> editorFields, boolean showRequiredIndicator) {
// Ensure the layout is empty.
removeTextChangedListeners();
mContentView.removeAllViews();
mFieldViews.clear();
mTextFieldMCPs.forEach(PropertyModelChangeProcessor::destroy);
mDropdownFieldMCPs.forEach(PropertyModelChangeProcessor::destroy);
mTextFieldMCPs.clear();
mDropdownFieldMCPs.clear();
mEditableTextFields.clear();
mDropdownFields.clear();
// Add Views for each of the {@link EditorFields}.
for (int i = 0; i < editorFields.size(); i++) {
FieldItem fieldItem = editorFields.get(i);
FieldItem nextFieldItem = null;
boolean isLastField = i == editorFields.size() - 1;
boolean useFullLine = fieldItem.isFullLine;
if (!isLastField && !useFullLine) {
// If the next field isn't full, stretch it out.
nextFieldItem = editorFields.get(i + 1);
if (nextFieldItem.isFullLine) useFullLine = true;
}
// Always keep dropdowns and text fields on different lines because of height
// differences.
if (!isLastField
&& !useFullLine
&& isDropdownField(fieldItem) != isDropdownField(nextFieldItem)) {
useFullLine = true;
}
if (useFullLine || isLastField) {
addFieldViewToEditor(mContentView, fieldItem, showRequiredIndicator);
} else {
// Create a LinearLayout to put it and the next view side by side.
LinearLayout rowLayout = new LinearLayout(mActivity);
mContentView.addView(rowLayout);
View firstView = addFieldViewToEditor(rowLayout, fieldItem, showRequiredIndicator);
View lastView =
addFieldViewToEditor(rowLayout, nextFieldItem, showRequiredIndicator);
LinearLayout.LayoutParams firstParams =
(LinearLayout.LayoutParams) firstView.getLayoutParams();
LinearLayout.LayoutParams lastParams =
(LinearLayout.LayoutParams) lastView.getLayoutParams();
firstParams.width = 0;
firstParams.weight = 1;
MarginLayoutParamsCompat.setMarginEnd(firstParams, mHalfRowMargin);
lastParams.width = 0;
lastParams.weight = 1;
i = i + 1;
}
}
setDoneRunnableToFields(mDoneRunnable);
// Add the footer.
mContentView.addView(mFooter);
setShowRequiredIndicator(showRequiredIndicator);
}
/**
* When this layout has a wide display style, it will be width constrained to
* {@link UiConfig#WIDE_DISPLAY_STYLE_MIN_WIDTH_DP}. If the current screen width is greater than
* UiConfig#WIDE_DISPLAY_STYLE_MIN_WIDTH_DP, the settings layout will be visually centered
* by adding padding to both sides.
*/
public void onConfigurationChanged() {
if (mUiConfig == null) {
int minWidePaddingPixels =
mActivity
.getResources()
.getDimensionPixelSize(R.dimen.settings_wide_display_min_padding);
mUiConfig = new UiConfig(mContentView);
ViewResizer.createAndAttach(mContentView, mUiConfig, 0, minWidePaddingPixels);
} else {
mUiConfig.updateDisplayStyle();
}
}
private void removeTextChangedListeners() {
for (FieldView view : mFieldViews) {
if (view instanceof TextFieldView) {
TextFieldView textView = (TextFieldView) view;
textView.removeTextChangedListeners();
}
}
}
private View addFieldViewToEditor(
ViewGroup parent, final FieldItem fieldItem, boolean showRequiredIndicator) {
View childView = null;
switch (fieldItem.type) {
case DROPDOWN:
{
DropdownFieldView dropdownView =
new DropdownFieldView(mActivity, parent, fieldItem.model);
mDropdownFieldMCPs.add(
PropertyModelChangeProcessor.create(
fieldItem.model,
dropdownView,
EditorDialogViewBinder::bindDropdownFieldView));
mFieldViews.add(dropdownView);
mDropdownFields.add(dropdownView.getDropdown());
childView = dropdownView.getLayout();
break;
}
case TEXT_INPUT:
{
TextFieldView inputLayout = new TextFieldView(mActivity, fieldItem.model);
mTextFieldMCPs.add(
PropertyModelChangeProcessor.create(
fieldItem.model,
inputLayout,
EditorDialogViewBinder::bindTextFieldView));
mFieldViews.add(inputLayout);
mEditableTextFields.add(inputLayout.getEditText());
childView = inputLayout;
break;
}
}
parent.addView(childView);
return childView;
}
/** Displays the editor user interface for the given model. */
private void showDialog() {
// If an asynchronous task calls show, while the activity is already finishing, return.
if (mActivity.isFinishing()) return;
onConfigurationChanged();
// Temporarily hide the content to avoid blink before animation starts.
mContainerView.setVisibility(View.INVISIBLE);
show();
}
@Override
public void onShow(DialogInterface dialog) {
if (mDialogInOutAnimator != null && mIsDismissed) return;
// Hide keyboard and disable EditText views for animation efficiency.
if (getCurrentFocus() != null) {
KeyboardVisibilityDelegate.getInstance().hideKeyboard(getCurrentFocus());
}
for (int i = 0; i < mEditableTextFields.size(); i++) {
mEditableTextFields.get(i).setEnabled(false);
}
mContainerView.setVisibility(View.VISIBLE);
mContainerView.setLayerType(View.LAYER_TYPE_HARDWARE, null);
mContainerView.buildLayer();
Animator popUp =
ObjectAnimator.ofFloat(
mContainerView, View.TRANSLATION_Y, mContainerView.getHeight(), 0f);
Animator fadeIn = ObjectAnimator.ofFloat(mContainerView, View.ALPHA, 0f, 1f);
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.playTogether(popUp, fadeIn);
mDialogInOutAnimator = animatorSet;
mDialogInOutAnimator.setDuration(DIALOG_ENTER_ANIMATION_MS);
mDialogInOutAnimator.setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN_INTERPOLATOR);
mDialogInOutAnimator.addListener(
new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
mContainerView.setLayerType(View.LAYER_TYPE_NONE, null);
for (int i = 0; i < mEditableTextFields.size(); i++) {
mEditableTextFields.get(i).setEnabled(true);
}
mDialogInOutAnimator = null;
initFocus();
}
});
mDialogInOutAnimator.start();
}
private void initFocus() {
mHandler.post(
() -> {
List<FieldView> invalidViews = new ArrayList<>();
if (mValidateOnShow) {
invalidViews =
mFieldViews.stream()
.filter(view -> !view.validate())
.collect(Collectors.toList());
}
// If TalkBack is enabled, we want to keep the focus at the top
// because the user would not learn about the elements that are
// above the focused field.
if (!ChromeAccessibilityUtil.get().isAccessibilityEnabled()) {
if (!invalidViews.isEmpty()) {
// Immediately focus the first invalid field to make it faster to edit.
invalidViews.get(0).scrollToAndFocus();
} else {
// Trigger default focus as it is not triggered automatically on Android
// P+.
mContainerView.requestFocus();
}
}
// Note that keyboard will not be shown for dropdown field since it's not
// necessary.
if (getCurrentFocus() != null) {
KeyboardVisibilityDelegate.getInstance().showKeyboard(getCurrentFocus());
// Put the cursor to the end of the text.
if (getCurrentFocus() instanceof EditText) {
EditText focusedEditText = (EditText) getCurrentFocus();
focusedEditText.setSelection(focusedEditText.getText().length());
}
}
if (sObserverForTest != null) sObserverForTest.onEditorReadyToEdit();
});
}
private void handleDelete() {
assert mDeleteRunnable != null;
mDeleteRunnable.run();
animateOutDialog();
}
private void handleDeleteWithConfirmation(
@Nullable String confirmationTitle, @Nullable String confirmationText) {
LayoutInflater inflater = LayoutInflater.from(getContext());
View body = inflater.inflate(R.layout.confirmation_dialog_view, null);
TextView titleView = body.findViewById(R.id.confirmation_dialog_title);
titleView.setText(confirmationTitle);
TextView messageView = body.findViewById(R.id.confirmation_dialog_message);
messageView.setText(confirmationText);
mConfirmationDialog =
new AlertDialog.Builder(getContext(), R.style.ThemeOverlay_BrowserUI_AlertDialog)
.setView(body)
.setNegativeButton(
R.string.cancel,
(dialog, which) -> {
dialog.cancel();
mConfirmationDialog = null;
if (sObserverForTest != null) {
sObserverForTest.onEditorReadyToEdit();
}
})
.setPositiveButton(
R.string.delete,
(dialog, which) -> {
handleDelete();
mConfirmationDialog = null;
})
.create();
mConfirmationDialog.show();
if (sObserverForTest != null) {
sObserverForTest.onEditorConfirmationDialogShown();
}
}
/** @return The View with all fields of this editor. */
public View getContentViewForTest() {
return mContentView;
}
/** @return All editable text fields in the editor. Used only for tests. */
public List<EditText> getEditableTextFieldsForTest() {
return mEditableTextFields;
}
/** @return All dropdown fields in the editor. Used only for tests. */
public List<Spinner> getDropdownFieldsForTest() {
return mDropdownFields;
}
public AlertDialog getConfirmationDialogForTest() {
return mConfirmationDialog;
}
public static void setEditorObserverForTest(EditorObserverForTest observerForTest) {
sObserverForTest = observerForTest;
DropdownFieldView.setEditorObserverForTest(sObserverForTest);
TextFieldView.setEditorObserverForTest(sObserverForTest);
}
private Drawable getTintedBackIcon() {
return TintedDrawable.constructTintedDrawable(
getContext(),
R.drawable.ic_arrow_back_white_24dp,
R.color.default_icon_color_tint_list);
}
}