chromium/components/infobars/android/java/src/org/chromium/components/infobars/InfoBarLayout.java

// Copyright 2013 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.components.infobars;

import android.content.Context;
import android.content.res.ColorStateList;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.TextView;

import androidx.annotation.ColorRes;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.widget.ImageViewCompat;

import org.chromium.components.browser_ui.widget.DualControlLayout;
import org.chromium.components.browser_ui.widget.DualControlLayout.ButtonType;
import org.chromium.ui.text.NoUnderlineClickableSpan;
import org.chromium.ui.widget.ButtonCompat;
import org.chromium.ui.widget.ChromeImageButton;
import org.chromium.ui.widget.ChromeImageView;

import java.util.ArrayList;
import java.util.List;

/**
 * Layout that arranges an infobar's views.
 *
 * An InfoBarLayout consists of:
 * - A message describing why the infobar is being displayed.
 * - A close button in the top right corner.
 * - (optional) An icon representing the infobar's purpose in the top left corner.
 * - (optional) Additional {@link InfoBarControlLayouts} for specialized controls (e.g. spinners).
 * - (optional) One or two buttons with text at the bottom, or a button paired with an ImageView.
 *
 * When adding custom views, widths and heights defined in the LayoutParams will be ignored.
 * Setting a minimum width using {@link View#setMinimumWidth()} will be obeyed.
 *
 * Logic for what happens when things are clicked should be implemented by the
 * InfoBarInteractionHandler.
 */
public final class InfoBarLayout extends ViewGroup implements View.OnClickListener {
    /** Parameters used for laying out children. */
    private static class LayoutParams extends ViewGroup.LayoutParams {
        public int startMargin;
        public int endMargin;
        public int topMargin;
        public int bottomMargin;

        // Where this view will be laid out. Calculated in onMeasure() and used in onLayout().
        public int start;
        public int top;

        LayoutParams(int startMargin, int topMargin, int endMargin, int bottomMargin) {
            super(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
            this.startMargin = startMargin;
            this.topMargin = topMargin;
            this.endMargin = endMargin;
            this.bottomMargin = bottomMargin;
        }
    }

    private final int mSmallIconSize;
    private final int mSmallIconMargin;
    private final int mMarginAboveButtonGroup;
    private final int mMarginAboveControlGroups;
    private final int mPadding;
    private final int mMinWidth;

    private final InfoBarInteractionHandler mInfoBar;
    private final ImageButton mCloseButton;
    private final InfoBarControlLayout mMessageLayout;
    private final List<InfoBarControlLayout> mControlLayouts;
    private ViewGroup mFooterViewGroup;

    private TextView mMessageTextView;
    private ImageView mIconView;
    private DualControlLayout mButtonRowLayout;

    private CharSequence mMessageMainText;
    private String mMessageLinkText;
    private int mMessageInlineLinkRangeStart;
    private int mMessageInlineLinkRangeEnd;

    /**
     * Constructs a layout for the specified infobar. After calling this, be sure to set the
     * message, the buttons, and/or the custom content using setMessage(), setButtons(), and
     * setCustomContent().
     * @param context The context used to render.
     * @param infoBar InfoBarInteractionHandler that listens to events.
     * @param iconResourceId ID of the icon to use for the infobar.
     * @param iconTintId The {@link ColorRes} used as tint for {@code iconResourceId}.
     * @param iconBitmap Bitmap for the icon to use, if the resource ID wasn't passed through.
     * @param message The message to show in the infobar.
     */
    public InfoBarLayout(
            Context context,
            InfoBarInteractionHandler infoBar,
            int iconResourceId,
            @ColorRes int iconTintId,
            Bitmap iconBitmap,
            CharSequence message) {
        super(context);
        mControlLayouts = new ArrayList<InfoBarControlLayout>();

        mInfoBar = infoBar;

        // Cache resource values.
        Resources res = getResources();
        mSmallIconSize = res.getDimensionPixelSize(R.dimen.infobar_small_icon_size);
        mSmallIconMargin = res.getDimensionPixelSize(R.dimen.infobar_small_icon_margin);
        mMarginAboveButtonGroup =
                res.getDimensionPixelSize(R.dimen.infobar_margin_above_button_row);
        mMarginAboveControlGroups =
                res.getDimensionPixelSize(R.dimen.infobar_margin_above_control_groups);
        mPadding = res.getDimensionPixelOffset(R.dimen.infobar_padding);
        mMinWidth = res.getDimensionPixelSize(R.dimen.infobar_min_width);

        // Set up the close button. Apply padding so it has a big touch target.
        mCloseButton = createCloseButton(context);
        mCloseButton.setOnClickListener(this);
        mCloseButton.setPadding(mPadding, mPadding, mPadding, mPadding);
        mCloseButton.setLayoutParams(new LayoutParams(0, -mPadding, -mPadding, -mPadding));

        // Set up the icon, if necessary.
        mIconView = createIconView(context, iconResourceId, iconTintId, iconBitmap);
        if (mIconView != null) {
            mIconView.setLayoutParams(new LayoutParams(0, 0, mSmallIconMargin, 0));
            mIconView.getLayoutParams().width = mSmallIconSize;
            mIconView.getLayoutParams().height = mSmallIconSize;
        }

        // Set up the message view.
        mMessageMainText = message;
        mMessageLayout = new InfoBarControlLayout(context);
        mMessageTextView = mMessageLayout.addMainMessage(prepareMainMessageString());
    }

