1 /* 2 * Copyright (C) 2020 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.internal.widget; 18 19 import static com.android.internal.widget.MessagingGroup.IMAGE_DISPLAY_LOCATION_EXTERNAL; 20 import static com.android.internal.widget.MessagingGroup.IMAGE_DISPLAY_LOCATION_INLINE; 21 22 import android.animation.Animator; 23 import android.animation.AnimatorListenerAdapter; 24 import android.animation.AnimatorSet; 25 import android.animation.ValueAnimator; 26 import android.annotation.AttrRes; 27 import android.annotation.NonNull; 28 import android.annotation.Nullable; 29 import android.annotation.StyleRes; 30 import android.app.Notification; 31 import android.app.Person; 32 import android.app.RemoteInputHistoryItem; 33 import android.content.Context; 34 import android.content.res.ColorStateList; 35 import android.graphics.Rect; 36 import android.graphics.Typeface; 37 import android.graphics.drawable.Drawable; 38 import android.graphics.drawable.GradientDrawable; 39 import android.graphics.drawable.Icon; 40 import android.os.Bundle; 41 import android.os.Parcelable; 42 import android.text.Spannable; 43 import android.text.SpannableString; 44 import android.text.TextUtils; 45 import android.text.style.StyleSpan; 46 import android.util.ArrayMap; 47 import android.util.AttributeSet; 48 import android.util.DisplayMetrics; 49 import android.view.Gravity; 50 import android.view.MotionEvent; 51 import android.view.RemotableViewMethod; 52 import android.view.TouchDelegate; 53 import android.view.View; 54 import android.view.ViewGroup; 55 import android.view.ViewTreeObserver; 56 import android.view.animation.Interpolator; 57 import android.view.animation.PathInterpolator; 58 import android.widget.FrameLayout; 59 import android.widget.ImageView; 60 import android.widget.LinearLayout; 61 import android.widget.RemoteViews; 62 import android.widget.TextView; 63 import android.widget.flags.Flags; 64 65 import com.android.internal.R; 66 import com.android.internal.widget.ConversationAvatarData.GroupConversationAvatarData; 67 import com.android.internal.widget.ConversationAvatarData.OneToOneConversationAvatarData; 68 69 import java.util.ArrayList; 70 import java.util.List; 71 import java.util.Map; 72 import java.util.Objects; 73 74 /** 75 * A custom-built layout for the Notification.MessagingStyle allows dynamic addition and removal 76 * messages and adapts the layout accordingly. 77 */ 78 @RemoteViews.RemoteView 79 public class ConversationLayout extends FrameLayout 80 implements ImageMessageConsumer, IMessagingLayout { 81 82 public static final Interpolator LINEAR_OUT_SLOW_IN = new PathInterpolator(0f, 0f, 0.2f, 1f); 83 public static final Interpolator FAST_OUT_LINEAR_IN = new PathInterpolator(0.4f, 0f, 1f, 1f); 84 public static final Interpolator FAST_OUT_SLOW_IN = new PathInterpolator(0.4f, 0f, 0.2f, 1f); 85 public static final Interpolator OVERSHOOT = new PathInterpolator(0.4f, 0f, 0.2f, 1.4f); 86 public static final OnLayoutChangeListener MESSAGING_PROPERTY_ANIMATOR 87 = new MessagingPropertyAnimator(); 88 public static final int IMPORTANCE_ANIM_GROW_DURATION = 250; 89 public static final int IMPORTANCE_ANIM_SHRINK_DURATION = 200; 90 public static final int IMPORTANCE_ANIM_SHRINK_DELAY = 25; 91 private final PeopleHelper mPeopleHelper = new PeopleHelper(); 92 private List<MessagingMessage> mMessages = new ArrayList<>(); 93 private List<MessagingMessage> mHistoricMessages = new ArrayList<>(); 94 private MessagingLinearLayout mMessagingLinearLayout; 95 private boolean mShowHistoricMessages; 96 private ArrayList<MessagingGroup> mGroups = new ArrayList<>(); 97 private int mLayoutColor; 98 private int mSenderTextColor; 99 private int mMessageTextColor; 100 private Icon mAvatarReplacement; 101 private boolean mIsOneToOne; 102 private ArrayList<MessagingGroup> mAddedGroups = new ArrayList<>(); 103 private Person mUser; 104 private CharSequence mNameReplacement; 105 private boolean mIsCollapsed; 106 private ImageResolver mImageResolver; 107 private CachingIconView mConversationIconView; 108 private View mConversationIconContainer; 109 private int mConversationIconTopPaddingExpandedGroup; 110 private int mConversationIconTopPadding; 111 private int mExpandedGroupMessagePadding; 112 // TODO (b/217799515) Currently, mConversationText shows the conversation title, the actual 113 // conversation text is inside of mMessagingLinearLayout, which is misleading, we should rename 114 // this to mConversationTitleView 115 private TextView mConversationText; 116 private View mConversationIconBadge; 117 private CachingIconView mConversationIconBadgeBg; 118 private Icon mLargeIcon; 119 private View mExpandButtonContainer; 120 private ViewGroup mExpandButtonAndContentContainer; 121 private ViewGroup mExpandButtonContainerA11yContainer; 122 private NotificationExpandButton mExpandButton; 123 private MessagingLinearLayout mImageMessageContainer; 124 private int mBadgeProtrusion; 125 private int mConversationAvatarSize; 126 private int mConversationAvatarSizeExpanded; 127 private CachingIconView mIcon; 128 private CachingIconView mImportanceRingView; 129 private int mExpandedGroupBadgeProtrusion; 130 private int mExpandedGroupBadgeProtrusionFacePile; 131 private View mConversationFacePile; 132 private int mNotificationBackgroundColor; 133 private CharSequence mFallbackChatName; 134 private CharSequence mFallbackGroupChatName; 135 //TODO (b/217799515) Currently, Notification.MessagingStyle, ConversationLayout, and 136 // HybridConversationNotificationView, each has their own definition of "ConversationTitle". 137 // What make things worse is that the term of "ConversationTitle" often confuses with 138 // "ConversationText". 139 // We need to unify them or differentiate the namings. 140 private CharSequence mConversationTitle; 141 private int mMessageSpacingStandard; 142 private int mMessageSpacingGroup; 143 private int mNotificationHeaderExpandedPadding; 144 private View mConversationHeader; 145 private View mContentContainer; 146 private boolean mExpandable = true; 147 private int mContentMarginEnd; 148 private Rect mMessagingClipRect; 149 private ObservableTextView mAppName; 150 private NotificationActionListLayout mActions; 151 private boolean mAppNameGone; 152 private int mFacePileAvatarSize; 153 private int mFacePileAvatarSizeExpandedGroup; 154 private int mFacePileProtectionWidth; 155 private int mFacePileProtectionWidthExpanded; 156 private boolean mImportantConversation; 157 private View mFeedbackIcon; 158 private float mMinTouchSize; 159 private Icon mConversationIcon; 160 private Icon mShortcutIcon; 161 private View mAppNameDivider; 162 private TouchDelegateComposite mTouchDelegate = new TouchDelegateComposite(this); 163 private ArrayList<MessagingLinearLayout.MessagingChild> mToRecycle = new ArrayList<>(); 164 private boolean mPrecomputedTextEnabled = false; 165 @Nullable 166 private ConversationHeaderData mConversationHeaderData; 167 ConversationLayout(@onNull Context context)168 public ConversationLayout(@NonNull Context context) { 169 super(context); 170 } 171 ConversationLayout(@onNull Context context, @Nullable AttributeSet attrs)172 public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs) { 173 super(context, attrs); 174 } 175 ConversationLayout(@onNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr)176 public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs, 177 @AttrRes int defStyleAttr) { 178 super(context, attrs, defStyleAttr); 179 } 180 ConversationLayout(@onNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr, @StyleRes int defStyleRes)181 public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs, 182 @AttrRes int defStyleAttr, @StyleRes int defStyleRes) { 183 super(context, attrs, defStyleAttr, defStyleRes); 184 } 185 186 @Override onFinishInflate()187 protected void onFinishInflate() { 188 super.onFinishInflate(); 189 mPeopleHelper.init(getContext()); 190 mMessagingLinearLayout = findViewById(R.id.notification_messaging); 191 mActions = findViewById(R.id.actions); 192 mImageMessageContainer = findViewById(R.id.conversation_image_message_container); 193 // We still want to clip, but only on the top, since views can temporarily out of bounds 194 // during transitions. 195 DisplayMetrics displayMetrics = getResources().getDisplayMetrics(); 196 int size = Math.max(displayMetrics.widthPixels, displayMetrics.heightPixels); 197 mMessagingClipRect = new Rect(0, 0, size, size); 198 setMessagingClippingDisabled(false); 199 mConversationIconView = findViewById(R.id.conversation_icon); 200 mConversationIconContainer = findViewById(R.id.conversation_icon_container); 201 mIcon = findViewById(R.id.icon); 202 mFeedbackIcon = findViewById(com.android.internal.R.id.feedback); 203 mMinTouchSize = 48 * getResources().getDisplayMetrics().density; 204 mImportanceRingView = findViewById(R.id.conversation_icon_badge_ring); 205 mConversationIconBadge = findViewById(R.id.conversation_icon_badge); 206 mConversationIconBadgeBg = findViewById(R.id.conversation_icon_badge_bg); 207 mIcon.setOnVisibilityChangedListener((visibility) -> { 208 209 // Let's hide the background directly or in an animated way 210 boolean isGone = visibility == GONE; 211 int oldVisibility = mConversationIconBadgeBg.getVisibility(); 212 boolean wasGone = oldVisibility == GONE; 213 if (wasGone != isGone) { 214 // Keep the badge gone state in sync with the icon. This is necessary in cases 215 // Where the icon is being hidden externally like in group children. 216 mConversationIconBadgeBg.animate().cancel(); 217 mConversationIconBadgeBg.setVisibility(visibility); 218 } 219 220 // Let's handle the importance ring which can also be be gone normally 221 oldVisibility = mImportanceRingView.getVisibility(); 222 wasGone = oldVisibility == GONE; 223 visibility = !mImportantConversation ? GONE : visibility; 224 boolean isRingGone = visibility == GONE; 225 if (wasGone != isRingGone) { 226 // Keep the badge visibility in sync with the icon. This is necessary in cases 227 // Where the icon is being hidden externally like in group children. 228 mImportanceRingView.animate().cancel(); 229 mImportanceRingView.setVisibility(visibility); 230 } 231 232 oldVisibility = mConversationIconBadge.getVisibility(); 233 wasGone = oldVisibility == GONE; 234 if (wasGone != isGone) { 235 mConversationIconBadge.animate().cancel(); 236 mConversationIconBadge.setVisibility(visibility); 237 } 238 }); 239 // When the small icon is gone, hide the rest of the badge 240 mIcon.setOnForceHiddenChangedListener((forceHidden) -> { 241 mPeopleHelper.animateViewForceHidden(mConversationIconBadgeBg, forceHidden); 242 mPeopleHelper.animateViewForceHidden(mImportanceRingView, forceHidden); 243 }); 244 245 // When the conversation icon is gone, hide the whole badge 246 mConversationIconView.setOnForceHiddenChangedListener((forceHidden) -> { 247 mPeopleHelper.animateViewForceHidden(mConversationIconBadgeBg, forceHidden); 248 mPeopleHelper.animateViewForceHidden(mImportanceRingView, forceHidden); 249 mPeopleHelper.animateViewForceHidden(mIcon, forceHidden); 250 }); 251 mConversationText = findViewById(R.id.conversation_text); 252 mExpandButtonContainer = findViewById(R.id.expand_button_container); 253 mExpandButtonContainerA11yContainer = 254 findViewById(R.id.expand_button_a11y_container); 255 mConversationHeader = findViewById(R.id.conversation_header); 256 mContentContainer = findViewById(R.id.notification_action_list_margin_target); 257 mExpandButtonAndContentContainer = findViewById(R.id.expand_button_and_content_container); 258 mExpandButton = findViewById(R.id.expand_button); 259 mMessageSpacingStandard = getResources().getDimensionPixelSize( 260 R.dimen.notification_messaging_spacing); 261 mMessageSpacingGroup = getResources().getDimensionPixelSize( 262 R.dimen.notification_messaging_spacing_conversation_group); 263 mNotificationHeaderExpandedPadding = getResources().getDimensionPixelSize( 264 R.dimen.conversation_header_expanded_padding_end); 265 mContentMarginEnd = getResources().getDimensionPixelSize( 266 R.dimen.notification_content_margin_end); 267 mBadgeProtrusion = getResources().getDimensionPixelSize( 268 R.dimen.conversation_badge_protrusion); 269 mConversationAvatarSize = getResources().getDimensionPixelSize( 270 R.dimen.conversation_avatar_size); 271 mConversationAvatarSizeExpanded = getResources().getDimensionPixelSize( 272 R.dimen.conversation_avatar_size_group_expanded); 273 mConversationIconTopPaddingExpandedGroup = getResources().getDimensionPixelSize( 274 R.dimen.conversation_icon_container_top_padding_small_avatar); 275 mConversationIconTopPadding = getResources().getDimensionPixelSize( 276 R.dimen.conversation_icon_container_top_padding); 277 mExpandedGroupMessagePadding = getResources().getDimensionPixelSize( 278 R.dimen.expanded_group_conversation_message_padding); 279 mExpandedGroupBadgeProtrusion = getResources().getDimensionPixelSize( 280 R.dimen.conversation_badge_protrusion_group_expanded); 281 mExpandedGroupBadgeProtrusionFacePile = getResources().getDimensionPixelSize( 282 R.dimen.conversation_badge_protrusion_group_expanded_face_pile); 283 mConversationFacePile = findViewById(R.id.conversation_face_pile); 284 mFacePileAvatarSize = getResources().getDimensionPixelSize( 285 R.dimen.conversation_face_pile_avatar_size); 286 mFacePileAvatarSizeExpandedGroup = getResources().getDimensionPixelSize( 287 R.dimen.conversation_face_pile_avatar_size_group_expanded); 288 mFacePileProtectionWidth = getResources().getDimensionPixelSize( 289 R.dimen.conversation_face_pile_protection_width); 290 mFacePileProtectionWidthExpanded = getResources().getDimensionPixelSize( 291 R.dimen.conversation_face_pile_protection_width_expanded); 292 mFallbackChatName = getResources().getString( 293 R.string.conversation_title_fallback_one_to_one); 294 mFallbackGroupChatName = getResources().getString( 295 R.string.conversation_title_fallback_group_chat); 296 mAppName = findViewById(R.id.app_name_text); 297 mAppNameDivider = findViewById(R.id.app_name_divider); 298 mAppNameGone = mAppName.getVisibility() == GONE; 299 mAppName.setOnVisibilityChangedListener((visibility) -> { 300 onAppNameVisibilityChanged(); 301 }); 302 } 303 304 @RemotableViewMethod setAvatarReplacement(Icon icon)305 public void setAvatarReplacement(Icon icon) { 306 mAvatarReplacement = icon; 307 } 308 309 @RemotableViewMethod setNameReplacement(CharSequence nameReplacement)310 public void setNameReplacement(CharSequence nameReplacement) { 311 mNameReplacement = nameReplacement; 312 } 313 314 /** 315 * Sets this conversation as "important", adding some additional UI treatment. 316 */ 317 @RemotableViewMethod setIsImportantConversation(boolean isImportantConversation)318 public void setIsImportantConversation(boolean isImportantConversation) { 319 setIsImportantConversation(isImportantConversation, false); 320 } 321 322 /** 323 * @hide 324 **/ setIsImportantConversation(boolean isImportantConversation, boolean animate)325 public void setIsImportantConversation(boolean isImportantConversation, boolean animate) { 326 mImportantConversation = isImportantConversation; 327 mImportanceRingView.setVisibility(isImportantConversation && mIcon.getVisibility() != GONE 328 ? VISIBLE : GONE); 329 330 if (animate && isImportantConversation) { 331 GradientDrawable ring = (GradientDrawable) mImportanceRingView.getDrawable(); 332 ring.mutate(); 333 GradientDrawable bg = (GradientDrawable) mConversationIconBadgeBg.getDrawable(); 334 bg.mutate(); 335 int ringColor = getResources() 336 .getColor(R.color.conversation_important_highlight); 337 int standardThickness = getResources() 338 .getDimensionPixelSize(R.dimen.importance_ring_stroke_width); 339 int largeThickness = getResources() 340 .getDimensionPixelSize(R.dimen.importance_ring_anim_max_stroke_width); 341 int standardSize = getResources().getDimensionPixelSize( 342 R.dimen.importance_ring_size); 343 int baseSize = standardSize - standardThickness * 2; 344 int bgSize = getResources() 345 .getDimensionPixelSize(R.dimen.conversation_icon_size_badged); 346 347 ValueAnimator.AnimatorUpdateListener animatorUpdateListener = animation -> { 348 int strokeWidth = Math.round((float) animation.getAnimatedValue()); 349 ring.setStroke(strokeWidth, ringColor); 350 int newSize = baseSize + strokeWidth * 2; 351 ring.setSize(newSize, newSize); 352 mImportanceRingView.invalidate(); 353 }; 354 355 ValueAnimator growAnimation = ValueAnimator.ofFloat(0, largeThickness); 356 growAnimation.setInterpolator(LINEAR_OUT_SLOW_IN); 357 growAnimation.setDuration(IMPORTANCE_ANIM_GROW_DURATION); 358 growAnimation.addUpdateListener(animatorUpdateListener); 359 360 ValueAnimator shrinkAnimation = 361 ValueAnimator.ofFloat(largeThickness, standardThickness); 362 shrinkAnimation.setDuration(IMPORTANCE_ANIM_SHRINK_DURATION); 363 shrinkAnimation.setStartDelay(IMPORTANCE_ANIM_SHRINK_DELAY); 364 shrinkAnimation.setInterpolator(OVERSHOOT); 365 shrinkAnimation.addUpdateListener(animatorUpdateListener); 366 shrinkAnimation.addListener(new AnimatorListenerAdapter() { 367 @Override 368 public void onAnimationStart(Animator animation) { 369 // Shrink the badge bg so that it doesn't peek behind the animation 370 bg.setSize(baseSize, baseSize); 371 mConversationIconBadgeBg.invalidate(); 372 } 373 374 @Override 375 public void onAnimationEnd(Animator animation) { 376 // Reset bg back to normal size 377 bg.setSize(bgSize, bgSize); 378 mConversationIconBadgeBg.invalidate(); 379 } 380 }); 381 382 AnimatorSet anims = new AnimatorSet(); 383 anims.playSequentially(growAnimation, shrinkAnimation); 384 anims.start(); 385 } 386 } 387 isImportantConversation()388 public boolean isImportantConversation() { 389 return mImportantConversation; 390 } 391 392 /** 393 * Set this layout to show the collapsed representation. 394 * 395 * @param isCollapsed is it collapsed 396 */ 397 @RemotableViewMethod setIsCollapsed(boolean isCollapsed)398 public void setIsCollapsed(boolean isCollapsed) { 399 mIsCollapsed = isCollapsed; 400 mMessagingLinearLayout.setMaxDisplayedLines(isCollapsed ? 1 : Integer.MAX_VALUE); 401 updateExpandButton(); 402 updateContentEndPaddings(); 403 } 404 405 /** 406 * Set conversation data 407 * 408 * @param extras Bundle contains conversation data 409 */ 410 @RemotableViewMethod(asyncImpl = "setDataAsync") setData(Bundle extras)411 public void setData(Bundle extras) { 412 bind(parseMessagingData(extras, 413 /* usePrecomputedText= */ false, 414 /*includeConversationIcon= */false)); 415 } 416 417 @NonNull parseMessagingData(Bundle extras, boolean usePrecomputedText, boolean includeConversationIcon)418 private MessagingData parseMessagingData(Bundle extras, boolean usePrecomputedText, 419 boolean includeConversationIcon) { 420 Parcelable[] messages = extras.getParcelableArray(Notification.EXTRA_MESSAGES); 421 List<Notification.MessagingStyle.Message> newMessages = 422 Notification.MessagingStyle.Message.getMessagesFromBundleArray(messages); 423 Parcelable[] histMessages = extras.getParcelableArray(Notification.EXTRA_HISTORIC_MESSAGES); 424 List<Notification.MessagingStyle.Message> newHistoricMessages = 425 Notification.MessagingStyle.Message.getMessagesFromBundleArray(histMessages); 426 427 // mUser now set (would be nice to avoid the side effect but WHATEVER) 428 final Person user = extras.getParcelable(Notification.EXTRA_MESSAGING_PERSON, Person.class); 429 // Append remote input history to newMessages (again, side effect is lame but WHATEVS) 430 RemoteInputHistoryItem[] history = (RemoteInputHistoryItem[]) 431 extras.getParcelableArray(Notification.EXTRA_REMOTE_INPUT_HISTORY_ITEMS, 432 RemoteInputHistoryItem.class); 433 addRemoteInputHistoryToMessages(newMessages, history); 434 435 boolean showSpinner = 436 extras.getBoolean(Notification.EXTRA_SHOW_REMOTE_INPUT_SPINNER, false); 437 int unreadCount = extras.getInt(Notification.EXTRA_CONVERSATION_UNREAD_MESSAGE_COUNT); 438 439 final List<MessagingMessage> newMessagingMessages = 440 createMessages(newMessages, /* isHistoric= */false, usePrecomputedText); 441 final List<MessagingMessage> newHistoricMessagingMessages = 442 createMessages(newHistoricMessages, /* isHistoric= */true, usePrecomputedText); 443 444 // Add our new MessagingMessages to groups 445 List<List<MessagingMessage>> groups = new ArrayList<>(); 446 List<Person> senders = new ArrayList<>(); 447 // Lets first find the groups (populate `groups` and `senders`) 448 findGroups(newHistoricMessagingMessages, newMessagingMessages, user, groups, senders); 449 450 // load conversation header data, avatar and title. 451 final ConversationHeaderData conversationHeaderData; 452 if (includeConversationIcon && Flags.conversationStyleSetAvatarAsync()) { 453 conversationHeaderData = loadConversationHeaderData(mIsOneToOne, 454 mConversationTitle, 455 mShortcutIcon, 456 mLargeIcon, newMessagingMessages, user, groups, mLayoutColor); 457 } else { 458 conversationHeaderData = null; 459 } 460 461 return new MessagingData(user, showSpinner, unreadCount, 462 newHistoricMessagingMessages, newMessagingMessages, groups, senders, 463 conversationHeaderData); 464 } 465 466 /** 467 * RemotableViewMethod's asyncImpl of {@link #setData(Bundle)}. 468 * This should be called on a background thread, and returns a Runnable which is then must be 469 * called on the main thread to complete the operation and set text. 470 * 471 * @param extras Bundle contains conversation data 472 * @hide 473 */ 474 @NonNull setDataAsync(Bundle extras)475 public Runnable setDataAsync(Bundle extras) { 476 if (!mPrecomputedTextEnabled) { 477 return () -> setData(extras); 478 } 479 480 final MessagingData messagingData = 481 parseMessagingData(extras, 482 /* usePrecomputedText= */ true, 483 /*includeConversationIcon=*/true); 484 485 return () -> { 486 finalizeInflate(messagingData.getHistoricMessagingMessages()); 487 finalizeInflate(messagingData.getNewMessagingMessages()); 488 489 bind(messagingData); 490 }; 491 } 492 493 /** 494 * enable/disable precomputed text usage 495 * 496 * @hide 497 */ setPrecomputedTextEnabled(boolean precomputedTextEnabled)498 public void setPrecomputedTextEnabled(boolean precomputedTextEnabled) { 499 mPrecomputedTextEnabled = precomputedTextEnabled; 500 } 501 finalizeInflate(List<MessagingMessage> historicMessagingMessages)502 private void finalizeInflate(List<MessagingMessage> historicMessagingMessages) { 503 for (MessagingMessage messagingMessage : historicMessagingMessages) { 504 messagingMessage.finalizeInflate(); 505 } 506 } 507 508 @Override setImageResolver(ImageResolver resolver)509 public void setImageResolver(ImageResolver resolver) { 510 mImageResolver = resolver; 511 } 512 513 /** 514 * @hide 515 */ setUnreadCount(int unreadCount)516 public void setUnreadCount(int unreadCount) { 517 mExpandButton.setNumber(unreadCount); 518 } 519 addRemoteInputHistoryToMessages( List<Notification.MessagingStyle.Message> newMessages, RemoteInputHistoryItem[] remoteInputHistory)520 private void addRemoteInputHistoryToMessages( 521 List<Notification.MessagingStyle.Message> newMessages, 522 RemoteInputHistoryItem[] remoteInputHistory) { 523 if (remoteInputHistory == null || remoteInputHistory.length == 0) { 524 return; 525 } 526 for (int i = remoteInputHistory.length - 1; i >= 0; i--) { 527 RemoteInputHistoryItem historyMessage = remoteInputHistory[i]; 528 Notification.MessagingStyle.Message message = new Notification.MessagingStyle.Message( 529 historyMessage.getText(), 0, (Person) null, true /* remoteHistory */); 530 if (historyMessage.getUri() != null) { 531 message.setData(historyMessage.getMimeType(), historyMessage.getUri()); 532 } 533 newMessages.add(message); 534 } 535 } 536 bind(MessagingData messagingData)537 private void bind(MessagingData messagingData) { 538 setUser(messagingData.getUser()); 539 setUnreadCount(messagingData.getUnreadCount()); 540 541 // Copy our groups, before they get clobbered 542 ArrayList<MessagingGroup> oldGroups = new ArrayList<>(mGroups); 543 544 // Let's now create the views and reorder them accordingly 545 // side-effect: updates mGroups, mAddedGroups 546 createGroupViews(messagingData.getGroups(), messagingData.getSenders(), 547 messagingData.getShowSpinner()); 548 549 // Let's first check which groups were removed altogether and remove them in one animation 550 removeGroups(oldGroups); 551 552 // Let's remove the remaining messages 553 for (MessagingMessage message : mMessages) { 554 message.removeMessage(mToRecycle); 555 } 556 for (MessagingMessage historicMessage : mHistoricMessages) { 557 historicMessage.removeMessage(mToRecycle); 558 } 559 560 mMessages = messagingData.getNewMessagingMessages(); 561 mHistoricMessages = messagingData.getHistoricMessagingMessages(); 562 updateHistoricMessageVisibility(); 563 updateTitleAndNamesDisplay(); 564 565 updateConversationLayout(messagingData); 566 567 // Recycle everything at the end of the update, now that we know it's no longer needed. 568 for (MessagingLinearLayout.MessagingChild child : mToRecycle) { 569 child.recycle(); 570 } 571 mToRecycle.clear(); 572 } 573 574 /** 575 * Update the layout according to the data provided (i.e mIsOneToOne, expanded etc); 576 */ updateConversationLayout(MessagingData messagingData)577 private void updateConversationLayout(MessagingData messagingData) { 578 if (!Flags.conversationStyleSetAvatarAsync()) { 579 computeAndSetConversationAvatarAndName(); 580 } else { 581 ConversationHeaderData conversationHeaderData = 582 messagingData.getConversationHeaderData(); 583 if (conversationHeaderData == null) { 584 conversationHeaderData = loadConversationHeaderData(mIsOneToOne, 585 mConversationTitle, mShortcutIcon, mLargeIcon, mMessages, mUser, 586 messagingData.getGroups(), 587 mLayoutColor); 588 } 589 setConversationAvatarAndNameFromData(conversationHeaderData); 590 } 591 592 updateAppName(); 593 updateIconPositionAndSize(); 594 updateImageMessages(); 595 updatePaddingsBasedOnContentAvailability(); 596 updateActionListPadding(); 597 updateAppNameDividerVisibility(); 598 } 599 600 @Deprecated computeAndSetConversationAvatarAndName()601 private void computeAndSetConversationAvatarAndName() { 602 // Set avatar and name 603 CharSequence conversationText = mConversationTitle; 604 mConversationIcon = mShortcutIcon; 605 if (mIsOneToOne) { 606 // Let's resolve the icon / text from the last sender 607 CharSequence userKey = getKey(mUser); 608 for (int i = mGroups.size() - 1; i >= 0; i--) { 609 MessagingGroup messagingGroup = mGroups.get(i); 610 Person messageSender = messagingGroup.getSender(); 611 if ((messageSender != null && !TextUtils.equals(userKey, getKey(messageSender))) 612 || i == 0) { 613 if (TextUtils.isEmpty(conversationText)) { 614 // We use the sendername as header text if no conversation title is provided 615 // (This usually happens for most 1:1 conversations) 616 conversationText = messagingGroup.getSenderName(); 617 } 618 if (mConversationIcon == null) { 619 Icon avatarIcon = messagingGroup.getAvatarIcon(); 620 if (avatarIcon == null) { 621 avatarIcon = mPeopleHelper.createAvatarSymbol(conversationText, "", 622 mLayoutColor); 623 } 624 mConversationIcon = avatarIcon; 625 } 626 break; 627 } 628 } 629 } 630 if (mConversationIcon == null) { 631 mConversationIcon = mLargeIcon; 632 } 633 if (mIsOneToOne || mConversationIcon != null) { 634 mConversationIconView.setVisibility(VISIBLE); 635 mConversationFacePile.setVisibility(GONE); 636 mConversationIconView.setImageIcon(mConversationIcon); 637 } else { 638 mConversationIconView.setVisibility(GONE); 639 // This will also inflate it! 640 mConversationFacePile.setVisibility(VISIBLE); 641 // rebind the value to the inflated view instead of the stub 642 mConversationFacePile = findViewById(R.id.conversation_face_pile); 643 bindFacePile(); 644 } 645 if (TextUtils.isEmpty(conversationText)) { 646 conversationText = mIsOneToOne ? mFallbackChatName : mFallbackGroupChatName; 647 } 648 mConversationText.setText(conversationText); 649 // Update if the groups can hide the sender if they are first (applies to 1:1 conversations) 650 // This needs to happen after all of the above o update all of the groups 651 mPeopleHelper.maybeHideFirstSenderName(mGroups, mIsOneToOne, conversationText); 652 } 653 setConversationAvatarAndNameFromData( ConversationHeaderData conversationHeaderData)654 private void setConversationAvatarAndNameFromData( 655 ConversationHeaderData conversationHeaderData) { 656 mConversationHeaderData = conversationHeaderData; 657 final OneToOneConversationAvatarData oneToOneConversationDrawable; 658 final GroupConversationAvatarData groupConversationAvatarData; 659 final ConversationAvatarData conversationAvatar = 660 conversationHeaderData.getConversationAvatar(); 661 if (conversationAvatar instanceof OneToOneConversationAvatarData) { 662 oneToOneConversationDrawable = 663 ((OneToOneConversationAvatarData) conversationAvatar); 664 groupConversationAvatarData = null; 665 } else { 666 oneToOneConversationDrawable = null; 667 groupConversationAvatarData = ((GroupConversationAvatarData) conversationAvatar); 668 } 669 670 if (oneToOneConversationDrawable != null) { 671 mConversationIconView.setVisibility(VISIBLE); 672 mConversationFacePile.setVisibility(GONE); 673 mConversationIconView.setImageDrawable(oneToOneConversationDrawable.mDrawable); 674 } else { 675 mConversationIconView.setVisibility(GONE); 676 // This will also inflate it! 677 mConversationFacePile.setVisibility(VISIBLE); 678 // rebind the value to the inflated view instead of the stub 679 mConversationFacePile = findViewById(R.id.conversation_face_pile); 680 bindFacePile(groupConversationAvatarData); 681 } 682 CharSequence conversationText = conversationHeaderData.getConversationText(); 683 if (TextUtils.isEmpty(conversationText)) { 684 conversationText = mIsOneToOne ? mFallbackChatName : mFallbackGroupChatName; 685 } 686 mConversationText.setText(conversationText); 687 // Update if the groups can hide the sender if they are first (applies to 1:1 conversations) 688 // This needs to happen after all of the above o update all of the groups 689 mPeopleHelper.maybeHideFirstSenderName(mGroups, mIsOneToOne, conversationText); 690 } 691 updateActionListPadding()692 private void updateActionListPadding() { 693 if (mActions != null) { 694 mActions.setCollapsibleIndentDimen(R.dimen.call_notification_collapsible_indent); 695 } 696 } 697 updateImageMessages()698 private void updateImageMessages() { 699 View newMessage = null; 700 if (mIsCollapsed && mGroups.size() > 0) { 701 702 // When collapsed, we're displaying the image message in a dedicated container 703 // on the right of the layout instead of inline. Let's add the isolated image there 704 MessagingGroup messagingGroup = mGroups.get(mGroups.size() - 1); 705 MessagingImageMessage isolatedMessage = messagingGroup.getIsolatedMessage(); 706 if (isolatedMessage != null) { 707 newMessage = isolatedMessage.getView(); 708 } 709 } 710 // Remove all messages that don't belong into the image layout 711 View previousMessage = mImageMessageContainer.getChildAt(0); 712 if (previousMessage != newMessage) { 713 mImageMessageContainer.removeView(previousMessage); 714 if (newMessage != null) { 715 mImageMessageContainer.addView(newMessage); 716 } 717 } 718 mImageMessageContainer.setVisibility(newMessage != null ? VISIBLE : GONE); 719 } 720 bindFacePile(ImageView bottomBackground, ImageView bottomView, ImageView topView)721 public void bindFacePile(ImageView bottomBackground, ImageView bottomView, ImageView topView) { 722 applyNotificationBackgroundColor(bottomBackground); 723 // Let's find the two last conversations: 724 Icon secondLastIcon = null; 725 CharSequence lastKey = null; 726 Icon lastIcon = null; 727 CharSequence userKey = getKey(mUser); 728 for (int i = mGroups.size() - 1; i >= 0; i--) { 729 MessagingGroup messagingGroup = mGroups.get(i); 730 Person messageSender = messagingGroup.getSender(); 731 boolean notUser = messageSender != null 732 && !TextUtils.equals(userKey, getKey(messageSender)); 733 boolean notIncluded = messageSender != null 734 && !TextUtils.equals(lastKey, getKey(messageSender)); 735 if ((notUser && notIncluded) 736 || (i == 0 && lastKey == null)) { 737 if (lastIcon == null) { 738 lastIcon = messagingGroup.getAvatarIcon(); 739 lastKey = getKey(messageSender); 740 } else { 741 secondLastIcon = messagingGroup.getAvatarIcon(); 742 break; 743 } 744 } 745 } 746 if (lastIcon == null) { 747 lastIcon = mPeopleHelper.createAvatarSymbol(" ", "", mLayoutColor); 748 } 749 bottomView.setImageIcon(lastIcon); 750 if (secondLastIcon == null) { 751 secondLastIcon = mPeopleHelper.createAvatarSymbol("", "", mLayoutColor); 752 } 753 topView.setImageIcon(secondLastIcon); 754 } 755 756 @Deprecated bindFacePile()757 private void bindFacePile() { 758 bindFacePile(null); 759 } 760 bindFacePile(@ullable GroupConversationAvatarData groupConversationAvatarData)761 private void bindFacePile(@Nullable GroupConversationAvatarData groupConversationAvatarData) { 762 ImageView bottomBackground = mConversationFacePile.findViewById( 763 R.id.conversation_face_pile_bottom_background); 764 ImageView bottomView = mConversationFacePile.findViewById( 765 R.id.conversation_face_pile_bottom); 766 ImageView topView = mConversationFacePile.findViewById( 767 R.id.conversation_face_pile_top); 768 769 if (groupConversationAvatarData == null) { 770 bindFacePile(bottomBackground, bottomView, topView); 771 } else { 772 bindFacePileWithDrawable(bottomBackground, bottomView, topView, 773 groupConversationAvatarData); 774 775 } 776 777 int conversationAvatarSize; 778 int facepileAvatarSize; 779 int facePileBackgroundSize; 780 if (mIsCollapsed) { 781 conversationAvatarSize = mConversationAvatarSize; 782 facepileAvatarSize = mFacePileAvatarSize; 783 facePileBackgroundSize = facepileAvatarSize + 2 * mFacePileProtectionWidth; 784 } else { 785 conversationAvatarSize = mConversationAvatarSizeExpanded; 786 facepileAvatarSize = mFacePileAvatarSizeExpandedGroup; 787 facePileBackgroundSize = facepileAvatarSize + 2 * mFacePileProtectionWidthExpanded; 788 } 789 LayoutParams layoutParams = (LayoutParams) mConversationFacePile.getLayoutParams(); 790 layoutParams.width = conversationAvatarSize; 791 layoutParams.height = conversationAvatarSize; 792 mConversationFacePile.setLayoutParams(layoutParams); 793 794 layoutParams = (LayoutParams) bottomView.getLayoutParams(); 795 layoutParams.width = facepileAvatarSize; 796 layoutParams.height = facepileAvatarSize; 797 bottomView.setLayoutParams(layoutParams); 798 799 layoutParams = (LayoutParams) topView.getLayoutParams(); 800 layoutParams.width = facepileAvatarSize; 801 layoutParams.height = facepileAvatarSize; 802 topView.setLayoutParams(layoutParams); 803 804 layoutParams = (LayoutParams) bottomBackground.getLayoutParams(); 805 layoutParams.width = facePileBackgroundSize; 806 layoutParams.height = facePileBackgroundSize; 807 bottomBackground.setLayoutParams(layoutParams); 808 } 809 810 /** 811 * Binds group avatar drawables to face pile. 812 */ bindFacePileWithDrawable(ImageView bottomBackground, ImageView bottomView, ImageView topView, GroupConversationAvatarData groupConversationAvatarData)813 public void bindFacePileWithDrawable(ImageView bottomBackground, ImageView bottomView, 814 ImageView topView, GroupConversationAvatarData groupConversationAvatarData) { 815 applyNotificationBackgroundColor(bottomBackground); 816 bottomView.setImageDrawable(groupConversationAvatarData.mLastIcon); 817 topView.setImageDrawable(groupConversationAvatarData.mSecondLastIcon); 818 } 819 updateAppName()820 private void updateAppName() { 821 mAppName.setVisibility(mIsCollapsed ? GONE : VISIBLE); 822 } 823 shouldHideAppName()824 public boolean shouldHideAppName() { 825 return mIsCollapsed; 826 } 827 828 /** 829 * update the icon position and sizing 830 */ updateIconPositionAndSize()831 private void updateIconPositionAndSize() { 832 int badgeProtrusion; 833 int conversationAvatarSize; 834 if (mIsOneToOne || mIsCollapsed) { 835 badgeProtrusion = mBadgeProtrusion; 836 conversationAvatarSize = mConversationAvatarSize; 837 } else { 838 badgeProtrusion = mConversationFacePile.getVisibility() == VISIBLE 839 ? mExpandedGroupBadgeProtrusionFacePile 840 : mExpandedGroupBadgeProtrusion; 841 conversationAvatarSize = mConversationAvatarSizeExpanded; 842 } 843 844 if (mConversationIconView.getVisibility() == VISIBLE) { 845 LayoutParams layoutParams = (LayoutParams) mConversationIconView.getLayoutParams(); 846 layoutParams.width = conversationAvatarSize; 847 layoutParams.height = conversationAvatarSize; 848 layoutParams.leftMargin = badgeProtrusion; 849 layoutParams.rightMargin = badgeProtrusion; 850 layoutParams.bottomMargin = badgeProtrusion; 851 mConversationIconView.setLayoutParams(layoutParams); 852 } 853 854 if (mConversationFacePile.getVisibility() == VISIBLE) { 855 LayoutParams layoutParams = (LayoutParams) mConversationFacePile.getLayoutParams(); 856 layoutParams.leftMargin = badgeProtrusion; 857 layoutParams.rightMargin = badgeProtrusion; 858 layoutParams.bottomMargin = badgeProtrusion; 859 mConversationFacePile.setLayoutParams(layoutParams); 860 } 861 } 862 updatePaddingsBasedOnContentAvailability()863 private void updatePaddingsBasedOnContentAvailability() { 864 // groups have avatars that need more spacing 865 mMessagingLinearLayout.setSpacing( 866 mIsOneToOne ? mMessageSpacingStandard : mMessageSpacingGroup); 867 868 int messagingPadding = mIsOneToOne || mIsCollapsed 869 ? 0 870 // Add some extra padding to the messages, since otherwise it will overlap with the 871 // group 872 : mExpandedGroupMessagePadding; 873 874 int iconPadding = mIsOneToOne || mIsCollapsed 875 ? mConversationIconTopPadding 876 : mConversationIconTopPaddingExpandedGroup; 877 878 mConversationIconContainer.setPaddingRelative( 879 mConversationIconContainer.getPaddingStart(), 880 iconPadding, 881 mConversationIconContainer.getPaddingEnd(), 882 mConversationIconContainer.getPaddingBottom()); 883 884 mMessagingLinearLayout.setPaddingRelative( 885 mMessagingLinearLayout.getPaddingStart(), 886 messagingPadding, 887 mMessagingLinearLayout.getPaddingEnd(), 888 mMessagingLinearLayout.getPaddingBottom()); 889 } 890 891 /** 892 * async version of {@link ConversationLayout#setLargeIcon} 893 */ 894 @RemotableViewMethod setLargeIconAsync(Icon largeIcon)895 public Runnable setLargeIconAsync(Icon largeIcon) { 896 if (!Flags.conversationStyleSetAvatarAsync()) { 897 return () -> setLargeIcon(largeIcon); 898 } 899 900 mLargeIcon = largeIcon; 901 return NotificationRunnables.NOOP; 902 } 903 904 @RemotableViewMethod(asyncImpl = "setLargeIconAsync") setLargeIcon(Icon largeIcon)905 public void setLargeIcon(Icon largeIcon) { 906 mLargeIcon = largeIcon; 907 } 908 909 /** 910 * async version of {@link ConversationLayout#setShortcutIcon} 911 */ 912 @RemotableViewMethod setShortcutIconAsync(Icon shortcutIcon)913 public Runnable setShortcutIconAsync(Icon shortcutIcon) { 914 if (!Flags.conversationStyleSetAvatarAsync()) { 915 return () -> setShortcutIcon(shortcutIcon); 916 } 917 918 mShortcutIcon = shortcutIcon; 919 return NotificationRunnables.NOOP; 920 } 921 922 @RemotableViewMethod(asyncImpl = "setShortcutIconAsync") setShortcutIcon(Icon shortcutIcon)923 public void setShortcutIcon(Icon shortcutIcon) { 924 mShortcutIcon = shortcutIcon; 925 } 926 927 /** 928 * async version of {@link ConversationLayout#setConversationTitle} 929 */ 930 @RemotableViewMethod setConversationTitleAsync(CharSequence conversationTitle)931 public Runnable setConversationTitleAsync(CharSequence conversationTitle) { 932 if (!Flags.conversationStyleSetAvatarAsync()) { 933 return () -> setConversationTitle(conversationTitle); 934 } 935 936 // Remove formatting from the title. 937 mConversationTitle = conversationTitle != null ? conversationTitle.toString() : null; 938 return NotificationRunnables.NOOP; 939 } 940 941 /** 942 * Sets the conversation title of this conversation. 943 * 944 * @param conversationTitle the conversation title 945 */ 946 @RemotableViewMethod(asyncImpl = "setConversationTitleAsync") setConversationTitle(CharSequence conversationTitle)947 public void setConversationTitle(CharSequence conversationTitle) { 948 // Remove formatting from the title. 949 mConversationTitle = conversationTitle != null ? conversationTitle.toString() : null; 950 } 951 952 // TODO (b/217799515) getConversationTitle is not consistent with setConversationTitle 953 // if you call getConversationTitle() immediately after setConversationTitle(), the result 954 // will not correctly reflect the new change without calling updateConversationLayout, for 955 // example. getConversationTitle()956 public CharSequence getConversationTitle() { 957 return mConversationText.getText(); 958 } 959 removeGroups(ArrayList<MessagingGroup> oldGroups)960 private void removeGroups(ArrayList<MessagingGroup> oldGroups) { 961 int size = oldGroups.size(); 962 for (int i = 0; i < size; i++) { 963 MessagingGroup group = oldGroups.get(i); 964 if (!mGroups.contains(group)) { 965 List<MessagingMessage> messages = group.getMessages(); 966 boolean wasShown = group.isShown(); 967 mMessagingLinearLayout.removeView(group); 968 if (wasShown && !MessagingLinearLayout.isGone(group)) { 969 mMessagingLinearLayout.addTransientView(group, 0); 970 group.removeGroupAnimated(() -> { 971 mMessagingLinearLayout.removeTransientView(group); 972 group.recycle(); 973 }); 974 } else { 975 // Defer recycling until after the update is done, since we may still need the 976 // old group around to perform other updates. 977 mToRecycle.add(group); 978 } 979 mMessages.removeAll(messages); 980 mHistoricMessages.removeAll(messages); 981 } 982 } 983 } 984 updateTitleAndNamesDisplay()985 private void updateTitleAndNamesDisplay() { 986 // Map of unique names to their prefix 987 Map<CharSequence, String> uniqueNames = mPeopleHelper.mapUniqueNamesToPrefix(mGroups); 988 989 // Now that we have the correct symbols, let's look what we have cached 990 ArrayMap<CharSequence, Icon> cachedAvatars = new ArrayMap<>(); 991 for (int i = 0; i < mGroups.size(); i++) { 992 // Let's now set the avatars 993 MessagingGroup group = mGroups.get(i); 994 boolean isOwnMessage = group.getSender() == mUser; 995 CharSequence senderName = group.getSenderName(); 996 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName) 997 || (mIsOneToOne && mAvatarReplacement != null && !isOwnMessage)) { 998 continue; 999 } 1000 String symbol = uniqueNames.get(senderName); 1001 Icon cachedIcon = group.getAvatarSymbolIfMatching(senderName, 1002 symbol, mLayoutColor); 1003 if (cachedIcon != null) { 1004 cachedAvatars.put(senderName, cachedIcon); 1005 } 1006 } 1007 1008 for (int i = 0; i < mGroups.size(); i++) { 1009 // Let's now set the avatars 1010 MessagingGroup group = mGroups.get(i); 1011 CharSequence senderName = group.getSenderName(); 1012 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) { 1013 continue; 1014 } 1015 if (mIsOneToOne && mAvatarReplacement != null && group.getSender() != mUser) { 1016 group.setAvatar(mAvatarReplacement); 1017 } else { 1018 Icon cachedIcon = cachedAvatars.get(senderName); 1019 if (cachedIcon == null) { 1020 cachedIcon = mPeopleHelper.createAvatarSymbol(senderName, 1021 uniqueNames.get(senderName), mLayoutColor); 1022 cachedAvatars.put(senderName, cachedIcon); 1023 } 1024 group.setCreatedAvatar(cachedIcon, senderName, uniqueNames.get(senderName), 1025 mLayoutColor); 1026 } 1027 } 1028 } 1029 1030 /** 1031 * async version of {@link ConversationLayout#setLayoutColor} 1032 */ 1033 @RemotableViewMethod setLayoutColorAsync(int color)1034 public Runnable setLayoutColorAsync(int color) { 1035 if (!Flags.conversationStyleSetAvatarAsync()) { 1036 return () -> setLayoutColor(color); 1037 } 1038 1039 mLayoutColor = color; 1040 return NotificationRunnables.NOOP; 1041 } 1042 1043 @RemotableViewMethod(asyncImpl = "setLayoutColorAsync") setLayoutColor(int color)1044 public void setLayoutColor(int color) { 1045 mLayoutColor = color; 1046 } 1047 1048 /** 1049 * async version of {@link ConversationLayout#setIsOneToOne} 1050 */ 1051 @RemotableViewMethod setIsOneToOneAsync(boolean oneToOne)1052 public Runnable setIsOneToOneAsync(boolean oneToOne) { 1053 if (!Flags.conversationStyleSetAvatarAsync()) { 1054 return () -> setIsOneToOne(oneToOne); 1055 } 1056 mIsOneToOne = oneToOne; 1057 return NotificationRunnables.NOOP; 1058 } 1059 1060 @RemotableViewMethod(asyncImpl = "setIsOneToOneAsync") setIsOneToOne(boolean oneToOne)1061 public void setIsOneToOne(boolean oneToOne) { 1062 mIsOneToOne = oneToOne; 1063 } 1064 1065 @RemotableViewMethod setSenderTextColor(int color)1066 public void setSenderTextColor(int color) { 1067 mSenderTextColor = color; 1068 mConversationText.setTextColor(color); 1069 } 1070 1071 /** 1072 * @param color the color of the notification background 1073 */ 1074 @RemotableViewMethod setNotificationBackgroundColor(int color)1075 public void setNotificationBackgroundColor(int color) { 1076 mNotificationBackgroundColor = color; 1077 applyNotificationBackgroundColor(mConversationIconBadgeBg); 1078 } 1079 applyNotificationBackgroundColor(ImageView view)1080 private void applyNotificationBackgroundColor(ImageView view) { 1081 view.setImageTintList(ColorStateList.valueOf(mNotificationBackgroundColor)); 1082 } 1083 1084 @RemotableViewMethod setMessageTextColor(int color)1085 public void setMessageTextColor(int color) { 1086 mMessageTextColor = color; 1087 } 1088 setUser(Person user)1089 private void setUser(Person user) { 1090 mUser = user; 1091 if (mUser.getIcon() == null) { 1092 Icon userIcon = Icon.createWithResource(getContext(), 1093 R.drawable.messaging_user); 1094 userIcon.setTint(mLayoutColor); 1095 mUser = mUser.toBuilder().setIcon(userIcon).build(); 1096 } 1097 } 1098 createGroupViews(List<List<MessagingMessage>> groups, List<Person> senders, boolean showSpinner)1099 private void createGroupViews(List<List<MessagingMessage>> groups, 1100 List<Person> senders, boolean showSpinner) { 1101 mGroups.clear(); 1102 for (int groupIndex = 0; groupIndex < groups.size(); groupIndex++) { 1103 List<MessagingMessage> group = groups.get(groupIndex); 1104 MessagingGroup newGroup = null; 1105 // we'll just take the first group that exists or create one there is none 1106 for (int messageIndex = group.size() - 1; messageIndex >= 0; messageIndex--) { 1107 MessagingMessage message = group.get(messageIndex); 1108 newGroup = message.getGroup(); 1109 if (newGroup != null) { 1110 break; 1111 } 1112 } 1113 // Create a new group, adding it to the linear layout as well 1114 if (newGroup == null) { 1115 newGroup = MessagingGroup.createGroup(mMessagingLinearLayout); 1116 mAddedGroups.add(newGroup); 1117 } else if (newGroup.getParent() != mMessagingLinearLayout) { 1118 throw new IllegalStateException( 1119 "group parent was " + newGroup.getParent() + " but expected " 1120 + mMessagingLinearLayout); 1121 } 1122 newGroup.setImageDisplayLocation(mIsCollapsed 1123 ? IMAGE_DISPLAY_LOCATION_EXTERNAL 1124 : IMAGE_DISPLAY_LOCATION_INLINE); 1125 newGroup.setIsInConversation(true); 1126 newGroup.setLayoutColor(mLayoutColor); 1127 newGroup.setTextColors(mSenderTextColor, mMessageTextColor); 1128 Person sender = senders.get(groupIndex); 1129 CharSequence nameOverride = null; 1130 if (sender != mUser && mNameReplacement != null) { 1131 nameOverride = mNameReplacement; 1132 } 1133 newGroup.setShowingAvatar(!mIsOneToOne && !mIsCollapsed); 1134 newGroup.setSingleLine(mIsCollapsed); 1135 newGroup.setSender(sender, nameOverride); 1136 newGroup.setSending(groupIndex == (groups.size() - 1) && showSpinner); 1137 mGroups.add(newGroup); 1138 1139 // Reposition to the correct place (if we're re-using a group) 1140 if (mMessagingLinearLayout.indexOfChild(newGroup) != groupIndex) { 1141 mMessagingLinearLayout.removeView(newGroup); 1142 mMessagingLinearLayout.addView(newGroup, groupIndex); 1143 } 1144 newGroup.setMessages(group); 1145 } 1146 } 1147 1148 /** 1149 * Finds groups and senders from the given messaging messages and fills outGroups and outSenders 1150 */ findGroups(List<MessagingMessage> historicMessages, List<MessagingMessage> messages, Person user, List<List<MessagingMessage>> outGroups, List<Person> outSenders)1151 private void findGroups(List<MessagingMessage> historicMessages, 1152 List<MessagingMessage> messages, Person user, List<List<MessagingMessage>> outGroups, 1153 List<Person> outSenders) { 1154 CharSequence currentSenderKey = null; 1155 List<MessagingMessage> currentGroup = null; 1156 int histSize = historicMessages.size(); 1157 for (int i = 0; i < histSize + messages.size(); i++) { 1158 MessagingMessage message; 1159 if (i < histSize) { 1160 message = historicMessages.get(i); 1161 } else { 1162 message = messages.get(i - histSize); 1163 } 1164 boolean isNewGroup = currentGroup == null; 1165 Person sender = 1166 message.getMessage() == null ? null : message.getMessage().getSenderPerson(); 1167 CharSequence key = getKey(sender); 1168 isNewGroup |= !TextUtils.equals(key, currentSenderKey); 1169 if (isNewGroup) { 1170 currentGroup = new ArrayList<>(); 1171 outGroups.add(currentGroup); 1172 if (sender == null) { 1173 sender = user; 1174 } else { 1175 // Remove all formatting from the sender name 1176 sender = sender.toBuilder().setName(Objects.toString(sender.getName())).build(); 1177 } 1178 outSenders.add(sender); 1179 currentSenderKey = key; 1180 } 1181 currentGroup.add(message); 1182 } 1183 } 1184 getKey(Person person)1185 private CharSequence getKey(Person person) { 1186 return person == null ? null : person.getKey() == null ? person.getName() : person.getKey(); 1187 } 1188 loadConversationHeaderData(boolean isOneToOne, CharSequence conversationTitle, Icon shortcutIcon, Icon largeIcon, List<MessagingMessage> messages, Person user, List<List<MessagingMessage>> groups, int layoutColor)1189 private ConversationHeaderData loadConversationHeaderData(boolean isOneToOne, 1190 CharSequence conversationTitle, Icon shortcutIcon, Icon largeIcon, 1191 List<MessagingMessage> messages, 1192 Person user, 1193 List<List<MessagingMessage>> groups, int layoutColor) { 1194 Icon conversationIcon = shortcutIcon; 1195 CharSequence conversationText = conversationTitle; 1196 final CharSequence userKey = getKey(user); 1197 if (isOneToOne) { 1198 for (int i = messages.size() - 1; i >= 0; i--) { 1199 final Notification.MessagingStyle.Message message = messages.get(i).getMessage(); 1200 final Person sender = message.getSenderPerson(); 1201 final CharSequence senderKey = getKey(sender); 1202 if ((sender != null && senderKey != userKey) || i == 0) { 1203 if (conversationText == null || conversationText.length() == 0) { 1204 conversationText = sender != null ? sender.getName() : ""; 1205 } 1206 if (conversationIcon == null) { 1207 conversationIcon = sender != null ? sender.getIcon() 1208 : mPeopleHelper.createAvatarSymbol(conversationText, "", 1209 layoutColor); 1210 } 1211 break; 1212 } 1213 } 1214 } 1215 if (android.app.Flags.cleanUpSpansAndNewLines() && conversationText != null) { 1216 // remove formatting from title. 1217 conversationText = conversationText.toString(); 1218 } 1219 1220 if (conversationIcon == null) { 1221 conversationIcon = largeIcon; 1222 } 1223 1224 if (isOneToOne || conversationIcon != null) { 1225 return new ConversationHeaderData( 1226 conversationText, 1227 new OneToOneConversationAvatarData( 1228 resolveAvatarImageForOneToOne(conversationIcon))); 1229 } 1230 1231 final List<List<Notification.MessagingStyle.Message>> groupMessages = new ArrayList<>(); 1232 for (int i = 0; i < groups.size(); i++) { 1233 final List<Notification.MessagingStyle.Message> groupMessage = new ArrayList<>(); 1234 for (int j = 0; j < groups.get(i).size(); j++) { 1235 groupMessage.add(groups.get(i).get(j).getMessage()); 1236 } 1237 groupMessages.add(groupMessage); 1238 } 1239 1240 final PeopleHelper.NameToPrefixMap nameToPrefixMap = 1241 mPeopleHelper.mapUniqueNamesToPrefixWithGroupList(groupMessages); 1242 1243 Icon lastIcon = null; 1244 Icon secondLastIcon = null; 1245 1246 CharSequence lastKey = null; 1247 1248 for (int i = groups.size() - 1; i >= 0; i--) { 1249 final Notification.MessagingStyle.Message message = groups.get(i).get(0).getMessage(); 1250 final Person sender = 1251 message.getSenderPerson() != null ? message.getSenderPerson() : user; 1252 final CharSequence senderKey = getKey(sender); 1253 final boolean notUser = senderKey != userKey; 1254 final boolean notIncluded = senderKey != lastKey; 1255 1256 if ((notUser && notIncluded) || (i == 0 && lastKey == null)) { 1257 if (lastIcon == null) { 1258 if (sender.getIcon() != null) { 1259 lastIcon = sender.getIcon(); 1260 } else { 1261 final CharSequence senderName = 1262 sender.getName() != null ? sender.getName() : ""; 1263 lastIcon = mPeopleHelper.createAvatarSymbol( 1264 senderName, nameToPrefixMap.getPrefix(senderName), 1265 layoutColor); 1266 } 1267 lastKey = senderKey; 1268 } else { 1269 if (sender.getIcon() != null) { 1270 secondLastIcon = sender.getIcon(); 1271 } else { 1272 final CharSequence senderName = 1273 sender.getName() != null ? sender.getName() : ""; 1274 secondLastIcon = mPeopleHelper.createAvatarSymbol( 1275 senderName, nameToPrefixMap.getPrefix(senderName), 1276 layoutColor); 1277 } 1278 break; 1279 } 1280 } 1281 } 1282 1283 if (lastIcon == null) { 1284 lastIcon = mPeopleHelper.createAvatarSymbol( 1285 "", "", layoutColor); 1286 } 1287 1288 if (secondLastIcon == null) { 1289 secondLastIcon = mPeopleHelper.createAvatarSymbol( 1290 "", "", layoutColor); 1291 } 1292 1293 return new ConversationHeaderData( 1294 conversationText, 1295 new GroupConversationAvatarData(resolveAvatarImageForFacePile(lastIcon), 1296 resolveAvatarImageForFacePile(secondLastIcon))); 1297 } 1298 1299 /** 1300 * One To One Conversation Avatars is loaded by CachingIconView(conversation icon view). 1301 */ 1302 @Nullable resolveAvatarImageForOneToOne(Icon conversationIcon)1303 private Drawable resolveAvatarImageForOneToOne(Icon conversationIcon) { 1304 final Drawable conversationIconDrawable = 1305 tryLoadingSizeRestrictedIconForOneToOne(conversationIcon); 1306 if (conversationIconDrawable != null) { 1307 return conversationIconDrawable; 1308 } 1309 // when size restricted icon loading fails, we fallback to icons load drawable. 1310 return loadDrawableFromIcon(conversationIcon); 1311 } 1312 1313 @Nullable tryLoadingSizeRestrictedIconForOneToOne(Icon conversationIcon)1314 private Drawable tryLoadingSizeRestrictedIconForOneToOne(Icon conversationIcon) { 1315 try { 1316 return mConversationIconView.loadSizeRestrictedIcon(conversationIcon); 1317 } catch (Exception ex) { 1318 return null; 1319 } 1320 } 1321 1322 /** 1323 * Group Avatar drawables are loaded by Icon. 1324 */ 1325 @Nullable resolveAvatarImageForFacePile(Icon conversationIcon)1326 private Drawable resolveAvatarImageForFacePile(Icon conversationIcon) { 1327 return loadDrawableFromIcon(conversationIcon); 1328 } 1329 1330 @Nullable loadDrawableFromIcon(Icon conversationIcon)1331 private Drawable loadDrawableFromIcon(Icon conversationIcon) { 1332 try { 1333 return conversationIcon.loadDrawable(getContext()); 1334 } catch (Exception ex) { 1335 return null; 1336 } 1337 } 1338 1339 /** 1340 * Creates new messages, reusing existing ones if they are available. 1341 * 1342 * @param newMessages the messages to parse. 1343 */ createMessages( List<Notification.MessagingStyle.Message> newMessages, boolean isHistoric, boolean usePrecomputedText)1344 private List<MessagingMessage> createMessages( 1345 List<Notification.MessagingStyle.Message> newMessages, boolean isHistoric, 1346 boolean usePrecomputedText) { 1347 List<MessagingMessage> result = new ArrayList<>(); 1348 for (int i = 0; i < newMessages.size(); i++) { 1349 Notification.MessagingStyle.Message m = newMessages.get(i); 1350 MessagingMessage message = findAndRemoveMatchingMessage(m); 1351 if (message == null) { 1352 message = MessagingMessage.createMessage(this, m, 1353 mImageResolver, usePrecomputedText); 1354 } 1355 message.setIsHistoric(isHistoric); 1356 result.add(message); 1357 } 1358 return result; 1359 } 1360 findAndRemoveMatchingMessage(Notification.MessagingStyle.Message m)1361 private MessagingMessage findAndRemoveMatchingMessage(Notification.MessagingStyle.Message m) { 1362 for (int i = 0; i < mMessages.size(); i++) { 1363 MessagingMessage existing = mMessages.get(i); 1364 if (existing.sameAs(m)) { 1365 mMessages.remove(i); 1366 return existing; 1367 } 1368 } 1369 for (int i = 0; i < mHistoricMessages.size(); i++) { 1370 MessagingMessage existing = mHistoricMessages.get(i); 1371 if (existing.sameAs(m)) { 1372 mHistoricMessages.remove(i); 1373 return existing; 1374 } 1375 } 1376 return null; 1377 } 1378 showHistoricMessages(boolean show)1379 public void showHistoricMessages(boolean show) { 1380 mShowHistoricMessages = show; 1381 updateHistoricMessageVisibility(); 1382 } 1383 updateHistoricMessageVisibility()1384 private void updateHistoricMessageVisibility() { 1385 int numHistoric = mHistoricMessages.size(); 1386 for (int i = 0; i < numHistoric; i++) { 1387 MessagingMessage existing = mHistoricMessages.get(i); 1388 existing.setVisibility(mShowHistoricMessages ? VISIBLE : GONE); 1389 } 1390 int numGroups = mGroups.size(); 1391 for (int i = 0; i < numGroups; i++) { 1392 MessagingGroup group = mGroups.get(i); 1393 int visibleChildren = 0; 1394 List<MessagingMessage> messages = group.getMessages(); 1395 int numGroupMessages = messages.size(); 1396 for (int j = 0; j < numGroupMessages; j++) { 1397 MessagingMessage message = messages.get(j); 1398 if (message.getVisibility() != GONE) { 1399 visibleChildren++; 1400 } 1401 } 1402 if (visibleChildren > 0 && group.getVisibility() == GONE) { 1403 group.setVisibility(VISIBLE); 1404 } else if (visibleChildren == 0 && group.getVisibility() != GONE) { 1405 group.setVisibility(GONE); 1406 } 1407 } 1408 } 1409 1410 @Override onLayout(boolean changed, int left, int top, int right, int bottom)1411 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 1412 super.onLayout(changed, left, top, right, bottom); 1413 if (!mAddedGroups.isEmpty()) { 1414 getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { 1415 @Override 1416 public boolean onPreDraw() { 1417 for (MessagingGroup group : mAddedGroups) { 1418 if (!group.isShown()) { 1419 continue; 1420 } 1421 MessagingPropertyAnimator.fadeIn(group.getAvatar()); 1422 MessagingPropertyAnimator.fadeIn(group.getSenderView()); 1423 MessagingPropertyAnimator.startLocalTranslationFrom(group, 1424 group.getHeight(), LINEAR_OUT_SLOW_IN); 1425 } 1426 mAddedGroups.clear(); 1427 getViewTreeObserver().removeOnPreDrawListener(this); 1428 return true; 1429 } 1430 }); 1431 } 1432 mTouchDelegate.clear(); 1433 if (mFeedbackIcon.getVisibility() == VISIBLE) { 1434 float width = Math.max(mMinTouchSize, mFeedbackIcon.getWidth()); 1435 float height = Math.max(mMinTouchSize, mFeedbackIcon.getHeight()); 1436 final Rect feedbackTouchRect = new Rect(); 1437 feedbackTouchRect.left = (int) ((mFeedbackIcon.getLeft() + mFeedbackIcon.getRight()) 1438 / 2.0f - width / 2.0f); 1439 feedbackTouchRect.top = (int) ((mFeedbackIcon.getTop() + mFeedbackIcon.getBottom()) 1440 / 2.0f - height / 2.0f); 1441 feedbackTouchRect.bottom = (int) (feedbackTouchRect.top + height); 1442 feedbackTouchRect.right = (int) (feedbackTouchRect.left + width); 1443 1444 getRelativeTouchRect(feedbackTouchRect, mFeedbackIcon); 1445 mTouchDelegate.add(new TouchDelegate(feedbackTouchRect, mFeedbackIcon)); 1446 } 1447 1448 setTouchDelegate(mTouchDelegate); 1449 } 1450 getRelativeTouchRect(Rect touchRect, View view)1451 private void getRelativeTouchRect(Rect touchRect, View view) { 1452 ViewGroup viewGroup = (ViewGroup) view.getParent(); 1453 while (viewGroup != this) { 1454 touchRect.offset(viewGroup.getLeft(), viewGroup.getTop()); 1455 viewGroup = (ViewGroup) viewGroup.getParent(); 1456 } 1457 } 1458 getMessagingLinearLayout()1459 public MessagingLinearLayout getMessagingLinearLayout() { 1460 return mMessagingLinearLayout; 1461 } 1462 getImageMessageContainer()1463 public @NonNull ViewGroup getImageMessageContainer() { 1464 return mImageMessageContainer; 1465 } 1466 getMessagingGroups()1467 public ArrayList<MessagingGroup> getMessagingGroups() { 1468 return mGroups; 1469 } 1470 updateExpandButton()1471 private void updateExpandButton() { 1472 int buttonGravity; 1473 ViewGroup newContainer; 1474 if (mIsCollapsed) { 1475 buttonGravity = Gravity.CENTER; 1476 // NOTE(b/182474419): In order for the touch target of the expand button to be the full 1477 // height of the notification, we would want the mExpandButtonContainer's height to be 1478 // set to WRAP_CONTENT (or 88dp) when in the collapsed state. Unfortunately, that 1479 // causes an unstable remeasuring infinite loop when the unread count is visible, 1480 // causing the layout to occasionally hide the messages. As an aside, that naive 1481 // solution also causes an undesirably large gap between content and smart replies. 1482 newContainer = mExpandButtonAndContentContainer; 1483 } else { 1484 buttonGravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP; 1485 newContainer = mExpandButtonContainerA11yContainer; 1486 } 1487 mExpandButton.setExpanded(!mIsCollapsed); 1488 1489 // We need to make sure that the expand button is in the linearlayout pushing over the 1490 // content when collapsed, but allows the content to flow under it when expanded. 1491 if (newContainer != mExpandButtonContainer.getParent()) { 1492 ((ViewGroup) mExpandButtonContainer.getParent()).removeView(mExpandButtonContainer); 1493 newContainer.addView(mExpandButtonContainer); 1494 } 1495 1496 // update if the expand button is centered 1497 LinearLayout.LayoutParams layoutParams = 1498 (LinearLayout.LayoutParams) mExpandButton.getLayoutParams(); 1499 layoutParams.gravity = buttonGravity; 1500 mExpandButton.setLayoutParams(layoutParams); 1501 } 1502 updateContentEndPaddings()1503 private void updateContentEndPaddings() { 1504 // Let's make sure the conversation header can't run into the expand button when we're 1505 // collapsed and update the paddings of the content 1506 int headerPaddingEnd; 1507 int contentPaddingEnd; 1508 if (!mExpandable) { 1509 headerPaddingEnd = 0; 1510 contentPaddingEnd = mContentMarginEnd; 1511 } else if (mIsCollapsed) { 1512 headerPaddingEnd = 0; 1513 contentPaddingEnd = 0; 1514 } else { 1515 headerPaddingEnd = mNotificationHeaderExpandedPadding; 1516 contentPaddingEnd = mContentMarginEnd; 1517 } 1518 mConversationHeader.setPaddingRelative( 1519 mConversationHeader.getPaddingStart(), 1520 mConversationHeader.getPaddingTop(), 1521 headerPaddingEnd, 1522 mConversationHeader.getPaddingBottom()); 1523 1524 mContentContainer.setPaddingRelative( 1525 mContentContainer.getPaddingStart(), 1526 mContentContainer.getPaddingTop(), 1527 contentPaddingEnd, 1528 mContentContainer.getPaddingBottom()); 1529 } 1530 onAppNameVisibilityChanged()1531 private void onAppNameVisibilityChanged() { 1532 boolean appNameGone = mAppName.getVisibility() == GONE; 1533 if (appNameGone != mAppNameGone) { 1534 mAppNameGone = appNameGone; 1535 updateAppNameDividerVisibility(); 1536 } 1537 } 1538 updateAppNameDividerVisibility()1539 private void updateAppNameDividerVisibility() { 1540 mAppNameDivider.setVisibility(mAppNameGone ? GONE : VISIBLE); 1541 } 1542 updateExpandability(boolean expandable, @Nullable OnClickListener onClickListener)1543 public void updateExpandability(boolean expandable, @Nullable OnClickListener onClickListener) { 1544 mExpandable = expandable; 1545 if (expandable) { 1546 mExpandButtonContainer.setVisibility(VISIBLE); 1547 mExpandButton.setOnClickListener(onClickListener); 1548 mConversationIconContainer.setOnClickListener(onClickListener); 1549 } else { 1550 mExpandButtonContainer.setVisibility(GONE); 1551 mConversationIconContainer.setOnClickListener(null); 1552 } 1553 mExpandButton.setVisibility(VISIBLE); 1554 updateContentEndPaddings(); 1555 } 1556 1557 @Override setMessagingClippingDisabled(boolean clippingDisabled)1558 public void setMessagingClippingDisabled(boolean clippingDisabled) { 1559 mMessagingLinearLayout.setClipBounds(clippingDisabled ? null : mMessagingClipRect); 1560 } 1561 1562 @Nullable getConversationSenderName()1563 public CharSequence getConversationSenderName() { 1564 if (mGroups.isEmpty()) { 1565 return null; 1566 } 1567 final CharSequence name = mGroups.get(mGroups.size() - 1).getSenderName(); 1568 return getResources().getString(R.string.conversation_single_line_name_display, name); 1569 } 1570 isOneToOne()1571 public boolean isOneToOne() { 1572 return mIsOneToOne; 1573 } 1574 1575 @Nullable getConversationText()1576 public CharSequence getConversationText() { 1577 if (mMessages.isEmpty()) { 1578 return null; 1579 } 1580 final MessagingMessage messagingMessage = mMessages.get(mMessages.size() - 1); 1581 final CharSequence text = messagingMessage.getMessage() == null ? null 1582 : messagingMessage.getMessage().getText(); 1583 if (text == null && messagingMessage instanceof MessagingImageMessage) { 1584 final String unformatted = 1585 getResources().getString(R.string.conversation_single_line_image_placeholder); 1586 SpannableString spannableString = new SpannableString(unformatted); 1587 spannableString.setSpan( 1588 new StyleSpan(Typeface.ITALIC), 1589 0, 1590 spannableString.length(), 1591 Spannable.SPAN_INCLUSIVE_EXCLUSIVE); 1592 return spannableString; 1593 } 1594 return text; 1595 } 1596 1597 @Nullable getConversationIcon()1598 public Icon getConversationIcon() { 1599 return mConversationIcon; 1600 } 1601 1602 @Nullable getConversationHeaderData()1603 public ConversationHeaderData getConversationHeaderData() { 1604 return mConversationHeaderData; 1605 } 1606 1607 private static class TouchDelegateComposite extends TouchDelegate { 1608 private final ArrayList<TouchDelegate> mDelegates = new ArrayList<>(); 1609 TouchDelegateComposite(View view)1610 private TouchDelegateComposite(View view) { 1611 super(new Rect(), view); 1612 } 1613 add(TouchDelegate delegate)1614 public void add(TouchDelegate delegate) { 1615 mDelegates.add(delegate); 1616 } 1617 clear()1618 public void clear() { 1619 mDelegates.clear(); 1620 } 1621 1622 @Override onTouchEvent(MotionEvent event)1623 public boolean onTouchEvent(MotionEvent event) { 1624 float x = event.getX(); 1625 float y = event.getY(); 1626 for (TouchDelegate delegate : mDelegates) { 1627 event.setLocation(x, y); 1628 if (delegate.onTouchEvent(event)) { 1629 return true; 1630 } 1631 } 1632 return false; 1633 } 1634 } 1635 } 1636