root/chrome/android/java/src/org/chromium/chrome/browser/banners/AppBannerView.java

/* [<][>][^][v][top][bottom][index][help] */

DEFINITIONS

This source file includes following definitions.
  1. onBannerRemoved
  2. onBannerBlocked
  3. onBannerDismissEvent
  4. onBannerInstallEvent
  5. onFireIntent
  6. create
  7. initialize
  8. initializeControls
  9. setAccessibilityInformation
  10. onClick
  11. onViewSwipedAway
  12. onViewClicked
  13. onViewPressed
  14. onIntentCompleted
  15. onInstallFinished
  16. createLayoutParams
  17. removeFromParent
  18. dismiss
  19. destroy
  20. updateButtonAppearance
  21. getIconSize
  22. onTouchEvent
  23. onAttachedToWindow
  24. onDetachedFromWindow
  25. onConfigurationChanged
  26. onMeasure
  27. onLayout
  28. measureChildForSpace
  29. measureChildForSpaceExactly
  30. getMarginWidth
  31. getWidthWithMargins
  32. getMarginHeight
  33. getHeightWithMargins

// Copyright 2014 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.banners;

import android.animation.ObjectAnimator;
import android.app.Activity;
import android.app.PendingIntent;
import android.content.ActivityNotFoundException;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentSender;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Rect;
import android.os.Looper;
import android.util.AttributeSet;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.TextView;

import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.chrome.R;
import org.chromium.content.browser.ContentView;
import org.chromium.ui.base.LocalizationUtils;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.base.WindowAndroid.IntentCallback;

/**
 * Lays out a banner for showing info about an app on the Play Store.
 * The banner mimics the appearance of a Google Now card using a background Drawable with a shadow.
 *
 * PADDING CALCULATIONS
 * The banner has three different types of padding that need to be accounted for:
 * 1) The background Drawable of the banner looks like card with a drop shadow.  The Drawable
 *    defines a padding around the card that solely encompasses the space occupied by the drop
 *    shadow.
 * 2) The card itself needs to have padding so that the widgets don't abut the borders of the card.
 *    This is defined as mPaddingCard, and is equally applied to all four sides.
 * 3) Controls other than the icon are further constrained by mPaddingControls, which applies only
 *    to the bottom and end margins.
 * See {@link #AppBannerView.onMeasure(int, int)} for details.
 *
 * MARGIN CALCULATIONS
 * Margin calculations for the banner are complicated by the background Drawable's drop shadows,
 * since the drop shadows are meant to be counted as being part of the margin.  To deal with this,
 * the margins are calculated by deducting the background Drawable's padding from the margins
 * defined by the XML files.
 *
 * EVEN MORE LAYOUT QUIRKS
 * The layout of the banner, which includes its widget sizes, may change when the screen is rotated
 * to account for less screen real estate.  This means that all of the View's widgets and cached
 * dimensions must be rebuilt from scratch.
 */
