1 package com.android.systemui.statusbar.policy;
2 
3 import android.annotation.ColorInt;
4 import android.annotation.NonNull;
5 import android.app.Notification;
6 import android.app.PendingIntent;
7 import android.app.RemoteInput;
8 import android.content.Context;
9 import android.content.Intent;
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.Bundle;
19 import android.os.SystemClock;
20 import android.text.Layout;
21 import android.text.TextPaint;
22 import android.text.method.TransformationMethod;
23 import android.util.AttributeSet;
24 import android.util.Log;
25 import android.view.ContextThemeWrapper;
26 import android.view.LayoutInflater;
27 import android.view.View;
28 import android.view.ViewGroup;
29 import android.view.accessibility.AccessibilityNodeInfo;
30 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
31 import android.widget.Button;
32 
33 import com.android.internal.annotations.VisibleForTesting;
34 import com.android.internal.util.ContrastColorUtil;
35 import com.android.systemui.Dependency;
36 import com.android.systemui.R;
37 import com.android.systemui.plugins.ActivityStarter;
38 import com.android.systemui.plugins.ActivityStarter.OnDismissAction;
39 import com.android.systemui.statusbar.NotificationRemoteInputManager;
40 import com.android.systemui.statusbar.SmartReplyController;
41 import com.android.systemui.statusbar.notification.NotificationUtils;
42 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
43 import com.android.systemui.statusbar.notification.collection.NotificationEntry.EditedSuggestionInfo;
44 import com.android.systemui.statusbar.notification.logging.NotificationLogger;
45 import com.android.systemui.statusbar.phone.KeyguardDismissUtil;
46 
47 import java.text.BreakIterator;
48 import java.util.ArrayList;
49 import java.util.Comparator;
50 import java.util.List;
51 import java.util.PriorityQueue;
52 
53 /** View which displays smart reply and smart actions buttons in notifications. */
54 public class SmartReplyView extends ViewGroup {
55 
56     private static final String TAG = "SmartReplyView";
57 
58     private static final int MEASURE_SPEC_ANY_LENGTH =
59             MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
60 
61     private static final Comparator<View> DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR =
62             (v1, v2) -> ((v2.getMeasuredWidth() - v2.getPaddingLeft() - v2.getPaddingRight())
63                     - (v1.getMeasuredWidth() - v1.getPaddingLeft() - v1.getPaddingRight()));
64 
65     private static final int SQUEEZE_FAILED = -1;
66 
67     private final SmartReplyConstants mConstants;
68     private final KeyguardDismissUtil mKeyguardDismissUtil;
69     private final NotificationRemoteInputManager mRemoteInputManager;
70 
71     /**
72      * The upper bound for the height of this view in pixels. Notifications are automatically
73      * recreated on density or font size changes so caching this should be fine.
74      */
75     private final int mHeightUpperLimit;
76 
77     /** Spacing to be applied between views. */
78     private final int mSpacing;
79 
80     /** Horizontal padding of smart reply buttons if all of them use only one line of text. */
81     private final int mSingleLineButtonPaddingHorizontal;
82 
83     /** Horizontal padding of smart reply buttons if at least one of them uses two lines of text. */
84     private final int mDoubleLineButtonPaddingHorizontal;
85 
86     /** Increase in width of a smart reply button as a result of using two lines instead of one. */
87     private final int mSingleToDoubleLineButtonWidthIncrease;
88 
89     private final BreakIterator mBreakIterator;
90 
91     private PriorityQueue<Button> mCandidateButtonQueueForSqueezing;
92 
93     private View mSmartReplyContainer;
94 
95     /**
96      * Whether the smart replies in this view were generated by the notification assistant. If not
97      * they're provided by the app.
98      */
99     private boolean mSmartRepliesGeneratedByAssistant = false;
100 
101     @ColorInt
102     private int mCurrentBackgroundColor;
103     @ColorInt
104     private final int mDefaultBackgroundColor;
105     @ColorInt
106     private final int mDefaultStrokeColor;
107     @ColorInt
108     private final int mDefaultTextColor;
109     @ColorInt
110     private final int mDefaultTextColorDarkBg;
111     @ColorInt
112     private final int mRippleColorDarkBg;
113     @ColorInt
114     private final int mRippleColor;
115     private final int mStrokeWidth;
116     private final double mMinStrokeContrast;
117 
118     private ActivityStarter mActivityStarter;
119 
SmartReplyView(Context context, AttributeSet attrs)120     public SmartReplyView(Context context, AttributeSet attrs) {
121         super(context, attrs);
122         mConstants = Dependency.get(SmartReplyConstants.class);
123         mKeyguardDismissUtil = Dependency.get(KeyguardDismissUtil.class);
124         mRemoteInputManager = Dependency.get(NotificationRemoteInputManager.class);
125 
126         mHeightUpperLimit = NotificationUtils.getFontScaledHeight(mContext,
127             R.dimen.smart_reply_button_max_height);
128 
129         mCurrentBackgroundColor = context.getColor(R.color.smart_reply_button_background);
130         mDefaultBackgroundColor = mCurrentBackgroundColor;
131         mDefaultTextColor = mContext.getColor(R.color.smart_reply_button_text);
132         mDefaultTextColorDarkBg = mContext.getColor(R.color.smart_reply_button_text_dark_bg);
133         mDefaultStrokeColor = mContext.getColor(R.color.smart_reply_button_stroke);
134         mRippleColor = mContext.getColor(R.color.notification_ripple_untinted_color);
135         mRippleColorDarkBg = Color.argb(Color.alpha(mRippleColor),
136                 255 /* red */, 255 /* green */, 255 /* blue */);
137         mMinStrokeContrast = ContrastColorUtil.calculateContrast(mDefaultStrokeColor,
138                 mDefaultBackgroundColor);
139 
140         int spacing = 0;
141         int singleLineButtonPaddingHorizontal = 0;
142         int doubleLineButtonPaddingHorizontal = 0;
143         int strokeWidth = 0;
144 
145         final TypedArray arr = context.obtainStyledAttributes(attrs, R.styleable.SmartReplyView,
146                 0, 0);
147         final int length = arr.getIndexCount();
148         for (int i = 0; i < length; i++) {
149             int attr = arr.getIndex(i);
150             if (attr == R.styleable.SmartReplyView_spacing) {
151                 spacing = arr.getDimensionPixelSize(i, 0);
152             } else if (attr == R.styleable.SmartReplyView_singleLineButtonPaddingHorizontal) {
153                 singleLineButtonPaddingHorizontal = arr.getDimensionPixelSize(i, 0);
154             } else if (attr == R.styleable.SmartReplyView_doubleLineButtonPaddingHorizontal) {
155                 doubleLineButtonPaddingHorizontal = arr.getDimensionPixelSize(i, 0);
156             } else if (attr == R.styleable.SmartReplyView_buttonStrokeWidth) {
157                 strokeWidth = arr.getDimensionPixelSize(i, 0);
158             }
159         }
160         arr.recycle();
161 
162         mStrokeWidth = strokeWidth;
163         mSpacing = spacing;
164         mSingleLineButtonPaddingHorizontal = singleLineButtonPaddingHorizontal;
165         mDoubleLineButtonPaddingHorizontal = doubleLineButtonPaddingHorizontal;
166         mSingleToDoubleLineButtonWidthIncrease =
167                 2 * (doubleLineButtonPaddingHorizontal - singleLineButtonPaddingHorizontal);
168 
169 
170         mBreakIterator = BreakIterator.getLineInstance();
171         reallocateCandidateButtonQueueForSqueezing();
172     }
173 
174     /**
175      * Returns an upper bound for the height of this view in pixels. This method is intended to be
176      * invoked before onMeasure, so it doesn't do any analysis on the contents of the buttons.
177      */
getHeightUpperLimit()178     public int getHeightUpperLimit() {
179        return mHeightUpperLimit;
180     }
181 
reallocateCandidateButtonQueueForSqueezing()182     private void reallocateCandidateButtonQueueForSqueezing() {
183         // Instead of clearing the priority queue, we re-allocate so that it would fit all buttons
184         // exactly. This avoids (1) wasting memory because PriorityQueue never shrinks and
185         // (2) growing in onMeasure.
186         // The constructor throws an IllegalArgument exception if initial capacity is less than 1.
187         mCandidateButtonQueueForSqueezing = new PriorityQueue<>(
188                 Math.max(getChildCount(), 1), DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR);
189     }
190 
191     /**
192      * Reset the smart suggestions view to allow adding new replies and actions.
193      */
resetSmartSuggestions(View newSmartReplyContainer)194     public void resetSmartSuggestions(View newSmartReplyContainer) {
195         mSmartReplyContainer = newSmartReplyContainer;
196         removeAllViews();
197         mCurrentBackgroundColor = mDefaultBackgroundColor;
198     }
199 
200     /**
201      * Add buttons to the {@link SmartReplyView} - these buttons must have been preinflated using
202      * one of the methods in this class.
203      */
addPreInflatedButtons(List<Button> smartSuggestionButtons)204     public void addPreInflatedButtons(List<Button> smartSuggestionButtons) {
205         for (Button button : smartSuggestionButtons) {
206             addView(button);
207         }
208         reallocateCandidateButtonQueueForSqueezing();
209     }
210 
211     /**
212      * Add smart replies to this view, using the provided {@link RemoteInput} and
213      * {@link PendingIntent} to respond when the user taps a smart reply. Only the replies that fit
214      * into the notification are shown.
215      */
inflateRepliesFromRemoteInput( @onNull SmartReplies smartReplies, SmartReplyController smartReplyController, NotificationEntry entry, boolean delayOnClickListener)216     public List<Button> inflateRepliesFromRemoteInput(
217             @NonNull SmartReplies smartReplies,
218             SmartReplyController smartReplyController, NotificationEntry entry,
219             boolean delayOnClickListener) {
220         List<Button> buttons = new ArrayList<>();
221 
222         if (smartReplies.remoteInput != null && smartReplies.pendingIntent != null) {
223             if (smartReplies.choices != null) {
224                 for (int i = 0; i < smartReplies.choices.length; ++i) {
225                     buttons.add(inflateReplyButton(
226                             this, getContext(), i, smartReplies, smartReplyController, entry,
227                             delayOnClickListener));
228                 }
229                 this.mSmartRepliesGeneratedByAssistant = smartReplies.fromAssistant;
230             }
231         }
232         return buttons;
233     }
234 
235     /**
236      * Add smart actions to be shown next to smart replies. Only the actions that fit into the
237      * notification are shown.
238      */
inflateSmartActions(Context packageContext, @NonNull SmartActions smartActions, SmartReplyController smartReplyController, NotificationEntry entry, HeadsUpManager headsUpManager, boolean delayOnClickListener)239     public List<Button> inflateSmartActions(Context packageContext,
240             @NonNull SmartActions smartActions, SmartReplyController smartReplyController,
241             NotificationEntry entry, HeadsUpManager headsUpManager, boolean delayOnClickListener) {
242         Context themedPackageContext = new ContextThemeWrapper(packageContext, mContext.getTheme());
243         List<Button> buttons = new ArrayList<>();
244         int numSmartActions = smartActions.actions.size();
245         for (int n = 0; n < numSmartActions; n++) {
246             Notification.Action action = smartActions.actions.get(n);
247             if (action.actionIntent != null) {
248                 buttons.add(inflateActionButton(
249                         this, getContext(), themedPackageContext, n, smartActions,
250                         smartReplyController,
251                         entry, headsUpManager, delayOnClickListener));
252             }
253         }
254         return buttons;
255     }
256 
257     /**
258      * Inflate an instance of this class.
259      */
inflate(Context context)260     public static SmartReplyView inflate(Context context) {
261         return (SmartReplyView) LayoutInflater.from(context).inflate(
262                 R.layout.smart_reply_view, null /* root */);
263     }
264 
265     @VisibleForTesting
inflateReplyButton(SmartReplyView smartReplyView, Context context, int replyIndex, SmartReplies smartReplies, SmartReplyController smartReplyController, NotificationEntry entry, boolean useDelayedOnClickListener)266     static Button inflateReplyButton(SmartReplyView smartReplyView, Context context,
267             int replyIndex, SmartReplies smartReplies, SmartReplyController smartReplyController,
268             NotificationEntry entry, boolean useDelayedOnClickListener) {
269         Button b = (Button) LayoutInflater.from(context).inflate(
270                 R.layout.smart_reply_button, smartReplyView, false);
271         CharSequence choice = smartReplies.choices[replyIndex];
272         b.setText(choice);
273 
274         OnDismissAction action = () -> {
275             if (smartReplyView.mConstants.getEffectiveEditChoicesBeforeSending(
276                     smartReplies.remoteInput.getEditChoicesBeforeSending())) {
277                 EditedSuggestionInfo editedSuggestionInfo =
278                         new EditedSuggestionInfo(choice, replyIndex);
279                 smartReplyView.mRemoteInputManager.activateRemoteInput(b,
280                         new RemoteInput[] { smartReplies.remoteInput }, smartReplies.remoteInput,
281                         smartReplies.pendingIntent, editedSuggestionInfo);
282                 return false;
283             }
284 
285             smartReplyController.smartReplySent(entry, replyIndex, b.getText(),
286                     NotificationLogger.getNotificationLocation(entry).toMetricsEventEnum(),
287                     false /* modifiedBeforeSending */);
288             Bundle results = new Bundle();
289             results.putString(smartReplies.remoteInput.getResultKey(), choice.toString());
290             Intent intent = new Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
291             RemoteInput.addResultsToIntent(new RemoteInput[] { smartReplies.remoteInput }, intent,
292                     results);
293             RemoteInput.setResultsSource(intent, RemoteInput.SOURCE_CHOICE);
294             entry.setHasSentReply();
295             try {
296                 smartReplies.pendingIntent.send(context, 0, intent);
297             } catch (PendingIntent.CanceledException e) {
298                 Log.w(TAG, "Unable to send smart reply", e);
299             }
300             // Note that as inflateReplyButton is called mSmartReplyContainer is null, but when the
301             // reply Button is added to the SmartReplyView mSmartReplyContainer will be set. So, it
302             // will not be possible for a user to trigger this on-click-listener without
303             // mSmartReplyContainer being set.
304             smartReplyView.mSmartReplyContainer.setVisibility(View.GONE);
305             return false; // do not defer
306         };
307 
308         OnClickListener onClickListener = view ->
309             smartReplyView.mKeyguardDismissUtil.executeWhenUnlocked(action, !entry.isRowPinned());
310         if (useDelayedOnClickListener) {
311             onClickListener = new DelayedOnClickListener(onClickListener,
312                     smartReplyView.mConstants.getOnClickInitDelay());
313         }
314         b.setOnClickListener(onClickListener);
315 
316         b.setAccessibilityDelegate(new AccessibilityDelegate() {
317             public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
318                 super.onInitializeAccessibilityNodeInfo(host, info);
319                 String label = smartReplyView.getResources().getString(
320                         R.string.accessibility_send_smart_reply);
321                 info.addAction(new AccessibilityAction(AccessibilityNodeInfo.ACTION_CLICK, label));
322             }
323         });
324 
325         SmartReplyView.setButtonColors(b, smartReplyView.mCurrentBackgroundColor,
326                 smartReplyView.mDefaultStrokeColor, smartReplyView.mDefaultTextColor,
327                 smartReplyView.mRippleColor, smartReplyView.mStrokeWidth);
328         return b;
329     }
330 
331     @VisibleForTesting
inflateActionButton(SmartReplyView smartReplyView, Context context, Context packageContext, int actionIndex, SmartActions smartActions, SmartReplyController smartReplyController, NotificationEntry entry, HeadsUpManager headsUpManager, boolean useDelayedOnClickListener)332     static Button inflateActionButton(SmartReplyView smartReplyView, Context context,
333             Context packageContext, int actionIndex, SmartActions smartActions,
334             SmartReplyController smartReplyController, NotificationEntry entry,
335             HeadsUpManager headsUpManager, boolean useDelayedOnClickListener) {
336         Notification.Action action = smartActions.actions.get(actionIndex);
337         Button button = (Button) LayoutInflater.from(context).inflate(
338                 R.layout.smart_action_button, smartReplyView, false);
339         button.setText(action.title);
340 
341         // We received the Icon from the application - so use the Context of the application to
342         // reference icon resources.
343         Drawable iconDrawable = action.getIcon().loadDrawable(packageContext);
344         // Add the action icon to the Smart Action button.
345         int newIconSize = context.getResources().getDimensionPixelSize(
346                 R.dimen.smart_action_button_icon_size);
347         iconDrawable.setBounds(0, 0, newIconSize, newIconSize);
348         button.setCompoundDrawables(iconDrawable, null, null, null);
349 
350         OnClickListener onClickListener = view ->
351                 smartReplyView.getActivityStarter().startPendingIntentDismissingKeyguard(
352                         action.actionIntent,
353                         () -> {
354                             smartReplyController.smartActionClicked(
355                                     entry, actionIndex, action, smartActions.fromAssistant);
356                             headsUpManager.removeNotification(entry.key, true);
357                         }, entry.getRow());
358         if (useDelayedOnClickListener) {
359             onClickListener = new DelayedOnClickListener(onClickListener,
360                     smartReplyView.mConstants.getOnClickInitDelay());
361         }
362         button.setOnClickListener(onClickListener);
363 
364         // Mark this as an Action button
365         final LayoutParams lp = (LayoutParams) button.getLayoutParams();
366         lp.buttonType = SmartButtonType.ACTION;
367         return button;
368     }
369 
370     @Override
generateLayoutParams(AttributeSet attrs)371     public LayoutParams generateLayoutParams(AttributeSet attrs) {
372         return new LayoutParams(mContext, attrs);
373     }
374 
375     @Override
generateDefaultLayoutParams()376     protected LayoutParams generateDefaultLayoutParams() {
377         return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
378     }
379 
380     @Override
generateLayoutParams(ViewGroup.LayoutParams params)381     protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams params) {
382         return new LayoutParams(params.width, params.height);
383     }
384 
385     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)386     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
387         final int targetWidth = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED
388                 ? Integer.MAX_VALUE : MeasureSpec.getSize(widthMeasureSpec);
389 
390         // Mark all buttons as hidden and un-squeezed.
391         resetButtonsLayoutParams();
392 
393         if (!mCandidateButtonQueueForSqueezing.isEmpty()) {
394             Log.wtf(TAG, "Single line button queue leaked between onMeasure calls");
395             mCandidateButtonQueueForSqueezing.clear();
396         }
397 
398         SmartSuggestionMeasures accumulatedMeasures = new SmartSuggestionMeasures(
399                 mPaddingLeft + mPaddingRight,
400                 0 /* maxChildHeight */,
401                 mSingleLineButtonPaddingHorizontal);
402         int displayedChildCount = 0;
403 
404         // Set up a list of suggestions where actions come before replies. Note that the Buttons
405         // themselves have already been added to the view hierarchy in an order such that Smart
406         // Replies are shown before Smart Actions. The order of the list below determines which
407         // suggestions will be shown at all - only the first X elements are shown (where X depends
408         // on how much space each suggestion button needs).
409         List<View> smartActions = filterActionsOrReplies(SmartButtonType.ACTION);
410         List<View> smartReplies = filterActionsOrReplies(SmartButtonType.REPLY);
411         List<View> smartSuggestions = new ArrayList<>(smartActions);
412         smartSuggestions.addAll(smartReplies);
413         List<View> coveredSuggestions = new ArrayList<>();
414 
415         // SmartSuggestionMeasures for all action buttons, this will be filled in when the first
416         // reply button is added.
417         SmartSuggestionMeasures actionsMeasures = null;
418 
419         final int maxNumActions = mConstants.getMaxNumActions();
420         int numShownActions = 0;
421 
422         for (View child : smartSuggestions) {
423             final LayoutParams lp = (LayoutParams) child.getLayoutParams();
424             if (maxNumActions != -1 // -1 means 'no limit'
425                     && lp.buttonType == SmartButtonType.ACTION
426                     && numShownActions >= maxNumActions) {
427                 // We've reached the maximum number of actions, don't add another one!
428                 continue;
429             }
430 
431             child.setPadding(accumulatedMeasures.mButtonPaddingHorizontal, child.getPaddingTop(),
432                     accumulatedMeasures.mButtonPaddingHorizontal, child.getPaddingBottom());
433             child.measure(MEASURE_SPEC_ANY_LENGTH, heightMeasureSpec);
434 
435             coveredSuggestions.add(child);
436 
437             final int lineCount = ((Button) child).getLineCount();
438             if (lineCount < 1 || lineCount > 2) {
439                 // If smart reply has no text, or more than two lines, then don't show it.
440                 continue;
441             }
442 
443             if (lineCount == 1) {
444                 mCandidateButtonQueueForSqueezing.add((Button) child);
445             }
446 
447             // Remember the current measurements in case the current button doesn't fit in.
448             SmartSuggestionMeasures originalMeasures = accumulatedMeasures.clone();
449             if (actionsMeasures == null && lp.buttonType == SmartButtonType.REPLY) {
450                 // We've added all actions (we go through actions first), now add their
451                 // measurements.
452                 actionsMeasures = accumulatedMeasures.clone();
453             }
454 
455             final int spacing = displayedChildCount == 0 ? 0 : mSpacing;
456             final int childWidth = child.getMeasuredWidth();
457             final int childHeight = child.getMeasuredHeight();
458             accumulatedMeasures.mMeasuredWidth += spacing + childWidth;
459             accumulatedMeasures.mMaxChildHeight =
460                     Math.max(accumulatedMeasures.mMaxChildHeight, childHeight);
461 
462             // Do we need to increase the number of lines in smart reply buttons to two?
463             final boolean increaseToTwoLines =
464                     (accumulatedMeasures.mButtonPaddingHorizontal
465                             == mSingleLineButtonPaddingHorizontal)
466                     && (lineCount == 2 || accumulatedMeasures.mMeasuredWidth > targetWidth);
467             if (increaseToTwoLines) {
468                 accumulatedMeasures.mMeasuredWidth +=
469                         (displayedChildCount + 1) * mSingleToDoubleLineButtonWidthIncrease;
470                 accumulatedMeasures.mButtonPaddingHorizontal =
471                         mDoubleLineButtonPaddingHorizontal;
472             }
473 
474             // If the last button doesn't fit into the remaining width, try squeezing preceding
475             // smart reply buttons.
476             if (accumulatedMeasures.mMeasuredWidth > targetWidth) {
477                 // Keep squeezing preceding and current smart reply buttons until they all fit.
478                 while (accumulatedMeasures.mMeasuredWidth > targetWidth
479                         && !mCandidateButtonQueueForSqueezing.isEmpty()) {
480                     final Button candidate = mCandidateButtonQueueForSqueezing.poll();
481                     final int squeezeReduction = squeezeButton(candidate, heightMeasureSpec);
482                     if (squeezeReduction != SQUEEZE_FAILED) {
483                         accumulatedMeasures.mMaxChildHeight =
484                                 Math.max(accumulatedMeasures.mMaxChildHeight,
485                                         candidate.getMeasuredHeight());
486                         accumulatedMeasures.mMeasuredWidth -= squeezeReduction;
487                     }
488                 }
489 
490                 // If the current button still doesn't fit after squeezing all buttons, undo the
491                 // last squeezing round.
492                 if (accumulatedMeasures.mMeasuredWidth > targetWidth) {
493                     accumulatedMeasures = originalMeasures;
494 
495                     // Mark all buttons from the last squeezing round as "failed to squeeze", so
496                     // that they're re-measured without squeezing later.
497                     markButtonsWithPendingSqueezeStatusAs(
498                             LayoutParams.SQUEEZE_STATUS_FAILED, coveredSuggestions);
499 
500                     // The current button doesn't fit, keep on adding lower-priority buttons in case
501                     // any of those fit.
502                     continue;
503                 }
504 
505                 // The current button fits, so mark all squeezed buttons as "successfully squeezed"
506                 // to prevent them from being un-squeezed in a subsequent squeezing round.
507                 markButtonsWithPendingSqueezeStatusAs(
508                         LayoutParams.SQUEEZE_STATUS_SUCCESSFUL, coveredSuggestions);
509             }
510 
511             lp.show = true;
512             displayedChildCount++;
513             if (lp.buttonType == SmartButtonType.ACTION) {
514                 numShownActions++;
515             }
516         }
517 
518         if (mSmartRepliesGeneratedByAssistant) {
519             if (!gotEnoughSmartReplies(smartReplies)) {
520                 // We don't have enough smart replies - hide all of them.
521                 for (View smartReplyButton : smartReplies) {
522                     final LayoutParams lp = (LayoutParams) smartReplyButton.getLayoutParams();
523                     lp.show = false;
524                 }
525                 // Reset our measures back to when we had only added actions (before adding
526                 // replies).
527                 accumulatedMeasures = actionsMeasures;
528             }
529         }
530 
531         // We're done squeezing buttons, so we can clear the priority queue.
532         mCandidateButtonQueueForSqueezing.clear();
533 
534         // Finally, we need to re-measure some buttons.
535         remeasureButtonsIfNecessary(accumulatedMeasures.mButtonPaddingHorizontal,
536                                     accumulatedMeasures.mMaxChildHeight);
537 
538         int buttonHeight = Math.max(getSuggestedMinimumHeight(), mPaddingTop
539                 + accumulatedMeasures.mMaxChildHeight + mPaddingBottom);
540 
541         // Set the corner radius to half the button height to make the side of the buttons look like
542         // a semicircle.
543         for (View smartSuggestionButton : smartSuggestions) {
544             setCornerRadius((Button) smartSuggestionButton, ((float) buttonHeight) / 2);
545         }
546 
547         setMeasuredDimension(
548                 resolveSize(Math.max(getSuggestedMinimumWidth(),
549                                      accumulatedMeasures.mMeasuredWidth),
550                             widthMeasureSpec),
551                 resolveSize(buttonHeight, heightMeasureSpec));
552     }
553 
554     /**
555      * Fields we keep track of inside onMeasure() to correctly measure the SmartReplyView depending
556      * on which suggestions are added.
557      */
558     private static class SmartSuggestionMeasures {
559         int mMeasuredWidth = -1;
560         int mMaxChildHeight = -1;
561         int mButtonPaddingHorizontal = -1;
562 
SmartSuggestionMeasures(int measuredWidth, int maxChildHeight, int buttonPaddingHorizontal)563         SmartSuggestionMeasures(int measuredWidth, int maxChildHeight,
564                 int buttonPaddingHorizontal) {
565             this.mMeasuredWidth = measuredWidth;
566             this.mMaxChildHeight = maxChildHeight;
567             this.mButtonPaddingHorizontal = buttonPaddingHorizontal;
568         }
569 
clone()570         public SmartSuggestionMeasures clone() {
571             return new SmartSuggestionMeasures(
572                     mMeasuredWidth, mMaxChildHeight, mButtonPaddingHorizontal);
573         }
574     }
575 
576     /**
577      * Returns whether our notification contains at least N smart replies (or 0) where N is
578      * determined by {@link SmartReplyConstants}.
579      */
gotEnoughSmartReplies(List<View> smartReplies)580     private boolean gotEnoughSmartReplies(List<View> smartReplies) {
581         int numShownReplies = 0;
582         for (View smartReplyButton : smartReplies) {
583             final LayoutParams lp = (LayoutParams) smartReplyButton.getLayoutParams();
584             if (lp.show) {
585                 numShownReplies++;
586             }
587         }
588         if (numShownReplies == 0
589                 || numShownReplies >= mConstants.getMinNumSystemGeneratedReplies()) {
590             // We have enough replies, yay!
591             return true;
592         }
593         return false;
594     }
595 
filterActionsOrReplies(SmartButtonType buttonType)596     private List<View> filterActionsOrReplies(SmartButtonType buttonType) {
597         List<View> actions = new ArrayList<>();
598         final int childCount = getChildCount();
599         for (int i = 0; i < childCount; i++) {
600             final View child = getChildAt(i);
601             final LayoutParams lp = (LayoutParams) child.getLayoutParams();
602             if (child.getVisibility() != View.VISIBLE || !(child instanceof Button)) {
603                 continue;
604             }
605             if (lp.buttonType == buttonType) {
606                 actions.add(child);
607             }
608         }
609         return actions;
610     }
611 
resetButtonsLayoutParams()612     private void resetButtonsLayoutParams() {
613         final int childCount = getChildCount();
614         for (int i = 0; i < childCount; i++) {
615             final View child = getChildAt(i);
616             final LayoutParams lp = (LayoutParams) child.getLayoutParams();
617             lp.show = false;
618             lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_NONE;
619         }
620     }
621 
squeezeButton(Button button, int heightMeasureSpec)622     private int squeezeButton(Button button, int heightMeasureSpec) {
623         final int estimatedOptimalTextWidth = estimateOptimalSqueezedButtonTextWidth(button);
624         if (estimatedOptimalTextWidth == SQUEEZE_FAILED) {
625             return SQUEEZE_FAILED;
626         }
627         return squeezeButtonToTextWidth(button, heightMeasureSpec, estimatedOptimalTextWidth);
628     }
629 
estimateOptimalSqueezedButtonTextWidth(Button button)630     private int estimateOptimalSqueezedButtonTextWidth(Button button) {
631         // Find a line-break point in the middle of the smart reply button text.
632         final String rawText = button.getText().toString();
633 
634         // The button sometimes has a transformation affecting text layout (e.g. all caps).
635         final TransformationMethod transformation = button.getTransformationMethod();
636         final String text = transformation == null ?
637                 rawText : transformation.getTransformation(rawText, button).toString();
638         final int length = text.length();
639         mBreakIterator.setText(text);
640 
641         if (mBreakIterator.preceding(length / 2) == BreakIterator.DONE) {
642             if (mBreakIterator.next() == BreakIterator.DONE) {
643                 // Can't find a single possible line break in either direction.
644                 return SQUEEZE_FAILED;
645             }
646         }
647 
648         final TextPaint paint = button.getPaint();
649         final int initialPosition = mBreakIterator.current();
650         final float initialLeftTextWidth = Layout.getDesiredWidth(text, 0, initialPosition, paint);
651         final float initialRightTextWidth =
652                 Layout.getDesiredWidth(text, initialPosition, length, paint);
653         float optimalTextWidth = Math.max(initialLeftTextWidth, initialRightTextWidth);
654 
655         if (initialLeftTextWidth != initialRightTextWidth) {
656             // See if there's a better line-break point (leading to a more narrow button) in
657             // either left or right direction.
658             final boolean moveLeft = initialLeftTextWidth > initialRightTextWidth;
659             final int maxSqueezeRemeasureAttempts = mConstants.getMaxSqueezeRemeasureAttempts();
660             for (int i = 0; i < maxSqueezeRemeasureAttempts; i++) {
661                 final int newPosition =
662                         moveLeft ? mBreakIterator.previous() : mBreakIterator.next();
663                 if (newPosition == BreakIterator.DONE) {
664                     break;
665                 }
666 
667                 final float newLeftTextWidth = Layout.getDesiredWidth(text, 0, newPosition, paint);
668                 final float newRightTextWidth =
669                         Layout.getDesiredWidth(text, newPosition, length, paint);
670                 final float newOptimalTextWidth = Math.max(newLeftTextWidth, newRightTextWidth);
671                 if (newOptimalTextWidth < optimalTextWidth) {
672                     optimalTextWidth = newOptimalTextWidth;
673                 } else {
674                     break;
675                 }
676 
677                 boolean tooFar = moveLeft
678                         ? newLeftTextWidth <= newRightTextWidth
679                         : newLeftTextWidth >= newRightTextWidth;
680                 if (tooFar) {
681                     break;
682                 }
683             }
684         }
685 
686         return (int) Math.ceil(optimalTextWidth);
687     }
688 
689     /**
690      * Returns the combined width of the left drawable (the action icon) and the padding between the
691      * drawable and the button text.
692      */
getLeftCompoundDrawableWidthWithPadding(Button button)693     private int getLeftCompoundDrawableWidthWithPadding(Button button) {
694         Drawable[] drawables = button.getCompoundDrawables();
695         Drawable leftDrawable = drawables[0];
696         if (leftDrawable == null) return 0;
697 
698         return leftDrawable.getBounds().width() + button.getCompoundDrawablePadding();
699     }
700 
squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth)701     private int squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth) {
702         int oldWidth = button.getMeasuredWidth();
703         if (button.getPaddingLeft() != mDoubleLineButtonPaddingHorizontal) {
704             // Correct for the fact that the button was laid out with single-line horizontal
705             // padding.
706             oldWidth += mSingleToDoubleLineButtonWidthIncrease;
707         }
708 
709         // Re-measure the squeezed smart reply button.
710         button.setPadding(mDoubleLineButtonPaddingHorizontal, button.getPaddingTop(),
711                 mDoubleLineButtonPaddingHorizontal, button.getPaddingBottom());
712         final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(
713                 2 * mDoubleLineButtonPaddingHorizontal + textWidth
714                       + getLeftCompoundDrawableWidthWithPadding(button), MeasureSpec.AT_MOST);
715         button.measure(widthMeasureSpec, heightMeasureSpec);
716 
717         final int newWidth = button.getMeasuredWidth();
718 
719         final LayoutParams lp = (LayoutParams) button.getLayoutParams();
720         if (button.getLineCount() > 2 || newWidth >= oldWidth) {
721             lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_FAILED;
722             return SQUEEZE_FAILED;
723         } else {
724             lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_PENDING;
725             return oldWidth - newWidth;
726         }
727     }
728 
remeasureButtonsIfNecessary( int buttonPaddingHorizontal, int maxChildHeight)729     private void remeasureButtonsIfNecessary(
730             int buttonPaddingHorizontal, int maxChildHeight) {
731         final int maxChildHeightMeasure =
732                 MeasureSpec.makeMeasureSpec(maxChildHeight, MeasureSpec.EXACTLY);
733 
734         final int childCount = getChildCount();
735         for (int i = 0; i < childCount; i++) {
736             final View child = getChildAt(i);
737             final LayoutParams lp = (LayoutParams) child.getLayoutParams();
738             if (!lp.show) {
739                 continue;
740             }
741 
742             boolean requiresNewMeasure = false;
743             int newWidth = child.getMeasuredWidth();
744 
745             // Re-measure reason 1: The button needs to be un-squeezed (either because it resulted
746             // in more than two lines or because it was unnecessary).
747             if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_FAILED) {
748                 requiresNewMeasure = true;
749                 newWidth = Integer.MAX_VALUE;
750             }
751 
752             // Re-measure reason 2: The button's horizontal padding is incorrect (because it was
753             // measured with the wrong number of lines).
754             if (child.getPaddingLeft() != buttonPaddingHorizontal) {
755                 requiresNewMeasure = true;
756                 if (newWidth != Integer.MAX_VALUE) {
757                     if (buttonPaddingHorizontal == mSingleLineButtonPaddingHorizontal) {
758                         // Change padding (2->1 line).
759                         newWidth -= mSingleToDoubleLineButtonWidthIncrease;
760                     } else {
761                         // Change padding (1->2 lines).
762                         newWidth += mSingleToDoubleLineButtonWidthIncrease;
763                     }
764                 }
765                 child.setPadding(buttonPaddingHorizontal, child.getPaddingTop(),
766                         buttonPaddingHorizontal, child.getPaddingBottom());
767             }
768 
769             // Re-measure reason 3: The button's height is less than the max height of all buttons
770             // (all should have the same height).
771             if (child.getMeasuredHeight() != maxChildHeight) {
772                 requiresNewMeasure = true;
773             }
774 
775             if (requiresNewMeasure) {
776                 child.measure(MeasureSpec.makeMeasureSpec(newWidth, MeasureSpec.AT_MOST),
777                         maxChildHeightMeasure);
778             }
779         }
780     }
781 
markButtonsWithPendingSqueezeStatusAs( int squeezeStatus, List<View> coveredChildren)782     private void markButtonsWithPendingSqueezeStatusAs(
783             int squeezeStatus, List<View> coveredChildren) {
784         for (View child : coveredChildren) {
785             final LayoutParams lp = (LayoutParams) child.getLayoutParams();
786             if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_PENDING) {
787                 lp.squeezeStatus = squeezeStatus;
788             }
789         }
790     }
791 
792     @Override
onLayout(boolean changed, int left, int top, int right, int bottom)793     protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
794         final boolean isRtl = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
795 
796         final int width = right - left;
797         int position = isRtl ? width - mPaddingRight : mPaddingLeft;
798 
799         final int childCount = getChildCount();
800         for (int i = 0; i < childCount; i++) {
801             final View child = getChildAt(i);
802             final LayoutParams lp = (LayoutParams) child.getLayoutParams();
803             if (!lp.show) {
804                 continue;
805             }
806 
807             final int childWidth = child.getMeasuredWidth();
808             final int childHeight = child.getMeasuredHeight();
809             final int childLeft = isRtl ? position - childWidth : position;
810             child.layout(childLeft, 0, childLeft + childWidth, childHeight);
811 
812             final int childWidthWithSpacing = childWidth + mSpacing;
813             if (isRtl) {
814                 position -= childWidthWithSpacing;
815             } else {
816                 position += childWidthWithSpacing;
817             }
818         }
819     }
820 
821     @Override
drawChild(Canvas canvas, View child, long drawingTime)822     protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
823         final LayoutParams lp = (LayoutParams) child.getLayoutParams();
824         return lp.show && super.drawChild(canvas, child, drawingTime);
825     }
826 
setBackgroundTintColor(int backgroundColor)827     public void setBackgroundTintColor(int backgroundColor) {
828         if (backgroundColor == mCurrentBackgroundColor) {
829             // Same color ignoring.
830            return;
831         }
832         mCurrentBackgroundColor = backgroundColor;
833 
834         final boolean dark = !ContrastColorUtil.isColorLight(backgroundColor);
835 
836         int textColor = ContrastColorUtil.ensureTextContrast(
837                 dark ? mDefaultTextColorDarkBg : mDefaultTextColor,
838                 backgroundColor | 0xff000000, dark);
839         int strokeColor = ContrastColorUtil.ensureContrast(
840                 mDefaultStrokeColor, backgroundColor | 0xff000000, dark, mMinStrokeContrast);
841         int rippleColor = dark ? mRippleColorDarkBg : mRippleColor;
842 
843         int childCount = getChildCount();
844         for (int i = 0; i < childCount; i++) {
845             final Button child = (Button) getChildAt(i);
846             setButtonColors(child, backgroundColor, strokeColor, textColor, rippleColor,
847                     mStrokeWidth);
848         }
849     }
850 
setButtonColors(Button button, int backgroundColor, int strokeColor, int textColor, int rippleColor, int strokeWidth)851     private static void setButtonColors(Button button, int backgroundColor, int strokeColor,
852             int textColor, int rippleColor, int strokeWidth) {
853         Drawable drawable = button.getBackground();
854         if (drawable instanceof RippleDrawable) {
855             // Mutate in case other notifications are using this drawable.
856             drawable = drawable.mutate();
857             RippleDrawable ripple = (RippleDrawable) drawable;
858             ripple.setColor(ColorStateList.valueOf(rippleColor));
859             Drawable inset = ripple.getDrawable(0);
860             if (inset instanceof InsetDrawable) {
861                 Drawable background = ((InsetDrawable) inset).getDrawable();
862                 if (background instanceof GradientDrawable) {
863                     GradientDrawable gradientDrawable = (GradientDrawable) background;
864                     gradientDrawable.setColor(backgroundColor);
865                     gradientDrawable.setStroke(strokeWidth, strokeColor);
866                 }
867             }
868             button.setBackground(drawable);
869         }
870         button.setTextColor(textColor);
871     }
872 
setCornerRadius(Button button, float radius)873     private void setCornerRadius(Button button, float radius) {
874         Drawable drawable = button.getBackground();
875         if (drawable instanceof RippleDrawable) {
876             // Mutate in case other notifications are using this drawable.
877             drawable = drawable.mutate();
878             RippleDrawable ripple = (RippleDrawable) drawable;
879             Drawable inset = ripple.getDrawable(0);
880             if (inset instanceof InsetDrawable) {
881                 Drawable background = ((InsetDrawable) inset).getDrawable();
882                 if (background instanceof GradientDrawable) {
883                     GradientDrawable gradientDrawable = (GradientDrawable) background;
884                     gradientDrawable.setCornerRadius(radius);
885                 }
886             }
887         }
888     }
889 
getActivityStarter()890     private ActivityStarter getActivityStarter() {
891         if (mActivityStarter == null) {
892             mActivityStarter = Dependency.get(ActivityStarter.class);
893         }
894         return mActivityStarter;
895     }
896 
897     private enum SmartButtonType {
898         REPLY,
899         ACTION
900     }
901 
902     @VisibleForTesting
903     static class LayoutParams extends ViewGroup.LayoutParams {
904 
905         /** Button is not squeezed. */
906         private static final int SQUEEZE_STATUS_NONE = 0;
907 
908         /**
909          * Button was successfully squeezed, but it might be un-squeezed later if the squeezing
910          * turns out to have been unnecessary (because there's still not enough space to add another
911          * button).
912          */
913         private static final int SQUEEZE_STATUS_PENDING = 1;
914 
915         /** Button was successfully squeezed and it won't be un-squeezed. */
916         private static final int SQUEEZE_STATUS_SUCCESSFUL = 2;
917 
918         /**
919          * Button wasn't successfully squeezed. The squeezing resulted in more than two lines of
920          * text or it didn't reduce the button's width at all. The button will have to be
921          * re-measured to use only one line of text.
922          */
923         private static final int SQUEEZE_STATUS_FAILED = 3;
924 
925         private boolean show = false;
926         private int squeezeStatus = SQUEEZE_STATUS_NONE;
927         private SmartButtonType buttonType = SmartButtonType.REPLY;
928 
LayoutParams(Context c, AttributeSet attrs)929         private LayoutParams(Context c, AttributeSet attrs) {
930             super(c, attrs);
931         }
932 
LayoutParams(int width, int height)933         private LayoutParams(int width, int height) {
934             super(width, height);
935         }
936 
937         @VisibleForTesting
isShown()938         boolean isShown() {
939             return show;
940         }
941     }
942 
943     /**
944      * Data class for smart replies.
945      */
946     public static class SmartReplies {
947         @NonNull
948         public final RemoteInput remoteInput;
949         @NonNull
950         public final PendingIntent pendingIntent;
951         @NonNull
952         public final CharSequence[] choices;
953         public final boolean fromAssistant;
954 
SmartReplies(CharSequence[] choices, RemoteInput remoteInput, PendingIntent pendingIntent, boolean fromAssistant)955         public SmartReplies(CharSequence[] choices, RemoteInput remoteInput,
956                 PendingIntent pendingIntent, boolean fromAssistant) {
957             this.choices = choices;
958             this.remoteInput = remoteInput;
959             this.pendingIntent = pendingIntent;
960             this.fromAssistant = fromAssistant;
961         }
962     }
963 
964 
965     /**
966      * Data class for smart actions.
967      */
968     public static class SmartActions {
969         @NonNull
970         public final List<Notification.Action> actions;
971         public final boolean fromAssistant;
972 
SmartActions(List<Notification.Action> actions, boolean fromAssistant)973         public SmartActions(List<Notification.Action> actions, boolean fromAssistant) {
974             this.actions = actions;
975             this.fromAssistant = fromAssistant;
976         }
977     }
978 
979     /**
980      * An OnClickListener wrapper that blocks the underlying OnClickListener for a given amount of
981      * time.
982      */
983     private static class DelayedOnClickListener implements OnClickListener {
984         private final OnClickListener mActualListener;
985         private final long mInitDelayMs;
986         private final long mInitTimeMs;
987 
DelayedOnClickListener(OnClickListener actualOnClickListener, long initDelayMs)988         DelayedOnClickListener(OnClickListener actualOnClickListener, long initDelayMs) {
989             mActualListener = actualOnClickListener;
990             mInitDelayMs = initDelayMs;
991             mInitTimeMs = SystemClock.elapsedRealtime();
992         }
993 
onClick(View v)994         public void onClick(View v) {
995             if (hasFinishedInitialization()) {
996                 mActualListener.onClick(v);
997             } else {
998                 Log.i(TAG, "Accidental Smart Suggestion click registered, delay: " + mInitDelayMs);
999             }
1000         }
1001 
hasFinishedInitialization()1002         private boolean hasFinishedInitialization() {
1003             return SystemClock.elapsedRealtime() >= mInitTimeMs + mInitDelayMs;
1004         }
1005     }
1006 }
1007