1// Copyright 2014 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5package org.chromium.chrome.browser.banners;
6
7import android.animation.ObjectAnimator;
8import android.app.Activity;
9import android.app.PendingIntent;
10import android.content.ActivityNotFoundException;
11import android.content.ContentResolver;
12import android.content.Context;
13import android.content.Intent;
14import android.content.IntentSender;
15import android.content.pm.PackageManager;
16import android.content.res.Configuration;
17import android.content.res.Resources;
18import android.graphics.Rect;
19import android.os.Looper;
20import android.util.AttributeSet;
21import android.util.Log;
22import android.view.LayoutInflater;
23import android.view.MotionEvent;
24import android.view.View;
25import android.view.ViewConfiguration;
26import android.view.ViewGroup;
27import android.widget.Button;
28import android.widget.ImageButton;
29import android.widget.ImageView;
30import android.widget.TextView;
31
32import org.chromium.base.ApiCompatibilityUtils;
33import org.chromium.chrome.R;
34import org.chromium.content.browser.ContentViewCore;
35import org.chromium.ui.base.LocalizationUtils;
36import org.chromium.ui.base.WindowAndroid;
37import org.chromium.ui.base.WindowAndroid.IntentCallback;
38
39/**
40 * Lays out a banner for showing info about an app on the Play Store.
41 * The banner mimics the appearance of a Google Now card using a background Drawable with a shadow.
42 *
43 * PADDING CALCULATIONS
44 * The banner has three different types of padding that need to be accounted for:
45 * 1) The background Drawable of the banner looks like card with a drop shadow.  The Drawable
46 *    defines a padding around the card that solely encompasses the space occupied by the drop
47 *    shadow.
48 * 2) The card itself needs to have padding so that the widgets don't abut the borders of the card.
49 *    This is defined as mPaddingCard, and is equally applied to all four sides.
50 * 3) Controls other than the icon are further constrained by mPaddingControls, which applies only
51 *    to the bottom and end margins.
52 * See {@link #AppBannerView.onMeasure(int, int)} for details.
53 *
54 * MARGIN CALCULATIONS
55 * Margin calculations for the banner are complicated by the background Drawable's drop shadows,
56 * since the drop shadows are meant to be counted as being part of the margin.  To deal with this,
57 * the margins are calculated by deducting the background Drawable's padding from the margins
58 * defined by the XML files.
59 *
60 * EVEN MORE LAYOUT QUIRKS
61 * The layout of the banner, which includes its widget sizes, may change when the screen is rotated
62 * to account for less screen real estate.  This means that all of the View's widgets and cached
63 * dimensions must be rebuilt from scratch.
64 */
65public class AppBannerView extends SwipableOverlayView
66        implements View.OnClickListener, InstallerDelegate.Observer, IntentCallback {
67    private static final String TAG = "AppBannerView";
68
69    /**
70     * Class that is alerted about things happening to the BannerView.
71     */
72    public static interface Observer {
73        /**
74         * Called when the banner is removed from the hierarchy.
75         * @param banner Banner being dismissed.
76         */
77        public void onBannerRemoved(AppBannerView banner);
78
79        /**
80         * Called when the user manually closes a banner.
81         * @param banner      Banner being blocked.
82         * @param url         URL of the page that requested the banner.
83         * @param packageName Name of the app's package.
84         */
85        public void onBannerBlocked(AppBannerView banner, String url, String packageName);
86
87        /**
88         * Called when the banner begins to be dismissed.
89         * @param banner      Banner being closed.
90         * @param dismissType Type of dismissal performed.
91         */
92        public void onBannerDismissEvent(AppBannerView banner, int dismissType);
93
94        /**
95         * Called when an install event has occurred.
96         */
97        public void onBannerInstallEvent(AppBannerView banner, int eventType);
98
99        /**
100         * Called when the banner needs to have an Activity started for a result.
101         * @param banner Banner firing the event.
102         * @param intent Intent to fire.
103         */
104        public boolean onFireIntent(AppBannerView banner, PendingIntent intent);
105    }
106
107    // Installation states.
108    private static final int INSTALL_STATE_NOT_INSTALLED = 0;
109    private static final int INSTALL_STATE_INSTALLING = 1;
110    private static final int INSTALL_STATE_INSTALLED = 2;
111
112    // XML layout for the BannerView.
113    private static final int BANNER_LAYOUT = R.layout.app_banner_view;
114
115    // True if the layout is in left-to-right layout mode (regular mode).
116    private final boolean mIsLayoutLTR;
117
118    // Class to alert about BannerView events.
119    private AppBannerView.Observer mObserver;
120
121    // Information about the package.  Shouldn't ever be null after calling {@link #initialize()}.
122    private AppData mAppData;
123
124    // Views comprising the app banner.
125    private ImageView mIconView;
126    private TextView mTitleView;
127    private Button mInstallButtonView;
128    private RatingView mRatingView;
129    private View mLogoView;
130    private View mBannerHighlightView;
131    private ImageButton mCloseButtonView;
132
133    // Dimension values.
134    private int mDefinedMaxWidth;
135    private int mPaddingCard;
136    private int mPaddingControls;
137    private int mMarginLeft;
138    private int mMarginRight;
139    private int mMarginBottom;
140    private int mTouchSlop;
141
142    // Highlight variables.
143    private boolean mIsBannerPressed;
144    private float mInitialXForHighlight;
145
146    // Initial padding values.
147    private final Rect mBackgroundDrawablePadding;
148
149    // Install tracking.
150    private boolean mWasInstallDialogShown;
151    private InstallerDelegate mInstallTask;
152    private int mInstallState;
153
154    /**
155     * Creates a BannerView and adds it to the given ContentViewCore.
156     * @param contentViewCore ContentViewCore to display the AppBannerView for.
157     * @param observer    Class that is alerted for AppBannerView events.
158     * @param data        Data about the app.
159     * @return            The created banner.
160     */
161    public static AppBannerView create(
162            ContentViewCore contentViewCore, Observer observer, AppData data) {
163        Context context = contentViewCore.getContext().getApplicationContext();
164        AppBannerView banner =
165                (AppBannerView) LayoutInflater.from(context).inflate(BANNER_LAYOUT, null);
166        banner.initialize(observer, data);
167        banner.addToView(contentViewCore);
168        return banner;
169    }
170
171    /**
172     * Creates a BannerView from an XML layout.
173     */
174    public AppBannerView(Context context, AttributeSet attrs) {
175        super(context, attrs);
176        mIsLayoutLTR = !LocalizationUtils.isLayoutRtl();
177
178        // Store the background Drawable's padding.  The background used for banners is a 9-patch,
179        // which means that it already defines padding.  We need to take it into account when adding
180        // even more padding to the inside of it.
181        mBackgroundDrawablePadding = new Rect();
182        mBackgroundDrawablePadding.left = ApiCompatibilityUtils.getPaddingStart(this);
183        mBackgroundDrawablePadding.right = ApiCompatibilityUtils.getPaddingEnd(this);
184        mBackgroundDrawablePadding.top = getPaddingTop();
185        mBackgroundDrawablePadding.bottom = getPaddingBottom();
186
187        mInstallState = INSTALL_STATE_NOT_INSTALLED;
188    }
189
190    /**
191     * Initialize the banner with information about the package.
192     * @param observer Class to alert about changes to the banner.
193     * @param data     Information about the app being advertised.
194     */
195    private void initialize(Observer observer, AppData data) {
196        mObserver = observer;
197        mAppData = data;
198        initializeControls();
199    }
200
201    private void initializeControls() {
202        // Cache the banner dimensions, adjusting margins for drop shadows defined in the background
203        // Drawable.
204        Resources res = getResources();
205        mDefinedMaxWidth = res.getDimensionPixelSize(R.dimen.app_banner_max_width);
206        mPaddingCard = res.getDimensionPixelSize(R.dimen.app_banner_padding);
207        mPaddingControls = res.getDimensionPixelSize(R.dimen.app_banner_padding_controls);
208        mMarginLeft = res.getDimensionPixelSize(R.dimen.app_banner_margin_sides)
209                - mBackgroundDrawablePadding.left;
210        mMarginRight = res.getDimensionPixelSize(R.dimen.app_banner_margin_sides)
211                - mBackgroundDrawablePadding.right;
212        mMarginBottom = res.getDimensionPixelSize(R.dimen.app_banner_margin_bottom)
213                - mBackgroundDrawablePadding.bottom;
214        if (getLayoutParams() != null) {
215            MarginLayoutParams params = (MarginLayoutParams) getLayoutParams();
216            params.leftMargin = mMarginLeft;
217            params.rightMargin = mMarginRight;
218            params.bottomMargin = mMarginBottom;
219        }
220
221        // Pull out all of the controls we are expecting.
222        mIconView = (ImageView) findViewById(R.id.app_icon);
223        mTitleView = (TextView) findViewById(R.id.app_title);
224        mInstallButtonView = (Button) findViewById(R.id.app_install_button);
225        mRatingView = (RatingView) findViewById(R.id.app_rating);
226        mLogoView = findViewById(R.id.store_logo);
227        mBannerHighlightView = findViewById(R.id.banner_highlight);
228        mCloseButtonView = (ImageButton) findViewById(R.id.close_button);
229
230        assert mIconView != null;
231        assert mTitleView != null;
232        assert mInstallButtonView != null;
233        assert mLogoView != null;
234        assert mRatingView != null;
235        assert mBannerHighlightView != null;
236        assert mCloseButtonView != null;
237
238        // Set up the buttons to fire an event.
239        mInstallButtonView.setOnClickListener(this);
240        mCloseButtonView.setOnClickListener(this);
241
242        // Configure the controls with the package information.
243        mTitleView.setText(mAppData.title());
244        mIconView.setImageDrawable(mAppData.icon());
245        mRatingView.initialize(mAppData.rating());
246        setAccessibilityInformation();
247
248        // Determine how much the user can drag sideways before their touch is considered a scroll.
249        mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
250
251        // Set up the install button.
252        updateButtonStatus();
253    }
254
255    /**
256     * Creates a succinct description about the app being advertised.
257     */
258    private void setAccessibilityInformation() {
259        String bannerText = getContext().getString(
260                R.string.app_banner_view_accessibility, mAppData.title(), mAppData.rating());
261        setContentDescription(bannerText);
262    }
263
264    @Override
265    public void onClick(View view) {
266        if (mObserver == null) return;
267
268        // Only allow the button to be clicked when the banner's in a neutral position.
269        if (Math.abs(getTranslationX()) > ZERO_THRESHOLD
270                || Math.abs(getTranslationY()) > ZERO_THRESHOLD) {
271            return;
272        }
273
274        if (view == mInstallButtonView) {
275            // Check that nothing happened in the background to change the install state of the app.
276            int previousState = mInstallState;
277            updateButtonStatus();
278            if (mInstallState != previousState) return;
279
280            // Ignore button clicks when the app is installing.
281            if (mInstallState == INSTALL_STATE_INSTALLING) return;
282
283            mInstallButtonView.setEnabled(false);
284
285            if (mInstallState == INSTALL_STATE_NOT_INSTALLED) {
286                // The user initiated an install. Track it happening only once.
287                if (!mWasInstallDialogShown) {
288                    mObserver.onBannerInstallEvent(this, AppBannerMetricsIds.INSTALL_TRIGGERED);
289                    mWasInstallDialogShown = true;
290                }
291
292                if (mObserver.onFireIntent(this, mAppData.installIntent())) {
293                    // Temporarily hide the banner.
294                    createVerticalSnapAnimation(false);
295                } else {
296                    Log.e(TAG, "Failed to fire install intent.");
297                    dismiss(AppBannerMetricsIds.DISMISS_ERROR);
298                }
299            } else if (mInstallState == INSTALL_STATE_INSTALLED) {
300                // The app is installed. Open it.
301                try {
302                    Intent appIntent = getAppLaunchIntent();
303                    if (appIntent != null) getContext().startActivity(appIntent);
304                } catch (ActivityNotFoundException e) {
305                    Log.e(TAG, "Failed to find app package: " + mAppData.packageName());
306                }
307
308                dismiss(AppBannerMetricsIds.DISMISS_APP_OPEN);
309            }
310        } else if (view == mCloseButtonView) {
311            if (mObserver != null) {
312                mObserver.onBannerBlocked(this, mAppData.siteUrl(), mAppData.packageName());
313            }
314
315            dismiss(AppBannerMetricsIds.DISMISS_CLOSE_BUTTON);
316        }
317    }
318
319    @Override
320    protected void onViewSwipedAway() {
321        if (mObserver == null) return;
322        mObserver.onBannerDismissEvent(this, AppBannerMetricsIds.DISMISS_BANNER_SWIPE);
323        mObserver.onBannerBlocked(this, mAppData.siteUrl(), mAppData.packageName());
324    }
325
326    @Override
327    protected void onViewClicked() {
328        // Send the user to the app's Play store page.
329        try {
330            IntentSender sender = mAppData.detailsIntent().getIntentSender();
331            getContext().startIntentSender(sender, new Intent(), 0, 0, 0);
332        } catch (IntentSender.SendIntentException e) {
333            Log.e(TAG, "Failed to launch details intent.");
334        }
335
336        dismiss(AppBannerMetricsIds.DISMISS_BANNER_CLICK);
337    }
338
339    @Override
340    protected void onViewPressed(MotionEvent event) {
341        // Highlight the banner when the user has held it for long enough and doesn't move.
342        mInitialXForHighlight = event.getRawX();
343        mIsBannerPressed = true;
344        mBannerHighlightView.setVisibility(View.VISIBLE);
345    }
346
347    @Override
348    public void onIntentCompleted(WindowAndroid window, int resultCode,
349            ContentResolver contentResolver, Intent data) {
350        if (isDismissed()) return;
351
352        createVerticalSnapAnimation(true);
353        if (resultCode == Activity.RESULT_OK) {
354            // The user chose to install the app. Watch the PackageManager to see when it finishes
355            // installing it.
356            mObserver.onBannerInstallEvent(this, AppBannerMetricsIds.INSTALL_STARTED);
357
358            PackageManager pm = getContext().getPackageManager();
359            mInstallTask =
360                    new InstallerDelegate(Looper.getMainLooper(), pm, this, mAppData.packageName());
361            mInstallTask.start();
362            mInstallState = INSTALL_STATE_INSTALLING;
363        }
364        updateButtonStatus();
365    }
366
367
368    @Override
369    public void onInstallFinished(InstallerDelegate monitor, boolean success) {
370        if (isDismissed() || mInstallTask != monitor) return;
371
372        if (success) {
373            // Let the user open the app from here.
374            mObserver.onBannerInstallEvent(this, AppBannerMetricsIds.INSTALL_COMPLETED);
375            mInstallState = INSTALL_STATE_INSTALLED;
376            updateButtonStatus();
377        } else {
378            dismiss(AppBannerMetricsIds.DISMISS_INSTALL_TIMEOUT);
379        }
380    }
381
382    @Override
383    protected ViewGroup.MarginLayoutParams createLayoutParams() {
384        // Define the margin around the entire banner that accounts for the drop shadow.
385        ViewGroup.MarginLayoutParams params = super.createLayoutParams();
386        params.setMargins(mMarginLeft, 0, mMarginRight, mMarginBottom);
387        return params;
388    }
389
390    /**
391     * Removes this View from its parent and alerts any observers of the dismissal.
392     * @return Whether or not the View was successfully dismissed.
393     */
394    @Override
395    boolean removeFromParent() {
396        if (super.removeFromParent()) {
397            mObserver.onBannerRemoved(this);
398            destroy();
399            return true;
400        }
401
402        return false;
403    }
404
405    /**
406     * Dismisses the banner.
407     * @param eventType Event that triggered the dismissal.  See {@link AppBannerMetricsIds}.
408     */
409    public void dismiss(int eventType) {
410        if (isDismissed() || mObserver == null) return;
411
412        dismiss(eventType == AppBannerMetricsIds.DISMISS_CLOSE_BUTTON);
413        mObserver.onBannerDismissEvent(this, eventType);
414    }
415
416    /**
417     * Destroys the Banner.
418     */
419    public void destroy() {
420        if (!isDismissed()) dismiss(AppBannerMetricsIds.DISMISS_ERROR);
421
422        if (mInstallTask != null) {
423            mInstallTask.cancel();
424            mInstallTask = null;
425        }
426    }
427
428    /**
429     * Updates the install button (install state, text, color, etc.).
430     */
431    void updateButtonStatus() {
432        if (mInstallButtonView == null) return;
433
434        // Determine if the saved install status of the app is out of date.
435        // It is not easily possible to detect if an app is in the process of being installed, so we
436        // can't properly transition to that state from here.
437        if (getAppLaunchIntent() == null) {
438            if (mInstallState == INSTALL_STATE_INSTALLED) {
439                mInstallState = INSTALL_STATE_NOT_INSTALLED;
440            }
441        } else {
442            mInstallState = INSTALL_STATE_INSTALLED;
443        }
444
445        // Update what the button looks like.
446        Resources res = getResources();
447        int fgColor;
448        String text;
449        if (mInstallState == INSTALL_STATE_INSTALLED) {
450            ApiCompatibilityUtils.setBackgroundForView(mInstallButtonView,
451                    res.getDrawable(R.drawable.app_banner_button_open));
452            fgColor = res.getColor(R.color.app_banner_open_button_fg);
453            text = res.getString(R.string.app_banner_open);
454        } else {
455            ApiCompatibilityUtils.setBackgroundForView(mInstallButtonView,
456                    res.getDrawable(R.drawable.app_banner_button_install));
457            fgColor = res.getColor(R.color.app_banner_install_button_fg);
458            if (mInstallState == INSTALL_STATE_NOT_INSTALLED) {
459                text = mAppData.installButtonText();
460                mInstallButtonView.setContentDescription(
461                        getContext().getString(R.string.app_banner_install_accessibility, text));
462            } else {
463                text = res.getString(R.string.app_banner_installing);
464            }
465        }
466
467        mInstallButtonView.setTextColor(fgColor);
468        mInstallButtonView.setText(text);
469        mInstallButtonView.setEnabled(mInstallState != INSTALL_STATE_INSTALLING);
470    }
471
472    /**
473     * Determine how big an icon needs to be for the Layout.
474     * @param context Context to grab resources from.
475     * @return        How big the icon is expected to be, in pixels.
476     */
477    static int getIconSize(Context context) {
478        return context.getResources().getDimensionPixelSize(R.dimen.app_banner_icon_size);
479    }
480
481    /**
482     * Passes all touch events through to the parent.
483     */
484    @Override
485    public boolean onTouchEvent(MotionEvent event) {
486        int action = event.getActionMasked();
487        if (mIsBannerPressed) {
488            // Mimic Google Now card behavior, where the card stops being highlighted if the user
489            // scrolls a bit to the side.
490            float xDifference = Math.abs(event.getRawX() - mInitialXForHighlight);
491            if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL
492                    || (action == MotionEvent.ACTION_MOVE && xDifference > mTouchSlop)) {
493                mIsBannerPressed = false;
494                mBannerHighlightView.setVisibility(View.INVISIBLE);
495            }
496        }
497
498        return super.onTouchEvent(event);
499    }
500
501    /**
502     * Fade the banner back into view.
503     */
504    @Override
505    protected void onAttachedToWindow() {
506        super.onAttachedToWindow();
507        ObjectAnimator.ofFloat(this, "alpha", getAlpha(), 1.f).setDuration(
508                MS_ANIMATION_DURATION).start();
509        setVisibility(VISIBLE);
510    }
511
512    /**
513     * Immediately hide the banner to avoid having them show up in snapshots.
514     */
515    @Override
516    protected void onDetachedFromWindow() {
517        super.onDetachedFromWindow();
518        setAlpha(0.0f);
519        setVisibility(INVISIBLE);
520    }
521
522    /**
523     * Watch for changes in the available screen height, which triggers a complete recreation of the
524     * banner widgets.  This is mainly due to the fact that the Nexus 7 has a smaller banner defined
525     * for its landscape versus its portrait layouts.
526     */
527    @Override
528    protected void onConfigurationChanged(Configuration config) {
529        super.onConfigurationChanged(config);
530
531        if (isDismissed()) return;
532
533        // If the card's maximum width hasn't changed, the individual views can't have, either.
534        int newDefinedWidth = getResources().getDimensionPixelSize(R.dimen.app_banner_max_width);
535        if (mDefinedMaxWidth == newDefinedWidth) return;
536
537        // Cannibalize another version of this layout to get Views using the new resources and
538        // sizes.
539        while (getChildCount() > 0) removeViewAt(0);
540        mIconView = null;
541        mTitleView = null;
542        mInstallButtonView = null;
543        mRatingView = null;
544        mLogoView = null;
545        mBannerHighlightView = null;
546
547        AppBannerView cannibalized =
548                (AppBannerView) LayoutInflater.from(getContext()).inflate(BANNER_LAYOUT, null);
549        while (cannibalized.getChildCount() > 0) {
550            View child = cannibalized.getChildAt(0);
551            cannibalized.removeViewAt(0);
552            addView(child);
553        }
554        initializeControls();
555        requestLayout();
556    }
557
558    @Override
559    public void onWindowFocusChanged(boolean hasWindowFocus) {
560        if (hasWindowFocus) updateButtonStatus();
561    }
562
563    /**
564     * @return Intent to launch the app that is being promoted.
565     */
566    private Intent getAppLaunchIntent() {
567        String packageName = mAppData.packageName();
568        PackageManager packageManager = getContext().getPackageManager();
569        return packageManager.getLaunchIntentForPackage(packageName);
570    }
571
572    /**
573     * Measures the banner and its children Views for the given space.
574     *
575     * DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD
576     * DPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPD
577     * DP......                               cPD
578     * DP...... TITLE----------------------- XcPD
579     * DP.ICON. *****                         cPD
580     * DP...... LOGO                    BUTTONcPD
581     * DP...... cccccccccccccccccccccccccccccccPD
582     * DPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPD
583     * DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD
584     *
585     * The three paddings mentioned in the class Javadoc are denoted by:
586     * D) Drop shadow padding.
587     * P) Inner card padding.
588     * c) Control padding.
589     *
590     * Measurement for components of the banner are performed assuming that components are laid out
591     * inside of the banner's background as follows:
592     * 1) A maximum width is enforced on the banner to keep the whole thing on screen and keep it a
593     *    reasonable size.
594     * 2) The icon takes up the left side of the banner.
595     * 3) The install button occupies the bottom-right of the banner.
596     * 4) The Google Play logo occupies the space to the left of the button.
597     * 5) The rating is assigned space above the logo and below the title.
598     * 6) The close button (if visible) sits in the top right of the banner.
599     * 7) The title is assigned whatever space is left and sits on top of the tallest stack of
600     *    controls.
601     *
602     * See {@link #android.view.View.onMeasure(int, int)} for the parameters.
603     */
604    @Override
605    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
606        // Enforce a maximum width on the banner, which is defined as the smallest of:
607        // 1) The smallest width for the device (in either landscape or portrait mode).
608        // 2) The defined maximum width in the dimens.xml files.
609        // 3) The width passed in through the MeasureSpec.
610        Resources res = getResources();
611        float density = res.getDisplayMetrics().density;
612        int screenSmallestWidth = (int) (res.getConfiguration().smallestScreenWidthDp * density);
613        int specWidth = MeasureSpec.getSize(widthMeasureSpec);
614        int bannerWidth = Math.min(Math.min(specWidth, mDefinedMaxWidth), screenSmallestWidth);
615
616        // Track how much space is available inside the banner's card-shaped background Drawable.
617        // To calculate this, we need to account for both the padding of the background (which
618        // is occupied by the card's drop shadows) as well as the padding defined on the inside of
619        // the card.
620        int bgPaddingWidth = mBackgroundDrawablePadding.left + mBackgroundDrawablePadding.right;
621        int bgPaddingHeight = mBackgroundDrawablePadding.top + mBackgroundDrawablePadding.bottom;
622        final int maxControlWidth = bannerWidth - bgPaddingWidth - (mPaddingCard * 2);
623
624        // Control height is constrained to provide a reasonable aspect ratio.
625        // In practice, the only controls which can cause an issue are the title and the install
626        // button, since they have strings that can change size according to user preference.  The
627        // other controls are all defined to be a certain height.
628        int specHeight = MeasureSpec.getSize(heightMeasureSpec);
629        int reasonableHeight = maxControlWidth / 4;
630        int paddingHeight = bgPaddingHeight + (mPaddingCard * 2);
631        final int maxControlHeight = Math.min(specHeight, reasonableHeight) - paddingHeight;
632        final int maxStackedControlHeight = maxControlWidth / 3;
633
634        // Determine how big each component wants to be.  The icon is measured separately because
635        // it is not stacked with the other controls.
636        measureChildForSpace(mIconView, maxControlWidth, maxControlHeight);
637        for (int i = 0; i < getChildCount(); i++) {
638            if (getChildAt(i) != mIconView) {
639                measureChildForSpace(getChildAt(i), maxControlWidth, maxStackedControlHeight);
640            }
641        }
642
643        // Determine how tall the banner needs to be to fit everything by calculating the combined
644        // height of the stacked controls.  There are three competing stacks to measure:
645        // 1) The icon.
646        // 2) The app title + control padding + star rating + store logo.
647        // 3) The app title + control padding + install button.
648        // The control padding is extra padding that applies only to the non-icon widgets.
649        // The close button does not get counted as part of a stack.
650        int iconStackHeight = getHeightWithMargins(mIconView);
651        int logoStackHeight = getHeightWithMargins(mTitleView) + mPaddingControls
652                + getHeightWithMargins(mRatingView) + getHeightWithMargins(mLogoView);
653        int buttonStackHeight = getHeightWithMargins(mTitleView) + mPaddingControls
654                + getHeightWithMargins(mInstallButtonView);
655        int biggestStackHeight =
656                Math.max(iconStackHeight, Math.max(logoStackHeight, buttonStackHeight));
657
658        // The icon hugs the banner's starting edge, from the top of the banner to the bottom.
659        final int iconSize = biggestStackHeight;
660        measureChildForSpaceExactly(mIconView, iconSize, iconSize);
661
662        // The rest of the content is laid out to the right of the icon.
663        // Additional padding is defined for non-icon content on the end and bottom.
664        final int contentWidth =
665                maxControlWidth - getWidthWithMargins(mIconView) - mPaddingControls;
666        final int contentHeight = biggestStackHeight - mPaddingControls;
667        measureChildForSpace(mLogoView, contentWidth, contentHeight);
668
669        // Restrict the button size to prevent overrunning the Google Play logo.
670        int remainingButtonWidth =
671                maxControlWidth - getWidthWithMargins(mLogoView) - getWidthWithMargins(mIconView);
672        mInstallButtonView.setMaxWidth(remainingButtonWidth);
673        measureChildForSpace(mInstallButtonView, contentWidth, contentHeight);
674
675        // Measure the star rating, which sits below the title and above the logo.
676        final int ratingWidth = contentWidth;
677        final int ratingHeight = contentHeight - getHeightWithMargins(mLogoView);
678        measureChildForSpace(mRatingView, ratingWidth, ratingHeight);
679
680        // The close button sits to the right of the title and above the install button.
681        final int closeWidth = contentWidth;
682        final int closeHeight = contentHeight - getHeightWithMargins(mInstallButtonView);
683        measureChildForSpace(mCloseButtonView, closeWidth, closeHeight);
684
685        // The app title spans the top of the banner and sits on top of the other controls, and to
686        // the left of the close button. The computation for the width available to the title is
687        // complicated by how the button sits in the corner and absorbs the padding that would
688        // normally be there.
689        int biggerStack = Math.max(getHeightWithMargins(mInstallButtonView),
690                getHeightWithMargins(mLogoView) + getHeightWithMargins(mRatingView));
691        final int titleWidth = contentWidth - getWidthWithMargins(mCloseButtonView) + mPaddingCard;
692        final int titleHeight = contentHeight - biggerStack;
693        measureChildForSpace(mTitleView, titleWidth, titleHeight);
694
695        // Set the measured dimensions for the banner.  The banner's height is defined by the
696        // tallest stack of components, the padding of the banner's card background, and the extra
697        // padding around the banner's components.
698        int bannerPadding = mBackgroundDrawablePadding.top + mBackgroundDrawablePadding.bottom
699                + (mPaddingCard * 2);
700        int bannerHeight = biggestStackHeight + bannerPadding;
701        setMeasuredDimension(bannerWidth, bannerHeight);
702
703        // Make the banner highlight view be the exact same size as the banner's card background.
704        final int cardWidth = bannerWidth - bgPaddingWidth;
705        final int cardHeight = bannerHeight - bgPaddingHeight;
706        measureChildForSpaceExactly(mBannerHighlightView, cardWidth, cardHeight);
707    }
708
709    /**
710     * Lays out the controls according to the algorithm in {@link #onMeasure}.
711     * See {@link #android.view.View.onLayout(boolean, int, int, int, int)} for the parameters.
712     */
713    @Override
714    protected void onLayout(boolean changed, int l, int t, int r, int b) {
715        super.onLayout(changed, l, t, r, b);
716        int top = mBackgroundDrawablePadding.top;
717        int bottom = getMeasuredHeight() - mBackgroundDrawablePadding.bottom;
718        int start = mBackgroundDrawablePadding.left;
719        int end = getMeasuredWidth() - mBackgroundDrawablePadding.right;
720
721        // The highlight overlay covers the entire banner (minus drop shadow padding).
722        mBannerHighlightView.layout(start, top, end, bottom);
723
724        // Lay out the close button in the top-right corner.  Padding that would normally go to the
725        // card is applied to the close button so that it has a bigger touch target.
726        if (mCloseButtonView.getVisibility() == VISIBLE) {
727            int closeWidth = mCloseButtonView.getMeasuredWidth();
728            int closeTop =
729                    top + ((MarginLayoutParams) mCloseButtonView.getLayoutParams()).topMargin;
730            int closeBottom = closeTop + mCloseButtonView.getMeasuredHeight();
731            int closeRight = mIsLayoutLTR ? end : (getMeasuredWidth() - end + closeWidth);
732            int closeLeft = closeRight - closeWidth;
733            mCloseButtonView.layout(closeLeft, closeTop, closeRight, closeBottom);
734        }
735
736        // Apply the padding for the rest of the widgets.
737        top += mPaddingCard;
738        bottom -= mPaddingCard;
739        start += mPaddingCard;
740        end -= mPaddingCard;
741
742        // Lay out the icon.
743        int iconWidth = mIconView.getMeasuredWidth();
744        int iconLeft = mIsLayoutLTR ? start : (getMeasuredWidth() - start - iconWidth);
745        mIconView.layout(iconLeft, top, iconLeft + iconWidth, top + mIconView.getMeasuredHeight());
746        start += getWidthWithMargins(mIconView);
747
748        // Factor in the additional padding, which is only tacked onto the end and bottom.
749        end -= mPaddingControls;
750        bottom -= mPaddingControls;
751
752        // Lay out the app title text.
753        int titleWidth = mTitleView.getMeasuredWidth();
754        int titleTop = top + ((MarginLayoutParams) mTitleView.getLayoutParams()).topMargin;
755        int titleBottom = titleTop + mTitleView.getMeasuredHeight();
756        int titleLeft = mIsLayoutLTR ? start : (getMeasuredWidth() - start - titleWidth);
757        mTitleView.layout(titleLeft, titleTop, titleLeft + titleWidth, titleBottom);
758
759        // The mock shows the margin eating into the descender area of the TextView.
760        int textBaseline = mTitleView.getLineBounds(mTitleView.getLineCount() - 1, null);
761        top = titleTop + textBaseline
762                + ((MarginLayoutParams) mTitleView.getLayoutParams()).bottomMargin;
763
764        // Lay out the app rating below the title.
765        int starWidth = mRatingView.getMeasuredWidth();
766        int starTop = top + ((MarginLayoutParams) mRatingView.getLayoutParams()).topMargin;
767        int starBottom = starTop + mRatingView.getMeasuredHeight();
768        int starLeft = mIsLayoutLTR ? start : (getMeasuredWidth() - start - starWidth);
769        mRatingView.layout(starLeft, starTop, starLeft + starWidth, starBottom);
770
771        // Lay out the logo in the bottom-left.
772        int logoWidth = mLogoView.getMeasuredWidth();
773        int logoBottom = bottom - ((MarginLayoutParams) mLogoView.getLayoutParams()).bottomMargin;
774        int logoTop = logoBottom - mLogoView.getMeasuredHeight();
775        int logoLeft = mIsLayoutLTR ? start : (getMeasuredWidth() - start - logoWidth);
776        mLogoView.layout(logoLeft, logoTop, logoLeft + logoWidth, logoBottom);
777
778        // Lay out the install button in the bottom-right corner.
779        int buttonHeight = mInstallButtonView.getMeasuredHeight();
780        int buttonWidth = mInstallButtonView.getMeasuredWidth();
781        int buttonRight = mIsLayoutLTR ? end : (getMeasuredWidth() - end + buttonWidth);
782        int buttonLeft = buttonRight - buttonWidth;
783        mInstallButtonView.layout(buttonLeft, bottom - buttonHeight, buttonRight, bottom);
784    }
785
786    /**
787     * Measures a child for the given space, accounting for defined heights and margins.
788     * @param child           View to measure.
789     * @param availableWidth  Available width for the view.
790     * @param availableHeight Available height for the view.
791     */
792    private void measureChildForSpace(View child, int availableWidth, int availableHeight) {
793        // Handle margins.
794        availableWidth -= getMarginWidth(child);
795        availableHeight -= getMarginHeight(child);
796
797        // Account for any layout-defined dimensions for the view.
798        int childWidth = child.getLayoutParams().width;
799        int childHeight = child.getLayoutParams().height;
800        if (childWidth >= 0) availableWidth = Math.min(availableWidth, childWidth);
801        if (childHeight >= 0) availableHeight = Math.min(availableHeight, childHeight);
802
803        int widthSpec = MeasureSpec.makeMeasureSpec(availableWidth, MeasureSpec.AT_MOST);
804        int heightSpec = MeasureSpec.makeMeasureSpec(availableHeight, MeasureSpec.AT_MOST);
805        child.measure(widthSpec, heightSpec);
806    }
807
808    /**
809     * Forces a child to exactly occupy the given space.
810     * @param child           View to measure.
811     * @param availableWidth  Available width for the view.
812     * @param availableHeight Available height for the view.
813     */
814    private void measureChildForSpaceExactly(View child, int availableWidth, int availableHeight) {
815        int widthSpec = MeasureSpec.makeMeasureSpec(availableWidth, MeasureSpec.EXACTLY);
816        int heightSpec = MeasureSpec.makeMeasureSpec(availableHeight, MeasureSpec.EXACTLY);
817        child.measure(widthSpec, heightSpec);
818    }
819
820    /**
821     * Calculates how wide the margins are for the given View.
822     * @param view View to measure.
823     * @return     Measured width of the margins.
824     */
825    private static int getMarginWidth(View view) {
826        MarginLayoutParams params = (MarginLayoutParams) view.getLayoutParams();
827        return params.leftMargin + params.rightMargin;
828    }
829
830    /**
831     * Calculates how wide the given View has been measured to be, including its margins.
832     * @param view View to measure.
833     * @return     Measured width of the view plus its margins.
834     */
835    private static int getWidthWithMargins(View view) {
836        return view.getMeasuredWidth() + getMarginWidth(view);
837    }
838
839    /**
840     * Calculates how tall the margins are for the given View.
841     * @param view View to measure.
842     * @return     Measured height of the margins.
843     */
844    private static int getMarginHeight(View view) {
845        MarginLayoutParams params = (MarginLayoutParams) view.getLayoutParams();
846        return params.topMargin + params.bottomMargin;
847    }
848
849    /**
850     * Calculates how tall the given View has been measured to be, including its margins.
851     * @param view View to measure.
852     * @return     Measured height of the view plus its margins.
853     */
854    private static int getHeightWithMargins(View view) {
855        return view.getMeasuredHeight() + getMarginHeight(view);
856    }
857}
858