    /**
     * Returns the {@link TextView} corresponding to the main infobar message.
     * The returned view is a part of internal layout strucutre and shouldn't be accessed by InfoBar
     * implementations.
     */
    public TextView getMessageTextView() {
        return mMessageTextView;
    }

    /**
     * Returns the {@link InfoBarControlLayout} containing the TextView showing the main infobar
     * message and associated controls, which is sandwiched between its icon and close button.
     * The returned view is a part of internal layout strucutre and shouldn't be accessed by InfoBar
     * implementations.
     */
    public InfoBarControlLayout getMessageLayout() {
        return mMessageLayout;
    }

    /**
     * Sets the message to show on the infobar.
     * TODO(dfalcantara): Do some magic here to determine if TextViews need to have line spacing
     *                    manually added.  Android changed when these values were applied between
     *                    KK and L: https://crbug.com/543205
     */
    public void setMessage(CharSequence message) {
        mMessageMainText = message;
        mMessageTextView.setText(prepareMainMessageString());
    }

    /** Appends a link to the message, if an infobar requires one (e.g. "Learn more"). */
    public void appendMessageLinkText(String linkText) {
        mMessageLinkText = linkText;
        mMessageTextView.setText(prepareMainMessageString());
    }

    /**
     * Sets up the message to have an inline link, assuming an inclusive range.
     * @param rangeStart Where the link starts.
     * @param rangeEnd   Where the link ends.
     */
    public void setInlineMessageLink(int rangeStart, int rangeEnd) {
        mMessageInlineLinkRangeStart = rangeStart;
        mMessageInlineLinkRangeEnd = rangeEnd;
        mMessageTextView.setText(prepareMainMessageString());
    }

    /**
     * Adds an {@link InfoBarControlLayout} to house additional infobar controls, like toggles and
     * spinners.
     */
    public InfoBarControlLayout addControlLayout() {
        InfoBarControlLayout controlLayout = new InfoBarControlLayout(getContext());
        mControlLayouts.add(controlLayout);
        return controlLayout;
    }

    /**
     * Adds a footer at the bottom of the InfoBar which spans the InfoBar's whole width.
     *
     * @param footerView footer to be added.
     */
    public ViewGroup addFooterView(ViewGroup footerView) {
        mFooterViewGroup = footerView;
        return footerView;
    }

    /**
     * Adds one or two buttons to the layout.
     *
     * @param primaryText Text for the primary button.  If empty, no buttons are added at all.
     * @param secondaryText Text for the secondary button, or null if there isn't a second button.
     */
    public void setButtons(String primaryText, String secondaryText) {
        if (TextUtils.isEmpty(primaryText)) {
            assert TextUtils.isEmpty(secondaryText);
            return;
        }

        Button secondaryButton = null;
        if (!TextUtils.isEmpty(secondaryText)) {
            secondaryButton =
                    DualControlLayout.createButtonForLayout(
                            getContext(), ButtonType.SECONDARY_TEXT, secondaryText, this);
        }

        setBottomViews(
                primaryText, secondaryButton, DualControlLayout.DualControlLayoutAlignment.END);
    }

