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