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