    /**
     * Sets up the bottom-most part of the infobar with a primary button (e.g. OK) and a secondary
     * View of your choice.  Subclasses should be calling {@link #setButtons(String, String)}
     * instead of this function in nearly all cases (that function calls this one).
     *
     * @param primaryText Text to display on the primary button.  If empty, the bottom layout is not
     *                    created.
     * @param secondaryView View that is aligned with the primary button.  May be null.
     * @param alignment One of ALIGN_START, ALIGN_APART, or ALIGN_END from
     *                  {@link DualControlLayout}.
     */
    public void setBottomViews(String primaryText, View secondaryView, int alignment) {
        assert !TextUtils.isEmpty(primaryText);
        Button primaryButton =
                DualControlLayout.createButtonForLayout(
                        getContext(), ButtonType.PRIMARY_FILLED, primaryText, this);

        assert mButtonRowLayout == null;
        mButtonRowLayout = new DualControlLayout(getContext(), null);
        mButtonRowLayout.setAlignment(alignment);
        mButtonRowLayout.setStackedMargin(
                getResources()
                        .getDimensionPixelSize(R.dimen.infobar_margin_between_stacked_buttons));

        mButtonRowLayout.addView(primaryButton);
        if (secondaryView != null) {
            mButtonRowLayout.addView(secondaryView);
        }
    }

    /** Returns the primary button, or null if it doesn't exist. */
    public ButtonCompat getPrimaryButton() {
        return mButtonRowLayout == null
                ? null
                : (ButtonCompat) mButtonRowLayout.findViewById(R.id.button_primary);
    }

    /** Returns whether or not InfoBar has a footer. */
    public boolean hasFooter() {
        return mFooterViewGroup != null;
    }

    /** Returns the icon, or null if it doesn't exist. */
    public ImageView getIcon() {
        return mIconView;
    }

    /**
     * Must be called after the message, buttons, and custom content have been set, and before the
     * first call to onMeasure().
     */
    // TODO(crbug.com/40120294): onContentCreated is made public to allow access from InfoBar. Once
    // InfoBar is modularized, restore access to package private.
    public void onContentCreated() {
        // Add the child views in the desired focus order.
        if (mIconView != null) addView(mIconView);
        addView(mMessageLayout);
        for (View v : mControlLayouts) addView(v);
        if (mButtonRowLayout != null) addView(mButtonRowLayout);
        if (mFooterViewGroup != null) addView(mFooterViewGroup);
        addView(mCloseButton);
    }

    @Override
    protected LayoutParams generateDefaultLayoutParams() {
        return new LayoutParams(0, 0, 0, 0);
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        // Place all the views in the positions already determined during onMeasure().
        int width = right - left;
        boolean isRtl = getLayoutDirection() == LAYOUT_DIRECTION_RTL;

        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            LayoutParams lp = (LayoutParams) child.getLayoutParams();
            int childLeft = lp.start;
            int childRight = lp.start + child.getMeasuredWidth();

            if (isRtl) {
                int tmp = width - childRight;
                childRight = width - childLeft;
                childLeft = tmp;
            }

            child.layout(childLeft, lp.top, childRight, lp.top + child.getMeasuredHeight());
        }
    }

