blob: 726771895212929a1fdcd5027edfe328f55ea49d [file] [log] [blame]
Selim Cinek20d1ee22020-02-03 16:04:26 -05001/*
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
17package com.android.internal.widget;
18
19import android.annotation.AttrRes;
20import android.annotation.NonNull;
21import android.annotation.Nullable;
22import android.annotation.StyleRes;
23import android.app.Notification;
24import android.app.Person;
25import android.app.RemoteInputHistoryItem;
26import android.content.Context;
27import android.graphics.Bitmap;
28import android.graphics.Canvas;
29import android.graphics.Color;
30import android.graphics.Paint;
31import android.graphics.Rect;
32import android.graphics.drawable.Icon;
33import android.os.Bundle;
34import android.os.Parcelable;
35import android.text.TextUtils;
36import android.util.ArrayMap;
37import android.util.AttributeSet;
38import android.util.DisplayMetrics;
39import android.view.Gravity;
40import android.view.RemotableViewMethod;
41import android.view.View;
42import android.view.ViewGroup;
43import android.view.ViewTreeObserver;
44import android.view.animation.Interpolator;
45import android.view.animation.PathInterpolator;
46import android.widget.FrameLayout;
47import android.widget.ImageView;
48import android.widget.RemoteViews;
49import android.widget.TextView;
50
51import com.android.internal.R;
52import com.android.internal.graphics.ColorUtils;
53import com.android.internal.util.ContrastColorUtil;
54
55import java.util.ArrayList;
56import java.util.List;
57import java.util.function.Consumer;
58import java.util.regex.Pattern;
59
60/**
61 * A custom-built layout for the Notification.MessagingStyle allows dynamic addition and removal
62 * messages and adapts the layout accordingly.
63 */
64@RemoteViews.RemoteView
65public class ConversationLayout extends FrameLayout
66 implements ImageMessageConsumer, IMessagingLayout {
67
68 public static final boolean CONVERSATION_LAYOUT_ENABLED = true;
69 private static final float COLOR_SHIFT_AMOUNT = 60;
70 /**
71 * Pattren for filter some ingonable characters.
72 * p{Z} for any kind of whitespace or invisible separator.
73 * p{C} for any kind of punctuation character.
74 */
75 private static final Pattern IGNORABLE_CHAR_PATTERN
76 = Pattern.compile("[\\p{C}\\p{Z}]");
77 private static final Pattern SPECIAL_CHAR_PATTERN
78 = Pattern.compile ("[!@#$%&*()_+=|<>?{}\\[\\]~-]");
79 private static final Consumer<MessagingMessage> REMOVE_MESSAGE
80 = MessagingMessage::removeMessage;
81 public static final Interpolator LINEAR_OUT_SLOW_IN = new PathInterpolator(0f, 0f, 0.2f, 1f);
82 public static final Interpolator FAST_OUT_LINEAR_IN = new PathInterpolator(0.4f, 0f, 1f, 1f);
83 public static final Interpolator FAST_OUT_SLOW_IN = new PathInterpolator(0.4f, 0f, 0.2f, 1f);
84 public static final OnLayoutChangeListener MESSAGING_PROPERTY_ANIMATOR
85 = new MessagingPropertyAnimator();
86 private List<MessagingMessage> mMessages = new ArrayList<>();
87 private List<MessagingMessage> mHistoricMessages = new ArrayList<>();
88 private MessagingLinearLayout mMessagingLinearLayout;
89 private boolean mShowHistoricMessages;
90 private ArrayList<MessagingGroup> mGroups = new ArrayList<>();
91 private TextView mTitleView;
92 private int mLayoutColor;
93 private int mSenderTextColor;
94 private int mMessageTextColor;
95 private int mAvatarSize;
96 private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
97 private Paint mTextPaint = new Paint();
98 private Icon mAvatarReplacement;
99 private boolean mIsOneToOne;
100 private ArrayList<MessagingGroup> mAddedGroups = new ArrayList<>();
101 private Person mUser;
102 private CharSequence mNameReplacement;
103 private boolean mIsCollapsed;
104 private ImageResolver mImageResolver;
105 private ImageView mConversationIcon;
106 private TextView mHeaderText;
107 private View mConversationIconBadge;
108 private Icon mLargeIcon;
109 private View mExpandButtonContainer;
110 private NotificationExpandButton mExpandButton;
111 private int mExpandButtonExpandedTopMargin;
112 private int mBadgedSideMargins;
113 private int mIconSizeBadged;
114 private int mIconSizeCentered;
115 private View mIcon;
116 private int mExpandedGroupTopMargin;
117
118 public ConversationLayout(@NonNull Context context) {
119 super(context);
120 }
121
122 public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
123 super(context, attrs);
124 }
125
126 public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs,
127 @AttrRes int defStyleAttr) {
128 super(context, attrs, defStyleAttr);
129 }
130
131 public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs,
132 @AttrRes int defStyleAttr, @StyleRes int defStyleRes) {
133 super(context, attrs, defStyleAttr, defStyleRes);
134 }
135
136 @Override
137 protected void onFinishInflate() {
138 super.onFinishInflate();
139 mMessagingLinearLayout = findViewById(R.id.notification_messaging);
140 mMessagingLinearLayout.setMessagingLayout(this);
141 // We still want to clip, but only on the top, since views can temporarily out of bounds
142 // during transitions.
143 DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
144 int size = Math.max(displayMetrics.widthPixels, displayMetrics.heightPixels);
145 Rect rect = new Rect(0, 0, size, size);
146 mMessagingLinearLayout.setClipBounds(rect);
147 mTitleView = findViewById(R.id.title);
148 mAvatarSize = getResources().getDimensionPixelSize(R.dimen.messaging_avatar_size);
149 mTextPaint.setTextAlign(Paint.Align.CENTER);
150 mTextPaint.setAntiAlias(true);
151 mConversationIcon = findViewById(R.id.conversation_icon);
152 mIcon = findViewById(R.id.icon);
153 mConversationIconBadge = findViewById(R.id.conversation_icon_badge);
154 mHeaderText = findViewById(R.id.header_text);
155 mExpandButtonContainer = findViewById(R.id.expand_button_container);
156 mExpandButton = findViewById(R.id.expand_button);
157 mExpandButtonExpandedTopMargin = getResources().getDimensionPixelSize(
158 R.dimen.conversation_expand_button_top_margin_expanded);
159 mBadgedSideMargins = getResources().getDimensionPixelSize(
160 R.dimen.conversation_badge_side_margin);
161 mIconSizeBadged = getResources().getDimensionPixelSize(
162 R.dimen.conversation_icon_size_badged);
163 mIconSizeCentered = getResources().getDimensionPixelSize(
164 R.dimen.conversation_icon_size_centered);
165 mExpandedGroupTopMargin = getResources().getDimensionPixelSize(
166 R.dimen.conversation_icon_margin_top_centered);
167 }
168
169 @RemotableViewMethod
170 public void setAvatarReplacement(Icon icon) {
171 mAvatarReplacement = icon;
172 }
173
174 @RemotableViewMethod
175 public void setNameReplacement(CharSequence nameReplacement) {
176 mNameReplacement = nameReplacement;
177 }
178
179 /**
180 * Set this layout to show the collapsed representation.
181 *
182 * @param isCollapsed is it collapsed
183 */
184 @RemotableViewMethod
185 public void setIsCollapsed(boolean isCollapsed) {
186 mIsCollapsed = isCollapsed;
187 mMessagingLinearLayout.setMaxDisplayedLines(isCollapsed ? 1 : Integer.MAX_VALUE);
188 updateExpandButton();
189 }
190
191 @RemotableViewMethod
192 public void setData(Bundle extras) {
193 Parcelable[] messages = extras.getParcelableArray(Notification.EXTRA_MESSAGES);
194 List<Notification.MessagingStyle.Message> newMessages
195 = Notification.MessagingStyle.Message.getMessagesFromBundleArray(messages);
196 Parcelable[] histMessages = extras.getParcelableArray(Notification.EXTRA_HISTORIC_MESSAGES);
197 List<Notification.MessagingStyle.Message> newHistoricMessages
198 = Notification.MessagingStyle.Message.getMessagesFromBundleArray(histMessages);
199
200 // mUser now set (would be nice to avoid the side effect but WHATEVER)
201 setUser(extras.getParcelable(Notification.EXTRA_MESSAGING_PERSON));
202
203
204 // Append remote input history to newMessages (again, side effect is lame but WHATEVS)
205 RemoteInputHistoryItem[] history = (RemoteInputHistoryItem[])
206 extras.getParcelableArray(Notification.EXTRA_REMOTE_INPUT_HISTORY_ITEMS);
207 addRemoteInputHistoryToMessages(newMessages, history);
208
209 boolean showSpinner =
210 extras.getBoolean(Notification.EXTRA_SHOW_REMOTE_INPUT_SPINNER, false);
211
212 // bind it, baby
213 bind(newMessages, newHistoricMessages, showSpinner);
214 }
215
216 @Override
217 public void setImageResolver(ImageResolver resolver) {
218 mImageResolver = resolver;
219 }
220
221 private void addRemoteInputHistoryToMessages(
222 List<Notification.MessagingStyle.Message> newMessages,
223 RemoteInputHistoryItem[] remoteInputHistory) {
224 if (remoteInputHistory == null || remoteInputHistory.length == 0) {
225 return;
226 }
227 for (int i = remoteInputHistory.length - 1; i >= 0; i--) {
228 RemoteInputHistoryItem historyMessage = remoteInputHistory[i];
229 Notification.MessagingStyle.Message message = new Notification.MessagingStyle.Message(
230 historyMessage.getText(), 0, (Person) null, true /* remoteHistory */);
231 if (historyMessage.getUri() != null) {
232 message.setData(historyMessage.getMimeType(), historyMessage.getUri());
233 }
234 newMessages.add(message);
235 }
236 }
237
238 private void bind(List<Notification.MessagingStyle.Message> newMessages,
239 List<Notification.MessagingStyle.Message> newHistoricMessages,
240 boolean showSpinner) {
241 // convert MessagingStyle.Message to MessagingMessage, re-using ones from a previous binding
242 // if they exist
243 List<MessagingMessage> historicMessages = createMessages(newHistoricMessages,
244 true /* isHistoric */);
245 List<MessagingMessage> messages = createMessages(newMessages, false /* isHistoric */);
246
247 // Copy our groups, before they get clobbered
248 ArrayList<MessagingGroup> oldGroups = new ArrayList<>(mGroups);
249
250 // Add our new MessagingMessages to groups
251 List<List<MessagingMessage>> groups = new ArrayList<>();
252 List<Person> senders = new ArrayList<>();
253
254 // Lets first find the groups (populate `groups` and `senders`)
255 findGroups(historicMessages, messages, groups, senders);
256
257 // Let's now create the views and reorder them accordingly
258 // side-effect: updates mGroups, mAddedGroups
259 createGroupViews(groups, senders, showSpinner);
260
261 // Let's first check which groups were removed altogether and remove them in one animation
262 removeGroups(oldGroups);
263
264 // Let's remove the remaining messages
265 mMessages.forEach(REMOVE_MESSAGE);
266 mHistoricMessages.forEach(REMOVE_MESSAGE);
267
268 mMessages = messages;
269 mHistoricMessages = historicMessages;
270
271 updateHistoricMessageVisibility();
272 updateTitleAndNamesDisplay();
273
Selim Cinek857f2792020-03-03 19:06:21 -0800274 updateConversationLayout();
Selim Cinek20d1ee22020-02-03 16:04:26 -0500275
276 }
277
Selim Cinek857f2792020-03-03 19:06:21 -0800278 /**
279 * Update the layout according to the data provided (i.e mIsOneToOne, expanded etc);
280 */
281 private void updateConversationLayout() {
Selim Cinek20d1ee22020-02-03 16:04:26 -0500282 // TODO: resolve this from shortcuts
283 // Set avatar and name
Selim Cinek857f2792020-03-03 19:06:21 -0800284 CharSequence personOnTop = null;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500285 if (mIsOneToOne) {
286 // Let's resolve the icon / text from the last sender
287 mConversationIcon.setVisibility(VISIBLE);
288 mHeaderText.setVisibility(VISIBLE);
Selim Cinek857f2792020-03-03 19:06:21 -0800289 CharSequence userKey = getKey(mUser);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500290 for (int i = mGroups.size() - 1; i >= 0; i--) {
291 MessagingGroup messagingGroup = mGroups.get(i);
292 Person messageSender = messagingGroup.getSender();
Selim Cinek857f2792020-03-03 19:06:21 -0800293 if ((messageSender != null && !TextUtils.equals(userKey, getKey(messageSender)))
294 || i == 0) {
Selim Cinek20d1ee22020-02-03 16:04:26 -0500295 // Make sure the header is actually visible
296 // TODO: figure out what to do if there's a converationtitle + a Sender
297 mHeaderText.setText(messagingGroup.getSenderName());
298 mConversationIcon.setImageIcon(messagingGroup.getAvatarIcon());
Selim Cinek857f2792020-03-03 19:06:21 -0800299 personOnTop = messagingGroup.getSenderName();
Selim Cinek20d1ee22020-02-03 16:04:26 -0500300 break;
301 }
302 }
Selim Cinek20d1ee22020-02-03 16:04:26 -0500303 } else {
304 mHeaderText.setVisibility(GONE);
305 if (mIsCollapsed) {
306 mConversationIcon.setVisibility(VISIBLE);
307 if (mLargeIcon != null) {
308 mConversationIcon.setImageIcon(mLargeIcon);
309 } else {
310 // TODO: generate LargeIcon from Conversation
311 }
312 } else {
313 mConversationIcon.setVisibility(GONE);
314 }
315 }
Selim Cinek857f2792020-03-03 19:06:21 -0800316 // Update if the groups can hide the sender if they are first (applies to 1:1 conversations)
317 // This needs to happen after all of the above o update all of the groups
318 for (int i = mGroups.size() - 1; i >= 0; i--) {
319 MessagingGroup messagingGroup = mGroups.get(i);
320 CharSequence messageSender = messagingGroup.getSenderName();
321 boolean canHide = mIsOneToOne
322 && TextUtils.equals(personOnTop, messageSender);
323 messagingGroup.setCanHideSenderIfFirst(canHide);
324 }
325 updateIconPositionAndSize();
326 }
327
328 /**
329 * update the icon position and sizing
330 */
331 private void updateIconPositionAndSize() {
Selim Cinek20d1ee22020-02-03 16:04:26 -0500332 int gravity;
333 int marginStart;
334 int marginTop;
335 int iconSize;
336 if (mIsOneToOne || mIsCollapsed) {
337 // Baded format
338 gravity = Gravity.LEFT;
339 marginStart = mBadgedSideMargins;
340 marginTop = mBadgedSideMargins;
341 iconSize = mIconSizeBadged;
342 } else {
343 gravity = Gravity.CENTER_HORIZONTAL;
344 marginStart = 0;
345 marginTop = mExpandedGroupTopMargin;
346 iconSize = mIconSizeCentered;
347 }
Selim Cinek857f2792020-03-03 19:06:21 -0800348 LayoutParams layoutParams =
Selim Cinek20d1ee22020-02-03 16:04:26 -0500349 (LayoutParams) mConversationIconBadge.getLayoutParams();
350 layoutParams.gravity = gravity;
351 layoutParams.topMargin = marginTop;
352 layoutParams.setMarginStart(marginStart);
353 mConversationIconBadge.setLayoutParams(layoutParams);
354 ViewGroup.LayoutParams iconParams = mIcon.getLayoutParams();
355 iconParams.width = iconSize;
356 iconParams.height = iconSize;
357 mIcon.setLayoutParams(iconParams);
358 }
359
360 @RemotableViewMethod
361 public void setLargeIcon(Icon largeIcon) {
362 mLargeIcon = largeIcon;
363 }
364
365 private void removeGroups(ArrayList<MessagingGroup> oldGroups) {
366 int size = oldGroups.size();
367 for (int i = 0; i < size; i++) {
368 MessagingGroup group = oldGroups.get(i);
369 if (!mGroups.contains(group)) {
370 List<MessagingMessage> messages = group.getMessages();
371 Runnable endRunnable = () -> {
372 mMessagingLinearLayout.removeTransientView(group);
373 group.recycle();
374 };
375
376 boolean wasShown = group.isShown();
377 mMessagingLinearLayout.removeView(group);
378 if (wasShown && !MessagingLinearLayout.isGone(group)) {
379 mMessagingLinearLayout.addTransientView(group, 0);
380 group.removeGroupAnimated(endRunnable);
381 } else {
382 endRunnable.run();
383 }
384 mMessages.removeAll(messages);
385 mHistoricMessages.removeAll(messages);
386 }
387 }
388 }
389
390 private void updateTitleAndNamesDisplay() {
391 ArrayMap<CharSequence, String> uniqueNames = new ArrayMap<>();
392 ArrayMap<Character, CharSequence> uniqueCharacters = new ArrayMap<>();
393 for (int i = 0; i < mGroups.size(); i++) {
394 MessagingGroup group = mGroups.get(i);
395 CharSequence senderName = group.getSenderName();
396 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) {
397 continue;
398 }
399 if (!uniqueNames.containsKey(senderName)) {
400 // Only use visible characters to get uniqueNames
401 String pureSenderName = IGNORABLE_CHAR_PATTERN
402 .matcher(senderName).replaceAll("" /* replacement */);
403 char c = pureSenderName.charAt(0);
404 if (uniqueCharacters.containsKey(c)) {
405 // this character was already used, lets make it more unique. We first need to
406 // resolve the existing character if it exists
407 CharSequence existingName = uniqueCharacters.get(c);
408 if (existingName != null) {
409 uniqueNames.put(existingName, findNameSplit((String) existingName));
410 uniqueCharacters.put(c, null);
411 }
412 uniqueNames.put(senderName, findNameSplit((String) senderName));
413 } else {
414 uniqueNames.put(senderName, Character.toString(c));
415 uniqueCharacters.put(c, pureSenderName);
416 }
417 }
418 }
419
420 // Now that we have the correct symbols, let's look what we have cached
421 ArrayMap<CharSequence, Icon> cachedAvatars = new ArrayMap<>();
422 for (int i = 0; i < mGroups.size(); i++) {
423 // Let's now set the avatars
424 MessagingGroup group = mGroups.get(i);
425 boolean isOwnMessage = group.getSender() == mUser;
426 CharSequence senderName = group.getSenderName();
427 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)
428 || (mIsOneToOne && mAvatarReplacement != null && !isOwnMessage)) {
429 continue;
430 }
431 String symbol = uniqueNames.get(senderName);
432 Icon cachedIcon = group.getAvatarSymbolIfMatching(senderName,
433 symbol, mLayoutColor);
434 if (cachedIcon != null) {
435 cachedAvatars.put(senderName, cachedIcon);
436 }
437 }
438
439 for (int i = 0; i < mGroups.size(); i++) {
440 // Let's now set the avatars
441 MessagingGroup group = mGroups.get(i);
442 CharSequence senderName = group.getSenderName();
443 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) {
444 continue;
445 }
446 if (mIsOneToOne && mAvatarReplacement != null && group.getSender() != mUser) {
447 group.setAvatar(mAvatarReplacement);
448 } else {
449 Icon cachedIcon = cachedAvatars.get(senderName);
450 if (cachedIcon == null) {
451 cachedIcon = createAvatarSymbol(senderName, uniqueNames.get(senderName),
452 mLayoutColor);
453 cachedAvatars.put(senderName, cachedIcon);
454 }
455 group.setCreatedAvatar(cachedIcon, senderName, uniqueNames.get(senderName),
456 mLayoutColor);
457 }
458 }
459 }
460
461 private Icon createAvatarSymbol(CharSequence senderName, String symbol, int layoutColor) {
462 if (symbol.isEmpty() || TextUtils.isDigitsOnly(symbol) ||
463 SPECIAL_CHAR_PATTERN.matcher(symbol).find()) {
464 Icon avatarIcon = Icon.createWithResource(getContext(),
465 R.drawable.messaging_user);
466 avatarIcon.setTint(findColor(senderName, layoutColor));
467 return avatarIcon;
468 } else {
469 Bitmap bitmap = Bitmap.createBitmap(mAvatarSize, mAvatarSize, Bitmap.Config.ARGB_8888);
470 Canvas canvas = new Canvas(bitmap);
471 float radius = mAvatarSize / 2.0f;
472 int color = findColor(senderName, layoutColor);
473 mPaint.setColor(color);
474 canvas.drawCircle(radius, radius, radius, mPaint);
475 boolean needDarkText = ColorUtils.calculateLuminance(color) > 0.5f;
476 mTextPaint.setColor(needDarkText ? Color.BLACK : Color.WHITE);
477 mTextPaint.setTextSize(symbol.length() == 1 ? mAvatarSize * 0.5f : mAvatarSize * 0.3f);
478 int yPos = (int) (radius - ((mTextPaint.descent() + mTextPaint.ascent()) / 2));
479 canvas.drawText(symbol, radius, yPos, mTextPaint);
480 return Icon.createWithBitmap(bitmap);
481 }
482 }
483
484 private int findColor(CharSequence senderName, int layoutColor) {
485 double luminance = ContrastColorUtil.calculateLuminance(layoutColor);
486 float shift = Math.abs(senderName.hashCode()) % 5 / 4.0f - 0.5f;
487
488 // we need to offset the range if the luminance is too close to the borders
489 shift += Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - luminance, 0);
490 shift -= Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - (1.0f - luminance), 0);
491 return ContrastColorUtil.getShiftedColor(layoutColor,
492 (int) (shift * COLOR_SHIFT_AMOUNT));
493 }
494
495 private String findNameSplit(String existingName) {
496 String[] split = existingName.split(" ");
497 if (split.length > 1) {
498 return Character.toString(split[0].charAt(0))
499 + Character.toString(split[1].charAt(0));
500 }
501 return existingName.substring(0, 1);
502 }
503
504 @RemotableViewMethod
505 public void setLayoutColor(int color) {
506 mLayoutColor = color;
507 }
508
509 @RemotableViewMethod
510 public void setIsOneToOne(boolean oneToOne) {
511 mIsOneToOne = oneToOne;
512 }
513
514 @RemotableViewMethod
515 public void setSenderTextColor(int color) {
516 mSenderTextColor = color;
517 }
518
519 @RemotableViewMethod
520 public void setMessageTextColor(int color) {
521 mMessageTextColor = color;
522 }
523
524 private void setUser(Person user) {
525 mUser = user;
526 if (mUser.getIcon() == null) {
527 Icon userIcon = Icon.createWithResource(getContext(),
528 R.drawable.messaging_user);
529 userIcon.setTint(mLayoutColor);
530 mUser = mUser.toBuilder().setIcon(userIcon).build();
531 }
532 }
533
534 private void createGroupViews(List<List<MessagingMessage>> groups,
535 List<Person> senders, boolean showSpinner) {
536 mGroups.clear();
537 for (int groupIndex = 0; groupIndex < groups.size(); groupIndex++) {
538 List<MessagingMessage> group = groups.get(groupIndex);
539 MessagingGroup newGroup = null;
540 // we'll just take the first group that exists or create one there is none
541 for (int messageIndex = group.size() - 1; messageIndex >= 0; messageIndex--) {
542 MessagingMessage message = group.get(messageIndex);
543 newGroup = message.getGroup();
544 if (newGroup != null) {
545 break;
546 }
547 }
548 // Create a new group, adding it to the linear layout as well
549 if (newGroup == null) {
550 newGroup = MessagingGroup.createGroup(mMessagingLinearLayout);
551 mAddedGroups.add(newGroup);
552 }
553 newGroup.setDisplayImagesAtEnd(mIsCollapsed);
554 newGroup.setLayoutColor(mLayoutColor);
555 newGroup.setTextColors(mSenderTextColor, mMessageTextColor);
556 Person sender = senders.get(groupIndex);
557 CharSequence nameOverride = null;
558 if (sender != mUser && mNameReplacement != null) {
559 nameOverride = mNameReplacement;
560 }
561 newGroup.setShowingAvatar(!mIsOneToOne && !mIsCollapsed);
562 newGroup.setSingleLine(mIsCollapsed);
563 newGroup.setSender(sender, nameOverride);
564 newGroup.setSending(groupIndex == (groups.size() - 1) && showSpinner);
565 mGroups.add(newGroup);
566
567 // Reposition to the correct place (if we're re-using a group)
568 if (mMessagingLinearLayout.indexOfChild(newGroup) != groupIndex) {
569 mMessagingLinearLayout.removeView(newGroup);
570 mMessagingLinearLayout.addView(newGroup, groupIndex);
571 }
572 newGroup.setMessages(group);
573 }
574 }
575
576 private void findGroups(List<MessagingMessage> historicMessages,
577 List<MessagingMessage> messages, List<List<MessagingMessage>> groups,
578 List<Person> senders) {
579 CharSequence currentSenderKey = null;
580 List<MessagingMessage> currentGroup = null;
581 int histSize = historicMessages.size();
582 for (int i = 0; i < histSize + messages.size(); i++) {
583 MessagingMessage message;
584 if (i < histSize) {
585 message = historicMessages.get(i);
586 } else {
587 message = messages.get(i - histSize);
588 }
589 boolean isNewGroup = currentGroup == null;
590 Person sender = message.getMessage().getSenderPerson();
Selim Cinek857f2792020-03-03 19:06:21 -0800591 CharSequence key = getKey(sender);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500592 isNewGroup |= !TextUtils.equals(key, currentSenderKey);
593 if (isNewGroup) {
594 currentGroup = new ArrayList<>();
595 groups.add(currentGroup);
596 if (sender == null) {
597 sender = mUser;
598 }
599 senders.add(sender);
600 currentSenderKey = key;
601 }
602 currentGroup.add(message);
603 }
604 }
605
Selim Cinek857f2792020-03-03 19:06:21 -0800606 private CharSequence getKey(Person person) {
607 return person == null ? null : person.getKey() == null ? person.getName() : person.getKey();
608 }
609
Selim Cinek20d1ee22020-02-03 16:04:26 -0500610 /**
611 * Creates new messages, reusing existing ones if they are available.
612 *
613 * @param newMessages the messages to parse.
614 */
615 private List<MessagingMessage> createMessages(
616 List<Notification.MessagingStyle.Message> newMessages, boolean historic) {
617 List<MessagingMessage> result = new ArrayList<>();
618 for (int i = 0; i < newMessages.size(); i++) {
619 Notification.MessagingStyle.Message m = newMessages.get(i);
620 MessagingMessage message = findAndRemoveMatchingMessage(m);
621 if (message == null) {
622 message = MessagingMessage.createMessage(this, m, mImageResolver);
623 }
624 message.setIsHistoric(historic);
625 result.add(message);
626 }
627 return result;
628 }
629
630 private MessagingMessage findAndRemoveMatchingMessage(Notification.MessagingStyle.Message m) {
631 for (int i = 0; i < mMessages.size(); i++) {
632 MessagingMessage existing = mMessages.get(i);
633 if (existing.sameAs(m)) {
634 mMessages.remove(i);
635 return existing;
636 }
637 }
638 for (int i = 0; i < mHistoricMessages.size(); i++) {
639 MessagingMessage existing = mHistoricMessages.get(i);
640 if (existing.sameAs(m)) {
641 mHistoricMessages.remove(i);
642 return existing;
643 }
644 }
645 return null;
646 }
647
648 public void showHistoricMessages(boolean show) {
649 mShowHistoricMessages = show;
650 updateHistoricMessageVisibility();
651 }
652
653 private void updateHistoricMessageVisibility() {
654 int numHistoric = mHistoricMessages.size();
655 for (int i = 0; i < numHistoric; i++) {
656 MessagingMessage existing = mHistoricMessages.get(i);
657 existing.setVisibility(mShowHistoricMessages ? VISIBLE : GONE);
658 }
659 int numGroups = mGroups.size();
660 for (int i = 0; i < numGroups; i++) {
661 MessagingGroup group = mGroups.get(i);
662 int visibleChildren = 0;
663 List<MessagingMessage> messages = group.getMessages();
664 int numGroupMessages = messages.size();
665 for (int j = 0; j < numGroupMessages; j++) {
666 MessagingMessage message = messages.get(j);
667 if (message.getVisibility() != GONE) {
668 visibleChildren++;
669 }
670 }
671 if (visibleChildren > 0 && group.getVisibility() == GONE) {
672 group.setVisibility(VISIBLE);
673 } else if (visibleChildren == 0 && group.getVisibility() != GONE) {
674 group.setVisibility(GONE);
675 }
676 }
677 }
678
679 @Override
680 protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
681 super.onLayout(changed, left, top, right, bottom);
682 if (!mAddedGroups.isEmpty()) {
683 getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
684 @Override
685 public boolean onPreDraw() {
686 for (MessagingGroup group : mAddedGroups) {
687 if (!group.isShown()) {
688 continue;
689 }
690 MessagingPropertyAnimator.fadeIn(group.getAvatar());
691 MessagingPropertyAnimator.fadeIn(group.getSenderView());
692 MessagingPropertyAnimator.startLocalTranslationFrom(group,
693 group.getHeight(), LINEAR_OUT_SLOW_IN);
694 }
695 mAddedGroups.clear();
696 getViewTreeObserver().removeOnPreDrawListener(this);
697 return true;
698 }
699 });
700 }
701 }
702
703 public MessagingLinearLayout getMessagingLinearLayout() {
704 return mMessagingLinearLayout;
705 }
706
707 public ArrayList<MessagingGroup> getMessagingGroups() {
708 return mGroups;
709 }
710
711 private void updateExpandButton() {
712 int drawableId;
713 int contentDescriptionId;
714 int gravity;
715 int topMargin = 0;
716 if (mIsCollapsed) {
717 drawableId = R.drawable.ic_expand_notification;
718 contentDescriptionId = R.string.expand_button_content_description_collapsed;
719 gravity = Gravity.CENTER;
720 } else {
721 drawableId = R.drawable.ic_collapse_notification;
722 contentDescriptionId = R.string.expand_button_content_description_expanded;
723 gravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP;
724 topMargin = mExpandButtonExpandedTopMargin;
725 }
726 mExpandButton.setImageDrawable(getContext().getDrawable(drawableId));
727 mExpandButton.setColorFilter(mExpandButton.getOriginalNotificationColor());
728
729 // update if the expand button is centered
730 FrameLayout.LayoutParams layoutParams = (LayoutParams) mExpandButton.getLayoutParams();
731 layoutParams.gravity = gravity;
732 layoutParams.topMargin = topMargin;
733 mExpandButton.setLayoutParams(layoutParams);
734
735 mExpandButtonContainer.setContentDescription(mContext.getText(contentDescriptionId));
736 }
737
738 public void updateExpandability(boolean expandable, @Nullable OnClickListener onClickListener) {
739 if (expandable) {
740 mExpandButtonContainer.setVisibility(VISIBLE);
741 mExpandButtonContainer.setOnClickListener(onClickListener);
742 } else {
743 // TODO: handle content paddings to end of layout
744 mExpandButtonContainer.setVisibility(GONE);
745 }
746 }
747}