public class AppBannerView extends SwipableOverlayView
        implements View.OnClickListener, InstallerDelegate.Observer, IntentCallback {
    private static final String TAG = "AppBannerView";

    /**
     * Class that is alerted about things happening to the BannerView.
     */
    public static interface Observer {
        /**
         * Called when the banner is removed from the hierarchy.
         * @param banner Banner being dismissed.
         */
        public void onBannerRemoved(AppBannerView banner);

        /**
         * Called when the user manually closes a banner.
         * @param banner      Banner being blocked.
         * @param url         URL of the page that requested the banner.
         * @param packageName Name of the app's package.
         */
        public void onBannerBlocked(AppBannerView banner, String url, String packageName);

        /**
         * Called when the banner begins to be dismissed.
         * @param banner      Banner being closed.
         * @param dismissType Type of dismissal performed.
         */
        public void onBannerDismissEvent(AppBannerView banner, int dismissType);

        /**
         * Called when an install event has occurred.
         */
        public void onBannerInstallEvent(AppBannerView banner, int eventType);

        /**
         * Called when the banner needs to have an Activity started for a result.
         * @param banner Banner firing the event.
         * @param intent Intent to fire.
         */
        public boolean onFireIntent(AppBannerView banner, PendingIntent intent);
    }

    // Installation states.
    private static final int INSTALL_STATE_NOT_INSTALLED = 0;
    private static final int INSTALL_STATE_INSTALLING = 1;
    private static final int INSTALL_STATE_INSTALLED = 2;

    // XML layout for the BannerView.
    private static final int BANNER_LAYOUT = R.layout.app_banner_view;

    // True if the layout is in left-to-right layout mode (regular mode).
    private final boolean mIsLayoutLTR;

    // Class to alert about BannerView events.
    private AppBannerView.Observer mObserver;

    // Information about the package.  Shouldn't ever be null after calling {@link #initialize()}.
    private AppData mAppData;

    // Views comprising the app banner.
    private ImageView mIconView;
    private TextView mTitleView;
    private Button mInstallButtonView;
    private RatingView mRatingView;
    private View mLogoView;
    private View mBannerHighlightView;
    private ImageButton mCloseButtonView;

    // Dimension values.
    private int mDefinedMaxWidth;
    private int mPaddingCard;
    private int mPaddingControls;
    private int mMarginLeft;
    private int mMarginRight;
    private int mMarginBottom;
    private int mTouchSlop;

    // Highlight variables.
    private boolean mIsBannerPressed;
    private float mInitialXForHighlight;

    // Initial padding values.
    private final Rect mBackgroundDrawablePadding;

    // Install tracking.
    private boolean mWasInstallDialogShown;
    private InstallerDelegate mInstallTask;
    private int mInstallState;

    /**
     * Creates a BannerView and adds it to the given ContentView.
     * @param contentView ContentView to display the AppBannerView for.
     * @param observer    Class that is alerted for AppBannerView events.
     * @param data        Data about the app.
     * @return            The created banner.
     */
    public static AppBannerView create(ContentView contentView, Observer observer, AppData data) {
        Context context = contentView.getContext().getApplicationContext();
        AppBannerView banner =
                (AppBannerView) LayoutInflater.from(context).inflate(BANNER_LAYOUT, null);
        banner.initialize(observer, data);
        banner.addToView(contentView);
        return banner;
    }

    /**
     * Creates a BannerView from an XML layout.
     */
    public AppBannerView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mIsLayoutLTR = !LocalizationUtils.isLayoutRtl();

        // Store the background Drawable's padding.  The background used for banners is a 9-patch,
        // which means that it already defines padding.  We need to take it into account when adding
        // even more padding to the inside of it.
        mBackgroundDrawablePadding = new Rect();
        mBackgroundDrawablePadding.left = ApiCompatibilityUtils.getPaddingStart(this);
        mBackgroundDrawablePadding.right = ApiCompatibilityUtils.getPaddingEnd(this);
        mBackgroundDrawablePadding.top = getPaddingTop();
        mBackgroundDrawablePadding.bottom = getPaddingBottom();

        mInstallState = INSTALL_STATE_NOT_INSTALLED;
    }

    /**
     * Initialize the banner with information about the package.
     * @param observer Class to alert about changes to the banner.
     * @param data     Information about the app being advertised.
     */
    private void initialize(Observer observer, AppData data) {
        mObserver = observer;
        mAppData = data;
        initializeControls();
    }

    private void initializeControls() {
        // Cache the banner dimensions, adjusting margins for drop shadows defined in the background
        // Drawable.
        Resources res = getResources();
        mDefinedMaxWidth = res.getDimensionPixelSize(R.dimen.app_banner_max_width);
        mPaddingCard = res.getDimensionPixelSize(R.dimen.app_banner_padding);
        mPaddingControls = res.getDimensionPixelSize(R.dimen.app_banner_padding_controls);
        mMarginLeft = res.getDimensionPixelSize(R.dimen.app_banner_margin_sides)
                - mBackgroundDrawablePadding.left;
        mMarginRight = res.getDimensionPixelSize(R.dimen.app_banner_margin_sides)
                - mBackgroundDrawablePadding.right;
        mMarginBottom = res.getDimensionPixelSize(R.dimen.app_banner_margin_bottom)
                - mBackgroundDrawablePadding.bottom;
        if (getLayoutParams() != null) {
            MarginLayoutParams params = (MarginLayoutParams) getLayoutParams();
            params.leftMargin = mMarginLeft;
            params.rightMargin = mMarginRight;
            params.bottomMargin = mMarginBottom;
        }

        // Pull out all of the controls we are expecting.
        mIconView = (ImageView) findViewById(R.id.app_icon);
        mTitleView = (TextView) findViewById(R.id.app_title);
        mInstallButtonView = (Button) findViewById(R.id.app_install_button);
        mRatingView = (RatingView) findViewById(R.id.app_rating);
        mLogoView = findViewById(R.id.store_logo);
        mBannerHighlightView = findViewById(R.id.banner_highlight);
        mCloseButtonView = (ImageButton) findViewById(R.id.close_button);

        assert mIconView != null;
        assert mTitleView != null;
        assert mInstallButtonView != null;
        assert mLogoView != null;
        assert mRatingView != null;
        assert mBannerHighlightView != null;
        assert mCloseButtonView != null;

        // Set up the buttons to fire an event.
        mInstallButtonView.setOnClickListener(this);
        mCloseButtonView.setOnClickListener(this);

        // Configure the controls with the package information.
        mTitleView.setText(mAppData.title());
        mIconView.setImageDrawable(mAppData.icon());
        mRatingView.initialize(mAppData.rating());
        setAccessibilityInformation();

        // Determine how much the user can drag sideways before their touch is considered a scroll.
        mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();

        // Set up the install button.
        updateButtonAppearance();
    }

    /**
     * Creates a succinct description about the app being advertised.
     */
    private void setAccessibilityInformation() {
        String bannerText = getContext().getString(
                R.string.app_banner_view_accessibility, mAppData.title(), mAppData.rating());
        setContentDescription(bannerText);
    }

    @Override
    public void onClick(View view) {
        if (mObserver == null) return;

        // Only allow the button to be clicked when the banner's in a neutral position.
        if (Math.abs(getTranslationX()) > ZERO_THRESHOLD
                || Math.abs(getTranslationY()) > ZERO_THRESHOLD) {
            return;
        }

        if (view == mInstallButtonView) {
            // Ignore button clicks when the app is installing.
            if (mInstallState == INSTALL_STATE_INSTALLING) return;

            mInstallButtonView.setEnabled(false);

            if (mInstallState == INSTALL_STATE_NOT_INSTALLED) {
                // The user initiated an install. Track it happening only once.
                if (!mWasInstallDialogShown) {
                    mObserver.onBannerInstallEvent(this, AppBannerMetricsIds.INSTALL_TRIGGERED);
                    mWasInstallDialogShown = true;
                }

                if (mObserver.onFireIntent(this, mAppData.installIntent())) {
                    // Temporarily hide the banner.
                    createVerticalSnapAnimation(false);
                } else {
                    Log.e(TAG, "Failed to fire install intent.");
                    dismiss(AppBannerMetricsIds.DISMISS_ERROR);
                }
            } else if (mInstallState == INSTALL_STATE_INSTALLED) {
                // The app is installed. Open it.
                String packageName = mAppData.packageName();
                PackageManager packageManager = getContext().getPackageManager();
                Intent appIntent = packageManager.getLaunchIntentForPackage(packageName);
                try {
                    getContext().startActivity(appIntent);
                } catch (ActivityNotFoundException e) {
                    Log.e(TAG, "Failed to find app package: " + packageName);
                }

                dismiss(AppBannerMetricsIds.DISMISS_APP_OPEN);
            }
        } else if (view == mCloseButtonView) {
            if (mObserver != null) {
                mObserver.onBannerBlocked(this, mAppData.siteUrl(), mAppData.packageName());
            }

            dismiss(AppBannerMetricsIds.DISMISS_CLOSE_BUTTON);
        }
    }

    @Override
    protected void onViewSwipedAway() {
        if (mObserver == null) return;
        mObserver.onBannerDismissEvent(this, AppBannerMetricsIds.DISMISS_BANNER_SWIPE);
        mObserver.onBannerBlocked(this, mAppData.siteUrl(), mAppData.packageName());
    }

    @Override
    protected void onViewClicked() {
        // Send the user to the app's Play store page.
        try {
            IntentSender sender = mAppData.detailsIntent().getIntentSender();
            getContext().startIntentSender(sender, new Intent(), 0, 0, 0);
        } catch (IntentSender.SendIntentException e) {
            Log.e(TAG, "Failed to launch details intent.");
        }

        dismiss(AppBannerMetricsIds.DISMISS_BANNER_CLICK);
    }

    @Override
    protected void onViewPressed(MotionEvent event) {
        // Highlight the banner when the user has held it for long enough and doesn't move.
        mInitialXForHighlight = event.getRawX();
        mIsBannerPressed = true;
        mBannerHighlightView.setVisibility(View.VISIBLE);
    }

    @Override
    public void onIntentCompleted(WindowAndroid window, int resultCode,
            ContentResolver contentResolver, Intent data) {
        if (isDismissed()) return;

        createVerticalSnapAnimation(true);
        if (resultCode == Activity.RESULT_OK) {
            // The user chose to install the app. Watch the PackageManager to see when it finishes
            // installing it.
            mObserver.onBannerInstallEvent(this, AppBannerMetricsIds.INSTALL_STARTED);

            PackageManager pm = getContext().getPackageManager();
            mInstallTask =
                    new InstallerDelegate(Looper.getMainLooper(), pm, this, mAppData.packageName());
            mInstallTask.start();
            mInstallState = INSTALL_STATE_INSTALLING;
        }
        updateButtonAppearance();
    }


    @Override
    public void onInstallFinished(InstallerDelegate monitor, boolean success) {
        if (isDismissed() || mInstallTask != monitor) return;

        if (success) {
            // Let the user open the app from here.
            mObserver.onBannerInstallEvent(this, AppBannerMetricsIds.INSTALL_COMPLETED);
            mInstallState = INSTALL_STATE_INSTALLED;
            updateButtonAppearance();
        } else {
            dismiss(AppBannerMetricsIds.DISMISS_INSTALL_TIMEOUT);
        }
    }

    @Override
    protected ViewGroup.MarginLayoutParams createLayoutParams() {
        // Define the margin around the entire banner that accounts for the drop shadow.
        ViewGroup.MarginLayoutParams params = super.createLayoutParams();
        params.setMargins(mMarginLeft, 0, mMarginRight, mMarginBottom);
        return params;
    }

    /**
     * Removes this View from its parent and alerts any observers of the dismissal.
     * @return Whether or not the View was successfully dismissed.
     */
    @Override
    boolean removeFromParent() {
        if (super.removeFromParent()) {
            mObserver.onBannerRemoved(this);
            destroy();
            return true;
        }

        return false;
    }

    /**
     * Dismisses the banner.
     * @param eventType Event that triggered the dismissal.  See {@link AppBannerMetricsIds}.
     */
    public void dismiss(int eventType) {
        if (isDismissed() || mObserver == null) return;

        dismiss(eventType == AppBannerMetricsIds.DISMISS_CLOSE_BUTTON);
        mObserver.onBannerDismissEvent(this, eventType);
    }

    /**
     * Destroys the Banner.
     */
    public void destroy() {
        if (!isDismissed()) dismiss(AppBannerMetricsIds.DISMISS_ERROR);

        if (mInstallTask != null) {
            mInstallTask.cancel();
            mInstallTask = null;
        }
    }

    /**
     * Updates the text and color of the button displayed on the button.
     */
    void updateButtonAppearance() {
        if (mInstallButtonView == null) return;

        Resources res = getResources();
        int fgColor;
        String text;
        if (mInstallState == INSTALL_STATE_INSTALLED) {
            ApiCompatibilityUtils.setBackgroundForView(mInstallButtonView,
                    res.getDrawable(R.drawable.app_banner_button_open));
            fgColor = res.getColor(R.color.app_banner_open_button_fg);
            text = res.getString(R.string.app_banner_open);
        } else {
            ApiCompatibilityUtils.setBackgroundForView(mInstallButtonView,
                    res.getDrawable(R.drawable.app_banner_button_install));
            fgColor = res.getColor(R.color.app_banner_install_button_fg);
            if (mInstallState == INSTALL_STATE_NOT_INSTALLED) {
                text = mAppData.installButtonText();
                mInstallButtonView.setContentDescription(
                        getContext().getString(R.string.app_banner_install_accessibility, text));
            } else {
                text = res.getString(R.string.app_banner_installing);
            }
        }

        mInstallButtonView.setTextColor(fgColor);
        mInstallButtonView.setText(text);
        mInstallButtonView.setEnabled(mInstallState != INSTALL_STATE_INSTALLING);
    }

    /**
     * Determine how big an icon needs to be for the Layout.
     * @param context Context to grab resources from.
     * @return        How big the icon is expected to be, in pixels.
     */
    static int getIconSize(Context context) {
        return context.getResources().getDimensionPixelSize(R.dimen.app_banner_icon_size);
    }

    /**
     * Passes all touch events through to the parent.
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getActionMasked();
        if (mIsBannerPressed) {
            // Mimic Google Now card behavior, where the card stops being highlighted if the user
            // scrolls a bit to the side.
            float xDifference = Math.abs(event.getRawX() - mInitialXForHighlight);
            if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL
                    || (action == MotionEvent.ACTION_MOVE && xDifference > mTouchSlop)) {
                mIsBannerPressed = false;
                mBannerHighlightView.setVisibility(View.INVISIBLE);
            }
        }

        return super.onTouchEvent(event);
    }

    /**
     * Fade the banner back into view.
     */
    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        ObjectAnimator.ofFloat(this, "alpha", getAlpha(), 1.f).setDuration(
                MS_ANIMATION_DURATION).start();
        setVisibility(VISIBLE);
    }

    /**
     * Immediately hide the banner to avoid having them show up in snapshots.
     */
    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        setAlpha(0.0f);
        setVisibility(INVISIBLE);
    }

    /**
     * Watch for changes in the available screen height, which triggers a complete recreation of the
     * banner widgets.  This is mainly due to the fact that the Nexus 7 has a smaller banner defined
     * for its landscape versus its portrait layouts.
     */
    @Override
    protected void onConfigurationChanged(Configuration config) {
        super.onConfigurationChanged(config);

        if (isDismissed()) return;

        // If the card's maximum width hasn't changed, the individual views can't have, either.
        int newDefinedWidth = getResources().getDimensionPixelSize(R.dimen.app_banner_max_width);
        if (mDefinedMaxWidth == newDefinedWidth) return;

        // Cannibalize another version of this layout to get Views using the new resources and
        // sizes.
        while (getChildCount() > 0) removeViewAt(0);
        mIconView = null;
        mTitleView = null;
        mInstallButtonView = null;
        mRatingView = null;
        mLogoView = null;
        mBannerHighlightView = null;

        AppBannerView cannibalized =
                (AppBannerView) LayoutInflater.from(getContext()).inflate(BANNER_LAYOUT, null);
        while (cannibalized.getChildCount() > 0) {
            View child = cannibalized.getChildAt(0);
            cannibalized.removeViewAt(0);
            addView(child);
        }
        initializeControls();
        requestLayout();
    }

    /**
     * Measures the banner and its children Views for the given space.
     *
     * DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD
     * DPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPD
     * DP......                               cPD
     * DP...... TITLE----------------------- XcPD
     * DP.ICON. *****                         cPD
     * DP...... LOGO                    BUTTONcPD
     * DP...... cccccccccccccccccccccccccccccccPD
     * DPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPD
     * DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD
     *
     * The three paddings mentioned in the class Javadoc are denoted by:
     * D) Drop shadow padding.
     * P) Inner card padding.
     * c) Control padding.
     *
     * Measurement for components of the banner are performed assuming that components are laid out
     * inside of the banner's background as follows:
     * 1) A maximum width is enforced on the banner to keep the whole thing on screen and keep it a
     *    reasonable size.
     * 2) The icon takes up the left side of the banner.
     * 3) The install button occupies the bottom-right of the banner.
     * 4) The Google Play logo occupies the space to the left of the button.
     * 5) The rating is assigned space above the logo and below the title.
     * 6) The close button (if visible) sits in the top right of the banner.
     * 7) The title is assigned whatever space is left and sits on top of the tallest stack of
     *    controls.
     *
     * See {@link #android.view.View.onMeasure(int, int)} for the parameters.
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // Enforce a maximum width on the banner, which is defined as the smallest of:
        // 1) The smallest width for the device (in either landscape or portrait mode).
        // 2) The defined maximum width in the dimens.xml files.
        // 3) The width passed in through the MeasureSpec.
        Resources res = getResources();
        float density = res.getDisplayMetrics().density;
        int screenSmallestWidth = (int) (res.getConfiguration().smallestScreenWidthDp * density);
        int specWidth = MeasureSpec.getSize(widthMeasureSpec);
        int bannerWidth = Math.min(Math.min(specWidth, mDefinedMaxWidth), screenSmallestWidth);

        // Track how much space is available inside the banner's card-shaped background Drawable.
        // To calculate this, we need to account for both the padding of the background (which
        // is occupied by the card's drop shadows) as well as the padding defined on the inside of
        // the card.
        int bgPaddingWidth = mBackgroundDrawablePadding.left + mBackgroundDrawablePadding.right;
        int bgPaddingHeight = mBackgroundDrawablePadding.top + mBackgroundDrawablePadding.bottom;
        final int maxControlWidth = bannerWidth - bgPaddingWidth - (mPaddingCard * 2);

        // Control height is constrained to provide a reasonable aspect ratio.
        // In practice, the only controls which can cause an issue are the title and the install
        // button, since they have strings that can change size according to user preference.  The
        // other controls are all defined to be a certain height.
        int specHeight = MeasureSpec.getSize(heightMeasureSpec);
        int reasonableHeight = maxControlWidth / 4;
        int paddingHeight = bgPaddingHeight + (mPaddingCard * 2);
        final int maxControlHeight = Math.min(specHeight, reasonableHeight) - paddingHeight;
        final int maxStackedControlHeight = maxControlWidth / 3;

        // Determine how big each component wants to be.  The icon is measured separately because
        // it is not stacked with the other controls.
        measureChildForSpace(mIconView, maxControlWidth, maxControlHeight);
        for (int i = 0; i < getChildCount(); i++) {
            if (getChildAt(i) != mIconView) {
                measureChildForSpace(getChildAt(i), maxControlWidth, maxStackedControlHeight);
            }
        }

        // Determine how tall the banner needs to be to fit everything by calculating the combined
        // height of the stacked controls.  There are three competing stacks to measure:
        // 1) The icon.
        // 2) The app title + control padding + star rating + store logo.
        // 3) The app title + control padding + install button.
        // The control padding is extra padding that applies only to the non-icon widgets.
        // The close button does not get counted as part of a stack.
        int iconStackHeight = getHeightWithMargins(mIconView);
        int logoStackHeight = getHeightWithMargins(mTitleView) + mPaddingControls
                + getHeightWithMargins(mRatingView) + getHeightWithMargins(mLogoView);
        int buttonStackHeight = getHeightWithMargins(mTitleView) + mPaddingControls
                + getHeightWithMargins(mInstallButtonView);
        int biggestStackHeight =
                Math.max(iconStackHeight, Math.max(logoStackHeight, buttonStackHeight));

        // The icon hugs the banner's starting edge, from the top of the banner to the bottom.
        final int iconSize = biggestStackHeight;
        measureChildForSpaceExactly(mIconView, iconSize, iconSize);

        // The rest of the content is laid out to the right of the icon.
        // Additional padding is defined for non-icon content on the end and bottom.
        final int contentWidth =
                maxControlWidth - getWidthWithMargins(mIconView) - mPaddingControls;
        final int contentHeight = biggestStackHeight - mPaddingControls;
        measureChildForSpace(mInstallButtonView, contentWidth, contentHeight);
        measureChildForSpace(mLogoView, contentWidth, contentHeight);

        // Measure the star rating, which sits below the title and above the logo.
        final int ratingWidth = contentWidth;
        final int ratingHeight = contentHeight - getHeightWithMargins(mLogoView);
        measureChildForSpace(mRatingView, ratingWidth, ratingHeight);

        // The close button sits to the right of the title and above the install button.
        final int closeWidth = contentWidth;
        final int closeHeight = contentHeight - getHeightWithMargins(mInstallButtonView);
        measureChildForSpace(mCloseButtonView, closeWidth, closeHeight);

        // The app title spans the top of the banner and sits on top of the other controls, and to
        // the left of the close button. The computation for the width available to the title is
        // complicated by how the button sits in the corner and absorbs the padding that would
        // normally be there.
        int biggerStack = Math.max(getHeightWithMargins(mInstallButtonView),
                getHeightWithMargins(mLogoView) + getHeightWithMargins(mRatingView));
        final int titleWidth = contentWidth - getWidthWithMargins(mCloseButtonView) + mPaddingCard;
        final int titleHeight = contentHeight - biggerStack;
        measureChildForSpace(mTitleView, titleWidth, titleHeight);

        // Set the measured dimensions for the banner.  The banner's height is defined by the
        // tallest stack of components, the padding of the banner's card background, and the extra
        // padding around the banner's components.
        int bannerPadding = mBackgroundDrawablePadding.top + mBackgroundDrawablePadding.bottom
                + (mPaddingCard * 2);
        int bannerHeight = biggestStackHeight + bannerPadding;
        setMeasuredDimension(bannerWidth, bannerHeight);

        // Make the banner highlight view be the exact same size as the banner's card background.
        final int cardWidth = bannerWidth - bgPaddingWidth;
        final int cardHeight = bannerHeight - bgPaddingHeight;
        measureChildForSpaceExactly(mBannerHighlightView, cardWidth, cardHeight);
    }

    /**
     * Lays out the controls according to the algorithm in {@link #onMeasure}.
     * See {@link #android.view.View.onLayout(boolean, int, int, int, int)} for the parameters.
     */
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        super.onLayout(changed, l, t, r, b);
        int top = mBackgroundDrawablePadding.top;
        int bottom = getMeasuredHeight() - mBackgroundDrawablePadding.bottom;
        int start = mBackgroundDrawablePadding.left;
        int end = getMeasuredWidth() - mBackgroundDrawablePadding.right;

        // The highlight overlay covers the entire banner (minus drop shadow padding).
        mBannerHighlightView.layout(start, top, end, bottom);

        // Lay out the close button in the top-right corner.  Padding that would normally go to the
        // card is applied to the close button so that it has a bigger touch target.
        if (mCloseButtonView.getVisibility() == VISIBLE) {
            int closeWidth = mCloseButtonView.getMeasuredWidth();
            int closeTop =
                    top + ((MarginLayoutParams) mCloseButtonView.getLayoutParams()).topMargin;
            int closeBottom = closeTop + mCloseButtonView.getMeasuredHeight();
            int closeRight = mIsLayoutLTR ? end : (getMeasuredWidth() - end + closeWidth);
            int closeLeft = closeRight - closeWidth;
            mCloseButtonView.layout(closeLeft, closeTop, closeRight, closeBottom);
        }

        // Apply the padding for the rest of the widgets.
        top += mPaddingCard;
        bottom -= mPaddingCard;
        start += mPaddingCard;
        end -= mPaddingCard;

        // Lay out the icon.
        int iconWidth = mIconView.getMeasuredWidth();
        int iconLeft = mIsLayoutLTR ? start : (getMeasuredWidth() - start - iconWidth);
        mIconView.layout(iconLeft, top, iconLeft + iconWidth, top + mIconView.getMeasuredHeight());
        start += getWidthWithMargins(mIconView);

        // Factor in the additional padding, which is only tacked onto the end and bottom.
        end -= mPaddingControls;
        bottom -= mPaddingControls;

        // Lay out the app title text.
        int titleWidth = mTitleView.getMeasuredWidth();
        int titleTop = top + ((MarginLayoutParams) mTitleView.getLayoutParams()).topMargin;
        int titleBottom = titleTop + mTitleView.getMeasuredHeight();
        int titleLeft = mIsLayoutLTR ? start : (getMeasuredWidth() - start - titleWidth);
        mTitleView.layout(titleLeft, titleTop, titleLeft + titleWidth, titleBottom);

        // The mock shows the margin eating into the descender area of the TextView.
        int textBaseline = mTitleView.getLineBounds(mTitleView.getLineCount() - 1, null);
        top = titleTop + textBaseline
                + ((MarginLayoutParams) mTitleView.getLayoutParams()).bottomMargin;

        // Lay out the app rating below the title.
        int starWidth = mRatingView.getMeasuredWidth();
        int starTop = top + ((MarginLayoutParams) mRatingView.getLayoutParams()).topMargin;
        int starBottom = starTop + mRatingView.getMeasuredHeight();
        int starLeft = mIsLayoutLTR ? start : (getMeasuredWidth() - start - starWidth);
        mRatingView.layout(starLeft, starTop, starLeft + starWidth, starBottom);

        // Lay out the logo in the bottom-left.
        int logoWidth = mLogoView.getMeasuredWidth();
        int logoBottom = bottom - ((MarginLayoutParams) mLogoView.getLayoutParams()).bottomMargin;
        int logoTop = logoBottom - mLogoView.getMeasuredHeight();
        int logoLeft = mIsLayoutLTR ? start : (getMeasuredWidth() - start - logoWidth);
        mLogoView.layout(logoLeft, logoTop, logoLeft + logoWidth, logoBottom);

        // Lay out the install button in the bottom-right corner.
        int buttonHeight = mInstallButtonView.getMeasuredHeight();
        int buttonWidth = mInstallButtonView.getMeasuredWidth();
        int buttonRight = mIsLayoutLTR ? end : (getMeasuredWidth() - end + buttonWidth);
        int buttonLeft = buttonRight - buttonWidth;
        mInstallButtonView.layout(buttonLeft, bottom - buttonHeight, buttonRight, bottom);
    }

    /**
     * Measures a child for the given space, accounting for defined heights and margins.
     * @param child           View to measure.
     * @param availableWidth  Available width for the view.
     * @param availableHeight Available height for the view.
     */
    private void measureChildForSpace(View child, int availableWidth, int availableHeight) {
        // Handle margins.
        availableWidth -= getMarginWidth(child);
        availableHeight -= getMarginHeight(child);

        // Account for any layout-defined dimensions for the view.
        int childWidth = child.getLayoutParams().width;
        int childHeight = child.getLayoutParams().height;
        if (childWidth >= 0) availableWidth = Math.min(availableWidth, childWidth);
        if (childHeight >= 0) availableHeight = Math.min(availableHeight, childHeight);

        int widthSpec = MeasureSpec.makeMeasureSpec(availableWidth, MeasureSpec.AT_MOST);
        int heightSpec = MeasureSpec.makeMeasureSpec(availableHeight, MeasureSpec.AT_MOST);
        child.measure(widthSpec, heightSpec);
    }

    /**
     * Forces a child to exactly occupy the given space.
     * @param child           View to measure.
     * @param availableWidth  Available width for the view.
     * @param availableHeight Available height for the view.
     */
    private void measureChildForSpaceExactly(View child, int availableWidth, int availableHeight) {
        int widthSpec = MeasureSpec.makeMeasureSpec(availableWidth, MeasureSpec.EXACTLY);
        int heightSpec = MeasureSpec.makeMeasureSpec(availableHeight, MeasureSpec.EXACTLY);
        child.measure(widthSpec, heightSpec);
    }

    /**
     * Calculates how wide the margins are for the given View.
     * @param view View to measure.
     * @return     Measured width of the margins.
     */
    private static int getMarginWidth(View view) {
        MarginLayoutParams params = (MarginLayoutParams) view.getLayoutParams();
        return params.leftMargin + params.rightMargin;
    }

    /**
     * Calculates how wide the given View has been measured to be, including its margins.
     * @param view View to measure.
     * @return     Measured width of the view plus its margins.
     */
    private static int getWidthWithMargins(View view) {
        return view.getMeasuredWidth() + getMarginWidth(view);
    }

    /**
     * Calculates how tall the margins are for the given View.
     * @param view View to measure.
     * @return     Measured height of the margins.
     */
    private static int getMarginHeight(View view) {
        MarginLayoutParams params = (MarginLayoutParams) view.getLayoutParams();
        return params.topMargin + params.bottomMargin;
    }

    /**
     * Calculates how tall the given View has been measured to be, including its margins.
     * @param view View to measure.
     * @return     Measured height of the view plus its margins.
     */
    private static int getHeightWithMargins(View view) {
        return view.getMeasuredHeight() + getMarginHeight(view);
    }
}

/* [<][>][^][v][top][bottom][index][help] */