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 import static com.android.internal.widget.MessagingPropertyAnimator.ALPHA_IN; 22 import static com.android.internal.widget.MessagingPropertyAnimator.ALPHA_OUT; 23 24 import android.animation.Animator; 25 import android.animation.AnimatorListenerAdapter; 26 import android.animation.AnimatorSet; 27 import android.animation.ValueAnimator; 28 import android.annotation.AttrRes; 29 import android.annotation.NonNull; 30 import android.annotation.Nullable; 31 import android.annotation.StyleRes; 32 import android.app.Notification; 33 import android.app.Person; 34 import android.app.RemoteInputHistoryItem; 35 import android.content.Context; 36 import android.content.res.ColorStateList; 37 import android.graphics.Bitmap; 38 import android.graphics.Canvas; 39 import android.graphics.Color; 40 import android.graphics.Paint; 41 import android.graphics.Rect; 42 import android.graphics.Typeface; 43 import android.graphics.drawable.GradientDrawable; 44 import android.graphics.drawable.Icon; 45 import android.os.Bundle; 46 import android.os.Parcelable; 47 import android.text.Spannable; 48 import android.text.SpannableString; 49 import android.text.TextUtils; 50 import android.text.style.StyleSpan; 51 import android.util.ArrayMap; 52 import android.util.AttributeSet; 53 import android.util.DisplayMetrics; 54 import android.view.Gravity; 55 import android.view.RemotableViewMethod; 56 import android.view.TouchDelegate; 57 import android.view.View; 58 import android.view.ViewGroup; 59 import android.view.ViewTreeObserver; 60 import android.view.animation.Interpolator; 61 import android.view.animation.PathInterpolator; 62 import android.widget.FrameLayout; 63 import android.widget.ImageView; 64 import android.widget.LinearLayout; 65 import android.widget.RemoteViews; 66 import android.widget.TextView; 67 68 import com.android.internal.R; 69 import com.android.internal.graphics.ColorUtils; 70 import com.android.internal.util.ContrastColorUtil; 71 72 import java.util.ArrayList; 73 import java.util.List; 74 import java.util.Locale; 75 import java.util.Objects; 76 import java.util.function.Consumer; 77 import java.util.regex.Pattern; 78 79 /** 80 * A custom-built layout for the Notification.MessagingStyle allows dynamic addition and removal 81 * messages and adapts the layout accordingly. 82 */ 83 @RemoteViews.RemoteView 84 public class ConversationLayout extends FrameLayout 85 implements ImageMessageConsumer, IMessagingLayout { 86 87 private static final float COLOR_SHIFT_AMOUNT = 60; 88 /** 89 * Pattern for filter some ignorable characters. 90 * p{Z} for any kind of whitespace or invisible separator. 91 * p{C} for any kind of punctuation character. 92 */ 93 private static final Pattern IGNORABLE_CHAR_PATTERN 94 = Pattern.compile("[\\p{C}\\p{Z}]"); 95 private static final Pattern SPECIAL_CHAR_PATTERN 96 = Pattern.compile ("[!@#$%&*()_+=|<>?{}\\[\\]~-]"); 97 private static final Consumer<MessagingMessage> REMOVE_MESSAGE 98 = MessagingMessage::removeMessage; 99 public static final Interpolator LINEAR_OUT_SLOW_IN = new PathInterpolator(0f, 0f, 0.2f, 1f); 100 public static final Interpolator FAST_OUT_LINEAR_IN = new PathInterpolator(0.4f, 0f, 1f, 1f); 101 public static final Interpolator FAST_OUT_SLOW_IN = new PathInterpolator(0.4f, 0f, 0.2f, 1f); 102 public static final Interpolator OVERSHOOT = new PathInterpolator(0.4f, 0f, 0.2f, 1.4f); 103 public static final OnLayoutChangeListener MESSAGING_PROPERTY_ANIMATOR 104 = new MessagingPropertyAnimator(); 105 public static final int IMPORTANCE_ANIM_GROW_DURATION = 250; 106 public static final int IMPORTANCE_ANIM_SHRINK_DURATION = 200; 107 public static final int IMPORTANCE_ANIM_SHRINK_DELAY = 25; 108 private List<MessagingMessage> mMessages = new ArrayList<>(); 109 private List<MessagingMessage> mHistoricMessages = new ArrayList<>(); 110 private MessagingLinearLayout mMessagingLinearLayout; 111 private boolean mShowHistoricMessages; 112 private ArrayList<MessagingGroup> mGroups = new ArrayList<>(); 113 private int mLayoutColor; 114 private int mSenderTextColor; 115 private int mMessageTextColor; 116 private int mAvatarSize; 117 private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); 118 private Paint mTextPaint = new Paint(); 119 private Icon mAvatarReplacement; 120 private boolean mIsOneToOne; 121 private ArrayList<MessagingGroup> mAddedGroups = new ArrayList<>(); 122 private Person mUser; 123 private CharSequence mNameReplacement; 124 private boolean mIsCollapsed; 125 private ImageResolver mImageResolver; 126 private CachingIconView mConversationIconView; 127 private View mConversationIconContainer; 128 private int mConversationIconTopPaddingExpandedGroup; 129 private int mConversationIconTopPadding; 130 private int mExpandedGroupMessagePadding; 131 private TextView mConversationText; 132 private View mConversationIconBadge; 133 private CachingIconView mConversationIconBadgeBg; 134 private Icon mLargeIcon; 135 private View mExpandButtonContainer; 136 private View mExpandButtonInnerContainer; 137 private ViewGroup mExpandButtonAndContentContainer; 138 private NotificationExpandButton mExpandButton; 139 private MessagingLinearLayout mImageMessageContainer; 140 private int mExpandButtonExpandedTopMargin; 141 private int mBadgedSideMargins; 142 private int mConversationAvatarSize; 143 private int mConversationAvatarSizeExpanded; 144 private CachingIconView mIcon; 145 private CachingIconView mImportanceRingView; 146 private int mExpandedGroupSideMargin; 147 private int mExpandedGroupSideMarginFacePile; 148 private View mConversationFacePile; 149 private int mNotificationBackgroundColor; 150 private CharSequence mFallbackChatName; 151 private CharSequence mFallbackGroupChatName; 152 private CharSequence mConversationTitle; 153 private int mNotificationHeaderExpandedPadding; 154 private View mConversationHeader; 155 private View mContentContainer; 156 private boolean mExpandable = true; 157 private int mContentMarginEnd; 158 private Rect mMessagingClipRect; 159 private ObservableTextView mAppName; 160 private ViewGroup mActions; 161 private int mConversationContentStart; 162 private int mInternalButtonPadding; 163 private boolean mAppNameGone; 164 private int mFacePileAvatarSize; 165 private int mFacePileAvatarSizeExpandedGroup; 166 private int mFacePileProtectionWidth; 167 private int mFacePileProtectionWidthExpanded; 168 private boolean mImportantConversation; 169 private TextView mUnreadBadge; 170 private ViewGroup mAppOps; 171 private Rect mAppOpsTouchRect = new Rect(); 172 private float mMinTouchSize; 173 private Icon mConversationIcon; 174 private Icon mShortcutIcon; 175 private View mAppNameDivider; 176 ConversationLayout(@onNull Context context)177 public ConversationLayout(@NonNull Context context) { 178 super(context); 179 } 180 ConversationLayout(@onNull Context context, @Nullable AttributeSet attrs)181 public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs) { 182 super(context, attrs); 183 } 184 ConversationLayout(@onNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr)185 public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs, 186 @AttrRes int defStyleAttr) { 187 super(context, attrs, defStyleAttr); 188 } 189 ConversationLayout(@onNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr, @StyleRes int defStyleRes)190 public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs, 191 @AttrRes int defStyleAttr, @StyleRes int defStyleRes) { 192 super(context, attrs, defStyleAttr, defStyleRes); 193 } 194 195 @Override onFinishInflate()196 protected void onFinishInflate() { 197 super.onFinishInflate(); 198 mMessagingLinearLayout = findViewById(R.id.notification_messaging); 199 mActions = findViewById(R.id.actions); 200 mImageMessageContainer = findViewById(R.id.conversation_image_message_container); 201 // We still want to clip, but only on the top, since views can temporarily out of bounds 202 // during transitions. 203 DisplayMetrics displayMetrics = getResources().getDisplayMetrics(); 204 int size = Math.max(displayMetrics.widthPixels, displayMetrics.heightPixels); 205 mMessagingClipRect = new Rect(0, 0, size, size); 206 setMessagingClippingDisabled(false); 207 mAvatarSize = getResources().getDimensionPixelSize(R.dimen.messaging_avatar_size); 208 mTextPaint.setTextAlign(Paint.Align.CENTER); 209 mTextPaint.setAntiAlias(true); 210 mConversationIconView = findViewById(R.id.conversation_icon); 211 mConversationIconContainer = findViewById(R.id.conversation_icon_container); 212 mIcon = findViewById(R.id.icon); 213 mAppOps = findViewById(com.android.internal.R.id.app_ops); 214 mMinTouchSize = 48 * getResources().getDisplayMetrics().density; 215 mImportanceRingView = findViewById(R.id.conversation_icon_badge_ring); 216 mConversationIconBadge = findViewById(R.id.conversation_icon_badge); 217 mConversationIconBadgeBg = findViewById(R.id.conversation_icon_badge_bg); 218 mIcon.setOnVisibilityChangedListener((visibility) -> { 219 220 // Let's hide the background directly or in an animated way 221 boolean isGone = visibility == GONE; 222 int oldVisibility = mConversationIconBadgeBg.getVisibility(); 223 boolean wasGone = oldVisibility == GONE; 224 if (wasGone != isGone) { 225 // Keep the badge gone state in sync with the icon. This is necessary in cases 226 // Where the icon is being hidden externally like in group children. 227 mConversationIconBadgeBg.animate().cancel(); 228 mConversationIconBadgeBg.setVisibility(visibility); 229 } 230 231 // Let's handle the importance ring which can also be be gone normally 232 oldVisibility = mImportanceRingView.getVisibility(); 233 wasGone = oldVisibility == GONE; 234 visibility = !mImportantConversation ? GONE : visibility; 235 boolean isRingGone = visibility == GONE; 236 if (wasGone != isRingGone) { 237 // Keep the badge visibility in sync with the icon. This is necessary in cases 238 // Where the icon is being hidden externally like in group children. 239 mImportanceRingView.animate().cancel(); 240 mImportanceRingView.setVisibility(visibility); 241 } 242 243 oldVisibility = mConversationIconBadge.getVisibility(); 244 wasGone = oldVisibility == GONE; 245 if (wasGone != isGone) { 246 mConversationIconBadge.animate().cancel(); 247 mConversationIconBadge.setVisibility(visibility); 248 } 249 }); 250 // When the small icon is gone, hide the rest of the badge 251 mIcon.setOnForceHiddenChangedListener((forceHidden) -> { 252 animateViewForceHidden(mConversationIconBadgeBg, forceHidden); 253 animateViewForceHidden(mImportanceRingView, forceHidden); 254 }); 255 256 // When the conversation icon is gone, hide the whole badge 257 mConversationIconView.setOnForceHiddenChangedListener((forceHidden) -> { 258 animateViewForceHidden(mConversationIconBadgeBg, forceHidden); 259 animateViewForceHidden(mImportanceRingView, forceHidden); 260 animateViewForceHidden(mIcon, forceHidden); 261 }); 262 mConversationText = findViewById(R.id.conversation_text); 263 mExpandButtonContainer = findViewById(R.id.expand_button_container); 264 mConversationHeader = findViewById(R.id.conversation_header); 265 mContentContainer = findViewById(R.id.notification_action_list_margin_target); 266 mExpandButtonAndContentContainer = findViewById(R.id.expand_button_and_content_container); 267 mExpandButtonInnerContainer = findViewById(R.id.expand_button_inner_container); 268 mExpandButton = findViewById(R.id.expand_button); 269 mExpandButtonExpandedTopMargin = getResources().getDimensionPixelSize( 270 R.dimen.conversation_expand_button_top_margin_expanded); 271 mNotificationHeaderExpandedPadding = getResources().getDimensionPixelSize( 272 R.dimen.conversation_header_expanded_padding_end); 273 mContentMarginEnd = getResources().getDimensionPixelSize( 274 R.dimen.notification_content_margin_end); 275 mBadgedSideMargins = getResources().getDimensionPixelSize( 276 R.dimen.conversation_badge_side_margin); 277 mConversationAvatarSize = getResources().getDimensionPixelSize( 278 R.dimen.conversation_avatar_size); 279 mConversationAvatarSizeExpanded = getResources().getDimensionPixelSize( 280 R.dimen.conversation_avatar_size_group_expanded); 281 mConversationIconTopPaddingExpandedGroup = getResources().getDimensionPixelSize( 282 R.dimen.conversation_icon_container_top_padding_small_avatar); 283 mConversationIconTopPadding = getResources().getDimensionPixelSize( 284 R.dimen.conversation_icon_container_top_padding); 285 mExpandedGroupMessagePadding = getResources().getDimensionPixelSize( 286 R.dimen.expanded_group_conversation_message_padding); 287 mExpandedGroupSideMargin = getResources().getDimensionPixelSize( 288 R.dimen.conversation_badge_side_margin_group_expanded); 289 mExpandedGroupSideMarginFacePile = getResources().getDimensionPixelSize( 290 R.dimen.conversation_badge_side_margin_group_expanded_face_pile); 291 mConversationFacePile = findViewById(R.id.conversation_face_pile); 292 mFacePileAvatarSize = getResources().getDimensionPixelSize( 293 R.dimen.conversation_face_pile_avatar_size); 294 mFacePileAvatarSizeExpandedGroup = getResources().getDimensionPixelSize( 295 R.dimen.conversation_face_pile_avatar_size_group_expanded); 296 mFacePileProtectionWidth = getResources().getDimensionPixelSize( 297 R.dimen.conversation_face_pile_protection_width); 298 mFacePileProtectionWidthExpanded = getResources().getDimensionPixelSize( 299 R.dimen.conversation_face_pile_protection_width_expanded); 300 mFallbackChatName = getResources().getString( 301 R.string.conversation_title_fallback_one_to_one); 302 mFallbackGroupChatName = getResources().getString( 303 R.string.conversation_title_fallback_group_chat); 304 mAppName = findViewById(R.id.app_name_text); 305 mAppNameDivider = findViewById(R.id.app_name_divider); 306 mAppNameGone = mAppName.getVisibility() == GONE; 307 mAppName.setOnVisibilityChangedListener((visibility) -> { 308 onAppNameVisibilityChanged(); 309 }); 310 mUnreadBadge = findViewById(R.id.conversation_unread_count); 311 mConversationContentStart = getResources().getDimensionPixelSize( 312 R.dimen.conversation_content_start); 313 mInternalButtonPadding 314 = getResources().getDimensionPixelSize(R.dimen.button_padding_horizontal_material) 315 + getResources().getDimensionPixelSize(R.dimen.button_inset_horizontal_material); 316 } 317 animateViewForceHidden(CachingIconView view, boolean forceHidden)318 private void animateViewForceHidden(CachingIconView view, boolean forceHidden) { 319 boolean nowForceHidden = view.willBeForceHidden() || view.isForceHidden(); 320 if (forceHidden == nowForceHidden) { 321 // We are either already forceHidden or will be 322 return; 323 } 324 view.animate().cancel(); 325 view.setWillBeForceHidden(forceHidden); 326 view.animate() 327 .scaleX(forceHidden ? 0.5f : 1.0f) 328 .scaleY(forceHidden ? 0.5f : 1.0f) 329 .alpha(forceHidden ? 0.0f : 1.0f) 330 .setInterpolator(forceHidden ? ALPHA_OUT : ALPHA_IN) 331 .setDuration(160); 332 if (view.getVisibility() != VISIBLE) { 333 view.setForceHidden(forceHidden); 334 } else { 335 view.animate().withEndAction(() -> view.setForceHidden(forceHidden)); 336 } 337 view.animate().start(); 338 } 339 340 @RemotableViewMethod setAvatarReplacement(Icon icon)341 public void setAvatarReplacement(Icon icon) { 342 mAvatarReplacement = icon; 343 } 344 345 @RemotableViewMethod setNameReplacement(CharSequence nameReplacement)346 public void setNameReplacement(CharSequence nameReplacement) { 347 mNameReplacement = nameReplacement; 348 } 349 350 /** Sets this conversation as "important", adding some additional UI treatment. */ 351 @RemotableViewMethod setIsImportantConversation(boolean isImportantConversation)352 public void setIsImportantConversation(boolean isImportantConversation) { 353 setIsImportantConversation(isImportantConversation, false); 354 } 355 356 /** @hide **/ setIsImportantConversation(boolean isImportantConversation, boolean animate)357 public void setIsImportantConversation(boolean isImportantConversation, boolean animate) { 358 mImportantConversation = isImportantConversation; 359 mImportanceRingView.setVisibility(isImportantConversation && mIcon.getVisibility() != GONE 360 ? VISIBLE : GONE); 361 362 if (animate && isImportantConversation) { 363 GradientDrawable ring = (GradientDrawable) mImportanceRingView.getDrawable(); 364 ring.mutate(); 365 GradientDrawable bg = (GradientDrawable) mConversationIconBadgeBg.getDrawable(); 366 bg.mutate(); 367 int ringColor = getResources() 368 .getColor(R.color.conversation_important_highlight); 369 int standardThickness = getResources() 370 .getDimensionPixelSize(R.dimen.importance_ring_stroke_width); 371 int largeThickness = getResources() 372 .getDimensionPixelSize(R.dimen.importance_ring_anim_max_stroke_width); 373 int standardSize = getResources().getDimensionPixelSize( 374 R.dimen.importance_ring_size); 375 int baseSize = standardSize - standardThickness * 2; 376 int bgSize = getResources() 377 .getDimensionPixelSize(R.dimen.conversation_icon_size_badged); 378 379 ValueAnimator.AnimatorUpdateListener animatorUpdateListener = animation -> { 380 int strokeWidth = Math.round((float) animation.getAnimatedValue()); 381 ring.setStroke(strokeWidth, ringColor); 382 int newSize = baseSize + strokeWidth * 2; 383 ring.setSize(newSize, newSize); 384 mImportanceRingView.invalidate(); 385 }; 386 387 ValueAnimator growAnimation = ValueAnimator.ofFloat(0, largeThickness); 388 growAnimation.setInterpolator(LINEAR_OUT_SLOW_IN); 389 growAnimation.setDuration(IMPORTANCE_ANIM_GROW_DURATION); 390 growAnimation.addUpdateListener(animatorUpdateListener); 391 392 ValueAnimator shrinkAnimation = 393 ValueAnimator.ofFloat(largeThickness, standardThickness); 394 shrinkAnimation.setDuration(IMPORTANCE_ANIM_SHRINK_DURATION); 395 shrinkAnimation.setStartDelay(IMPORTANCE_ANIM_SHRINK_DELAY); 396 shrinkAnimation.setInterpolator(OVERSHOOT); 397 shrinkAnimation.addUpdateListener(animatorUpdateListener); 398 shrinkAnimation.addListener(new AnimatorListenerAdapter() { 399 @Override 400 public void onAnimationStart(Animator animation) { 401 // Shrink the badge bg so that it doesn't peek behind the animation 402 bg.setSize(baseSize, baseSize); 403 mConversationIconBadgeBg.invalidate(); 404 } 405 406 @Override 407 public void onAnimationEnd(Animator animation) { 408 // Reset bg back to normal size 409 bg.setSize(bgSize, bgSize); 410 mConversationIconBadgeBg.invalidate(); 411 } 412 }); 413 414 AnimatorSet anims = new AnimatorSet(); 415 anims.playSequentially(growAnimation, shrinkAnimation); 416 anims.start(); 417 } 418 } 419 isImportantConversation()420 public boolean isImportantConversation() { 421 return mImportantConversation; 422 } 423 424 /** 425 * Set this layout to show the collapsed representation. 426 * 427 * @param isCollapsed is it collapsed 428 */ 429 @RemotableViewMethod setIsCollapsed(boolean isCollapsed)430 public void setIsCollapsed(boolean isCollapsed) { 431 mIsCollapsed = isCollapsed; 432 mMessagingLinearLayout.setMaxDisplayedLines(isCollapsed ? 1 : Integer.MAX_VALUE); 433 updateExpandButton(); 434 updateContentEndPaddings(); 435 } 436 437 @RemotableViewMethod setData(Bundle extras)438 public void setData(Bundle extras) { 439 Parcelable[] messages = extras.getParcelableArray(Notification.EXTRA_MESSAGES); 440 List<Notification.MessagingStyle.Message> newMessages 441 = Notification.MessagingStyle.Message.getMessagesFromBundleArray(messages); 442 Parcelable[] histMessages = extras.getParcelableArray(Notification.EXTRA_HISTORIC_MESSAGES); 443 List<Notification.MessagingStyle.Message> newHistoricMessages 444 = Notification.MessagingStyle.Message.getMessagesFromBundleArray(histMessages); 445 446 // mUser now set (would be nice to avoid the side effect but WHATEVER) 447 setUser(extras.getParcelable(Notification.EXTRA_MESSAGING_PERSON)); 448 449 // Append remote input history to newMessages (again, side effect is lame but WHATEVS) 450 RemoteInputHistoryItem[] history = (RemoteInputHistoryItem[]) 451 extras.getParcelableArray(Notification.EXTRA_REMOTE_INPUT_HISTORY_ITEMS); 452 addRemoteInputHistoryToMessages(newMessages, history); 453 454 boolean showSpinner = 455 extras.getBoolean(Notification.EXTRA_SHOW_REMOTE_INPUT_SPINNER, false); 456 // bind it, baby 457 bind(newMessages, newHistoricMessages, showSpinner); 458 459 int unreadCount = extras.getInt(Notification.EXTRA_CONVERSATION_UNREAD_MESSAGE_COUNT); 460 setUnreadCount(unreadCount); 461 } 462 463 @Override setImageResolver(ImageResolver resolver)464 public void setImageResolver(ImageResolver resolver) { 465 mImageResolver = resolver; 466 } 467 468 /** @hide */ setUnreadCount(int unreadCount)469 public void setUnreadCount(int unreadCount) { 470 boolean visible = mIsCollapsed && unreadCount > 1; 471 mUnreadBadge.setVisibility(visible ? VISIBLE : GONE); 472 if (visible) { 473 CharSequence text = unreadCount >= 100 474 ? getResources().getString(R.string.unread_convo_overflow, 99) 475 : String.format(Locale.getDefault(), "%d", unreadCount); 476 mUnreadBadge.setText(text); 477 mUnreadBadge.setBackgroundTintList(ColorStateList.valueOf(mLayoutColor)); 478 boolean needDarkText = ColorUtils.calculateLuminance(mLayoutColor) > 0.5f; 479 mUnreadBadge.setTextColor(needDarkText ? Color.BLACK : Color.WHITE); 480 } 481 } 482 addRemoteInputHistoryToMessages( List<Notification.MessagingStyle.Message> newMessages, RemoteInputHistoryItem[] remoteInputHistory)483 private void addRemoteInputHistoryToMessages( 484 List<Notification.MessagingStyle.Message> newMessages, 485 RemoteInputHistoryItem[] remoteInputHistory) { 486 if (remoteInputHistory == null || remoteInputHistory.length == 0) { 487 return; 488 } 489 for (int i = remoteInputHistory.length - 1; i >= 0; i--) { 490 RemoteInputHistoryItem historyMessage = remoteInputHistory[i]; 491 Notification.MessagingStyle.Message message = new Notification.MessagingStyle.Message( 492 historyMessage.getText(), 0, (Person) null, true /* remoteHistory */); 493 if (historyMessage.getUri() != null) { 494 message.setData(historyMessage.getMimeType(), historyMessage.getUri()); 495 } 496 newMessages.add(message); 497 } 498 } 499 bind(List<Notification.MessagingStyle.Message> newMessages, List<Notification.MessagingStyle.Message> newHistoricMessages, boolean showSpinner)500 private void bind(List<Notification.MessagingStyle.Message> newMessages, 501 List<Notification.MessagingStyle.Message> newHistoricMessages, 502 boolean showSpinner) { 503 // convert MessagingStyle.Message to MessagingMessage, re-using ones from a previous binding 504 // if they exist 505 List<MessagingMessage> historicMessages = createMessages(newHistoricMessages, 506 true /* isHistoric */); 507 List<MessagingMessage> messages = createMessages(newMessages, false /* isHistoric */); 508 509 // Copy our groups, before they get clobbered 510 ArrayList<MessagingGroup> oldGroups = new ArrayList<>(mGroups); 511 512 // Add our new MessagingMessages to groups 513 List<List<MessagingMessage>> groups = new ArrayList<>(); 514 List<Person> senders = new ArrayList<>(); 515 516 // Lets first find the groups (populate `groups` and `senders`) 517 findGroups(historicMessages, messages, groups, senders); 518 519 // Let's now create the views and reorder them accordingly 520 // side-effect: updates mGroups, mAddedGroups 521 createGroupViews(groups, senders, showSpinner); 522 523 // Let's first check which groups were removed altogether and remove them in one animation 524 removeGroups(oldGroups); 525 526 // Let's remove the remaining messages 527 mMessages.forEach(REMOVE_MESSAGE); 528 mHistoricMessages.forEach(REMOVE_MESSAGE); 529 530 mMessages = messages; 531 mHistoricMessages = historicMessages; 532 533 updateHistoricMessageVisibility(); 534 updateTitleAndNamesDisplay(); 535 536 updateConversationLayout(); 537 } 538 539 /** 540 * Update the layout according to the data provided (i.e mIsOneToOne, expanded etc); 541 */ updateConversationLayout()542 private void updateConversationLayout() { 543 // Set avatar and name 544 CharSequence conversationText = mConversationTitle; 545 mConversationIcon = mShortcutIcon; 546 if (mIsOneToOne) { 547 // Let's resolve the icon / text from the last sender 548 CharSequence userKey = getKey(mUser); 549 for (int i = mGroups.size() - 1; i >= 0; i--) { 550 MessagingGroup messagingGroup = mGroups.get(i); 551 Person messageSender = messagingGroup.getSender(); 552 if ((messageSender != null && !TextUtils.equals(userKey, getKey(messageSender))) 553 || i == 0) { 554 if (TextUtils.isEmpty(conversationText)) { 555 // We use the sendername as header text if no conversation title is provided 556 // (This usually happens for most 1:1 conversations) 557 conversationText = messagingGroup.getSenderName(); 558 } 559 if (mConversationIcon == null) { 560 Icon avatarIcon = messagingGroup.getAvatarIcon(); 561 if (avatarIcon == null) { 562 avatarIcon = createAvatarSymbol(conversationText, "", mLayoutColor); 563 } 564 mConversationIcon = avatarIcon; 565 } 566 break; 567 } 568 } 569 } 570 if (mConversationIcon == null) { 571 mConversationIcon = mLargeIcon; 572 } 573 if (mIsOneToOne || mConversationIcon != null) { 574 mConversationIconView.setVisibility(VISIBLE); 575 mConversationFacePile.setVisibility(GONE); 576 mConversationIconView.setImageIcon(mConversationIcon); 577 } else { 578 mConversationIconView.setVisibility(GONE); 579 // This will also inflate it! 580 mConversationFacePile.setVisibility(VISIBLE); 581 // rebind the value to the inflated view instead of the stub 582 mConversationFacePile = findViewById(R.id.conversation_face_pile); 583 bindFacePile(); 584 } 585 if (TextUtils.isEmpty(conversationText)) { 586 conversationText = mIsOneToOne ? mFallbackChatName : mFallbackGroupChatName; 587 } 588 mConversationText.setText(conversationText); 589 // Update if the groups can hide the sender if they are first (applies to 1:1 conversations) 590 // This needs to happen after all of the above o update all of the groups 591 for (int i = mGroups.size() - 1; i >= 0; i--) { 592 MessagingGroup messagingGroup = mGroups.get(i); 593 CharSequence messageSender = messagingGroup.getSenderName(); 594 boolean canHide = mIsOneToOne 595 && TextUtils.equals(conversationText, messageSender); 596 messagingGroup.setCanHideSenderIfFirst(canHide); 597 } 598 updateAppName(); 599 updateIconPositionAndSize(); 600 updateImageMessages(); 601 updatePaddingsBasedOnContentAvailability(); 602 updateActionListPadding(); 603 updateAppNameDividerVisibility(); 604 } 605 updateActionListPadding()606 private void updateActionListPadding() { 607 if (mActions == null) { 608 return; 609 } 610 View firstAction = mActions.getChildAt(0); 611 if (firstAction != null) { 612 // Let's visually position the first action where the content starts 613 int paddingStart = mConversationContentStart; 614 615 MarginLayoutParams layoutParams = (MarginLayoutParams) firstAction.getLayoutParams(); 616 paddingStart -= layoutParams.getMarginStart(); 617 paddingStart -= mInternalButtonPadding; 618 619 mActions.setPaddingRelative(paddingStart, 620 mActions.getPaddingTop(), 621 mActions.getPaddingEnd(), 622 mActions.getPaddingBottom()); 623 } 624 } 625 updateImageMessages()626 private void updateImageMessages() { 627 View newMessage = null; 628 if (mIsCollapsed && mGroups.size() > 0) { 629 630 // When collapsed, we're displaying the image message in a dedicated container 631 // on the right of the layout instead of inline. Let's add the isolated image there 632 MessagingGroup messagingGroup = mGroups.get(mGroups.size() -1); 633 MessagingImageMessage isolatedMessage = messagingGroup.getIsolatedMessage(); 634 if (isolatedMessage != null) { 635 newMessage = isolatedMessage.getView(); 636 } 637 } 638 // Remove all messages that don't belong into the image layout 639 View previousMessage = mImageMessageContainer.getChildAt(0); 640 if (previousMessage != newMessage) { 641 mImageMessageContainer.removeView(previousMessage); 642 if (newMessage != null) { 643 mImageMessageContainer.addView(newMessage); 644 } 645 } 646 mImageMessageContainer.setVisibility(newMessage != null ? VISIBLE : GONE); 647 } 648 bindFacePile(ImageView bottomBackground, ImageView bottomView, ImageView topView)649 public void bindFacePile(ImageView bottomBackground, ImageView bottomView, ImageView topView) { 650 applyNotificationBackgroundColor(bottomBackground); 651 // Let's find the two last conversations: 652 Icon secondLastIcon = null; 653 CharSequence lastKey = null; 654 Icon lastIcon = null; 655 CharSequence userKey = getKey(mUser); 656 for (int i = mGroups.size() - 1; i >= 0; i--) { 657 MessagingGroup messagingGroup = mGroups.get(i); 658 Person messageSender = messagingGroup.getSender(); 659 boolean notUser = messageSender != null 660 && !TextUtils.equals(userKey, getKey(messageSender)); 661 boolean notIncluded = messageSender != null 662 && !TextUtils.equals(lastKey, getKey(messageSender)); 663 if ((notUser && notIncluded) 664 || (i == 0 && lastKey == null)) { 665 if (lastIcon == null) { 666 lastIcon = messagingGroup.getAvatarIcon(); 667 lastKey = getKey(messageSender); 668 } else { 669 secondLastIcon = messagingGroup.getAvatarIcon(); 670 break; 671 } 672 } 673 } 674 if (lastIcon == null) { 675 lastIcon = createAvatarSymbol(" ", "", mLayoutColor); 676 } 677 bottomView.setImageIcon(lastIcon); 678 if (secondLastIcon == null) { 679 secondLastIcon = createAvatarSymbol("", "", mLayoutColor); 680 } 681 topView.setImageIcon(secondLastIcon); 682 } 683 bindFacePile()684 private void bindFacePile() { 685 ImageView bottomBackground = mConversationFacePile.findViewById( 686 R.id.conversation_face_pile_bottom_background); 687 ImageView bottomView = mConversationFacePile.findViewById( 688 R.id.conversation_face_pile_bottom); 689 ImageView topView = mConversationFacePile.findViewById( 690 R.id.conversation_face_pile_top); 691 692 bindFacePile(bottomBackground, bottomView, topView); 693 694 int conversationAvatarSize; 695 int facepileAvatarSize; 696 int facePileBackgroundSize; 697 if (mIsCollapsed) { 698 conversationAvatarSize = mConversationAvatarSize; 699 facepileAvatarSize = mFacePileAvatarSize; 700 facePileBackgroundSize = facepileAvatarSize + 2 * mFacePileProtectionWidth; 701 } else { 702 conversationAvatarSize = mConversationAvatarSizeExpanded; 703 facepileAvatarSize = mFacePileAvatarSizeExpandedGroup; 704 facePileBackgroundSize = facepileAvatarSize + 2 * mFacePileProtectionWidthExpanded; 705 } 706 LayoutParams layoutParams = (LayoutParams) mConversationIconView.getLayoutParams(); 707 layoutParams.width = conversationAvatarSize; 708 layoutParams.height = conversationAvatarSize; 709 mConversationFacePile.setLayoutParams(layoutParams); 710 711 layoutParams = (LayoutParams) bottomView.getLayoutParams(); 712 layoutParams.width = facepileAvatarSize; 713 layoutParams.height = facepileAvatarSize; 714 bottomView.setLayoutParams(layoutParams); 715 716 layoutParams = (LayoutParams) topView.getLayoutParams(); 717 layoutParams.width = facepileAvatarSize; 718 layoutParams.height = facepileAvatarSize; 719 topView.setLayoutParams(layoutParams); 720 721 layoutParams = (LayoutParams) bottomBackground.getLayoutParams(); 722 layoutParams.width = facePileBackgroundSize; 723 layoutParams.height = facePileBackgroundSize; 724 bottomBackground.setLayoutParams(layoutParams); 725 } 726 updateAppName()727 private void updateAppName() { 728 mAppName.setVisibility(mIsCollapsed ? GONE : VISIBLE); 729 } 730 shouldHideAppName()731 public boolean shouldHideAppName() { 732 return mIsCollapsed; 733 } 734 735 /** 736 * update the icon position and sizing 737 */ updateIconPositionAndSize()738 private void updateIconPositionAndSize() { 739 int sidemargin; 740 int conversationAvatarSize; 741 if (mIsOneToOne || mIsCollapsed) { 742 sidemargin = mBadgedSideMargins; 743 conversationAvatarSize = mConversationAvatarSize; 744 } else { 745 sidemargin = mConversationFacePile.getVisibility() == VISIBLE 746 ? mExpandedGroupSideMarginFacePile 747 : mExpandedGroupSideMargin; 748 conversationAvatarSize = mConversationAvatarSizeExpanded; 749 } 750 LayoutParams layoutParams = 751 (LayoutParams) mConversationIconBadge.getLayoutParams(); 752 layoutParams.topMargin = sidemargin; 753 layoutParams.setMarginStart(sidemargin); 754 mConversationIconBadge.setLayoutParams(layoutParams); 755 756 if (mConversationIconView.getVisibility() == VISIBLE) { 757 layoutParams = (LayoutParams) mConversationIconView.getLayoutParams(); 758 layoutParams.width = conversationAvatarSize; 759 layoutParams.height = conversationAvatarSize; 760 mConversationIconView.setLayoutParams(layoutParams); 761 } 762 } 763 updatePaddingsBasedOnContentAvailability()764 private void updatePaddingsBasedOnContentAvailability() { 765 int messagingPadding = mIsOneToOne || mIsCollapsed 766 ? 0 767 // Add some extra padding to the messages, since otherwise it will overlap with the 768 // group 769 : mExpandedGroupMessagePadding; 770 771 int iconPadding = mIsOneToOne || mIsCollapsed 772 ? mConversationIconTopPadding 773 : mConversationIconTopPaddingExpandedGroup; 774 775 mConversationIconContainer.setPaddingRelative( 776 mConversationIconContainer.getPaddingStart(), 777 iconPadding, 778 mConversationIconContainer.getPaddingEnd(), 779 mConversationIconContainer.getPaddingBottom()); 780 781 mMessagingLinearLayout.setPaddingRelative( 782 mMessagingLinearLayout.getPaddingStart(), 783 messagingPadding, 784 mMessagingLinearLayout.getPaddingEnd(), 785 mMessagingLinearLayout.getPaddingBottom()); 786 } 787 788 @RemotableViewMethod setLargeIcon(Icon largeIcon)789 public void setLargeIcon(Icon largeIcon) { 790 mLargeIcon = largeIcon; 791 } 792 793 @RemotableViewMethod setShortcutIcon(Icon shortcutIcon)794 public void setShortcutIcon(Icon shortcutIcon) { 795 mShortcutIcon = shortcutIcon; 796 } 797 798 /** 799 * Sets the conversation title of this conversation. 800 * 801 * @param conversationTitle the conversation title 802 */ 803 @RemotableViewMethod setConversationTitle(CharSequence conversationTitle)804 public void setConversationTitle(CharSequence conversationTitle) { 805 // Remove formatting from the title. 806 mConversationTitle = conversationTitle != null ? conversationTitle.toString() : null; 807 } 808 getConversationTitle()809 public CharSequence getConversationTitle() { 810 return mConversationText.getText(); 811 } 812 removeGroups(ArrayList<MessagingGroup> oldGroups)813 private void removeGroups(ArrayList<MessagingGroup> oldGroups) { 814 int size = oldGroups.size(); 815 for (int i = 0; i < size; i++) { 816 MessagingGroup group = oldGroups.get(i); 817 if (!mGroups.contains(group)) { 818 List<MessagingMessage> messages = group.getMessages(); 819 Runnable endRunnable = () -> { 820 mMessagingLinearLayout.removeTransientView(group); 821 group.recycle(); 822 }; 823 824 boolean wasShown = group.isShown(); 825 mMessagingLinearLayout.removeView(group); 826 if (wasShown && !MessagingLinearLayout.isGone(group)) { 827 mMessagingLinearLayout.addTransientView(group, 0); 828 group.removeGroupAnimated(endRunnable); 829 } else { 830 endRunnable.run(); 831 } 832 mMessages.removeAll(messages); 833 mHistoricMessages.removeAll(messages); 834 } 835 } 836 } 837 updateTitleAndNamesDisplay()838 private void updateTitleAndNamesDisplay() { 839 ArrayMap<CharSequence, String> uniqueNames = new ArrayMap<>(); 840 ArrayMap<Character, CharSequence> uniqueCharacters = new ArrayMap<>(); 841 for (int i = 0; i < mGroups.size(); i++) { 842 MessagingGroup group = mGroups.get(i); 843 CharSequence senderName = group.getSenderName(); 844 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) { 845 continue; 846 } 847 if (!uniqueNames.containsKey(senderName)) { 848 // Only use visible characters to get uniqueNames 849 String pureSenderName = IGNORABLE_CHAR_PATTERN 850 .matcher(senderName).replaceAll("" /* replacement */); 851 char c = pureSenderName.charAt(0); 852 if (uniqueCharacters.containsKey(c)) { 853 // this character was already used, lets make it more unique. We first need to 854 // resolve the existing character if it exists 855 CharSequence existingName = uniqueCharacters.get(c); 856 if (existingName != null) { 857 uniqueNames.put(existingName, findNameSplit((String) existingName)); 858 uniqueCharacters.put(c, null); 859 } 860 uniqueNames.put(senderName, findNameSplit((String) senderName)); 861 } else { 862 uniqueNames.put(senderName, Character.toString(c)); 863 uniqueCharacters.put(c, pureSenderName); 864 } 865 } 866 } 867 868 // Now that we have the correct symbols, let's look what we have cached 869 ArrayMap<CharSequence, Icon> cachedAvatars = new ArrayMap<>(); 870 for (int i = 0; i < mGroups.size(); i++) { 871 // Let's now set the avatars 872 MessagingGroup group = mGroups.get(i); 873 boolean isOwnMessage = group.getSender() == mUser; 874 CharSequence senderName = group.getSenderName(); 875 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName) 876 || (mIsOneToOne && mAvatarReplacement != null && !isOwnMessage)) { 877 continue; 878 } 879 String symbol = uniqueNames.get(senderName); 880 Icon cachedIcon = group.getAvatarSymbolIfMatching(senderName, 881 symbol, mLayoutColor); 882 if (cachedIcon != null) { 883 cachedAvatars.put(senderName, cachedIcon); 884 } 885 } 886 887 for (int i = 0; i < mGroups.size(); i++) { 888 // Let's now set the avatars 889 MessagingGroup group = mGroups.get(i); 890 CharSequence senderName = group.getSenderName(); 891 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) { 892 continue; 893 } 894 if (mIsOneToOne && mAvatarReplacement != null && group.getSender() != mUser) { 895 group.setAvatar(mAvatarReplacement); 896 } else { 897 Icon cachedIcon = cachedAvatars.get(senderName); 898 if (cachedIcon == null) { 899 cachedIcon = createAvatarSymbol(senderName, uniqueNames.get(senderName), 900 mLayoutColor); 901 cachedAvatars.put(senderName, cachedIcon); 902 } 903 group.setCreatedAvatar(cachedIcon, senderName, uniqueNames.get(senderName), 904 mLayoutColor); 905 } 906 } 907 } 908 createAvatarSymbol(CharSequence senderName, String symbol, int layoutColor)909 private Icon createAvatarSymbol(CharSequence senderName, String symbol, int layoutColor) { 910 if (symbol.isEmpty() || TextUtils.isDigitsOnly(symbol) || 911 SPECIAL_CHAR_PATTERN.matcher(symbol).find()) { 912 Icon avatarIcon = Icon.createWithResource(getContext(), 913 R.drawable.messaging_user); 914 avatarIcon.setTint(findColor(senderName, layoutColor)); 915 return avatarIcon; 916 } else { 917 Bitmap bitmap = Bitmap.createBitmap(mAvatarSize, mAvatarSize, Bitmap.Config.ARGB_8888); 918 Canvas canvas = new Canvas(bitmap); 919 float radius = mAvatarSize / 2.0f; 920 int color = findColor(senderName, layoutColor); 921 mPaint.setColor(color); 922 canvas.drawCircle(radius, radius, radius, mPaint); 923 boolean needDarkText = ColorUtils.calculateLuminance(color) > 0.5f; 924 mTextPaint.setColor(needDarkText ? Color.BLACK : Color.WHITE); 925 mTextPaint.setTextSize(symbol.length() == 1 ? mAvatarSize * 0.5f : mAvatarSize * 0.3f); 926 int yPos = (int) (radius - ((mTextPaint.descent() + mTextPaint.ascent()) / 2)); 927 canvas.drawText(symbol, radius, yPos, mTextPaint); 928 return Icon.createWithBitmap(bitmap); 929 } 930 } 931 findColor(CharSequence senderName, int layoutColor)932 private int findColor(CharSequence senderName, int layoutColor) { 933 double luminance = ContrastColorUtil.calculateLuminance(layoutColor); 934 float shift = Math.abs(senderName.hashCode()) % 5 / 4.0f - 0.5f; 935 936 // we need to offset the range if the luminance is too close to the borders 937 shift += Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - luminance, 0); 938 shift -= Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - (1.0f - luminance), 0); 939 return ContrastColorUtil.getShiftedColor(layoutColor, 940 (int) (shift * COLOR_SHIFT_AMOUNT)); 941 } 942 findNameSplit(String existingName)943 private String findNameSplit(String existingName) { 944 String[] split = existingName.split(" "); 945 if (split.length > 1) { 946 return Character.toString(split[0].charAt(0)) 947 + Character.toString(split[1].charAt(0)); 948 } 949 return existingName.substring(0, 1); 950 } 951 952 @RemotableViewMethod setLayoutColor(int color)953 public void setLayoutColor(int color) { 954 mLayoutColor = color; 955 } 956 957 @RemotableViewMethod setIsOneToOne(boolean oneToOne)958 public void setIsOneToOne(boolean oneToOne) { 959 mIsOneToOne = oneToOne; 960 } 961 962 @RemotableViewMethod setSenderTextColor(int color)963 public void setSenderTextColor(int color) { 964 mSenderTextColor = color; 965 mConversationText.setTextColor(color); 966 } 967 968 /** 969 * @param color the color of the notification background 970 */ 971 @RemotableViewMethod setNotificationBackgroundColor(int color)972 public void setNotificationBackgroundColor(int color) { 973 mNotificationBackgroundColor = color; 974 applyNotificationBackgroundColor(mConversationIconBadgeBg); 975 } 976 applyNotificationBackgroundColor(ImageView view)977 private void applyNotificationBackgroundColor(ImageView view) { 978 view.setImageTintList(ColorStateList.valueOf(mNotificationBackgroundColor)); 979 } 980 981 @RemotableViewMethod setMessageTextColor(int color)982 public void setMessageTextColor(int color) { 983 mMessageTextColor = color; 984 } 985 setUser(Person user)986 private void setUser(Person user) { 987 mUser = user; 988 if (mUser.getIcon() == null) { 989 Icon userIcon = Icon.createWithResource(getContext(), 990 R.drawable.messaging_user); 991 userIcon.setTint(mLayoutColor); 992 mUser = mUser.toBuilder().setIcon(userIcon).build(); 993 } 994 } 995 createGroupViews(List<List<MessagingMessage>> groups, List<Person> senders, boolean showSpinner)996 private void createGroupViews(List<List<MessagingMessage>> groups, 997 List<Person> senders, boolean showSpinner) { 998 mGroups.clear(); 999 for (int groupIndex = 0; groupIndex < groups.size(); groupIndex++) { 1000 List<MessagingMessage> group = groups.get(groupIndex); 1001 MessagingGroup newGroup = null; 1002 // we'll just take the first group that exists or create one there is none 1003 for (int messageIndex = group.size() - 1; messageIndex >= 0; messageIndex--) { 1004 MessagingMessage message = group.get(messageIndex); 1005 newGroup = message.getGroup(); 1006 if (newGroup != null) { 1007 break; 1008 } 1009 } 1010 // Create a new group, adding it to the linear layout as well 1011 if (newGroup == null) { 1012 newGroup = MessagingGroup.createGroup(mMessagingLinearLayout); 1013 mAddedGroups.add(newGroup); 1014 } 1015 newGroup.setImageDisplayLocation(mIsCollapsed 1016 ? IMAGE_DISPLAY_LOCATION_EXTERNAL 1017 : IMAGE_DISPLAY_LOCATION_INLINE); 1018 newGroup.setIsInConversation(true); 1019 newGroup.setLayoutColor(mLayoutColor); 1020 newGroup.setTextColors(mSenderTextColor, mMessageTextColor); 1021 Person sender = senders.get(groupIndex); 1022 CharSequence nameOverride = null; 1023 if (sender != mUser && mNameReplacement != null) { 1024 nameOverride = mNameReplacement; 1025 } 1026 newGroup.setShowingAvatar(!mIsOneToOne && !mIsCollapsed); 1027 newGroup.setSingleLine(mIsCollapsed); 1028 newGroup.setSender(sender, nameOverride); 1029 newGroup.setSending(groupIndex == (groups.size() - 1) && showSpinner); 1030 mGroups.add(newGroup); 1031 1032 // Reposition to the correct place (if we're re-using a group) 1033 if (mMessagingLinearLayout.indexOfChild(newGroup) != groupIndex) { 1034 mMessagingLinearLayout.removeView(newGroup); 1035 mMessagingLinearLayout.addView(newGroup, groupIndex); 1036 } 1037 newGroup.setMessages(group); 1038 } 1039 } 1040 findGroups(List<MessagingMessage> historicMessages, List<MessagingMessage> messages, List<List<MessagingMessage>> groups, List<Person> senders)1041 private void findGroups(List<MessagingMessage> historicMessages, 1042 List<MessagingMessage> messages, List<List<MessagingMessage>> groups, 1043 List<Person> senders) { 1044 CharSequence currentSenderKey = null; 1045 List<MessagingMessage> currentGroup = null; 1046 int histSize = historicMessages.size(); 1047 for (int i = 0; i < histSize + messages.size(); i++) { 1048 MessagingMessage message; 1049 if (i < histSize) { 1050 message = historicMessages.get(i); 1051 } else { 1052 message = messages.get(i - histSize); 1053 } 1054 boolean isNewGroup = currentGroup == null; 1055 Person sender = message.getMessage().getSenderPerson(); 1056 CharSequence key = getKey(sender); 1057 isNewGroup |= !TextUtils.equals(key, currentSenderKey); 1058 if (isNewGroup) { 1059 currentGroup = new ArrayList<>(); 1060 groups.add(currentGroup); 1061 if (sender == null) { 1062 sender = mUser; 1063 } else { 1064 // Remove all formatting from the sender name 1065 sender = sender.toBuilder().setName(Objects.toString(sender.getName())).build(); 1066 } 1067 senders.add(sender); 1068 currentSenderKey = key; 1069 } 1070 currentGroup.add(message); 1071 } 1072 } 1073 getKey(Person person)1074 private CharSequence getKey(Person person) { 1075 return person == null ? null : person.getKey() == null ? person.getName() : person.getKey(); 1076 } 1077 1078 /** 1079 * Creates new messages, reusing existing ones if they are available. 1080 * 1081 * @param newMessages the messages to parse. 1082 */ createMessages( List<Notification.MessagingStyle.Message> newMessages, boolean historic)1083 private List<MessagingMessage> createMessages( 1084 List<Notification.MessagingStyle.Message> newMessages, boolean historic) { 1085 List<MessagingMessage> result = new ArrayList<>(); 1086 for (int i = 0; i < newMessages.size(); i++) { 1087 Notification.MessagingStyle.Message m = newMessages.get(i); 1088 MessagingMessage message = findAndRemoveMatchingMessage(m); 1089 if (message == null) { 1090 message = MessagingMessage.createMessage(this, m, mImageResolver); 1091 } 1092 message.setIsHistoric(historic); 1093 result.add(message); 1094 } 1095 return result; 1096 } 1097 findAndRemoveMatchingMessage(Notification.MessagingStyle.Message m)1098 private MessagingMessage findAndRemoveMatchingMessage(Notification.MessagingStyle.Message m) { 1099 for (int i = 0; i < mMessages.size(); i++) { 1100 MessagingMessage existing = mMessages.get(i); 1101 if (existing.sameAs(m)) { 1102 mMessages.remove(i); 1103 return existing; 1104 } 1105 } 1106 for (int i = 0; i < mHistoricMessages.size(); i++) { 1107 MessagingMessage existing = mHistoricMessages.get(i); 1108 if (existing.sameAs(m)) { 1109 mHistoricMessages.remove(i); 1110 return existing; 1111 } 1112 } 1113 return null; 1114 } 1115 showHistoricMessages(boolean show)1116 public void showHistoricMessages(boolean show) { 1117 mShowHistoricMessages = show; 1118 updateHistoricMessageVisibility(); 1119 } 1120 updateHistoricMessageVisibility()1121 private void updateHistoricMessageVisibility() { 1122 int numHistoric = mHistoricMessages.size(); 1123 for (int i = 0; i < numHistoric; i++) { 1124 MessagingMessage existing = mHistoricMessages.get(i); 1125 existing.setVisibility(mShowHistoricMessages ? VISIBLE : GONE); 1126 } 1127 int numGroups = mGroups.size(); 1128 for (int i = 0; i < numGroups; i++) { 1129 MessagingGroup group = mGroups.get(i); 1130 int visibleChildren = 0; 1131 List<MessagingMessage> messages = group.getMessages(); 1132 int numGroupMessages = messages.size(); 1133 for (int j = 0; j < numGroupMessages; j++) { 1134 MessagingMessage message = messages.get(j); 1135 if (message.getVisibility() != GONE) { 1136 visibleChildren++; 1137 } 1138 } 1139 if (visibleChildren > 0 && group.getVisibility() == GONE) { 1140 group.setVisibility(VISIBLE); 1141 } else if (visibleChildren == 0 && group.getVisibility() != GONE) { 1142 group.setVisibility(GONE); 1143 } 1144 } 1145 } 1146 1147 @Override onLayout(boolean changed, int left, int top, int right, int bottom)1148 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 1149 super.onLayout(changed, left, top, right, bottom); 1150 if (!mAddedGroups.isEmpty()) { 1151 getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { 1152 @Override 1153 public boolean onPreDraw() { 1154 for (MessagingGroup group : mAddedGroups) { 1155 if (!group.isShown()) { 1156 continue; 1157 } 1158 MessagingPropertyAnimator.fadeIn(group.getAvatar()); 1159 MessagingPropertyAnimator.fadeIn(group.getSenderView()); 1160 MessagingPropertyAnimator.startLocalTranslationFrom(group, 1161 group.getHeight(), LINEAR_OUT_SLOW_IN); 1162 } 1163 mAddedGroups.clear(); 1164 getViewTreeObserver().removeOnPreDrawListener(this); 1165 return true; 1166 } 1167 }); 1168 } 1169 if (mAppOps.getWidth() > 0) { 1170 1171 // Let's increase the touch size of the app ops view if it's here 1172 mAppOpsTouchRect.set( 1173 mAppOps.getLeft(), 1174 mAppOps.getTop(), 1175 mAppOps.getRight(), 1176 mAppOps.getBottom()); 1177 for (int i = 0; i < mAppOps.getChildCount(); i++) { 1178 View child = mAppOps.getChildAt(i); 1179 if (child.getVisibility() == GONE) { 1180 continue; 1181 } 1182 // Make sure each child has at least a minTouchSize touch target around it 1183 float childTouchLeft = child.getLeft() + child.getWidth() / 2.0f 1184 - mMinTouchSize / 2.0f; 1185 float childTouchRight = childTouchLeft + mMinTouchSize; 1186 mAppOpsTouchRect.left = (int) Math.min(mAppOpsTouchRect.left, 1187 mAppOps.getLeft() + childTouchLeft); 1188 mAppOpsTouchRect.right = (int) Math.max(mAppOpsTouchRect.right, 1189 mAppOps.getLeft() + childTouchRight); 1190 } 1191 1192 // Increase the height 1193 int heightIncrease = 0; 1194 if (mAppOpsTouchRect.height() < mMinTouchSize) { 1195 heightIncrease = (int) Math.ceil((mMinTouchSize - mAppOpsTouchRect.height()) 1196 / 2.0f); 1197 } 1198 mAppOpsTouchRect.inset(0, -heightIncrease); 1199 1200 // Let's adjust the hitrect since app ops isn't a direct child 1201 ViewGroup viewGroup = (ViewGroup) mAppOps.getParent(); 1202 while (viewGroup != this) { 1203 mAppOpsTouchRect.offset(viewGroup.getLeft(), viewGroup.getTop()); 1204 viewGroup = (ViewGroup) viewGroup.getParent(); 1205 } 1206 // 1207 // Extend the size of the app opps to be at least 48dp 1208 setTouchDelegate(new TouchDelegate(mAppOpsTouchRect, mAppOps)); 1209 } 1210 } 1211 getMessagingLinearLayout()1212 public MessagingLinearLayout getMessagingLinearLayout() { 1213 return mMessagingLinearLayout; 1214 } 1215 getImageMessageContainer()1216 public @NonNull ViewGroup getImageMessageContainer() { 1217 return mImageMessageContainer; 1218 } 1219 getMessagingGroups()1220 public ArrayList<MessagingGroup> getMessagingGroups() { 1221 return mGroups; 1222 } 1223 updateExpandButton()1224 private void updateExpandButton() { 1225 int drawableId; 1226 int contentDescriptionId; 1227 int gravity; 1228 int topMargin = 0; 1229 ViewGroup newContainer; 1230 if (mIsCollapsed) { 1231 drawableId = R.drawable.ic_expand_notification; 1232 contentDescriptionId = R.string.expand_button_content_description_collapsed; 1233 gravity = Gravity.CENTER; 1234 newContainer = mExpandButtonAndContentContainer; 1235 } else { 1236 drawableId = R.drawable.ic_collapse_notification; 1237 contentDescriptionId = R.string.expand_button_content_description_expanded; 1238 gravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP; 1239 topMargin = mExpandButtonExpandedTopMargin; 1240 newContainer = this; 1241 } 1242 mExpandButton.setImageDrawable(getContext().getDrawable(drawableId)); 1243 mExpandButton.setColorFilter(mExpandButton.getOriginalNotificationColor()); 1244 1245 // We need to make sure that the expand button is in the linearlayout pushing over the 1246 // content when collapsed, but allows the content to flow under it when expanded. 1247 if (newContainer != mExpandButtonContainer.getParent()) { 1248 ((ViewGroup) mExpandButtonContainer.getParent()).removeView(mExpandButtonContainer); 1249 newContainer.addView(mExpandButtonContainer); 1250 } 1251 1252 // update if the expand button is centered 1253 LinearLayout.LayoutParams layoutParams = 1254 (LinearLayout.LayoutParams) mExpandButton.getLayoutParams(); 1255 layoutParams.gravity = gravity; 1256 layoutParams.topMargin = topMargin; 1257 mExpandButton.setLayoutParams(layoutParams); 1258 1259 mExpandButtonInnerContainer.setContentDescription(mContext.getText(contentDescriptionId)); 1260 } 1261 updateContentEndPaddings()1262 private void updateContentEndPaddings() { 1263 // Let's make sure the conversation header can't run into the expand button when we're 1264 // collapsed and update the paddings of the content 1265 int headerPaddingEnd; 1266 int contentPaddingEnd; 1267 if (!mExpandable) { 1268 headerPaddingEnd = 0; 1269 contentPaddingEnd = mContentMarginEnd; 1270 } else if (mIsCollapsed) { 1271 headerPaddingEnd = 0; 1272 contentPaddingEnd = 0; 1273 } else { 1274 headerPaddingEnd = mNotificationHeaderExpandedPadding; 1275 contentPaddingEnd = mContentMarginEnd; 1276 } 1277 mConversationHeader.setPaddingRelative( 1278 mConversationHeader.getPaddingStart(), 1279 mConversationHeader.getPaddingTop(), 1280 headerPaddingEnd, 1281 mConversationHeader.getPaddingBottom()); 1282 1283 mContentContainer.setPaddingRelative( 1284 mContentContainer.getPaddingStart(), 1285 mContentContainer.getPaddingTop(), 1286 contentPaddingEnd, 1287 mContentContainer.getPaddingBottom()); 1288 } 1289 onAppNameVisibilityChanged()1290 private void onAppNameVisibilityChanged() { 1291 boolean appNameGone = mAppName.getVisibility() == GONE; 1292 if (appNameGone != mAppNameGone) { 1293 mAppNameGone = appNameGone; 1294 updateAppNameDividerVisibility(); 1295 } 1296 } 1297 updateAppNameDividerVisibility()1298 private void updateAppNameDividerVisibility() { 1299 mAppNameDivider.setVisibility(mAppNameGone ? GONE : VISIBLE); 1300 } 1301 updateExpandability(boolean expandable, @Nullable OnClickListener onClickListener)1302 public void updateExpandability(boolean expandable, @Nullable OnClickListener onClickListener) { 1303 mExpandable = expandable; 1304 if (expandable) { 1305 mExpandButtonContainer.setVisibility(VISIBLE); 1306 mExpandButtonInnerContainer.setOnClickListener(onClickListener); 1307 mConversationIconContainer.setOnClickListener(onClickListener); 1308 } else { 1309 mExpandButtonContainer.setVisibility(GONE); 1310 mConversationIconContainer.setOnClickListener(null); 1311 } 1312 updateContentEndPaddings(); 1313 } 1314 1315 @Override setMessagingClippingDisabled(boolean clippingDisabled)1316 public void setMessagingClippingDisabled(boolean clippingDisabled) { 1317 mMessagingLinearLayout.setClipBounds(clippingDisabled ? null : mMessagingClipRect); 1318 } 1319 1320 @Nullable getConversationSenderName()1321 public CharSequence getConversationSenderName() { 1322 if (mGroups.isEmpty()) { 1323 return null; 1324 } 1325 final CharSequence name = mGroups.get(mGroups.size() - 1).getSenderName(); 1326 return getResources().getString(R.string.conversation_single_line_name_display, name); 1327 } 1328 isOneToOne()1329 public boolean isOneToOne() { 1330 return mIsOneToOne; 1331 } 1332 1333 @Nullable getConversationText()1334 public CharSequence getConversationText() { 1335 if (mMessages.isEmpty()) { 1336 return null; 1337 } 1338 final MessagingMessage messagingMessage = mMessages.get(mMessages.size() - 1); 1339 final CharSequence text = messagingMessage.getMessage().getText(); 1340 if (text == null && messagingMessage instanceof MessagingImageMessage) { 1341 final String unformatted = 1342 getResources().getString(R.string.conversation_single_line_image_placeholder); 1343 SpannableString spannableString = new SpannableString(unformatted); 1344 spannableString.setSpan( 1345 new StyleSpan(Typeface.ITALIC), 1346 0, 1347 spannableString.length(), 1348 Spannable.SPAN_INCLUSIVE_EXCLUSIVE); 1349 return spannableString; 1350 } 1351 return text; 1352 } 1353 1354 @Nullable getConversationIcon()1355 public Icon getConversationIcon() { 1356 return mConversationIcon; 1357 } 1358 } 1359