    /**
     * Measures and determines where children should go.
     *
     * For current specs, see https://goto.google.com/infobar-spec
     *
     * All controls are padded from the infobar boundary by the same amount, but different types of
     * control groups are bound by different widths and have different margins:
     * --------------------------------------------------------------------------------
     * |  PADDING                                                                     |
     * |  --------------------------------------------------------------------------  |
     * |  | ICON | MESSAGE LAYOUT                                              | X |  |
     * |  |------+                                                             +---|  |
     * |  |      |                                                             |   |  |
     * |  |      ------------------------------------------------------------------|  |
     * |  |      | CONTROL LAYOUT #1                                               |  |
     * |  |      ------------------------------------------------------------------|  |
     * |  |      | CONTROL LAYOUT #X                                               |  |
     * |  |------------------------------------------------------------------------|  |
     * |  | BOTTOM ROW LAYOUT                                                      |  |
     * |  -------------------------------------------------------------------------|  |
     * |                                                                              |
     * --------------------------------------------------------------------------------
     * |  FOOTER                                                                      |
     * --------------------------------------------------------------------------------
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        assert getLayoutParams().height == LayoutParams.WRAP_CONTENT
                : "InfoBar heights cannot be constrained.";

        // Apply the padding that surrounds all the infobar controls.
        final int layoutWidth = Math.max(MeasureSpec.getSize(widthMeasureSpec), mMinWidth);
        final int paddedStart = mPadding;
        final int paddedEnd = layoutWidth - mPadding;
        int layoutBottom = mPadding;

        // Measure and place the icon in the top-left corner.
        int unspecifiedSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
        if (mIconView != null) {
            LayoutParams iconParams = getChildLayoutParams(mIconView);
            measureChild(mIconView, unspecifiedSpec, unspecifiedSpec);
            iconParams.start = paddedStart + iconParams.startMargin;
            iconParams.top = layoutBottom + iconParams.topMargin;
        }
        final int iconWidth = getChildWidthWithMargins(mIconView);

        // Measure and place the close button in the top-right corner of the layout.
        LayoutParams closeParams = getChildLayoutParams(mCloseButton);
        measureChild(mCloseButton, unspecifiedSpec, unspecifiedSpec);
        closeParams.start = paddedEnd - closeParams.endMargin - mCloseButton.getMeasuredWidth();
        closeParams.top = layoutBottom + closeParams.topMargin;

        // Determine how much width is available for all the different control layouts; see the
        // function JavaDoc above for details.
        final int paddedWidth = paddedEnd - paddedStart;
        final int controlLayoutWidth = paddedWidth - iconWidth;
        final int messageWidth = controlLayoutWidth - getChildWidthWithMargins(mCloseButton);

        // The message layout is sandwiched between the icon and the close button.
        LayoutParams messageParams = getChildLayoutParams(mMessageLayout);
        measureChildWithFixedWidth(mMessageLayout, messageWidth);
        messageParams.start = paddedStart + iconWidth;
        messageParams.top = layoutBottom;

        // Control layouts are placed below the message layout and the close button.  The icon is
        // ignored for this particular calculation because the icon enforces a left margin on all of
        // the control layouts and won't be overlapped.
        layoutBottom +=
                Math.max(
                        getChildHeightWithMargins(mMessageLayout),
                        getChildHeightWithMargins(mCloseButton));

        // The other control layouts are constrained only by the icon's width.
        final int controlPaddedStart = paddedStart + iconWidth;
        for (int i = 0; i < mControlLayouts.size(); i++) {
            View child = mControlLayouts.get(i);
            measureChildWithFixedWidth(child, controlLayoutWidth);

            layoutBottom += mMarginAboveControlGroups;
            getChildLayoutParams(child).start = controlPaddedStart;
            getChildLayoutParams(child).top = layoutBottom;
            layoutBottom += child.getMeasuredHeight();
        }

        // The button layout takes up the full width of the infobar and sits below everything else,
        // including the icon.
        layoutBottom = Math.max(layoutBottom, getChildHeightWithMargins(mIconView));
        if (mButtonRowLayout != null) {
            measureChildWithFixedWidth(mButtonRowLayout, paddedWidth);

            layoutBottom += mMarginAboveButtonGroup;
            getChildLayoutParams(mButtonRowLayout).start = paddedStart;
            getChildLayoutParams(mButtonRowLayout).top = layoutBottom;
            layoutBottom += mButtonRowLayout.getMeasuredHeight();
        }

        // Apply padding to the bottom of the infobar.
        layoutBottom += mPadding;

        if (mFooterViewGroup != null) {
            LayoutParams footerParams = getChildLayoutParams(mFooterViewGroup);
            measureChildWithFixedWidth(mFooterViewGroup, layoutWidth);
            footerParams.start = 0;
            footerParams.top = layoutBottom;
            layoutBottom += mFooterViewGroup.getMeasuredHeight();
        }

        setMeasuredDimension(
                resolveSize(layoutWidth, widthMeasureSpec),
                resolveSize(layoutBottom, heightMeasureSpec));
    }

    private static int getChildWidthWithMargins(View view) {
        if (view == null) return 0;
        return view.getMeasuredWidth()
                + getChildLayoutParams(view).startMargin
                + getChildLayoutParams(view).endMargin;
    }

    private static int getChildHeightWithMargins(View view) {
        if (view == null) return 0;
        return view.getMeasuredHeight()
                + getChildLayoutParams(view).topMargin
                + getChildLayoutParams(view).bottomMargin;
    }

    private static LayoutParams getChildLayoutParams(View view) {
        return (LayoutParams) view.getLayoutParams();
    }

    /** Measures a child for the given space, taking into account its margins. */
    private void measureChildWithFixedWidth(View child, int width) {
        LayoutParams lp = getChildLayoutParams(child);
        int availableWidth = width - lp.startMargin - lp.endMargin;
        int widthSpec = MeasureSpec.makeMeasureSpec(availableWidth, MeasureSpec.EXACTLY);
        int heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
        child.measure(widthSpec, heightSpec);
    }

