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