blob: a0a98a2b3b33ec23f100a29c425a2d070dd493a8 [file] [log] [blame]
// Copyright 2018 The Chromium Authors. All rights reserved.
// 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_assistant.details;
import static org.chromium.chrome.browser.autofill_assistant.AssistantAccessibilityUtils.setAccessibility;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ArgbEvaluator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.media.ThumbnailUtils;
import android.text.TextUtils;
import android.util.TypedValue;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.StyleRes;
import androidx.core.graphics.drawable.RoundedBitmapDrawable;
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.chrome.autofill_assistant.R;
import org.chromium.chrome.browser.autofill_assistant.AssistantTextUtils;
import org.chromium.chrome.browser.customtabs.CustomTabActivity;
import org.chromium.chrome.browser.image_fetcher.ImageFetcher;
import org.chromium.components.browser_ui.modaldialog.AppModalPresenter;
import org.chromium.components.browser_ui.widget.animation.Interpolators;
import org.chromium.ui.modaldialog.DialogDismissalCause;
import org.chromium.ui.modaldialog.ModalDialogManager;
import org.chromium.ui.modaldialog.ModalDialogManager.ModalDialogType;
import org.chromium.ui.modaldialog.ModalDialogProperties;
import org.chromium.ui.modelutil.PropertyKey;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.modelutil.PropertyModelChangeProcessor;
/**
* This class is responsible for pushing updates to the Autofill Assistant details view. These
* updates are pulled from the {@link AssistantDetailsModel} when a notification of an update is
* received.
*/
class AssistantDetailsViewBinder
implements PropertyModelChangeProcessor.ViewBinder<AssistantDetailsModel,
AssistantDetailsViewBinder.ViewHolder, PropertyKey> {
private static final int IMAGE_BORDER_RADIUS = 8;
private static final int PULSING_DURATION_MS = 1_000;
/**
* A wrapper class that holds the different views of the header.
*/
static class ViewHolder {
final GradientDrawable mDefaultImage;
final ImageView mImageView;
final TextView mTitleView;
final TextView mDescriptionLine1View;
final TextView mDescriptionLine2View;
final TextView mDescriptionLine3View;
final TextView mPriceAttributionView;
final View mPriceView;
final TextView mTotalPriceLabelView;
final TextView mTotalPriceView;
ViewHolder(Context context, View detailsView) {
mDefaultImage = (GradientDrawable) context.getResources().getDrawable(
R.drawable.autofill_assistant_default_details);
mImageView = detailsView.findViewById(R.id.details_image);
mTitleView = detailsView.findViewById(R.id.details_title);
mDescriptionLine1View = detailsView.findViewById(R.id.details_line1);
mDescriptionLine2View = detailsView.findViewById(R.id.details_line2);
mDescriptionLine3View = detailsView.findViewById(R.id.details_line3);
mPriceAttributionView = detailsView.findViewById(R.id.details_price_attribution);
mPriceView = detailsView.findViewById(R.id.details_price);
mTotalPriceView = detailsView.findViewById(R.id.details_total_price);
mTotalPriceLabelView = detailsView.findViewById(R.id.details_total_price_label);
}
}
private final Context mContext;
private final int mImageWidth;
private final int mImageHeight;
private final int mPulseAnimationStartColor;
private final int mPulseAnimationEndColor;
private ValueAnimator mPulseAnimation;
private ImageFetcher mImageFetcher;
AssistantDetailsViewBinder(Context context, ImageFetcher imageFetcher) {
mContext = context;
mImageWidth = context.getResources().getDimensionPixelSize(
R.dimen.autofill_assistant_details_image_size);
mImageHeight = context.getResources().getDimensionPixelSize(
R.dimen.autofill_assistant_details_image_size);
mPulseAnimationStartColor = context.getResources().getColor(R.color.modern_grey_300);
mPulseAnimationEndColor = context.getResources().getColor(R.color.modern_grey_200);
mImageFetcher = imageFetcher;
}
/**
* Cleanup resources when this goes out of scope.
*/
void destroy() {
mImageFetcher.destroy();
mImageFetcher = null;
}
@Override
public void bind(AssistantDetailsModel model, ViewHolder view, PropertyKey propertyKey) {
if (AssistantDetailsModel.DETAILS == propertyKey) {
AssistantDetails details = model.get(AssistantDetailsModel.DETAILS);
if (details == null) {
// Handled by the AssistantDetailsCoordinator.
return;
}
setDetails(details, view);
} else {
assert false : "Unhandled property detected in AssistantDetailsViewBinder!";
}
}
private void setDetails(AssistantDetails details, ViewHolder viewHolder) {
AssistantTextUtils.applyVisualAppearanceTags(
viewHolder.mTitleView, details.getTitle(), null);
AssistantTextUtils.applyVisualAppearanceTags(
viewHolder.mDescriptionLine1View, details.getDescriptionLine1(), null);
AssistantTextUtils.applyVisualAppearanceTags(
viewHolder.mDescriptionLine2View, details.getDescriptionLine2(), null);
AssistantTextUtils.applyVisualAppearanceTags(
viewHolder.mDescriptionLine3View, details.getDescriptionLine3(), null);
AssistantTextUtils.applyVisualAppearanceTags(
viewHolder.mTotalPriceLabelView, details.getTotalPriceLabel(), null);
AssistantTextUtils.applyVisualAppearanceTags(
viewHolder.mTotalPriceView, details.getTotalPrice(), null);
AssistantTextUtils.applyVisualAppearanceTags(
viewHolder.mPriceAttributionView, details.getPriceAttribution(), null);
// Allow title line wrapping according to number of maximum allowed lines.
if (details.getTitleMaxLines() == 1) {
viewHolder.mTitleView.setSingleLine(true);
viewHolder.mTitleView.setEllipsize(null);
} else {
viewHolder.mTitleView.setSingleLine(false);
viewHolder.mTitleView.setMaxLines(details.getTitleMaxLines());
viewHolder.mTitleView.setEllipsize(TextUtils.TruncateAt.END);
}
hideIfEmpty(viewHolder.mDescriptionLine1View);
hideIfEmpty(viewHolder.mDescriptionLine2View);
hideIfEmpty(viewHolder.mDescriptionLine3View);
hideIfEmpty(viewHolder.mPriceAttributionView);
// If no price provided, hide the price view (containing separator, price label, and price).
viewHolder.mPriceView.setVisibility(
details.getTotalPrice().isEmpty() ? View.GONE : View.VISIBLE);
viewHolder.mImageView.setVisibility(View.VISIBLE);
setAccessibility(viewHolder.mImageView, details.getImageAccessibilityHint());
if (details.getImageUrl().isEmpty()) {
if (details.getShowImagePlaceholder()) {
viewHolder.mImageView.setImageDrawable(viewHolder.mDefaultImage);
viewHolder.mImageView.setOnClickListener(null);
} else {
viewHolder.mImageView.setVisibility(View.GONE);
}
} else {
// Download image and then set it in the view.
ImageFetcher.Params params = ImageFetcher.Params.create(
details.getImageUrl(), ImageFetcher.ASSISTANT_DETAILS_UMA_CLIENT_NAME);
mImageFetcher.fetchImage(
params, image -> {
if (image != null) {
viewHolder.mImageView.setImageDrawable(getRoundedImage(image));
if (details.hasImageClickthroughData()
&& details.getImageClickthroughData().getAllowClickthrough()) {
viewHolder.mImageView.setOnClickListener(unusedView
-> onImageClicked(mContext, details.getImageUrl(),
details.getImageClickthroughData()));
} else {
viewHolder.mImageView.setOnClickListener(null);
}
}
});
}
setTextStyles(details, viewHolder);
}
private void hideIfEmpty(TextView view) {
view.setVisibility(view.length() == 0 ? View.GONE : View.VISIBLE);
}
private void setTextStyles(AssistantDetails details, ViewHolder viewHolder) {
setTextStyle(viewHolder.mTitleView, details.getUserApprovalRequired(),
details.getHighlightTitle(), R.style.TextAppearance_AssistantDetailsTitle);
setTextStyle(viewHolder.mDescriptionLine1View, details.getUserApprovalRequired(),
details.getHighlightLine1(), R.style.TextAppearance_TextMedium_Secondary);
setTextStyle(viewHolder.mDescriptionLine2View, details.getUserApprovalRequired(),
details.getHighlightLine2(), R.style.TextAppearance_TextMedium_Secondary);
// TODO(crbug.com/1118226): Update the styles that use *_Disabled with UX guidance.
setTextStyle(viewHolder.mDescriptionLine3View, details.getUserApprovalRequired(),
details.getHighlightLine3(), R.style.TextAppearance_TextSmall_Secondary);
setTextStyle(viewHolder.mPriceAttributionView, details.getUserApprovalRequired(),
details.getHighlightLine3(), R.style.TextAppearance_TextSmall_Secondary);
setTextStyle(viewHolder.mTotalPriceLabelView, details.getUserApprovalRequired(),
/* highlight= */ false, R.style.TextAppearance_TextMedium_Secondary);
setTextStyle(viewHolder.mTotalPriceView, details.getUserApprovalRequired(),
/* highlight= */ false, R.style.TextAppearance_AssistantDetailsPrice);
if (shouldStartOrContinuePlaceholderAnimation(details, viewHolder)) {
startOrContinuePlaceholderAnimations(viewHolder);
} else {
stopPlaceholderAnimations();
}
}
private boolean shouldStartOrContinuePlaceholderAnimation(
AssistantDetails details, ViewHolder viewHolder) {
boolean isAtLeastOneFieldEmpty = viewHolder.mTitleView.length() == 0
|| viewHolder.mDescriptionLine1View.length() == 0
|| viewHolder.mDescriptionLine2View.length() == 0
|| viewHolder.mDescriptionLine3View.length() == 0
|| viewHolder.mImageView.getDrawable() == viewHolder.mDefaultImage;
return details.getAnimatePlaceholders() && isAtLeastOneFieldEmpty;
}
private void setTextStyle(
TextView view, boolean approvalRequired, boolean highlight, @StyleRes int normalStyle) {
ApiCompatibilityUtils.setTextAppearance(view, normalStyle);
if (approvalRequired && highlight) {
// Emphasized style.
view.setTypeface(view.getTypeface(), Typeface.BOLD_ITALIC);
} else if (approvalRequired) {
// De-emphasized style.
// TODO(b/154592651) Use setTextAppearance instead of setTextColor.
view.setTextColor(ApiCompatibilityUtils.getColor(
mContext.getResources(), R.color.modern_grey_300));
}
}
private Drawable getRoundedImage(Bitmap bitmap) {
RoundedBitmapDrawable roundedBitmap =
RoundedBitmapDrawableFactory.create(mContext.getResources(),
ThumbnailUtils.extractThumbnail(bitmap, mImageWidth, mImageHeight));
roundedBitmap.setCornerRadius(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
IMAGE_BORDER_RADIUS, mContext.getResources().getDisplayMetrics()));
return roundedBitmap;
}
private void startOrContinuePlaceholderAnimations(ViewHolder viewHolder) {
if (mPulseAnimation != null) {
return;
}
mPulseAnimation = ValueAnimator.ofInt(mPulseAnimationStartColor, mPulseAnimationEndColor);
mPulseAnimation.setDuration(PULSING_DURATION_MS);
mPulseAnimation.setEvaluator(new ArgbEvaluator());
mPulseAnimation.setRepeatCount(ValueAnimator.INFINITE);
mPulseAnimation.setRepeatMode(ValueAnimator.REVERSE);
mPulseAnimation.setInterpolator(Interpolators.ACCELERATE_INTERPOLATOR);
mPulseAnimation.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationCancel(Animator animation) {
viewHolder.mTitleView.setBackgroundColor(Color.TRANSPARENT);
viewHolder.mDescriptionLine1View.setBackgroundColor(Color.TRANSPARENT);
viewHolder.mDescriptionLine2View.setBackgroundColor(Color.TRANSPARENT);
viewHolder.mDescriptionLine3View.setBackgroundColor(Color.TRANSPARENT);
viewHolder.mDefaultImage.setColor(Color.TRANSPARENT);
}
});
mPulseAnimation.addUpdateListener(animation -> {
int animatedValue = (int) animation.getAnimatedValue();
viewHolder.mTitleView.setBackgroundColor(
viewHolder.mTitleView.length() == 0 ? animatedValue : Color.TRANSPARENT);
viewHolder.mDescriptionLine1View.setBackgroundColor(
viewHolder.mDescriptionLine1View.length() == 0 ? animatedValue
: Color.TRANSPARENT);
viewHolder.mDescriptionLine2View.setBackgroundColor(
viewHolder.mDescriptionLine2View.length() == 0 ? animatedValue
: Color.TRANSPARENT);
viewHolder.mDescriptionLine3View.setBackgroundColor(
viewHolder.mDescriptionLine3View.length() == 0 ? animatedValue
: Color.TRANSPARENT);
viewHolder.mDefaultImage.setColor(
viewHolder.mImageView.getDrawable() == viewHolder.mDefaultImage
? animatedValue
: Color.TRANSPARENT);
});
mPulseAnimation.start();
}
private void stopPlaceholderAnimations() {
if (mPulseAnimation != null) {
mPulseAnimation.cancel();
mPulseAnimation = null;
}
}
/**
* Clicking on the image will trigger a modal dialog asking whether the user wants to
* see the original image, if they choose to see it, a new custom tab pointing to the
* url of the orinial image will present on top of current one.
*/
private void onImageClicked(
Context context, String imageUrl, ImageClickthroughData clickthroughData) {
ModalDialogManager manager = new ModalDialogManager(
new AppModalPresenter((android.app.Activity) context), ModalDialogType.APP);
// Handles 'View' and 'Cancel' actions from modal dialog.
ModalDialogProperties.Controller dialogController = new ModalDialogProperties.Controller() {
@Override
public void onClick(PropertyModel model, int buttonType) {
if (buttonType == ModalDialogProperties.ButtonType.NEGATIVE) {
manager.dismissDialog(model, DialogDismissalCause.NEGATIVE_BUTTON_CLICKED);
} else {
manager.dismissDialog(model, DialogDismissalCause.POSITIVE_BUTTON_CLICKED);
String presentUrl = clickthroughData.getClickthroughUrl().isEmpty()
? imageUrl
: clickthroughData.getClickthroughUrl();
CustomTabActivity.showInfoPage(context.getApplicationContext(), presentUrl);
}
}
@Override
public void onDismiss(PropertyModel model, int dismissalCause) {}
};
Resources resources = context.getResources();
PropertyModel.Builder builder =
new PropertyModel.Builder(ModalDialogProperties.ALL_KEYS)
.with(ModalDialogProperties.CONTROLLER, dialogController);
if (!clickthroughData.getDescription().isEmpty()) {
builder.with(ModalDialogProperties.MESSAGE, clickthroughData.getDescription());
} else {
builder.with(ModalDialogProperties.MESSAGE, resources,
R.string.autofill_assistant_view_original_image_desc);
}
if (!clickthroughData.getPositiveText().isEmpty()) {
builder.with(
ModalDialogProperties.POSITIVE_BUTTON_TEXT, clickthroughData.getPositiveText());
} else {
builder.with(ModalDialogProperties.POSITIVE_BUTTON_TEXT, resources,
R.string.autofill_assistant_view_original_image_view);
}
if (!clickthroughData.getNegativeText().isEmpty()) {
builder.with(
ModalDialogProperties.NEGATIVE_BUTTON_TEXT, clickthroughData.getNegativeText());
} else {
builder.with(ModalDialogProperties.NEGATIVE_BUTTON_TEXT, resources,
R.string.autofill_assistant_view_original_image_cancel);
}
PropertyModel dialogModel = builder.build();
manager.showDialog(dialogModel, ModalDialogType.APP);
}
}