blob: 73da6007156a6b4df037ecb273f7ab79fca0fd44 [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;
Selim Cineke9714eb2020-03-09 11:21:49 -070027import android.content.res.ColorStateList;
Selim Cinek20d1ee22020-02-03 16:04:26 -050028import android.graphics.Bitmap;
29import android.graphics.Canvas;
30import android.graphics.Color;
31import android.graphics.Paint;
32import android.graphics.Rect;
33import android.graphics.drawable.Icon;
34import android.os.Bundle;
35import android.os.Parcelable;
36import android.text.TextUtils;
37import android.util.ArrayMap;
38import android.util.AttributeSet;
39import android.util.DisplayMetrics;
40import android.view.Gravity;
41import android.view.RemotableViewMethod;
42import android.view.View;
43import android.view.ViewGroup;
44import android.view.ViewTreeObserver;
45import android.view.animation.Interpolator;
46import android.view.animation.PathInterpolator;
47import android.widget.FrameLayout;
48import android.widget.ImageView;
49import android.widget.RemoteViews;
50import android.widget.TextView;
51
52import com.android.internal.R;
53import com.android.internal.graphics.ColorUtils;
54import com.android.internal.util.ContrastColorUtil;
55
56import java.util.ArrayList;
57import java.util.List;
58import java.util.function.Consumer;
59import java.util.regex.Pattern;
60
61/**
62 * A custom-built layout for the Notification.MessagingStyle allows dynamic addition and removal
63 * messages and adapts the layout accordingly.
64 */
65@RemoteViews.RemoteView
66public class ConversationLayout extends FrameLayout
67 implements ImageMessageConsumer, IMessagingLayout {
68
69 public static final boolean CONVERSATION_LAYOUT_ENABLED = true;
70 private static final float COLOR_SHIFT_AMOUNT = 60;
71 /**
72 * Pattren for filter some ingonable characters.
73 * p{Z} for any kind of whitespace or invisible separator.
74 * p{C} for any kind of punctuation character.
75 */
76 private static final Pattern IGNORABLE_CHAR_PATTERN
77 = Pattern.compile("[\\p{C}\\p{Z}]");
78 private static final Pattern SPECIAL_CHAR_PATTERN
79 = Pattern.compile ("[!@#$%&*()_+=|<>?{}\\[\\]~-]");
80 private static final Consumer<MessagingMessage> REMOVE_MESSAGE
81 = MessagingMessage::removeMessage;
82 public static final Interpolator LINEAR_OUT_SLOW_IN = new PathInterpolator(0f, 0f, 0.2f, 1f);
83 public static final Interpolator FAST_OUT_LINEAR_IN = new PathInterpolator(0.4f, 0f, 1f, 1f);
84 public static final Interpolator FAST_OUT_SLOW_IN = new PathInterpolator(0.4f, 0f, 0.2f, 1f);
85 public static final OnLayoutChangeListener MESSAGING_PROPERTY_ANIMATOR
86 = new MessagingPropertyAnimator();
87 private List<MessagingMessage> mMessages = new ArrayList<>();
88 private List<MessagingMessage> mHistoricMessages = new ArrayList<>();
89 private MessagingLinearLayout mMessagingLinearLayout;
90 private boolean mShowHistoricMessages;
91 private ArrayList<MessagingGroup> mGroups = new ArrayList<>();
92 private TextView mTitleView;
93 private int mLayoutColor;
94 private int mSenderTextColor;
95 private int mMessageTextColor;
96 private int mAvatarSize;
97 private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
98 private Paint mTextPaint = new Paint();
99 private Icon mAvatarReplacement;
100 private boolean mIsOneToOne;
101 private ArrayList<MessagingGroup> mAddedGroups = new ArrayList<>();
102 private Person mUser;
103 private CharSequence mNameReplacement;
104 private boolean mIsCollapsed;
105 private ImageResolver mImageResolver;
106 private ImageView mConversationIcon;
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700107 private TextView mConversationText;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500108 private View mConversationIconBadge;
109 private Icon mLargeIcon;
110 private View mExpandButtonContainer;
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800111 private ViewGroup mExpandButtonAndContentContainer;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500112 private NotificationExpandButton mExpandButton;
113 private int mExpandButtonExpandedTopMargin;
114 private int mBadgedSideMargins;
115 private int mIconSizeBadged;
116 private int mIconSizeCentered;
Selim Cinekf2b8aa72020-03-05 15:29:02 -0800117 private CachingIconView mIcon;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500118 private int mExpandedGroupTopMargin;
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800119 private int mExpandButtonExpandedSize;
Selim Cinek8baa70f2020-03-09 20:09:35 -0700120 private View mConversationFacePile;
121 private int mNotificationBackgroundColor;
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700122 private CharSequence mFallbackChatName;
123 private CharSequence mFallbackGroupChatName;
124 private CharSequence mConversationTitle;
Selim Cinekafc20582020-03-13 20:15:38 -0700125 private int mNotificationHeaderExpandedPadding;
126 private View mConversationHeader;
127 private View mContentContainer;
128 private boolean mExpandable = true;
129 private int mContentMarginEnd;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500130
131 public ConversationLayout(@NonNull Context context) {
132 super(context);
133 }
134
135 public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
136 super(context, attrs);
137 }
138
139 public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs,
140 @AttrRes int defStyleAttr) {
141 super(context, attrs, defStyleAttr);
142 }
143
144 public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs,
145 @AttrRes int defStyleAttr, @StyleRes int defStyleRes) {
146 super(context, attrs, defStyleAttr, defStyleRes);
147 }
148
149 @Override
150 protected void onFinishInflate() {
151 super.onFinishInflate();
152 mMessagingLinearLayout = findViewById(R.id.notification_messaging);
153 mMessagingLinearLayout.setMessagingLayout(this);
154 // We still want to clip, but only on the top, since views can temporarily out of bounds
155 // during transitions.
156 DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
157 int size = Math.max(displayMetrics.widthPixels, displayMetrics.heightPixels);
158 Rect rect = new Rect(0, 0, size, size);
159 mMessagingLinearLayout.setClipBounds(rect);
160 mTitleView = findViewById(R.id.title);
161 mAvatarSize = getResources().getDimensionPixelSize(R.dimen.messaging_avatar_size);
162 mTextPaint.setTextAlign(Paint.Align.CENTER);
163 mTextPaint.setAntiAlias(true);
164 mConversationIcon = findViewById(R.id.conversation_icon);
165 mIcon = findViewById(R.id.icon);
166 mConversationIconBadge = findViewById(R.id.conversation_icon_badge);
Selim Cinekf2b8aa72020-03-05 15:29:02 -0800167 mIcon.setOnVisibilityChangedListener((visibility) -> {
168 // Always keep the badge visibility in sync with the icon. This is necessary in cases
169 // Where the icon is being hidden externally like in group children.
170 mConversationIconBadge.setVisibility(visibility);
171 });
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700172 mConversationText = findViewById(R.id.conversation_text);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500173 mExpandButtonContainer = findViewById(R.id.expand_button_container);
Selim Cinekafc20582020-03-13 20:15:38 -0700174 mConversationHeader = findViewById(R.id.conversation_header);
175 mContentContainer = findViewById(R.id.notification_action_list_margin_target);
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800176 mExpandButtonAndContentContainer = findViewById(R.id.expand_button_and_content_container);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500177 mExpandButton = findViewById(R.id.expand_button);
178 mExpandButtonExpandedTopMargin = getResources().getDimensionPixelSize(
179 R.dimen.conversation_expand_button_top_margin_expanded);
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800180 mExpandButtonExpandedSize = getResources().getDimensionPixelSize(
181 R.dimen.conversation_expand_button_expanded_size);
Selim Cinekafc20582020-03-13 20:15:38 -0700182 mNotificationHeaderExpandedPadding = getResources().getDimensionPixelSize(
183 R.dimen.conversation_header_expanded_padding_end);
184 mContentMarginEnd = getResources().getDimensionPixelSize(
185 R.dimen.notification_content_margin_end);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500186 mBadgedSideMargins = getResources().getDimensionPixelSize(
187 R.dimen.conversation_badge_side_margin);
188 mIconSizeBadged = getResources().getDimensionPixelSize(
189 R.dimen.conversation_icon_size_badged);
190 mIconSizeCentered = getResources().getDimensionPixelSize(
191 R.dimen.conversation_icon_size_centered);
192 mExpandedGroupTopMargin = getResources().getDimensionPixelSize(
193 R.dimen.conversation_icon_margin_top_centered);
Selim Cinek8baa70f2020-03-09 20:09:35 -0700194 mConversationFacePile = findViewById(R.id.conversation_face_pile);
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700195 mFallbackChatName = getResources().getString(
196 R.string.conversation_title_fallback_one_to_one);
197 mFallbackGroupChatName = getResources().getString(
198 R.string.conversation_title_fallback_group_chat);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500199 }
200
201 @RemotableViewMethod
202 public void setAvatarReplacement(Icon icon) {
203 mAvatarReplacement = icon;
204 }
205
206 @RemotableViewMethod
207 public void setNameReplacement(CharSequence nameReplacement) {
208 mNameReplacement = nameReplacement;
209 }
210
211 /**
212 * Set this layout to show the collapsed representation.
213 *
214 * @param isCollapsed is it collapsed
215 */
216 @RemotableViewMethod
217 public void setIsCollapsed(boolean isCollapsed) {
218 mIsCollapsed = isCollapsed;
219 mMessagingLinearLayout.setMaxDisplayedLines(isCollapsed ? 1 : Integer.MAX_VALUE);
220 updateExpandButton();
Selim Cinekafc20582020-03-13 20:15:38 -0700221 updateContentPaddings();
Selim Cinek20d1ee22020-02-03 16:04:26 -0500222 }
223
224 @RemotableViewMethod
225 public void setData(Bundle extras) {
226 Parcelable[] messages = extras.getParcelableArray(Notification.EXTRA_MESSAGES);
227 List<Notification.MessagingStyle.Message> newMessages
228 = Notification.MessagingStyle.Message.getMessagesFromBundleArray(messages);
229 Parcelable[] histMessages = extras.getParcelableArray(Notification.EXTRA_HISTORIC_MESSAGES);
230 List<Notification.MessagingStyle.Message> newHistoricMessages
231 = Notification.MessagingStyle.Message.getMessagesFromBundleArray(histMessages);
232
233 // mUser now set (would be nice to avoid the side effect but WHATEVER)
234 setUser(extras.getParcelable(Notification.EXTRA_MESSAGING_PERSON));
235
236
237 // Append remote input history to newMessages (again, side effect is lame but WHATEVS)
238 RemoteInputHistoryItem[] history = (RemoteInputHistoryItem[])
239 extras.getParcelableArray(Notification.EXTRA_REMOTE_INPUT_HISTORY_ITEMS);
240 addRemoteInputHistoryToMessages(newMessages, history);
241
242 boolean showSpinner =
243 extras.getBoolean(Notification.EXTRA_SHOW_REMOTE_INPUT_SPINNER, false);
244
245 // bind it, baby
246 bind(newMessages, newHistoricMessages, showSpinner);
247 }
248
249 @Override
250 public void setImageResolver(ImageResolver resolver) {
251 mImageResolver = resolver;
252 }
253
254 private void addRemoteInputHistoryToMessages(
255 List<Notification.MessagingStyle.Message> newMessages,
256 RemoteInputHistoryItem[] remoteInputHistory) {
257 if (remoteInputHistory == null || remoteInputHistory.length == 0) {
258 return;
259 }
260 for (int i = remoteInputHistory.length - 1; i >= 0; i--) {
261 RemoteInputHistoryItem historyMessage = remoteInputHistory[i];
262 Notification.MessagingStyle.Message message = new Notification.MessagingStyle.Message(
263 historyMessage.getText(), 0, (Person) null, true /* remoteHistory */);
264 if (historyMessage.getUri() != null) {
265 message.setData(historyMessage.getMimeType(), historyMessage.getUri());
266 }
267 newMessages.add(message);
268 }
269 }
270
271 private void bind(List<Notification.MessagingStyle.Message> newMessages,
272 List<Notification.MessagingStyle.Message> newHistoricMessages,
273 boolean showSpinner) {
274 // convert MessagingStyle.Message to MessagingMessage, re-using ones from a previous binding
275 // if they exist
276 List<MessagingMessage> historicMessages = createMessages(newHistoricMessages,
277 true /* isHistoric */);
278 List<MessagingMessage> messages = createMessages(newMessages, false /* isHistoric */);
279
280 // Copy our groups, before they get clobbered
281 ArrayList<MessagingGroup> oldGroups = new ArrayList<>(mGroups);
282
283 // Add our new MessagingMessages to groups
284 List<List<MessagingMessage>> groups = new ArrayList<>();
285 List<Person> senders = new ArrayList<>();
286
287 // Lets first find the groups (populate `groups` and `senders`)
288 findGroups(historicMessages, messages, groups, senders);
289
290 // Let's now create the views and reorder them accordingly
291 // side-effect: updates mGroups, mAddedGroups
292 createGroupViews(groups, senders, showSpinner);
293
294 // Let's first check which groups were removed altogether and remove them in one animation
295 removeGroups(oldGroups);
296
297 // Let's remove the remaining messages
298 mMessages.forEach(REMOVE_MESSAGE);
299 mHistoricMessages.forEach(REMOVE_MESSAGE);
300
301 mMessages = messages;
302 mHistoricMessages = historicMessages;
303
304 updateHistoricMessageVisibility();
305 updateTitleAndNamesDisplay();
306
Selim Cinek857f2792020-03-03 19:06:21 -0800307 updateConversationLayout();
Selim Cinek20d1ee22020-02-03 16:04:26 -0500308
309 }
310
Selim Cinek857f2792020-03-03 19:06:21 -0800311 /**
312 * Update the layout according to the data provided (i.e mIsOneToOne, expanded etc);
313 */
314 private void updateConversationLayout() {
Selim Cinek20d1ee22020-02-03 16:04:26 -0500315 // TODO: resolve this from shortcuts
316 // Set avatar and name
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700317 CharSequence conversationText = mConversationTitle;
318 // TODO: display the secondary text somewhere
Selim Cinek20d1ee22020-02-03 16:04:26 -0500319 if (mIsOneToOne) {
320 // Let's resolve the icon / text from the last sender
321 mConversationIcon.setVisibility(VISIBLE);
Selim Cinek8baa70f2020-03-09 20:09:35 -0700322 mConversationFacePile.setVisibility(GONE);
Selim Cinek857f2792020-03-03 19:06:21 -0800323 CharSequence userKey = getKey(mUser);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500324 for (int i = mGroups.size() - 1; i >= 0; i--) {
325 MessagingGroup messagingGroup = mGroups.get(i);
326 Person messageSender = messagingGroup.getSender();
Selim Cinek857f2792020-03-03 19:06:21 -0800327 if ((messageSender != null && !TextUtils.equals(userKey, getKey(messageSender)))
328 || i == 0) {
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700329 if (TextUtils.isEmpty(conversationText)) {
330 // We use the sendername as header text if no conversation title is provided
331 // (This usually happens for most 1:1 conversations)
332 conversationText = messagingGroup.getSenderName();
333 }
334 Icon avatarIcon = messagingGroup.getAvatarIcon();
335 if (avatarIcon == null) {
336 avatarIcon = createAvatarSymbol(conversationText, "", mLayoutColor);
337 }
338 mConversationIcon.setImageIcon(avatarIcon);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500339 break;
340 }
341 }
Selim Cinek20d1ee22020-02-03 16:04:26 -0500342 } else {
Selim Cinek20d1ee22020-02-03 16:04:26 -0500343 if (mIsCollapsed) {
Selim Cinek20d1ee22020-02-03 16:04:26 -0500344 if (mLargeIcon != null) {
Selim Cinek8baa70f2020-03-09 20:09:35 -0700345 mConversationIcon.setVisibility(VISIBLE);
346 mConversationFacePile.setVisibility(GONE);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500347 mConversationIcon.setImageIcon(mLargeIcon);
348 } else {
Selim Cinek8baa70f2020-03-09 20:09:35 -0700349 mConversationIcon.setVisibility(GONE);
350 // This will also inflate it!
351 mConversationFacePile.setVisibility(VISIBLE);
352 mConversationFacePile = findViewById(R.id.conversation_face_pile);
353 bindFacePile();
Selim Cinek20d1ee22020-02-03 16:04:26 -0500354 }
355 } else {
Selim Cinek8baa70f2020-03-09 20:09:35 -0700356 mConversationFacePile.setVisibility(GONE);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500357 mConversationIcon.setVisibility(GONE);
358 }
359 }
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700360 if (TextUtils.isEmpty(conversationText)) {
361 conversationText = mIsOneToOne ? mFallbackChatName : mFallbackGroupChatName;
362 }
363 mConversationText.setText(conversationText);
Selim Cinek857f2792020-03-03 19:06:21 -0800364 // Update if the groups can hide the sender if they are first (applies to 1:1 conversations)
365 // This needs to happen after all of the above o update all of the groups
366 for (int i = mGroups.size() - 1; i >= 0; i--) {
367 MessagingGroup messagingGroup = mGroups.get(i);
368 CharSequence messageSender = messagingGroup.getSenderName();
369 boolean canHide = mIsOneToOne
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700370 && TextUtils.equals(conversationText, messageSender);
Selim Cinek857f2792020-03-03 19:06:21 -0800371 messagingGroup.setCanHideSenderIfFirst(canHide);
372 }
373 updateIconPositionAndSize();
374 }
375
Selim Cinek8baa70f2020-03-09 20:09:35 -0700376 private void bindFacePile() {
377 // Let's bind the face pile
378 View bottomBackground = mConversationFacePile.findViewById(
379 R.id.conversation_face_pile_bottom_background);
380 applyNotificationBackgroundColor(bottomBackground);
381 ImageView bottomView = mConversationFacePile.findViewById(
382 R.id.conversation_face_pile_bottom);
383 ImageView topView = mConversationFacePile.findViewById(
384 R.id.conversation_face_pile_top);
385 // Let's find the two last conversations:
386 Icon secondLastIcon = null;
387 CharSequence lastKey = null;
388 Icon lastIcon = null;
389 CharSequence userKey = getKey(mUser);
390 for (int i = mGroups.size() - 1; i >= 0; i--) {
391 MessagingGroup messagingGroup = mGroups.get(i);
392 Person messageSender = messagingGroup.getSender();
393 boolean notUser = messageSender != null
394 && !TextUtils.equals(userKey, getKey(messageSender));
395 boolean notIncluded = messageSender != null
396 && !TextUtils.equals(lastKey, getKey(messageSender));
397 if ((notUser && notIncluded)
398 || (i == 0 && lastKey == null)) {
399 if (lastIcon == null) {
400 lastIcon = messagingGroup.getAvatarIcon();
401 lastKey = getKey(messageSender);
402 } else {
403 secondLastIcon = messagingGroup.getAvatarIcon();
404 break;
405 }
406 }
407 }
408 if (lastIcon == null) {
409 lastIcon = createAvatarSymbol(" ", "", mLayoutColor);
410 }
411 bottomView.setImageIcon(lastIcon);
412 if (secondLastIcon == null) {
413 secondLastIcon = createAvatarSymbol("", "", mLayoutColor);
414 }
415 topView.setImageIcon(secondLastIcon);
416 }
417
Selim Cinek857f2792020-03-03 19:06:21 -0800418 /**
419 * update the icon position and sizing
420 */
421 private void updateIconPositionAndSize() {
Selim Cinek20d1ee22020-02-03 16:04:26 -0500422 int gravity;
423 int marginStart;
424 int marginTop;
425 int iconSize;
426 if (mIsOneToOne || mIsCollapsed) {
427 // Baded format
428 gravity = Gravity.LEFT;
429 marginStart = mBadgedSideMargins;
430 marginTop = mBadgedSideMargins;
431 iconSize = mIconSizeBadged;
432 } else {
433 gravity = Gravity.CENTER_HORIZONTAL;
434 marginStart = 0;
435 marginTop = mExpandedGroupTopMargin;
436 iconSize = mIconSizeCentered;
437 }
Selim Cinek857f2792020-03-03 19:06:21 -0800438 LayoutParams layoutParams =
Selim Cinek20d1ee22020-02-03 16:04:26 -0500439 (LayoutParams) mConversationIconBadge.getLayoutParams();
440 layoutParams.gravity = gravity;
441 layoutParams.topMargin = marginTop;
442 layoutParams.setMarginStart(marginStart);
443 mConversationIconBadge.setLayoutParams(layoutParams);
444 ViewGroup.LayoutParams iconParams = mIcon.getLayoutParams();
445 iconParams.width = iconSize;
446 iconParams.height = iconSize;
447 mIcon.setLayoutParams(iconParams);
448 }
449
450 @RemotableViewMethod
451 public void setLargeIcon(Icon largeIcon) {
452 mLargeIcon = largeIcon;
453 }
454
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700455 /**
456 * Sets the conversation title of this conversation.
457 *
458 * @param conversationTitle the conversation title
459 */
460 @RemotableViewMethod
461 public void setConversationTitle(CharSequence conversationTitle) {
462 mConversationTitle = conversationTitle;
463 }
464
Selim Cinek20d1ee22020-02-03 16:04:26 -0500465 private void removeGroups(ArrayList<MessagingGroup> oldGroups) {
466 int size = oldGroups.size();
467 for (int i = 0; i < size; i++) {
468 MessagingGroup group = oldGroups.get(i);
469 if (!mGroups.contains(group)) {
470 List<MessagingMessage> messages = group.getMessages();
471 Runnable endRunnable = () -> {
472 mMessagingLinearLayout.removeTransientView(group);
473 group.recycle();
474 };
475
476 boolean wasShown = group.isShown();
477 mMessagingLinearLayout.removeView(group);
478 if (wasShown && !MessagingLinearLayout.isGone(group)) {
479 mMessagingLinearLayout.addTransientView(group, 0);
480 group.removeGroupAnimated(endRunnable);
481 } else {
482 endRunnable.run();
483 }
484 mMessages.removeAll(messages);
485 mHistoricMessages.removeAll(messages);
486 }
487 }
488 }
489
490 private void updateTitleAndNamesDisplay() {
491 ArrayMap<CharSequence, String> uniqueNames = new ArrayMap<>();
492 ArrayMap<Character, CharSequence> uniqueCharacters = new ArrayMap<>();
493 for (int i = 0; i < mGroups.size(); i++) {
494 MessagingGroup group = mGroups.get(i);
495 CharSequence senderName = group.getSenderName();
496 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) {
497 continue;
498 }
499 if (!uniqueNames.containsKey(senderName)) {
500 // Only use visible characters to get uniqueNames
501 String pureSenderName = IGNORABLE_CHAR_PATTERN
502 .matcher(senderName).replaceAll("" /* replacement */);
503 char c = pureSenderName.charAt(0);
504 if (uniqueCharacters.containsKey(c)) {
505 // this character was already used, lets make it more unique. We first need to
506 // resolve the existing character if it exists
507 CharSequence existingName = uniqueCharacters.get(c);
508 if (existingName != null) {
509 uniqueNames.put(existingName, findNameSplit((String) existingName));
510 uniqueCharacters.put(c, null);
511 }
512 uniqueNames.put(senderName, findNameSplit((String) senderName));
513 } else {
514 uniqueNames.put(senderName, Character.toString(c));
515 uniqueCharacters.put(c, pureSenderName);
516 }
517 }
518 }
519
520 // Now that we have the correct symbols, let's look what we have cached
521 ArrayMap<CharSequence, Icon> cachedAvatars = new ArrayMap<>();
522 for (int i = 0; i < mGroups.size(); i++) {
523 // Let's now set the avatars
524 MessagingGroup group = mGroups.get(i);
525 boolean isOwnMessage = group.getSender() == mUser;
526 CharSequence senderName = group.getSenderName();
527 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)
528 || (mIsOneToOne && mAvatarReplacement != null && !isOwnMessage)) {
529 continue;
530 }
531 String symbol = uniqueNames.get(senderName);
532 Icon cachedIcon = group.getAvatarSymbolIfMatching(senderName,
533 symbol, mLayoutColor);
534 if (cachedIcon != null) {
535 cachedAvatars.put(senderName, cachedIcon);
536 }
537 }
538
539 for (int i = 0; i < mGroups.size(); i++) {
540 // Let's now set the avatars
541 MessagingGroup group = mGroups.get(i);
542 CharSequence senderName = group.getSenderName();
543 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) {
544 continue;
545 }
546 if (mIsOneToOne && mAvatarReplacement != null && group.getSender() != mUser) {
547 group.setAvatar(mAvatarReplacement);
548 } else {
549 Icon cachedIcon = cachedAvatars.get(senderName);
550 if (cachedIcon == null) {
551 cachedIcon = createAvatarSymbol(senderName, uniqueNames.get(senderName),
552 mLayoutColor);
553 cachedAvatars.put(senderName, cachedIcon);
554 }
555 group.setCreatedAvatar(cachedIcon, senderName, uniqueNames.get(senderName),
556 mLayoutColor);
557 }
558 }
559 }
560
561 private Icon createAvatarSymbol(CharSequence senderName, String symbol, int layoutColor) {
562 if (symbol.isEmpty() || TextUtils.isDigitsOnly(symbol) ||
563 SPECIAL_CHAR_PATTERN.matcher(symbol).find()) {
564 Icon avatarIcon = Icon.createWithResource(getContext(),
565 R.drawable.messaging_user);
566 avatarIcon.setTint(findColor(senderName, layoutColor));
567 return avatarIcon;
568 } else {
569 Bitmap bitmap = Bitmap.createBitmap(mAvatarSize, mAvatarSize, Bitmap.Config.ARGB_8888);
570 Canvas canvas = new Canvas(bitmap);
571 float radius = mAvatarSize / 2.0f;
572 int color = findColor(senderName, layoutColor);
573 mPaint.setColor(color);
574 canvas.drawCircle(radius, radius, radius, mPaint);
575 boolean needDarkText = ColorUtils.calculateLuminance(color) > 0.5f;
576 mTextPaint.setColor(needDarkText ? Color.BLACK : Color.WHITE);
577 mTextPaint.setTextSize(symbol.length() == 1 ? mAvatarSize * 0.5f : mAvatarSize * 0.3f);
578 int yPos = (int) (radius - ((mTextPaint.descent() + mTextPaint.ascent()) / 2));
579 canvas.drawText(symbol, radius, yPos, mTextPaint);
580 return Icon.createWithBitmap(bitmap);
581 }
582 }
583
584 private int findColor(CharSequence senderName, int layoutColor) {
585 double luminance = ContrastColorUtil.calculateLuminance(layoutColor);
586 float shift = Math.abs(senderName.hashCode()) % 5 / 4.0f - 0.5f;
587
588 // we need to offset the range if the luminance is too close to the borders
589 shift += Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - luminance, 0);
590 shift -= Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - (1.0f - luminance), 0);
591 return ContrastColorUtil.getShiftedColor(layoutColor,
592 (int) (shift * COLOR_SHIFT_AMOUNT));
593 }
594
595 private String findNameSplit(String existingName) {
596 String[] split = existingName.split(" ");
597 if (split.length > 1) {
598 return Character.toString(split[0].charAt(0))
599 + Character.toString(split[1].charAt(0));
600 }
601 return existingName.substring(0, 1);
602 }
603
604 @RemotableViewMethod
605 public void setLayoutColor(int color) {
606 mLayoutColor = color;
607 }
608
609 @RemotableViewMethod
610 public void setIsOneToOne(boolean oneToOne) {
611 mIsOneToOne = oneToOne;
612 }
613
614 @RemotableViewMethod
615 public void setSenderTextColor(int color) {
616 mSenderTextColor = color;
617 }
618
Selim Cineke9714eb2020-03-09 11:21:49 -0700619 /**
620 * @param color the color of the notification background
621 */
622 @RemotableViewMethod
623 public void setNotificationBackgroundColor(int color) {
Selim Cinek8baa70f2020-03-09 20:09:35 -0700624 mNotificationBackgroundColor = color;
625 applyNotificationBackgroundColor(mConversationIconBadge);
626 }
627
628 private void applyNotificationBackgroundColor(View view) {
629 view.setBackgroundTintList(ColorStateList.valueOf(mNotificationBackgroundColor));
Selim Cineke9714eb2020-03-09 11:21:49 -0700630 }
631
Selim Cinek20d1ee22020-02-03 16:04:26 -0500632 @RemotableViewMethod
633 public void setMessageTextColor(int color) {
634 mMessageTextColor = color;
635 }
636
637 private void setUser(Person user) {
638 mUser = user;
639 if (mUser.getIcon() == null) {
640 Icon userIcon = Icon.createWithResource(getContext(),
641 R.drawable.messaging_user);
642 userIcon.setTint(mLayoutColor);
643 mUser = mUser.toBuilder().setIcon(userIcon).build();
644 }
645 }
646
647 private void createGroupViews(List<List<MessagingMessage>> groups,
648 List<Person> senders, boolean showSpinner) {
649 mGroups.clear();
650 for (int groupIndex = 0; groupIndex < groups.size(); groupIndex++) {
651 List<MessagingMessage> group = groups.get(groupIndex);
652 MessagingGroup newGroup = null;
653 // we'll just take the first group that exists or create one there is none
654 for (int messageIndex = group.size() - 1; messageIndex >= 0; messageIndex--) {
655 MessagingMessage message = group.get(messageIndex);
656 newGroup = message.getGroup();
657 if (newGroup != null) {
658 break;
659 }
660 }
661 // Create a new group, adding it to the linear layout as well
662 if (newGroup == null) {
663 newGroup = MessagingGroup.createGroup(mMessagingLinearLayout);
664 mAddedGroups.add(newGroup);
665 }
666 newGroup.setDisplayImagesAtEnd(mIsCollapsed);
Selim Cineka91778a32020-03-13 17:30:34 -0700667 newGroup.setIsInConversation(true);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500668 newGroup.setLayoutColor(mLayoutColor);
669 newGroup.setTextColors(mSenderTextColor, mMessageTextColor);
670 Person sender = senders.get(groupIndex);
671 CharSequence nameOverride = null;
672 if (sender != mUser && mNameReplacement != null) {
673 nameOverride = mNameReplacement;
674 }
675 newGroup.setShowingAvatar(!mIsOneToOne && !mIsCollapsed);
676 newGroup.setSingleLine(mIsCollapsed);
677 newGroup.setSender(sender, nameOverride);
678 newGroup.setSending(groupIndex == (groups.size() - 1) && showSpinner);
679 mGroups.add(newGroup);
680
681 // Reposition to the correct place (if we're re-using a group)
682 if (mMessagingLinearLayout.indexOfChild(newGroup) != groupIndex) {
683 mMessagingLinearLayout.removeView(newGroup);
684 mMessagingLinearLayout.addView(newGroup, groupIndex);
685 }
686 newGroup.setMessages(group);
687 }
688 }
689
690 private void findGroups(List<MessagingMessage> historicMessages,
691 List<MessagingMessage> messages, List<List<MessagingMessage>> groups,
692 List<Person> senders) {
693 CharSequence currentSenderKey = null;
694 List<MessagingMessage> currentGroup = null;
695 int histSize = historicMessages.size();
696 for (int i = 0; i < histSize + messages.size(); i++) {
697 MessagingMessage message;
698 if (i < histSize) {
699 message = historicMessages.get(i);
700 } else {
701 message = messages.get(i - histSize);
702 }
703 boolean isNewGroup = currentGroup == null;
704 Person sender = message.getMessage().getSenderPerson();
Selim Cinek857f2792020-03-03 19:06:21 -0800705 CharSequence key = getKey(sender);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500706 isNewGroup |= !TextUtils.equals(key, currentSenderKey);
707 if (isNewGroup) {
708 currentGroup = new ArrayList<>();
709 groups.add(currentGroup);
710 if (sender == null) {
711 sender = mUser;
712 }
713 senders.add(sender);
714 currentSenderKey = key;
715 }
716 currentGroup.add(message);
717 }
718 }
719
Selim Cinek857f2792020-03-03 19:06:21 -0800720 private CharSequence getKey(Person person) {
721 return person == null ? null : person.getKey() == null ? person.getName() : person.getKey();
722 }
723
Selim Cinek20d1ee22020-02-03 16:04:26 -0500724 /**
725 * Creates new messages, reusing existing ones if they are available.
726 *
727 * @param newMessages the messages to parse.
728 */
729 private List<MessagingMessage> createMessages(
730 List<Notification.MessagingStyle.Message> newMessages, boolean historic) {
731 List<MessagingMessage> result = new ArrayList<>();
732 for (int i = 0; i < newMessages.size(); i++) {
733 Notification.MessagingStyle.Message m = newMessages.get(i);
734 MessagingMessage message = findAndRemoveMatchingMessage(m);
735 if (message == null) {
736 message = MessagingMessage.createMessage(this, m, mImageResolver);
737 }
738 message.setIsHistoric(historic);
739 result.add(message);
740 }
741 return result;
742 }
743
744 private MessagingMessage findAndRemoveMatchingMessage(Notification.MessagingStyle.Message m) {
745 for (int i = 0; i < mMessages.size(); i++) {
746 MessagingMessage existing = mMessages.get(i);
747 if (existing.sameAs(m)) {
748 mMessages.remove(i);
749 return existing;
750 }
751 }
752 for (int i = 0; i < mHistoricMessages.size(); i++) {
753 MessagingMessage existing = mHistoricMessages.get(i);
754 if (existing.sameAs(m)) {
755 mHistoricMessages.remove(i);
756 return existing;
757 }
758 }
759 return null;
760 }
761
762 public void showHistoricMessages(boolean show) {
763 mShowHistoricMessages = show;
764 updateHistoricMessageVisibility();
765 }
766
767 private void updateHistoricMessageVisibility() {
768 int numHistoric = mHistoricMessages.size();
769 for (int i = 0; i < numHistoric; i++) {
770 MessagingMessage existing = mHistoricMessages.get(i);
771 existing.setVisibility(mShowHistoricMessages ? VISIBLE : GONE);
772 }
773 int numGroups = mGroups.size();
774 for (int i = 0; i < numGroups; i++) {
775 MessagingGroup group = mGroups.get(i);
776 int visibleChildren = 0;
777 List<MessagingMessage> messages = group.getMessages();
778 int numGroupMessages = messages.size();
779 for (int j = 0; j < numGroupMessages; j++) {
780 MessagingMessage message = messages.get(j);
781 if (message.getVisibility() != GONE) {
782 visibleChildren++;
783 }
784 }
785 if (visibleChildren > 0 && group.getVisibility() == GONE) {
786 group.setVisibility(VISIBLE);
787 } else if (visibleChildren == 0 && group.getVisibility() != GONE) {
788 group.setVisibility(GONE);
789 }
790 }
791 }
792
793 @Override
794 protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
795 super.onLayout(changed, left, top, right, bottom);
796 if (!mAddedGroups.isEmpty()) {
797 getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
798 @Override
799 public boolean onPreDraw() {
800 for (MessagingGroup group : mAddedGroups) {
801 if (!group.isShown()) {
802 continue;
803 }
804 MessagingPropertyAnimator.fadeIn(group.getAvatar());
805 MessagingPropertyAnimator.fadeIn(group.getSenderView());
806 MessagingPropertyAnimator.startLocalTranslationFrom(group,
807 group.getHeight(), LINEAR_OUT_SLOW_IN);
808 }
809 mAddedGroups.clear();
810 getViewTreeObserver().removeOnPreDrawListener(this);
811 return true;
812 }
813 });
814 }
815 }
816
817 public MessagingLinearLayout getMessagingLinearLayout() {
818 return mMessagingLinearLayout;
819 }
820
821 public ArrayList<MessagingGroup> getMessagingGroups() {
822 return mGroups;
823 }
824
825 private void updateExpandButton() {
826 int drawableId;
827 int contentDescriptionId;
828 int gravity;
829 int topMargin = 0;
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800830 ViewGroup newContainer;
831 int newContainerHeight;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500832 if (mIsCollapsed) {
833 drawableId = R.drawable.ic_expand_notification;
834 contentDescriptionId = R.string.expand_button_content_description_collapsed;
835 gravity = Gravity.CENTER;
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800836 newContainer = mExpandButtonAndContentContainer;
837 newContainerHeight = LayoutParams.MATCH_PARENT;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500838 } else {
839 drawableId = R.drawable.ic_collapse_notification;
840 contentDescriptionId = R.string.expand_button_content_description_expanded;
841 gravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP;
842 topMargin = mExpandButtonExpandedTopMargin;
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800843 newContainer = this;
844 newContainerHeight = mExpandButtonExpandedSize;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500845 }
846 mExpandButton.setImageDrawable(getContext().getDrawable(drawableId));
847 mExpandButton.setColorFilter(mExpandButton.getOriginalNotificationColor());
848
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800849 // We need to make sure that the expand button is in the linearlayout pushing over the
850 // content when collapsed, but allows the content to flow under it when expanded.
851 if (newContainer != mExpandButtonContainer.getParent()) {
852 ((ViewGroup) mExpandButtonContainer.getParent()).removeView(mExpandButtonContainer);
853 newContainer.addView(mExpandButtonContainer);
854 MarginLayoutParams layoutParams =
855 (MarginLayoutParams) mExpandButtonContainer.getLayoutParams();
856 layoutParams.height = newContainerHeight;
857 mExpandButtonContainer.setLayoutParams(layoutParams);
858 }
859
Selim Cinek20d1ee22020-02-03 16:04:26 -0500860 // update if the expand button is centered
861 FrameLayout.LayoutParams layoutParams = (LayoutParams) mExpandButton.getLayoutParams();
862 layoutParams.gravity = gravity;
863 layoutParams.topMargin = topMargin;
864 mExpandButton.setLayoutParams(layoutParams);
865
866 mExpandButtonContainer.setContentDescription(mContext.getText(contentDescriptionId));
Selim Cinekafc20582020-03-13 20:15:38 -0700867 }
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800868
Selim Cinekafc20582020-03-13 20:15:38 -0700869 private void updateContentPaddings() {
870
871 // Let's make sure the conversation header can't run into the expand button when we're
872 // collapsed and update the paddings of the content
873 int headerPaddingEnd;
874 int contentPaddingEnd;
875 if (!mExpandable) {
876 headerPaddingEnd = 0;
877 contentPaddingEnd = mContentMarginEnd;
878 } else if (mIsCollapsed) {
879 headerPaddingEnd = 0;
880 contentPaddingEnd = 0;
881 } else {
882 headerPaddingEnd = mNotificationHeaderExpandedPadding;
883 contentPaddingEnd = mContentMarginEnd;
884 }
885 mConversationHeader.setPaddingRelative(
886 mConversationHeader.getPaddingStart(),
887 mConversationHeader.getPaddingTop(),
888 headerPaddingEnd,
889 mConversationHeader.getPaddingBottom());
890
891 mContentContainer.setPaddingRelative(
892 mContentContainer.getPaddingStart(),
893 mContentContainer.getPaddingTop(),
894 contentPaddingEnd,
895 mContentContainer.getPaddingBottom());
Selim Cinek20d1ee22020-02-03 16:04:26 -0500896 }
897
898 public void updateExpandability(boolean expandable, @Nullable OnClickListener onClickListener) {
Selim Cinekafc20582020-03-13 20:15:38 -0700899 mExpandable = expandable;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500900 if (expandable) {
901 mExpandButtonContainer.setVisibility(VISIBLE);
902 mExpandButtonContainer.setOnClickListener(onClickListener);
903 } else {
904 // TODO: handle content paddings to end of layout
905 mExpandButtonContainer.setVisibility(GONE);
906 }
Selim Cinekafc20582020-03-13 20:15:38 -0700907 updateContentPaddings();
Selim Cinek20d1ee22020-02-03 16:04:26 -0500908 }
909}