blob: 5d8a971d1696d0e17f7a54a60a8f930818c29bea [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;
Gustav Senntond0e84532018-12-03 16:48:36 +000019import android.os.Handler;
20import android.os.Looper;
Petr Cermak102431d2018-01-29 10:36:07 +000021import android.text.Layout;
22import android.text.TextPaint;
23import android.text.method.TransformationMethod;
Petr Cermaked7429c2017-12-18 19:38:04 +000024import android.util.AttributeSet;
25import android.util.Log;
26import android.view.LayoutInflater;
Petr Cermak102431d2018-01-29 10:36:07 +000027import android.view.View;
Petr Cermaked7429c2017-12-18 19:38:04 +000028import android.view.ViewGroup;
Milo Sredkov66da07b2018-04-17 14:04:54 +010029import android.view.accessibility.AccessibilityNodeInfo;
30import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
Petr Cermaked7429c2017-12-18 19:38:04 +000031import android.widget.Button;
Petr Cermaked7429c2017-12-18 19:38:04 +000032
Petr Cermak102431d2018-01-29 10:36:07 +000033import com.android.internal.annotations.VisibleForTesting;
Lucas Dupina291d192018-06-07 13:59:42 -070034import com.android.internal.util.ContrastColorUtil;
Petr Cermak10011fa2018-02-05 19:00:54 +000035import com.android.systemui.Dependency;
Petr Cermaked7429c2017-12-18 19:38:04 +000036import com.android.systemui.R;
Gustav Senntoneab53682018-11-01 16:30:23 +000037import com.android.systemui.plugins.ActivityStarter;
Gus Prevasab336792018-11-14 13:52:20 -050038import com.android.systemui.plugins.ActivityStarter.OnDismissAction;
Milo Sredkovb2af7f92018-12-04 15:22:18 +000039import com.android.systemui.statusbar.NotificationRemoteInputManager;
Kenny Guya0f6de82018-04-06 16:20:16 +010040import com.android.systemui.statusbar.SmartReplyController;
Gus Prevasab336792018-11-14 13:52:20 -050041import com.android.systemui.statusbar.notification.NotificationData;
Milo Sredkove7cf4982018-04-09 15:08:26 +010042import com.android.systemui.statusbar.notification.NotificationUtils;
Milo Sredkovb0f55e92018-04-04 16:13:28 +010043import com.android.systemui.statusbar.phone.KeyguardDismissUtil;
Petr Cermaked7429c2017-12-18 19:38:04 +000044
Petr Cermak102431d2018-01-29 10:36:07 +000045import java.text.BreakIterator;
Gustav Senntonb149a1a2018-11-20 17:25:50 +000046import java.util.ArrayList;
Petr Cermak102431d2018-01-29 10:36:07 +000047import java.util.Comparator;
Gustav Senntoneab53682018-11-01 16:30:23 +000048import java.util.List;
Petr Cermak102431d2018-01-29 10:36:07 +000049import java.util.PriorityQueue;
50
Gustav Senntoneab53682018-11-01 16:30:23 +000051/** View which displays smart reply and smart actions buttons in notifications. */
Petr Cermak102431d2018-01-29 10:36:07 +000052public class SmartReplyView extends ViewGroup {
Petr Cermaked7429c2017-12-18 19:38:04 +000053
54 private static final String TAG = "SmartReplyView";
55
Gustav Senntoneab53682018-11-01 16:30:23 +000056 private static final int MEASURE_SPEC_ANY_LENGTH =
Petr Cermak102431d2018-01-29 10:36:07 +000057 MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
58
59 private static final Comparator<View> DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR =
60 (v1, v2) -> ((v2.getMeasuredWidth() - v2.getPaddingLeft() - v2.getPaddingRight())
61 - (v1.getMeasuredWidth() - v1.getPaddingLeft() - v1.getPaddingRight()));
62
63 private static final int SQUEEZE_FAILED = -1;
64
Petr Cermak10011fa2018-02-05 19:00:54 +000065 private final SmartReplyConstants mConstants;
Milo Sredkovb0f55e92018-04-04 16:13:28 +010066 private final KeyguardDismissUtil mKeyguardDismissUtil;
Milo Sredkovb2af7f92018-12-04 15:22:18 +000067 private final NotificationRemoteInputManager mRemoteInputManager;
Gustav Senntond0e84532018-12-03 16:48:36 +000068 private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper());
Petr Cermak10011fa2018-02-05 19:00:54 +000069
Milo Sredkove7cf4982018-04-09 15:08:26 +010070 /**
71 * The upper bound for the height of this view in pixels. Notifications are automatically
72 * recreated on density or font size changes so caching this should be fine.
73 */
74 private final int mHeightUpperLimit;
75
Petr Cermak102431d2018-01-29 10:36:07 +000076 /** Spacing to be applied between views. */
77 private final int mSpacing;
78
79 /** Horizontal padding of smart reply buttons if all of them use only one line of text. */
80 private final int mSingleLineButtonPaddingHorizontal;
81
82 /** Horizontal padding of smart reply buttons if at least one of them uses two lines of text. */
83 private final int mDoubleLineButtonPaddingHorizontal;
84
85 /** Increase in width of a smart reply button as a result of using two lines instead of one. */
86 private final int mSingleToDoubleLineButtonWidthIncrease;
87
88 private final BreakIterator mBreakIterator;
89
90 private PriorityQueue<Button> mCandidateButtonQueueForSqueezing;
91
Kenny Guya0f6de82018-04-06 16:20:16 +010092 private View mSmartReplyContainer;
93
Kenny Guy14d035c2018-05-02 19:10:36 +010094 @ColorInt
95 private int mCurrentBackgroundColor;
96 @ColorInt
97 private final int mDefaultBackgroundColor;
98 @ColorInt
99 private final int mDefaultStrokeColor;
100 @ColorInt
101 private final int mDefaultTextColor;
102 @ColorInt
103 private final int mDefaultTextColorDarkBg;
104 @ColorInt
105 private final int mRippleColorDarkBg;
106 @ColorInt
107 private final int mRippleColor;
108 private final int mStrokeWidth;
109 private final double mMinStrokeContrast;
110
Gustav Senntoneab53682018-11-01 16:30:23 +0000111 private ActivityStarter mActivityStarter;
112
Petr Cermaked7429c2017-12-18 19:38:04 +0000113 public SmartReplyView(Context context, AttributeSet attrs) {
114 super(context, attrs);
Petr Cermak10011fa2018-02-05 19:00:54 +0000115 mConstants = Dependency.get(SmartReplyConstants.class);
Milo Sredkovb0f55e92018-04-04 16:13:28 +0100116 mKeyguardDismissUtil = Dependency.get(KeyguardDismissUtil.class);
Milo Sredkovb2af7f92018-12-04 15:22:18 +0000117 mRemoteInputManager = Dependency.get(NotificationRemoteInputManager.class);
Petr Cermak102431d2018-01-29 10:36:07 +0000118
Milo Sredkove7cf4982018-04-09 15:08:26 +0100119 mHeightUpperLimit = NotificationUtils.getFontScaledHeight(mContext,
120 R.dimen.smart_reply_button_max_height);
121
Kenny Guy14d035c2018-05-02 19:10:36 +0100122 mCurrentBackgroundColor = context.getColor(R.color.smart_reply_button_background);
123 mDefaultBackgroundColor = mCurrentBackgroundColor;
124 mDefaultTextColor = mContext.getColor(R.color.smart_reply_button_text);
125 mDefaultTextColorDarkBg = mContext.getColor(R.color.smart_reply_button_text_dark_bg);
126 mDefaultStrokeColor = mContext.getColor(R.color.smart_reply_button_stroke);
127 mRippleColor = mContext.getColor(R.color.notification_ripple_untinted_color);
128 mRippleColorDarkBg = Color.argb(Color.alpha(mRippleColor),
129 255 /* red */, 255 /* green */, 255 /* blue */);
Lucas Dupina291d192018-06-07 13:59:42 -0700130 mMinStrokeContrast = ContrastColorUtil.calculateContrast(mDefaultStrokeColor,
Kenny Guy14d035c2018-05-02 19:10:36 +0100131 mDefaultBackgroundColor);
132
Petr Cermak102431d2018-01-29 10:36:07 +0000133 int spacing = 0;
134 int singleLineButtonPaddingHorizontal = 0;
135 int doubleLineButtonPaddingHorizontal = 0;
Kenny Guy14d035c2018-05-02 19:10:36 +0100136 int strokeWidth = 0;
Petr Cermak102431d2018-01-29 10:36:07 +0000137
138 final TypedArray arr = context.obtainStyledAttributes(attrs, R.styleable.SmartReplyView,
139 0, 0);
140 final int length = arr.getIndexCount();
141 for (int i = 0; i < length; i++) {
142 int attr = arr.getIndex(i);
Jason Monk05dd5672018-08-09 09:38:21 -0400143 if (attr == R.styleable.SmartReplyView_spacing) {
144 spacing = arr.getDimensionPixelSize(i, 0);
145 } else if (attr == R.styleable.SmartReplyView_singleLineButtonPaddingHorizontal) {
146 singleLineButtonPaddingHorizontal = arr.getDimensionPixelSize(i, 0);
147 } else if (attr == R.styleable.SmartReplyView_doubleLineButtonPaddingHorizontal) {
148 doubleLineButtonPaddingHorizontal = arr.getDimensionPixelSize(i, 0);
149 } else if (attr == R.styleable.SmartReplyView_buttonStrokeWidth) {
150 strokeWidth = arr.getDimensionPixelSize(i, 0);
Petr Cermak102431d2018-01-29 10:36:07 +0000151 }
152 }
153 arr.recycle();
154
Kenny Guy14d035c2018-05-02 19:10:36 +0100155 mStrokeWidth = strokeWidth;
Petr Cermak102431d2018-01-29 10:36:07 +0000156 mSpacing = spacing;
157 mSingleLineButtonPaddingHorizontal = singleLineButtonPaddingHorizontal;
158 mDoubleLineButtonPaddingHorizontal = doubleLineButtonPaddingHorizontal;
159 mSingleToDoubleLineButtonWidthIncrease =
160 2 * (doubleLineButtonPaddingHorizontal - singleLineButtonPaddingHorizontal);
161
Milo Sredkove7cf4982018-04-09 15:08:26 +0100162
Petr Cermak102431d2018-01-29 10:36:07 +0000163 mBreakIterator = BreakIterator.getLineInstance();
164 reallocateCandidateButtonQueueForSqueezing();
165 }
166
Milo Sredkove7cf4982018-04-09 15:08:26 +0100167 /**
168 * Returns an upper bound for the height of this view in pixels. This method is intended to be
169 * invoked before onMeasure, so it doesn't do any analysis on the contents of the buttons.
170 */
171 public int getHeightUpperLimit() {
172 return mHeightUpperLimit;
173 }
174
Petr Cermak102431d2018-01-29 10:36:07 +0000175 private void reallocateCandidateButtonQueueForSqueezing() {
176 // Instead of clearing the priority queue, we re-allocate so that it would fit all buttons
177 // exactly. This avoids (1) wasting memory because PriorityQueue never shrinks and
178 // (2) growing in onMeasure.
179 // The constructor throws an IllegalArgument exception if initial capacity is less than 1.
180 mCandidateButtonQueueForSqueezing = new PriorityQueue<>(
181 Math.max(getChildCount(), 1), DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR);
Petr Cermaked7429c2017-12-18 19:38:04 +0000182 }
183
Gustav Senntoneab53682018-11-01 16:30:23 +0000184 /**
185 * Reset the smart suggestions view to allow adding new replies and actions.
186 */
187 public void resetSmartSuggestions(View newSmartReplyContainer) {
188 mSmartReplyContainer = newSmartReplyContainer;
Petr Cermaked7429c2017-12-18 19:38:04 +0000189 removeAllViews();
Kenny Guy14d035c2018-05-02 19:10:36 +0100190 mCurrentBackgroundColor = mDefaultBackgroundColor;
Gustav Senntoneab53682018-11-01 16:30:23 +0000191 }
192
193 /**
194 * Add smart replies to this view, using the provided {@link RemoteInput} and
195 * {@link PendingIntent} to respond when the user taps a smart reply. Only the replies that fit
196 * into the notification are shown.
197 */
198 public void addRepliesFromRemoteInput(
Tony Mak29996702018-11-26 16:23:34 +0000199 SmartReplies smartReplies,
200 SmartReplyController smartReplyController, NotificationData.Entry entry) {
201 if (smartReplies.remoteInput != null && smartReplies.pendingIntent != null) {
202 if (smartReplies.choices != null) {
203 for (int i = 0; i < smartReplies.choices.length; ++i) {
Petr Cermaked7429c2017-12-18 19:38:04 +0000204 Button replyButton = inflateReplyButton(
Tony Mak29996702018-11-26 16:23:34 +0000205 getContext(), this, i, smartReplies, smartReplyController, entry);
Petr Cermaked7429c2017-12-18 19:38:04 +0000206 addView(replyButton);
207 }
208 }
209 }
Petr Cermak102431d2018-01-29 10:36:07 +0000210 reallocateCandidateButtonQueueForSqueezing();
Petr Cermaked7429c2017-12-18 19:38:04 +0000211 }
212
Gustav Senntoneab53682018-11-01 16:30:23 +0000213 /**
214 * Add smart actions to be shown next to smart replies. Only the actions that fit into the
215 * notification are shown.
216 */
Tony Mak7d4b3a52018-11-27 17:29:36 +0000217 public void addSmartActions(SmartActions smartActions,
Gustav Senntond0e84532018-12-03 16:48:36 +0000218 SmartReplyController smartReplyController, NotificationData.Entry entry,
219 HeadsUpManager headsUpManager) {
Tony Mak29996702018-11-26 16:23:34 +0000220 int numSmartActions = smartActions.actions.size();
Gustav Senntoneab53682018-11-01 16:30:23 +0000221 for (int n = 0; n < numSmartActions; n++) {
Tony Mak29996702018-11-26 16:23:34 +0000222 Notification.Action action = smartActions.actions.get(n);
Gustav Senntoneab53682018-11-01 16:30:23 +0000223 if (action.actionIntent != null) {
Tony Mak7d4b3a52018-11-27 17:29:36 +0000224 Button actionButton = inflateActionButton(
Gustav Senntond0e84532018-12-03 16:48:36 +0000225 getContext(), this, n, smartActions, smartReplyController, entry,
226 headsUpManager);
Gustav Senntoneab53682018-11-01 16:30:23 +0000227 addView(actionButton);
228 }
229 }
230 reallocateCandidateButtonQueueForSqueezing();
231 }
232
Petr Cermaked7429c2017-12-18 19:38:04 +0000233 public static SmartReplyView inflate(Context context, ViewGroup root) {
234 return (SmartReplyView)
235 LayoutInflater.from(context).inflate(R.layout.smart_reply_view, root, false);
236 }
237
Petr Cermak102431d2018-01-29 10:36:07 +0000238 @VisibleForTesting
Kenny Guy23991102018-04-05 21:18:38 +0100239 Button inflateReplyButton(Context context, ViewGroup root, int replyIndex,
Tony Mak29996702018-11-26 16:23:34 +0000240 SmartReplies smartReplies, SmartReplyController smartReplyController,
241 NotificationData.Entry entry) {
Petr Cermaked7429c2017-12-18 19:38:04 +0000242 Button b = (Button) LayoutInflater.from(context).inflate(
243 R.layout.smart_reply_button, root, false);
Tony Mak29996702018-11-26 16:23:34 +0000244 CharSequence choice = smartReplies.choices[replyIndex];
Petr Cermaked7429c2017-12-18 19:38:04 +0000245 b.setText(choice);
Milo Sredkovb0f55e92018-04-04 16:13:28 +0100246
247 OnDismissAction action = () -> {
Milo Sredkovb2af7f92018-12-04 15:22:18 +0000248 // TODO(b/111437455): Also for EDIT_CHOICES_BEFORE_SENDING_AUTO, depending on flags.
249 if (smartReplies.remoteInput.getEditChoicesBeforeSending()
250 == RemoteInput.EDIT_CHOICES_BEFORE_SENDING_ENABLED) {
251 entry.remoteInputText = choice;
252 mRemoteInputManager.activateRemoteInput(b,
253 new RemoteInput[] { smartReplies.remoteInput }, smartReplies.remoteInput,
254 smartReplies.pendingIntent);
255 return false;
256 }
257
Tony Mak29996702018-11-26 16:23:34 +0000258 smartReplyController.smartReplySent(
259 entry, replyIndex, b.getText(), smartReplies.fromAssistant);
Petr Cermaked7429c2017-12-18 19:38:04 +0000260 Bundle results = new Bundle();
Tony Mak29996702018-11-26 16:23:34 +0000261 results.putString(smartReplies.remoteInput.getResultKey(), choice.toString());
Petr Cermaked7429c2017-12-18 19:38:04 +0000262 Intent intent = new Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
Milo Sredkovb2af7f92018-12-04 15:22:18 +0000263 RemoteInput.addResultsToIntent(new RemoteInput[] { smartReplies.remoteInput }, intent,
Tony Mak29996702018-11-26 16:23:34 +0000264 results);
Petr Cermak9a3380c2018-01-19 15:00:24 +0000265 RemoteInput.setResultsSource(intent, RemoteInput.SOURCE_CHOICE);
Selim Cinekb0dc61b2018-05-22 18:49:36 -0700266 entry.setHasSentReply();
Petr Cermaked7429c2017-12-18 19:38:04 +0000267 try {
Tony Mak29996702018-11-26 16:23:34 +0000268 smartReplies.pendingIntent.send(context, 0, intent);
Petr Cermaked7429c2017-12-18 19:38:04 +0000269 } catch (PendingIntent.CanceledException e) {
270 Log.w(TAG, "Unable to send smart reply", e);
271 }
Kenny Guya0f6de82018-04-06 16:20:16 +0100272 mSmartReplyContainer.setVisibility(View.GONE);
Milo Sredkovb0f55e92018-04-04 16:13:28 +0100273 return false; // do not defer
274 };
275
276 b.setOnClickListener(view -> {
Milo Sredkove433e9b2018-05-01 22:45:38 +0100277 mKeyguardDismissUtil.executeWhenUnlocked(action);
Petr Cermaked7429c2017-12-18 19:38:04 +0000278 });
Milo Sredkov66da07b2018-04-17 14:04:54 +0100279
280 b.setAccessibilityDelegate(new AccessibilityDelegate() {
281 public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
282 super.onInitializeAccessibilityNodeInfo(host, info);
283 String label = getResources().getString(R.string.accessibility_send_smart_reply);
284 info.addAction(new AccessibilityAction(AccessibilityNodeInfo.ACTION_CLICK, label));
285 }
286 });
287
Kenny Guy14d035c2018-05-02 19:10:36 +0100288 setColors(b, mCurrentBackgroundColor, mDefaultStrokeColor, mDefaultTextColor, mRippleColor);
Petr Cermaked7429c2017-12-18 19:38:04 +0000289 return b;
290 }
Petr Cermak102431d2018-01-29 10:36:07 +0000291
Gustav Senntoneab53682018-11-01 16:30:23 +0000292 @VisibleForTesting
Tony Mak7d4b3a52018-11-27 17:29:36 +0000293 Button inflateActionButton(Context context, ViewGroup root, int actionIndex,
294 SmartActions smartActions, SmartReplyController smartReplyController,
Gustav Senntond0e84532018-12-03 16:48:36 +0000295 NotificationData.Entry entry, HeadsUpManager headsUpManager) {
Tony Mak7d4b3a52018-11-27 17:29:36 +0000296 Notification.Action action = smartActions.actions.get(actionIndex);
Gustav Senntoneab53682018-11-01 16:30:23 +0000297 Button button = (Button) LayoutInflater.from(context).inflate(
298 R.layout.smart_action_button, root, false);
299 button.setText(action.title);
300
301 Drawable iconDrawable = action.getIcon().loadDrawable(context);
302 // Add the action icon to the Smart Action button.
Gustav Senntonc726f472018-12-04 11:57:06 +0000303 int newIconSize = context.getResources().getDimensionPixelSize(
304 R.dimen.smart_action_button_icon_size);
305 iconDrawable.setBounds(0, 0, newIconSize, newIconSize);
Gustav Senntoneab53682018-11-01 16:30:23 +0000306 button.setCompoundDrawables(iconDrawable, null, null, null);
307
308 button.setOnClickListener(view ->
Tony Mak7d4b3a52018-11-27 17:29:36 +0000309 getActivityStarter().startPendingIntentDismissingKeyguard(
310 action.actionIntent,
Gustav Senntond0e84532018-12-03 16:48:36 +0000311 () -> {
312 smartReplyController.smartActionClicked(
313 entry, actionIndex, action, smartActions.fromAssistant);
314 postOnUiThread(() ->
315 headsUpManager.removeNotification(entry.key, true));
316 }));
Gustav Senntoneab53682018-11-01 16:30:23 +0000317
318 // TODO(b/119010281): handle accessibility
319
Gustav Senntonb149a1a2018-11-20 17:25:50 +0000320 // Mark this as an Action button
321 final LayoutParams lp = (LayoutParams) button.getLayoutParams();
322 lp.buttonType = SmartButtonType.ACTION;
Gustav Senntoneab53682018-11-01 16:30:23 +0000323 return button;
324 }
325
Gustav Senntond0e84532018-12-03 16:48:36 +0000326 private void postOnUiThread(Runnable runnable) {
327 mMainThreadHandler.post(runnable);
328 }
329
Petr Cermak102431d2018-01-29 10:36:07 +0000330 @Override
331 public LayoutParams generateLayoutParams(AttributeSet attrs) {
332 return new LayoutParams(mContext, attrs);
333 }
334
335 @Override
336 protected LayoutParams generateDefaultLayoutParams() {
337 return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
338 }
339
340 @Override
341 protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams params) {
342 return new LayoutParams(params.width, params.height);
343 }
344
345 @Override
346 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
347 final int targetWidth = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED
348 ? Integer.MAX_VALUE : MeasureSpec.getSize(widthMeasureSpec);
349
350 // Mark all buttons as hidden and un-squeezed.
351 resetButtonsLayoutParams();
352
353 if (!mCandidateButtonQueueForSqueezing.isEmpty()) {
354 Log.wtf(TAG, "Single line button queue leaked between onMeasure calls");
355 mCandidateButtonQueueForSqueezing.clear();
356 }
357
358 int measuredWidth = mPaddingLeft + mPaddingRight;
359 int maxChildHeight = 0;
360 int displayedChildCount = 0;
361 int buttonPaddingHorizontal = mSingleLineButtonPaddingHorizontal;
362
Gustav Senntonb149a1a2018-11-20 17:25:50 +0000363 // Set up a list of suggestions where actions come before replies. Note that the Buttons
364 // themselves have already been added to the view hierarchy in an order such that Smart
365 // Replies are shown before Smart Actions. The order of the list below determines which
366 // suggestions will be shown at all - only the first X elements are shown (where X depends
367 // on how much space each suggestion button needs).
368 List<View> smartActions = filterActionsOrReplies(SmartButtonType.ACTION);
369 List<View> smartReplies = filterActionsOrReplies(SmartButtonType.REPLY);
370 List<View> smartSuggestions = new ArrayList<>(smartActions);
371 smartSuggestions.addAll(smartReplies);
372 List<View> coveredSuggestions = new ArrayList<>();
373
374 for (View child : smartSuggestions) {
Petr Cermak102431d2018-01-29 10:36:07 +0000375 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
Petr Cermak102431d2018-01-29 10:36:07 +0000376
377 child.setPadding(buttonPaddingHorizontal, child.getPaddingTop(),
378 buttonPaddingHorizontal, child.getPaddingBottom());
Gustav Senntoneab53682018-11-01 16:30:23 +0000379 child.measure(MEASURE_SPEC_ANY_LENGTH, heightMeasureSpec);
Petr Cermak102431d2018-01-29 10:36:07 +0000380
Gustav Senntonb149a1a2018-11-20 17:25:50 +0000381 coveredSuggestions.add(child);
382
Petr Cermak102431d2018-01-29 10:36:07 +0000383 final int lineCount = ((Button) child).getLineCount();
384 if (lineCount < 1 || lineCount > 2) {
385 // If smart reply has no text, or more than two lines, then don't show it.
386 continue;
387 }
388
389 if (lineCount == 1) {
390 mCandidateButtonQueueForSqueezing.add((Button) child);
391 }
392
393 // Remember the current measurements in case the current button doesn't fit in.
394 final int originalMaxChildHeight = maxChildHeight;
395 final int originalMeasuredWidth = measuredWidth;
396 final int originalButtonPaddingHorizontal = buttonPaddingHorizontal;
397
398 final int spacing = displayedChildCount == 0 ? 0 : mSpacing;
399 final int childWidth = child.getMeasuredWidth();
400 final int childHeight = child.getMeasuredHeight();
401 measuredWidth += spacing + childWidth;
402 maxChildHeight = Math.max(maxChildHeight, childHeight);
403
404 // Do we need to increase the number of lines in smart reply buttons to two?
405 final boolean increaseToTwoLines =
406 buttonPaddingHorizontal == mSingleLineButtonPaddingHorizontal
407 && (lineCount == 2 || measuredWidth > targetWidth);
408 if (increaseToTwoLines) {
409 measuredWidth += (displayedChildCount + 1) * mSingleToDoubleLineButtonWidthIncrease;
410 buttonPaddingHorizontal = mDoubleLineButtonPaddingHorizontal;
411 }
412
413 // If the last button doesn't fit into the remaining width, try squeezing preceding
414 // smart reply buttons.
415 if (measuredWidth > targetWidth) {
416 // Keep squeezing preceding and current smart reply buttons until they all fit.
417 while (measuredWidth > targetWidth
418 && !mCandidateButtonQueueForSqueezing.isEmpty()) {
419 final Button candidate = mCandidateButtonQueueForSqueezing.poll();
420 final int squeezeReduction = squeezeButton(candidate, heightMeasureSpec);
421 if (squeezeReduction != SQUEEZE_FAILED) {
422 maxChildHeight = Math.max(maxChildHeight, candidate.getMeasuredHeight());
423 measuredWidth -= squeezeReduction;
424 }
425 }
426
427 // If the current button still doesn't fit after squeezing all buttons, undo the
428 // last squeezing round.
429 if (measuredWidth > targetWidth) {
430 measuredWidth = originalMeasuredWidth;
431 maxChildHeight = originalMaxChildHeight;
432 buttonPaddingHorizontal = originalButtonPaddingHorizontal;
433
434 // Mark all buttons from the last squeezing round as "failed to squeeze", so
435 // that they're re-measured without squeezing later.
Gustav Senntonb149a1a2018-11-20 17:25:50 +0000436 markButtonsWithPendingSqueezeStatusAs(
437 LayoutParams.SQUEEZE_STATUS_FAILED, coveredSuggestions);
Petr Cermak102431d2018-01-29 10:36:07 +0000438
Gustav Sennton35156752018-12-20 10:35:03 +0000439 // The current button doesn't fit, keep on adding lower-priority buttons in case
440 // any of those fit.
441 continue;
Petr Cermak102431d2018-01-29 10:36:07 +0000442 }
443
444 // The current button fits, so mark all squeezed buttons as "successfully squeezed"
445 // to prevent them from being un-squeezed in a subsequent squeezing round.
Gustav Senntonb149a1a2018-11-20 17:25:50 +0000446 markButtonsWithPendingSqueezeStatusAs(
447 LayoutParams.SQUEEZE_STATUS_SUCCESSFUL, coveredSuggestions);
Petr Cermak102431d2018-01-29 10:36:07 +0000448 }
449
450 lp.show = true;
451 displayedChildCount++;
452 }
453
454 // We're done squeezing buttons, so we can clear the priority queue.
455 mCandidateButtonQueueForSqueezing.clear();
456
Milo Sredkova5bacea2018-04-12 12:52:43 +0100457 // Finally, we need to re-measure some buttons.
458 remeasureButtonsIfNecessary(buttonPaddingHorizontal, maxChildHeight);
Petr Cermak102431d2018-01-29 10:36:07 +0000459
460 setMeasuredDimension(
461 resolveSize(Math.max(getSuggestedMinimumWidth(), measuredWidth), widthMeasureSpec),
462 resolveSize(Math.max(getSuggestedMinimumHeight(),
463 mPaddingTop + maxChildHeight + mPaddingBottom), heightMeasureSpec));
464 }
465
Gustav Senntonb149a1a2018-11-20 17:25:50 +0000466 private List<View> filterActionsOrReplies(SmartButtonType buttonType) {
467 List<View> actions = new ArrayList<>();
468 final int childCount = getChildCount();
469 for (int i = 0; i < childCount; i++) {
470 final View child = getChildAt(i);
471 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
472 if (child.getVisibility() != View.VISIBLE || !(child instanceof Button)) {
473 continue;
474 }
475 if (lp.buttonType == buttonType) {
476 actions.add(child);
477 }
478 }
479 return actions;
480 }
481
Petr Cermak102431d2018-01-29 10:36:07 +0000482 private void resetButtonsLayoutParams() {
483 final int childCount = getChildCount();
484 for (int i = 0; i < childCount; i++) {
485 final View child = getChildAt(i);
486 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
487 lp.show = false;
488 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_NONE;
489 }
490 }
491
492 private int squeezeButton(Button button, int heightMeasureSpec) {
493 final int estimatedOptimalTextWidth = estimateOptimalSqueezedButtonTextWidth(button);
494 if (estimatedOptimalTextWidth == SQUEEZE_FAILED) {
495 return SQUEEZE_FAILED;
496 }
497 return squeezeButtonToTextWidth(button, heightMeasureSpec, estimatedOptimalTextWidth);
498 }
499
500 private int estimateOptimalSqueezedButtonTextWidth(Button button) {
501 // Find a line-break point in the middle of the smart reply button text.
502 final String rawText = button.getText().toString();
503
504 // The button sometimes has a transformation affecting text layout (e.g. all caps).
505 final TransformationMethod transformation = button.getTransformationMethod();
506 final String text = transformation == null ?
507 rawText : transformation.getTransformation(rawText, button).toString();
508 final int length = text.length();
509 mBreakIterator.setText(text);
510
511 if (mBreakIterator.preceding(length / 2) == BreakIterator.DONE) {
512 if (mBreakIterator.next() == BreakIterator.DONE) {
513 // Can't find a single possible line break in either direction.
514 return SQUEEZE_FAILED;
515 }
516 }
517
518 final TextPaint paint = button.getPaint();
519 final int initialPosition = mBreakIterator.current();
520 final float initialLeftTextWidth = Layout.getDesiredWidth(text, 0, initialPosition, paint);
521 final float initialRightTextWidth =
522 Layout.getDesiredWidth(text, initialPosition, length, paint);
523 float optimalTextWidth = Math.max(initialLeftTextWidth, initialRightTextWidth);
524
525 if (initialLeftTextWidth != initialRightTextWidth) {
526 // See if there's a better line-break point (leading to a more narrow button) in
527 // either left or right direction.
528 final boolean moveLeft = initialLeftTextWidth > initialRightTextWidth;
529 final int maxSqueezeRemeasureAttempts = mConstants.getMaxSqueezeRemeasureAttempts();
530 for (int i = 0; i < maxSqueezeRemeasureAttempts; i++) {
531 final int newPosition =
532 moveLeft ? mBreakIterator.previous() : mBreakIterator.next();
533 if (newPosition == BreakIterator.DONE) {
534 break;
535 }
536
537 final float newLeftTextWidth = Layout.getDesiredWidth(text, 0, newPosition, paint);
538 final float newRightTextWidth =
539 Layout.getDesiredWidth(text, newPosition, length, paint);
540 final float newOptimalTextWidth = Math.max(newLeftTextWidth, newRightTextWidth);
541 if (newOptimalTextWidth < optimalTextWidth) {
542 optimalTextWidth = newOptimalTextWidth;
543 } else {
544 break;
545 }
546
547 boolean tooFar = moveLeft
548 ? newLeftTextWidth <= newRightTextWidth
549 : newLeftTextWidth >= newRightTextWidth;
550 if (tooFar) {
551 break;
552 }
553 }
554 }
555
556 return (int) Math.ceil(optimalTextWidth);
557 }
558
Gustav Senntoneab53682018-11-01 16:30:23 +0000559 /**
560 * Returns the combined width of the left drawable (the action icon) and the padding between the
561 * drawable and the button text.
562 */
563 private int getLeftCompoundDrawableWidthWithPadding(Button button) {
564 Drawable[] drawables = button.getCompoundDrawables();
565 Drawable leftDrawable = drawables[0];
566 if (leftDrawable == null) return 0;
567
568 return leftDrawable.getBounds().width() + button.getCompoundDrawablePadding();
569 }
570
Petr Cermak102431d2018-01-29 10:36:07 +0000571 private int squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth) {
572 int oldWidth = button.getMeasuredWidth();
573 if (button.getPaddingLeft() != mDoubleLineButtonPaddingHorizontal) {
574 // Correct for the fact that the button was laid out with single-line horizontal
575 // padding.
576 oldWidth += mSingleToDoubleLineButtonWidthIncrease;
577 }
578
579 // Re-measure the squeezed smart reply button.
580 button.setPadding(mDoubleLineButtonPaddingHorizontal, button.getPaddingTop(),
581 mDoubleLineButtonPaddingHorizontal, button.getPaddingBottom());
582 final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(
Gustav Senntoneab53682018-11-01 16:30:23 +0000583 2 * mDoubleLineButtonPaddingHorizontal + textWidth
584 + getLeftCompoundDrawableWidthWithPadding(button), MeasureSpec.AT_MOST);
Petr Cermak102431d2018-01-29 10:36:07 +0000585 button.measure(widthMeasureSpec, heightMeasureSpec);
586
587 final int newWidth = button.getMeasuredWidth();
588
589 final LayoutParams lp = (LayoutParams) button.getLayoutParams();
590 if (button.getLineCount() > 2 || newWidth >= oldWidth) {
591 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_FAILED;
592 return SQUEEZE_FAILED;
593 } else {
594 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_PENDING;
595 return oldWidth - newWidth;
596 }
597 }
598
Milo Sredkova5bacea2018-04-12 12:52:43 +0100599 private void remeasureButtonsIfNecessary(
Petr Cermak102431d2018-01-29 10:36:07 +0000600 int buttonPaddingHorizontal, int maxChildHeight) {
Petr Cermak102431d2018-01-29 10:36:07 +0000601 final int maxChildHeightMeasure =
602 MeasureSpec.makeMeasureSpec(maxChildHeight, MeasureSpec.EXACTLY);
603
604 final int childCount = getChildCount();
605 for (int i = 0; i < childCount; i++) {
606 final View child = getChildAt(i);
607 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
608 if (!lp.show) {
609 continue;
610 }
611
Petr Cermak102431d2018-01-29 10:36:07 +0000612 boolean requiresNewMeasure = false;
613 int newWidth = child.getMeasuredWidth();
614
615 // Re-measure reason 1: The button needs to be un-squeezed (either because it resulted
616 // in more than two lines or because it was unnecessary).
617 if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_FAILED) {
618 requiresNewMeasure = true;
619 newWidth = Integer.MAX_VALUE;
620 }
621
622 // Re-measure reason 2: The button's horizontal padding is incorrect (because it was
623 // measured with the wrong number of lines).
624 if (child.getPaddingLeft() != buttonPaddingHorizontal) {
625 requiresNewMeasure = true;
Kenny Guy48ee6d62018-05-09 16:51:26 +0100626 if (newWidth != Integer.MAX_VALUE) {
627 if (buttonPaddingHorizontal == mSingleLineButtonPaddingHorizontal) {
628 // Change padding (2->1 line).
629 newWidth -= mSingleToDoubleLineButtonWidthIncrease;
630 } else {
631 // Change padding (1->2 lines).
632 newWidth += mSingleToDoubleLineButtonWidthIncrease;
633 }
Petr Cermak102431d2018-01-29 10:36:07 +0000634 }
635 child.setPadding(buttonPaddingHorizontal, child.getPaddingTop(),
636 buttonPaddingHorizontal, child.getPaddingBottom());
637 }
638
639 // Re-measure reason 3: The button's height is less than the max height of all buttons
640 // (all should have the same height).
641 if (child.getMeasuredHeight() != maxChildHeight) {
642 requiresNewMeasure = true;
643 }
644
645 if (requiresNewMeasure) {
646 child.measure(MeasureSpec.makeMeasureSpec(newWidth, MeasureSpec.AT_MOST),
647 maxChildHeightMeasure);
648 }
649 }
650 }
651
Gustav Senntonb149a1a2018-11-20 17:25:50 +0000652 private void markButtonsWithPendingSqueezeStatusAs(
653 int squeezeStatus, List<View> coveredChildren) {
654 for (View child : coveredChildren) {
Petr Cermak102431d2018-01-29 10:36:07 +0000655 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
656 if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_PENDING) {
657 lp.squeezeStatus = squeezeStatus;
658 }
659 }
660 }
661
662 @Override
663 protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
664 final boolean isRtl = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
665
666 final int width = right - left;
667 int position = isRtl ? width - mPaddingRight : mPaddingLeft;
668
669 final int childCount = getChildCount();
670 for (int i = 0; i < childCount; i++) {
671 final View child = getChildAt(i);
672 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
673 if (!lp.show) {
674 continue;
675 }
676
677 final int childWidth = child.getMeasuredWidth();
678 final int childHeight = child.getMeasuredHeight();
679 final int childLeft = isRtl ? position - childWidth : position;
680 child.layout(childLeft, 0, childLeft + childWidth, childHeight);
681
682 final int childWidthWithSpacing = childWidth + mSpacing;
683 if (isRtl) {
684 position -= childWidthWithSpacing;
685 } else {
686 position += childWidthWithSpacing;
687 }
688 }
689 }
690
691 @Override
692 protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
693 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
694 return lp.show && super.drawChild(canvas, child, drawingTime);
695 }
696
Kenny Guy14d035c2018-05-02 19:10:36 +0100697 public void setBackgroundTintColor(int backgroundColor) {
698 if (backgroundColor == mCurrentBackgroundColor) {
699 // Same color ignoring.
700 return;
701 }
702 mCurrentBackgroundColor = backgroundColor;
703
Lucas Dupina291d192018-06-07 13:59:42 -0700704 final boolean dark = !ContrastColorUtil.isColorLight(backgroundColor);
Kenny Guy14d035c2018-05-02 19:10:36 +0100705
Lucas Dupina291d192018-06-07 13:59:42 -0700706 int textColor = ContrastColorUtil.ensureTextContrast(
Kenny Guy14d035c2018-05-02 19:10:36 +0100707 dark ? mDefaultTextColorDarkBg : mDefaultTextColor,
708 backgroundColor | 0xff000000, dark);
Lucas Dupina291d192018-06-07 13:59:42 -0700709 int strokeColor = ContrastColorUtil.ensureContrast(
Kenny Guy14d035c2018-05-02 19:10:36 +0100710 mDefaultStrokeColor, backgroundColor | 0xff000000, dark, mMinStrokeContrast);
711 int rippleColor = dark ? mRippleColorDarkBg : mRippleColor;
712
713 int childCount = getChildCount();
714 for (int i = 0; i < childCount; i++) {
715 final Button child = (Button) getChildAt(i);
716 setColors(child, backgroundColor, strokeColor, textColor, rippleColor);
717 }
718 }
719
720 private void setColors(Button button, int backgroundColor, int strokeColor, int textColor,
721 int rippleColor) {
722 Drawable drawable = button.getBackground();
723 if (drawable instanceof RippleDrawable) {
724 // Mutate in case other notifications are using this drawable.
725 drawable = drawable.mutate();
726 RippleDrawable ripple = (RippleDrawable) drawable;
727 ripple.setColor(ColorStateList.valueOf(rippleColor));
728 Drawable inset = ripple.getDrawable(0);
729 if (inset instanceof InsetDrawable) {
730 Drawable background = ((InsetDrawable) inset).getDrawable();
731 if (background instanceof GradientDrawable) {
732 GradientDrawable gradientDrawable = (GradientDrawable) background;
733 gradientDrawable.setColor(backgroundColor);
734 gradientDrawable.setStroke(mStrokeWidth, strokeColor);
735 }
736 }
737 button.setBackground(drawable);
738 }
739 button.setTextColor(textColor);
740 }
741
Gustav Senntoneab53682018-11-01 16:30:23 +0000742 private ActivityStarter getActivityStarter() {
743 if (mActivityStarter == null) {
744 mActivityStarter = Dependency.get(ActivityStarter.class);
745 }
746 return mActivityStarter;
747 }
748
Gustav Senntonb149a1a2018-11-20 17:25:50 +0000749 private enum SmartButtonType {
750 REPLY,
751 ACTION
752 }
753
Petr Cermak102431d2018-01-29 10:36:07 +0000754 @VisibleForTesting
755 static class LayoutParams extends ViewGroup.LayoutParams {
756
757 /** Button is not squeezed. */
758 private static final int SQUEEZE_STATUS_NONE = 0;
759
760 /**
761 * Button was successfully squeezed, but it might be un-squeezed later if the squeezing
762 * turns out to have been unnecessary (because there's still not enough space to add another
763 * button).
764 */
765 private static final int SQUEEZE_STATUS_PENDING = 1;
766
767 /** Button was successfully squeezed and it won't be un-squeezed. */
768 private static final int SQUEEZE_STATUS_SUCCESSFUL = 2;
769
770 /**
771 * Button wasn't successfully squeezed. The squeezing resulted in more than two lines of
772 * text or it didn't reduce the button's width at all. The button will have to be
773 * re-measured to use only one line of text.
774 */
775 private static final int SQUEEZE_STATUS_FAILED = 3;
776
777 private boolean show = false;
778 private int squeezeStatus = SQUEEZE_STATUS_NONE;
Gustav Senntonb149a1a2018-11-20 17:25:50 +0000779 private SmartButtonType buttonType = SmartButtonType.REPLY;
Petr Cermak102431d2018-01-29 10:36:07 +0000780
781 private LayoutParams(Context c, AttributeSet attrs) {
782 super(c, attrs);
783 }
784
785 private LayoutParams(int width, int height) {
786 super(width, height);
787 }
788
789 @VisibleForTesting
790 boolean isShown() {
791 return show;
792 }
793 }
Tony Mak29996702018-11-26 16:23:34 +0000794
795 /**
796 * Data class for smart replies.
797 */
798 public static class SmartReplies {
799 @NonNull
800 public final RemoteInput remoteInput;
801 @NonNull
802 public final PendingIntent pendingIntent;
803 @NonNull
804 public final CharSequence[] choices;
805 public final boolean fromAssistant;
806
807 public SmartReplies(CharSequence[] choices, RemoteInput remoteInput,
808 PendingIntent pendingIntent, boolean fromAssistant) {
809 this.choices = choices;
810 this.remoteInput = remoteInput;
811 this.pendingIntent = pendingIntent;
812 this.fromAssistant = fromAssistant;
813 }
814 }
815
816
817 /**
818 * Data class for smart actions.
819 */
820 public static class SmartActions {
821 @NonNull
822 public final List<Notification.Action> actions;
823 public final boolean fromAssistant;
824
825 public SmartActions(List<Notification.Action> actions, boolean fromAssistant) {
826 this.actions = actions;
827 this.fromAssistant = fromAssistant;
828 }
829 }
Petr Cermaked7429c2017-12-18 19:38:04 +0000830}