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.res.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                 lp.mNoShowReason = "max-actions-shown";
269                 // We've reached the maximum number of actions, don't add another one!
270                 continue;
271             }
272 
273             clearLayoutLineCount(child);
274             child.measure(MEASURE_SPEC_ANY_LENGTH, heightMeasureSpec);
275             if (((Button) child).getLayout() == null) {
276                 Log.wtf(TAG, "Button layout is null after measure.");
277             }
278 
279             coveredSuggestions.add(child);
280 
281             final int lineCount = ((Button) child).getLineCount();
282             if (lineCount < 1) {
283                 // If smart reply has no text, then don't show it.
284                 lp.mNoShowReason = "line-count-0";
285                 continue;
286 
287             }
288             if (lineCount > 2) {
289                 // If smart reply has more than two lines, then don't show it.
290                 lp.mNoShowReason = "line-count-3+";
291                 continue;
292             }
293 
294             if (lineCount == 1) {
295                 mCandidateButtonQueueForSqueezing.add((Button) child);
296             }
297 
298             // Remember the current measurements in case the current button doesn't fit in.
299             SmartSuggestionMeasures originalMeasures = accumulatedMeasures.clone();
300             if (actionsMeasures == null && lp.mButtonType == SmartButtonType.REPLY) {
301                 // We've added all actions (we go through actions first), now add their
302                 // measurements.
303                 actionsMeasures = accumulatedMeasures.clone();
304             }
305 
306             final int spacing = displayedChildCount == 0 ? 0 : mSpacing;
307             final int childWidth = child.getMeasuredWidth();
308             final int childHeight = child.getMeasuredHeight();
309             accumulatedMeasures.mMeasuredWidth += spacing + childWidth;
310             accumulatedMeasures.mMaxChildHeight =
311                     Math.max(accumulatedMeasures.mMaxChildHeight, childHeight);
312 
313             // If the last button doesn't fit into the remaining width, try squeezing preceding
314             // smart reply buttons.
315             if (accumulatedMeasures.mMeasuredWidth > targetWidth) {
316                 // Keep squeezing preceding and current smart reply buttons until they all fit.
317                 while (accumulatedMeasures.mMeasuredWidth > targetWidth
318                         && !mCandidateButtonQueueForSqueezing.isEmpty()) {
319                     final Button candidate = mCandidateButtonQueueForSqueezing.poll();
320                     final int squeezeReduction = squeezeButton(candidate, heightMeasureSpec);
321                     if (squeezeReduction != SQUEEZE_FAILED) {
322                         accumulatedMeasures.mMaxChildHeight =
323                                 Math.max(accumulatedMeasures.mMaxChildHeight,
324                                         candidate.getMeasuredHeight());
325                         accumulatedMeasures.mMeasuredWidth -= squeezeReduction;
326                     }
327                 }
328 
329                 // If the current button still doesn't fit after squeezing all buttons, undo the
330                 // last squeezing round.
331                 if (accumulatedMeasures.mMeasuredWidth > targetWidth) {
332                     accumulatedMeasures = originalMeasures;
333 
334                     // Mark all buttons from the last squeezing round as "failed to squeeze", so
335                     // that they're re-measured without squeezing later.
336                     markButtonsWithPendingSqueezeStatusAs(
337                             LayoutParams.SQUEEZE_STATUS_FAILED, coveredSuggestions);
338 
339                     lp.mNoShowReason = "overflow";
340                     // The current button doesn't fit, keep on adding lower-priority buttons in case
341                     // any of those fit.
342                     continue;
343                 }
344 
345                 // The current button fits, so mark all squeezed buttons as "successfully squeezed"
346                 // to prevent them from being un-squeezed in a subsequent squeezing round.
347                 markButtonsWithPendingSqueezeStatusAs(
348                         LayoutParams.SQUEEZE_STATUS_SUCCESSFUL, coveredSuggestions);
349             }
350 
351             lp.show = true;
352             lp.mNoShowReason = "n/a";
353             displayedChildCount++;
354             if (lp.mButtonType == SmartButtonType.ACTION) {
355                 numShownActions++;
356             }
357         }
358 
359         mDidHideSystemReplies = false;
360         if (mSmartRepliesGeneratedByAssistant) {
361             if (!gotEnoughSmartReplies(smartReplies)) {
362                 // We don't have enough smart replies - hide all of them.
363                 for (View smartReplyButton : smartReplies) {
364                     final LayoutParams lp = (LayoutParams) smartReplyButton.getLayoutParams();
365                     lp.show = false;
366                     lp.mNoShowReason = "not-enough-system-replies";
367                 }
368                 // Reset our measures back to when we had only added actions (before adding
369                 // replies).
370                 accumulatedMeasures = actionsMeasures;
371                 mDidHideSystemReplies = true;
372             }
373         }
374 
375         // We're done squeezing buttons, so we can clear the priority queue.
376         mCandidateButtonQueueForSqueezing.clear();
377 
378         // Finally, we need to re-measure some buttons.
379         remeasureButtonsIfNecessary(accumulatedMeasures.mMaxChildHeight);
380 
381         int buttonHeight = Math.max(getSuggestedMinimumHeight(), mPaddingTop
382                 + accumulatedMeasures.mMaxChildHeight + mPaddingBottom);
383 
384         setMeasuredDimension(
385                 resolveSize(Math.max(getSuggestedMinimumWidth(),
386                                      accumulatedMeasures.mMeasuredWidth),
387                             widthMeasureSpec),
388                 resolveSize(buttonHeight, heightMeasureSpec));
389         mLastMeasureTime = SystemClock.elapsedRealtime();
390     }
391 
392     // TODO: this should be replaced, and instead, setMinSystemGenerated... should be invoked
393     //  with MAX_VALUE if mSmartRepliesGeneratedByAssistant would be false (essentially, this is a
394     //  ViewModel decision, as opposed to a View decision)
setSmartRepliesGeneratedByAssistant(boolean fromAssistant)395     void setSmartRepliesGeneratedByAssistant(boolean fromAssistant) {
396         mSmartRepliesGeneratedByAssistant = fromAssistant;
397     }
398 
hideSmartSuggestions()399     void hideSmartSuggestions() {
400         if (mSmartReplyContainer != null) {
401             mSmartReplyContainer.setVisibility(View.GONE);
402         }
403     }
404 
405     /** Dump internal state for debugging */
dump(IndentingPrintWriter pw)406     public void dump(IndentingPrintWriter pw) {
407         pw.println(this);
408         pw.increaseIndent();
409         pw.print("mMaxSqueezeRemeasureAttempts=");
410         pw.println(mMaxSqueezeRemeasureAttempts);
411         pw.print("mTotalSqueezeRemeasureAttempts=");
412         pw.println(mTotalSqueezeRemeasureAttempts);
413         pw.print("mMaxNumActions=");
414         pw.println(mMaxNumActions);
415         pw.print("mSmartRepliesGeneratedByAssistant=");
416         pw.println(mSmartRepliesGeneratedByAssistant);
417         pw.print("mMinNumSystemGeneratedReplies=");
418         pw.println(mMinNumSystemGeneratedReplies);
419         pw.print("mHeightUpperLimit=");
420         pw.println(mHeightUpperLimit);
421         pw.print("mDidHideSystemReplies=");
422         pw.println(mDidHideSystemReplies);
423         long now = SystemClock.elapsedRealtime();
424         pw.print("lastMeasureAge (s)=");
425         pw.println(mLastMeasureTime == 0 ? NaN : (now - mLastMeasureTime) / 1000.0f);
426         pw.print("lastDrawChildAge (s)=");
427         pw.println(mLastDrawChildTime == 0 ? NaN : (now - mLastDrawChildTime) / 1000.0f);
428         pw.print("lastDispatchDrawAge (s)=");
429         pw.println(mLastDispatchDrawTime == 0 ? NaN : (now - mLastDispatchDrawTime) / 1000.0f);
430         int numChildren = getChildCount();
431         pw.print("children: num=");
432         pw.println(numChildren);
433         pw.increaseIndent();
434         for (int i = 0; i < numChildren; i++) {
435             View child = getChildAt(i);
436             LayoutParams lp = (LayoutParams) child.getLayoutParams();
437             pw.print("[");
438             pw.print(i);
439             pw.print("] type=");
440             pw.print(lp.mButtonType);
441             pw.print(" squeezeStatus=");
442             pw.print(lp.squeezeStatus);
443             pw.print(" show=");
444             pw.print(lp.show);
445             pw.print(" noShowReason=");
446             pw.print(lp.mNoShowReason);
447             pw.print(" view=");
448             pw.println(child);
449         }
450         pw.decreaseIndent();
451         pw.decreaseIndent();
452     }
453 
454     /**
455      * Fields we keep track of inside onMeasure() to correctly measure the SmartReplyView depending
456      * on which suggestions are added.
457      */
458     private static class SmartSuggestionMeasures {
459         int mMeasuredWidth = -1;
460         int mMaxChildHeight = -1;
461 
SmartSuggestionMeasures(int measuredWidth, int maxChildHeight)462         SmartSuggestionMeasures(int measuredWidth, int maxChildHeight) {
463             this.mMeasuredWidth = measuredWidth;
464             this.mMaxChildHeight = maxChildHeight;
465         }
466 
clone()467         public SmartSuggestionMeasures clone() {
468             return new SmartSuggestionMeasures(mMeasuredWidth, mMaxChildHeight);
469         }
470     }
471 
472     /**
473      * Returns whether our notification contains at least N smart replies (or 0) where N is
474      * determined by {@link SmartReplyConstants}.
475      */
gotEnoughSmartReplies(List<View> smartReplies)476     private boolean gotEnoughSmartReplies(List<View> smartReplies) {
477         if (mMinNumSystemGeneratedReplies <= 1) {
478             // Count is irrelevant, do not bother.
479             return true;
480         }
481         int numShownReplies = 0;
482         for (View smartReplyButton : smartReplies) {
483             final LayoutParams lp = (LayoutParams) smartReplyButton.getLayoutParams();
484             if (lp.show) {
485                 numShownReplies++;
486             }
487         }
488         if (numShownReplies == 0 || numShownReplies >= mMinNumSystemGeneratedReplies) {
489             // We have enough replies, yay!
490             return true;
491         }
492         return false;
493     }
494 
filterActionsOrReplies(SmartButtonType buttonType)495     private List<View> filterActionsOrReplies(SmartButtonType buttonType) {
496         List<View> actions = new ArrayList<>();
497         final int childCount = getChildCount();
498         for (int i = 0; i < childCount; i++) {
499             final View child = getChildAt(i);
500             final LayoutParams lp = (LayoutParams) child.getLayoutParams();
501             if (child.getVisibility() != View.VISIBLE || !(child instanceof Button)) {
502                 continue;
503             }
504             if (lp.mButtonType == buttonType) {
505                 actions.add(child);
506             }
507         }
508         return actions;
509     }
510 
resetButtonsLayoutParams()511     private void resetButtonsLayoutParams() {
512         final int childCount = getChildCount();
513         for (int i = 0; i < childCount; i++) {
514             final View child = getChildAt(i);
515             final LayoutParams lp = (LayoutParams) child.getLayoutParams();
516             lp.show = false;
517             lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_NONE;
518             lp.mNoShowReason = "reset";
519         }
520     }
521 
squeezeButton(Button button, int heightMeasureSpec)522     private int squeezeButton(Button button, int heightMeasureSpec) {
523         final int estimatedOptimalTextWidth = estimateOptimalSqueezedButtonTextWidth(button);
524         if (estimatedOptimalTextWidth == SQUEEZE_FAILED) {
525             return SQUEEZE_FAILED;
526         }
527         return squeezeButtonToTextWidth(button, heightMeasureSpec, estimatedOptimalTextWidth);
528     }
529 
estimateOptimalSqueezedButtonTextWidth(Button button)530     private int estimateOptimalSqueezedButtonTextWidth(Button button) {
531         // Find a line-break point in the middle of the smart reply button text.
532         final String rawText = button.getText().toString();
533 
534         // The button sometimes has a transformation affecting text layout (e.g. all caps).
535         final TransformationMethod transformation = button.getTransformationMethod();
536         final String text = transformation == null ?
537                 rawText : transformation.getTransformation(rawText, button).toString();
538         final int length = text.length();
539         mBreakIterator.setText(text);
540 
541         if (mBreakIterator.preceding(length / 2) == BreakIterator.DONE) {
542             if (mBreakIterator.next() == BreakIterator.DONE) {
543                 // Can't find a single possible line break in either direction.
544                 return SQUEEZE_FAILED;
545             }
546         }
547 
548         final TextPaint paint = button.getPaint();
549         final int initialPosition = mBreakIterator.current();
550         final float initialLeftTextWidth = Layout.getDesiredWidth(text, 0, initialPosition, paint);
551         final float initialRightTextWidth =
552                 Layout.getDesiredWidth(text, initialPosition, length, paint);
553         float optimalTextWidth = Math.max(initialLeftTextWidth, initialRightTextWidth);
554 
555         if (initialLeftTextWidth != initialRightTextWidth) {
556             // See if there's a better line-break point (leading to a more narrow button) in
557             // either left or right direction.
558             final boolean moveLeft = initialLeftTextWidth > initialRightTextWidth;
559             final int maxSqueezeRemeasureAttempts = mMaxSqueezeRemeasureAttempts;
560             for (int i = 0; i < maxSqueezeRemeasureAttempts; i++) {
561                 mTotalSqueezeRemeasureAttempts++;
562                 final int newPosition =
563                         moveLeft ? mBreakIterator.previous() : mBreakIterator.next();
564                 if (newPosition == BreakIterator.DONE) {
565                     break;
566                 }
567 
568                 final float newLeftTextWidth = Layout.getDesiredWidth(text, 0, newPosition, paint);
569                 final float newRightTextWidth =
570                         Layout.getDesiredWidth(text, newPosition, length, paint);
571                 final float newOptimalTextWidth = Math.max(newLeftTextWidth, newRightTextWidth);
572                 if (newOptimalTextWidth < optimalTextWidth) {
573                     optimalTextWidth = newOptimalTextWidth;
574                 } else {
575                     break;
576                 }
577 
578                 boolean tooFar = moveLeft
579                         ? newLeftTextWidth <= newRightTextWidth
580                         : newLeftTextWidth >= newRightTextWidth;
581                 if (tooFar) {
582                     break;
583                 }
584             }
585         }
586 
587         return (int) Math.ceil(optimalTextWidth);
588     }
589 
590     /**
591      * Returns the combined width of the start drawable (the action icon) and the padding between
592      * the drawable and the button text.
593      */
getStartCompoundDrawableWidthWithPadding(Button button)594     private int getStartCompoundDrawableWidthWithPadding(Button button) {
595         Drawable[] drawables = button.getCompoundDrawablesRelative();
596         Drawable startDrawable = drawables[0];
597         if (startDrawable == null) return 0;
598 
599         return startDrawable.getBounds().width() + button.getCompoundDrawablePadding();
600     }
601 
squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth)602     private int squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth) {
603         int oldWidth = button.getMeasuredWidth();
604 
605         // Re-measure the squeezed smart reply button.
606         clearLayoutLineCount(button);
607         final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(
608                 button.getPaddingStart() + button.getPaddingEnd() + textWidth
609                       + getStartCompoundDrawableWidthWithPadding(button), MeasureSpec.AT_MOST);
610         button.measure(widthMeasureSpec, heightMeasureSpec);
611         if (button.getLayout() == null) {
612             Log.wtf(TAG, "Button layout is null after measure.");
613         }
614 
615         final int newWidth = button.getMeasuredWidth();
616 
617         final LayoutParams lp = (LayoutParams) button.getLayoutParams();
618         if (button.getLineCount() > 2 || newWidth >= oldWidth) {
619             lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_FAILED;
620             return SQUEEZE_FAILED;
621         } else {
622             lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_PENDING;
623             return oldWidth - newWidth;
624         }
625     }
626 
remeasureButtonsIfNecessary(int maxChildHeight)627     private void remeasureButtonsIfNecessary(int maxChildHeight) {
628         final int maxChildHeightMeasure =
629                 MeasureSpec.makeMeasureSpec(maxChildHeight, MeasureSpec.EXACTLY);
630 
631         final int childCount = getChildCount();
632         for (int i = 0; i < childCount; i++) {
633             final View child = getChildAt(i);
634             final LayoutParams lp = (LayoutParams) child.getLayoutParams();
635             if (!lp.show) {
636                 continue;
637             }
638 
639             boolean requiresNewMeasure = false;
640             int newWidth = child.getMeasuredWidth();
641 
642             // Re-measure reason 1: The button needs to be un-squeezed (either because it resulted
643             // in more than two lines or because it was unnecessary).
644             if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_FAILED) {
645                 requiresNewMeasure = true;
646                 newWidth = Integer.MAX_VALUE;
647             }
648 
649             // Re-measure reason 2: The button's height is less than the max height of all buttons
650             // (all should have the same height).
651             if (child.getMeasuredHeight() != maxChildHeight) {
652                 requiresNewMeasure = true;
653             }
654 
655             if (requiresNewMeasure) {
656                 child.measure(MeasureSpec.makeMeasureSpec(newWidth, MeasureSpec.AT_MOST),
657                         maxChildHeightMeasure);
658             }
659         }
660     }
661 
markButtonsWithPendingSqueezeStatusAs( int squeezeStatus, List<View> coveredChildren)662     private void markButtonsWithPendingSqueezeStatusAs(
663             int squeezeStatus, List<View> coveredChildren) {
664         for (View child : coveredChildren) {
665             final LayoutParams lp = (LayoutParams) child.getLayoutParams();
666             if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_PENDING) {
667                 lp.squeezeStatus = squeezeStatus;
668             }
669         }
670     }
671 
672     @Override
onLayout(boolean changed, int left, int top, int right, int bottom)673     protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
674         final boolean isRtl = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
675 
676         final int width = right - left;
677         int position = isRtl ? width - mPaddingRight : mPaddingLeft;
678 
679         final int childCount = getChildCount();
680         for (int i = 0; i < childCount; i++) {
681             final View child = getChildAt(i);
682             final LayoutParams lp = (LayoutParams) child.getLayoutParams();
683             if (!lp.show) {
684                 continue;
685             }
686 
687             final int childWidth = child.getMeasuredWidth();
688             final int childHeight = child.getMeasuredHeight();
689             final int childLeft = isRtl ? position - childWidth : position;
690             child.layout(childLeft, 0, childLeft + childWidth, childHeight);
691 
692             final int childWidthWithSpacing = childWidth + mSpacing;
693             if (isRtl) {
694                 position -= childWidthWithSpacing;
695             } else {
696                 position += childWidthWithSpacing;
697             }
698         }
699     }
700 
701     @Override
drawChild(Canvas canvas, View child, long drawingTime)702     protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
703         final LayoutParams lp = (LayoutParams) child.getLayoutParams();
704         if (!lp.show) {
705             return false;
706         }
707         mLastDrawChildTime = SystemClock.elapsedRealtime();
708         return super.drawChild(canvas, child, drawingTime);
709     }
710 
711     @Override
dispatchDraw(Canvas canvas)712     protected void dispatchDraw(Canvas canvas) {
713         super.dispatchDraw(canvas);
714         mLastDispatchDrawTime = SystemClock.elapsedRealtime();
715     }
716 
717     /**
718      * Set the current background color of the notification so that the smart reply buttons can
719      * match it, and calculate other colors (e.g. text, ripple, stroke)
720      */
setBackgroundTintColor(int backgroundColor, boolean colorized)721     public void setBackgroundTintColor(int backgroundColor, boolean colorized) {
722         if (backgroundColor == mCurrentBackgroundColor && colorized == mCurrentColorized) {
723             // Same color ignoring.
724            return;
725         }
726         mCurrentBackgroundColor = backgroundColor;
727         mCurrentColorized = colorized;
728 
729         final boolean dark = ContrastColorUtil.isColorDark(backgroundColor);
730 
731         mCurrentTextColor = ContrastColorUtil.ensureTextContrast(
732                 dark ? mDefaultTextColorDarkBg : mDefaultTextColor,
733                 backgroundColor | 0xff000000, dark);
734         mCurrentStrokeColor = colorized ? mCurrentTextColor : ContrastColorUtil.ensureContrast(
735                 mDefaultStrokeColor, backgroundColor | 0xff000000, dark, mMinStrokeContrast);
736         mCurrentRippleColor = dark ? mRippleColorDarkBg : mRippleColor;
737 
738         int childCount = getChildCount();
739         for (int i = 0; i < childCount; i++) {
740             setButtonColors((Button) getChildAt(i));
741         }
742     }
743 
setButtonColors(Button button)744     private void setButtonColors(Button button) {
745         Drawable drawable = button.getBackground();
746         if (drawable instanceof RippleDrawable) {
747             // Mutate in case other notifications are using this drawable.
748             drawable = drawable.mutate();
749             RippleDrawable ripple = (RippleDrawable) drawable;
750             ripple.setColor(ColorStateList.valueOf(mCurrentRippleColor));
751             Drawable inset = ripple.getDrawable(0);
752             if (inset instanceof InsetDrawable) {
753                 Drawable background = ((InsetDrawable) inset).getDrawable();
754                 if (background instanceof GradientDrawable) {
755                     GradientDrawable gradientDrawable = (GradientDrawable) background;
756                     gradientDrawable.setColor(mCurrentBackgroundColor);
757                     gradientDrawable.setStroke(mStrokeWidth, mCurrentStrokeColor);
758                 }
759             }
760             button.setBackground(drawable);
761         }
762         button.setTextColor(mCurrentTextColor);
763     }
764 
765     enum SmartButtonType {
766         REPLY,
767         ACTION
768     }
769 
770     @VisibleForTesting
771     static class LayoutParams extends ViewGroup.LayoutParams {
772 
773         /** Button is not squeezed. */
774         private static final int SQUEEZE_STATUS_NONE = 0;
775 
776         /**
777          * Button was successfully squeezed, but it might be un-squeezed later if the squeezing
778          * turns out to have been unnecessary (because there's still not enough space to add another
779          * button).
780          */
781         private static final int SQUEEZE_STATUS_PENDING = 1;
782 
783         /** Button was successfully squeezed and it won't be un-squeezed. */
784         private static final int SQUEEZE_STATUS_SUCCESSFUL = 2;
785 
786         /**
787          * Button wasn't successfully squeezed. The squeezing resulted in more than two lines of
788          * text or it didn't reduce the button's width at all. The button will have to be
789          * re-measured to use only one line of text.
790          */
791         private static final int SQUEEZE_STATUS_FAILED = 3;
792 
793         private boolean show = false;
794         private int squeezeStatus = SQUEEZE_STATUS_NONE;
795         SmartButtonType mButtonType = SmartButtonType.REPLY;
796         String mNoShowReason = "new";
797 
LayoutParams(Context c, AttributeSet attrs)798         private LayoutParams(Context c, AttributeSet attrs) {
799             super(c, attrs);
800         }
801 
LayoutParams(int width, int height)802         private LayoutParams(int width, int height) {
803             super(width, height);
804         }
805 
806         @VisibleForTesting
isShown()807         boolean isShown() {
808             return show;
809         }
810     }
811 
812     /**
813      * Data class for smart replies.
814      */
815     public static class SmartReplies {
816         @NonNull
817         public final RemoteInput remoteInput;
818         @NonNull
819         public final PendingIntent pendingIntent;
820         @NonNull
821         public final List<CharSequence> choices;
822         public final boolean fromAssistant;
823 
SmartReplies(@onNull List<CharSequence> choices, @NonNull RemoteInput remoteInput, @NonNull PendingIntent pendingIntent, boolean fromAssistant)824         public SmartReplies(@NonNull List<CharSequence> choices, @NonNull RemoteInput remoteInput,
825                 @NonNull PendingIntent pendingIntent, boolean fromAssistant) {
826             this.choices = choices;
827             this.remoteInput = remoteInput;
828             this.pendingIntent = pendingIntent;
829             this.fromAssistant = fromAssistant;
830         }
831     }
832 
833 
834     /**
835      * Data class for smart actions.
836      */
837     public static class SmartActions {
838         @NonNull
839         public final List<Notification.Action> actions;
840         public final boolean fromAssistant;
841 
SmartActions(@onNull List<Notification.Action> actions, boolean fromAssistant)842         public SmartActions(@NonNull List<Notification.Action> actions, boolean fromAssistant) {
843             this.actions = actions;
844             this.fromAssistant = fromAssistant;
845         }
846     }
847 }
848