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