blob: 018668377a50c5960aef9ba0f13553f9e3cf1b8d [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;
Gustav Senntoneab53682018-11-01 16:30:23 +00004import android.app.Notification;
Petr Cermaked7429c2017-12-18 19:38:04 +00005import android.app.PendingIntent;
6import android.app.RemoteInput;
7import android.content.Context;
8import android.content.Intent;
Kenny Guy14d035c2018-05-02 19:10:36 +01009import android.content.res.ColorStateList;
Petr Cermak102431d2018-01-29 10:36:07 +000010import android.content.res.TypedArray;
11import android.graphics.Canvas;
Kenny Guy14d035c2018-05-02 19:10:36 +010012import android.graphics.Color;
13import android.graphics.drawable.Drawable;
Petr Cermak102431d2018-01-29 10:36:07 +000014import android.graphics.drawable.GradientDrawable;
Kenny Guy14d035c2018-05-02 19:10:36 +010015import android.graphics.drawable.InsetDrawable;
Petr Cermak102431d2018-01-29 10:36:07 +000016import android.graphics.drawable.RippleDrawable;
Petr Cermaked7429c2017-12-18 19:38:04 +000017import android.os.Bundle;
Petr Cermak102431d2018-01-29 10:36:07 +000018import android.text.Layout;
19import android.text.TextPaint;
20import android.text.method.TransformationMethod;
Petr Cermaked7429c2017-12-18 19:38:04 +000021import android.util.AttributeSet;
22import android.util.Log;
Gustav Senntoneab53682018-11-01 16:30:23 +000023import android.util.Size;
Petr Cermaked7429c2017-12-18 19:38:04 +000024import 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(
192 RemoteInput remoteInput, PendingIntent pendingIntent,
193 SmartReplyController smartReplyController, NotificationData.Entry entry,
194 CharSequence[] choices) {
Petr Cermaked7429c2017-12-18 19:38:04 +0000195 if (remoteInput != null && pendingIntent != null) {
Petr Cermaked7429c2017-12-18 19:38:04 +0000196 if (choices != null) {
Kenny Guy23991102018-04-05 21:18:38 +0100197 for (int i = 0; i < choices.length; ++i) {
Petr Cermaked7429c2017-12-18 19:38:04 +0000198 Button replyButton = inflateReplyButton(
Kenny Guy23991102018-04-05 21:18:38 +0100199 getContext(), this, i, choices[i], remoteInput, pendingIntent,
Kenny Guya0f6de82018-04-06 16:20:16 +0100200 smartReplyController, entry);
Petr Cermaked7429c2017-12-18 19:38:04 +0000201 addView(replyButton);
202 }
203 }
204 }
Petr Cermak102431d2018-01-29 10:36:07 +0000205 reallocateCandidateButtonQueueForSqueezing();
Petr Cermaked7429c2017-12-18 19:38:04 +0000206 }
207
Gustav Senntoneab53682018-11-01 16:30:23 +0000208 /**
209 * Add smart actions to be shown next to smart replies. Only the actions that fit into the
210 * notification are shown.
211 */
212 public void addSmartActions(List<Notification.Action> smartActions) {
213 int numSmartActions = smartActions.size();
214 for (int n = 0; n < numSmartActions; n++) {
215 Notification.Action action = smartActions.get(n);
216 if (action.actionIntent != null) {
217 Button actionButton = inflateActionButton(getContext(), this, action);
218 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,
231 CharSequence choice, RemoteInput remoteInput, PendingIntent pendingIntent,
Kenny Guya0f6de82018-04-06 16:20:16 +0100232 SmartReplyController smartReplyController, 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);
235 b.setText(choice);
Milo Sredkovb0f55e92018-04-04 16:13:28 +0100236
237 OnDismissAction action = () -> {
Kenny Guy8cc15d22018-05-09 09:50:55 +0100238 smartReplyController.smartReplySent(entry, replyIndex, b.getText());
Petr Cermaked7429c2017-12-18 19:38:04 +0000239 Bundle results = new Bundle();
240 results.putString(remoteInput.getResultKey(), choice.toString());
241 Intent intent = new Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
242 RemoteInput.addResultsToIntent(new RemoteInput[]{remoteInput}, intent, results);
Petr Cermak9a3380c2018-01-19 15:00:24 +0000243 RemoteInput.setResultsSource(intent, RemoteInput.SOURCE_CHOICE);
Selim Cinekb0dc61b2018-05-22 18:49:36 -0700244 entry.setHasSentReply();
Petr Cermaked7429c2017-12-18 19:38:04 +0000245 try {
246 pendingIntent.send(context, 0, intent);
247 } catch (PendingIntent.CanceledException e) {
248 Log.w(TAG, "Unable to send smart reply", e);
249 }
Kenny Guya0f6de82018-04-06 16:20:16 +0100250 mSmartReplyContainer.setVisibility(View.GONE);
Milo Sredkovb0f55e92018-04-04 16:13:28 +0100251 return false; // do not defer
252 };
253
254 b.setOnClickListener(view -> {
Milo Sredkove433e9b2018-05-01 22:45:38 +0100255 mKeyguardDismissUtil.executeWhenUnlocked(action);
Petr Cermaked7429c2017-12-18 19:38:04 +0000256 });
Milo Sredkov66da07b2018-04-17 14:04:54 +0100257
258 b.setAccessibilityDelegate(new AccessibilityDelegate() {
259 public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
260 super.onInitializeAccessibilityNodeInfo(host, info);
261 String label = getResources().getString(R.string.accessibility_send_smart_reply);
262 info.addAction(new AccessibilityAction(AccessibilityNodeInfo.ACTION_CLICK, label));
263 }
264 });
265
Kenny Guy14d035c2018-05-02 19:10:36 +0100266 setColors(b, mCurrentBackgroundColor, mDefaultStrokeColor, mDefaultTextColor, mRippleColor);
Petr Cermaked7429c2017-12-18 19:38:04 +0000267 return b;
268 }
Petr Cermak102431d2018-01-29 10:36:07 +0000269
Gustav Senntoneab53682018-11-01 16:30:23 +0000270 @VisibleForTesting
271 Button inflateActionButton(Context context, ViewGroup root, Notification.Action action) {
272 Button button = (Button) LayoutInflater.from(context).inflate(
273 R.layout.smart_action_button, root, false);
274 button.setText(action.title);
275
276 Drawable iconDrawable = action.getIcon().loadDrawable(context);
277 // Add the action icon to the Smart Action button.
278 Size newIconSize = calculateIconSizeFromSingleLineButton(context, root,
279 new Size(iconDrawable.getIntrinsicWidth(), iconDrawable.getIntrinsicHeight()));
280 iconDrawable.setBounds(0, 0, newIconSize.getWidth(), newIconSize.getHeight());
281 button.setCompoundDrawables(iconDrawable, null, null, null);
282
283 button.setOnClickListener(view ->
284 getActivityStarter().startPendingIntentDismissingKeyguard(action.actionIntent));
285
286 // TODO(b/119010281): handle accessibility
287
288 return button;
289 }
290
291 private static Size calculateIconSizeFromSingleLineButton(Context context, ViewGroup root,
292 Size originalIconSize) {
293 Button button = (Button) LayoutInflater.from(context).inflate(
294 R.layout.smart_action_button, root, false);
295 // Add simple text here to ensure the button displays one line of text.
296 button.setText("a");
297 return calculateIconSizeFromButtonHeight(button, originalIconSize);
298 }
299
300 // Given a button with text on a single line - we want to add an icon to that button. This
301 // method calculates the icon height to use to avoid making the button grow in height.
302 private static Size calculateIconSizeFromButtonHeight(Button button, Size originalIconSize) {
303 // A completely permissive measure spec should make the button text single-line.
304 button.measure(MEASURE_SPEC_ANY_LENGTH, MEASURE_SPEC_ANY_LENGTH);
305 int buttonHeight = button.getMeasuredHeight();
306 int newIconHeight = buttonHeight / 2;
307 int newIconWidth = (int) (originalIconSize.getWidth()
308 * ((double) newIconHeight) / originalIconSize.getHeight());
309 return new Size(newIconWidth, newIconHeight);
310 }
311
Petr Cermak102431d2018-01-29 10:36:07 +0000312 @Override
313 public LayoutParams generateLayoutParams(AttributeSet attrs) {
314 return new LayoutParams(mContext, attrs);
315 }
316
317 @Override
318 protected LayoutParams generateDefaultLayoutParams() {
319 return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
320 }
321
322 @Override
323 protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams params) {
324 return new LayoutParams(params.width, params.height);
325 }
326
327 @Override
328 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
329 final int targetWidth = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED
330 ? Integer.MAX_VALUE : MeasureSpec.getSize(widthMeasureSpec);
331
332 // Mark all buttons as hidden and un-squeezed.
333 resetButtonsLayoutParams();
334
335 if (!mCandidateButtonQueueForSqueezing.isEmpty()) {
336 Log.wtf(TAG, "Single line button queue leaked between onMeasure calls");
337 mCandidateButtonQueueForSqueezing.clear();
338 }
339
340 int measuredWidth = mPaddingLeft + mPaddingRight;
341 int maxChildHeight = 0;
342 int displayedChildCount = 0;
343 int buttonPaddingHorizontal = mSingleLineButtonPaddingHorizontal;
344
345 final int childCount = getChildCount();
346 for (int i = 0; i < childCount; i++) {
347 final View child = getChildAt(i);
348 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
349 if (child.getVisibility() != View.VISIBLE || !(child instanceof Button)) {
350 continue;
351 }
352
353 child.setPadding(buttonPaddingHorizontal, child.getPaddingTop(),
354 buttonPaddingHorizontal, child.getPaddingBottom());
Gustav Senntoneab53682018-11-01 16:30:23 +0000355 child.measure(MEASURE_SPEC_ANY_LENGTH, heightMeasureSpec);
Petr Cermak102431d2018-01-29 10:36:07 +0000356
357 final int lineCount = ((Button) child).getLineCount();
358 if (lineCount < 1 || lineCount > 2) {
359 // If smart reply has no text, or more than two lines, then don't show it.
360 continue;
361 }
362
363 if (lineCount == 1) {
364 mCandidateButtonQueueForSqueezing.add((Button) child);
365 }
366
367 // Remember the current measurements in case the current button doesn't fit in.
368 final int originalMaxChildHeight = maxChildHeight;
369 final int originalMeasuredWidth = measuredWidth;
370 final int originalButtonPaddingHorizontal = buttonPaddingHorizontal;
371
372 final int spacing = displayedChildCount == 0 ? 0 : mSpacing;
373 final int childWidth = child.getMeasuredWidth();
374 final int childHeight = child.getMeasuredHeight();
375 measuredWidth += spacing + childWidth;
376 maxChildHeight = Math.max(maxChildHeight, childHeight);
377
378 // Do we need to increase the number of lines in smart reply buttons to two?
379 final boolean increaseToTwoLines =
380 buttonPaddingHorizontal == mSingleLineButtonPaddingHorizontal
381 && (lineCount == 2 || measuredWidth > targetWidth);
382 if (increaseToTwoLines) {
383 measuredWidth += (displayedChildCount + 1) * mSingleToDoubleLineButtonWidthIncrease;
384 buttonPaddingHorizontal = mDoubleLineButtonPaddingHorizontal;
385 }
386
387 // If the last button doesn't fit into the remaining width, try squeezing preceding
388 // smart reply buttons.
389 if (measuredWidth > targetWidth) {
390 // Keep squeezing preceding and current smart reply buttons until they all fit.
391 while (measuredWidth > targetWidth
392 && !mCandidateButtonQueueForSqueezing.isEmpty()) {
393 final Button candidate = mCandidateButtonQueueForSqueezing.poll();
394 final int squeezeReduction = squeezeButton(candidate, heightMeasureSpec);
395 if (squeezeReduction != SQUEEZE_FAILED) {
396 maxChildHeight = Math.max(maxChildHeight, candidate.getMeasuredHeight());
397 measuredWidth -= squeezeReduction;
398 }
399 }
400
401 // If the current button still doesn't fit after squeezing all buttons, undo the
402 // last squeezing round.
403 if (measuredWidth > targetWidth) {
404 measuredWidth = originalMeasuredWidth;
405 maxChildHeight = originalMaxChildHeight;
406 buttonPaddingHorizontal = originalButtonPaddingHorizontal;
407
408 // Mark all buttons from the last squeezing round as "failed to squeeze", so
409 // that they're re-measured without squeezing later.
410 markButtonsWithPendingSqueezeStatusAs(LayoutParams.SQUEEZE_STATUS_FAILED, i);
411
412 // The current button doesn't fit, so there's no point in measuring further
413 // buttons.
414 break;
415 }
416
417 // The current button fits, so mark all squeezed buttons as "successfully squeezed"
418 // to prevent them from being un-squeezed in a subsequent squeezing round.
419 markButtonsWithPendingSqueezeStatusAs(LayoutParams.SQUEEZE_STATUS_SUCCESSFUL, i);
420 }
421
422 lp.show = true;
423 displayedChildCount++;
424 }
425
426 // We're done squeezing buttons, so we can clear the priority queue.
427 mCandidateButtonQueueForSqueezing.clear();
428
Milo Sredkova5bacea2018-04-12 12:52:43 +0100429 // Finally, we need to re-measure some buttons.
430 remeasureButtonsIfNecessary(buttonPaddingHorizontal, maxChildHeight);
Petr Cermak102431d2018-01-29 10:36:07 +0000431
432 setMeasuredDimension(
433 resolveSize(Math.max(getSuggestedMinimumWidth(), measuredWidth), widthMeasureSpec),
434 resolveSize(Math.max(getSuggestedMinimumHeight(),
435 mPaddingTop + maxChildHeight + mPaddingBottom), heightMeasureSpec));
436 }
437
438 private void resetButtonsLayoutParams() {
439 final int childCount = getChildCount();
440 for (int i = 0; i < childCount; i++) {
441 final View child = getChildAt(i);
442 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
443 lp.show = false;
444 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_NONE;
445 }
446 }
447
448 private int squeezeButton(Button button, int heightMeasureSpec) {
449 final int estimatedOptimalTextWidth = estimateOptimalSqueezedButtonTextWidth(button);
450 if (estimatedOptimalTextWidth == SQUEEZE_FAILED) {
451 return SQUEEZE_FAILED;
452 }
453 return squeezeButtonToTextWidth(button, heightMeasureSpec, estimatedOptimalTextWidth);
454 }
455
456 private int estimateOptimalSqueezedButtonTextWidth(Button button) {
457 // Find a line-break point in the middle of the smart reply button text.
458 final String rawText = button.getText().toString();
459
460 // The button sometimes has a transformation affecting text layout (e.g. all caps).
461 final TransformationMethod transformation = button.getTransformationMethod();
462 final String text = transformation == null ?
463 rawText : transformation.getTransformation(rawText, button).toString();
464 final int length = text.length();
465 mBreakIterator.setText(text);
466
467 if (mBreakIterator.preceding(length / 2) == BreakIterator.DONE) {
468 if (mBreakIterator.next() == BreakIterator.DONE) {
469 // Can't find a single possible line break in either direction.
470 return SQUEEZE_FAILED;
471 }
472 }
473
474 final TextPaint paint = button.getPaint();
475 final int initialPosition = mBreakIterator.current();
476 final float initialLeftTextWidth = Layout.getDesiredWidth(text, 0, initialPosition, paint);
477 final float initialRightTextWidth =
478 Layout.getDesiredWidth(text, initialPosition, length, paint);
479 float optimalTextWidth = Math.max(initialLeftTextWidth, initialRightTextWidth);
480
481 if (initialLeftTextWidth != initialRightTextWidth) {
482 // See if there's a better line-break point (leading to a more narrow button) in
483 // either left or right direction.
484 final boolean moveLeft = initialLeftTextWidth > initialRightTextWidth;
485 final int maxSqueezeRemeasureAttempts = mConstants.getMaxSqueezeRemeasureAttempts();
486 for (int i = 0; i < maxSqueezeRemeasureAttempts; i++) {
487 final int newPosition =
488 moveLeft ? mBreakIterator.previous() : mBreakIterator.next();
489 if (newPosition == BreakIterator.DONE) {
490 break;
491 }
492
493 final float newLeftTextWidth = Layout.getDesiredWidth(text, 0, newPosition, paint);
494 final float newRightTextWidth =
495 Layout.getDesiredWidth(text, newPosition, length, paint);
496 final float newOptimalTextWidth = Math.max(newLeftTextWidth, newRightTextWidth);
497 if (newOptimalTextWidth < optimalTextWidth) {
498 optimalTextWidth = newOptimalTextWidth;
499 } else {
500 break;
501 }
502
503 boolean tooFar = moveLeft
504 ? newLeftTextWidth <= newRightTextWidth
505 : newLeftTextWidth >= newRightTextWidth;
506 if (tooFar) {
507 break;
508 }
509 }
510 }
511
512 return (int) Math.ceil(optimalTextWidth);
513 }
514
Gustav Senntoneab53682018-11-01 16:30:23 +0000515 /**
516 * Returns the combined width of the left drawable (the action icon) and the padding between the
517 * drawable and the button text.
518 */
519 private int getLeftCompoundDrawableWidthWithPadding(Button button) {
520 Drawable[] drawables = button.getCompoundDrawables();
521 Drawable leftDrawable = drawables[0];
522 if (leftDrawable == null) return 0;
523
524 return leftDrawable.getBounds().width() + button.getCompoundDrawablePadding();
525 }
526
Petr Cermak102431d2018-01-29 10:36:07 +0000527 private int squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth) {
528 int oldWidth = button.getMeasuredWidth();
529 if (button.getPaddingLeft() != mDoubleLineButtonPaddingHorizontal) {
530 // Correct for the fact that the button was laid out with single-line horizontal
531 // padding.
532 oldWidth += mSingleToDoubleLineButtonWidthIncrease;
533 }
534
535 // Re-measure the squeezed smart reply button.
536 button.setPadding(mDoubleLineButtonPaddingHorizontal, button.getPaddingTop(),
537 mDoubleLineButtonPaddingHorizontal, button.getPaddingBottom());
538 final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(
Gustav Senntoneab53682018-11-01 16:30:23 +0000539 2 * mDoubleLineButtonPaddingHorizontal + textWidth
540 + getLeftCompoundDrawableWidthWithPadding(button), MeasureSpec.AT_MOST);
Petr Cermak102431d2018-01-29 10:36:07 +0000541 button.measure(widthMeasureSpec, heightMeasureSpec);
542
543 final int newWidth = button.getMeasuredWidth();
544
545 final LayoutParams lp = (LayoutParams) button.getLayoutParams();
546 if (button.getLineCount() > 2 || newWidth >= oldWidth) {
547 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_FAILED;
548 return SQUEEZE_FAILED;
549 } else {
550 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_PENDING;
551 return oldWidth - newWidth;
552 }
553 }
554
Milo Sredkova5bacea2018-04-12 12:52:43 +0100555 private void remeasureButtonsIfNecessary(
Petr Cermak102431d2018-01-29 10:36:07 +0000556 int buttonPaddingHorizontal, int maxChildHeight) {
Petr Cermak102431d2018-01-29 10:36:07 +0000557 final int maxChildHeightMeasure =
558 MeasureSpec.makeMeasureSpec(maxChildHeight, MeasureSpec.EXACTLY);
559
560 final int childCount = getChildCount();
561 for (int i = 0; i < childCount; i++) {
562 final View child = getChildAt(i);
563 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
564 if (!lp.show) {
565 continue;
566 }
567
Petr Cermak102431d2018-01-29 10:36:07 +0000568 boolean requiresNewMeasure = false;
569 int newWidth = child.getMeasuredWidth();
570
571 // Re-measure reason 1: The button needs to be un-squeezed (either because it resulted
572 // in more than two lines or because it was unnecessary).
573 if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_FAILED) {
574 requiresNewMeasure = true;
575 newWidth = Integer.MAX_VALUE;
576 }
577
578 // Re-measure reason 2: The button's horizontal padding is incorrect (because it was
579 // measured with the wrong number of lines).
580 if (child.getPaddingLeft() != buttonPaddingHorizontal) {
581 requiresNewMeasure = true;
Kenny Guy48ee6d62018-05-09 16:51:26 +0100582 if (newWidth != Integer.MAX_VALUE) {
583 if (buttonPaddingHorizontal == mSingleLineButtonPaddingHorizontal) {
584 // Change padding (2->1 line).
585 newWidth -= mSingleToDoubleLineButtonWidthIncrease;
586 } else {
587 // Change padding (1->2 lines).
588 newWidth += mSingleToDoubleLineButtonWidthIncrease;
589 }
Petr Cermak102431d2018-01-29 10:36:07 +0000590 }
591 child.setPadding(buttonPaddingHorizontal, child.getPaddingTop(),
592 buttonPaddingHorizontal, child.getPaddingBottom());
593 }
594
595 // Re-measure reason 3: The button's height is less than the max height of all buttons
596 // (all should have the same height).
597 if (child.getMeasuredHeight() != maxChildHeight) {
598 requiresNewMeasure = true;
599 }
600
601 if (requiresNewMeasure) {
602 child.measure(MeasureSpec.makeMeasureSpec(newWidth, MeasureSpec.AT_MOST),
603 maxChildHeightMeasure);
604 }
605 }
606 }
607
608 private void markButtonsWithPendingSqueezeStatusAs(int squeezeStatus, int maxChildIndex) {
609 for (int i = 0; i <= maxChildIndex; i++) {
610 final View child = getChildAt(i);
611 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
612 if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_PENDING) {
613 lp.squeezeStatus = squeezeStatus;
614 }
615 }
616 }
617
618 @Override
619 protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
620 final boolean isRtl = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
621
622 final int width = right - left;
623 int position = isRtl ? width - mPaddingRight : mPaddingLeft;
624
625 final int childCount = getChildCount();
626 for (int i = 0; i < childCount; i++) {
627 final View child = getChildAt(i);
628 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
629 if (!lp.show) {
630 continue;
631 }
632
633 final int childWidth = child.getMeasuredWidth();
634 final int childHeight = child.getMeasuredHeight();
635 final int childLeft = isRtl ? position - childWidth : position;
636 child.layout(childLeft, 0, childLeft + childWidth, childHeight);
637
638 final int childWidthWithSpacing = childWidth + mSpacing;
639 if (isRtl) {
640 position -= childWidthWithSpacing;
641 } else {
642 position += childWidthWithSpacing;
643 }
644 }
645 }
646
647 @Override
648 protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
649 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
650 return lp.show && super.drawChild(canvas, child, drawingTime);
651 }
652
Kenny Guy14d035c2018-05-02 19:10:36 +0100653 public void setBackgroundTintColor(int backgroundColor) {
654 if (backgroundColor == mCurrentBackgroundColor) {
655 // Same color ignoring.
656 return;
657 }
658 mCurrentBackgroundColor = backgroundColor;
659
Lucas Dupina291d192018-06-07 13:59:42 -0700660 final boolean dark = !ContrastColorUtil.isColorLight(backgroundColor);
Kenny Guy14d035c2018-05-02 19:10:36 +0100661
Lucas Dupina291d192018-06-07 13:59:42 -0700662 int textColor = ContrastColorUtil.ensureTextContrast(
Kenny Guy14d035c2018-05-02 19:10:36 +0100663 dark ? mDefaultTextColorDarkBg : mDefaultTextColor,
664 backgroundColor | 0xff000000, dark);
Lucas Dupina291d192018-06-07 13:59:42 -0700665 int strokeColor = ContrastColorUtil.ensureContrast(
Kenny Guy14d035c2018-05-02 19:10:36 +0100666 mDefaultStrokeColor, backgroundColor | 0xff000000, dark, mMinStrokeContrast);
667 int rippleColor = dark ? mRippleColorDarkBg : mRippleColor;
668
669 int childCount = getChildCount();
670 for (int i = 0; i < childCount; i++) {
671 final Button child = (Button) getChildAt(i);
672 setColors(child, backgroundColor, strokeColor, textColor, rippleColor);
673 }
674 }
675
676 private void setColors(Button button, int backgroundColor, int strokeColor, int textColor,
677 int rippleColor) {
678 Drawable drawable = button.getBackground();
679 if (drawable instanceof RippleDrawable) {
680 // Mutate in case other notifications are using this drawable.
681 drawable = drawable.mutate();
682 RippleDrawable ripple = (RippleDrawable) drawable;
683 ripple.setColor(ColorStateList.valueOf(rippleColor));
684 Drawable inset = ripple.getDrawable(0);
685 if (inset instanceof InsetDrawable) {
686 Drawable background = ((InsetDrawable) inset).getDrawable();
687 if (background instanceof GradientDrawable) {
688 GradientDrawable gradientDrawable = (GradientDrawable) background;
689 gradientDrawable.setColor(backgroundColor);
690 gradientDrawable.setStroke(mStrokeWidth, strokeColor);
691 }
692 }
693 button.setBackground(drawable);
694 }
695 button.setTextColor(textColor);
696 }
697
Gustav Senntoneab53682018-11-01 16:30:23 +0000698 private ActivityStarter getActivityStarter() {
699 if (mActivityStarter == null) {
700 mActivityStarter = Dependency.get(ActivityStarter.class);
701 }
702 return mActivityStarter;
703 }
704
Petr Cermak102431d2018-01-29 10:36:07 +0000705 @VisibleForTesting
706 static class LayoutParams extends ViewGroup.LayoutParams {
707
708 /** Button is not squeezed. */
709 private static final int SQUEEZE_STATUS_NONE = 0;
710
711 /**
712 * Button was successfully squeezed, but it might be un-squeezed later if the squeezing
713 * turns out to have been unnecessary (because there's still not enough space to add another
714 * button).
715 */
716 private static final int SQUEEZE_STATUS_PENDING = 1;
717
718 /** Button was successfully squeezed and it won't be un-squeezed. */
719 private static final int SQUEEZE_STATUS_SUCCESSFUL = 2;
720
721 /**
722 * Button wasn't successfully squeezed. The squeezing resulted in more than two lines of
723 * text or it didn't reduce the button's width at all. The button will have to be
724 * re-measured to use only one line of text.
725 */
726 private static final int SQUEEZE_STATUS_FAILED = 3;
727
728 private boolean show = false;
729 private int squeezeStatus = SQUEEZE_STATUS_NONE;
730
731 private LayoutParams(Context c, AttributeSet attrs) {
732 super(c, attrs);
733 }
734
735 private LayoutParams(int width, int height) {
736 super(width, height);
737 }
738
739 @VisibleForTesting
740 boolean isShown() {
741 return show;
742 }
743 }
Petr Cermaked7429c2017-12-18 19:38:04 +0000744}