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.Intent; 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.Bundle; 19 import android.os.SystemClock; 20 import android.text.Layout; 21 import android.text.TextPaint; 22 import android.text.method.TransformationMethod; 23 import android.util.AttributeSet; 24 import android.util.Log; 25 import android.view.LayoutInflater; 26 import android.view.View; 27 import android.view.ViewGroup; 28 import android.view.accessibility.AccessibilityNodeInfo; 29 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; 30 import android.widget.Button; 31 32 import com.android.internal.annotations.VisibleForTesting; 33 import com.android.internal.util.ContrastColorUtil; 34 import com.android.systemui.Dependency; 35 import com.android.systemui.R; 36 import com.android.systemui.plugins.ActivityStarter; 37 import com.android.systemui.plugins.ActivityStarter.OnDismissAction; 38 import com.android.systemui.statusbar.NotificationRemoteInputManager; 39 import com.android.systemui.statusbar.SmartReplyController; 40 import com.android.systemui.statusbar.notification.NotificationUtils; 41 import com.android.systemui.statusbar.notification.collection.NotificationEntry; 42 import com.android.systemui.statusbar.notification.collection.NotificationEntry.EditedSuggestionInfo; 43 import com.android.systemui.statusbar.notification.logging.NotificationLogger; 44 import com.android.systemui.statusbar.phone.KeyguardDismissUtil; 45 46 import java.text.BreakIterator; 47 import java.util.ArrayList; 48 import java.util.Comparator; 49 import java.util.List; 50 import java.util.PriorityQueue; 51 52 /** View which displays smart reply and smart actions buttons in notifications. */ 53 public class SmartReplyView extends ViewGroup { 54 55 private static final String TAG = "SmartReplyView"; 56 57 private static final int MEASURE_SPEC_ANY_LENGTH = 58 MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); 59 60 private static final Comparator<View> DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR = 61 (v1, v2) -> ((v2.getMeasuredWidth() - v2.getPaddingLeft() - v2.getPaddingRight()) 62 - (v1.getMeasuredWidth() - v1.getPaddingLeft() - v1.getPaddingRight())); 63 64 private static final int SQUEEZE_FAILED = -1; 65 66 private final SmartReplyConstants mConstants; 67 private final KeyguardDismissUtil mKeyguardDismissUtil; 68 private final NotificationRemoteInputManager mRemoteInputManager; 69 70 /** 71 * The upper bound for the height of this view in pixels. Notifications are automatically 72 * recreated on density or font size changes so caching this should be fine. 73 */ 74 private final int mHeightUpperLimit; 75 76 /** Spacing to be applied between views. */ 77 private final int mSpacing; 78 79 /** Horizontal padding of smart reply buttons if all of them use only one line of text. */ 80 private final int mSingleLineButtonPaddingHorizontal; 81 82 /** Horizontal padding of smart reply buttons if at least one of them uses two lines of text. */ 83 private final int mDoubleLineButtonPaddingHorizontal; 84 85 /** Increase in width of a smart reply button as a result of using two lines instead of one. */ 86 private final int mSingleToDoubleLineButtonWidthIncrease; 87 88 private final BreakIterator mBreakIterator; 89 90 private PriorityQueue<Button> mCandidateButtonQueueForSqueezing; 91 92 private View mSmartReplyContainer; 93 94 /** 95 * Whether the smart replies in this view were generated by the notification assistant. If not 96 * they're provided by the app. 97 */ 98 private boolean mSmartRepliesGeneratedByAssistant = false; 99 100 @ColorInt 101 private int mCurrentBackgroundColor; 102 @ColorInt 103 private final int mDefaultBackgroundColor; 104 @ColorInt 105 private final int mDefaultStrokeColor; 106 @ColorInt 107 private final int mDefaultTextColor; 108 @ColorInt 109 private final int mDefaultTextColorDarkBg; 110 @ColorInt 111 private final int mRippleColorDarkBg; 112 @ColorInt 113 private final int mRippleColor; 114 private final int mStrokeWidth; 115 private final double mMinStrokeContrast; 116 117 private ActivityStarter mActivityStarter; 118 SmartReplyView(Context context, AttributeSet attrs)119 public SmartReplyView(Context context, AttributeSet attrs) { 120 super(context, attrs); 121 mConstants = Dependency.get(SmartReplyConstants.class); 122 mKeyguardDismissUtil = Dependency.get(KeyguardDismissUtil.class); 123 mRemoteInputManager = Dependency.get(NotificationRemoteInputManager.class); 124 125 mHeightUpperLimit = NotificationUtils.getFontScaledHeight(mContext, 126 R.dimen.smart_reply_button_max_height); 127 128 mCurrentBackgroundColor = context.getColor(R.color.smart_reply_button_background); 129 mDefaultBackgroundColor = mCurrentBackgroundColor; 130 mDefaultTextColor = mContext.getColor(R.color.smart_reply_button_text); 131 mDefaultTextColorDarkBg = mContext.getColor(R.color.smart_reply_button_text_dark_bg); 132 mDefaultStrokeColor = mContext.getColor(R.color.smart_reply_button_stroke); 133 mRippleColor = mContext.getColor(R.color.notification_ripple_untinted_color); 134 mRippleColorDarkBg = Color.argb(Color.alpha(mRippleColor), 135 255 /* red */, 255 /* green */, 255 /* blue */); 136 mMinStrokeContrast = ContrastColorUtil.calculateContrast(mDefaultStrokeColor, 137 mDefaultBackgroundColor); 138 139 int spacing = 0; 140 int singleLineButtonPaddingHorizontal = 0; 141 int doubleLineButtonPaddingHorizontal = 0; 142 int strokeWidth = 0; 143 144 final TypedArray arr = context.obtainStyledAttributes(attrs, R.styleable.SmartReplyView, 145 0, 0); 146 final int length = arr.getIndexCount(); 147 for (int i = 0; i < length; i++) { 148 int attr = arr.getIndex(i); 149 if (attr == R.styleable.SmartReplyView_spacing) { 150 spacing = arr.getDimensionPixelSize(i, 0); 151 } else if (attr == R.styleable.SmartReplyView_singleLineButtonPaddingHorizontal) { 152 singleLineButtonPaddingHorizontal = arr.getDimensionPixelSize(i, 0); 153 } else if (attr == R.styleable.SmartReplyView_doubleLineButtonPaddingHorizontal) { 154 doubleLineButtonPaddingHorizontal = arr.getDimensionPixelSize(i, 0); 155 } else if (attr == R.styleable.SmartReplyView_buttonStrokeWidth) { 156 strokeWidth = arr.getDimensionPixelSize(i, 0); 157 } 158 } 159 arr.recycle(); 160 161 mStrokeWidth = strokeWidth; 162 mSpacing = spacing; 163 mSingleLineButtonPaddingHorizontal = singleLineButtonPaddingHorizontal; 164 mDoubleLineButtonPaddingHorizontal = doubleLineButtonPaddingHorizontal; 165 mSingleToDoubleLineButtonWidthIncrease = 166 2 * (doubleLineButtonPaddingHorizontal - singleLineButtonPaddingHorizontal); 167 168 169 mBreakIterator = BreakIterator.getLineInstance(); 170 reallocateCandidateButtonQueueForSqueezing(); 171 } 172 173 /** 174 * Returns an upper bound for the height of this view in pixels. This method is intended to be 175 * invoked before onMeasure, so it doesn't do any analysis on the contents of the buttons. 176 */ getHeightUpperLimit()177 public int getHeightUpperLimit() { 178 return mHeightUpperLimit; 179 } 180 reallocateCandidateButtonQueueForSqueezing()181 private void reallocateCandidateButtonQueueForSqueezing() { 182 // Instead of clearing the priority queue, we re-allocate so that it would fit all buttons 183 // exactly. This avoids (1) wasting memory because PriorityQueue never shrinks and 184 // (2) growing in onMeasure. 185 // The constructor throws an IllegalArgument exception if initial capacity is less than 1. 186 mCandidateButtonQueueForSqueezing = new PriorityQueue<>( 187 Math.max(getChildCount(), 1), DECREASING_MEASURED_WIDTH_WITHOUT_PADDING_COMPARATOR); 188 } 189 190 /** 191 * Reset the smart suggestions view to allow adding new replies and actions. 192 */ resetSmartSuggestions(View newSmartReplyContainer)193 public void resetSmartSuggestions(View newSmartReplyContainer) { 194 mSmartReplyContainer = newSmartReplyContainer; 195 removeAllViews(); 196 mCurrentBackgroundColor = mDefaultBackgroundColor; 197 } 198 199 /** 200 * Add buttons to the {@link SmartReplyView} - these buttons must have been preinflated using 201 * one of the methods in this class. 202 */ addPreInflatedButtons(List<Button> smartSuggestionButtons)203 public void addPreInflatedButtons(List<Button> smartSuggestionButtons) { 204 for (Button button : smartSuggestionButtons) { 205 addView(button); 206 } 207 reallocateCandidateButtonQueueForSqueezing(); 208 } 209 210 /** 211 * Add smart replies to this view, using the provided {@link RemoteInput} and 212 * {@link PendingIntent} to respond when the user taps a smart reply. Only the replies that fit 213 * into the notification are shown. 214 */ inflateRepliesFromRemoteInput( @onNull SmartReplies smartReplies, SmartReplyController smartReplyController, NotificationEntry entry, boolean delayOnClickListener)215 public List<Button> inflateRepliesFromRemoteInput( 216 @NonNull SmartReplies smartReplies, 217 SmartReplyController smartReplyController, NotificationEntry entry, 218 boolean delayOnClickListener) { 219 List<Button> buttons = new ArrayList<>(); 220 221 if (smartReplies.remoteInput != null && smartReplies.pendingIntent != null) { 222 if (smartReplies.choices != null) { 223 for (int i = 0; i < smartReplies.choices.length; ++i) { 224 buttons.add(inflateReplyButton( 225 this, getContext(), i, smartReplies, smartReplyController, entry, 226 delayOnClickListener)); 227 } 228 this.mSmartRepliesGeneratedByAssistant = smartReplies.fromAssistant; 229 } 230 } 231 return buttons; 232 } 233 234 /** 235 * Add smart actions to be shown next to smart replies. Only the actions that fit into the 236 * notification are shown. 237 */ inflateSmartActions(@onNull SmartActions smartActions, SmartReplyController smartReplyController, NotificationEntry entry, HeadsUpManager headsUpManager, boolean delayOnClickListener)238 public List<Button> inflateSmartActions(@NonNull SmartActions smartActions, 239 SmartReplyController smartReplyController, NotificationEntry entry, 240 HeadsUpManager headsUpManager, boolean delayOnClickListener) { 241 List<Button> buttons = new ArrayList<>(); 242 int numSmartActions = smartActions.actions.size(); 243 for (int n = 0; n < numSmartActions; n++) { 244 Notification.Action action = smartActions.actions.get(n); 245 if (action.actionIntent != null) { 246 buttons.add(inflateActionButton( 247 this, getContext(), n, smartActions, smartReplyController, entry, 248 headsUpManager, delayOnClickListener)); 249 } 250 } 251 return buttons; 252 } 253 254 /** 255 * Inflate an instance of this class. 256 */ inflate(Context context)257 public static SmartReplyView inflate(Context context) { 258 return (SmartReplyView) LayoutInflater.from(context).inflate( 259 R.layout.smart_reply_view, null /* root */); 260 } 261 262 @VisibleForTesting inflateReplyButton(SmartReplyView smartReplyView, Context context, int replyIndex, SmartReplies smartReplies, SmartReplyController smartReplyController, NotificationEntry entry, boolean useDelayedOnClickListener)263 static Button inflateReplyButton(SmartReplyView smartReplyView, Context context, 264 int replyIndex, SmartReplies smartReplies, SmartReplyController smartReplyController, 265 NotificationEntry entry, boolean useDelayedOnClickListener) { 266 Button b = (Button) LayoutInflater.from(context).inflate( 267 R.layout.smart_reply_button, smartReplyView, false); 268 CharSequence choice = smartReplies.choices[replyIndex]; 269 b.setText(choice); 270 271 OnDismissAction action = () -> { 272 if (smartReplyView.mConstants.getEffectiveEditChoicesBeforeSending( 273 smartReplies.remoteInput.getEditChoicesBeforeSending())) { 274 EditedSuggestionInfo editedSuggestionInfo = 275 new EditedSuggestionInfo(choice, replyIndex); 276 smartReplyView.mRemoteInputManager.activateRemoteInput(b, 277 new RemoteInput[] { smartReplies.remoteInput }, smartReplies.remoteInput, 278 smartReplies.pendingIntent, editedSuggestionInfo); 279 return false; 280 } 281 282 smartReplyController.smartReplySent(entry, replyIndex, b.getText(), 283 NotificationLogger.getNotificationLocation(entry).toMetricsEventEnum(), 284 false /* modifiedBeforeSending */); 285 Bundle results = new Bundle(); 286 results.putString(smartReplies.remoteInput.getResultKey(), choice.toString()); 287 Intent intent = new Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND); 288 RemoteInput.addResultsToIntent(new RemoteInput[] { smartReplies.remoteInput }, intent, 289 results); 290 RemoteInput.setResultsSource(intent, RemoteInput.SOURCE_CHOICE); 291 entry.setHasSentReply(); 292 try { 293 smartReplies.pendingIntent.send(context, 0, intent); 294 } catch (PendingIntent.CanceledException e) { 295 Log.w(TAG, "Unable to send smart reply", e); 296 } 297 // Note that as inflateReplyButton is called mSmartReplyContainer is null, but when the 298 // reply Button is added to the SmartReplyView mSmartReplyContainer will be set. So, it 299 // will not be possible for a user to trigger this on-click-listener without 300 // mSmartReplyContainer being set. 301 smartReplyView.mSmartReplyContainer.setVisibility(View.GONE); 302 return false; // do not defer 303 }; 304 305 OnClickListener onClickListener = view -> 306 smartReplyView.mKeyguardDismissUtil.executeWhenUnlocked(action); 307 if (useDelayedOnClickListener) { 308 onClickListener = new DelayedOnClickListener(onClickListener, 309 smartReplyView.mConstants.getOnClickInitDelay()); 310 } 311 b.setOnClickListener(onClickListener); 312 313 b.setAccessibilityDelegate(new AccessibilityDelegate() { 314 public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { 315 super.onInitializeAccessibilityNodeInfo(host, info); 316 String label = smartReplyView.getResources().getString( 317 R.string.accessibility_send_smart_reply); 318 info.addAction(new AccessibilityAction(AccessibilityNodeInfo.ACTION_CLICK, label)); 319 } 320 }); 321 322 SmartReplyView.setButtonColors(b, smartReplyView.mCurrentBackgroundColor, 323 smartReplyView.mDefaultStrokeColor, smartReplyView.mDefaultTextColor, 324 smartReplyView.mRippleColor, smartReplyView.mStrokeWidth); 325 return b; 326 } 327 328 @VisibleForTesting inflateActionButton(SmartReplyView smartReplyView, Context context, int actionIndex, SmartActions smartActions, SmartReplyController smartReplyController, NotificationEntry entry, HeadsUpManager headsUpManager, boolean useDelayedOnClickListener)329 static Button inflateActionButton(SmartReplyView smartReplyView, Context context, 330 int actionIndex, SmartActions smartActions, 331 SmartReplyController smartReplyController, NotificationEntry entry, 332 HeadsUpManager headsUpManager, boolean useDelayedOnClickListener) { 333 Notification.Action action = smartActions.actions.get(actionIndex); 334 Button button = (Button) LayoutInflater.from(context).inflate( 335 R.layout.smart_action_button, smartReplyView, false); 336 button.setText(action.title); 337 338 Drawable iconDrawable = action.getIcon().loadDrawable(context); 339 // Add the action icon to the Smart Action button. 340 int newIconSize = context.getResources().getDimensionPixelSize( 341 R.dimen.smart_action_button_icon_size); 342 iconDrawable.setBounds(0, 0, newIconSize, newIconSize); 343 button.setCompoundDrawables(iconDrawable, null, null, null); 344 345 OnClickListener onClickListener = view -> 346 smartReplyView.getActivityStarter().startPendingIntentDismissingKeyguard( 347 action.actionIntent, 348 () -> { 349 smartReplyController.smartActionClicked( 350 entry, actionIndex, action, smartActions.fromAssistant); 351 headsUpManager.removeNotification(entry.key, true); 352 }, entry.getRow()); 353 if (useDelayedOnClickListener) { 354 onClickListener = new DelayedOnClickListener(onClickListener, 355 smartReplyView.mConstants.getOnClickInitDelay()); 356 } 357 button.setOnClickListener(onClickListener); 358 359 // Mark this as an Action button 360 final LayoutParams lp = (LayoutParams) button.getLayoutParams(); 361 lp.buttonType = SmartButtonType.ACTION; 362 return button; 363 } 364 365 @Override generateLayoutParams(AttributeSet attrs)366 public LayoutParams generateLayoutParams(AttributeSet attrs) { 367 return new LayoutParams(mContext, attrs); 368 } 369 370 @Override generateDefaultLayoutParams()371 protected LayoutParams generateDefaultLayoutParams() { 372 return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); 373 } 374 375 @Override generateLayoutParams(ViewGroup.LayoutParams params)376 protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams params) { 377 return new LayoutParams(params.width, params.height); 378 } 379 380 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)381 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 382 final int targetWidth = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED 383 ? Integer.MAX_VALUE : MeasureSpec.getSize(widthMeasureSpec); 384 385 // Mark all buttons as hidden and un-squeezed. 386 resetButtonsLayoutParams(); 387 388 if (!mCandidateButtonQueueForSqueezing.isEmpty()) { 389 Log.wtf(TAG, "Single line button queue leaked between onMeasure calls"); 390 mCandidateButtonQueueForSqueezing.clear(); 391 } 392 393 SmartSuggestionMeasures accumulatedMeasures = new SmartSuggestionMeasures( 394 mPaddingLeft + mPaddingRight, 395 0 /* maxChildHeight */, 396 mSingleLineButtonPaddingHorizontal); 397 int displayedChildCount = 0; 398 399 // Set up a list of suggestions where actions come before replies. Note that the Buttons 400 // themselves have already been added to the view hierarchy in an order such that Smart 401 // Replies are shown before Smart Actions. The order of the list below determines which 402 // suggestions will be shown at all - only the first X elements are shown (where X depends 403 // on how much space each suggestion button needs). 404 List<View> smartActions = filterActionsOrReplies(SmartButtonType.ACTION); 405 List<View> smartReplies = filterActionsOrReplies(SmartButtonType.REPLY); 406 List<View> smartSuggestions = new ArrayList<>(smartActions); 407 smartSuggestions.addAll(smartReplies); 408 List<View> coveredSuggestions = new ArrayList<>(); 409 410 // SmartSuggestionMeasures for all action buttons, this will be filled in when the first 411 // reply button is added. 412 SmartSuggestionMeasures actionsMeasures = null; 413 414 final int maxNumActions = mConstants.getMaxNumActions(); 415 int numShownActions = 0; 416 417 for (View child : smartSuggestions) { 418 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 419 if (maxNumActions != -1 // -1 means 'no limit' 420 && lp.buttonType == SmartButtonType.ACTION 421 && numShownActions >= maxNumActions) { 422 // We've reached the maximum number of actions, don't add another one! 423 continue; 424 } 425 426 child.setPadding(accumulatedMeasures.mButtonPaddingHorizontal, child.getPaddingTop(), 427 accumulatedMeasures.mButtonPaddingHorizontal, child.getPaddingBottom()); 428 child.measure(MEASURE_SPEC_ANY_LENGTH, heightMeasureSpec); 429 430 coveredSuggestions.add(child); 431 432 final int lineCount = ((Button) child).getLineCount(); 433 if (lineCount < 1 || lineCount > 2) { 434 // If smart reply has no text, or more than two lines, then don't show it. 435 continue; 436 } 437 438 if (lineCount == 1) { 439 mCandidateButtonQueueForSqueezing.add((Button) child); 440 } 441 442 // Remember the current measurements in case the current button doesn't fit in. 443 SmartSuggestionMeasures originalMeasures = accumulatedMeasures.clone(); 444 if (actionsMeasures == null && lp.buttonType == SmartButtonType.REPLY) { 445 // We've added all actions (we go through actions first), now add their 446 // measurements. 447 actionsMeasures = accumulatedMeasures.clone(); 448 } 449 450 final int spacing = displayedChildCount == 0 ? 0 : mSpacing; 451 final int childWidth = child.getMeasuredWidth(); 452 final int childHeight = child.getMeasuredHeight(); 453 accumulatedMeasures.mMeasuredWidth += spacing + childWidth; 454 accumulatedMeasures.mMaxChildHeight = 455 Math.max(accumulatedMeasures.mMaxChildHeight, childHeight); 456 457 // Do we need to increase the number of lines in smart reply buttons to two? 458 final boolean increaseToTwoLines = 459 (accumulatedMeasures.mButtonPaddingHorizontal 460 == mSingleLineButtonPaddingHorizontal) 461 && (lineCount == 2 || accumulatedMeasures.mMeasuredWidth > targetWidth); 462 if (increaseToTwoLines) { 463 accumulatedMeasures.mMeasuredWidth += 464 (displayedChildCount + 1) * mSingleToDoubleLineButtonWidthIncrease; 465 accumulatedMeasures.mButtonPaddingHorizontal = 466 mDoubleLineButtonPaddingHorizontal; 467 } 468 469 // If the last button doesn't fit into the remaining width, try squeezing preceding 470 // smart reply buttons. 471 if (accumulatedMeasures.mMeasuredWidth > targetWidth) { 472 // Keep squeezing preceding and current smart reply buttons until they all fit. 473 while (accumulatedMeasures.mMeasuredWidth > targetWidth 474 && !mCandidateButtonQueueForSqueezing.isEmpty()) { 475 final Button candidate = mCandidateButtonQueueForSqueezing.poll(); 476 final int squeezeReduction = squeezeButton(candidate, heightMeasureSpec); 477 if (squeezeReduction != SQUEEZE_FAILED) { 478 accumulatedMeasures.mMaxChildHeight = 479 Math.max(accumulatedMeasures.mMaxChildHeight, 480 candidate.getMeasuredHeight()); 481 accumulatedMeasures.mMeasuredWidth -= squeezeReduction; 482 } 483 } 484 485 // If the current button still doesn't fit after squeezing all buttons, undo the 486 // last squeezing round. 487 if (accumulatedMeasures.mMeasuredWidth > targetWidth) { 488 accumulatedMeasures = originalMeasures; 489 490 // Mark all buttons from the last squeezing round as "failed to squeeze", so 491 // that they're re-measured without squeezing later. 492 markButtonsWithPendingSqueezeStatusAs( 493 LayoutParams.SQUEEZE_STATUS_FAILED, coveredSuggestions); 494 495 // The current button doesn't fit, keep on adding lower-priority buttons in case 496 // any of those fit. 497 continue; 498 } 499 500 // The current button fits, so mark all squeezed buttons as "successfully squeezed" 501 // to prevent them from being un-squeezed in a subsequent squeezing round. 502 markButtonsWithPendingSqueezeStatusAs( 503 LayoutParams.SQUEEZE_STATUS_SUCCESSFUL, coveredSuggestions); 504 } 505 506 lp.show = true; 507 displayedChildCount++; 508 if (lp.buttonType == SmartButtonType.ACTION) { 509 numShownActions++; 510 } 511 } 512 513 if (mSmartRepliesGeneratedByAssistant) { 514 if (!gotEnoughSmartReplies(smartReplies)) { 515 // We don't have enough smart replies - hide all of them. 516 for (View smartReplyButton : smartReplies) { 517 final LayoutParams lp = (LayoutParams) smartReplyButton.getLayoutParams(); 518 lp.show = false; 519 } 520 // Reset our measures back to when we had only added actions (before adding 521 // replies). 522 accumulatedMeasures = actionsMeasures; 523 } 524 } 525 526 // We're done squeezing buttons, so we can clear the priority queue. 527 mCandidateButtonQueueForSqueezing.clear(); 528 529 // Finally, we need to re-measure some buttons. 530 remeasureButtonsIfNecessary(accumulatedMeasures.mButtonPaddingHorizontal, 531 accumulatedMeasures.mMaxChildHeight); 532 533 int buttonHeight = Math.max(getSuggestedMinimumHeight(), mPaddingTop 534 + accumulatedMeasures.mMaxChildHeight + mPaddingBottom); 535 536 // Set the corner radius to half the button height to make the side of the buttons look like 537 // a semicircle. 538 for (View smartSuggestionButton : smartSuggestions) { 539 setCornerRadius((Button) smartSuggestionButton, ((float) buttonHeight) / 2); 540 } 541 542 setMeasuredDimension( 543 resolveSize(Math.max(getSuggestedMinimumWidth(), 544 accumulatedMeasures.mMeasuredWidth), 545 widthMeasureSpec), 546 resolveSize(buttonHeight, heightMeasureSpec)); 547 } 548 549 /** 550 * Fields we keep track of inside onMeasure() to correctly measure the SmartReplyView depending 551 * on which suggestions are added. 552 */ 553 private static class SmartSuggestionMeasures { 554 int mMeasuredWidth = -1; 555 int mMaxChildHeight = -1; 556 int mButtonPaddingHorizontal = -1; 557 SmartSuggestionMeasures(int measuredWidth, int maxChildHeight, int buttonPaddingHorizontal)558 SmartSuggestionMeasures(int measuredWidth, int maxChildHeight, 559 int buttonPaddingHorizontal) { 560 this.mMeasuredWidth = measuredWidth; 561 this.mMaxChildHeight = maxChildHeight; 562 this.mButtonPaddingHorizontal = buttonPaddingHorizontal; 563 } 564 clone()565 public SmartSuggestionMeasures clone() { 566 return new SmartSuggestionMeasures( 567 mMeasuredWidth, mMaxChildHeight, mButtonPaddingHorizontal); 568 } 569 } 570 571 /** 572 * Returns whether our notification contains at least N smart replies (or 0) where N is 573 * determined by {@link SmartReplyConstants}. 574 */ gotEnoughSmartReplies(List<View> smartReplies)575 private boolean gotEnoughSmartReplies(List<View> smartReplies) { 576 int numShownReplies = 0; 577 for (View smartReplyButton : smartReplies) { 578 final LayoutParams lp = (LayoutParams) smartReplyButton.getLayoutParams(); 579 if (lp.show) { 580 numShownReplies++; 581 } 582 } 583 if (numShownReplies == 0 584 || numShownReplies >= mConstants.getMinNumSystemGeneratedReplies()) { 585 // We have enough replies, yay! 586 return true; 587 } 588 return false; 589 } 590 filterActionsOrReplies(SmartButtonType buttonType)591 private List<View> filterActionsOrReplies(SmartButtonType buttonType) { 592 List<View> actions = new ArrayList<>(); 593 final int childCount = getChildCount(); 594 for (int i = 0; i < childCount; i++) { 595 final View child = getChildAt(i); 596 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 597 if (child.getVisibility() != View.VISIBLE || !(child instanceof Button)) { 598 continue; 599 } 600 if (lp.buttonType == buttonType) { 601 actions.add(child); 602 } 603 } 604 return actions; 605 } 606 resetButtonsLayoutParams()607 private void resetButtonsLayoutParams() { 608 final int childCount = getChildCount(); 609 for (int i = 0; i < childCount; i++) { 610 final View child = getChildAt(i); 611 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 612 lp.show = false; 613 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_NONE; 614 } 615 } 616 squeezeButton(Button button, int heightMeasureSpec)617 private int squeezeButton(Button button, int heightMeasureSpec) { 618 final int estimatedOptimalTextWidth = estimateOptimalSqueezedButtonTextWidth(button); 619 if (estimatedOptimalTextWidth == SQUEEZE_FAILED) { 620 return SQUEEZE_FAILED; 621 } 622 return squeezeButtonToTextWidth(button, heightMeasureSpec, estimatedOptimalTextWidth); 623 } 624 estimateOptimalSqueezedButtonTextWidth(Button button)625 private int estimateOptimalSqueezedButtonTextWidth(Button button) { 626 // Find a line-break point in the middle of the smart reply button text. 627 final String rawText = button.getText().toString(); 628 629 // The button sometimes has a transformation affecting text layout (e.g. all caps). 630 final TransformationMethod transformation = button.getTransformationMethod(); 631 final String text = transformation == null ? 632 rawText : transformation.getTransformation(rawText, button).toString(); 633 final int length = text.length(); 634 mBreakIterator.setText(text); 635 636 if (mBreakIterator.preceding(length / 2) == BreakIterator.DONE) { 637 if (mBreakIterator.next() == BreakIterator.DONE) { 638 // Can't find a single possible line break in either direction. 639 return SQUEEZE_FAILED; 640 } 641 } 642 643 final TextPaint paint = button.getPaint(); 644 final int initialPosition = mBreakIterator.current(); 645 final float initialLeftTextWidth = Layout.getDesiredWidth(text, 0, initialPosition, paint); 646 final float initialRightTextWidth = 647 Layout.getDesiredWidth(text, initialPosition, length, paint); 648 float optimalTextWidth = Math.max(initialLeftTextWidth, initialRightTextWidth); 649 650 if (initialLeftTextWidth != initialRightTextWidth) { 651 // See if there's a better line-break point (leading to a more narrow button) in 652 // either left or right direction. 653 final boolean moveLeft = initialLeftTextWidth > initialRightTextWidth; 654 final int maxSqueezeRemeasureAttempts = mConstants.getMaxSqueezeRemeasureAttempts(); 655 for (int i = 0; i < maxSqueezeRemeasureAttempts; i++) { 656 final int newPosition = 657 moveLeft ? mBreakIterator.previous() : mBreakIterator.next(); 658 if (newPosition == BreakIterator.DONE) { 659 break; 660 } 661 662 final float newLeftTextWidth = Layout.getDesiredWidth(text, 0, newPosition, paint); 663 final float newRightTextWidth = 664 Layout.getDesiredWidth(text, newPosition, length, paint); 665 final float newOptimalTextWidth = Math.max(newLeftTextWidth, newRightTextWidth); 666 if (newOptimalTextWidth < optimalTextWidth) { 667 optimalTextWidth = newOptimalTextWidth; 668 } else { 669 break; 670 } 671 672 boolean tooFar = moveLeft 673 ? newLeftTextWidth <= newRightTextWidth 674 : newLeftTextWidth >= newRightTextWidth; 675 if (tooFar) { 676 break; 677 } 678 } 679 } 680 681 return (int) Math.ceil(optimalTextWidth); 682 } 683 684 /** 685 * Returns the combined width of the left drawable (the action icon) and the padding between the 686 * drawable and the button text. 687 */ getLeftCompoundDrawableWidthWithPadding(Button button)688 private int getLeftCompoundDrawableWidthWithPadding(Button button) { 689 Drawable[] drawables = button.getCompoundDrawables(); 690 Drawable leftDrawable = drawables[0]; 691 if (leftDrawable == null) return 0; 692 693 return leftDrawable.getBounds().width() + button.getCompoundDrawablePadding(); 694 } 695 squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth)696 private int squeezeButtonToTextWidth(Button button, int heightMeasureSpec, int textWidth) { 697 int oldWidth = button.getMeasuredWidth(); 698 if (button.getPaddingLeft() != mDoubleLineButtonPaddingHorizontal) { 699 // Correct for the fact that the button was laid out with single-line horizontal 700 // padding. 701 oldWidth += mSingleToDoubleLineButtonWidthIncrease; 702 } 703 704 // Re-measure the squeezed smart reply button. 705 button.setPadding(mDoubleLineButtonPaddingHorizontal, button.getPaddingTop(), 706 mDoubleLineButtonPaddingHorizontal, button.getPaddingBottom()); 707 final int widthMeasureSpec = MeasureSpec.makeMeasureSpec( 708 2 * mDoubleLineButtonPaddingHorizontal + textWidth 709 + getLeftCompoundDrawableWidthWithPadding(button), MeasureSpec.AT_MOST); 710 button.measure(widthMeasureSpec, heightMeasureSpec); 711 712 final int newWidth = button.getMeasuredWidth(); 713 714 final LayoutParams lp = (LayoutParams) button.getLayoutParams(); 715 if (button.getLineCount() > 2 || newWidth >= oldWidth) { 716 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_FAILED; 717 return SQUEEZE_FAILED; 718 } else { 719 lp.squeezeStatus = LayoutParams.SQUEEZE_STATUS_PENDING; 720 return oldWidth - newWidth; 721 } 722 } 723 remeasureButtonsIfNecessary( int buttonPaddingHorizontal, int maxChildHeight)724 private void remeasureButtonsIfNecessary( 725 int buttonPaddingHorizontal, int maxChildHeight) { 726 final int maxChildHeightMeasure = 727 MeasureSpec.makeMeasureSpec(maxChildHeight, MeasureSpec.EXACTLY); 728 729 final int childCount = getChildCount(); 730 for (int i = 0; i < childCount; i++) { 731 final View child = getChildAt(i); 732 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 733 if (!lp.show) { 734 continue; 735 } 736 737 boolean requiresNewMeasure = false; 738 int newWidth = child.getMeasuredWidth(); 739 740 // Re-measure reason 1: The button needs to be un-squeezed (either because it resulted 741 // in more than two lines or because it was unnecessary). 742 if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_FAILED) { 743 requiresNewMeasure = true; 744 newWidth = Integer.MAX_VALUE; 745 } 746 747 // Re-measure reason 2: The button's horizontal padding is incorrect (because it was 748 // measured with the wrong number of lines). 749 if (child.getPaddingLeft() != buttonPaddingHorizontal) { 750 requiresNewMeasure = true; 751 if (newWidth != Integer.MAX_VALUE) { 752 if (buttonPaddingHorizontal == mSingleLineButtonPaddingHorizontal) { 753 // Change padding (2->1 line). 754 newWidth -= mSingleToDoubleLineButtonWidthIncrease; 755 } else { 756 // Change padding (1->2 lines). 757 newWidth += mSingleToDoubleLineButtonWidthIncrease; 758 } 759 } 760 child.setPadding(buttonPaddingHorizontal, child.getPaddingTop(), 761 buttonPaddingHorizontal, child.getPaddingBottom()); 762 } 763 764 // Re-measure reason 3: The button's height is less than the max height of all buttons 765 // (all should have the same height). 766 if (child.getMeasuredHeight() != maxChildHeight) { 767 requiresNewMeasure = true; 768 } 769 770 if (requiresNewMeasure) { 771 child.measure(MeasureSpec.makeMeasureSpec(newWidth, MeasureSpec.AT_MOST), 772 maxChildHeightMeasure); 773 } 774 } 775 } 776 markButtonsWithPendingSqueezeStatusAs( int squeezeStatus, List<View> coveredChildren)777 private void markButtonsWithPendingSqueezeStatusAs( 778 int squeezeStatus, List<View> coveredChildren) { 779 for (View child : coveredChildren) { 780 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 781 if (lp.squeezeStatus == LayoutParams.SQUEEZE_STATUS_PENDING) { 782 lp.squeezeStatus = squeezeStatus; 783 } 784 } 785 } 786 787 @Override onLayout(boolean changed, int left, int top, int right, int bottom)788 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 789 final boolean isRtl = getLayoutDirection() == View.LAYOUT_DIRECTION_RTL; 790 791 final int width = right - left; 792 int position = isRtl ? width - mPaddingRight : mPaddingLeft; 793 794 final int childCount = getChildCount(); 795 for (int i = 0; i < childCount; i++) { 796 final View child = getChildAt(i); 797 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 798 if (!lp.show) { 799 continue; 800 } 801 802 final int childWidth = child.getMeasuredWidth(); 803 final int childHeight = child.getMeasuredHeight(); 804 final int childLeft = isRtl ? position - childWidth : position; 805 child.layout(childLeft, 0, childLeft + childWidth, childHeight); 806 807 final int childWidthWithSpacing = childWidth + mSpacing; 808 if (isRtl) { 809 position -= childWidthWithSpacing; 810 } else { 811 position += childWidthWithSpacing; 812 } 813 } 814 } 815 816 @Override drawChild(Canvas canvas, View child, long drawingTime)817 protected boolean drawChild(Canvas canvas, View child, long drawingTime) { 818 final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 819 return lp.show && super.drawChild(canvas, child, drawingTime); 820 } 821 setBackgroundTintColor(int backgroundColor)822 public void setBackgroundTintColor(int backgroundColor) { 823 if (backgroundColor == mCurrentBackgroundColor) { 824 // Same color ignoring. 825 return; 826 } 827 mCurrentBackgroundColor = backgroundColor; 828 829 final boolean dark = !ContrastColorUtil.isColorLight(backgroundColor); 830 831 int textColor = ContrastColorUtil.ensureTextContrast( 832 dark ? mDefaultTextColorDarkBg : mDefaultTextColor, 833 backgroundColor | 0xff000000, dark); 834 int strokeColor = ContrastColorUtil.ensureContrast( 835 mDefaultStrokeColor, backgroundColor | 0xff000000, dark, mMinStrokeContrast); 836 int rippleColor = dark ? mRippleColorDarkBg : mRippleColor; 837 838 int childCount = getChildCount(); 839 for (int i = 0; i < childCount; i++) { 840 final Button child = (Button) getChildAt(i); 841 setButtonColors(child, backgroundColor, strokeColor, textColor, rippleColor, 842 mStrokeWidth); 843 } 844 } 845 setButtonColors(Button button, int backgroundColor, int strokeColor, int textColor, int rippleColor, int strokeWidth)846 private static void setButtonColors(Button button, int backgroundColor, int strokeColor, 847 int textColor, int rippleColor, int strokeWidth) { 848 Drawable drawable = button.getBackground(); 849 if (drawable instanceof RippleDrawable) { 850 // Mutate in case other notifications are using this drawable. 851 drawable = drawable.mutate(); 852 RippleDrawable ripple = (RippleDrawable) drawable; 853 ripple.setColor(ColorStateList.valueOf(rippleColor)); 854 Drawable inset = ripple.getDrawable(0); 855 if (inset instanceof InsetDrawable) { 856 Drawable background = ((InsetDrawable) inset).getDrawable(); 857 if (background instanceof GradientDrawable) { 858 GradientDrawable gradientDrawable = (GradientDrawable) background; 859 gradientDrawable.setColor(backgroundColor); 860 gradientDrawable.setStroke(strokeWidth, strokeColor); 861 } 862 } 863 button.setBackground(drawable); 864 } 865 button.setTextColor(textColor); 866 } 867 setCornerRadius(Button button, float radius)868 private void setCornerRadius(Button button, float radius) { 869 Drawable drawable = button.getBackground(); 870 if (drawable instanceof RippleDrawable) { 871 // Mutate in case other notifications are using this drawable. 872 drawable = drawable.mutate(); 873 RippleDrawable ripple = (RippleDrawable) drawable; 874 Drawable inset = ripple.getDrawable(0); 875 if (inset instanceof InsetDrawable) { 876 Drawable background = ((InsetDrawable) inset).getDrawable(); 877 if (background instanceof GradientDrawable) { 878 GradientDrawable gradientDrawable = (GradientDrawable) background; 879 gradientDrawable.setCornerRadius(radius); 880 } 881 } 882 } 883 } 884 getActivityStarter()885 private ActivityStarter getActivityStarter() { 886 if (mActivityStarter == null) { 887 mActivityStarter = Dependency.get(ActivityStarter.class); 888 } 889 return mActivityStarter; 890 } 891 892 private enum SmartButtonType { 893 REPLY, 894 ACTION 895 } 896 897 @VisibleForTesting 898 static class LayoutParams extends ViewGroup.LayoutParams { 899 900 /** Button is not squeezed. */ 901 private static final int SQUEEZE_STATUS_NONE = 0; 902 903 /** 904 * Button was successfully squeezed, but it might be un-squeezed later if the squeezing 905 * turns out to have been unnecessary (because there's still not enough space to add another 906 * button). 907 */ 908 private static final int SQUEEZE_STATUS_PENDING = 1; 909 910 /** Button was successfully squeezed and it won't be un-squeezed. */ 911 private static final int SQUEEZE_STATUS_SUCCESSFUL = 2; 912 913 /** 914 * Button wasn't successfully squeezed. The squeezing resulted in more than two lines of 915 * text or it didn't reduce the button's width at all. The button will have to be 916 * re-measured to use only one line of text. 917 */ 918 private static final int SQUEEZE_STATUS_FAILED = 3; 919 920 private boolean show = false; 921 private int squeezeStatus = SQUEEZE_STATUS_NONE; 922 private SmartButtonType buttonType = SmartButtonType.REPLY; 923 LayoutParams(Context c, AttributeSet attrs)924 private LayoutParams(Context c, AttributeSet attrs) { 925 super(c, attrs); 926 } 927 LayoutParams(int width, int height)928 private LayoutParams(int width, int height) { 929 super(width, height); 930 } 931 932 @VisibleForTesting isShown()933 boolean isShown() { 934 return show; 935 } 936 } 937 938 /** 939 * Data class for smart replies. 940 */ 941 public static class SmartReplies { 942 @NonNull 943 public final RemoteInput remoteInput; 944 @NonNull 945 public final PendingIntent pendingIntent; 946 @NonNull 947 public final CharSequence[] choices; 948 public final boolean fromAssistant; 949 SmartReplies(CharSequence[] choices, RemoteInput remoteInput, PendingIntent pendingIntent, boolean fromAssistant)950 public SmartReplies(CharSequence[] choices, RemoteInput remoteInput, 951 PendingIntent pendingIntent, boolean fromAssistant) { 952 this.choices = choices; 953 this.remoteInput = remoteInput; 954 this.pendingIntent = pendingIntent; 955 this.fromAssistant = fromAssistant; 956 } 957 } 958 959 960 /** 961 * Data class for smart actions. 962 */ 963 public static class SmartActions { 964 @NonNull 965 public final List<Notification.Action> actions; 966 public final boolean fromAssistant; 967 SmartActions(List<Notification.Action> actions, boolean fromAssistant)968 public SmartActions(List<Notification.Action> actions, boolean fromAssistant) { 969 this.actions = actions; 970 this.fromAssistant = fromAssistant; 971 } 972 } 973 974 /** 975 * An OnClickListener wrapper that blocks the underlying OnClickListener for a given amount of 976 * time. 977 */ 978 private static class DelayedOnClickListener implements OnClickListener { 979 private final OnClickListener mActualListener; 980 private final long mInitDelayMs; 981 private final long mInitTimeMs; 982 DelayedOnClickListener(OnClickListener actualOnClickListener, long initDelayMs)983 DelayedOnClickListener(OnClickListener actualOnClickListener, long initDelayMs) { 984 mActualListener = actualOnClickListener; 985 mInitDelayMs = initDelayMs; 986 mInitTimeMs = SystemClock.elapsedRealtime(); 987 } 988 onClick(View v)989 public void onClick(View v) { 990 if (hasFinishedInitialization()) { 991 mActualListener.onClick(v); 992 } else { 993 Log.i(TAG, "Accidental Smart Suggestion click registered, delay: " + mInitDelayMs); 994 } 995 } 996 hasFinishedInitialization()997 private boolean hasFinishedInitialization() { 998 return SystemClock.elapsedRealtime() >= mInitTimeMs + mInitDelayMs; 999 } 1000 } 1001 } 1002