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