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