blob: f1996b1370215a4884d5a7aa1640081dfb7fde3b [file] [log] [blame]
Petr Cermaked7429c2017-12-18 19:38:04 +00001package com.android.systemui.statusbar.policy;
2
Kenny Guy14d035c2018-05-02 19:10:36 +01003import android.annotation.ColorInt;
Tony Mak29996702018-11-26 16:23:34 +00004import android.annotation.NonNull;
Gustav Senntoneab53682018-11-01 16:30:23 +00005import android.app.Notification;
Petr Cermaked7429c2017-12-18 19:38:04 +00006import android.app.PendingIntent;
7import android.app.RemoteInput;
8import android.content.Context;
9import android.content.Intent;
Kenny Guy14d035c2018-05-02 19:10:36 +010010import android.content.res.ColorStateList;
Petr Cermak102431d2018-01-29 10:36:07 +000011import android.content.res.TypedArray;
12import android.graphics.Canvas;
Kenny Guy14d035c2018-05-02 19:10:36 +010013import android.graphics.Color;
14import android.graphics.drawable.Drawable;
Petr Cermak102431d2018-01-29 10:36:07 +000015import android.graphics.drawable.GradientDrawable;
Kenny Guy14d035c2018-05-02 19:10:36 +010016import android.graphics.drawable.InsetDrawable;
Petr Cermak102431d2018-01-29 10:36:07 +000017import android.graphics.drawable.RippleDrawable;
Petr Cermaked7429c2017-12-18 19:38:04 +000018import android.os.Bundle;
Petr Cermak102431d2018-01-29 10:36:07 +000019import android.text.Layout;
20import android.text.TextPaint;
21import android.text.method.TransformationMethod;
Petr Cermaked7429c2017-12-18 19:38:04 +000022import android.util.AttributeSet;
23import android.util.Log;
24import android.view.LayoutInflater;
Petr Cermak102431d2018-01-29 10:36:07 +000025import android.view.View;
Petr Cermaked7429c2017-12-18 19:38:04 +000026import android.view.ViewGroup;
Milo Sredkov66da07b2018-04-17 14:04:54 +010027import android.view.accessibility.AccessibilityNodeInfo;
28import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
Petr Cermaked7429c2017-12-18 19:38:04 +000029import android.widget.Button;
Petr Cermaked7429c2017-12-18 19:38:04 +000030
Petr Cermak102431d2018-01-29 10:36:07 +000031import com.android.internal.annotations.VisibleForTesting;
Lucas Dupina291d192018-06-07 13:59:42 -070032import com.android.internal.util.ContrastColorUtil;
Petr Cermak10011fa2018-02-05 19:00:54 +000033import com.android.systemui.Dependency;
Petr Cermaked7429c2017-12-18 19:38:04 +000034import com.android.systemui.R;
Gustav Senntoneab53682018-11-01 16:30:23 +000035import com.android.systemui.plugins.ActivityStarter;
Gus Prevasab336792018-11-14 13:52:20 -050036import com.android.systemui.plugins.ActivityStarter.OnDismissAction;
Kenny Guya0f6de82018-04-06 16:20:16 +010037import com.android.systemui.statusbar.SmartReplyController;
Gus Prevasab336792018-11-14 13:52:20 -050038import com.android.systemui.statusbar.notification.NotificationData;
Milo Sredkove7cf4982018-04-09 15:08:26 +010039import com.android.systemui.statusbar.notification.NotificationUtils;
Milo Sredkovb0f55e92018-04-04 16:13:28 +010040import com.android.systemui.statusbar.phone.KeyguardDismissUtil;
Petr Cermaked7429c2017-12-18 19:38:04 +000041
Petr Cermak102431d2018-01-29 10:36:07 +000042import java.text.BreakIterator;
43import java.util.Comparator;
Gustav Senntoneab53682018-11-01 16:30:23 +000044import java.util.List;
Petr Cermak102431d2018-01-29 10:36:07 +000045import java.util.PriorityQueue;
46
Gustav Senntoneab53682018-11-01 16:30:23 +000047/** View which displays smart reply and smart actions buttons in notifications. */
Petr Cermak102431d2018-01-29 10:36:07 +000048public class SmartReplyView extends ViewGroup {
Petr Cermaked7429c2017-12-18 19:38:04 +000049
50 private static final String TAG = "SmartReplyView";
51
Gustav Senntoneab53682018-11-01 16:30:23 +000052 private static final int MEASURE_SPEC_ANY_LENGTH =
Petr Cermak102431d2018-01-29 10:36:07 +000053 MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
54
55 private static final Comparator<View> DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR =
56 (v1, v2) -> ((v2.getMeasuredWidth() - v2.getPaddingLeft() - v2.getPaddingRight())
57 - (v1.getMeasuredWidth() - v1.getPaddingLeft() - v1.getPaddingRight()));
58
59 private static final int SQUEEZE_FAILED = -1;
60
Petr Cermak10011fa2018-02-05 19:00:54 +000061 private final SmartReplyConstants mConstants;
Milo Sredkovb0f55e92018-04-04 16:13:28 +010062 private final KeyguardDismissUtil mKeyguardDismissUtil;
Petr Cermak10011fa2018-02-05 19:00:54 +000063
Milo Sredkove7cf4982018-04-09 15:08:26 +010064 /**
65 * The upper bound for the height of this view in pixels. Notifications are automatically
66 * recreated on density or font size changes so caching this should be fine.
67 */
68 private final int mHeightUpperLimit;
69
Petr Cermak102431d2018-01-29 10:36:07 +000070 /** Spacing to be applied between views. */
71 private final int mSpacing;
72
73 /** Horizontal padding of smart reply buttons if all of them use only one line of text. */
74 private final int mSingleLineButtonPaddingHorizontal;
75
76 /** Horizontal padding of smart reply buttons if at least one of them uses two lines of text. */
77 private final int mDoubleLineButtonPaddingHorizontal;
78
79 /** Increase in width of a smart reply button as a result of using two lines instead of one. */
80 private final int mSingleToDoubleLineButtonWidthIncrease;
81
82 private final BreakIterator mBreakIterator;
83
84 private PriorityQueue<Button> mCandidateButtonQueueForSqueezing;
85
Kenny Guya0f6de82018-04-06 16:20:16 +010086 private View mSmartReplyContainer;
87
Kenny Guy14d035c2018-05-02 19:10:36 +010088 @ColorInt
89 private int mCurrentBackgroundColor;
90 @ColorInt
91 private final int mDefaultBackgroundColor;
92 @ColorInt
93 private final int mDefaultStrokeColor;
94 @ColorInt
95 private final int mDefaultTextColor;
96 @ColorInt
97 private final int mDefaultTextColorDarkBg;
98 @ColorInt
99 private final int mRippleColorDarkBg;
100 @ColorInt
101 private final int mRippleColor;
102 private final int mStrokeWidth;
103 private final double mMinStrokeContrast;
104
Gustav Senntoneab53682018-11-01 16:30:23 +0000105 private ActivityStarter mActivityStarter;
106
Petr Cermaked7429c2017-12-18 19:38:04 +0000107 public SmartReplyView(Context context, AttributeSet attrs) {
108 super(context, attrs);
Petr Cermak10011fa2018-02-05 19:00:54 +0000109 mConstants = Dependency.get(SmartReplyConstants.class);
Milo Sredkovb0f55e92018-04-04 16:13:28 +0100110 mKeyguardDismissUtil = Dependency.get(KeyguardDismissUtil.class);
Petr Cermak102431d2018-01-29 10:36:07 +0000111
Milo Sredkove7cf4982018-04-09 15:08:26 +0100112 mHeightUpperLimit = NotificationUtils.getFontScaledHeight(mContext,
113 R.dimen.smart_reply_button_max_height);
114
Kenny Guy14d035c2018-05-02 19:10:36 +0100115 mCurrentBackgroundColor = context.getColor(R.color.smart_reply_button_background);
116 mDefaultBackgroundColor = mCurrentBackgroundColor;
117 mDefaultTextColor = mContext.getColor(R.color.smart_reply_button_text);
118 mDefaultTextColorDarkBg = mContext.getColor(R.color.smart_reply_button_text_dark_bg);
119 mDefaultStrokeColor = mContext.getColor(R.color.smart_reply_button_stroke);
120 mRippleColor = mContext.getColor(R.color.notification_ripple_untinted_color);
121 mRippleColorDarkBg = Color.argb(Color.alpha(mRippleColor),
122 255 /* red */, 255 /* green */, 255 /* blue */);
Lucas Dupina291d192018-06-07 13:59:42 -0700123 mMinStrokeContrast = ContrastColorUtil.calculateContrast(mDefaultStrokeColor,
Kenny Guy14d035c2018-05-02 19:10:36 +0100124 mDefaultBackgroundColor);
125
Petr Cermak102431d2018-01-29 10:36:07 +0000126 int spacing = 0;
127 int singleLineButtonPaddingHorizontal = 0;
128 int doubleLineButtonPaddingHorizontal = 0;
Kenny Guy14d035c2018-05-02 19:10:36 +0100129 int strokeWidth = 0;
Petr Cermak102431d2018-01-29 10:36:07 +0000130
131 final TypedArray arr = context.obtainStyledAttributes(attrs, R.styleable.SmartReplyView,
132 0, 0);
133 final int length = arr.getIndexCount();
134 for (int i = 0; i < length; i++) {
135 int attr = arr.getIndex(i);
Jason Monk05dd5672018-08-09 09:38:21 -0400136 if (attr == R.styleable.SmartReplyView_spacing) {
137 spacing = arr.getDimensionPixelSize(i, 0);
138 } else if (attr == R.styleable.SmartReplyView_singleLineButtonPaddingHorizontal) {
139 singleLineButtonPaddingHorizontal = arr.getDimensionPixelSize(i, 0);
140 } else if (attr == R.styleable.SmartReplyView_doubleLineButtonPaddingHorizontal) {
141 doubleLineButtonPaddingHorizontal = arr.getDimensionPixelSize(i, 0);
142 } else if (attr == R.styleable.SmartReplyView_buttonStrokeWidth) {
143 strokeWidth = arr.getDimensionPixelSize(i, 0);
Petr Cermak102431d2018-01-29 10:36:07 +0000144 }
145 }
146 arr.recycle();
147
Kenny Guy14d035c2018-05-02 19:10:36 +0100148 mStrokeWidth = strokeWidth;
Petr Cermak102431d2018-01-29 10:36:07 +0000149 mSpacing = spacing;
150 mSingleLineButtonPaddingHorizontal = singleLineButtonPaddingHorizontal;
151 mDoubleLineButtonPaddingHorizontal = doubleLineButtonPaddingHorizontal;
152 mSingleToDoubleLineButtonWidthIncrease =
153 2 * (doubleLineButtonPaddingHorizontal - singleLineButtonPaddingHorizontal);
154
Milo Sredkove7cf4982018-04-09 15:08:26 +0100155
Petr Cermak102431d2018-01-29 10:36:07 +0000156 mBreakIterator = BreakIterator.getLineInstance();
157 reallocateCandidateButtonQueueForSqueezing();
158 }
159
Milo Sredkove7cf4982018-04-09 15:08:26 +0100160 /**
161 * Returns an upper bound for the height of this view in pixels. This method is intended to be
162 * invoked before onMeasure, so it doesn't do any analysis on the contents of the buttons.
163 */
164 public int getHeightUpperLimit() {
165 return mHeightUpperLimit;
166 }
167
Petr Cermak102431d2018-01-29 10:36:07 +0000168 private void reallocateCandidateButtonQueueForSqueezing() {
169 // Instead of clearing the priority queue, we re-allocate so that it would fit all buttons
170 // exactly. This avoids (1) wasting memory because PriorityQueue never shrinks and
171 // (2) growing in onMeasure.
172 // The constructor throws an IllegalArgument exception if initial capacity is less than 1.
173 mCandidateButtonQueueForSqueezing = new PriorityQueue<>(
174 Math.max(getChildCount(), 1), DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR);
Petr Cermaked7429c2017-12-18 19:38:04 +0000175 }
176
Gustav Senntoneab53682018-11-01 16:30:23 +0000177 /**
178 * Reset the smart suggestions view to allow adding new replies and actions.
179 */
180 public void resetSmartSuggestions(View newSmartReplyContainer) {
181 mSmartReplyContainer = newSmartReplyContainer;
Petr Cermaked7429c2017-12-18 19:38:04 +0000182 removeAllViews();
Kenny Guy14d035c2018-05-02 19:10:36 +0100183 mCurrentBackgroundColor = mDefaultBackgroundColor;
Gustav Senntoneab53682018-11-01 16:30:23 +0000184 }
185
186 /**
187 * Add smart replies to this view, using the provided {@link RemoteInput} and
188 * {@link PendingIntent} to respond when the user taps a smart reply. Only the replies that fit
189 * into the notification are shown.
190 */
191 public void addRepliesFromRemoteInput(
Tony Mak29996702018-11-26 16:23:34 +0000192 SmartReplies smartReplies,
193 SmartReplyController smartReplyController, NotificationData.Entry entry) {
194 if (smartReplies.remoteInput != null && smartReplies.pendingIntent != null) {
195 if (smartReplies.choices != null) {
196 for (int i = 0; i < smartReplies.choices.length; ++i) {
Petr Cermaked7429c2017-12-18 19:38:04 +0000197 Button replyButton = inflateReplyButton(
Tony Mak29996702018-11-26 16:23:34 +0000198 getContext(), this, i, smartReplies, smartReplyController, entry);
Petr Cermaked7429c2017-12-18 19:38:04 +0000199 addView(replyButton);
200 }
201 }
202 }
Petr Cermak102431d2018-01-29 10:36:07 +0000203 reallocateCandidateButtonQueueForSqueezing();
Petr Cermaked7429c2017-12-18 19:38:04 +0000204 }
205
Gustav Senntoneab53682018-11-01 16:30:23 +0000206 /**
207 * Add smart actions to be shown next to smart replies. Only the actions that fit into the
208 * notification are shown.
209 */
Tony Mak7d4b3a52018-11-27 17:29:36 +0000210 public void addSmartActions(SmartActions smartActions,
211 SmartReplyController smartReplyController, NotificationData.Entry entry) {
Tony Mak29996702018-11-26 16:23:34 +0000212 int numSmartActions = smartActions.actions.size();
Gustav Senntoneab53682018-11-01 16:30:23 +0000213 for (int n = 0; n < numSmartActions; n++) {
Tony Mak29996702018-11-26 16:23:34 +0000214 Notification.Action action = smartActions.actions.get(n);
Gustav Senntoneab53682018-11-01 16:30:23 +0000215 if (action.actionIntent != null) {
Tony Mak7d4b3a52018-11-27 17:29:36 +0000216 Button actionButton = inflateActionButton(
217 getContext(), this, n, smartActions, smartReplyController, entry);
Gustav Senntoneab53682018-11-01 16:30:23 +0000218 addView(actionButton);
219 }
220 }
221 reallocateCandidateButtonQueueForSqueezing();
222 }
223
Petr Cermaked7429c2017-12-18 19:38:04 +0000224 public static SmartReplyView inflate(Context context, ViewGroup root) {
225 return (SmartReplyView)
226 LayoutInflater.from(context).inflate(R.layout.smart_reply_view, root, false);
227 }
228
Petr Cermak102431d2018-01-29 10:36:07 +0000229 @VisibleForTesting
Kenny Guy23991102018-04-05 21:18:38 +0100230 Button inflateReplyButton(Context context, ViewGroup root, int replyIndex,
Tony Mak29996702018-11-26 16:23:34 +0000231 SmartReplies smartReplies, SmartReplyController smartReplyController,
232 NotificationData.Entry entry) {
Petr Cermaked7429c2017-12-18 19:38:04 +0000233 Button b = (Button) LayoutInflater.from(context).inflate(
234 R.layout.smart_reply_button, root, false);
Tony Mak29996702018-11-26 16:23:34 +0000235 CharSequence choice = smartReplies.choices[replyIndex];
Petr Cermaked7429c2017-12-18 19:38:04 +0000236 b.setText(choice);
Milo Sredkovb0f55e92018-04-04 16:13:28 +0100237
238 OnDismissAction action = () -> {
Tony Mak29996702018-11-26 16:23:34 +0000239 smartReplyController.smartReplySent(
240 entry, replyIndex, b.getText(), smartReplies.fromAssistant);
Petr Cermaked7429c2017-12-18 19:38:04 +0000241 Bundle results = new Bundle();
Tony Mak29996702018-11-26 16:23:34 +0000242 results.putString(smartReplies.remoteInput.getResultKey(), choice.toString());
Petr Cermaked7429c2017-12-18 19:38:04 +0000243 Intent intent = new Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
Tony Mak29996702018-11-26 16:23:34 +0000244 RemoteInput.addResultsToIntent(new RemoteInput[]{smartReplies.remoteInput}, intent,
245 results);
Petr Cermak9a3380c2018-01-19 15:00:24 +0000246 RemoteInput.setResultsSource(intent, RemoteInput.SOURCE_CHOICE);
Selim Cinekb0dc61b2018-05-22 18:49:36 -0700247 entry.setHasSentReply();
Petr Cermaked7429c2017-12-18 19:38:04 +0000248 try {
Tony Mak29996702018-11-26 16:23:34 +0000249 smartReplies.pendingIntent.send(context, 0, intent);
Petr Cermaked7429c2017-12-18 19:38:04 +0000250 } catch (PendingIntent.CanceledException e) {
251 Log.w(TAG, "Unable to send smart reply", e);
252 }
Kenny Guya0f6de82018-04-06 16:20:16 +0100253 mSmartReplyContainer.setVisibility(View.GONE);
Milo Sredkovb0f55e92018-04-04 16:13:28 +0100254 return false; // do not defer
255 };
256
257 b.setOnClickListener(view -> {
Milo Sredkove433e9b2018-05-01 22:45:38 +0100258 mKeyguardDismissUtil.executeWhenUnlocked(action);
Petr Cermaked7429c2017-12-18 19:38:04 +0000259 });
Milo Sredkov66da07b2018-04-17 14:04:54 +0100260
261 b.setAccessibilityDelegate(new AccessibilityDelegate() {
262 public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
263 super.onInitializeAccessibilityNodeInfo(host, info);
264 String label = getResources().getString(R.string.accessibility_send_smart_reply);
265 info.addAction(new AccessibilityAction(AccessibilityNodeInfo.ACTION_CLICK, label));
266 }
267 });
268
Kenny Guy14d035c2018-05-02 19:10:36 +0100269 setColors(b, mCurrentBackgroundColor, mDefaultStrokeColor, mDefaultTextColor, mRippleColor);
Petr Cermaked7429c2017-12-18 19:38:04 +0000270 return b;
271 }
Petr Cermak102431d2018-01-29 10:36:07 +0000272
Gustav Senntoneab53682018-11-01 16:30:23 +0000273 @VisibleForTesting
Tony Mak7d4b3a52018-11-27 17:29:36 +0000274 Button inflateActionButton(Context context, ViewGroup root, int actionIndex,
275 SmartActions smartActions, SmartReplyController smartReplyController,
276 NotificationData.Entry entry) {
277 Notification.Action action = smartActions.actions.get(actionIndex);
Gustav Senntoneab53682018-11-01 16:30:23 +0000278 Button button = (Button) LayoutInflater.from(context).inflate(
279 R.layout.smart_action_button, root, false);
280 button.setText(action.title);
281
282 Drawable iconDrawable = action.getIcon().loadDrawable(context);
283 // Add the action icon to the Smart Action button.
Gustav Senntonc726f472018-12-04 11:57:06 +0000284 int newIconSize = context.getResources().getDimensionPixelSize(
285 R.dimen.smart_action_button_icon_size);
286 iconDrawable.setBounds(0, 0, newIconSize, newIconSize);
Gustav Senntoneab53682018-11-01 16:30:23 +0000287 button.setCompoundDrawables(iconDrawable, null, null, null);
288
289 button.setOnClickListener(view ->
Tony Mak7d4b3a52018-11-27 17:29:36 +0000290 getActivityStarter().startPendingIntentDismissingKeyguard(
291 action.actionIntent,
292 () -> smartReplyController.smartActionClicked(
293 entry, actionIndex, action, smartActions.fromAssistant)));
Gustav Senntoneab53682018-11-01 16:30:23 +0000294
295 // TODO(b/119010281): handle accessibility
296
297 return button;
298 }
299
Petr Cermak102431d2018-01-29 10:36:07 +0000300 @Override
301 public LayoutParams generateLayoutParams(AttributeSet attrs) {
302 return new LayoutParams(mContext, attrs);
303 }
304
305 @Override
306 protected LayoutParams generateDefaultLayoutParams() {
307 return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
308 }
309
310 @Override
311 protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams params) {
312 return new LayoutParams(params.width, params.height);
313 }
314
315 @Override
316 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
317 final int targetWidth = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED
318 ? Integer.MAX_VALUE : MeasureSpec.getSize(widthMeasureSpec);
319
320 // Mark all buttons as hidden and un-squeezed.
321 resetButtonsLayoutParams();
322
323 if (!mCandidateButtonQueueForSqueezing.isEmpty()) {
324 Log.wtf(TAG, "Single line button queue leaked between onMeasure calls");
325 mCandidateButtonQueueForSqueezing.clear();
326 }
327
328 int measuredWidth = mPaddingLeft + mPaddingRight;
329 int maxChildHeight = 0;
330 int displayedChildCount = 0;
331 int buttonPaddingHorizontal = mSingleLineButtonPaddingHorizontal;
332
333 final int childCount = getChildCount();
334 for (int i = 0; i < childCount; i++) {
335 final View child = getChildAt(i);
336 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
337 if (child.getVisibility() != View.VISIBLE || !(child instanceof Button)) {
338 continue;
339 }
340
341 child.setPadding(buttonPaddingHorizontal, child.getPaddingTop(),
342 buttonPaddingHorizontal, child.getPaddingBottom());
Gustav Senntoneab53682018-11-01 16:30:23 +0000343 child.measure(MEASURE_SPEC_ANY_LENGTH, heightMeasureSpec);
Petr Cermak102431d2018-01-29 10:36:07 +0000344
345 final int lineCount = ((Button) child).getLineCount();
346 if (lineCount < 1 || lineCount > 2) {
347 // If smart reply has no text, or more than two lines, then don't show it.
348 continue;
349 }
350
351 if (lineCount == 1) {
352 mCandidateButtonQueueForSqueezing.add((Button) child);
353 }
354
355 // Remember the current measurements in case the current button doesn't fit in.
356 final int originalMaxChildHeight = maxChildHeight;
357 final int originalMeasuredWidth = measuredWidth;
358 final int originalButtonPaddingHorizontal = buttonPaddingHorizontal;
359
360 final int spacing = displayedChildCount == 0 ? 0 : mSpacing;
361 final int childWidth = child.getMeasuredWidth();
362 final int childHeight = child.getMeasuredHeight();
363 measuredWidth += spacing + childWidth;
364 maxChildHeight = Math.max(maxChildHeight, childHeight);
365
366 // Do we need to increase the number of lines in smart reply buttons to two?
367 final boolean increaseToTwoLines =
368 buttonPaddingHorizontal == mSingleLineButtonPaddingHorizontal
369 && (lineCount == 2 || measuredWidth > targetWidth);
370 if (increaseToTwoLines) {
371 measuredWidth += (displayedChildCount + 1) * mSingleToDoubleLineButtonWidthIncrease;
372 buttonPaddingHorizontal = mDoubleLineButtonPaddingHorizontal;
373 }
374
375 // If the last button doesn't fit into the remaining width, try squeezing preceding
376 // smart reply buttons.
377 if (measuredWidth > targetWidth) {
378 // Keep squeezing preceding and current smart reply buttons until they all fit.
379 while (measuredWidth > targetWidth
380 && !mCandidateButtonQueueForSqueezing.isEmpty()) {
381 final Button candidate = mCandidateButtonQueueForSqueezing.poll();
382 final int squeezeReduction = squeezeButton(candidate, heightMeasureSpec);
383 if (squeezeReduction != SQUEEZE_FAILED) {
384 maxChildHeight = Math.max(maxChildHeight, candidate.getMeasuredHeight());
385 measuredWidth -= squeezeReduction;
386 }
387 }
388
389 // If the current button still doesn't fit after squeezing all buttons, undo the
390 // last squeezing round.
391 if (measuredWidth > targetWidth) {
392 measuredWidth = originalMeasuredWidth;
393 maxChildHeight = originalMaxChildHeight;
394 buttonPaddingHorizontal = originalButtonPaddingHorizontal;
395
396 // Mark all buttons from the last squeezing round as "failed to squeeze", so
397 // that they're re-measured without squeezing later.
398 markButtonsWithPendingSqueezeStatusAs(LayoutParams.SQUEEZE_STATUS_FAILED, i);
399
400 // The current button doesn't fit, so there's no point in measuring further
401 // buttons.
402 break;
403 }
404
405 // The current button fits, so mark all squeezed buttons as "successfully squeezed"
406 // to prevent them from being un-squeezed in a subsequent squeezing round.
407 markButtonsWithPendingSqueezeStatusAs(LayoutParams.SQUEEZE_STATUS_SUCCESSFUL, i);
408 }
409
410 lp.show = true;
411 displayedChildCount++;
412 }
413
414 // We're done squeezing buttons, so we can clear the priority queue.
415 mCandidateButtonQueueForSqueezing.clear();
416
Milo Sredkova5bacea2018-04-12 12:52:43 +0100417 // Finally, we need to re-measure some buttons.
418 remeasureButtonsIfNecessary(buttonPaddingHorizontal, maxChildHeight);
Petr Cermak102431d2018-01-29 10:36:07 +0000419
420 setMeasuredDimension(
421 resolveSize(Math.max(getSuggestedMinimumWidth(), measuredWidth), widthMeasureSpec),
422 resolveSize(Math.max(getSuggestedMinimumHeight(),
423 mPaddingTop + maxChildHeight + mPaddingBottom), heightMeasureSpec));
424 }
425
426 private void resetButtonsLayoutParams() {
427 final int childCount = getChildCount();
428 for (int i = 0; i < childCount; i++) {
429 final View child = getChildAt(i);
430 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
431 lp.show = false;
432 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_NONE;
433 }
434 }
435
436 private int squeezeButton(Button button, int heightMeasureSpec) {
437 final int estimatedOptimalTextWidth = estimateOptimalSqueezedButtonTextWidth(button);
438 if (estimatedOptimalTextWidth == SQUEEZE_FAILED) {
439 return SQUEEZE_FAILED;
440 }
441 return squeezeButtonToTextWidth(button, heightMeasureSpec, estimatedOptimalTextWidth);
442 }
443
444 private int estimateOptimalSqueezedButtonTextWidth(Button button) {
445 // Find a line-break point in the middle of the smart reply button text.
446 final String rawText = button.getText().toString();
447
448 // The button sometimes has a transformation affecting text layout (e.g. all caps).
449 final TransformationMethod transformation = button.getTransformationMethod();
450 final String text = transformation == null ?
451 rawText : transformation.getTransformation(rawText, button).toString();
452 final int length = text.length();
453 mBreakIterator.setText(text);
454
455 if (mBreakIterator.preceding(length / 2) == BreakIterator.DONE) {
456 if (mBreakIterator.next() == BreakIterator.DONE) {
457 // Can't find a single possible line break in either direction.
458 return SQUEEZE_FAILED;
459 }
460 }
461
462 final TextPaint paint = button.getPaint();
463 final int initialPosition = mBreakIterator.current();
464 final float initialLeftTextWidth = Layout.getDesiredWidth(text, 0, initialPosition, paint);
465 final float initialRightTextWidth =
466 Layout.getDesiredWidth(text, initialPosition, length, paint);
467 float optimalTextWidth = Math.max(initialLeftTextWidth, initialRightTextWidth);
468
469 if (initialLeftTextWidth != initialRightTextWidth) {
470 // See if there's a better line-break point (leading to a more narrow button) in
471 // either left or right direction.
472 final boolean moveLeft = initialLeftTextWidth > initialRightTextWidth;
473 final int maxSqueezeRemeasureAttempts = mConstants.getMaxSqueezeRemeasureAttempts();
474 for (int i = 0; i < maxSqueezeRemeasureAttempts; i++) {
475 final int newPosition =
476 moveLeft ? mBreakIterator.previous() : mBreakIterator.next();
477 if (newPosition == BreakIterator.DONE) {
478 break;
479 }
480
481 final float newLeftTextWidth = Layout.getDesiredWidth(text, 0, newPosition, paint);
482 final float newRightTextWidth =
483 Layout.getDesiredWidth(text, newPosition, length, paint);
484 final float newOptimalTextWidth = Math.max(newLeftTextWidth, newRightTextWidth);
485 if (newOptimalTextWidth < optimalTextWidth) {
486 optimalTextWidth = newOptimalTextWidth;
487 } else {
488 break;
489 }
490
491 boolean tooFar = moveLeft
492 ? newLeftTextWidth <= newRightTextWidth
493 : newLeftTextWidth >= newRightTextWidth;
494 if (tooFar) {
495 break;
496 }
497 }
498 }
499
500 return (int) Math.ceil(optimalTextWidth);
501 }
502
Gustav Senntoneab53682018-11-01 16:30:23 +0000503 /**
504 * Returns the combined width of the left drawable (the action icon) and the padding between the
505 * drawable and the button text.
506 */
507 private int getLeftCompoundDrawableWidthWithPadding(Button button) {
508 Drawable[] drawables = button.getCompoundDrawables();
509 Drawable leftDrawable = drawables[0];
510 if (leftDrawable == null) return 0;
511
512 return leftDrawable.getBounds().width() + button.getCompoundDrawablePadding();
513 }
514
Petr Cermak102431d2018-01-29 10:36:07 +0000515 private int squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth) {
516 int oldWidth = button.getMeasuredWidth();
517 if (button.getPaddingLeft() != mDoubleLineButtonPaddingHorizontal) {
518 // Correct for the fact that the button was laid out with single-line horizontal
519 // padding.
520 oldWidth += mSingleToDoubleLineButtonWidthIncrease;
521 }
522
523 // Re-measure the squeezed smart reply button.
524 button.setPadding(mDoubleLineButtonPaddingHorizontal, button.getPaddingTop(),
525 mDoubleLineButtonPaddingHorizontal, button.getPaddingBottom());
526 final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(
Gustav Senntoneab53682018-11-01 16:30:23 +0000527 2 * mDoubleLineButtonPaddingHorizontal + textWidth
528 + getLeftCompoundDrawableWidthWithPadding(button), MeasureSpec.AT_MOST);
Petr Cermak102431d2018-01-29 10:36:07 +0000529 button.measure(widthMeasureSpec, heightMeasureSpec);
530
531 final int newWidth = button.getMeasuredWidth();
532
533 final LayoutParams lp = (LayoutParams) button.getLayoutParams();
534 if (button.getLineCount() > 2 || newWidth >= oldWidth) {
535 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_FAILED;
536 return SQUEEZE_FAILED;
537 } else {
538 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_PENDING;
539 return oldWidth - newWidth;
540 }
541 }
542
Milo Sredkova5bacea2018-04-12 12:52:43 +0100543 private void remeasureButtonsIfNecessary(
Petr Cermak102431d2018-01-29 10:36:07 +0000544 int buttonPaddingHorizontal, int maxChildHeight) {
Petr Cermak102431d2018-01-29 10:36:07 +0000545 final int maxChildHeightMeasure =
546 MeasureSpec.makeMeasureSpec(maxChildHeight, MeasureSpec.EXACTLY);
547
548 final int childCount = getChildCount();
549 for (int i = 0; i < childCount; i++) {
550 final View child = getChildAt(i);
551 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
552 if (!lp.show) {
553 continue;
554 }
555
Petr Cermak102431d2018-01-29 10:36:07 +0000556 boolean requiresNewMeasure = false;
557 int newWidth = child.getMeasuredWidth();
558
559 // Re-measure reason 1: The button needs to be un-squeezed (either because it resulted
560 // in more than two lines or because it was unnecessary).
561 if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_FAILED) {
562 requiresNewMeasure = true;
563 newWidth = Integer.MAX_VALUE;
564 }
565
566 // Re-measure reason 2: The button's horizontal padding is incorrect (because it was
567 // measured with the wrong number of lines).
568 if (child.getPaddingLeft() != buttonPaddingHorizontal) {
569 requiresNewMeasure = true;
Kenny Guy48ee6d62018-05-09 16:51:26 +0100570 if (newWidth != Integer.MAX_VALUE) {
571 if (buttonPaddingHorizontal == mSingleLineButtonPaddingHorizontal) {
572 // Change padding (2->1 line).
573 newWidth -= mSingleToDoubleLineButtonWidthIncrease;
574 } else {
575 // Change padding (1->2 lines).
576 newWidth += mSingleToDoubleLineButtonWidthIncrease;
577 }
Petr Cermak102431d2018-01-29 10:36:07 +0000578 }
579 child.setPadding(buttonPaddingHorizontal, child.getPaddingTop(),
580 buttonPaddingHorizontal, child.getPaddingBottom());
581 }
582
583 // Re-measure reason 3: The button's height is less than the max height of all buttons
584 // (all should have the same height).
585 if (child.getMeasuredHeight() != maxChildHeight) {
586 requiresNewMeasure = true;
587 }
588
589 if (requiresNewMeasure) {
590 child.measure(MeasureSpec.makeMeasureSpec(newWidth, MeasureSpec.AT_MOST),
591 maxChildHeightMeasure);
592 }
593 }
594 }
595
596 private void markButtonsWithPendingSqueezeStatusAs(int squeezeStatus, int maxChildIndex) {
597 for (int i = 0; i <= maxChildIndex; i++) {
598 final View child = getChildAt(i);
599 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
600 if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_PENDING) {
601 lp.squeezeStatus = squeezeStatus;
602 }
603 }
604 }
605
606 @Override
607 protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
608 final boolean isRtl = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
609
610 final int width = right - left;
611 int position = isRtl ? width - mPaddingRight : mPaddingLeft;
612
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 if (!lp.show) {
618 continue;
619 }
620
621 final int childWidth = child.getMeasuredWidth();
622 final int childHeight = child.getMeasuredHeight();
623 final int childLeft = isRtl ? position - childWidth : position;
624 child.layout(childLeft, 0, childLeft + childWidth, childHeight);
625
626 final int childWidthWithSpacing = childWidth + mSpacing;
627 if (isRtl) {
628 position -= childWidthWithSpacing;
629 } else {
630 position += childWidthWithSpacing;
631 }
632 }
633 }
634
635 @Override
636 protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
637 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
638 return lp.show && super.drawChild(canvas, child, drawingTime);
639 }
640
Kenny Guy14d035c2018-05-02 19:10:36 +0100641 public void setBackgroundTintColor(int backgroundColor) {
642 if (backgroundColor == mCurrentBackgroundColor) {
643 // Same color ignoring.
644 return;
645 }
646 mCurrentBackgroundColor = backgroundColor;
647
Lucas Dupina291d192018-06-07 13:59:42 -0700648 final boolean dark = !ContrastColorUtil.isColorLight(backgroundColor);
Kenny Guy14d035c2018-05-02 19:10:36 +0100649
Lucas Dupina291d192018-06-07 13:59:42 -0700650 int textColor = ContrastColorUtil.ensureTextContrast(
Kenny Guy14d035c2018-05-02 19:10:36 +0100651 dark ? mDefaultTextColorDarkBg : mDefaultTextColor,
652 backgroundColor | 0xff000000, dark);
Lucas Dupina291d192018-06-07 13:59:42 -0700653 int strokeColor = ContrastColorUtil.ensureContrast(
Kenny Guy14d035c2018-05-02 19:10:36 +0100654 mDefaultStrokeColor, backgroundColor | 0xff000000, dark, mMinStrokeContrast);
655 int rippleColor = dark ? mRippleColorDarkBg : mRippleColor;
656
657 int childCount = getChildCount();
658 for (int i = 0; i < childCount; i++) {
659 final Button child = (Button) getChildAt(i);
660 setColors(child, backgroundColor, strokeColor, textColor, rippleColor);
661 }
662 }
663
664 private void setColors(Button button, int backgroundColor, int strokeColor, int textColor,
665 int rippleColor) {
666 Drawable drawable = button.getBackground();
667 if (drawable instanceof RippleDrawable) {
668 // Mutate in case other notifications are using this drawable.
669 drawable = drawable.mutate();
670 RippleDrawable ripple = (RippleDrawable) drawable;
671 ripple.setColor(ColorStateList.valueOf(rippleColor));
672 Drawable inset = ripple.getDrawable(0);
673 if (inset instanceof InsetDrawable) {
674 Drawable background = ((InsetDrawable) inset).getDrawable();
675 if (background instanceof GradientDrawable) {
676 GradientDrawable gradientDrawable = (GradientDrawable) background;
677 gradientDrawable.setColor(backgroundColor);
678 gradientDrawable.setStroke(mStrokeWidth, strokeColor);
679 }
680 }
681 button.setBackground(drawable);
682 }
683 button.setTextColor(textColor);
684 }
685
Gustav Senntoneab53682018-11-01 16:30:23 +0000686 private ActivityStarter getActivityStarter() {
687 if (mActivityStarter == null) {
688 mActivityStarter = Dependency.get(ActivityStarter.class);
689 }
690 return mActivityStarter;
691 }
692
Petr Cermak102431d2018-01-29 10:36:07 +0000693 @VisibleForTesting
694 static class LayoutParams extends ViewGroup.LayoutParams {
695
696 /** Button is not squeezed. */
697 private static final int SQUEEZE_STATUS_NONE = 0;
698
699 /**
700 * Button was successfully squeezed, but it might be un-squeezed later if the squeezing
701 * turns out to have been unnecessary (because there's still not enough space to add another
702 * button).
703 */
704 private static final int SQUEEZE_STATUS_PENDING = 1;
705
706 /** Button was successfully squeezed and it won't be un-squeezed. */
707 private static final int SQUEEZE_STATUS_SUCCESSFUL = 2;
708
709 /**
710 * Button wasn't successfully squeezed. The squeezing resulted in more than two lines of
711 * text or it didn't reduce the button's width at all. The button will have to be
712 * re-measured to use only one line of text.
713 */
714 private static final int SQUEEZE_STATUS_FAILED = 3;
715
716 private boolean show = false;
717 private int squeezeStatus = SQUEEZE_STATUS_NONE;
718
719 private LayoutParams(Context c, AttributeSet attrs) {
720 super(c, attrs);
721 }
722
723 private LayoutParams(int width, int height) {
724 super(width, height);
725 }
726
727 @VisibleForTesting
728 boolean isShown() {
729 return show;
730 }
731 }
Tony Mak29996702018-11-26 16:23:34 +0000732
733 /**
734 * Data class for smart replies.
735 */
736 public static class SmartReplies {
737 @NonNull
738 public final RemoteInput remoteInput;
739 @NonNull
740 public final PendingIntent pendingIntent;
741 @NonNull
742 public final CharSequence[] choices;
743 public final boolean fromAssistant;
744
745 public SmartReplies(CharSequence[] choices, RemoteInput remoteInput,
746 PendingIntent pendingIntent, boolean fromAssistant) {
747 this.choices = choices;
748 this.remoteInput = remoteInput;
749 this.pendingIntent = pendingIntent;
750 this.fromAssistant = fromAssistant;
751 }
752 }
753
754
755 /**
756 * Data class for smart actions.
757 */
758 public static class SmartActions {
759 @NonNull
760 public final List<Notification.Action> actions;
761 public final boolean fromAssistant;
762
763 public SmartActions(List<Notification.Action> actions, boolean fromAssistant) {
764 this.actions = actions;
765 this.fromAssistant = fromAssistant;
766 }
767 }
Petr Cermaked7429c2017-12-18 19:38:04 +0000768}