blob: 790135fc03ca06b6d61e439f9f2d4e41e06215c0 [file] [log] [blame]
Petr Cermaked7429c2017-12-18 19:38:04 +00001package com.android.systemui.statusbar.policy;
2
3import android.app.PendingIntent;
4import android.app.RemoteInput;
5import android.content.Context;
6import android.content.Intent;
Petr Cermak102431d2018-01-29 10:36:07 +00007import android.content.res.TypedArray;
8import android.graphics.Canvas;
9import android.graphics.drawable.GradientDrawable;
10import android.graphics.drawable.RippleDrawable;
Petr Cermaked7429c2017-12-18 19:38:04 +000011import android.os.Bundle;
Petr Cermak102431d2018-01-29 10:36:07 +000012import android.text.Layout;
13import android.text.TextPaint;
14import android.text.method.TransformationMethod;
Petr Cermaked7429c2017-12-18 19:38:04 +000015import android.util.AttributeSet;
16import android.util.Log;
17import android.view.LayoutInflater;
Petr Cermak102431d2018-01-29 10:36:07 +000018import android.view.View;
Petr Cermaked7429c2017-12-18 19:38:04 +000019import android.view.ViewGroup;
20import android.widget.Button;
Petr Cermaked7429c2017-12-18 19:38:04 +000021
Petr Cermak102431d2018-01-29 10:36:07 +000022import com.android.internal.annotations.VisibleForTesting;
Petr Cermak10011fa2018-02-05 19:00:54 +000023import com.android.systemui.Dependency;
Petr Cermaked7429c2017-12-18 19:38:04 +000024import com.android.systemui.R;
25
Petr Cermak102431d2018-01-29 10:36:07 +000026import java.text.BreakIterator;
27import java.util.Comparator;
28import java.util.PriorityQueue;
29
Petr Cermaked7429c2017-12-18 19:38:04 +000030/** View which displays smart reply buttons in notifications. */
Petr Cermak102431d2018-01-29 10:36:07 +000031public class SmartReplyView extends ViewGroup {
Petr Cermaked7429c2017-12-18 19:38:04 +000032
33 private static final String TAG = "SmartReplyView";
34
Petr Cermak102431d2018-01-29 10:36:07 +000035 private static final int MEASURE_SPEC_ANY_WIDTH =
36 MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
37
38 private static final Comparator<View> DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR =
39 (v1, v2) -> ((v2.getMeasuredWidth() - v2.getPaddingLeft() - v2.getPaddingRight())
40 - (v1.getMeasuredWidth() - v1.getPaddingLeft() - v1.getPaddingRight()));
41
42 private static final int SQUEEZE_FAILED = -1;
43
Petr Cermak10011fa2018-02-05 19:00:54 +000044 private final SmartReplyConstants mConstants;
45
Petr Cermak102431d2018-01-29 10:36:07 +000046 /** Spacing to be applied between views. */
47 private final int mSpacing;
48
49 /** Horizontal padding of smart reply buttons if all of them use only one line of text. */
50 private final int mSingleLineButtonPaddingHorizontal;
51
52 /** Horizontal padding of smart reply buttons if at least one of them uses two lines of text. */
53 private final int mDoubleLineButtonPaddingHorizontal;
54
55 /** Increase in width of a smart reply button as a result of using two lines instead of one. */
56 private final int mSingleToDoubleLineButtonWidthIncrease;
57
58 private final BreakIterator mBreakIterator;
59
60 private PriorityQueue<Button> mCandidateButtonQueueForSqueezing;
61
Petr Cermaked7429c2017-12-18 19:38:04 +000062 public SmartReplyView(Context context, AttributeSet attrs) {
63 super(context, attrs);
Petr Cermak10011fa2018-02-05 19:00:54 +000064 mConstants = Dependency.get(SmartReplyConstants.class);
Petr Cermak102431d2018-01-29 10:36:07 +000065
66 int spacing = 0;
67 int singleLineButtonPaddingHorizontal = 0;
68 int doubleLineButtonPaddingHorizontal = 0;
69
70 final TypedArray arr = context.obtainStyledAttributes(attrs, R.styleable.SmartReplyView,
71 0, 0);
72 final int length = arr.getIndexCount();
73 for (int i = 0; i < length; i++) {
74 int attr = arr.getIndex(i);
75 switch (attr) {
76 case R.styleable.SmartReplyView_spacing:
77 spacing = arr.getDimensionPixelSize(i, 0);
78 break;
79 case R.styleable.SmartReplyView_singleLineButtonPaddingHorizontal:
80 singleLineButtonPaddingHorizontal = arr.getDimensionPixelSize(i, 0);
81 break;
82 case R.styleable.SmartReplyView_doubleLineButtonPaddingHorizontal:
83 doubleLineButtonPaddingHorizontal = arr.getDimensionPixelSize(i, 0);
84 break;
85 }
86 }
87 arr.recycle();
88
89 mSpacing = spacing;
90 mSingleLineButtonPaddingHorizontal = singleLineButtonPaddingHorizontal;
91 mDoubleLineButtonPaddingHorizontal = doubleLineButtonPaddingHorizontal;
92 mSingleToDoubleLineButtonWidthIncrease =
93 2 * (doubleLineButtonPaddingHorizontal - singleLineButtonPaddingHorizontal);
94
95 mBreakIterator = BreakIterator.getLineInstance();
96 reallocateCandidateButtonQueueForSqueezing();
97 }
98
99 private void reallocateCandidateButtonQueueForSqueezing() {
100 // Instead of clearing the priority queue, we re-allocate so that it would fit all buttons
101 // exactly. This avoids (1) wasting memory because PriorityQueue never shrinks and
102 // (2) growing in onMeasure.
103 // The constructor throws an IllegalArgument exception if initial capacity is less than 1.
104 mCandidateButtonQueueForSqueezing = new PriorityQueue<>(
105 Math.max(getChildCount(), 1), DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR);
Petr Cermaked7429c2017-12-18 19:38:04 +0000106 }
107
108 public void setRepliesFromRemoteInput(RemoteInput remoteInput, PendingIntent pendingIntent) {
109 removeAllViews();
110 if (remoteInput != null && pendingIntent != null) {
111 CharSequence[] choices = remoteInput.getChoices();
112 if (choices != null) {
113 for (CharSequence choice : choices) {
114 Button replyButton = inflateReplyButton(
115 getContext(), this, choice, remoteInput, pendingIntent);
116 addView(replyButton);
117 }
118 }
119 }
Petr Cermak102431d2018-01-29 10:36:07 +0000120 reallocateCandidateButtonQueueForSqueezing();
Petr Cermaked7429c2017-12-18 19:38:04 +0000121 }
122
123 public static SmartReplyView inflate(Context context, ViewGroup root) {
124 return (SmartReplyView)
125 LayoutInflater.from(context).inflate(R.layout.smart_reply_view, root, false);
126 }
127
Petr Cermak102431d2018-01-29 10:36:07 +0000128 @VisibleForTesting
129 static Button inflateReplyButton(Context context, ViewGroup root, CharSequence choice,
Petr Cermaked7429c2017-12-18 19:38:04 +0000130 RemoteInput remoteInput, PendingIntent pendingIntent) {
131 Button b = (Button) LayoutInflater.from(context).inflate(
132 R.layout.smart_reply_button, root, false);
133 b.setText(choice);
134 b.setOnClickListener(view -> {
135 Bundle results = new Bundle();
136 results.putString(remoteInput.getResultKey(), choice.toString());
137 Intent intent = new Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
138 RemoteInput.addResultsToIntent(new RemoteInput[]{remoteInput}, intent, results);
Petr Cermak9a3380c2018-01-19 15:00:24 +0000139 RemoteInput.setResultsSource(intent, RemoteInput.SOURCE_CHOICE);
Petr Cermaked7429c2017-12-18 19:38:04 +0000140 try {
141 pendingIntent.send(context, 0, intent);
142 } catch (PendingIntent.CanceledException e) {
143 Log.w(TAG, "Unable to send smart reply", e);
144 }
145 });
146 return b;
147 }
Petr Cermak102431d2018-01-29 10:36:07 +0000148
149 @Override
150 public LayoutParams generateLayoutParams(AttributeSet attrs) {
151 return new LayoutParams(mContext, attrs);
152 }
153
154 @Override
155 protected LayoutParams generateDefaultLayoutParams() {
156 return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
157 }
158
159 @Override
160 protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams params) {
161 return new LayoutParams(params.width, params.height);
162 }
163
164 @Override
165 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
166 final int targetWidth = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED
167 ? Integer.MAX_VALUE : MeasureSpec.getSize(widthMeasureSpec);
168
169 // Mark all buttons as hidden and un-squeezed.
170 resetButtonsLayoutParams();
171
172 if (!mCandidateButtonQueueForSqueezing.isEmpty()) {
173 Log.wtf(TAG, "Single line button queue leaked between onMeasure calls");
174 mCandidateButtonQueueForSqueezing.clear();
175 }
176
177 int measuredWidth = mPaddingLeft + mPaddingRight;
178 int maxChildHeight = 0;
179 int displayedChildCount = 0;
180 int buttonPaddingHorizontal = mSingleLineButtonPaddingHorizontal;
181
182 final int childCount = getChildCount();
183 for (int i = 0; i < childCount; i++) {
184 final View child = getChildAt(i);
185 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
186 if (child.getVisibility() != View.VISIBLE || !(child instanceof Button)) {
187 continue;
188 }
189
190 child.setPadding(buttonPaddingHorizontal, child.getPaddingTop(),
191 buttonPaddingHorizontal, child.getPaddingBottom());
192 child.measure(MEASURE_SPEC_ANY_WIDTH, heightMeasureSpec);
193
194 final int lineCount = ((Button) child).getLineCount();
195 if (lineCount < 1 || lineCount > 2) {
196 // If smart reply has no text, or more than two lines, then don't show it.
197 continue;
198 }
199
200 if (lineCount == 1) {
201 mCandidateButtonQueueForSqueezing.add((Button) child);
202 }
203
204 // Remember the current measurements in case the current button doesn't fit in.
205 final int originalMaxChildHeight = maxChildHeight;
206 final int originalMeasuredWidth = measuredWidth;
207 final int originalButtonPaddingHorizontal = buttonPaddingHorizontal;
208
209 final int spacing = displayedChildCount == 0 ? 0 : mSpacing;
210 final int childWidth = child.getMeasuredWidth();
211 final int childHeight = child.getMeasuredHeight();
212 measuredWidth += spacing + childWidth;
213 maxChildHeight = Math.max(maxChildHeight, childHeight);
214
215 // Do we need to increase the number of lines in smart reply buttons to two?
216 final boolean increaseToTwoLines =
217 buttonPaddingHorizontal == mSingleLineButtonPaddingHorizontal
218 && (lineCount == 2 || measuredWidth > targetWidth);
219 if (increaseToTwoLines) {
220 measuredWidth += (displayedChildCount + 1) * mSingleToDoubleLineButtonWidthIncrease;
221 buttonPaddingHorizontal = mDoubleLineButtonPaddingHorizontal;
222 }
223
224 // If the last button doesn't fit into the remaining width, try squeezing preceding
225 // smart reply buttons.
226 if (measuredWidth > targetWidth) {
227 // Keep squeezing preceding and current smart reply buttons until they all fit.
228 while (measuredWidth > targetWidth
229 && !mCandidateButtonQueueForSqueezing.isEmpty()) {
230 final Button candidate = mCandidateButtonQueueForSqueezing.poll();
231 final int squeezeReduction = squeezeButton(candidate, heightMeasureSpec);
232 if (squeezeReduction != SQUEEZE_FAILED) {
233 maxChildHeight = Math.max(maxChildHeight, candidate.getMeasuredHeight());
234 measuredWidth -= squeezeReduction;
235 }
236 }
237
238 // If the current button still doesn't fit after squeezing all buttons, undo the
239 // last squeezing round.
240 if (measuredWidth > targetWidth) {
241 measuredWidth = originalMeasuredWidth;
242 maxChildHeight = originalMaxChildHeight;
243 buttonPaddingHorizontal = originalButtonPaddingHorizontal;
244
245 // Mark all buttons from the last squeezing round as "failed to squeeze", so
246 // that they're re-measured without squeezing later.
247 markButtonsWithPendingSqueezeStatusAs(LayoutParams.SQUEEZE_STATUS_FAILED, i);
248
249 // The current button doesn't fit, so there's no point in measuring further
250 // buttons.
251 break;
252 }
253
254 // The current button fits, so mark all squeezed buttons as "successfully squeezed"
255 // to prevent them from being un-squeezed in a subsequent squeezing round.
256 markButtonsWithPendingSqueezeStatusAs(LayoutParams.SQUEEZE_STATUS_SUCCESSFUL, i);
257 }
258
259 lp.show = true;
260 displayedChildCount++;
261 }
262
263 // We're done squeezing buttons, so we can clear the priority queue.
264 mCandidateButtonQueueForSqueezing.clear();
265
266 // Finally, we need to update corner radius and re-measure some buttons.
267 updateCornerRadiusAndRemeasureButtonsIfNecessary(buttonPaddingHorizontal, maxChildHeight);
268
269 setMeasuredDimension(
270 resolveSize(Math.max(getSuggestedMinimumWidth(), measuredWidth), widthMeasureSpec),
271 resolveSize(Math.max(getSuggestedMinimumHeight(),
272 mPaddingTop + maxChildHeight + mPaddingBottom), heightMeasureSpec));
273 }
274
275 private void resetButtonsLayoutParams() {
276 final int childCount = getChildCount();
277 for (int i = 0; i < childCount; i++) {
278 final View child = getChildAt(i);
279 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
280 lp.show = false;
281 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_NONE;
282 }
283 }
284
285 private int squeezeButton(Button button, int heightMeasureSpec) {
286 final int estimatedOptimalTextWidth = estimateOptimalSqueezedButtonTextWidth(button);
287 if (estimatedOptimalTextWidth == SQUEEZE_FAILED) {
288 return SQUEEZE_FAILED;
289 }
290 return squeezeButtonToTextWidth(button, heightMeasureSpec, estimatedOptimalTextWidth);
291 }
292
293 private int estimateOptimalSqueezedButtonTextWidth(Button button) {
294 // Find a line-break point in the middle of the smart reply button text.
295 final String rawText = button.getText().toString();
296
297 // The button sometimes has a transformation affecting text layout (e.g. all caps).
298 final TransformationMethod transformation = button.getTransformationMethod();
299 final String text = transformation == null ?
300 rawText : transformation.getTransformation(rawText, button).toString();
301 final int length = text.length();
302 mBreakIterator.setText(text);
303
304 if (mBreakIterator.preceding(length / 2) == BreakIterator.DONE) {
305 if (mBreakIterator.next() == BreakIterator.DONE) {
306 // Can't find a single possible line break in either direction.
307 return SQUEEZE_FAILED;
308 }
309 }
310
311 final TextPaint paint = button.getPaint();
312 final int initialPosition = mBreakIterator.current();
313 final float initialLeftTextWidth = Layout.getDesiredWidth(text, 0, initialPosition, paint);
314 final float initialRightTextWidth =
315 Layout.getDesiredWidth(text, initialPosition, length, paint);
316 float optimalTextWidth = Math.max(initialLeftTextWidth, initialRightTextWidth);
317
318 if (initialLeftTextWidth != initialRightTextWidth) {
319 // See if there's a better line-break point (leading to a more narrow button) in
320 // either left or right direction.
321 final boolean moveLeft = initialLeftTextWidth > initialRightTextWidth;
322 final int maxSqueezeRemeasureAttempts = mConstants.getMaxSqueezeRemeasureAttempts();
323 for (int i = 0; i < maxSqueezeRemeasureAttempts; i++) {
324 final int newPosition =
325 moveLeft ? mBreakIterator.previous() : mBreakIterator.next();
326 if (newPosition == BreakIterator.DONE) {
327 break;
328 }
329
330 final float newLeftTextWidth = Layout.getDesiredWidth(text, 0, newPosition, paint);
331 final float newRightTextWidth =
332 Layout.getDesiredWidth(text, newPosition, length, paint);
333 final float newOptimalTextWidth = Math.max(newLeftTextWidth, newRightTextWidth);
334 if (newOptimalTextWidth < optimalTextWidth) {
335 optimalTextWidth = newOptimalTextWidth;
336 } else {
337 break;
338 }
339
340 boolean tooFar = moveLeft
341 ? newLeftTextWidth <= newRightTextWidth
342 : newLeftTextWidth >= newRightTextWidth;
343 if (tooFar) {
344 break;
345 }
346 }
347 }
348
349 return (int) Math.ceil(optimalTextWidth);
350 }
351
352 private int squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth) {
353 int oldWidth = button.getMeasuredWidth();
354 if (button.getPaddingLeft() != mDoubleLineButtonPaddingHorizontal) {
355 // Correct for the fact that the button was laid out with single-line horizontal
356 // padding.
357 oldWidth += mSingleToDoubleLineButtonWidthIncrease;
358 }
359
360 // Re-measure the squeezed smart reply button.
361 button.setPadding(mDoubleLineButtonPaddingHorizontal, button.getPaddingTop(),
362 mDoubleLineButtonPaddingHorizontal, button.getPaddingBottom());
363 final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(
364 2 * mDoubleLineButtonPaddingHorizontal + textWidth, MeasureSpec.AT_MOST);
365 button.measure(widthMeasureSpec, heightMeasureSpec);
366
367 final int newWidth = button.getMeasuredWidth();
368
369 final LayoutParams lp = (LayoutParams) button.getLayoutParams();
370 if (button.getLineCount() > 2 || newWidth >= oldWidth) {
371 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_FAILED;
372 return SQUEEZE_FAILED;
373 } else {
374 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_PENDING;
375 return oldWidth - newWidth;
376 }
377 }
378
379 private void updateCornerRadiusAndRemeasureButtonsIfNecessary(
380 int buttonPaddingHorizontal, int maxChildHeight) {
381 final float cornerRadius = ((float) maxChildHeight) / 2;
382 final int maxChildHeightMeasure =
383 MeasureSpec.makeMeasureSpec(maxChildHeight, MeasureSpec.EXACTLY);
384
385 final int childCount = getChildCount();
386 for (int i = 0; i < childCount; i++) {
387 final View child = getChildAt(i);
388 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
389 if (!lp.show) {
390 continue;
391 }
392
393 // Update corner radius.
394 GradientDrawable backgroundDrawable =
395 (GradientDrawable) ((RippleDrawable) child.getBackground()).getDrawable(0);
396 backgroundDrawable.setCornerRadius(cornerRadius);
397
398 boolean requiresNewMeasure = false;
399 int newWidth = child.getMeasuredWidth();
400
401 // Re-measure reason 1: The button needs to be un-squeezed (either because it resulted
402 // in more than two lines or because it was unnecessary).
403 if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_FAILED) {
404 requiresNewMeasure = true;
405 newWidth = Integer.MAX_VALUE;
406 }
407
408 // Re-measure reason 2: The button's horizontal padding is incorrect (because it was
409 // measured with the wrong number of lines).
410 if (child.getPaddingLeft() != buttonPaddingHorizontal) {
411 requiresNewMeasure = true;
412 if (buttonPaddingHorizontal == mSingleLineButtonPaddingHorizontal) {
413 // Decrease padding (2->1 line).
414 newWidth -= mSingleToDoubleLineButtonWidthIncrease;
415 } else {
416 // Increase padding (1->2 lines).
417 newWidth += mSingleToDoubleLineButtonWidthIncrease;
418 }
419 child.setPadding(buttonPaddingHorizontal, child.getPaddingTop(),
420 buttonPaddingHorizontal, child.getPaddingBottom());
421 }
422
423 // Re-measure reason 3: The button's height is less than the max height of all buttons
424 // (all should have the same height).
425 if (child.getMeasuredHeight() != maxChildHeight) {
426 requiresNewMeasure = true;
427 }
428
429 if (requiresNewMeasure) {
430 child.measure(MeasureSpec.makeMeasureSpec(newWidth, MeasureSpec.AT_MOST),
431 maxChildHeightMeasure);
432 }
433 }
434 }
435
436 private void markButtonsWithPendingSqueezeStatusAs(int squeezeStatus, int maxChildIndex) {
437 for (int i = 0; i <= maxChildIndex; i++) {
438 final View child = getChildAt(i);
439 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
440 if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_PENDING) {
441 lp.squeezeStatus = squeezeStatus;
442 }
443 }
444 }
445
446 @Override
447 protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
448 final boolean isRtl = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
449
450 final int width = right - left;
451 int position = isRtl ? width - mPaddingRight : mPaddingLeft;
452
453 final int childCount = getChildCount();
454 for (int i = 0; i < childCount; i++) {
455 final View child = getChildAt(i);
456 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
457 if (!lp.show) {
458 continue;
459 }
460
461 final int childWidth = child.getMeasuredWidth();
462 final int childHeight = child.getMeasuredHeight();
463 final int childLeft = isRtl ? position - childWidth : position;
464 child.layout(childLeft, 0, childLeft + childWidth, childHeight);
465
466 final int childWidthWithSpacing = childWidth + mSpacing;
467 if (isRtl) {
468 position -= childWidthWithSpacing;
469 } else {
470 position += childWidthWithSpacing;
471 }
472 }
473 }
474
475 @Override
476 protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
477 final LayoutParams lp = (LayoutParams) child.getLayoutParams();
478 return lp.show && super.drawChild(canvas, child, drawingTime);
479 }
480
481 @VisibleForTesting
482 static class LayoutParams extends ViewGroup.LayoutParams {
483
484 /** Button is not squeezed. */
485 private static final int SQUEEZE_STATUS_NONE = 0;
486
487 /**
488 * Button was successfully squeezed, but it might be un-squeezed later if the squeezing
489 * turns out to have been unnecessary (because there's still not enough space to add another
490 * button).
491 */
492 private static final int SQUEEZE_STATUS_PENDING = 1;
493
494 /** Button was successfully squeezed and it won't be un-squeezed. */
495 private static final int SQUEEZE_STATUS_SUCCESSFUL = 2;
496
497 /**
498 * Button wasn't successfully squeezed. The squeezing resulted in more than two lines of
499 * text or it didn't reduce the button's width at all. The button will have to be
500 * re-measured to use only one line of text.
501 */
502 private static final int SQUEEZE_STATUS_FAILED = 3;
503
504 private boolean show = false;
505 private int squeezeStatus = SQUEEZE_STATUS_NONE;
506
507 private LayoutParams(Context c, AttributeSet attrs) {
508 super(c, attrs);
509 }
510
511 private LayoutParams(int width, int height) {
512 super(width, height);
513 }
514
515 @VisibleForTesting
516 boolean isShown() {
517 return show;
518 }
519 }
Petr Cermaked7429c2017-12-18 19:38:04 +0000520}