1 package com.android.systemui.statusbar.policy; 2 3 import android.annotation.ColorInt; 4 import android.annotation.NonNull; 5 import android.app.Notification; 6 import android.app.PendingIntent; 7 import android.app.RemoteInput; 8 import android.content.Context; 9 import android.content.res.ColorStateList; 10 import android.content.res.TypedArray; 11 import android.graphics.Canvas; 12 import android.graphics.Color; 13 import android.graphics.drawable.Drawable; 14 import android.graphics.drawable.GradientDrawable; 15 import android.graphics.drawable.InsetDrawable; 16 import android.graphics.drawable.RippleDrawable; 17 import android.text.Layout; 18 import android.text.TextPaint; 19 import android.text.method.TransformationMethod; 20 import android.util.AttributeSet; 21 import android.util.Log; 22 import android.view.LayoutInflater; 23 import android.view.View; 24 import android.view.ViewGroup; 25 import android.widget.Button; 26 import android.widget.TextView; 27 28 import com.android.internal.annotations.VisibleForTesting; 29 import com.android.internal.util.ContrastColorUtil; 30 import com.android.systemui.R; 31 import com.android.systemui.statusbar.notification.NotificationUtils; 32 33 import java.text.BreakIterator; 34 import java.util.ArrayList; 35 import java.util.Comparator; 36 import java.util.List; 37 import java.util.PriorityQueue; 38 39 /** View which displays smart reply and smart actions buttons in notifications. */ 40 public class SmartReplyView extends ViewGroup { 41 42 private static final String TAG = "SmartReplyView"; 43 44 private static final int MEASURE_SPEC_ANY_LENGTH = 45 MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); 46 47 private static final Comparator<View> DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR = 48 (v1, v2) -> ((v2.getMeasuredWidth() - v2.getPaddingLeft() - v2.getPaddingRight()) 49 - (v1.getMeasuredWidth() - v1.getPaddingLeft() - v1.getPaddingRight())); 50 51 private static final int SQUEEZE_FAILED = -1; 52 53 /** 54 * The upper bound for the height of this view in pixels. Notifications are automatically 55 * recreated on density or font size changes so caching this should be fine. 56 */ 57 private final int mHeightUpperLimit; 58 59 /** Spacing to be applied between views. */ 60 private final int mSpacing; 61 62 private final BreakIterator mBreakIterator; 63 64 private PriorityQueue<Button> mCandidateButtonQueueForSqueezing; 65 66 private View mSmartReplyContainer; 67 68 /** 69 * Whether the smart replies in this view were generated by the notification assistant. If not 70 * they're provided by the app. 71 */ 72 private boolean mSmartRepliesGeneratedByAssistant = false; 73 74 @ColorInt private int mCurrentBackgroundColor; 75 @ColorInt private final int mDefaultBackgroundColor; 76 @ColorInt private final int mDefaultStrokeColor; 77 @ColorInt private final int mDefaultTextColor; 78 @ColorInt private final int mDefaultTextColorDarkBg; 79 @ColorInt private final int mRippleColorDarkBg; 80 @ColorInt private final int mRippleColor; 81 private final int mStrokeWidth; 82 private final double mMinStrokeContrast; 83 84 @ColorInt private int mCurrentStrokeColor; 85 @ColorInt private int mCurrentTextColor; 86 @ColorInt private int mCurrentRippleColor; 87 private boolean mCurrentColorized; 88 private int mMaxSqueezeRemeasureAttempts; 89 private int mMaxNumActions; 90 private int mMinNumSystemGeneratedReplies; 91 SmartReplyView(Context context, AttributeSet attrs)92 public SmartReplyView(Context context, AttributeSet attrs) { 93 super(context, attrs); 94 95 mHeightUpperLimit = NotificationUtils.getFontScaledHeight(mContext, 96 R.dimen.smart_reply_button_max_height); 97 98 mDefaultBackgroundColor = context.getColor(R.color.smart_reply_button_background); 99 mDefaultTextColor = mContext.getColor(R.color.smart_reply_button_text); 100 mDefaultTextColorDarkBg = mContext.getColor(R.color.smart_reply_button_text_dark_bg); 101 mDefaultStrokeColor = mContext.getColor(R.color.smart_reply_button_stroke); 102 mRippleColor = mContext.getColor(R.color.notification_ripple_untinted_color); 103 mRippleColorDarkBg = Color.argb(Color.alpha(mRippleColor), 104 255 /* red */, 255 /* green */, 255 /* blue */); 105 mMinStrokeContrast = ContrastColorUtil.calculateContrast(mDefaultStrokeColor, 106 mDefaultBackgroundColor); 107 108 int spacing = 0; 109 int strokeWidth = 0; 110 111 final TypedArray arr = context.obtainStyledAttributes(attrs, R.styleable.SmartReplyView, 112 0, 0); 113 final int length = arr.getIndexCount(); 114 for (int i = 0; i < length; i++) { 115 int attr = arr.getIndex(i); 116 if (attr == R.styleable.SmartReplyView_spacing) { 117 spacing = arr.getDimensionPixelSize(i, 0); 118 } else if (attr == R.styleable.SmartReplyView_buttonStrokeWidth) { 119 strokeWidth = arr.getDimensionPixelSize(i, 0); 120 } 121 } 122 arr.recycle(); 123 124 mStrokeWidth = strokeWidth; 125 mSpacing = spacing; 126 127 mBreakIterator = BreakIterator.getLineInstance(); 128 129 setBackgroundTintColor(mDefaultBackgroundColor, false /* colorized */); 130 reallocateCandidateButtonQueueForSqueezing(); 131 } 132 133 /** 134 * Inflate an instance of this class. 135 */ inflate(Context context, SmartReplyConstants constants)136 public static SmartReplyView inflate(Context context, SmartReplyConstants constants) { 137 SmartReplyView view = (SmartReplyView) LayoutInflater.from(context).inflate( 138 R.layout.smart_reply_view, null /* root */); 139 view.setMaxNumActions(constants.getMaxNumActions()); 140 view.setMaxSqueezeRemeasureAttempts(constants.getMaxSqueezeRemeasureAttempts()); 141 view.setMinNumSystemGeneratedReplies(constants.getMinNumSystemGeneratedReplies()); 142 return view; 143 } 144 145 /** 146 * Returns an upper bound for the height of this view in pixels. This method is intended to be 147 * invoked before onMeasure, so it doesn't do any analysis on the contents of the buttons. 148 */ getHeightUpperLimit()149 public int getHeightUpperLimit() { 150 return mHeightUpperLimit; 151 } 152 reallocateCandidateButtonQueueForSqueezing()153 private void reallocateCandidateButtonQueueForSqueezing() { 154 // Instead of clearing the priority queue, we re-allocate so that it would fit all buttons 155 // exactly. This avoids (1) wasting memory because PriorityQueue never shrinks and 156 // (2) growing in onMeasure. 157 // The constructor throws an IllegalArgument exception if initial capacity is less than 1. 158 mCandidateButtonQueueForSqueezing = new PriorityQueue<>( 159 Math.max(getChildCount(), 1), DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR); 160 } 161 162 /** 163 * Reset the smart suggestions view to allow adding new replies and actions. 164 */ resetSmartSuggestions(View newSmartReplyContainer)165 public void resetSmartSuggestions(View newSmartReplyContainer) { 166 mSmartReplyContainer = newSmartReplyContainer; 167 removeAllViews(); 168 setBackgroundTintColor(mDefaultBackgroundColor, false /* colorized */); 169 } 170 171 /** Add buttons to the {@link SmartReplyView} */ addPreInflatedButtons(List<Button> smartSuggestionButtons)172 public void addPreInflatedButtons(List<Button> smartSuggestionButtons) { 173 for (Button button : smartSuggestionButtons) { 174 addView(button); 175 setButtonColors(button); 176 } 177 reallocateCandidateButtonQueueForSqueezing(); 178 } 179 setMaxNumActions(int maxNumActions)180 public void setMaxNumActions(int maxNumActions) { 181 mMaxNumActions = maxNumActions; 182 } 183 setMinNumSystemGeneratedReplies(int minNumSystemGeneratedReplies)184 public void setMinNumSystemGeneratedReplies(int minNumSystemGeneratedReplies) { 185 mMinNumSystemGeneratedReplies = minNumSystemGeneratedReplies; 186 } 187 setMaxSqueezeRemeasureAttempts(int maxSqueezeRemeasureAttempts)188 public void setMaxSqueezeRemeasureAttempts(int maxSqueezeRemeasureAttempts) { 189 mMaxSqueezeRemeasureAttempts = maxSqueezeRemeasureAttempts; 190 } 191 192 @Override generateLayoutParams(AttributeSet attrs)193 public LayoutParams generateLayoutParams(AttributeSet attrs) { 194 return new LayoutParams(mContext, attrs); 195 } 196 197 @Override generateDefaultLayoutParams()198 protected LayoutParams generateDefaultLayoutParams() { 199 return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); 200 } 201 202 @Override generateLayoutParams(ViewGroup.LayoutParams params)203 protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams params) { 204 return new LayoutParams(params.width, params.height); 205 } 206 clearLayoutLineCount(View view)207 private void clearLayoutLineCount(View view) { 208 if (view instanceof TextView) { 209 ((TextView) view).nullLayouts(); 210 } 211 } 212 213 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)214 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 215 final int targetWidth = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED 216 ? Integer.MAX_VALUE : MeasureSpec.getSize(widthMeasureSpec); 217 218 // Mark all buttons as hidden and un-squeezed. 219 resetButtonsLayoutParams(); 220 221 if (!mCandidateButtonQueueForSqueezing.isEmpty()) { 222 Log.wtf(TAG, "Single line button queue leaked between onMeasure calls"); 223 mCandidateButtonQueueForSqueezing.clear(); 224 } 225 226 SmartSuggestionMeasures accumulatedMeasures = new SmartSuggestionMeasures( 227 mPaddingLeft + mPaddingRight, 228 0 /* maxChildHeight */); 229 int displayedChildCount = 0; 230 231 // Set up a list of suggestions where actions come before replies. Note that the Buttons 232 // themselves have already been added to the view hierarchy in an order such that Smart 233 // Replies are shown before Smart Actions. The order of the list below determines which 234 // suggestions will be shown at all - only the first X elements are shown (where X depends 235 // on how much space each suggestion button needs). 236 List<View> smartActions = filterActionsOrReplies(SmartButtonType.ACTION); 237 List<View> smartReplies = filterActionsOrReplies(SmartButtonType.REPLY); 238 List<View> smartSuggestions = new ArrayList<>(smartActions); 239 smartSuggestions.addAll(smartReplies); 240 List<View> coveredSuggestions = new ArrayList<>(); 241 242 // SmartSuggestionMeasures for all action buttons, this will be filled in when the first 243 // reply button is added. 244 SmartSuggestionMeasures actionsMeasures = null; 245 246 final int maxNumActions = mMaxNumActions; 247 int numShownActions = 0; 248 249 for (View child : smartSuggestions) { 250 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 251 if (maxNumActions != -1 // -1 means 'no limit' 252 && lp.mButtonType == SmartButtonType.ACTION 253 && numShownActions >= maxNumActions) { 254 // We've reached the maximum number of actions, don't add another one! 255 continue; 256 } 257 258 clearLayoutLineCount(child); 259 child.measure(MEASURE_SPEC_ANY_LENGTH, heightMeasureSpec); 260 261 coveredSuggestions.add(child); 262 263 final int lineCount = ((Button) child).getLineCount(); 264 if (lineCount < 1 || lineCount > 2) { 265 // If smart reply has no text, or more than two lines, then don't show it. 266 continue; 267 } 268 269 if (lineCount == 1) { 270 mCandidateButtonQueueForSqueezing.add((Button) child); 271 } 272 273 // Remember the current measurements in case the current button doesn't fit in. 274 SmartSuggestionMeasures originalMeasures = accumulatedMeasures.clone(); 275 if (actionsMeasures == null && lp.mButtonType == SmartButtonType.REPLY) { 276 // We've added all actions (we go through actions first), now add their 277 // measurements. 278 actionsMeasures = accumulatedMeasures.clone(); 279 } 280 281 final int spacing = displayedChildCount == 0 ? 0 : mSpacing; 282 final int childWidth = child.getMeasuredWidth(); 283 final int childHeight = child.getMeasuredHeight(); 284 accumulatedMeasures.mMeasuredWidth += spacing + childWidth; 285 accumulatedMeasures.mMaxChildHeight = 286 Math.max(accumulatedMeasures.mMaxChildHeight, childHeight); 287 288 // If the last button doesn't fit into the remaining width, try squeezing preceding 289 // smart reply buttons. 290 if (accumulatedMeasures.mMeasuredWidth > targetWidth) { 291 // Keep squeezing preceding and current smart reply buttons until they all fit. 292 while (accumulatedMeasures.mMeasuredWidth > targetWidth 293 && !mCandidateButtonQueueForSqueezing.isEmpty()) { 294 final Button candidate = mCandidateButtonQueueForSqueezing.poll(); 295 final int squeezeReduction = squeezeButton(candidate, heightMeasureSpec); 296 if (squeezeReduction != SQUEEZE_FAILED) { 297 accumulatedMeasures.mMaxChildHeight = 298 Math.max(accumulatedMeasures.mMaxChildHeight, 299 candidate.getMeasuredHeight()); 300 accumulatedMeasures.mMeasuredWidth -= squeezeReduction; 301 } 302 } 303 304 // If the current button still doesn't fit after squeezing all buttons, undo the 305 // last squeezing round. 306 if (accumulatedMeasures.mMeasuredWidth > targetWidth) { 307 accumulatedMeasures = originalMeasures; 308 309 // Mark all buttons from the last squeezing round as "failed to squeeze", so 310 // that they're re-measured without squeezing later. 311 markButtonsWithPendingSqueezeStatusAs( 312 LayoutParams.SQUEEZE_STATUS_FAILED, coveredSuggestions); 313 314 // The current button doesn't fit, keep on adding lower-priority buttons in case 315 // any of those fit. 316 continue; 317 } 318 319 // The current button fits, so mark all squeezed buttons as "successfully squeezed" 320 // to prevent them from being un-squeezed in a subsequent squeezing round. 321 markButtonsWithPendingSqueezeStatusAs( 322 LayoutParams.SQUEEZE_STATUS_SUCCESSFUL, coveredSuggestions); 323 } 324 325 lp.show = true; 326 displayedChildCount++; 327 if (lp.mButtonType == SmartButtonType.ACTION) { 328 numShownActions++; 329 } 330 } 331 332 if (mSmartRepliesGeneratedByAssistant) { 333 if (!gotEnoughSmartReplies(smartReplies)) { 334 // We don't have enough smart replies - hide all of them. 335 for (View smartReplyButton : smartReplies) { 336 final LayoutParams lp = (LayoutParams) smartReplyButton.getLayoutParams(); 337 lp.show = false; 338 } 339 // Reset our measures back to when we had only added actions (before adding 340 // replies). 341 accumulatedMeasures = actionsMeasures; 342 } 343 } 344 345 // We're done squeezing buttons, so we can clear the priority queue. 346 mCandidateButtonQueueForSqueezing.clear(); 347 348 // Finally, we need to re-measure some buttons. 349 remeasureButtonsIfNecessary(accumulatedMeasures.mMaxChildHeight); 350 351 int buttonHeight = Math.max(getSuggestedMinimumHeight(), mPaddingTop 352 + accumulatedMeasures.mMaxChildHeight + mPaddingBottom); 353 354 setMeasuredDimension( 355 resolveSize(Math.max(getSuggestedMinimumWidth(), 356 accumulatedMeasures.mMeasuredWidth), 357 widthMeasureSpec), 358 resolveSize(buttonHeight, heightMeasureSpec)); 359 } 360 361 // TODO: this should be replaced, and instead, setMinSystemGenerated... should be invoked 362 // with MAX_VALUE if mSmartRepliesGeneratedByAssistant would be false (essentially, this is a 363 // ViewModel decision, as opposed to a View decision) setSmartRepliesGeneratedByAssistant(boolean fromAssistant)364 void setSmartRepliesGeneratedByAssistant(boolean fromAssistant) { 365 mSmartRepliesGeneratedByAssistant = fromAssistant; 366 } 367 hideSmartSuggestions()368 void hideSmartSuggestions() { 369 if (mSmartReplyContainer != null) { 370 mSmartReplyContainer.setVisibility(View.GONE); 371 } 372 } 373 374 /** 375 * Fields we keep track of inside onMeasure() to correctly measure the SmartReplyView depending 376 * on which suggestions are added. 377 */ 378 private static class SmartSuggestionMeasures { 379 int mMeasuredWidth = -1; 380 int mMaxChildHeight = -1; 381 SmartSuggestionMeasures(int measuredWidth, int maxChildHeight)382 SmartSuggestionMeasures(int measuredWidth, int maxChildHeight) { 383 this.mMeasuredWidth = measuredWidth; 384 this.mMaxChildHeight = maxChildHeight; 385 } 386 clone()387 public SmartSuggestionMeasures clone() { 388 return new SmartSuggestionMeasures(mMeasuredWidth, mMaxChildHeight); 389 } 390 } 391 392 /** 393 * Returns whether our notification contains at least N smart replies (or 0) where N is 394 * determined by {@link SmartReplyConstants}. 395 */ 396 // TODO: we probably sholdn't make this deliberation in the View gotEnoughSmartReplies(List<View> smartReplies)397 private boolean gotEnoughSmartReplies(List<View> smartReplies) { 398 int numShownReplies = 0; 399 for (View smartReplyButton : smartReplies) { 400 final LayoutParams lp = (LayoutParams) smartReplyButton.getLayoutParams(); 401 if (lp.show) { 402 numShownReplies++; 403 } 404 } 405 if (numShownReplies == 0 || numShownReplies >= mMinNumSystemGeneratedReplies) { 406 // We have enough replies, yay! 407 return true; 408 } 409 return false; 410 } 411 filterActionsOrReplies(SmartButtonType buttonType)412 private List<View> filterActionsOrReplies(SmartButtonType buttonType) { 413 List<View> actions = new ArrayList<>(); 414 final int childCount = getChildCount(); 415 for (int i = 0; i < childCount; i++) { 416 final View child = getChildAt(i); 417 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 418 if (child.getVisibility() != View.VISIBLE || !(child instanceof Button)) { 419 continue; 420 } 421 if (lp.mButtonType == buttonType) { 422 actions.add(child); 423 } 424 } 425 return actions; 426 } 427 resetButtonsLayoutParams()428 private void resetButtonsLayoutParams() { 429 final int childCount = getChildCount(); 430 for (int i = 0; i < childCount; i++) { 431 final View child = getChildAt(i); 432 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 433 lp.show = false; 434 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_NONE; 435 } 436 } 437 squeezeButton(Button button, int heightMeasureSpec)438 private int squeezeButton(Button button, int heightMeasureSpec) { 439 final int estimatedOptimalTextWidth = estimateOptimalSqueezedButtonTextWidth(button); 440 if (estimatedOptimalTextWidth == SQUEEZE_FAILED) { 441 return SQUEEZE_FAILED; 442 } 443 return squeezeButtonToTextWidth(button, heightMeasureSpec, estimatedOptimalTextWidth); 444 } 445 estimateOptimalSqueezedButtonTextWidth(Button button)446 private int estimateOptimalSqueezedButtonTextWidth(Button button) { 447 // Find a line-break point in the middle of the smart reply button text. 448 final String rawText = button.getText().toString(); 449 450 // The button sometimes has a transformation affecting text layout (e.g. all caps). 451 final TransformationMethod transformation = button.getTransformationMethod(); 452 final String text = transformation == null ? 453 rawText : transformation.getTransformation(rawText, button).toString(); 454 final int length = text.length(); 455 mBreakIterator.setText(text); 456 457 if (mBreakIterator.preceding(length / 2) == BreakIterator.DONE) { 458 if (mBreakIterator.next() == BreakIterator.DONE) { 459 // Can't find a single possible line break in either direction. 460 return SQUEEZE_FAILED; 461 } 462 } 463 464 final TextPaint paint = button.getPaint(); 465 final int initialPosition = mBreakIterator.current(); 466 final float initialLeftTextWidth = Layout.getDesiredWidth(text, 0, initialPosition, paint); 467 final float initialRightTextWidth = 468 Layout.getDesiredWidth(text, initialPosition, length, paint); 469 float optimalTextWidth = Math.max(initialLeftTextWidth, initialRightTextWidth); 470 471 if (initialLeftTextWidth != initialRightTextWidth) { 472 // See if there's a better line-break point (leading to a more narrow button) in 473 // either left or right direction. 474 final boolean moveLeft = initialLeftTextWidth > initialRightTextWidth; 475 final int maxSqueezeRemeasureAttempts = mMaxSqueezeRemeasureAttempts; 476 for (int i = 0; i < maxSqueezeRemeasureAttempts; i++) { 477 final int newPosition = 478 moveLeft ? mBreakIterator.previous() : mBreakIterator.next(); 479 if (newPosition == BreakIterator.DONE) { 480 break; 481 } 482 483 final float newLeftTextWidth = Layout.getDesiredWidth(text, 0, newPosition, paint); 484 final float newRightTextWidth = 485 Layout.getDesiredWidth(text, newPosition, length, paint); 486 final float newOptimalTextWidth = Math.max(newLeftTextWidth, newRightTextWidth); 487 if (newOptimalTextWidth < optimalTextWidth) { 488 optimalTextWidth = newOptimalTextWidth; 489 } else { 490 break; 491 } 492 493 boolean tooFar = moveLeft 494 ? newLeftTextWidth <= newRightTextWidth 495 : newLeftTextWidth >= newRightTextWidth; 496 if (tooFar) { 497 break; 498 } 499 } 500 } 501 502 return (int) Math.ceil(optimalTextWidth); 503 } 504 505 /** 506 * Returns the combined width of the left drawable (the action icon) and the padding between the 507 * drawable and the button text. 508 */ getLeftCompoundDrawableWidthWithPadding(Button button)509 private int getLeftCompoundDrawableWidthWithPadding(Button button) { 510 Drawable[] drawables = button.getCompoundDrawables(); 511 Drawable leftDrawable = drawables[0]; 512 if (leftDrawable == null) return 0; 513 514 return leftDrawable.getBounds().width() + button.getCompoundDrawablePadding(); 515 } 516 squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth)517 private int squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth) { 518 int oldWidth = button.getMeasuredWidth(); 519 520 // Re-measure the squeezed smart reply button. 521 clearLayoutLineCount(button); 522 final int widthMeasureSpec = MeasureSpec.makeMeasureSpec( 523 button.getPaddingLeft() + button.getPaddingRight() + textWidth 524 + getLeftCompoundDrawableWidthWithPadding(button), MeasureSpec.AT_MOST); 525 button.measure(widthMeasureSpec, heightMeasureSpec); 526 527 final int newWidth = button.getMeasuredWidth(); 528 529 final LayoutParams lp = (LayoutParams) button.getLayoutParams(); 530 if (button.getLineCount() > 2 || newWidth >= oldWidth) { 531 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_FAILED; 532 return SQUEEZE_FAILED; 533 } else { 534 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_PENDING; 535 return oldWidth - newWidth; 536 } 537 } 538 remeasureButtonsIfNecessary(int maxChildHeight)539 private void remeasureButtonsIfNecessary(int maxChildHeight) { 540 final int maxChildHeightMeasure = 541 MeasureSpec.makeMeasureSpec(maxChildHeight, MeasureSpec.EXACTLY); 542 543 final int childCount = getChildCount(); 544 for (int i = 0; i < childCount; i++) { 545 final View child = getChildAt(i); 546 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 547 if (!lp.show) { 548 continue; 549 } 550 551 boolean requiresNewMeasure = false; 552 int newWidth = child.getMeasuredWidth(); 553 554 // Re-measure reason 1: The button needs to be un-squeezed (either because it resulted 555 // in more than two lines or because it was unnecessary). 556 if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_FAILED) { 557 requiresNewMeasure = true; 558 newWidth = Integer.MAX_VALUE; 559 } 560 561 // Re-measure reason 2: The button's height is less than the max height of all buttons 562 // (all should have the same height). 563 if (child.getMeasuredHeight() != maxChildHeight) { 564 requiresNewMeasure = true; 565 } 566 567 if (requiresNewMeasure) { 568 child.measure(MeasureSpec.makeMeasureSpec(newWidth, MeasureSpec.AT_MOST), 569 maxChildHeightMeasure); 570 } 571 } 572 } 573 markButtonsWithPendingSqueezeStatusAs( int squeezeStatus, List<View> coveredChildren)574 private void markButtonsWithPendingSqueezeStatusAs( 575 int squeezeStatus, List<View> coveredChildren) { 576 for (View child : coveredChildren) { 577 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 578 if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_PENDING) { 579 lp.squeezeStatus = squeezeStatus; 580 } 581 } 582 } 583 584 @Override onLayout(boolean changed, int left, int top, int right, int bottom)585 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 586 final boolean isRtl = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL; 587 588 final int width = right - left; 589 int position = isRtl ? width - mPaddingRight : mPaddingLeft; 590 591 final int childCount = getChildCount(); 592 for (int i = 0; i < childCount; i++) { 593 final View child = getChildAt(i); 594 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 595 if (!lp.show) { 596 continue; 597 } 598 599 final int childWidth = child.getMeasuredWidth(); 600 final int childHeight = child.getMeasuredHeight(); 601 final int childLeft = isRtl ? position - childWidth : position; 602 child.layout(childLeft, 0, childLeft + childWidth, childHeight); 603 604 final int childWidthWithSpacing = childWidth + mSpacing; 605 if (isRtl) { 606 position -= childWidthWithSpacing; 607 } else { 608 position += childWidthWithSpacing; 609 } 610 } 611 } 612 613 @Override drawChild(Canvas canvas, View child, long drawingTime)614 protected boolean drawChild(Canvas canvas, View child, long drawingTime) { 615 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 616 return lp.show && super.drawChild(canvas, child, drawingTime); 617 } 618 619 /** 620 * Set the current background color of the notification so that the smart reply buttons can 621 * match it, and calculate other colors (e.g. text, ripple, stroke) 622 */ setBackgroundTintColor(int backgroundColor, boolean colorized)623 public void setBackgroundTintColor(int backgroundColor, boolean colorized) { 624 if (backgroundColor == mCurrentBackgroundColor && colorized == mCurrentColorized) { 625 // Same color ignoring. 626 return; 627 } 628 mCurrentBackgroundColor = backgroundColor; 629 mCurrentColorized = colorized; 630 631 final boolean dark = !ContrastColorUtil.isColorLight(backgroundColor); 632 633 mCurrentTextColor = ContrastColorUtil.ensureTextContrast( 634 dark ? mDefaultTextColorDarkBg : mDefaultTextColor, 635 backgroundColor | 0xff000000, dark); 636 mCurrentStrokeColor = colorized ? mCurrentTextColor : ContrastColorUtil.ensureContrast( 637 mDefaultStrokeColor, backgroundColor | 0xff000000, dark, mMinStrokeContrast); 638 mCurrentRippleColor = dark ? mRippleColorDarkBg : mRippleColor; 639 640 int childCount = getChildCount(); 641 for (int i = 0; i < childCount; i++) { 642 setButtonColors((Button) getChildAt(i)); 643 } 644 } 645 setButtonColors(Button button)646 private void setButtonColors(Button button) { 647 Drawable drawable = button.getBackground(); 648 if (drawable instanceof RippleDrawable) { 649 // Mutate in case other notifications are using this drawable. 650 drawable = drawable.mutate(); 651 RippleDrawable ripple = (RippleDrawable) drawable; 652 ripple.setColor(ColorStateList.valueOf(mCurrentRippleColor)); 653 Drawable inset = ripple.getDrawable(0); 654 if (inset instanceof InsetDrawable) { 655 Drawable background = ((InsetDrawable) inset).getDrawable(); 656 if (background instanceof GradientDrawable) { 657 GradientDrawable gradientDrawable = (GradientDrawable) background; 658 gradientDrawable.setColor(mCurrentBackgroundColor); 659 gradientDrawable.setStroke(mStrokeWidth, mCurrentStrokeColor); 660 } 661 } 662 button.setBackground(drawable); 663 } 664 button.setTextColor(mCurrentTextColor); 665 } 666 667 enum SmartButtonType { 668 REPLY, 669 ACTION 670 } 671 672 @VisibleForTesting 673 static class LayoutParams extends ViewGroup.LayoutParams { 674 675 /** Button is not squeezed. */ 676 private static final int SQUEEZE_STATUS_NONE = 0; 677 678 /** 679 * Button was successfully squeezed, but it might be un-squeezed later if the squeezing 680 * turns out to have been unnecessary (because there's still not enough space to add another 681 * button). 682 */ 683 private static final int SQUEEZE_STATUS_PENDING = 1; 684 685 /** Button was successfully squeezed and it won't be un-squeezed. */ 686 private static final int SQUEEZE_STATUS_SUCCESSFUL = 2; 687 688 /** 689 * Button wasn't successfully squeezed. The squeezing resulted in more than two lines of 690 * text or it didn't reduce the button's width at all. The button will have to be 691 * re-measured to use only one line of text. 692 */ 693 private static final int SQUEEZE_STATUS_FAILED = 3; 694 695 private boolean show = false; 696 private int squeezeStatus = SQUEEZE_STATUS_NONE; 697 SmartButtonType mButtonType = SmartButtonType.REPLY; 698 LayoutParams(Context c, AttributeSet attrs)699 private LayoutParams(Context c, AttributeSet attrs) { 700 super(c, attrs); 701 } 702 LayoutParams(int width, int height)703 private LayoutParams(int width, int height) { 704 super(width, height); 705 } 706 707 @VisibleForTesting isShown()708 boolean isShown() { 709 return show; 710 } 711 } 712 713 /** 714 * Data class for smart replies. 715 */ 716 public static class SmartReplies { 717 @NonNull 718 public final RemoteInput remoteInput; 719 @NonNull 720 public final PendingIntent pendingIntent; 721 @NonNull 722 public final List<CharSequence> choices; 723 public final boolean fromAssistant; 724 SmartReplies(@onNull List<CharSequence> choices, @NonNull RemoteInput remoteInput, @NonNull PendingIntent pendingIntent, boolean fromAssistant)725 public SmartReplies(@NonNull List<CharSequence> choices, @NonNull RemoteInput remoteInput, 726 @NonNull PendingIntent pendingIntent, boolean fromAssistant) { 727 this.choices = choices; 728 this.remoteInput = remoteInput; 729 this.pendingIntent = pendingIntent; 730 this.fromAssistant = fromAssistant; 731 } 732 } 733 734 735 /** 736 * Data class for smart actions. 737 */ 738 public static class SmartActions { 739 @NonNull 740 public final List<Notification.Action> actions; 741 public final boolean fromAssistant; 742 SmartActions(@onNull List<Notification.Action> actions, boolean fromAssistant)743 public SmartActions(@NonNull List<Notification.Action> actions, boolean fromAssistant) { 744 this.actions = actions; 745 this.fromAssistant = fromAssistant; 746 } 747 } 748 } 749