blob: f36066ce37946360aefb9bf8d512f48b9a4344d7 [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;
Gustav Senntoneab53682018-11-01 16:30:23 +000024import android.util.Size;
Petr Cermaked7429c2017-12-18 19:38:04 +000025import android.view.LayoutInflater;
Petr Cermak102431d2018-01-29 10:36:07 +000026import android.view.View;
Petr Cermaked7429c2017-12-18 19:38:04 +000027import android.view.ViewGroup;
Milo Sredkov66da07b2018-04-17 14:04:54 +010028import android.view.accessibility.AccessibilityNodeInfo;
29import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
Petr Cermaked7429c2017-12-18 19:38:04 +000030import android.widget.Button;
Petr Cermaked7429c2017-12-18 19:38:04 +000031
Petr Cermak102431d2018-01-29 10:36:07 +000032import com.android.internal.annotations.VisibleForTesting;
Lucas Dupina291d192018-06-07 13:59:42 -070033import com.android.internal.util.ContrastColorUtil;
Petr Cermak10011fa2018-02-05 19:00:54 +000034import com.android.systemui.Dependency;
Petr Cermaked7429c2017-12-18 19:38:04 +000035import com.android.systemui.R;
Gustav Senntoneab53682018-11-01 16:30:23 +000036import com.android.systemui.plugins.ActivityStarter;
Gus Prevasab336792018-11-14 13:52:20 -050037import com.android.systemui.plugins.ActivityStarter.OnDismissAction;
Kenny Guya0f6de82018-04-06 16:20:16 +010038import com.android.systemui.statusbar.SmartReplyController;
Gus Prevasab336792018-11-14 13:52:20 -050039import com.android.systemui.statusbar.notification.NotificationData;
Milo Sredkove7cf4982018-04-09 15:08:26 +010040import com.android.systemui.statusbar.notification.NotificationUtils;
Milo Sredkovb0f55e92018-04-04 16:13:28 +010041import com.android.systemui.statusbar.phone.KeyguardDismissUtil;
Petr Cermaked7429c2017-12-18 19:38:04 +000042
Petr Cermak102431d2018-01-29 10:36:07 +000043import java.text.BreakIterator;
44import java.util.Comparator;
Gustav Senntoneab53682018-11-01 16:30:23 +000045import java.util.List;
Petr Cermak102431d2018-01-29 10:36:07 +000046import java.util.PriorityQueue;
47
Gustav Senntoneab53682018-11-01 16:30:23 +000048/** View which displays smart reply and smart actions buttons in notifications. */
Petr Cermak102431d2018-01-29 10:36:07 +000049public class SmartReplyView extends ViewGroup {
Petr Cermaked7429c2017-12-18 19:38:04 +000050
51 private static final String TAG = "SmartReplyView";
52
Gustav Senntoneab53682018-11-01 16:30:23 +000053 private static final int MEASURE_SPEC_ANY_LENGTH =
Petr Cermak102431d2018-01-29 10:36:07 +000054 MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
55
56 private static final Comparator<View> DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR =
57 (v1, v2) -> ((v2.getMeasuredWidth() - v2.getPaddingLeft() - v2.getPaddingRight())
58 - (v1.getMeasuredWidth() - v1.getPaddingLeft() - v1.getPaddingRight()));
59
60 private static final int SQUEEZE_FAILED = -1;
61
Petr Cermak10011fa2018-02-05 19:00:54 +000062 private final SmartReplyConstants mConstants;
Milo Sredkovb0f55e92018-04-04 16:13:28 +010063 private final KeyguardDismissUtil mKeyguardDismissUtil;
Petr Cermak10011fa2018-02-05 19:00:54 +000064
Milo Sredkove7cf4982018-04-09 15:08:26 +010065 /**
66 * The upper bound for the height of this view in pixels. Notifications are automatically
67 * recreated on density or font size changes so caching this should be fine.
68 */
69 private final int mHeightUpperLimit;
70
Petr Cermak102431d2018-01-29 10:36:07 +000071 /** Spacing to be applied between views. */
72 private final int mSpacing;
73
74 /** Horizontal padding of smart reply buttons if all of them use only one line of text. */
75 private final int mSingleLineButtonPaddingHorizontal;
76
77 /** Horizontal padding of smart reply buttons if at least one of them uses two lines of text. */
78 private final int mDoubleLineButtonPaddingHorizontal;
79
80 /** Increase in width of a smart reply button as a result of using two lines instead of one. */
81 private final int mSingleToDoubleLineButtonWidthIncrease;
82
83 private final BreakIterator mBreakIterator;
84
85 private PriorityQueue<Button> mCandidateButtonQueueForSqueezing;
86
Kenny Guya0f6de82018-04-06 16:20:16 +010087 private View mSmartReplyContainer;
88
Kenny Guy14d035c2018-05-02 19:10:36 +010089 @ColorInt
90 private int mCurrentBackgroundColor;
91 @ColorInt
92 private final int mDefaultBackgroundColor;
93 @ColorInt
94 private final int mDefaultStrokeColor;
95 @ColorInt
96 private final int mDefaultTextColor;
97 @ColorInt
98 private final int mDefaultTextColorDarkBg;
99 @ColorInt
100 private final int mRippleColorDarkBg;
101 @ColorInt
102 private final int mRippleColor;
103 private final int mStrokeWidth;
104 private final double mMinStrokeContrast;
105
Gustav Senntoneab53682018-11-01 16:30:23 +0000106 private ActivityStarter mActivityStarter;
107
Petr Cermaked7429c2017-12-18 19:38:04 +0000108 public SmartReplyView(Context context, AttributeSet attrs) {
109 super(context, attrs);
Petr Cermak10011fa2018-02-05 19:00:54 +0000110 mConstants = Dependency.get(SmartReplyConstants.class);
Milo Sredkovb0f55e92018-04-04 16:13:28 +0100111 mKeyguardDismissUtil = Dependency.get(KeyguardDismissUtil.class);
Petr Cermak102431d2018-01-29 10:36:07 +0000112
Milo Sredkove7cf4982018-04-09 15:08:26 +0100113 mHeightUpperLimit = NotificationUtils.getFontScaledHeight(mContext,
114 R.dimen.smart_reply_button_max_height);
115
Kenny Guy14d035c2018-05-02 19:10:36 +0100116 mCurrentBackgroundColor = context.getColor(R.color.smart_reply_button_background);
117 mDefaultBackgroundColor = mCurrentBackgroundColor;
118 mDefaultTextColor = mContext.getColor(R.color.smart_reply_button_text);
119 mDefaultTextColorDarkBg = mContext.getColor(R.color.smart_reply_button_text_dark_bg);
120 mDefaultStrokeColor = mContext.getColor(R.color.smart_reply_button_stroke);
121 mRippleColor = mContext.getColor(R.color.notification_ripple_untinted_color);
122 mRippleColorDarkBg = Color.argb(Color.alpha(mRippleColor),
123 255 /* red */, 255 /* green */, 255 /* blue */);
Lucas Dupina291d192018-06-07 13:59:42 -0700124 mMinStrokeContrast = ContrastColorUtil.calculateContrast(mDefaultStrokeColor,
Kenny Guy14d035c2018-05-02 19:10:36 +0100125 mDefaultBackgroundColor);
126
Petr Cermak102431d2018-01-29 10:36:07 +0000127 int spacing = 0;
128 int singleLineButtonPaddingHorizontal = 0;
129 int doubleLineButtonPaddingHorizontal = 0;
Kenny Guy14d035c2018-05-02 19:10:36 +0100130 int strokeWidth = 0;
Petr Cermak102431d2018-01-29 10:36:07 +0000131
132 final TypedArray arr = context.obtainStyledAttributes(attrs, R.styleable.SmartReplyView,
133 0, 0);
134 final int length = arr.getIndexCount();
135 for (int i = 0; i < length; i++) {
136 int attr = arr.getIndex(i);
Jason Monk05dd5672018-08-09 09:38:21 -0400137 if (attr == R.styleable.SmartReplyView_spacing) {
138 spacing = arr.getDimensionPixelSize(i, 0);
139 } else if (attr == R.styleable.SmartReplyView_singleLineButtonPaddingHorizontal) {
140 singleLineButtonPaddingHorizontal = arr.getDimensionPixelSize(i, 0);
141 } else if (attr == R.styleable.SmartReplyView_doubleLineButtonPaddingHorizontal) {
142 doubleLineButtonPaddingHorizontal = arr.getDimensionPixelSize(i, 0);
143 } else if (attr == R.styleable.SmartReplyView_buttonStrokeWidth) {
144 strokeWidth = arr.getDimensionPixelSize(i, 0);
Petr Cermak102431d2018-01-29 10:36:07 +0000145 }
146 }
147 arr.recycle();
148
Kenny Guy14d035c2018-05-02 19:10:36 +0100149 mStrokeWidth = strokeWidth;
Petr Cermak102431d2018-01-29 10:36:07 +0000150 mSpacing = spacing;
151 mSingleLineButtonPaddingHorizontal = singleLineButtonPaddingHorizontal;
152 mDoubleLineButtonPaddingHorizontal = doubleLineButtonPaddingHorizontal;
153 mSingleToDoubleLineButtonWidthIncrease =
154 2 * (doubleLineButtonPaddingHorizontal - singleLineButtonPaddingHorizontal);
155
Milo Sredkove7cf4982018-04-09 15:08:26 +0100156
Petr Cermak102431d2018-01-29 10:36:07 +0000157 mBreakIterator = BreakIterator.getLineInstance();
158 reallocateCandidateButtonQueueForSqueezing();
159 }
160
Milo Sredkove7cf4982018-04-09 15:08:26 +0100161 /**
162 * Returns an upper bound for the height of this view in pixels. This method is intended to be
163 * invoked before onMeasure, so it doesn't do any analysis on the contents of the buttons.
164 */
165 public int getHeightUpperLimit() {
166 return mHeightUpperLimit;
167 }
168
Petr Cermak102431d2018-01-29 10:36:07 +0000169 private void reallocateCandidateButtonQueueForSqueezing() {
170 // Instead of clearing the priority queue, we re-allocate so that it would fit all buttons
171 // exactly. This avoids (1) wasting memory because PriorityQueue never shrinks and
172 // (2) growing in onMeasure.
173 // The constructor throws an IllegalArgument exception if initial capacity is less than 1.
174 mCandidateButtonQueueForSqueezing = new PriorityQueue<>(
175 Math.max(getChildCount(), 1), DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR);
Petr Cermaked7429c2017-12-18 19:38:04 +0000176 }
177
Gustav Senntoneab53682018-11-01 16:30:23 +0000178 /**
179 * Reset the smart suggestions view to allow adding new replies and actions.
180 */
181 public void resetSmartSuggestions(View newSmartReplyContainer) {
182 mSmartReplyContainer = newSmartReplyContainer;
Petr Cermaked7429c2017-12-18 19:38:04 +0000183 removeAllViews();
Kenny Guy14d035c2018-05-02 19:10:36 +0100184 mCurrentBackgroundColor = mDefaultBackgroundColor;
Gustav Senntoneab53682018-11-01 16:30:23 +0000185 }
186
187 /**
188 * Add smart replies to this view, using the provided {@link RemoteInput} and
189 * {@link PendingIntent} to respond when the user taps a smart reply. Only the replies that fit
190 * into the notification are shown.
191 */
192 public void addRepliesFromRemoteInput(
Tony Mak29996702018-11-26 16:23:34 +0000193 SmartReplies smartReplies,
194 SmartReplyController smartReplyController, NotificationData.Entry entry) {
195 if (smartReplies.remoteInput != null && smartReplies.pendingIntent != null) {
196 if (smartReplies.choices != null) {
197 for (int i = 0; i < smartReplies.choices.length; ++i) {
Petr Cermaked7429c2017-12-18 19:38:04 +0000198 Button replyButton = inflateReplyButton(
Tony Mak29996702018-11-26 16:23:34 +0000199 getContext(), this, i, smartReplies, smartReplyController, entry);
Petr Cermaked7429c2017-12-18 19:38:04 +0000200 addView(replyButton);
201 }
202 }
203 }
Petr Cermak102431d2018-01-29 10:36:07 +0000204 reallocateCandidateButtonQueueForSqueezing();
Petr Cermaked7429c2017-12-18 19:38:04 +0000205 }
206
Gustav Senntoneab53682018-11-01 16:30:23 +0000207 /**
208 * Add smart actions to be shown next to smart replies. Only the actions that fit into the
209 * notification are shown.
210 */
Tony Mak7d4b3a52018-11-27 17:29:36 +0000211 public void addSmartActions(SmartActions smartActions,
212 SmartReplyController smartReplyController, NotificationData.Entry entry) {
Tony Mak29996702018-11-26 16:23:34 +0000213 int numSmartActions = smartActions.actions.size();
Gustav Senntoneab53682018-11-01 16:30:23 +0000214 for (int n = 0; n < numSmartActions; n++) {
Tony Mak29996702018-11-26 16:23:34 +0000215 Notification.Action action = smartActions.actions.get(n);
Gustav Senntoneab53682018-11-01 16:30:23 +0000216 if (action.actionIntent != null) {
Tony Mak7d4b3a52018-11-27 17:29:36 +0000217 Button actionButton = inflateActionButton(
218 getContext(), this, n, smartActions, smartReplyController, entry);
Gustav Senntoneab53682018-11-01 16:30:23 +0000219 addView(actionButton);
220 }
221 }
222 reallocateCandidateButtonQueueForSqueezing();
223 }
224
Petr Cermaked7429c2017-12-18 19:38:04 +0000225 public static SmartReplyView inflate(Context context, ViewGroup root) {
226 return (SmartReplyView)
227 LayoutInflater.from(context).inflate(R.layout.smart_reply_view, root, false);
228 }
229
Petr Cermak102431d2018-01-29 10:36:07 +0000230 @VisibleForTesting
Kenny Guy23991102018-04-05 21:18:38 +0100231 Button inflateReplyButton(Context context, ViewGroup root, int replyIndex,
Tony Mak29996702018-11-26 16:23:34 +0000232 SmartReplies smartReplies, SmartReplyController smartReplyController,
233 NotificationData.Entry entry) {
Petr Cermaked7429c2017-12-18 19:38:04 +0000234 Button b = (Button) LayoutInflater.from(context).inflate(
235 R.layout.smart_reply_button, root, false);
Tony Mak29996702018-11-26 16:23:34 +0000236 CharSequence choice = smartReplies.choices[replyIndex];
Petr Cermaked7429c2017-12-18 19:38:04 +0000237 b.setText(choice);
Milo Sredkovb0f55e92018-04-04 16:13:28 +0100238
239 OnDismissAction action = () -> {
Tony Mak29996702018-11-26 16:23:34 +0000240 smartReplyController.smartReplySent(
241 entry, replyIndex, b.getText(), smartReplies.fromAssistant);
Petr Cermaked7429c2017-12-18 19:38:04 +0000242 Bundle results = new Bundle();
Tony Mak29996702018-11-26 16:23:34 +0000243 results.putString(smartReplies.remoteInput.getResultKey(), choice.toString());
Petr Cermaked7429c2017-12-18 19:38:04 +0000244 Intent intent = new Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
Tony Mak29996702018-11-26 16:23:34 +0000245 RemoteInput.addResultsToIntent(new RemoteInput[]{smartReplies.remoteInput}, intent,
246 results);
Petr Cermak9a3380c2018-01-19 15:00:24 +0000247 RemoteInput.setResultsSource(intent, RemoteInput.SOURCE_CHOICE);
Selim Cinekb0dc61b2018-05-22 18:49:36 -0700248 entry.setHasSentReply();
Petr Cermaked7429c2017-12-18 19:38:04 +0000249 try {
Tony Mak29996702018-11-26 16:23:34 +0000250 smartReplies.pendingIntent.send(context, 0, intent);
Petr Cermaked7429c2017-12-18 19:38:04 +0000251 } catch (PendingIntent.CanceledException e) {
252 Log.w(TAG, "Unable to send smart reply", e);
253 }
Kenny Guya0f6de82018-04-06 16:20:16 +0100254 mSmartReplyContainer.setVisibility(View.GONE);
Milo Sredkovb0f55e92018-04-04 16:13:28 +0100255 return false; // do not defer
256 };
257
258 b.setOnClickListener(view -> {
Milo Sredkove433e9b2018-05-01 22:45:38 +0100259 mKeyguardDismissUtil.executeWhenUnlocked(action);
Petr Cermaked7429c2017-12-18 19:38:04 +0000260 });
Milo Sredkov66da07b2018-04-17 14:04:54 +0100261
262 b.setAccessibilityDelegate(new AccessibilityDelegate() {
263 public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
264 super.onInitializeAccessibilityNodeInfo(host, info);
265 String label = getResources().getString(R.string.accessibility_send_smart_reply);
266 info.addAction(new AccessibilityAction(AccessibilityNodeInfo.ACTION_CLICK, label));
267 }
268 });
269
Kenny Guy14d035c2018-05-02 19:10:36 +0100270 setColors(b, mCurrentBackgroundColor, mDefaultStrokeColor, mDefaultTextColor, mRippleColor);
Petr Cermaked7429c2017-12-18 19:38:04 +0000271 return b;
272 }
Petr Cermak102431d2018-01-29 10:36:07 +0000273
Gustav Senntoneab53682018-11-01 16:30:23 +0000274 @VisibleForTesting
Tony Mak7d4b3a52018-11-27 17:29:36 +0000275 Button inflateActionButton(Context context, ViewGroup root, int actionIndex,
276 SmartActions smartActions, SmartReplyController smartReplyController,
277 NotificationData.Entry entry) {
278 Notification.Action action = smartActions.actions.get(actionIndex);
Gustav Senntoneab53682018-11-01 16:30:23 +0000279 Button button = (Button) LayoutInflater.from(context).inflate(
280 R.layout.smart_action_button, root, false);
281 button.setText(action.title);
282
283 Drawable iconDrawable = action.getIcon().loadDrawable(context);
284 // Add the action icon to the Smart Action button.
285 Size newIconSize = calculateIconSizeFromSingleLineButton(context, root,
286 new Size(iconDrawable.getIntrinsicWidth(), iconDrawable.getIntrinsicHeight()));
287 iconDrawable.setBounds(0, 0, newIconSize.getWidth(), newIconSize.getHeight());
288 button.setCompoundDrawables(iconDrawable, null, null, null);
289
290 button.setOnClickListener(view ->
Tony Mak7d4b3a52018-11-27 17:29:36 +0000291 getActivityStarter().startPendingIntentDismissingKeyguard(
292 action.actionIntent,
293 () -> smartReplyController.smartActionClicked(
294 entry, actionIndex, action, smartActions.fromAssistant)));
Gustav Senntoneab53682018-11-01 16:30:23 +0000295
296 // TODO(b/119010281): handle accessibility
297
298 return button;
299 }
300
301 private static Size calculateIconSizeFromSingleLineButton(Context context, ViewGroup root,
302 Size originalIconSize) {
303 Button button = (Button) LayoutInflater.from(context).inflate(
304 R.layout.smart_action_button, root, false);
305 // Add simple text here to ensure the button displays one line of text.
306 button.setText("a");
307 return calculateIconSizeFromButtonHeight(button, originalIconSize);
308 }
309
310 // Given a button with text on a single line - we want to add an icon to that button. This
311 // method calculates the icon height to use to avoid making the button grow in height.
312 private static Size calculateIconSizeFromButtonHeight(Button button, Size originalIconSize) {
313 // A completely permissive measure spec should make the button text single-line.
314 button.measure(MEASURE_SPEC_ANY_LENGTH, MEASURE_SPEC_ANY_LENGTH);
315 int buttonHeight = button.getMeasuredHeight();
316 int newIconHeight = buttonHeight / 2;
317 int newIconWidth = (int) (originalIconSize.getWidth()
318 * ((double) newIconHeight) / originalIconSize.getHeight());
319 return new Size(newIconWidth, newIconHeight);
320 }
321
Petr Cermak102431d2018-01-29 10:36:07 +0000322 @Override
323 public LayoutParams generateLayoutParams(AttributeSet attrs) {
324 return new LayoutParams(mContext, attrs);
325 }
326
327 @Override
328 protected LayoutParams generateDefaultLayoutParams() {
329 return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
330 }
331
332 @Override
333 protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams params) {
334 return new LayoutParams(params.width, params.height);
335 }
336
337 @Override
338 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
339 final int targetWidth = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED
340 ? Integer.MAX_VALUE : MeasureSpec.getSize(widthMeasureSpec);
341
342 // Mark all buttons as hidden and un-squeezed.
343 resetButtonsLayoutParams();
344
345 if (!mCandidateButtonQueueForSqueezing.isEmpty()) {
346 Log.wtf(TAG, "Single line button queue leaked between onMeasure calls");
347 mCandidateButtonQueueForSqueezing.clear();
348 }
349
350 int measuredWidth = mPaddingLeft + mPaddingRight;
351 int maxChildHeight = 0;
352 int displayedChildCount = 0;
353 int buttonPaddingHorizontal = mSingleLineButtonPaddingHorizontal;
354
355 final int childCount = getChildCount();
356 for (int i = 0; i < childCount; i++) {
357 final View child = getChildAt(i);
358 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
359 if (child.getVisibility() != View.VISIBLE || !(child instanceof Button)) {
360 continue;
361 }
362
363 child.setPadding(buttonPaddingHorizontal, child.getPaddingTop(),
364 buttonPaddingHorizontal, child.getPaddingBottom());
Gustav Senntoneab53682018-11-01 16:30:23 +0000365 child.measure(MEASURE_SPEC_ANY_LENGTH, heightMeasureSpec);
Petr Cermak102431d2018-01-29 10:36:07 +0000366
367 final int lineCount = ((Button) child).getLineCount();
368 if (lineCount < 1 || lineCount > 2) {
369 // If smart reply has no text, or more than two lines, then don't show it.
370 continue;
371 }
372
373 if (lineCount == 1) {
374 mCandidateButtonQueueForSqueezing.add((Button) child);
375 }
376
377 // Remember the current measurements in case the current button doesn't fit in.
378 final int originalMaxChildHeight = maxChildHeight;
379 final int originalMeasuredWidth = measuredWidth;
380 final int originalButtonPaddingHorizontal = buttonPaddingHorizontal;
381
382 final int spacing = displayedChildCount == 0 ? 0 : mSpacing;
383 final int childWidth = child.getMeasuredWidth();
384 final int childHeight = child.getMeasuredHeight();
385 measuredWidth += spacing + childWidth;
386 maxChildHeight = Math.max(maxChildHeight, childHeight);
387
388 // Do we need to increase the number of lines in smart reply buttons to two?
389 final boolean increaseToTwoLines =
390 buttonPaddingHorizontal == mSingleLineButtonPaddingHorizontal
391 && (lineCount == 2 || measuredWidth > targetWidth);
392 if (increaseToTwoLines) {
393 measuredWidth += (displayedChildCount + 1) * mSingleToDoubleLineButtonWidthIncrease;
394 buttonPaddingHorizontal = mDoubleLineButtonPaddingHorizontal;
395 }
396
397 // If the last button doesn't fit into the remaining width, try squeezing preceding
398 // smart reply buttons.
399 if (measuredWidth > targetWidth) {
400 // Keep squeezing preceding and current smart reply buttons until they all fit.
401 while (measuredWidth > targetWidth
402 && !mCandidateButtonQueueForSqueezing.isEmpty()) {
403 final Button candidate = mCandidateButtonQueueForSqueezing.poll();
404 final int squeezeReduction = squeezeButton(candidate, heightMeasureSpec);
405 if (squeezeReduction != SQUEEZE_FAILED) {
406 maxChildHeight = Math.max(maxChildHeight, candidate.getMeasuredHeight());
407 measuredWidth -= squeezeReduction;
408 }
409 }
410
411 // If the current button still doesn't fit after squeezing all buttons, undo the
412 // last squeezing round.
413 if (measuredWidth > targetWidth) {
414 measuredWidth = originalMeasuredWidth;
415 maxChildHeight = originalMaxChildHeight;
416 buttonPaddingHorizontal = originalButtonPaddingHorizontal;
417
418 // Mark all buttons from the last squeezing round as "failed to squeeze", so
419 // that they're re-measured without squeezing later.
420 markButtonsWithPendingSqueezeStatusAs(LayoutParams.SQUEEZE_STATUS_FAILED, i);
421
422 // The current button doesn't fit, so there's no point in measuring further
423 // buttons.
424 break;
425 }
426
427 // The current button fits, so mark all squeezed buttons as "successfully squeezed"
428 // to prevent them from being un-squeezed in a subsequent squeezing round.
429 markButtonsWithPendingSqueezeStatusAs(LayoutParams.SQUEEZE_STATUS_SUCCESSFUL, i);
430 }
431
432 lp.show = true;
433 displayedChildCount++;
434 }
435
436 // We're done squeezing buttons, so we can clear the priority queue.
437 mCandidateButtonQueueForSqueezing.clear();
438
Milo Sredkova5bacea2018-04-12 12:52:43 +0100439 // Finally, we need to re-measure some buttons.
440 remeasureButtonsIfNecessary(buttonPaddingHorizontal, maxChildHeight);
Petr Cermak102431d2018-01-29 10:36:07 +0000441
442 setMeasuredDimension(
443 resolveSize(Math.max(getSuggestedMinimumWidth(), measuredWidth), widthMeasureSpec),
444 resolveSize(Math.max(getSuggestedMinimumHeight(),
445 mPaddingTop + maxChildHeight + mPaddingBottom), heightMeasureSpec));
446 }
447
448 private void resetButtonsLayoutParams() {
449 final int childCount = getChildCount();
450 for (int i = 0; i < childCount; i++) {
451 final View child = getChildAt(i);
452 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
453 lp.show = false;
454 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_NONE;
455 }
456 }
457
458 private int squeezeButton(Button button, int heightMeasureSpec) {
459 final int estimatedOptimalTextWidth = estimateOptimalSqueezedButtonTextWidth(button);
460 if (estimatedOptimalTextWidth == SQUEEZE_FAILED) {
461 return SQUEEZE_FAILED;
462 }
463 return squeezeButtonToTextWidth(button, heightMeasureSpec, estimatedOptimalTextWidth);
464 }
465
466 private int estimateOptimalSqueezedButtonTextWidth(Button button) {
467 // Find a line-break point in the middle of the smart reply button text.
468 final String rawText = button.getText().toString();
469
470 // The button sometimes has a transformation affecting text layout (e.g. all caps).
471 final TransformationMethod transformation = button.getTransformationMethod();
472 final String text = transformation == null ?
473 rawText : transformation.getTransformation(rawText, button).toString();
474 final int length = text.length();
475 mBreakIterator.setText(text);
476
477 if (mBreakIterator.preceding(length / 2) == BreakIterator.DONE) {
478 if (mBreakIterator.next() == BreakIterator.DONE) {
479 // Can't find a single possible line break in either direction.
480 return SQUEEZE_FAILED;
481 }
482 }
483
484 final TextPaint paint = button.getPaint();
485 final int initialPosition = mBreakIterator.current();
486 final float initialLeftTextWidth = Layout.getDesiredWidth(text, 0, initialPosition, paint);
487 final float initialRightTextWidth =
488 Layout.getDesiredWidth(text, initialPosition, length, paint);
489 float optimalTextWidth = Math.max(initialLeftTextWidth, initialRightTextWidth);
490
491 if (initialLeftTextWidth != initialRightTextWidth) {
492 // See if there's a better line-break point (leading to a more narrow button) in
493 // either left or right direction.
494 final boolean moveLeft = initialLeftTextWidth > initialRightTextWidth;
495 final int maxSqueezeRemeasureAttempts = mConstants.getMaxSqueezeRemeasureAttempts();
496 for (int i = 0; i < maxSqueezeRemeasureAttempts; i++) {
497 final int newPosition =
498 moveLeft ? mBreakIterator.previous() : mBreakIterator.next();
499 if (newPosition == BreakIterator.DONE) {
500 break;
501 }
502
503 final float newLeftTextWidth = Layout.getDesiredWidth(text, 0, newPosition, paint);
504 final float newRightTextWidth =
505 Layout.getDesiredWidth(text, newPosition, length, paint);
506 final float newOptimalTextWidth = Math.max(newLeftTextWidth, newRightTextWidth);
507 if (newOptimalTextWidth < optimalTextWidth) {
508 optimalTextWidth = newOptimalTextWidth;
509 } else {
510 break;
511 }
512
513 boolean tooFar = moveLeft
514 ? newLeftTextWidth <= newRightTextWidth
515 : newLeftTextWidth >= newRightTextWidth;
516 if (tooFar) {
517 break;
518 }
519 }
520 }
521
522 return (int) Math.ceil(optimalTextWidth);
523 }
524
Gustav Senntoneab53682018-11-01 16:30:23 +0000525 /**
526 * Returns the combined width of the left drawable (the action icon) and the padding between the
527 * drawable and the button text.
528 */
529 private int getLeftCompoundDrawableWidthWithPadding(Button button) {
530 Drawable[] drawables = button.getCompoundDrawables();
531 Drawable leftDrawable = drawables[0];
532 if (leftDrawable == null) return 0;
533
534 return leftDrawable.getBounds().width() + button.getCompoundDrawablePadding();
535 }
536
Petr Cermak102431d2018-01-29 10:36:07 +0000537 private int squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth) {
538 int oldWidth = button.getMeasuredWidth();
539 if (button.getPaddingLeft() != mDoubleLineButtonPaddingHorizontal) {
540 // Correct for the fact that the button was laid out with single-line horizontal
541 // padding.
542 oldWidth += mSingleToDoubleLineButtonWidthIncrease;
543 }
544
545 // Re-measure the squeezed smart reply button.
546 button.setPadding(mDoubleLineButtonPaddingHorizontal, button.getPaddingTop(),
547 mDoubleLineButtonPaddingHorizontal, button.getPaddingBottom());
548 final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(
Gustav Senntoneab53682018-11-01 16:30:23 +0000549 2 * mDoubleLineButtonPaddingHorizontal + textWidth
550 + getLeftCompoundDrawableWidthWithPadding(button), MeasureSpec.AT_MOST);
Petr Cermak102431d2018-01-29 10:36:07 +0000551 button.measure(widthMeasureSpec, heightMeasureSpec);
552
553 final int newWidth = button.getMeasuredWidth();
554
555 final LayoutParams lp = (LayoutParams) button.getLayoutParams();
556 if (button.getLineCount() > 2 || newWidth >= oldWidth) {
557 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_FAILED;
558 return SQUEEZE_FAILED;
559 } else {
560 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_PENDING;
561 return oldWidth - newWidth;
562 }
563 }
564
Milo Sredkova5bacea2018-04-12 12:52:43 +0100565 private void remeasureButtonsIfNecessary(
Petr Cermak102431d2018-01-29 10:36:07 +0000566 int buttonPaddingHorizontal, int maxChildHeight) {
Petr Cermak102431d2018-01-29 10:36:07 +0000567 final int maxChildHeightMeasure =
568 MeasureSpec.makeMeasureSpec(maxChildHeight, MeasureSpec.EXACTLY);
569
570 final int childCount = getChildCount();
571 for (int i = 0; i < childCount; i++) {
572 final View child = getChildAt(i);
573 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
574 if (!lp.show) {
575 continue;
576 }
577
Petr Cermak102431d2018-01-29 10:36:07 +0000578 boolean requiresNewMeasure = false;
579 int newWidth = child.getMeasuredWidth();
580
581 // Re-measure reason 1: The button needs to be un-squeezed (either because it resulted
582 // in more than two lines or because it was unnecessary).
583 if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_FAILED) {
584 requiresNewMeasure = true;
585 newWidth = Integer.MAX_VALUE;
586 }
587
588 // Re-measure reason 2: The button's horizontal padding is incorrect (because it was
589 // measured with the wrong number of lines).
590 if (child.getPaddingLeft() != buttonPaddingHorizontal) {
591 requiresNewMeasure = true;
Kenny Guy48ee6d62018-05-09 16:51:26 +0100592 if (newWidth != Integer.MAX_VALUE) {
593 if (buttonPaddingHorizontal == mSingleLineButtonPaddingHorizontal) {
594 // Change padding (2->1 line).
595 newWidth -= mSingleToDoubleLineButtonWidthIncrease;
596 } else {
597 // Change padding (1->2 lines).
598 newWidth += mSingleToDoubleLineButtonWidthIncrease;
599 }
Petr Cermak102431d2018-01-29 10:36:07 +0000600 }
601 child.setPadding(buttonPaddingHorizontal, child.getPaddingTop(),
602 buttonPaddingHorizontal, child.getPaddingBottom());
603 }
604
605 // Re-measure reason 3: The button's height is less than the max height of all buttons
606 // (all should have the same height).
607 if (child.getMeasuredHeight() != maxChildHeight) {
608 requiresNewMeasure = true;
609 }
610
611 if (requiresNewMeasure) {
612 child.measure(MeasureSpec.makeMeasureSpec(newWidth, MeasureSpec.AT_MOST),
613 maxChildHeightMeasure);
614 }
615 }
616 }
617
618 private void markButtonsWithPendingSqueezeStatusAs(int squeezeStatus, int maxChildIndex) {
619 for (int i = 0; i <= maxChildIndex; i++) {
620 final View child = getChildAt(i);
621 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
622 if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_PENDING) {
623 lp.squeezeStatus = squeezeStatus;
624 }
625 }
626 }
627
628 @Override
629 protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
630 final boolean isRtl = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
631
632 final int width = right - left;
633 int position = isRtl ? width - mPaddingRight : mPaddingLeft;
634
635 final int childCount = getChildCount();
636 for (int i = 0; i < childCount; i++) {
637 final View child = getChildAt(i);
638 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
639 if (!lp.show) {
640 continue;
641 }
642
643 final int childWidth = child.getMeasuredWidth();
644 final int childHeight = child.getMeasuredHeight();
645 final int childLeft = isRtl ? position - childWidth : position;
646 child.layout(childLeft, 0, childLeft + childWidth, childHeight);
647
648 final int childWidthWithSpacing = childWidth + mSpacing;
649 if (isRtl) {
650 position -= childWidthWithSpacing;
651 } else {
652 position += childWidthWithSpacing;
653 }
654 }
655 }
656
657 @Override
658 protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
659 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
660 return lp.show && super.drawChild(canvas, child, drawingTime);
661 }
662
Kenny Guy14d035c2018-05-02 19:10:36 +0100663 public void setBackgroundTintColor(int backgroundColor) {
664 if (backgroundColor == mCurrentBackgroundColor) {
665 // Same color ignoring.
666 return;
667 }
668 mCurrentBackgroundColor = backgroundColor;
669
Lucas Dupina291d192018-06-07 13:59:42 -0700670 final boolean dark = !ContrastColorUtil.isColorLight(backgroundColor);
Kenny Guy14d035c2018-05-02 19:10:36 +0100671
Lucas Dupina291d192018-06-07 13:59:42 -0700672 int textColor = ContrastColorUtil.ensureTextContrast(
Kenny Guy14d035c2018-05-02 19:10:36 +0100673 dark ? mDefaultTextColorDarkBg : mDefaultTextColor,
674 backgroundColor | 0xff000000, dark);
Lucas Dupina291d192018-06-07 13:59:42 -0700675 int strokeColor = ContrastColorUtil.ensureContrast(
Kenny Guy14d035c2018-05-02 19:10:36 +0100676 mDefaultStrokeColor, backgroundColor | 0xff000000, dark, mMinStrokeContrast);
677 int rippleColor = dark ? mRippleColorDarkBg : mRippleColor;
678
679 int childCount = getChildCount();
680 for (int i = 0; i < childCount; i++) {
681 final Button child = (Button) getChildAt(i);
682 setColors(child, backgroundColor, strokeColor, textColor, rippleColor);
683 }
684 }
685
686 private void setColors(Button button, int backgroundColor, int strokeColor, int textColor,
687 int rippleColor) {
688 Drawable drawable = button.getBackground();
689 if (drawable instanceof RippleDrawable) {
690 // Mutate in case other notifications are using this drawable.
691 drawable = drawable.mutate();
692 RippleDrawable ripple = (RippleDrawable) drawable;
693 ripple.setColor(ColorStateList.valueOf(rippleColor));
694 Drawable inset = ripple.getDrawable(0);
695 if (inset instanceof InsetDrawable) {
696 Drawable background = ((InsetDrawable) inset).getDrawable();
697 if (background instanceof GradientDrawable) {
698 GradientDrawable gradientDrawable = (GradientDrawable) background;
699 gradientDrawable.setColor(backgroundColor);
700 gradientDrawable.setStroke(mStrokeWidth, strokeColor);
701 }
702 }
703 button.setBackground(drawable);
704 }
705 button.setTextColor(textColor);
706 }
707
Gustav Senntoneab53682018-11-01 16:30:23 +0000708 private ActivityStarter getActivityStarter() {
709 if (mActivityStarter == null) {
710 mActivityStarter = Dependency.get(ActivityStarter.class);
711 }
712 return mActivityStarter;
713 }
714
Petr Cermak102431d2018-01-29 10:36:07 +0000715 @VisibleForTesting
716 static class LayoutParams extends ViewGroup.LayoutParams {
717
718 /** Button is not squeezed. */
719 private static final int SQUEEZE_STATUS_NONE = 0;
720
721 /**
722 * Button was successfully squeezed, but it might be un-squeezed later if the squeezing
723 * turns out to have been unnecessary (because there's still not enough space to add another
724 * button).
725 */
726 private static final int SQUEEZE_STATUS_PENDING = 1;
727
728 /** Button was successfully squeezed and it won't be un-squeezed. */
729 private static final int SQUEEZE_STATUS_SUCCESSFUL = 2;
730
731 /**
732 * Button wasn't successfully squeezed. The squeezing resulted in more than two lines of
733 * text or it didn't reduce the button's width at all. The button will have to be
734 * re-measured to use only one line of text.
735 */
736 private static final int SQUEEZE_STATUS_FAILED = 3;
737
738 private boolean show = false;
739 private int squeezeStatus = SQUEEZE_STATUS_NONE;
740
741 private LayoutParams(Context c, AttributeSet attrs) {
742 super(c, attrs);
743 }
744
745 private LayoutParams(int width, int height) {
746 super(width, height);
747 }
748
749 @VisibleForTesting
750 boolean isShown() {
751 return show;
752 }
753 }
Tony Mak29996702018-11-26 16:23:34 +0000754
755 /**
756 * Data class for smart replies.
757 */
758 public static class SmartReplies {
759 @NonNull
760 public final RemoteInput remoteInput;
761 @NonNull
762 public final PendingIntent pendingIntent;
763 @NonNull
764 public final CharSequence[] choices;
765 public final boolean fromAssistant;
766
767 public SmartReplies(CharSequence[] choices, RemoteInput remoteInput,
768 PendingIntent pendingIntent, boolean fromAssistant) {
769 this.choices = choices;
770 this.remoteInput = remoteInput;
771 this.pendingIntent = pendingIntent;
772 this.fromAssistant = fromAssistant;
773 }
774 }
775
776
777 /**
778 * Data class for smart actions.
779 */
780 public static class SmartActions {
781 @NonNull
782 public final List<Notification.Action> actions;
783 public final boolean fromAssistant;
784
785 public SmartActions(List<Notification.Action> actions, boolean fromAssistant) {
786 this.actions = actions;
787 this.fromAssistant = fromAssistant;
788 }
789 }
Petr Cermaked7429c2017-12-18 19:38:04 +0000790}