1 package com.android.systemui.statusbar.policy;
2 
3 import static java.lang.Float.NaN;
4 
5 import android.annotation.ColorInt;
6 import android.app.Notification;
7 import android.app.PendingIntent;
8 import android.app.RemoteInput;
9 import android.content.Context;
10 import android.content.res.ColorStateList;
11 import android.content.res.TypedArray;
12 import android.graphics.Canvas;
13 import android.graphics.Color;
14 import android.graphics.drawable.Drawable;
15 import android.graphics.drawable.GradientDrawable;
16 import android.graphics.drawable.InsetDrawable;
17 import android.graphics.drawable.RippleDrawable;
18 import android.os.SystemClock;
19 import android.text.Layout;
20 import android.text.TextPaint;
21 import android.text.method.TransformationMethod;
22 import android.util.AttributeSet;
23 import android.util.IndentingPrintWriter;
24 import android.util.Log;
25 import android.view.LayoutInflater;
26 import android.view.View;
27 import android.view.ViewGroup;
28 import android.widget.Button;
29 import android.widget.TextView;
30 
31 import androidx.annotation.NonNull;
32 
33 import com.android.internal.annotations.VisibleForTesting;
34 import com.android.internal.util.ContrastColorUtil;
35 import com.android.systemui.R;
36 import com.android.systemui.statusbar.notification.NotificationUtils;
37 
38 import java.text.BreakIterator;
39 import java.util.ArrayList;
40 import java.util.Comparator;
41 import java.util.List;
42 import java.util.PriorityQueue;
43 
44 /** View which displays smart reply and smart actions buttons in notifications. */
45 public class SmartReplyView extends ViewGroup {
46 
47     private static final String TAG = "SmartReplyView";
48 
49     private static final int MEASURE_SPEC_ANY_LENGTH =
50             MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
51 
52     private static final Comparator<View> DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR =
53             (v1, v2) -> ((v2.getMeasuredWidth() - v2.getPaddingLeft() - v2.getPaddingRight())
54                     - (v1.getMeasuredWidth() - v1.getPaddingLeft() - v1.getPaddingRight()));
55 
56     private static final int SQUEEZE_FAILED = -1;
57 
58     /**
59      * The upper bound for the height of this view in pixels. Notifications are automatically
60      * recreated on density or font size changes so caching this should be fine.
61      */
62     private final int mHeightUpperLimit;
63 
64     /** Spacing to be applied between views. */
65     private final int mSpacing;
66 
67     private final BreakIterator mBreakIterator;
68 
69     private PriorityQueue<Button> mCandidateButtonQueueForSqueezing;
70 
71     private View mSmartReplyContainer;
72 
73     /**
74      * Whether the smart replies in this view were generated by the notification assistant. If not
75      * they're provided by the app.
76      */
77     private boolean mSmartRepliesGeneratedByAssistant = false;
78 
79     @ColorInt private int mCurrentBackgroundColor;
80     @ColorInt private final int mDefaultBackgroundColor;
81     @ColorInt private final int mDefaultStrokeColor;
82     @ColorInt private final int mDefaultTextColor;
83     @ColorInt private final int mDefaultTextColorDarkBg;
84     @ColorInt private final int mRippleColorDarkBg;
85     @ColorInt private final int mRippleColor;
86     private final int mStrokeWidth;
87     private final double mMinStrokeContrast;
88 
89     @ColorInt private int mCurrentStrokeColor;
90     @ColorInt private int mCurrentTextColor;
91     @ColorInt private int mCurrentRippleColor;
92     private boolean mCurrentColorized;
93     private int mMaxSqueezeRemeasureAttempts;
94     private int mMaxNumActions;
95     private int mMinNumSystemGeneratedReplies;
96 
97     // DEBUG variables tracked for the dump()
98     private long mLastDrawChildTime;
99     private long mLastDispatchDrawTime;
100     private long mLastMeasureTime;
101     private int mTotalSqueezeRemeasureAttempts;
102     private boolean mDidHideSystemReplies;
103 
SmartReplyView(Context context, AttributeSet attrs)104     public SmartReplyView(Context context, AttributeSet attrs) {
105         super(context, attrs);
106 
107         mHeightUpperLimit = NotificationUtils.getFontScaledHeight(mContext,
108             R.dimen.smart_reply_button_max_height);
109 
110         mDefaultBackgroundColor = context.getColor(R.color.smart_reply_button_background);
111         mDefaultTextColor = mContext.getColor(R.color.smart_reply_button_text);
112         mDefaultTextColorDarkBg = mContext.getColor(R.color.smart_reply_button_text_dark_bg);
113         mDefaultStrokeColor = mContext.getColor(R.color.smart_reply_button_stroke);
114         mRippleColor = mContext.getColor(R.color.notification_ripple_untinted_color);
115         mRippleColorDarkBg = Color.argb(Color.alpha(mRippleColor),
116                 255 /* red */, 255 /* green */, 255 /* blue */);
117         mMinStrokeContrast = ContrastColorUtil.calculateContrast(mDefaultStrokeColor,
118                 mDefaultBackgroundColor);
119 
120         int spacing = 0;
121         int strokeWidth = 0;
122 
123         final TypedArray arr = context.obtainStyledAttributes(attrs, R.styleable.SmartReplyView,
124                 0, 0);
125         final int length = arr.getIndexCount();
126         for (int i = 0; i < length; i++) {
127             int attr = arr.getIndex(i);
128             if (attr == R.styleable.SmartReplyView_spacing) {
129                 spacing = arr.getDimensionPixelSize(i, 0);
130             } else if (attr == R.styleable.SmartReplyView_buttonStrokeWidth) {
131                 strokeWidth = arr.getDimensionPixelSize(i, 0);
132             }
133         }
134         arr.recycle();
135 
136         mStrokeWidth = strokeWidth;
137         mSpacing = spacing;
138 
139         mBreakIterator = BreakIterator.getLineInstance();
140 
141         setBackgroundTintColor(mDefaultBackgroundColor, false /* colorized */);
142         reallocateCandidateButtonQueueForSqueezing();
143     }
144 
145     /**
146      * Inflate an instance of this class.
147      */
inflate(Context context, SmartReplyConstants constants)148     public static SmartReplyView inflate(Context context, SmartReplyConstants constants) {
149         SmartReplyView view = (SmartReplyView) LayoutInflater.from(context).inflate(
150                 R.layout.smart_reply_view, null /* root */);
151         view.setMaxNumActions(constants.getMaxNumActions());
152         view.setMaxSqueezeRemeasureAttempts(constants.getMaxSqueezeRemeasureAttempts());
153         view.setMinNumSystemGeneratedReplies(constants.getMinNumSystemGeneratedReplies());
154         return view;
155     }
156 
157     /**
158      * Returns an upper bound for the height of this view in pixels. This method is intended to be
159      * invoked before onMeasure, so it doesn't do any analysis on the contents of the buttons.
160      */
getHeightUpperLimit()161     public int getHeightUpperLimit() {
162        return mHeightUpperLimit;
163     }
164 
reallocateCandidateButtonQueueForSqueezing()165     private void reallocateCandidateButtonQueueForSqueezing() {
166         // Instead of clearing the priority queue, we re-allocate so that it would fit all buttons
167         // exactly. This avoids (1) wasting memory because PriorityQueue never shrinks and
168         // (2) growing in onMeasure.
169         // The constructor throws an IllegalArgument exception if initial capacity is less than 1.
170         mCandidateButtonQueueForSqueezing = new PriorityQueue<>(
171                 Math.max(getChildCount(), 1), DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR);
172     }
173 
174     /**
175      * Reset the smart suggestions view to allow adding new replies and actions.
176      */
resetSmartSuggestions(View newSmartReplyContainer)177     public void resetSmartSuggestions(View newSmartReplyContainer) {
178         mSmartReplyContainer = newSmartReplyContainer;
179         removeAllViews();
180         setBackgroundTintColor(mDefaultBackgroundColor, false /* colorized */);
181     }
182 
183     /** Add buttons to the {@link SmartReplyView} */
addPreInflatedButtons(List<Button> smartSuggestionButtons)184     public void addPreInflatedButtons(List<Button> smartSuggestionButtons) {
185         for (Button button : smartSuggestionButtons) {
186             addView(button);
187             setButtonColors(button);
188         }
189         reallocateCandidateButtonQueueForSqueezing();
190     }
191 
setMaxNumActions(int maxNumActions)192     public void setMaxNumActions(int maxNumActions) {
193         mMaxNumActions = maxNumActions;
194     }
195 
setMinNumSystemGeneratedReplies(int minNumSystemGeneratedReplies)196     public void setMinNumSystemGeneratedReplies(int minNumSystemGeneratedReplies) {
197         mMinNumSystemGeneratedReplies = minNumSystemGeneratedReplies;
198     }
199 
setMaxSqueezeRemeasureAttempts(int maxSqueezeRemeasureAttempts)200     public void setMaxSqueezeRemeasureAttempts(int maxSqueezeRemeasureAttempts) {
201         mMaxSqueezeRemeasureAttempts = maxSqueezeRemeasureAttempts;
202     }
203 
204     @Override
generateLayoutParams(AttributeSet attrs)205     public LayoutParams generateLayoutParams(AttributeSet attrs) {
206         return new LayoutParams(mContext, attrs);
207     }
208 
209     @Override
generateDefaultLayoutParams()210     protected LayoutParams generateDefaultLayoutParams() {
211         return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
212     }
213 
214     @Override
generateLayoutParams(ViewGroup.LayoutParams params)215     protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams params) {
216         return new LayoutParams(params.width, params.height);
217     }
218 
clearLayoutLineCount(View view)219     private void clearLayoutLineCount(View view) {
220         if (view instanceof TextView) {
221             ((TextView) view).nullLayouts();
222             view.forceLayout();
223         }
224     }
225 
226     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)227     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
228         final int targetWidth = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED
229                 ? Integer.MAX_VALUE : MeasureSpec.getSize(widthMeasureSpec);
230 
231         // Mark all buttons as hidden and un-squeezed.
232         resetButtonsLayoutParams();
233         mTotalSqueezeRemeasureAttempts = 0;
234 
235         if (!mCandidateButtonQueueForSqueezing.isEmpty()) {
236             Log.wtf(TAG, "Single line button queue leaked between onMeasure calls");
237             mCandidateButtonQueueForSqueezing.clear();
238         }
239 
240         SmartSuggestionMeasures accumulatedMeasures = new SmartSuggestionMeasures(
241                 mPaddingLeft + mPaddingRight,
242                 0 /* maxChildHeight */);
243         int displayedChildCount = 0;
244 
245         // Set up a list of suggestions where actions come before replies. Note that the Buttons
246         // themselves have already been added to the view hierarchy in an order such that Smart
247         // Replies are shown before Smart Actions. The order of the list below determines which
248         // suggestions will be shown at all - only the first X elements are shown (where X depends
249         // on how much space each suggestion button needs).
250         List<View> smartActions = filterActionsOrReplies(SmartButtonType.ACTION);
251         List<View> smartReplies = filterActionsOrReplies(SmartButtonType.REPLY);
252         List<View> smartSuggestions = new ArrayList<>(smartActions);
253         smartSuggestions.addAll(smartReplies);
254         List<View> coveredSuggestions = new ArrayList<>();
255 
256         // SmartSuggestionMeasures for all action buttons, this will be filled in when the first
257         // reply button is added.
258         SmartSuggestionMeasures actionsMeasures = null;
259 
260         final int maxNumActions = mMaxNumActions;
261         int numShownActions = 0;
262 
263         for (View child : smartSuggestions) {
264             final LayoutParams lp = (LayoutParams) child.getLayoutParams();
265             if (maxNumActions != -1 // -1 means 'no limit'
266                     && lp.mButtonType == SmartButtonType.ACTION
267                     && numShownActions >= maxNumActions) {
268                 // We've reached the maximum number of actions, don't add another one!
269                 continue;
270             }
271 
272             clearLayoutLineCount(child);
273             child.measure(MEASURE_SPEC_ANY_LENGTH, heightMeasureSpec);
274             if (((Button) child).getLayout() == null) {
275                 Log.wtf(TAG, "Button layout is null after measure.");
276             }
277 
278             coveredSuggestions.add(child);
279 
280             final int lineCount = ((Button) child).getLineCount();
281             if (lineCount < 1 || lineCount > 2) {
282                 // If smart reply has no text, or more than two lines, then don't show it.
283                 continue;
284             }
285 
286             if (lineCount == 1) {
287                 mCandidateButtonQueueForSqueezing.add((Button) child);
288             }
289 
290             // Remember the current measurements in case the current button doesn't fit in.
291             SmartSuggestionMeasures originalMeasures = accumulatedMeasures.clone();
292             if (actionsMeasures == null && lp.mButtonType == SmartButtonType.REPLY) {
293                 // We've added all actions (we go through actions first), now add their
294                 // measurements.
295                 actionsMeasures = accumulatedMeasures.clone();
296             }
297 
298             final int spacing = displayedChildCount == 0 ? 0 : mSpacing;
299             final int childWidth = child.getMeasuredWidth();
300             final int childHeight = child.getMeasuredHeight();
301             accumulatedMeasures.mMeasuredWidth += spacing + childWidth;
302             accumulatedMeasures.mMaxChildHeight =
303                     Math.max(accumulatedMeasures.mMaxChildHeight, childHeight);
304 
305             // If the last button doesn't fit into the remaining width, try squeezing preceding
306             // smart reply buttons.
307             if (accumulatedMeasures.mMeasuredWidth > targetWidth) {
308                 // Keep squeezing preceding and current smart reply buttons until they all fit.
309                 while (accumulatedMeasures.mMeasuredWidth > targetWidth
310                         && !mCandidateButtonQueueForSqueezing.isEmpty()) {
311                     final Button candidate = mCandidateButtonQueueForSqueezing.poll();
312                     final int squeezeReduction = squeezeButton(candidate, heightMeasureSpec);
313                     if (squeezeReduction != SQUEEZE_FAILED) {
314                         accumulatedMeasures.mMaxChildHeight =
315                                 Math.max(accumulatedMeasures.mMaxChildHeight,
316                                         candidate.getMeasuredHeight());
317                         accumulatedMeasures.mMeasuredWidth -= squeezeReduction;
318                     }
319                 }
320 
321                 // If the current button still doesn't fit after squeezing all buttons, undo the
322                 // last squeezing round.
323                 if (accumulatedMeasures.mMeasuredWidth > targetWidth) {
324                     accumulatedMeasures = originalMeasures;
325 
326                     // Mark all buttons from the last squeezing round as "failed to squeeze", so
327                     // that they're re-measured without squeezing later.
328                     markButtonsWithPendingSqueezeStatusAs(
329                             LayoutParams.SQUEEZE_STATUS_FAILED, coveredSuggestions);
330 
331                     // The current button doesn't fit, keep on adding lower-priority buttons in case
332                     // any of those fit.
333                     continue;
334                 }
335 
336                 // The current button fits, so mark all squeezed buttons as "successfully squeezed"
337                 // to prevent them from being un-squeezed in a subsequent squeezing round.
338                 markButtonsWithPendingSqueezeStatusAs(
339                         LayoutParams.SQUEEZE_STATUS_SUCCESSFUL, coveredSuggestions);
340             }
341 
342             lp.show = true;
343             displayedChildCount++;
344             if (lp.mButtonType == SmartButtonType.ACTION) {
345                 numShownActions++;
346             }
347         }
348 
349         mDidHideSystemReplies = false;
350         if (mSmartRepliesGeneratedByAssistant) {
351             if (!gotEnoughSmartReplies(smartReplies)) {
352                 // We don't have enough smart replies - hide all of them.
353                 for (View smartReplyButton : smartReplies) {
354                     final LayoutParams lp = (LayoutParams) smartReplyButton.getLayoutParams();
355                     lp.show = false;
356                 }
357                 // Reset our measures back to when we had only added actions (before adding
358                 // replies).
359                 accumulatedMeasures = actionsMeasures;
360                 mDidHideSystemReplies = true;
361             }
362         }
363 
364         // We're done squeezing buttons, so we can clear the priority queue.
365         mCandidateButtonQueueForSqueezing.clear();
366 
367         // Finally, we need to re-measure some buttons.
368         remeasureButtonsIfNecessary(accumulatedMeasures.mMaxChildHeight);
369 
370         int buttonHeight = Math.max(getSuggestedMinimumHeight(), mPaddingTop
371                 + accumulatedMeasures.mMaxChildHeight + mPaddingBottom);
372 
373         setMeasuredDimension(
374                 resolveSize(Math.max(getSuggestedMinimumWidth(),
375                                      accumulatedMeasures.mMeasuredWidth),
376                             widthMeasureSpec),
377                 resolveSize(buttonHeight, heightMeasureSpec));
378         mLastMeasureTime = SystemClock.elapsedRealtime();
379     }
380 
381     // TODO: this should be replaced, and instead, setMinSystemGenerated... should be invoked
382     //  with MAX_VALUE if mSmartRepliesGeneratedByAssistant would be false (essentially, this is a
383     //  ViewModel decision, as opposed to a View decision)
setSmartRepliesGeneratedByAssistant(boolean fromAssistant)384     void setSmartRepliesGeneratedByAssistant(boolean fromAssistant) {
385         mSmartRepliesGeneratedByAssistant = fromAssistant;
386     }
387 
hideSmartSuggestions()388     void hideSmartSuggestions() {
389         if (mSmartReplyContainer != null) {
390             mSmartReplyContainer.setVisibility(View.GONE);
391         }
392     }
393 
394     /** Dump internal state for debugging */
dump(IndentingPrintWriter pw)395     public void dump(IndentingPrintWriter pw) {
396         pw.println(this);
397         pw.increaseIndent();
398         pw.print("mMaxSqueezeRemeasureAttempts=");
399         pw.println(mMaxSqueezeRemeasureAttempts);
400         pw.print("mTotalSqueezeRemeasureAttempts=");
401         pw.println(mTotalSqueezeRemeasureAttempts);
402         pw.print("mMaxNumActions=");
403         pw.println(mMaxNumActions);
404         pw.print("mSmartRepliesGeneratedByAssistant=");
405         pw.println(mSmartRepliesGeneratedByAssistant);
406         pw.print("mMinNumSystemGeneratedReplies=");
407         pw.println(mMinNumSystemGeneratedReplies);
408         pw.print("mHeightUpperLimit=");
409         pw.println(mHeightUpperLimit);
410         pw.print("mDidHideSystemReplies=");
411         pw.println(mDidHideSystemReplies);
412         long now = SystemClock.elapsedRealtime();
413         pw.print("lastMeasureAge (s)=");
414         pw.println(mLastMeasureTime == 0 ? NaN : (now - mLastMeasureTime) / 1000.0f);
415         pw.print("lastDrawChildAge (s)=");
416         pw.println(mLastDrawChildTime == 0 ? NaN : (now - mLastDrawChildTime) / 1000.0f);
417         pw.print("lastDispatchDrawAge (s)=");
418         pw.println(mLastDispatchDrawTime == 0 ? NaN : (now - mLastDispatchDrawTime) / 1000.0f);
419         int numChildren = getChildCount();
420         pw.print("children: num=");
421         pw.println(numChildren);
422         pw.increaseIndent();
423         for (int i = 0; i < numChildren; i++) {
424             View child = getChildAt(i);
425             LayoutParams lp = (LayoutParams) child.getLayoutParams();
426             pw.print("[");
427             pw.print(i);
428             pw.print("] type=");
429             pw.print(lp.mButtonType);
430             pw.print(" squeezeStatus=");
431             pw.print(lp.squeezeStatus);
432             pw.print(" show=");
433             pw.print(lp.show);
434             pw.print(" view=");
435             pw.println(child);
436         }
437         pw.decreaseIndent();
438         pw.decreaseIndent();
439     }
440 
441     /**
442      * Fields we keep track of inside onMeasure() to correctly measure the SmartReplyView depending
443      * on which suggestions are added.
444      */
445     private static class SmartSuggestionMeasures {
446         int mMeasuredWidth = -1;
447         int mMaxChildHeight = -1;
448 
SmartSuggestionMeasures(int measuredWidth, int maxChildHeight)449         SmartSuggestionMeasures(int measuredWidth, int maxChildHeight) {
450             this.mMeasuredWidth = measuredWidth;
451             this.mMaxChildHeight = maxChildHeight;
452         }
453 
clone()454         public SmartSuggestionMeasures clone() {
455             return new SmartSuggestionMeasures(mMeasuredWidth, mMaxChildHeight);
456         }
457     }
458 
459     /**
460      * Returns whether our notification contains at least N smart replies (or 0) where N is
461      * determined by {@link SmartReplyConstants}.
462      */
gotEnoughSmartReplies(List<View> smartReplies)463     private boolean gotEnoughSmartReplies(List<View> smartReplies) {
464         if (mMinNumSystemGeneratedReplies <= 1) {
465             // Count is irrelevant, do not bother.
466             return true;
467         }
468         int numShownReplies = 0;
469         for (View smartReplyButton : smartReplies) {
470             final LayoutParams lp = (LayoutParams) smartReplyButton.getLayoutParams();
471             if (lp.show) {
472                 numShownReplies++;
473             }
474         }
475         if (numShownReplies == 0 || numShownReplies >= mMinNumSystemGeneratedReplies) {
476             // We have enough replies, yay!
477             return true;
478         }
479         return false;
480     }
481 
filterActionsOrReplies(SmartButtonType buttonType)482     private List<View> filterActionsOrReplies(SmartButtonType buttonType) {
483         List<View> actions = new ArrayList<>();
484         final int childCount = getChildCount();
485         for (int i = 0; i < childCount; i++) {
486             final View child = getChildAt(i);
487             final LayoutParams lp = (LayoutParams) child.getLayoutParams();
488             if (child.getVisibility() != View.VISIBLE || !(child instanceof Button)) {
489                 continue;
490             }
491             if (lp.mButtonType == buttonType) {
492                 actions.add(child);
493             }
494         }
495         return actions;
496     }
497 
resetButtonsLayoutParams()498     private void resetButtonsLayoutParams() {
499         final int childCount = getChildCount();
500         for (int i = 0; i < childCount; i++) {
501             final View child = getChildAt(i);
502             final LayoutParams lp = (LayoutParams) child.getLayoutParams();
503             lp.show = false;
504             lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_NONE;
505         }
506     }
507 
squeezeButton(Button button, int heightMeasureSpec)508     private int squeezeButton(Button button, int heightMeasureSpec) {
509         final int estimatedOptimalTextWidth = estimateOptimalSqueezedButtonTextWidth(button);
510         if (estimatedOptimalTextWidth == SQUEEZE_FAILED) {
511             return SQUEEZE_FAILED;
512         }
513         return squeezeButtonToTextWidth(button, heightMeasureSpec, estimatedOptimalTextWidth);
514     }
515 
estimateOptimalSqueezedButtonTextWidth(Button button)516     private int estimateOptimalSqueezedButtonTextWidth(Button button) {
517         // Find a line-break point in the middle of the smart reply button text.
518         final String rawText = button.getText().toString();
519 
520         // The button sometimes has a transformation affecting text layout (e.g. all caps).
521         final TransformationMethod transformation = button.getTransformationMethod();
522         final String text = transformation == null ?
523                 rawText : transformation.getTransformation(rawText, button).toString();
524         final int length = text.length();
525         mBreakIterator.setText(text);
526 
527         if (mBreakIterator.preceding(length / 2) == BreakIterator.DONE) {
528             if (mBreakIterator.next() == BreakIterator.DONE) {
529                 // Can't find a single possible line break in either direction.
530                 return SQUEEZE_FAILED;
531             }
532         }
533 
534         final TextPaint paint = button.getPaint();
535         final int initialPosition = mBreakIterator.current();
536         final float initialLeftTextWidth = Layout.getDesiredWidth(text, 0, initialPosition, paint);
537         final float initialRightTextWidth =
538                 Layout.getDesiredWidth(text, initialPosition, length, paint);
539         float optimalTextWidth = Math.max(initialLeftTextWidth, initialRightTextWidth);
540 
541         if (initialLeftTextWidth != initialRightTextWidth) {
542             // See if there's a better line-break point (leading to a more narrow button) in
543             // either left or right direction.
544             final boolean moveLeft = initialLeftTextWidth > initialRightTextWidth;
545             final int maxSqueezeRemeasureAttempts = mMaxSqueezeRemeasureAttempts;
546             for (int i = 0; i < maxSqueezeRemeasureAttempts; i++) {
547                 mTotalSqueezeRemeasureAttempts++;
548                 final int newPosition =
549                         moveLeft ? mBreakIterator.previous() : mBreakIterator.next();
550                 if (newPosition == BreakIterator.DONE) {
551                     break;
552                 }
553 
554                 final float newLeftTextWidth = Layout.getDesiredWidth(text, 0, newPosition, paint);
555                 final float newRightTextWidth =
556                         Layout.getDesiredWidth(text, newPosition, length, paint);
557                 final float newOptimalTextWidth = Math.max(newLeftTextWidth, newRightTextWidth);
558                 if (newOptimalTextWidth < optimalTextWidth) {
559                     optimalTextWidth = newOptimalTextWidth;
560                 } else {
561                     break;
562                 }
563 
564                 boolean tooFar = moveLeft
565                         ? newLeftTextWidth <= newRightTextWidth
566                         : newLeftTextWidth >= newRightTextWidth;
567                 if (tooFar) {
568                     break;
569                 }
570             }
571         }
572 
573         return (int) Math.ceil(optimalTextWidth);
574     }
575 
576     /**
577      * Returns the combined width of the left drawable (the action icon) and the padding between the
578      * drawable and the button text.
579      */
getLeftCompoundDrawableWidthWithPadding(Button button)580     private int getLeftCompoundDrawableWidthWithPadding(Button button) {
581         Drawable[] drawables = button.getCompoundDrawables();
582         Drawable leftDrawable = drawables[0];
583         if (leftDrawable == null) return 0;
584 
585         return leftDrawable.getBounds().width() + button.getCompoundDrawablePadding();
586     }
587 
squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth)588     private int squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth) {
589         int oldWidth = button.getMeasuredWidth();
590 
591         // Re-measure the squeezed smart reply button.
592         clearLayoutLineCount(button);
593         final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(
594                 button.getPaddingLeft() + button.getPaddingRight() + textWidth
595                       + getLeftCompoundDrawableWidthWithPadding(button), MeasureSpec.AT_MOST);
596         button.measure(widthMeasureSpec, heightMeasureSpec);
597         if (button.getLayout() == null) {
598             Log.wtf(TAG, "Button layout is null after measure.");
599         }
600 
601         final int newWidth = button.getMeasuredWidth();
602 
603         final LayoutParams lp = (LayoutParams) button.getLayoutParams();
604         if (button.getLineCount() > 2 || newWidth >= oldWidth) {
605             lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_FAILED;
606             return SQUEEZE_FAILED;
607         } else {
608             lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_PENDING;
609             return oldWidth - newWidth;
610         }
611     }
612 
remeasureButtonsIfNecessary(int maxChildHeight)613     private void remeasureButtonsIfNecessary(int maxChildHeight) {
614         final int maxChildHeightMeasure =
615                 MeasureSpec.makeMeasureSpec(maxChildHeight, MeasureSpec.EXACTLY);
616 
617         final int childCount = getChildCount();
618         for (int i = 0; i < childCount; i++) {
619             final View child = getChildAt(i);
620             final LayoutParams lp = (LayoutParams) child.getLayoutParams();
621             if (!lp.show) {
622                 continue;
623             }
624 
625             boolean requiresNewMeasure = false;
626             int newWidth = child.getMeasuredWidth();
627 
628             // Re-measure reason 1: The button needs to be un-squeezed (either because it resulted
629             // in more than two lines or because it was unnecessary).
630             if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_FAILED) {
631                 requiresNewMeasure = true;
632                 newWidth = Integer.MAX_VALUE;
633             }
634 
635             // Re-measure reason 2: The button's height is less than the max height of all buttons
636             // (all should have the same height).
637             if (child.getMeasuredHeight() != maxChildHeight) {
638                 requiresNewMeasure = true;
639             }
640 
641             if (requiresNewMeasure) {
642                 child.measure(MeasureSpec.makeMeasureSpec(newWidth, MeasureSpec.AT_MOST),
643                         maxChildHeightMeasure);
644             }
645         }
646     }
647 
markButtonsWithPendingSqueezeStatusAs( int squeezeStatus, List<View> coveredChildren)648     private void markButtonsWithPendingSqueezeStatusAs(
649             int squeezeStatus, List<View> coveredChildren) {
650         for (View child : coveredChildren) {
651             final LayoutParams lp = (LayoutParams) child.getLayoutParams();
652             if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_PENDING) {
653                 lp.squeezeStatus = squeezeStatus;
654             }
655         }
656     }
657 
658     @Override
onLayout(boolean changed, int left, int top, int right, int bottom)659     protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
660         final boolean isRtl = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
661 
662         final int width = right - left;
663         int position = isRtl ? width - mPaddingRight : mPaddingLeft;
664 
665         final int childCount = getChildCount();
666         for (int i = 0; i < childCount; i++) {
667             final View child = getChildAt(i);
668             final LayoutParams lp = (LayoutParams) child.getLayoutParams();
669             if (!lp.show) {
670                 continue;
671             }
672 
673             final int childWidth = child.getMeasuredWidth();
674             final int childHeight = child.getMeasuredHeight();
675             final int childLeft = isRtl ? position - childWidth : position;
676             child.layout(childLeft, 0, childLeft + childWidth, childHeight);
677 
678             final int childWidthWithSpacing = childWidth + mSpacing;
679             if (isRtl) {
680                 position -= childWidthWithSpacing;
681             } else {
682                 position += childWidthWithSpacing;
683             }
684         }
685     }
686 
687     @Override
drawChild(Canvas canvas, View child, long drawingTime)688     protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
689         final LayoutParams lp = (LayoutParams) child.getLayoutParams();
690         if (!lp.show) {
691             return false;
692         }
693         mLastDrawChildTime = SystemClock.elapsedRealtime();
694         return super.drawChild(canvas, child, drawingTime);
695     }
696 
697     @Override
dispatchDraw(Canvas canvas)698     protected void dispatchDraw(Canvas canvas) {
699         super.dispatchDraw(canvas);
700         mLastDispatchDrawTime = SystemClock.elapsedRealtime();
701     }
702 
703     /**
704      * Set the current background color of the notification so that the smart reply buttons can
705      * match it, and calculate other colors (e.g. text, ripple, stroke)
706      */
setBackgroundTintColor(int backgroundColor, boolean colorized)707     public void setBackgroundTintColor(int backgroundColor, boolean colorized) {
708         if (backgroundColor == mCurrentBackgroundColor && colorized == mCurrentColorized) {
709             // Same color ignoring.
710            return;
711         }
712         mCurrentBackgroundColor = backgroundColor;
713         mCurrentColorized = colorized;
714 
715         final boolean dark = Notification.Builder.isColorDark(backgroundColor);
716 
717         mCurrentTextColor = ContrastColorUtil.ensureTextContrast(
718                 dark ? mDefaultTextColorDarkBg : mDefaultTextColor,
719                 backgroundColor | 0xff000000, dark);
720         mCurrentStrokeColor = colorized ? mCurrentTextColor : ContrastColorUtil.ensureContrast(
721                 mDefaultStrokeColor, backgroundColor | 0xff000000, dark, mMinStrokeContrast);
722         mCurrentRippleColor = dark ? mRippleColorDarkBg : mRippleColor;
723 
724         int childCount = getChildCount();
725         for (int i = 0; i < childCount; i++) {
726             setButtonColors((Button) getChildAt(i));
727         }
728     }
729 
setButtonColors(Button button)730     private void setButtonColors(Button button) {
731         Drawable drawable = button.getBackground();
732         if (drawable instanceof RippleDrawable) {
733             // Mutate in case other notifications are using this drawable.
734             drawable = drawable.mutate();
735             RippleDrawable ripple = (RippleDrawable) drawable;
736             ripple.setColor(ColorStateList.valueOf(mCurrentRippleColor));
737             Drawable inset = ripple.getDrawable(0);
738             if (inset instanceof InsetDrawable) {
739                 Drawable background = ((InsetDrawable) inset).getDrawable();
740                 if (background instanceof GradientDrawable) {
741                     GradientDrawable gradientDrawable = (GradientDrawable) background;
742                     gradientDrawable.setColor(mCurrentBackgroundColor);
743                     gradientDrawable.setStroke(mStrokeWidth, mCurrentStrokeColor);
744                 }
745             }
746             button.setBackground(drawable);
747         }
748         button.setTextColor(mCurrentTextColor);
749     }
750 
751     enum SmartButtonType {
752         REPLY,
753         ACTION
754     }
755 
756     @VisibleForTesting
757     static class LayoutParams extends ViewGroup.LayoutParams {
758 
759         /** Button is not squeezed. */
760         private static final int SQUEEZE_STATUS_NONE = 0;
761 
762         /**
763          * Button was successfully squeezed, but it might be un-squeezed later if the squeezing
764          * turns out to have been unnecessary (because there's still not enough space to add another
765          * button).
766          */
767         private static final int SQUEEZE_STATUS_PENDING = 1;
768 
769         /** Button was successfully squeezed and it won't be un-squeezed. */
770         private static final int SQUEEZE_STATUS_SUCCESSFUL = 2;
771 
772         /**
773          * Button wasn't successfully squeezed. The squeezing resulted in more than two lines of
774          * text or it didn't reduce the button's width at all. The button will have to be
775          * re-measured to use only one line of text.
776          */
777         private static final int SQUEEZE_STATUS_FAILED = 3;
778 
779         private boolean show = false;
780         private int squeezeStatus = SQUEEZE_STATUS_NONE;
781         SmartButtonType mButtonType = SmartButtonType.REPLY;
782 
LayoutParams(Context c, AttributeSet attrs)783         private LayoutParams(Context c, AttributeSet attrs) {
784             super(c, attrs);
785         }
786 
LayoutParams(int width, int height)787         private LayoutParams(int width, int height) {
788             super(width, height);
789         }
790 
791         @VisibleForTesting
isShown()792         boolean isShown() {
793             return show;
794         }
795     }
796 
797     /**
798      * Data class for smart replies.
799      */
800     public static class SmartReplies {
801         @NonNull
802         public final RemoteInput remoteInput;
803         @NonNull
804         public final PendingIntent pendingIntent;
805         @NonNull
806         public final List<CharSequence> choices;
807         public final boolean fromAssistant;
808 
SmartReplies(@onNull List<CharSequence> choices, @NonNull RemoteInput remoteInput, @NonNull PendingIntent pendingIntent, boolean fromAssistant)809         public SmartReplies(@NonNull List<CharSequence> choices, @NonNull RemoteInput remoteInput,
810                 @NonNull PendingIntent pendingIntent, boolean fromAssistant) {
811             this.choices = choices;
812             this.remoteInput = remoteInput;
813             this.pendingIntent = pendingIntent;
814             this.fromAssistant = fromAssistant;
815         }
816     }
817 
818 
819     /**
820      * Data class for smart actions.
821      */
822     public static class SmartActions {
823         @NonNull
824         public final List<Notification.Action> actions;
825         public final boolean fromAssistant;
826 
SmartActions(@onNull List<Notification.Action> actions, boolean fromAssistant)827         public SmartActions(@NonNull List<Notification.Action> actions, boolean fromAssistant) {
828             this.actions = actions;
829             this.fromAssistant = fromAssistant;
830         }
831     }
832 }
833