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.res.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 lp.mNoShowReason = "max-actions-shown"; 269 // We've reached the maximum number of actions, don't add another one! 270 continue; 271 } 272 273 clearLayoutLineCount(child); 274 child.measure(MEASURE_SPEC_ANY_LENGTH, heightMeasureSpec); 275 if (((Button) child).getLayout() == null) { 276 Log.wtf(TAG, "Button layout is null after measure."); 277 } 278 279 coveredSuggestions.add(child); 280 281 final int lineCount = ((Button) child).getLineCount(); 282 if (lineCount < 1) { 283 // If smart reply has no text, then don't show it. 284 lp.mNoShowReason = "line-count-0"; 285 continue; 286 287 } 288 if (lineCount > 2) { 289 // If smart reply has more than two lines, then don't show it. 290 lp.mNoShowReason = "line-count-3+"; 291 continue; 292 } 293 294 if (lineCount == 1) { 295 mCandidateButtonQueueForSqueezing.add((Button) child); 296 } 297 298 // Remember the current measurements in case the current button doesn't fit in. 299 SmartSuggestionMeasures originalMeasures = accumulatedMeasures.clone(); 300 if (actionsMeasures == null && lp.mButtonType == SmartButtonType.REPLY) { 301 // We've added all actions (we go through actions first), now add their 302 // measurements. 303 actionsMeasures = accumulatedMeasures.clone(); 304 } 305 306 final int spacing = displayedChildCount == 0 ? 0 : mSpacing; 307 final int childWidth = child.getMeasuredWidth(); 308 final int childHeight = child.getMeasuredHeight(); 309 accumulatedMeasures.mMeasuredWidth += spacing + childWidth; 310 accumulatedMeasures.mMaxChildHeight = 311 Math.max(accumulatedMeasures.mMaxChildHeight, childHeight); 312 313 // If the last button doesn't fit into the remaining width, try squeezing preceding 314 // smart reply buttons. 315 if (accumulatedMeasures.mMeasuredWidth > targetWidth) { 316 // Keep squeezing preceding and current smart reply buttons until they all fit. 317 while (accumulatedMeasures.mMeasuredWidth > targetWidth 318 && !mCandidateButtonQueueForSqueezing.isEmpty()) { 319 final Button candidate = mCandidateButtonQueueForSqueezing.poll(); 320 final int squeezeReduction = squeezeButton(candidate, heightMeasureSpec); 321 if (squeezeReduction != SQUEEZE_FAILED) { 322 accumulatedMeasures.mMaxChildHeight = 323 Math.max(accumulatedMeasures.mMaxChildHeight, 324 candidate.getMeasuredHeight()); 325 accumulatedMeasures.mMeasuredWidth -= squeezeReduction; 326 } 327 } 328 329 // If the current button still doesn't fit after squeezing all buttons, undo the 330 // last squeezing round. 331 if (accumulatedMeasures.mMeasuredWidth > targetWidth) { 332 accumulatedMeasures = originalMeasures; 333 334 // Mark all buttons from the last squeezing round as "failed to squeeze", so 335 // that they're re-measured without squeezing later. 336 markButtonsWithPendingSqueezeStatusAs( 337 LayoutParams.SQUEEZE_STATUS_FAILED, coveredSuggestions); 338 339 lp.mNoShowReason = "overflow"; 340 // The current button doesn't fit, keep on adding lower-priority buttons in case 341 // any of those fit. 342 continue; 343 } 344 345 // The current button fits, so mark all squeezed buttons as "successfully squeezed" 346 // to prevent them from being un-squeezed in a subsequent squeezing round. 347 markButtonsWithPendingSqueezeStatusAs( 348 LayoutParams.SQUEEZE_STATUS_SUCCESSFUL, coveredSuggestions); 349 } 350 351 lp.show = true; 352 lp.mNoShowReason = "n/a"; 353 displayedChildCount++; 354 if (lp.mButtonType == SmartButtonType.ACTION) { 355 numShownActions++; 356 } 357 } 358 359 mDidHideSystemReplies = false; 360 if (mSmartRepliesGeneratedByAssistant) { 361 if (!gotEnoughSmartReplies(smartReplies)) { 362 // We don't have enough smart replies - hide all of them. 363 for (View smartReplyButton : smartReplies) { 364 final LayoutParams lp = (LayoutParams) smartReplyButton.getLayoutParams(); 365 lp.show = false; 366 lp.mNoShowReason = "not-enough-system-replies"; 367 } 368 // Reset our measures back to when we had only added actions (before adding 369 // replies). 370 accumulatedMeasures = actionsMeasures; 371 mDidHideSystemReplies = true; 372 } 373 } 374 375 // We're done squeezing buttons, so we can clear the priority queue. 376 mCandidateButtonQueueForSqueezing.clear(); 377 378 // Finally, we need to re-measure some buttons. 379 remeasureButtonsIfNecessary(accumulatedMeasures.mMaxChildHeight); 380 381 int buttonHeight = Math.max(getSuggestedMinimumHeight(), mPaddingTop 382 + accumulatedMeasures.mMaxChildHeight + mPaddingBottom); 383 384 setMeasuredDimension( 385 resolveSize(Math.max(getSuggestedMinimumWidth(), 386 accumulatedMeasures.mMeasuredWidth), 387 widthMeasureSpec), 388 resolveSize(buttonHeight, heightMeasureSpec)); 389 mLastMeasureTime = SystemClock.elapsedRealtime(); 390 } 391 392 // TODO: this should be replaced, and instead, setMinSystemGenerated... should be invoked 393 // with MAX_VALUE if mSmartRepliesGeneratedByAssistant would be false (essentially, this is a 394 // ViewModel decision, as opposed to a View decision) setSmartRepliesGeneratedByAssistant(boolean fromAssistant)395 void setSmartRepliesGeneratedByAssistant(boolean fromAssistant) { 396 mSmartRepliesGeneratedByAssistant = fromAssistant; 397 } 398 hideSmartSuggestions()399 void hideSmartSuggestions() { 400 if (mSmartReplyContainer != null) { 401 mSmartReplyContainer.setVisibility(View.GONE); 402 } 403 } 404 405 /** Dump internal state for debugging */ dump(IndentingPrintWriter pw)406 public void dump(IndentingPrintWriter pw) { 407 pw.println(this); 408 pw.increaseIndent(); 409 pw.print("mMaxSqueezeRemeasureAttempts="); 410 pw.println(mMaxSqueezeRemeasureAttempts); 411 pw.print("mTotalSqueezeRemeasureAttempts="); 412 pw.println(mTotalSqueezeRemeasureAttempts); 413 pw.print("mMaxNumActions="); 414 pw.println(mMaxNumActions); 415 pw.print("mSmartRepliesGeneratedByAssistant="); 416 pw.println(mSmartRepliesGeneratedByAssistant); 417 pw.print("mMinNumSystemGeneratedReplies="); 418 pw.println(mMinNumSystemGeneratedReplies); 419 pw.print("mHeightUpperLimit="); 420 pw.println(mHeightUpperLimit); 421 pw.print("mDidHideSystemReplies="); 422 pw.println(mDidHideSystemReplies); 423 long now = SystemClock.elapsedRealtime(); 424 pw.print("lastMeasureAge (s)="); 425 pw.println(mLastMeasureTime == 0 ? NaN : (now - mLastMeasureTime) / 1000.0f); 426 pw.print("lastDrawChildAge (s)="); 427 pw.println(mLastDrawChildTime == 0 ? NaN : (now - mLastDrawChildTime) / 1000.0f); 428 pw.print("lastDispatchDrawAge (s)="); 429 pw.println(mLastDispatchDrawTime == 0 ? NaN : (now - mLastDispatchDrawTime) / 1000.0f); 430 int numChildren = getChildCount(); 431 pw.print("children: num="); 432 pw.println(numChildren); 433 pw.increaseIndent(); 434 for (int i = 0; i < numChildren; i++) { 435 View child = getChildAt(i); 436 LayoutParams lp = (LayoutParams) child.getLayoutParams(); 437 pw.print("["); 438 pw.print(i); 439 pw.print("] type="); 440 pw.print(lp.mButtonType); 441 pw.print(" squeezeStatus="); 442 pw.print(lp.squeezeStatus); 443 pw.print(" show="); 444 pw.print(lp.show); 445 pw.print(" noShowReason="); 446 pw.print(lp.mNoShowReason); 447 pw.print(" view="); 448 pw.println(child); 449 } 450 pw.decreaseIndent(); 451 pw.decreaseIndent(); 452 } 453 454 /** 455 * Fields we keep track of inside onMeasure() to correctly measure the SmartReplyView depending 456 * on which suggestions are added. 457 */ 458 private static class SmartSuggestionMeasures { 459 int mMeasuredWidth = -1; 460 int mMaxChildHeight = -1; 461 SmartSuggestionMeasures(int measuredWidth, int maxChildHeight)462 SmartSuggestionMeasures(int measuredWidth, int maxChildHeight) { 463 this.mMeasuredWidth = measuredWidth; 464 this.mMaxChildHeight = maxChildHeight; 465 } 466 clone()467 public SmartSuggestionMeasures clone() { 468 return new SmartSuggestionMeasures(mMeasuredWidth, mMaxChildHeight); 469 } 470 } 471 472 /** 473 * Returns whether our notification contains at least N smart replies (or 0) where N is 474 * determined by {@link SmartReplyConstants}. 475 */ gotEnoughSmartReplies(List<View> smartReplies)476 private boolean gotEnoughSmartReplies(List<View> smartReplies) { 477 if (mMinNumSystemGeneratedReplies <= 1) { 478 // Count is irrelevant, do not bother. 479 return true; 480 } 481 int numShownReplies = 0; 482 for (View smartReplyButton : smartReplies) { 483 final LayoutParams lp = (LayoutParams) smartReplyButton.getLayoutParams(); 484 if (lp.show) { 485 numShownReplies++; 486 } 487 } 488 if (numShownReplies == 0 || numShownReplies >= mMinNumSystemGeneratedReplies) { 489 // We have enough replies, yay! 490 return true; 491 } 492 return false; 493 } 494 filterActionsOrReplies(SmartButtonType buttonType)495 private List<View> filterActionsOrReplies(SmartButtonType buttonType) { 496 List<View> actions = new ArrayList<>(); 497 final int childCount = getChildCount(); 498 for (int i = 0; i < childCount; i++) { 499 final View child = getChildAt(i); 500 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 501 if (child.getVisibility() != View.VISIBLE || !(child instanceof Button)) { 502 continue; 503 } 504 if (lp.mButtonType == buttonType) { 505 actions.add(child); 506 } 507 } 508 return actions; 509 } 510 resetButtonsLayoutParams()511 private void resetButtonsLayoutParams() { 512 final int childCount = getChildCount(); 513 for (int i = 0; i < childCount; i++) { 514 final View child = getChildAt(i); 515 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 516 lp.show = false; 517 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_NONE; 518 lp.mNoShowReason = "reset"; 519 } 520 } 521 squeezeButton(Button button, int heightMeasureSpec)522 private int squeezeButton(Button button, int heightMeasureSpec) { 523 final int estimatedOptimalTextWidth = estimateOptimalSqueezedButtonTextWidth(button); 524 if (estimatedOptimalTextWidth == SQUEEZE_FAILED) { 525 return SQUEEZE_FAILED; 526 } 527 return squeezeButtonToTextWidth(button, heightMeasureSpec, estimatedOptimalTextWidth); 528 } 529 estimateOptimalSqueezedButtonTextWidth(Button button)530 private int estimateOptimalSqueezedButtonTextWidth(Button button) { 531 // Find a line-break point in the middle of the smart reply button text. 532 final String rawText = button.getText().toString(); 533 534 // The button sometimes has a transformation affecting text layout (e.g. all caps). 535 final TransformationMethod transformation = button.getTransformationMethod(); 536 final String text = transformation == null ? 537 rawText : transformation.getTransformation(rawText, button).toString(); 538 final int length = text.length(); 539 mBreakIterator.setText(text); 540 541 if (mBreakIterator.preceding(length / 2) == BreakIterator.DONE) { 542 if (mBreakIterator.next() == BreakIterator.DONE) { 543 // Can't find a single possible line break in either direction. 544 return SQUEEZE_FAILED; 545 } 546 } 547 548 final TextPaint paint = button.getPaint(); 549 final int initialPosition = mBreakIterator.current(); 550 final float initialLeftTextWidth = Layout.getDesiredWidth(text, 0, initialPosition, paint); 551 final float initialRightTextWidth = 552 Layout.getDesiredWidth(text, initialPosition, length, paint); 553 float optimalTextWidth = Math.max(initialLeftTextWidth, initialRightTextWidth); 554 555 if (initialLeftTextWidth != initialRightTextWidth) { 556 // See if there's a better line-break point (leading to a more narrow button) in 557 // either left or right direction. 558 final boolean moveLeft = initialLeftTextWidth > initialRightTextWidth; 559 final int maxSqueezeRemeasureAttempts = mMaxSqueezeRemeasureAttempts; 560 for (int i = 0; i < maxSqueezeRemeasureAttempts; i++) { 561 mTotalSqueezeRemeasureAttempts++; 562 final int newPosition = 563 moveLeft ? mBreakIterator.previous() : mBreakIterator.next(); 564 if (newPosition == BreakIterator.DONE) { 565 break; 566 } 567 568 final float newLeftTextWidth = Layout.getDesiredWidth(text, 0, newPosition, paint); 569 final float newRightTextWidth = 570 Layout.getDesiredWidth(text, newPosition, length, paint); 571 final float newOptimalTextWidth = Math.max(newLeftTextWidth, newRightTextWidth); 572 if (newOptimalTextWidth < optimalTextWidth) { 573 optimalTextWidth = newOptimalTextWidth; 574 } else { 575 break; 576 } 577 578 boolean tooFar = moveLeft 579 ? newLeftTextWidth <= newRightTextWidth 580 : newLeftTextWidth >= newRightTextWidth; 581 if (tooFar) { 582 break; 583 } 584 } 585 } 586 587 return (int) Math.ceil(optimalTextWidth); 588 } 589 590 /** 591 * Returns the combined width of the start drawable (the action icon) and the padding between 592 * the drawable and the button text. 593 */ getStartCompoundDrawableWidthWithPadding(Button button)594 private int getStartCompoundDrawableWidthWithPadding(Button button) { 595 Drawable[] drawables = button.getCompoundDrawablesRelative(); 596 Drawable startDrawable = drawables[0]; 597 if (startDrawable == null) return 0; 598 599 return startDrawable.getBounds().width() + button.getCompoundDrawablePadding(); 600 } 601 squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth)602 private int squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth) { 603 int oldWidth = button.getMeasuredWidth(); 604 605 // Re-measure the squeezed smart reply button. 606 clearLayoutLineCount(button); 607 final int widthMeasureSpec = MeasureSpec.makeMeasureSpec( 608 button.getPaddingStart() + button.getPaddingEnd() + textWidth 609 + getStartCompoundDrawableWidthWithPadding(button), MeasureSpec.AT_MOST); 610 button.measure(widthMeasureSpec, heightMeasureSpec); 611 if (button.getLayout() == null) { 612 Log.wtf(TAG, "Button layout is null after measure."); 613 } 614 615 final int newWidth = button.getMeasuredWidth(); 616 617 final LayoutParams lp = (LayoutParams) button.getLayoutParams(); 618 if (button.getLineCount() > 2 || newWidth >= oldWidth) { 619 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_FAILED; 620 return SQUEEZE_FAILED; 621 } else { 622 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_PENDING; 623 return oldWidth - newWidth; 624 } 625 } 626 remeasureButtonsIfNecessary(int maxChildHeight)627 private void remeasureButtonsIfNecessary(int maxChildHeight) { 628 final int maxChildHeightMeasure = 629 MeasureSpec.makeMeasureSpec(maxChildHeight, MeasureSpec.EXACTLY); 630 631 final int childCount = getChildCount(); 632 for (int i = 0; i < childCount; i++) { 633 final View child = getChildAt(i); 634 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 635 if (!lp.show) { 636 continue; 637 } 638 639 boolean requiresNewMeasure = false; 640 int newWidth = child.getMeasuredWidth(); 641 642 // Re-measure reason 1: The button needs to be un-squeezed (either because it resulted 643 // in more than two lines or because it was unnecessary). 644 if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_FAILED) { 645 requiresNewMeasure = true; 646 newWidth = Integer.MAX_VALUE; 647 } 648 649 // Re-measure reason 2: The button's height is less than the max height of all buttons 650 // (all should have the same height). 651 if (child.getMeasuredHeight() != maxChildHeight) { 652 requiresNewMeasure = true; 653 } 654 655 if (requiresNewMeasure) { 656 child.measure(MeasureSpec.makeMeasureSpec(newWidth, MeasureSpec.AT_MOST), 657 maxChildHeightMeasure); 658 } 659 } 660 } 661 markButtonsWithPendingSqueezeStatusAs( int squeezeStatus, List<View> coveredChildren)662 private void markButtonsWithPendingSqueezeStatusAs( 663 int squeezeStatus, List<View> coveredChildren) { 664 for (View child : coveredChildren) { 665 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 666 if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_PENDING) { 667 lp.squeezeStatus = squeezeStatus; 668 } 669 } 670 } 671 672 @Override onLayout(boolean changed, int left, int top, int right, int bottom)673 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 674 final boolean isRtl = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL; 675 676 final int width = right - left; 677 int position = isRtl ? width - mPaddingRight : mPaddingLeft; 678 679 final int childCount = getChildCount(); 680 for (int i = 0; i < childCount; i++) { 681 final View child = getChildAt(i); 682 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 683 if (!lp.show) { 684 continue; 685 } 686 687 final int childWidth = child.getMeasuredWidth(); 688 final int childHeight = child.getMeasuredHeight(); 689 final int childLeft = isRtl ? position - childWidth : position; 690 child.layout(childLeft, 0, childLeft + childWidth, childHeight); 691 692 final int childWidthWithSpacing = childWidth + mSpacing; 693 if (isRtl) { 694 position -= childWidthWithSpacing; 695 } else { 696 position += childWidthWithSpacing; 697 } 698 } 699 } 700 701 @Override drawChild(Canvas canvas, View child, long drawingTime)702 protected boolean drawChild(Canvas canvas, View child, long drawingTime) { 703 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 704 if (!lp.show) { 705 return false; 706 } 707 mLastDrawChildTime = SystemClock.elapsedRealtime(); 708 return super.drawChild(canvas, child, drawingTime); 709 } 710 711 @Override dispatchDraw(Canvas canvas)712 protected void dispatchDraw(Canvas canvas) { 713 super.dispatchDraw(canvas); 714 mLastDispatchDrawTime = SystemClock.elapsedRealtime(); 715 } 716 717 /** 718 * Set the current background color of the notification so that the smart reply buttons can 719 * match it, and calculate other colors (e.g. text, ripple, stroke) 720 */ setBackgroundTintColor(int backgroundColor, boolean colorized)721 public void setBackgroundTintColor(int backgroundColor, boolean colorized) { 722 if (backgroundColor == mCurrentBackgroundColor && colorized == mCurrentColorized) { 723 // Same color ignoring. 724 return; 725 } 726 mCurrentBackgroundColor = backgroundColor; 727 mCurrentColorized = colorized; 728 729 final boolean dark = ContrastColorUtil.isColorDark(backgroundColor); 730 731 mCurrentTextColor = ContrastColorUtil.ensureTextContrast( 732 dark ? mDefaultTextColorDarkBg : mDefaultTextColor, 733 backgroundColor | 0xff000000, dark); 734 mCurrentStrokeColor = colorized ? mCurrentTextColor : ContrastColorUtil.ensureContrast( 735 mDefaultStrokeColor, backgroundColor | 0xff000000, dark, mMinStrokeContrast); 736 mCurrentRippleColor = dark ? mRippleColorDarkBg : mRippleColor; 737 738 int childCount = getChildCount(); 739 for (int i = 0; i < childCount; i++) { 740 setButtonColors((Button) getChildAt(i)); 741 } 742 } 743 setButtonColors(Button button)744 private void setButtonColors(Button button) { 745 Drawable drawable = button.getBackground(); 746 if (drawable instanceof RippleDrawable) { 747 // Mutate in case other notifications are using this drawable. 748 drawable = drawable.mutate(); 749 RippleDrawable ripple = (RippleDrawable) drawable; 750 ripple.setColor(ColorStateList.valueOf(mCurrentRippleColor)); 751 Drawable inset = ripple.getDrawable(0); 752 if (inset instanceof InsetDrawable) { 753 Drawable background = ((InsetDrawable) inset).getDrawable(); 754 if (background instanceof GradientDrawable) { 755 GradientDrawable gradientDrawable = (GradientDrawable) background; 756 gradientDrawable.setColor(mCurrentBackgroundColor); 757 gradientDrawable.setStroke(mStrokeWidth, mCurrentStrokeColor); 758 } 759 } 760 button.setBackground(drawable); 761 } 762 button.setTextColor(mCurrentTextColor); 763 } 764 765 enum SmartButtonType { 766 REPLY, 767 ACTION 768 } 769 770 @VisibleForTesting 771 static class LayoutParams extends ViewGroup.LayoutParams { 772 773 /** Button is not squeezed. */ 774 private static final int SQUEEZE_STATUS_NONE = 0; 775 776 /** 777 * Button was successfully squeezed, but it might be un-squeezed later if the squeezing 778 * turns out to have been unnecessary (because there's still not enough space to add another 779 * button). 780 */ 781 private static final int SQUEEZE_STATUS_PENDING = 1; 782 783 /** Button was successfully squeezed and it won't be un-squeezed. */ 784 private static final int SQUEEZE_STATUS_SUCCESSFUL = 2; 785 786 /** 787 * Button wasn't successfully squeezed. The squeezing resulted in more than two lines of 788 * text or it didn't reduce the button's width at all. The button will have to be 789 * re-measured to use only one line of text. 790 */ 791 private static final int SQUEEZE_STATUS_FAILED = 3; 792 793 private boolean show = false; 794 private int squeezeStatus = SQUEEZE_STATUS_NONE; 795 SmartButtonType mButtonType = SmartButtonType.REPLY; 796 String mNoShowReason = "new"; 797 LayoutParams(Context c, AttributeSet attrs)798 private LayoutParams(Context c, AttributeSet attrs) { 799 super(c, attrs); 800 } 801 LayoutParams(int width, int height)802 private LayoutParams(int width, int height) { 803 super(width, height); 804 } 805 806 @VisibleForTesting isShown()807 boolean isShown() { 808 return show; 809 } 810 } 811 812 /** 813 * Data class for smart replies. 814 */ 815 public static class SmartReplies { 816 @NonNull 817 public final RemoteInput remoteInput; 818 @NonNull 819 public final PendingIntent pendingIntent; 820 @NonNull 821 public final List<CharSequence> choices; 822 public final boolean fromAssistant; 823 SmartReplies(@onNull List<CharSequence> choices, @NonNull RemoteInput remoteInput, @NonNull PendingIntent pendingIntent, boolean fromAssistant)824 public SmartReplies(@NonNull List<CharSequence> choices, @NonNull RemoteInput remoteInput, 825 @NonNull PendingIntent pendingIntent, boolean fromAssistant) { 826 this.choices = choices; 827 this.remoteInput = remoteInput; 828 this.pendingIntent = pendingIntent; 829 this.fromAssistant = fromAssistant; 830 } 831 } 832 833 834 /** 835 * Data class for smart actions. 836 */ 837 public static class SmartActions { 838 @NonNull 839 public final List<Notification.Action> actions; 840 public final boolean fromAssistant; 841 SmartActions(@onNull List<Notification.Action> actions, boolean fromAssistant)842 public SmartActions(@NonNull List<Notification.Action> actions, boolean fromAssistant) { 843 this.actions = actions; 844 this.fromAssistant = fromAssistant; 845 } 846 } 847 } 848