blob: 68b172d397c45132acf307a85f167074d906ad3b [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;
Milo Sredkovb2af7f92018-12-04 15:22:18 +000037import com.android.systemui.statusbar.NotificationRemoteInputManager;
Kenny Guya0f6de82018-04-06 16:20:16 +010038import com.android.systemui.statusbar.SmartReplyController;
Milo Sredkove7cf4982018-04-09 15:08:26 +010039import com.android.systemui.statusbar.notification.NotificationUtils;
Ned Burnsf81c4c42019-01-07 14:10:43 -050040import com.android.systemui.statusbar.notification.collection.NotificationEntry;
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;
Gustav Senntonb149a1a2018-11-20 17:25:50 +000044import java.util.ArrayList;
Petr Cermak102431d2018-01-29 10:36:07 +000045import java.util.Comparator;
Gustav Senntoneab53682018-11-01 16:30:23 +000046import java.util.List;
Petr Cermak102431d2018-01-29 10:36:07 +000047import java.util.PriorityQueue;
48
Gustav Senntoneab53682018-11-01 16:30:23 +000049/** View which displays smart reply and smart actions buttons in notifications. */
Petr Cermak102431d2018-01-29 10:36:07 +000050public class SmartReplyView extends ViewGroup {
Petr Cermaked7429c2017-12-18 19:38:04 +000051
52 private static final String TAG = "SmartReplyView";
53
Gustav Senntoneab53682018-11-01 16:30:23 +000054 private static final int MEASURE_SPEC_ANY_LENGTH =
Petr Cermak102431d2018-01-29 10:36:07 +000055 MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
56
57 private static final Comparator<View> DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR =
58 (v1, v2) -> ((v2.getMeasuredWidth() - v2.getPaddingLeft() - v2.getPaddingRight())
59 - (v1.getMeasuredWidth() - v1.getPaddingLeft() - v1.getPaddingRight()));
60
61 private static final int SQUEEZE_FAILED = -1;
62
Petr Cermak10011fa2018-02-05 19:00:54 +000063 private final SmartReplyConstants mConstants;
Milo Sredkovb0f55e92018-04-04 16:13:28 +010064 private final KeyguardDismissUtil mKeyguardDismissUtil;
Milo Sredkovb2af7f92018-12-04 15:22:18 +000065 private final NotificationRemoteInputManager mRemoteInputManager;
Petr Cermak10011fa2018-02-05 19:00:54 +000066
Milo Sredkove7cf4982018-04-09 15:08:26 +010067 /**
68 * The upper bound for the height of this view in pixels. Notifications are automatically
69 * recreated on density or font size changes so caching this should be fine.
70 */
71 private final int mHeightUpperLimit;
72
Petr Cermak102431d2018-01-29 10:36:07 +000073 /** Spacing to be applied between views. */
74 private final int mSpacing;
75
76 /** Horizontal padding of smart reply buttons if all of them use only one line of text. */
77 private final int mSingleLineButtonPaddingHorizontal;
78
79 /** Horizontal padding of smart reply buttons if at least one of them uses two lines of text. */
80 private final int mDoubleLineButtonPaddingHorizontal;
81
82 /** Increase in width of a smart reply button as a result of using two lines instead of one. */
83 private final int mSingleToDoubleLineButtonWidthIncrease;
84
85 private final BreakIterator mBreakIterator;
86
87 private PriorityQueue<Button> mCandidateButtonQueueForSqueezing;
88
Kenny Guya0f6de82018-04-06 16:20:16 +010089 private View mSmartReplyContainer;
90
Kenny Guy14d035c2018-05-02 19:10:36 +010091 @ColorInt
92 private int mCurrentBackgroundColor;
93 @ColorInt
94 private final int mDefaultBackgroundColor;
95 @ColorInt
96 private final int mDefaultStrokeColor;
97 @ColorInt
98 private final int mDefaultTextColor;
99 @ColorInt
100 private final int mDefaultTextColorDarkBg;
101 @ColorInt
102 private final int mRippleColorDarkBg;
103 @ColorInt
104 private final int mRippleColor;
105 private final int mStrokeWidth;
106 private final double mMinStrokeContrast;
107
Gustav Senntoneab53682018-11-01 16:30:23 +0000108 private ActivityStarter mActivityStarter;
109
Petr Cermaked7429c2017-12-18 19:38:04 +0000110 public SmartReplyView(Context context, AttributeSet attrs) {
111 super(context, attrs);
Petr Cermak10011fa2018-02-05 19:00:54 +0000112 mConstants = Dependency.get(SmartReplyConstants.class);
Milo Sredkovb0f55e92018-04-04 16:13:28 +0100113 mKeyguardDismissUtil = Dependency.get(KeyguardDismissUtil.class);
Milo Sredkovb2af7f92018-12-04 15:22:18 +0000114 mRemoteInputManager = Dependency.get(NotificationRemoteInputManager.class);
Petr Cermak102431d2018-01-29 10:36:07 +0000115
Milo Sredkove7cf4982018-04-09 15:08:26 +0100116 mHeightUpperLimit = NotificationUtils.getFontScaledHeight(mContext,
117 R.dimen.smart_reply_button_max_height);
118
Kenny Guy14d035c2018-05-02 19:10:36 +0100119 mCurrentBackgroundColor = context.getColor(R.color.smart_reply_button_background);
120 mDefaultBackgroundColor = mCurrentBackgroundColor;
121 mDefaultTextColor = mContext.getColor(R.color.smart_reply_button_text);
122 mDefaultTextColorDarkBg = mContext.getColor(R.color.smart_reply_button_text_dark_bg);
123 mDefaultStrokeColor = mContext.getColor(R.color.smart_reply_button_stroke);
124 mRippleColor = mContext.getColor(R.color.notification_ripple_untinted_color);
125 mRippleColorDarkBg = Color.argb(Color.alpha(mRippleColor),
126 255 /* red */, 255 /* green */, 255 /* blue */);
Lucas Dupina291d192018-06-07 13:59:42 -0700127 mMinStrokeContrast = ContrastColorUtil.calculateContrast(mDefaultStrokeColor,
Kenny Guy14d035c2018-05-02 19:10:36 +0100128 mDefaultBackgroundColor);
129
Petr Cermak102431d2018-01-29 10:36:07 +0000130 int spacing = 0;
131 int singleLineButtonPaddingHorizontal = 0;
132 int doubleLineButtonPaddingHorizontal = 0;
Kenny Guy14d035c2018-05-02 19:10:36 +0100133 int strokeWidth = 0;
Petr Cermak102431d2018-01-29 10:36:07 +0000134
135 final TypedArray arr = context.obtainStyledAttributes(attrs, R.styleable.SmartReplyView,
136 0, 0);
137 final int length = arr.getIndexCount();
138 for (int i = 0; i < length; i++) {
139 int attr = arr.getIndex(i);
Jason Monk05dd5672018-08-09 09:38:21 -0400140 if (attr == R.styleable.SmartReplyView_spacing) {
141 spacing = arr.getDimensionPixelSize(i, 0);
142 } else if (attr == R.styleable.SmartReplyView_singleLineButtonPaddingHorizontal) {
143 singleLineButtonPaddingHorizontal = arr.getDimensionPixelSize(i, 0);
144 } else if (attr == R.styleable.SmartReplyView_doubleLineButtonPaddingHorizontal) {
145 doubleLineButtonPaddingHorizontal = arr.getDimensionPixelSize(i, 0);
146 } else if (attr == R.styleable.SmartReplyView_buttonStrokeWidth) {
147 strokeWidth = arr.getDimensionPixelSize(i, 0);
Petr Cermak102431d2018-01-29 10:36:07 +0000148 }
149 }
150 arr.recycle();
151
Kenny Guy14d035c2018-05-02 19:10:36 +0100152 mStrokeWidth = strokeWidth;
Petr Cermak102431d2018-01-29 10:36:07 +0000153 mSpacing = spacing;
154 mSingleLineButtonPaddingHorizontal = singleLineButtonPaddingHorizontal;
155 mDoubleLineButtonPaddingHorizontal = doubleLineButtonPaddingHorizontal;
156 mSingleToDoubleLineButtonWidthIncrease =
157 2 * (doubleLineButtonPaddingHorizontal - singleLineButtonPaddingHorizontal);
158
Milo Sredkove7cf4982018-04-09 15:08:26 +0100159
Petr Cermak102431d2018-01-29 10:36:07 +0000160 mBreakIterator = BreakIterator.getLineInstance();
161 reallocateCandidateButtonQueueForSqueezing();
162 }
163
Milo Sredkove7cf4982018-04-09 15:08:26 +0100164 /**
165 * Returns an upper bound for the height of this view in pixels. This method is intended to be
166 * invoked before onMeasure, so it doesn't do any analysis on the contents of the buttons.
167 */
168 public int getHeightUpperLimit() {
169 return mHeightUpperLimit;
170 }
171
Petr Cermak102431d2018-01-29 10:36:07 +0000172 private void reallocateCandidateButtonQueueForSqueezing() {
173 // Instead of clearing the priority queue, we re-allocate so that it would fit all buttons
174 // exactly. This avoids (1) wasting memory because PriorityQueue never shrinks and
175 // (2) growing in onMeasure.
176 // The constructor throws an IllegalArgument exception if initial capacity is less than 1.
177 mCandidateButtonQueueForSqueezing = new PriorityQueue<>(
178 Math.max(getChildCount(), 1), DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR);
Petr Cermaked7429c2017-12-18 19:38:04 +0000179 }
180
Gustav Senntoneab53682018-11-01 16:30:23 +0000181 /**
182 * Reset the smart suggestions view to allow adding new replies and actions.
183 */
184 public void resetSmartSuggestions(View newSmartReplyContainer) {
185 mSmartReplyContainer = newSmartReplyContainer;
Petr Cermaked7429c2017-12-18 19:38:04 +0000186 removeAllViews();
Kenny Guy14d035c2018-05-02 19:10:36 +0100187 mCurrentBackgroundColor = mDefaultBackgroundColor;
Gustav Senntoneab53682018-11-01 16:30:23 +0000188 }
189
190 /**
191 * Add smart replies to this view, using the provided {@link RemoteInput} and
192 * {@link PendingIntent} to respond when the user taps a smart reply. Only the replies that fit
193 * into the notification are shown.
194 */
195 public void addRepliesFromRemoteInput(
Tony Mak29996702018-11-26 16:23:34 +0000196 SmartReplies smartReplies,
Ned Burnsf81c4c42019-01-07 14:10:43 -0500197 SmartReplyController smartReplyController, NotificationEntry entry) {
Tony Mak29996702018-11-26 16:23:34 +0000198 if (smartReplies.remoteInput != null && smartReplies.pendingIntent != null) {
199 if (smartReplies.choices != null) {
200 for (int i = 0; i < smartReplies.choices.length; ++i) {
Petr Cermaked7429c2017-12-18 19:38:04 +0000201 Button replyButton = inflateReplyButton(
Tony Mak29996702018-11-26 16:23:34 +0000202 getContext(), this, i, smartReplies, smartReplyController, entry);
Petr Cermaked7429c2017-12-18 19:38:04 +0000203 addView(replyButton);
204 }
205 }
206 }
Petr Cermak102431d2018-01-29 10:36:07 +0000207 reallocateCandidateButtonQueueForSqueezing();
Petr Cermaked7429c2017-12-18 19:38:04 +0000208 }
209
Gustav Senntoneab53682018-11-01 16:30:23 +0000210 /**
211 * Add smart actions to be shown next to smart replies. Only the actions that fit into the
212 * notification are shown.
213 */
Tony Mak7d4b3a52018-11-27 17:29:36 +0000214 public void addSmartActions(SmartActions smartActions,
Ned Burnsf81c4c42019-01-07 14:10:43 -0500215 SmartReplyController smartReplyController, NotificationEntry entry,
Gustav Senntond0e84532018-12-03 16:48:36 +0000216 HeadsUpManager headsUpManager) {
Tony Mak29996702018-11-26 16:23:34 +0000217 int numSmartActions = smartActions.actions.size();
Gustav Senntoneab53682018-11-01 16:30:23 +0000218 for (int n = 0; n < numSmartActions; n++) {
Tony Mak29996702018-11-26 16:23:34 +0000219 Notification.Action action = smartActions.actions.get(n);
Gustav Senntoneab53682018-11-01 16:30:23 +0000220 if (action.actionIntent != null) {
Tony Mak7d4b3a52018-11-27 17:29:36 +0000221 Button actionButton = inflateActionButton(
Gustav Senntond0e84532018-12-03 16:48:36 +0000222 getContext(), this, n, smartActions, smartReplyController, entry,
223 headsUpManager);
Gustav Senntoneab53682018-11-01 16:30:23 +0000224 addView(actionButton);
225 }
226 }
227 reallocateCandidateButtonQueueForSqueezing();
228 }
229
Petr Cermaked7429c2017-12-18 19:38:04 +0000230 public static SmartReplyView inflate(Context context, ViewGroup root) {
231 return (SmartReplyView)
232 LayoutInflater.from(context).inflate(R.layout.smart_reply_view, root, false);
233 }
234
Petr Cermak102431d2018-01-29 10:36:07 +0000235 @VisibleForTesting
Kenny Guy23991102018-04-05 21:18:38 +0100236 Button inflateReplyButton(Context context, ViewGroup root, int replyIndex,
Tony Mak29996702018-11-26 16:23:34 +0000237 SmartReplies smartReplies, SmartReplyController smartReplyController,
Ned Burnsf81c4c42019-01-07 14:10:43 -0500238 NotificationEntry entry) {
Petr Cermaked7429c2017-12-18 19:38:04 +0000239 Button b = (Button) LayoutInflater.from(context).inflate(
240 R.layout.smart_reply_button, root, false);
Tony Mak29996702018-11-26 16:23:34 +0000241 CharSequence choice = smartReplies.choices[replyIndex];
Petr Cermaked7429c2017-12-18 19:38:04 +0000242 b.setText(choice);
Milo Sredkovb0f55e92018-04-04 16:13:28 +0100243
244 OnDismissAction action = () -> {
Milo Sredkovb2af7f92018-12-04 15:22:18 +0000245 // TODO(b/111437455): Also for EDIT_CHOICES_BEFORE_SENDING_AUTO, depending on flags.
246 if (smartReplies.remoteInput.getEditChoicesBeforeSending()
247 == RemoteInput.EDIT_CHOICES_BEFORE_SENDING_ENABLED) {
248 entry.remoteInputText = choice;
249 mRemoteInputManager.activateRemoteInput(b,
250 new RemoteInput[] { smartReplies.remoteInput }, smartReplies.remoteInput,
251 smartReplies.pendingIntent);
252 return false;
253 }
254
Tony Mak29996702018-11-26 16:23:34 +0000255 smartReplyController.smartReplySent(
256 entry, replyIndex, b.getText(), smartReplies.fromAssistant);
Petr Cermaked7429c2017-12-18 19:38:04 +0000257 Bundle results = new Bundle();
Tony Mak29996702018-11-26 16:23:34 +0000258 results.putString(smartReplies.remoteInput.getResultKey(), choice.toString());
Petr Cermaked7429c2017-12-18 19:38:04 +0000259 Intent intent = new Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
Milo Sredkovb2af7f92018-12-04 15:22:18 +0000260 RemoteInput.addResultsToIntent(new RemoteInput[] { smartReplies.remoteInput }, intent,
Tony Mak29996702018-11-26 16:23:34 +0000261 results);
Petr Cermak9a3380c2018-01-19 15:00:24 +0000262 RemoteInput.setResultsSource(intent, RemoteInput.SOURCE_CHOICE);
Selim Cinekb0dc61b2018-05-22 18:49:36 -0700263 entry.setHasSentReply();
Petr Cermaked7429c2017-12-18 19:38:04 +0000264 try {
Tony Mak29996702018-11-26 16:23:34 +0000265 smartReplies.pendingIntent.send(context, 0, intent);
Petr Cermaked7429c2017-12-18 19:38:04 +0000266 } catch (PendingIntent.CanceledException e) {
267 Log.w(TAG, "Unable to send smart reply", e);
268 }
Kenny Guya0f6de82018-04-06 16:20:16 +0100269 mSmartReplyContainer.setVisibility(View.GONE);
Milo Sredkovb0f55e92018-04-04 16:13:28 +0100270 return false; // do not defer
271 };
272
273 b.setOnClickListener(view -> {
Milo Sredkove433e9b2018-05-01 22:45:38 +0100274 mKeyguardDismissUtil.executeWhenUnlocked(action);
Petr Cermaked7429c2017-12-18 19:38:04 +0000275 });
Milo Sredkov66da07b2018-04-17 14:04:54 +0100276
277 b.setAccessibilityDelegate(new AccessibilityDelegate() {
278 public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
279 super.onInitializeAccessibilityNodeInfo(host, info);
280 String label = getResources().getString(R.string.accessibility_send_smart_reply);
281 info.addAction(new AccessibilityAction(AccessibilityNodeInfo.ACTION_CLICK, label));
282 }
283 });
284
Kenny Guy14d035c2018-05-02 19:10:36 +0100285 setColors(b, mCurrentBackgroundColor, mDefaultStrokeColor, mDefaultTextColor, mRippleColor);
Petr Cermaked7429c2017-12-18 19:38:04 +0000286 return b;
287 }
Petr Cermak102431d2018-01-29 10:36:07 +0000288
Gustav Senntoneab53682018-11-01 16:30:23 +0000289 @VisibleForTesting
Tony Mak7d4b3a52018-11-27 17:29:36 +0000290 Button inflateActionButton(Context context, ViewGroup root, int actionIndex,
291 SmartActions smartActions, SmartReplyController smartReplyController,
Ned Burnsf81c4c42019-01-07 14:10:43 -0500292 NotificationEntry entry, HeadsUpManager headsUpManager) {
Tony Mak7d4b3a52018-11-27 17:29:36 +0000293 Notification.Action action = smartActions.actions.get(actionIndex);
Gustav Senntoneab53682018-11-01 16:30:23 +0000294 Button button = (Button) LayoutInflater.from(context).inflate(
295 R.layout.smart_action_button, root, false);
296 button.setText(action.title);
297
298 Drawable iconDrawable = action.getIcon().loadDrawable(context);
299 // Add the action icon to the Smart Action button.
Gustav Senntonc726f472018-12-04 11:57:06 +0000300 int newIconSize = context.getResources().getDimensionPixelSize(
301 R.dimen.smart_action_button_icon_size);
302 iconDrawable.setBounds(0, 0, newIconSize, newIconSize);
Gustav Senntoneab53682018-11-01 16:30:23 +0000303 button.setCompoundDrawables(iconDrawable, null, null, null);
304
305 button.setOnClickListener(view ->
Tony Mak7d4b3a52018-11-27 17:29:36 +0000306 getActivityStarter().startPendingIntentDismissingKeyguard(
307 action.actionIntent,
Gustav Senntond0e84532018-12-03 16:48:36 +0000308 () -> {
309 smartReplyController.smartActionClicked(
310 entry, actionIndex, action, smartActions.fromAssistant);
Gustav Sennton32137e42018-12-17 12:26:46 +0000311 headsUpManager.removeNotification(entry.key, true);
Gustav Senntond0e84532018-12-03 16:48:36 +0000312 }));
Gustav Senntoneab53682018-11-01 16:30:23 +0000313
314 // TODO(b/119010281): handle accessibility
315
Gustav Senntonb149a1a2018-11-20 17:25:50 +0000316 // Mark this as an Action button
317 final LayoutParams lp = (LayoutParams) button.getLayoutParams();
318 lp.buttonType = SmartButtonType.ACTION;
Gustav Senntoneab53682018-11-01 16:30:23 +0000319 return button;
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
Gustav Senntonb149a1a2018-11-20 17:25:50 +0000355 // Set up a list of suggestions where actions come before replies. Note that the Buttons
356 // themselves have already been added to the view hierarchy in an order such that Smart
357 // Replies are shown before Smart Actions. The order of the list below determines which
358 // suggestions will be shown at all - only the first X elements are shown (where X depends
359 // on how much space each suggestion button needs).
360 List<View> smartActions = filterActionsOrReplies(SmartButtonType.ACTION);
361 List<View> smartReplies = filterActionsOrReplies(SmartButtonType.REPLY);
362 List<View> smartSuggestions = new ArrayList<>(smartActions);
363 smartSuggestions.addAll(smartReplies);
364 List<View> coveredSuggestions = new ArrayList<>();
365
366 for (View child : smartSuggestions) {
Petr Cermak102431d2018-01-29 10:36:07 +0000367 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
Petr Cermak102431d2018-01-29 10:36:07 +0000368
369 child.setPadding(buttonPaddingHorizontal, child.getPaddingTop(),
370 buttonPaddingHorizontal, child.getPaddingBottom());
Gustav Senntoneab53682018-11-01 16:30:23 +0000371 child.measure(MEASURE_SPEC_ANY_LENGTH, heightMeasureSpec);
Petr Cermak102431d2018-01-29 10:36:07 +0000372
Gustav Senntonb149a1a2018-11-20 17:25:50 +0000373 coveredSuggestions.add(child);
374
Petr Cermak102431d2018-01-29 10:36:07 +0000375 final int lineCount = ((Button) child).getLineCount();
376 if (lineCount < 1 || lineCount > 2) {
377 // If smart reply has no text, or more than two lines, then don't show it.
378 continue;
379 }
380
381 if (lineCount == 1) {
382 mCandidateButtonQueueForSqueezing.add((Button) child);
383 }
384
385 // Remember the current measurements in case the current button doesn't fit in.
386 final int originalMaxChildHeight = maxChildHeight;
387 final int originalMeasuredWidth = measuredWidth;
388 final int originalButtonPaddingHorizontal = buttonPaddingHorizontal;
389
390 final int spacing = displayedChildCount == 0 ? 0 : mSpacing;
391 final int childWidth = child.getMeasuredWidth();
392 final int childHeight = child.getMeasuredHeight();
393 measuredWidth += spacing + childWidth;
394 maxChildHeight = Math.max(maxChildHeight, childHeight);
395
396 // Do we need to increase the number of lines in smart reply buttons to two?
397 final boolean increaseToTwoLines =
398 buttonPaddingHorizontal == mSingleLineButtonPaddingHorizontal
399 && (lineCount == 2 || measuredWidth > targetWidth);
400 if (increaseToTwoLines) {
401 measuredWidth += (displayedChildCount + 1) * mSingleToDoubleLineButtonWidthIncrease;
402 buttonPaddingHorizontal = mDoubleLineButtonPaddingHorizontal;
403 }
404
405 // If the last button doesn't fit into the remaining width, try squeezing preceding
406 // smart reply buttons.
407 if (measuredWidth > targetWidth) {
408 // Keep squeezing preceding and current smart reply buttons until they all fit.
409 while (measuredWidth > targetWidth
410 && !mCandidateButtonQueueForSqueezing.isEmpty()) {
411 final Button candidate = mCandidateButtonQueueForSqueezing.poll();
412 final int squeezeReduction = squeezeButton(candidate, heightMeasureSpec);
413 if (squeezeReduction != SQUEEZE_FAILED) {
414 maxChildHeight = Math.max(maxChildHeight, candidate.getMeasuredHeight());
415 measuredWidth -= squeezeReduction;
416 }
417 }
418
419 // If the current button still doesn't fit after squeezing all buttons, undo the
420 // last squeezing round.
421 if (measuredWidth > targetWidth) {
422 measuredWidth = originalMeasuredWidth;
423 maxChildHeight = originalMaxChildHeight;
424 buttonPaddingHorizontal = originalButtonPaddingHorizontal;
425
426 // Mark all buttons from the last squeezing round as "failed to squeeze", so
427 // that they're re-measured without squeezing later.
Gustav Senntonb149a1a2018-11-20 17:25:50 +0000428 markButtonsWithPendingSqueezeStatusAs(
429 LayoutParams.SQUEEZE_STATUS_FAILED, coveredSuggestions);
Petr Cermak102431d2018-01-29 10:36:07 +0000430
Gustav Sennton35156752018-12-20 10:35:03 +0000431 // The current button doesn't fit, keep on adding lower-priority buttons in case
432 // any of those fit.
433 continue;
Petr Cermak102431d2018-01-29 10:36:07 +0000434 }
435
436 // The current button fits, so mark all squeezed buttons as "successfully squeezed"
437 // to prevent them from being un-squeezed in a subsequent squeezing round.
Gustav Senntonb149a1a2018-11-20 17:25:50 +0000438 markButtonsWithPendingSqueezeStatusAs(
439 LayoutParams.SQUEEZE_STATUS_SUCCESSFUL, coveredSuggestions);
Petr Cermak102431d2018-01-29 10:36:07 +0000440 }
441
442 lp.show = true;
443 displayedChildCount++;
444 }
445
446 // We're done squeezing buttons, so we can clear the priority queue.
447 mCandidateButtonQueueForSqueezing.clear();
448
Milo Sredkova5bacea2018-04-12 12:52:43 +0100449 // Finally, we need to re-measure some buttons.
450 remeasureButtonsIfNecessary(buttonPaddingHorizontal, maxChildHeight);
Petr Cermak102431d2018-01-29 10:36:07 +0000451
452 setMeasuredDimension(
453 resolveSize(Math.max(getSuggestedMinimumWidth(), measuredWidth), widthMeasureSpec),
454 resolveSize(Math.max(getSuggestedMinimumHeight(),
455 mPaddingTop + maxChildHeight + mPaddingBottom), heightMeasureSpec));
456 }
457
Gustav Senntonb149a1a2018-11-20 17:25:50 +0000458 private List<View> filterActionsOrReplies(SmartButtonType buttonType) {
459 List<View> actions = new ArrayList<>();
460 final int childCount = getChildCount();
461 for (int i = 0; i < childCount; i++) {
462 final View child = getChildAt(i);
463 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
464 if (child.getVisibility() != View.VISIBLE || !(child instanceof Button)) {
465 continue;
466 }
467 if (lp.buttonType == buttonType) {
468 actions.add(child);
469 }
470 }
471 return actions;
472 }
473
Petr Cermak102431d2018-01-29 10:36:07 +0000474 private void resetButtonsLayoutParams() {
475 final int childCount = getChildCount();
476 for (int i = 0; i < childCount; i++) {
477 final View child = getChildAt(i);
478 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
479 lp.show = false;
480 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_NONE;
481 }
482 }
483
484 private int squeezeButton(Button button, int heightMeasureSpec) {
485 final int estimatedOptimalTextWidth = estimateOptimalSqueezedButtonTextWidth(button);
486 if (estimatedOptimalTextWidth == SQUEEZE_FAILED) {
487 return SQUEEZE_FAILED;
488 }
489 return squeezeButtonToTextWidth(button, heightMeasureSpec, estimatedOptimalTextWidth);
490 }
491
492 private int estimateOptimalSqueezedButtonTextWidth(Button button) {
493 // Find a line-break point in the middle of the smart reply button text.
494 final String rawText = button.getText().toString();
495
496 // The button sometimes has a transformation affecting text layout (e.g. all caps).
497 final TransformationMethod transformation = button.getTransformationMethod();
498 final String text = transformation == null ?
499 rawText : transformation.getTransformation(rawText, button).toString();
500 final int length = text.length();
501 mBreakIterator.setText(text);
502
503 if (mBreakIterator.preceding(length / 2) == BreakIterator.DONE) {
504 if (mBreakIterator.next() == BreakIterator.DONE) {
505 // Can't find a single possible line break in either direction.
506 return SQUEEZE_FAILED;
507 }
508 }
509
510 final TextPaint paint = button.getPaint();
511 final int initialPosition = mBreakIterator.current();
512 final float initialLeftTextWidth = Layout.getDesiredWidth(text, 0, initialPosition, paint);
513 final float initialRightTextWidth =
514 Layout.getDesiredWidth(text, initialPosition, length, paint);
515 float optimalTextWidth = Math.max(initialLeftTextWidth, initialRightTextWidth);
516
517 if (initialLeftTextWidth != initialRightTextWidth) {
518 // See if there's a better line-break point (leading to a more narrow button) in
519 // either left or right direction.
520 final boolean moveLeft = initialLeftTextWidth > initialRightTextWidth;
521 final int maxSqueezeRemeasureAttempts = mConstants.getMaxSqueezeRemeasureAttempts();
522 for (int i = 0; i < maxSqueezeRemeasureAttempts; i++) {
523 final int newPosition =
524 moveLeft ? mBreakIterator.previous() : mBreakIterator.next();
525 if (newPosition == BreakIterator.DONE) {
526 break;
527 }
528
529 final float newLeftTextWidth = Layout.getDesiredWidth(text, 0, newPosition, paint);
530 final float newRightTextWidth =
531 Layout.getDesiredWidth(text, newPosition, length, paint);
532 final float newOptimalTextWidth = Math.max(newLeftTextWidth, newRightTextWidth);
533 if (newOptimalTextWidth < optimalTextWidth) {
534 optimalTextWidth = newOptimalTextWidth;
535 } else {
536 break;
537 }
538
539 boolean tooFar = moveLeft
540 ? newLeftTextWidth <= newRightTextWidth
541 : newLeftTextWidth >= newRightTextWidth;
542 if (tooFar) {
543 break;
544 }
545 }
546 }
547
548 return (int) Math.ceil(optimalTextWidth);
549 }
550
Gustav Senntoneab53682018-11-01 16:30:23 +0000551 /**
552 * Returns the combined width of the left drawable (the action icon) and the padding between the
553 * drawable and the button text.
554 */
555 private int getLeftCompoundDrawableWidthWithPadding(Button button) {
556 Drawable[] drawables = button.getCompoundDrawables();
557 Drawable leftDrawable = drawables[0];
558 if (leftDrawable == null) return 0;
559
560 return leftDrawable.getBounds().width() + button.getCompoundDrawablePadding();
561 }
562
Petr Cermak102431d2018-01-29 10:36:07 +0000563 private int squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth) {
564 int oldWidth = button.getMeasuredWidth();
565 if (button.getPaddingLeft() != mDoubleLineButtonPaddingHorizontal) {
566 // Correct for the fact that the button was laid out with single-line horizontal
567 // padding.
568 oldWidth += mSingleToDoubleLineButtonWidthIncrease;
569 }
570
571 // Re-measure the squeezed smart reply button.
572 button.setPadding(mDoubleLineButtonPaddingHorizontal, button.getPaddingTop(),
573 mDoubleLineButtonPaddingHorizontal, button.getPaddingBottom());
574 final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(
Gustav Senntoneab53682018-11-01 16:30:23 +0000575 2 * mDoubleLineButtonPaddingHorizontal + textWidth
576 + getLeftCompoundDrawableWidthWithPadding(button), MeasureSpec.AT_MOST);
Petr Cermak102431d2018-01-29 10:36:07 +0000577 button.measure(widthMeasureSpec, heightMeasureSpec);
578
579 final int newWidth = button.getMeasuredWidth();
580
581 final LayoutParams lp = (LayoutParams) button.getLayoutParams();
582 if (button.getLineCount() > 2 || newWidth >= oldWidth) {
583 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_FAILED;
584 return SQUEEZE_FAILED;
585 } else {
586 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_PENDING;
587 return oldWidth - newWidth;
588 }
589 }
590
Milo Sredkova5bacea2018-04-12 12:52:43 +0100591 private void remeasureButtonsIfNecessary(
Petr Cermak102431d2018-01-29 10:36:07 +0000592 int buttonPaddingHorizontal, int maxChildHeight) {
Petr Cermak102431d2018-01-29 10:36:07 +0000593 final int maxChildHeightMeasure =
594 MeasureSpec.makeMeasureSpec(maxChildHeight, MeasureSpec.EXACTLY);
595
596 final int childCount = getChildCount();
597 for (int i = 0; i < childCount; i++) {
598 final View child = getChildAt(i);
599 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
600 if (!lp.show) {
601 continue;
602 }
603
Petr Cermak102431d2018-01-29 10:36:07 +0000604 boolean requiresNewMeasure = false;
605 int newWidth = child.getMeasuredWidth();
606
607 // Re-measure reason 1: The button needs to be un-squeezed (either because it resulted
608 // in more than two lines or because it was unnecessary).
609 if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_FAILED) {
610 requiresNewMeasure = true;
611 newWidth = Integer.MAX_VALUE;
612 }
613
614 // Re-measure reason 2: The button's horizontal padding is incorrect (because it was
615 // measured with the wrong number of lines).
616 if (child.getPaddingLeft() != buttonPaddingHorizontal) {
617 requiresNewMeasure = true;
Kenny Guy48ee6d62018-05-09 16:51:26 +0100618 if (newWidth != Integer.MAX_VALUE) {
619 if (buttonPaddingHorizontal == mSingleLineButtonPaddingHorizontal) {
620 // Change padding (2->1 line).
621 newWidth -= mSingleToDoubleLineButtonWidthIncrease;
622 } else {
623 // Change padding (1->2 lines).
624 newWidth += mSingleToDoubleLineButtonWidthIncrease;
625 }
Petr Cermak102431d2018-01-29 10:36:07 +0000626 }
627 child.setPadding(buttonPaddingHorizontal, child.getPaddingTop(),
628 buttonPaddingHorizontal, child.getPaddingBottom());
629 }
630
631 // Re-measure reason 3: The button's height is less than the max height of all buttons
632 // (all should have the same height).
633 if (child.getMeasuredHeight() != maxChildHeight) {
634 requiresNewMeasure = true;
635 }
636
637 if (requiresNewMeasure) {
638 child.measure(MeasureSpec.makeMeasureSpec(newWidth, MeasureSpec.AT_MOST),
639 maxChildHeightMeasure);
640 }
641 }
642 }
643
Gustav Senntonb149a1a2018-11-20 17:25:50 +0000644 private void markButtonsWithPendingSqueezeStatusAs(
645 int squeezeStatus, List<View> coveredChildren) {
646 for (View child : coveredChildren) {
Petr Cermak102431d2018-01-29 10:36:07 +0000647 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
648 if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_PENDING) {
649 lp.squeezeStatus = squeezeStatus;
650 }
651 }
652 }
653
654 @Override
655 protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
656 final boolean isRtl = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
657
658 final int width = right - left;
659 int position = isRtl ? width - mPaddingRight : mPaddingLeft;
660
661 final int childCount = getChildCount();
662 for (int i = 0; i < childCount; i++) {
663 final View child = getChildAt(i);
664 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
665 if (!lp.show) {
666 continue;
667 }
668
669 final int childWidth = child.getMeasuredWidth();
670 final int childHeight = child.getMeasuredHeight();
671 final int childLeft = isRtl ? position - childWidth : position;
672 child.layout(childLeft, 0, childLeft + childWidth, childHeight);
673
674 final int childWidthWithSpacing = childWidth + mSpacing;
675 if (isRtl) {
676 position -= childWidthWithSpacing;
677 } else {
678 position += childWidthWithSpacing;
679 }
680 }
681 }
682
683 @Override
684 protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
685 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
686 return lp.show && super.drawChild(canvas, child, drawingTime);
687 }
688
Kenny Guy14d035c2018-05-02 19:10:36 +0100689 public void setBackgroundTintColor(int backgroundColor) {
690 if (backgroundColor == mCurrentBackgroundColor) {
691 // Same color ignoring.
692 return;
693 }
694 mCurrentBackgroundColor = backgroundColor;
695
Lucas Dupina291d192018-06-07 13:59:42 -0700696 final boolean dark = !ContrastColorUtil.isColorLight(backgroundColor);
Kenny Guy14d035c2018-05-02 19:10:36 +0100697
Lucas Dupina291d192018-06-07 13:59:42 -0700698 int textColor = ContrastColorUtil.ensureTextContrast(
Kenny Guy14d035c2018-05-02 19:10:36 +0100699 dark ? mDefaultTextColorDarkBg : mDefaultTextColor,
700 backgroundColor | 0xff000000, dark);
Lucas Dupina291d192018-06-07 13:59:42 -0700701 int strokeColor = ContrastColorUtil.ensureContrast(
Kenny Guy14d035c2018-05-02 19:10:36 +0100702 mDefaultStrokeColor, backgroundColor | 0xff000000, dark, mMinStrokeContrast);
703 int rippleColor = dark ? mRippleColorDarkBg : mRippleColor;
704
705 int childCount = getChildCount();
706 for (int i = 0; i < childCount; i++) {
707 final Button child = (Button) getChildAt(i);
708 setColors(child, backgroundColor, strokeColor, textColor, rippleColor);
709 }
710 }
711
712 private void setColors(Button button, int backgroundColor, int strokeColor, int textColor,
713 int rippleColor) {
714 Drawable drawable = button.getBackground();
715 if (drawable instanceof RippleDrawable) {
716 // Mutate in case other notifications are using this drawable.
717 drawable = drawable.mutate();
718 RippleDrawable ripple = (RippleDrawable) drawable;
719 ripple.setColor(ColorStateList.valueOf(rippleColor));
720 Drawable inset = ripple.getDrawable(0);
721 if (inset instanceof InsetDrawable) {
722 Drawable background = ((InsetDrawable) inset).getDrawable();
723 if (background instanceof GradientDrawable) {
724 GradientDrawable gradientDrawable = (GradientDrawable) background;
725 gradientDrawable.setColor(backgroundColor);
726 gradientDrawable.setStroke(mStrokeWidth, strokeColor);
727 }
728 }
729 button.setBackground(drawable);
730 }
731 button.setTextColor(textColor);
732 }
733
Gustav Senntoneab53682018-11-01 16:30:23 +0000734 private ActivityStarter getActivityStarter() {
735 if (mActivityStarter == null) {
736 mActivityStarter = Dependency.get(ActivityStarter.class);
737 }
738 return mActivityStarter;
739 }
740
Gustav Senntonb149a1a2018-11-20 17:25:50 +0000741 private enum SmartButtonType {
742 REPLY,
743 ACTION
744 }
745
Petr Cermak102431d2018-01-29 10:36:07 +0000746 @VisibleForTesting
747 static class LayoutParams extends ViewGroup.LayoutParams {
748
749 /** Button is not squeezed. */
750 private static final int SQUEEZE_STATUS_NONE = 0;
751
752 /**
753 * Button was successfully squeezed, but it might be un-squeezed later if the squeezing
754 * turns out to have been unnecessary (because there's still not enough space to add another
755 * button).
756 */
757 private static final int SQUEEZE_STATUS_PENDING = 1;
758
759 /** Button was successfully squeezed and it won't be un-squeezed. */
760 private static final int SQUEEZE_STATUS_SUCCESSFUL = 2;
761
762 /**
763 * Button wasn't successfully squeezed. The squeezing resulted in more than two lines of
764 * text or it didn't reduce the button's width at all. The button will have to be
765 * re-measured to use only one line of text.
766 */
767 private static final int SQUEEZE_STATUS_FAILED = 3;
768
769 private boolean show = false;
770 private int squeezeStatus = SQUEEZE_STATUS_NONE;
Gustav Senntonb149a1a2018-11-20 17:25:50 +0000771 private SmartButtonType buttonType = SmartButtonType.REPLY;
Petr Cermak102431d2018-01-29 10:36:07 +0000772
773 private LayoutParams(Context c, AttributeSet attrs) {
774 super(c, attrs);
775 }
776
777 private LayoutParams(int width, int height) {
778 super(width, height);
779 }
780
781 @VisibleForTesting
782 boolean isShown() {
783 return show;
784 }
785 }
Tony Mak29996702018-11-26 16:23:34 +0000786
787 /**
788 * Data class for smart replies.
789 */
790 public static class SmartReplies {
791 @NonNull
792 public final RemoteInput remoteInput;
793 @NonNull
794 public final PendingIntent pendingIntent;
795 @NonNull
796 public final CharSequence[] choices;
797 public final boolean fromAssistant;
798
799 public SmartReplies(CharSequence[] choices, RemoteInput remoteInput,
800 PendingIntent pendingIntent, boolean fromAssistant) {
801 this.choices = choices;
802 this.remoteInput = remoteInput;
803 this.pendingIntent = pendingIntent;
804 this.fromAssistant = fromAssistant;
805 }
806 }
807
808
809 /**
810 * Data class for smart actions.
811 */
812 public static class SmartActions {
813 @NonNull
814 public final List<Notification.Action> actions;
815 public final boolean fromAssistant;
816
817 public SmartActions(List<Notification.Action> actions, boolean fromAssistant) {
818 this.actions = actions;
819 this.fromAssistant = fromAssistant;
820 }
821 }
Petr Cermaked7429c2017-12-18 19:38:04 +0000822}