    /**
     * Listens for View clicks.
     * Classes that override this function MUST call this one.
     * @param view View that was clicked on.
     */
    @Override
    public void onClick(View view) {
        mInfoBar.onClick();

        if (view.getId() == R.id.infobar_close_button) {
            mInfoBar.onCloseButtonClicked();
        } else if (view.getId() == R.id.button_primary) {
            mInfoBar.onButtonClicked(true);
        } else if (view.getId() == R.id.button_secondary) {
            mInfoBar.onButtonClicked(false);
        }
    }

    /**
     * Prepares text to be displayed as the infobar's main message, including setting up a
     * clickable link if the infobar requires it.
     */
    private CharSequence prepareMainMessageString() {
        SpannableStringBuilder fullString = new SpannableStringBuilder();

        if (!TextUtils.isEmpty(mMessageMainText)) {
            SpannableString spannedMessage = new SpannableString(mMessageMainText);

            // If there's an inline link, apply the necessary span for it.
            if (mMessageInlineLinkRangeEnd != 0) {
                assert mMessageInlineLinkRangeStart < mMessageInlineLinkRangeEnd;
                assert mMessageInlineLinkRangeEnd < mMessageMainText.length();

                spannedMessage.setSpan(
                        createClickableSpan(),
                        mMessageInlineLinkRangeStart,
                        mMessageInlineLinkRangeEnd,
                        Spanned.SPAN_INCLUSIVE_INCLUSIVE);
            }

            fullString.append(spannedMessage);
        }

        // Concatenate the text to display for the link and make it clickable.
        if (!TextUtils.isEmpty(mMessageLinkText)) {
            if (fullString.length() > 0) fullString.append(" ");
            int spanStart = fullString.length();

            fullString.append(mMessageLinkText);
            fullString.setSpan(
                    createClickableSpan(),
                    spanStart,
                    fullString.length(),
                    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        }

        return fullString;
    }

    private NoUnderlineClickableSpan createClickableSpan() {
        return new NoUnderlineClickableSpan(getContext(), (view) -> mInfoBar.onLinkClicked());
    }

    /**
     * Creates a View that holds an icon representing an infobar.
     * @param context Context to grab resources from.
     * @param iconResourceId ID of the icon to use for the infobar.
     * @param iconTintId The {@link ColorRes} used as tint for {@code iconResourceId}.
     * @param iconBitmap Bitmap for the icon to use, if the resource ID wasn't passed through.
     * @return {@link ImageButton} that represents the icon.
     */
    @Nullable
    public static ImageView createIconView(
            Context context, int iconResourceId, @ColorRes int iconTintId, Bitmap iconBitmap) {
        if (iconResourceId == 0 && iconBitmap == null) return null;

        final ChromeImageView iconView = new ChromeImageView(context);
        if (iconResourceId != 0) {
            iconView.setImageDrawable(AppCompatResources.getDrawable(context, iconResourceId));
            if (iconTintId != 0) {
                ImageViewCompat.setImageTintList(
                        iconView, AppCompatResources.getColorStateList(context, iconTintId));
            }
        } else {
            iconView.setImageBitmap(iconBitmap);
        }

        iconView.setFocusable(false);
        iconView.setId(R.id.infobar_icon);
        iconView.setScaleType(ImageView.ScaleType.CENTER_INSIDE);
        return iconView;
    }

    /**
     * Creates a close button that can be inserted into an infobar.
     * @param context Context to grab resources from.
     * @return {@link ImageButton} that represents a close button.
     */
    public static ImageButton createCloseButton(Context context) {
        final ColorStateList tint =
                AppCompatResources.getColorStateList(context, R.color.default_icon_color_tint_list);
        TypedArray a =
                context.obtainStyledAttributes(new int[] {android.R.attr.selectableItemBackground});
        Drawable closeButtonBackground = a.getDrawable(0);
        a.recycle();

        ChromeImageButton closeButton = new ChromeImageButton(context);
        closeButton.setId(R.id.infobar_close_button);
        closeButton.setImageResource(R.drawable.btn_close);
        ImageViewCompat.setImageTintList(closeButton, tint);
        closeButton.setBackground(closeButtonBackground);
        closeButton.setContentDescription(context.getString(R.string.close));
        closeButton.setScaleType(ImageView.ScaleType.CENTER_INSIDE);

        return closeButton;
    }
}