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