blob: 07be113d9a53fcd216f40f1bd41392a3cefc1421 [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 Cinek20d1ee22020-02-03 16:04:26 -0500125
126 public ConversationLayout(@NonNull Context context) {
127 super(context);
128 }
129
130 public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
131 super(context, attrs);
132 }
133
134 public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs,
135 @AttrRes int defStyleAttr) {
136 super(context, attrs, defStyleAttr);
137 }
138
139 public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs,
140 @AttrRes int defStyleAttr, @StyleRes int defStyleRes) {
141 super(context, attrs, defStyleAttr, defStyleRes);
142 }
143
144 @Override
145 protected void onFinishInflate() {
146 super.onFinishInflate();
147 mMessagingLinearLayout = findViewById(R.id.notification_messaging);
148 mMessagingLinearLayout.setMessagingLayout(this);
149 // We still want to clip, but only on the top, since views can temporarily out of bounds
150 // during transitions.
151 DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
152 int size = Math.max(displayMetrics.widthPixels, displayMetrics.heightPixels);
153 Rect rect = new Rect(0, 0, size, size);
154 mMessagingLinearLayout.setClipBounds(rect);
155 mTitleView = findViewById(R.id.title);
156 mAvatarSize = getResources().getDimensionPixelSize(R.dimen.messaging_avatar_size);
157 mTextPaint.setTextAlign(Paint.Align.CENTER);
158 mTextPaint.setAntiAlias(true);
159 mConversationIcon = findViewById(R.id.conversation_icon);
160 mIcon = findViewById(R.id.icon);
161 mConversationIconBadge = findViewById(R.id.conversation_icon_badge);
Selim Cinekf2b8aa72020-03-05 15:29:02 -0800162 mIcon.setOnVisibilityChangedListener((visibility) -> {
163 // Always keep the badge visibility in sync with the icon. This is necessary in cases
164 // Where the icon is being hidden externally like in group children.
165 mConversationIconBadge.setVisibility(visibility);
166 });
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700167 mConversationText = findViewById(R.id.conversation_text);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500168 mExpandButtonContainer = findViewById(R.id.expand_button_container);
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800169 mExpandButtonAndContentContainer = findViewById(R.id.expand_button_and_content_container);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500170 mExpandButton = findViewById(R.id.expand_button);
171 mExpandButtonExpandedTopMargin = getResources().getDimensionPixelSize(
172 R.dimen.conversation_expand_button_top_margin_expanded);
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800173 mExpandButtonExpandedSize = getResources().getDimensionPixelSize(
174 R.dimen.conversation_expand_button_expanded_size);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500175 mBadgedSideMargins = getResources().getDimensionPixelSize(
176 R.dimen.conversation_badge_side_margin);
177 mIconSizeBadged = getResources().getDimensionPixelSize(
178 R.dimen.conversation_icon_size_badged);
179 mIconSizeCentered = getResources().getDimensionPixelSize(
180 R.dimen.conversation_icon_size_centered);
181 mExpandedGroupTopMargin = getResources().getDimensionPixelSize(
182 R.dimen.conversation_icon_margin_top_centered);
Selim Cinek8baa70f2020-03-09 20:09:35 -0700183 mConversationFacePile = findViewById(R.id.conversation_face_pile);
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700184 mFallbackChatName = getResources().getString(
185 R.string.conversation_title_fallback_one_to_one);
186 mFallbackGroupChatName = getResources().getString(
187 R.string.conversation_title_fallback_group_chat);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500188 }
189
190 @RemotableViewMethod
191 public void setAvatarReplacement(Icon icon) {
192 mAvatarReplacement = icon;
193 }
194
195 @RemotableViewMethod
196 public void setNameReplacement(CharSequence nameReplacement) {
197 mNameReplacement = nameReplacement;
198 }
199
200 /**
201 * Set this layout to show the collapsed representation.
202 *
203 * @param isCollapsed is it collapsed
204 */
205 @RemotableViewMethod
206 public void setIsCollapsed(boolean isCollapsed) {
207 mIsCollapsed = isCollapsed;
208 mMessagingLinearLayout.setMaxDisplayedLines(isCollapsed ? 1 : Integer.MAX_VALUE);
209 updateExpandButton();
210 }
211
212 @RemotableViewMethod
213 public void setData(Bundle extras) {
214 Parcelable[] messages = extras.getParcelableArray(Notification.EXTRA_MESSAGES);
215 List<Notification.MessagingStyle.Message> newMessages
216 = Notification.MessagingStyle.Message.getMessagesFromBundleArray(messages);
217 Parcelable[] histMessages = extras.getParcelableArray(Notification.EXTRA_HISTORIC_MESSAGES);
218 List<Notification.MessagingStyle.Message> newHistoricMessages
219 = Notification.MessagingStyle.Message.getMessagesFromBundleArray(histMessages);
220
221 // mUser now set (would be nice to avoid the side effect but WHATEVER)
222 setUser(extras.getParcelable(Notification.EXTRA_MESSAGING_PERSON));
223
224
225 // Append remote input history to newMessages (again, side effect is lame but WHATEVS)
226 RemoteInputHistoryItem[] history = (RemoteInputHistoryItem[])
227 extras.getParcelableArray(Notification.EXTRA_REMOTE_INPUT_HISTORY_ITEMS);
228 addRemoteInputHistoryToMessages(newMessages, history);
229
230 boolean showSpinner =
231 extras.getBoolean(Notification.EXTRA_SHOW_REMOTE_INPUT_SPINNER, false);
232
233 // bind it, baby
234 bind(newMessages, newHistoricMessages, showSpinner);
235 }
236
237 @Override
238 public void setImageResolver(ImageResolver resolver) {
239 mImageResolver = resolver;
240 }
241
242 private void addRemoteInputHistoryToMessages(
243 List<Notification.MessagingStyle.Message> newMessages,
244 RemoteInputHistoryItem[] remoteInputHistory) {
245 if (remoteInputHistory == null || remoteInputHistory.length == 0) {
246 return;
247 }
248 for (int i = remoteInputHistory.length - 1; i >= 0; i--) {
249 RemoteInputHistoryItem historyMessage = remoteInputHistory[i];
250 Notification.MessagingStyle.Message message = new Notification.MessagingStyle.Message(
251 historyMessage.getText(), 0, (Person) null, true /* remoteHistory */);
252 if (historyMessage.getUri() != null) {
253 message.setData(historyMessage.getMimeType(), historyMessage.getUri());
254 }
255 newMessages.add(message);
256 }
257 }
258
259 private void bind(List<Notification.MessagingStyle.Message> newMessages,
260 List<Notification.MessagingStyle.Message> newHistoricMessages,
261 boolean showSpinner) {
262 // convert MessagingStyle.Message to MessagingMessage, re-using ones from a previous binding
263 // if they exist
264 List<MessagingMessage> historicMessages = createMessages(newHistoricMessages,
265 true /* isHistoric */);
266 List<MessagingMessage> messages = createMessages(newMessages, false /* isHistoric */);
267
268 // Copy our groups, before they get clobbered
269 ArrayList<MessagingGroup> oldGroups = new ArrayList<>(mGroups);
270
271 // Add our new MessagingMessages to groups
272 List<List<MessagingMessage>> groups = new ArrayList<>();
273 List<Person> senders = new ArrayList<>();
274
275 // Lets first find the groups (populate `groups` and `senders`)
276 findGroups(historicMessages, messages, groups, senders);
277
278 // Let's now create the views and reorder them accordingly
279 // side-effect: updates mGroups, mAddedGroups
280 createGroupViews(groups, senders, showSpinner);
281
282 // Let's first check which groups were removed altogether and remove them in one animation
283 removeGroups(oldGroups);
284
285 // Let's remove the remaining messages
286 mMessages.forEach(REMOVE_MESSAGE);
287 mHistoricMessages.forEach(REMOVE_MESSAGE);
288
289 mMessages = messages;
290 mHistoricMessages = historicMessages;
291
292 updateHistoricMessageVisibility();
293 updateTitleAndNamesDisplay();
294
Selim Cinek857f2792020-03-03 19:06:21 -0800295 updateConversationLayout();
Selim Cinek20d1ee22020-02-03 16:04:26 -0500296
297 }
298
Selim Cinek857f2792020-03-03 19:06:21 -0800299 /**
300 * Update the layout according to the data provided (i.e mIsOneToOne, expanded etc);
301 */
302 private void updateConversationLayout() {
Selim Cinek20d1ee22020-02-03 16:04:26 -0500303 // TODO: resolve this from shortcuts
304 // Set avatar and name
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700305 CharSequence conversationText = mConversationTitle;
306 // TODO: display the secondary text somewhere
Selim Cinek20d1ee22020-02-03 16:04:26 -0500307 if (mIsOneToOne) {
308 // Let's resolve the icon / text from the last sender
309 mConversationIcon.setVisibility(VISIBLE);
Selim Cinek8baa70f2020-03-09 20:09:35 -0700310 mConversationFacePile.setVisibility(GONE);
Selim Cinek857f2792020-03-03 19:06:21 -0800311 CharSequence userKey = getKey(mUser);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500312 for (int i = mGroups.size() - 1; i >= 0; i--) {
313 MessagingGroup messagingGroup = mGroups.get(i);
314 Person messageSender = messagingGroup.getSender();
Selim Cinek857f2792020-03-03 19:06:21 -0800315 if ((messageSender != null && !TextUtils.equals(userKey, getKey(messageSender)))
316 || i == 0) {
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700317 if (TextUtils.isEmpty(conversationText)) {
318 // We use the sendername as header text if no conversation title is provided
319 // (This usually happens for most 1:1 conversations)
320 conversationText = messagingGroup.getSenderName();
321 }
322 Icon avatarIcon = messagingGroup.getAvatarIcon();
323 if (avatarIcon == null) {
324 avatarIcon = createAvatarSymbol(conversationText, "", mLayoutColor);
325 }
326 mConversationIcon.setImageIcon(avatarIcon);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500327 break;
328 }
329 }
Selim Cinek20d1ee22020-02-03 16:04:26 -0500330 } else {
Selim Cinek20d1ee22020-02-03 16:04:26 -0500331 if (mIsCollapsed) {
Selim Cinek20d1ee22020-02-03 16:04:26 -0500332 if (mLargeIcon != null) {
Selim Cinek8baa70f2020-03-09 20:09:35 -0700333 mConversationIcon.setVisibility(VISIBLE);
334 mConversationFacePile.setVisibility(GONE);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500335 mConversationIcon.setImageIcon(mLargeIcon);
336 } else {
Selim Cinek8baa70f2020-03-09 20:09:35 -0700337 mConversationIcon.setVisibility(GONE);
338 // This will also inflate it!
339 mConversationFacePile.setVisibility(VISIBLE);
340 mConversationFacePile = findViewById(R.id.conversation_face_pile);
341 bindFacePile();
Selim Cinek20d1ee22020-02-03 16:04:26 -0500342 }
343 } else {
Selim Cinek8baa70f2020-03-09 20:09:35 -0700344 mConversationFacePile.setVisibility(GONE);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500345 mConversationIcon.setVisibility(GONE);
346 }
347 }
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700348 if (TextUtils.isEmpty(conversationText)) {
349 conversationText = mIsOneToOne ? mFallbackChatName : mFallbackGroupChatName;
350 }
351 mConversationText.setText(conversationText);
Selim Cinek857f2792020-03-03 19:06:21 -0800352 // Update if the groups can hide the sender if they are first (applies to 1:1 conversations)
353 // This needs to happen after all of the above o update all of the groups
354 for (int i = mGroups.size() - 1; i >= 0; i--) {
355 MessagingGroup messagingGroup = mGroups.get(i);
356 CharSequence messageSender = messagingGroup.getSenderName();
357 boolean canHide = mIsOneToOne
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700358 && TextUtils.equals(conversationText, messageSender);
Selim Cinek857f2792020-03-03 19:06:21 -0800359 messagingGroup.setCanHideSenderIfFirst(canHide);
360 }
361 updateIconPositionAndSize();
362 }
363
Selim Cinek8baa70f2020-03-09 20:09:35 -0700364 private void bindFacePile() {
365 // Let's bind the face pile
366 View bottomBackground = mConversationFacePile.findViewById(
367 R.id.conversation_face_pile_bottom_background);
368 applyNotificationBackgroundColor(bottomBackground);
369 ImageView bottomView = mConversationFacePile.findViewById(
370 R.id.conversation_face_pile_bottom);
371 ImageView topView = mConversationFacePile.findViewById(
372 R.id.conversation_face_pile_top);
373 // Let's find the two last conversations:
374 Icon secondLastIcon = null;
375 CharSequence lastKey = null;
376 Icon lastIcon = null;
377 CharSequence userKey = getKey(mUser);
378 for (int i = mGroups.size() - 1; i >= 0; i--) {
379 MessagingGroup messagingGroup = mGroups.get(i);
380 Person messageSender = messagingGroup.getSender();
381 boolean notUser = messageSender != null
382 && !TextUtils.equals(userKey, getKey(messageSender));
383 boolean notIncluded = messageSender != null
384 && !TextUtils.equals(lastKey, getKey(messageSender));
385 if ((notUser && notIncluded)
386 || (i == 0 && lastKey == null)) {
387 if (lastIcon == null) {
388 lastIcon = messagingGroup.getAvatarIcon();
389 lastKey = getKey(messageSender);
390 } else {
391 secondLastIcon = messagingGroup.getAvatarIcon();
392 break;
393 }
394 }
395 }
396 if (lastIcon == null) {
397 lastIcon = createAvatarSymbol(" ", "", mLayoutColor);
398 }
399 bottomView.setImageIcon(lastIcon);
400 if (secondLastIcon == null) {
401 secondLastIcon = createAvatarSymbol("", "", mLayoutColor);
402 }
403 topView.setImageIcon(secondLastIcon);
404 }
405
Selim Cinek857f2792020-03-03 19:06:21 -0800406 /**
407 * update the icon position and sizing
408 */
409 private void updateIconPositionAndSize() {
Selim Cinek20d1ee22020-02-03 16:04:26 -0500410 int gravity;
411 int marginStart;
412 int marginTop;
413 int iconSize;
414 if (mIsOneToOne || mIsCollapsed) {
415 // Baded format
416 gravity = Gravity.LEFT;
417 marginStart = mBadgedSideMargins;
418 marginTop = mBadgedSideMargins;
419 iconSize = mIconSizeBadged;
420 } else {
421 gravity = Gravity.CENTER_HORIZONTAL;
422 marginStart = 0;
423 marginTop = mExpandedGroupTopMargin;
424 iconSize = mIconSizeCentered;
425 }
Selim Cinek857f2792020-03-03 19:06:21 -0800426 LayoutParams layoutParams =
Selim Cinek20d1ee22020-02-03 16:04:26 -0500427 (LayoutParams) mConversationIconBadge.getLayoutParams();
428 layoutParams.gravity = gravity;
429 layoutParams.topMargin = marginTop;
430 layoutParams.setMarginStart(marginStart);
431 mConversationIconBadge.setLayoutParams(layoutParams);
432 ViewGroup.LayoutParams iconParams = mIcon.getLayoutParams();
433 iconParams.width = iconSize;
434 iconParams.height = iconSize;
435 mIcon.setLayoutParams(iconParams);
436 }
437
438 @RemotableViewMethod
439 public void setLargeIcon(Icon largeIcon) {
440 mLargeIcon = largeIcon;
441 }
442
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700443 /**
444 * Sets the conversation title of this conversation.
445 *
446 * @param conversationTitle the conversation title
447 */
448 @RemotableViewMethod
449 public void setConversationTitle(CharSequence conversationTitle) {
450 mConversationTitle = conversationTitle;
451 }
452
Selim Cinek20d1ee22020-02-03 16:04:26 -0500453 private void removeGroups(ArrayList<MessagingGroup> oldGroups) {
454 int size = oldGroups.size();
455 for (int i = 0; i < size; i++) {
456 MessagingGroup group = oldGroups.get(i);
457 if (!mGroups.contains(group)) {
458 List<MessagingMessage> messages = group.getMessages();
459 Runnable endRunnable = () -> {
460 mMessagingLinearLayout.removeTransientView(group);
461 group.recycle();
462 };
463
464 boolean wasShown = group.isShown();
465 mMessagingLinearLayout.removeView(group);
466 if (wasShown && !MessagingLinearLayout.isGone(group)) {
467 mMessagingLinearLayout.addTransientView(group, 0);
468 group.removeGroupAnimated(endRunnable);
469 } else {
470 endRunnable.run();
471 }
472 mMessages.removeAll(messages);
473 mHistoricMessages.removeAll(messages);
474 }
475 }
476 }
477
478 private void updateTitleAndNamesDisplay() {
479 ArrayMap<CharSequence, String> uniqueNames = new ArrayMap<>();
480 ArrayMap<Character, CharSequence> uniqueCharacters = new ArrayMap<>();
481 for (int i = 0; i < mGroups.size(); i++) {
482 MessagingGroup group = mGroups.get(i);
483 CharSequence senderName = group.getSenderName();
484 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) {
485 continue;
486 }
487 if (!uniqueNames.containsKey(senderName)) {
488 // Only use visible characters to get uniqueNames
489 String pureSenderName = IGNORABLE_CHAR_PATTERN
490 .matcher(senderName).replaceAll("" /* replacement */);
491 char c = pureSenderName.charAt(0);
492 if (uniqueCharacters.containsKey(c)) {
493 // this character was already used, lets make it more unique. We first need to
494 // resolve the existing character if it exists
495 CharSequence existingName = uniqueCharacters.get(c);
496 if (existingName != null) {
497 uniqueNames.put(existingName, findNameSplit((String) existingName));
498 uniqueCharacters.put(c, null);
499 }
500 uniqueNames.put(senderName, findNameSplit((String) senderName));
501 } else {
502 uniqueNames.put(senderName, Character.toString(c));
503 uniqueCharacters.put(c, pureSenderName);
504 }
505 }
506 }
507
508 // Now that we have the correct symbols, let's look what we have cached
509 ArrayMap<CharSequence, Icon> cachedAvatars = new ArrayMap<>();
510 for (int i = 0; i < mGroups.size(); i++) {
511 // Let's now set the avatars
512 MessagingGroup group = mGroups.get(i);
513 boolean isOwnMessage = group.getSender() == mUser;
514 CharSequence senderName = group.getSenderName();
515 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)
516 || (mIsOneToOne && mAvatarReplacement != null && !isOwnMessage)) {
517 continue;
518 }
519 String symbol = uniqueNames.get(senderName);
520 Icon cachedIcon = group.getAvatarSymbolIfMatching(senderName,
521 symbol, mLayoutColor);
522 if (cachedIcon != null) {
523 cachedAvatars.put(senderName, cachedIcon);
524 }
525 }
526
527 for (int i = 0; i < mGroups.size(); i++) {
528 // Let's now set the avatars
529 MessagingGroup group = mGroups.get(i);
530 CharSequence senderName = group.getSenderName();
531 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) {
532 continue;
533 }
534 if (mIsOneToOne && mAvatarReplacement != null && group.getSender() != mUser) {
535 group.setAvatar(mAvatarReplacement);
536 } else {
537 Icon cachedIcon = cachedAvatars.get(senderName);
538 if (cachedIcon == null) {
539 cachedIcon = createAvatarSymbol(senderName, uniqueNames.get(senderName),
540 mLayoutColor);
541 cachedAvatars.put(senderName, cachedIcon);
542 }
543 group.setCreatedAvatar(cachedIcon, senderName, uniqueNames.get(senderName),
544 mLayoutColor);
545 }
546 }
547 }
548
549 private Icon createAvatarSymbol(CharSequence senderName, String symbol, int layoutColor) {
550 if (symbol.isEmpty() || TextUtils.isDigitsOnly(symbol) ||
551 SPECIAL_CHAR_PATTERN.matcher(symbol).find()) {
552 Icon avatarIcon = Icon.createWithResource(getContext(),
553 R.drawable.messaging_user);
554 avatarIcon.setTint(findColor(senderName, layoutColor));
555 return avatarIcon;
556 } else {
557 Bitmap bitmap = Bitmap.createBitmap(mAvatarSize, mAvatarSize, Bitmap.Config.ARGB_8888);
558 Canvas canvas = new Canvas(bitmap);
559 float radius = mAvatarSize / 2.0f;
560 int color = findColor(senderName, layoutColor);
561 mPaint.setColor(color);
562 canvas.drawCircle(radius, radius, radius, mPaint);
563 boolean needDarkText = ColorUtils.calculateLuminance(color) > 0.5f;
564 mTextPaint.setColor(needDarkText ? Color.BLACK : Color.WHITE);
565 mTextPaint.setTextSize(symbol.length() == 1 ? mAvatarSize * 0.5f : mAvatarSize * 0.3f);
566 int yPos = (int) (radius - ((mTextPaint.descent() + mTextPaint.ascent()) / 2));
567 canvas.drawText(symbol, radius, yPos, mTextPaint);
568 return Icon.createWithBitmap(bitmap);
569 }
570 }
571
572 private int findColor(CharSequence senderName, int layoutColor) {
573 double luminance = ContrastColorUtil.calculateLuminance(layoutColor);
574 float shift = Math.abs(senderName.hashCode()) % 5 / 4.0f - 0.5f;
575
576 // we need to offset the range if the luminance is too close to the borders
577 shift += Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - luminance, 0);
578 shift -= Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - (1.0f - luminance), 0);
579 return ContrastColorUtil.getShiftedColor(layoutColor,
580 (int) (shift * COLOR_SHIFT_AMOUNT));
581 }
582
583 private String findNameSplit(String existingName) {
584 String[] split = existingName.split(" ");
585 if (split.length > 1) {
586 return Character.toString(split[0].charAt(0))
587 + Character.toString(split[1].charAt(0));
588 }
589 return existingName.substring(0, 1);
590 }
591
592 @RemotableViewMethod
593 public void setLayoutColor(int color) {
594 mLayoutColor = color;
595 }
596
597 @RemotableViewMethod
598 public void setIsOneToOne(boolean oneToOne) {
599 mIsOneToOne = oneToOne;
600 }
601
602 @RemotableViewMethod
603 public void setSenderTextColor(int color) {
604 mSenderTextColor = color;
605 }
606
Selim Cineke9714eb2020-03-09 11:21:49 -0700607 /**
608 * @param color the color of the notification background
609 */
610 @RemotableViewMethod
611 public void setNotificationBackgroundColor(int color) {
Selim Cinek8baa70f2020-03-09 20:09:35 -0700612 mNotificationBackgroundColor = color;
613 applyNotificationBackgroundColor(mConversationIconBadge);
614 }
615
616 private void applyNotificationBackgroundColor(View view) {
617 view.setBackgroundTintList(ColorStateList.valueOf(mNotificationBackgroundColor));
Selim Cineke9714eb2020-03-09 11:21:49 -0700618 }
619
Selim Cinek20d1ee22020-02-03 16:04:26 -0500620 @RemotableViewMethod
621 public void setMessageTextColor(int color) {
622 mMessageTextColor = color;
623 }
624
625 private void setUser(Person user) {
626 mUser = user;
627 if (mUser.getIcon() == null) {
628 Icon userIcon = Icon.createWithResource(getContext(),
629 R.drawable.messaging_user);
630 userIcon.setTint(mLayoutColor);
631 mUser = mUser.toBuilder().setIcon(userIcon).build();
632 }
633 }
634
635 private void createGroupViews(List<List<MessagingMessage>> groups,
636 List<Person> senders, boolean showSpinner) {
637 mGroups.clear();
638 for (int groupIndex = 0; groupIndex < groups.size(); groupIndex++) {
639 List<MessagingMessage> group = groups.get(groupIndex);
640 MessagingGroup newGroup = null;
641 // we'll just take the first group that exists or create one there is none
642 for (int messageIndex = group.size() - 1; messageIndex >= 0; messageIndex--) {
643 MessagingMessage message = group.get(messageIndex);
644 newGroup = message.getGroup();
645 if (newGroup != null) {
646 break;
647 }
648 }
649 // Create a new group, adding it to the linear layout as well
650 if (newGroup == null) {
651 newGroup = MessagingGroup.createGroup(mMessagingLinearLayout);
652 mAddedGroups.add(newGroup);
653 }
654 newGroup.setDisplayImagesAtEnd(mIsCollapsed);
655 newGroup.setLayoutColor(mLayoutColor);
656 newGroup.setTextColors(mSenderTextColor, mMessageTextColor);
657 Person sender = senders.get(groupIndex);
658 CharSequence nameOverride = null;
659 if (sender != mUser && mNameReplacement != null) {
660 nameOverride = mNameReplacement;
661 }
662 newGroup.setShowingAvatar(!mIsOneToOne && !mIsCollapsed);
663 newGroup.setSingleLine(mIsCollapsed);
664 newGroup.setSender(sender, nameOverride);
665 newGroup.setSending(groupIndex == (groups.size() - 1) && showSpinner);
666 mGroups.add(newGroup);
667
668 // Reposition to the correct place (if we're re-using a group)
669 if (mMessagingLinearLayout.indexOfChild(newGroup) != groupIndex) {
670 mMessagingLinearLayout.removeView(newGroup);
671 mMessagingLinearLayout.addView(newGroup, groupIndex);
672 }
673 newGroup.setMessages(group);
674 }
675 }
676
677 private void findGroups(List<MessagingMessage> historicMessages,
678 List<MessagingMessage> messages, List<List<MessagingMessage>> groups,
679 List<Person> senders) {
680 CharSequence currentSenderKey = null;
681 List<MessagingMessage> currentGroup = null;
682 int histSize = historicMessages.size();
683 for (int i = 0; i < histSize + messages.size(); i++) {
684 MessagingMessage message;
685 if (i < histSize) {
686 message = historicMessages.get(i);
687 } else {
688 message = messages.get(i - histSize);
689 }
690 boolean isNewGroup = currentGroup == null;
691 Person sender = message.getMessage().getSenderPerson();
Selim Cinek857f2792020-03-03 19:06:21 -0800692 CharSequence key = getKey(sender);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500693 isNewGroup |= !TextUtils.equals(key, currentSenderKey);
694 if (isNewGroup) {
695 currentGroup = new ArrayList<>();
696 groups.add(currentGroup);
697 if (sender == null) {
698 sender = mUser;
699 }
700 senders.add(sender);
701 currentSenderKey = key;
702 }
703 currentGroup.add(message);
704 }
705 }
706
Selim Cinek857f2792020-03-03 19:06:21 -0800707 private CharSequence getKey(Person person) {
708 return person == null ? null : person.getKey() == null ? person.getName() : person.getKey();
709 }
710
Selim Cinek20d1ee22020-02-03 16:04:26 -0500711 /**
712 * Creates new messages, reusing existing ones if they are available.
713 *
714 * @param newMessages the messages to parse.
715 */
716 private List<MessagingMessage> createMessages(
717 List<Notification.MessagingStyle.Message> newMessages, boolean historic) {
718 List<MessagingMessage> result = new ArrayList<>();
719 for (int i = 0; i < newMessages.size(); i++) {
720 Notification.MessagingStyle.Message m = newMessages.get(i);
721 MessagingMessage message = findAndRemoveMatchingMessage(m);
722 if (message == null) {
723 message = MessagingMessage.createMessage(this, m, mImageResolver);
724 }
725 message.setIsHistoric(historic);
726 result.add(message);
727 }
728 return result;
729 }
730
731 private MessagingMessage findAndRemoveMatchingMessage(Notification.MessagingStyle.Message m) {
732 for (int i = 0; i < mMessages.size(); i++) {
733 MessagingMessage existing = mMessages.get(i);
734 if (existing.sameAs(m)) {
735 mMessages.remove(i);
736 return existing;
737 }
738 }
739 for (int i = 0; i < mHistoricMessages.size(); i++) {
740 MessagingMessage existing = mHistoricMessages.get(i);
741 if (existing.sameAs(m)) {
742 mHistoricMessages.remove(i);
743 return existing;
744 }
745 }
746 return null;
747 }
748
749 public void showHistoricMessages(boolean show) {
750 mShowHistoricMessages = show;
751 updateHistoricMessageVisibility();
752 }
753
754 private void updateHistoricMessageVisibility() {
755 int numHistoric = mHistoricMessages.size();
756 for (int i = 0; i < numHistoric; i++) {
757 MessagingMessage existing = mHistoricMessages.get(i);
758 existing.setVisibility(mShowHistoricMessages ? VISIBLE : GONE);
759 }
760 int numGroups = mGroups.size();
761 for (int i = 0; i < numGroups; i++) {
762 MessagingGroup group = mGroups.get(i);
763 int visibleChildren = 0;
764 List<MessagingMessage> messages = group.getMessages();
765 int numGroupMessages = messages.size();
766 for (int j = 0; j < numGroupMessages; j++) {
767 MessagingMessage message = messages.get(j);
768 if (message.getVisibility() != GONE) {
769 visibleChildren++;
770 }
771 }
772 if (visibleChildren > 0 && group.getVisibility() == GONE) {
773 group.setVisibility(VISIBLE);
774 } else if (visibleChildren == 0 && group.getVisibility() != GONE) {
775 group.setVisibility(GONE);
776 }
777 }
778 }
779
780 @Override
781 protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
782 super.onLayout(changed, left, top, right, bottom);
783 if (!mAddedGroups.isEmpty()) {
784 getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
785 @Override
786 public boolean onPreDraw() {
787 for (MessagingGroup group : mAddedGroups) {
788 if (!group.isShown()) {
789 continue;
790 }
791 MessagingPropertyAnimator.fadeIn(group.getAvatar());
792 MessagingPropertyAnimator.fadeIn(group.getSenderView());
793 MessagingPropertyAnimator.startLocalTranslationFrom(group,
794 group.getHeight(), LINEAR_OUT_SLOW_IN);
795 }
796 mAddedGroups.clear();
797 getViewTreeObserver().removeOnPreDrawListener(this);
798 return true;
799 }
800 });
801 }
802 }
803
804 public MessagingLinearLayout getMessagingLinearLayout() {
805 return mMessagingLinearLayout;
806 }
807
808 public ArrayList<MessagingGroup> getMessagingGroups() {
809 return mGroups;
810 }
811
812 private void updateExpandButton() {
813 int drawableId;
814 int contentDescriptionId;
815 int gravity;
816 int topMargin = 0;
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800817 ViewGroup newContainer;
818 int newContainerHeight;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500819 if (mIsCollapsed) {
820 drawableId = R.drawable.ic_expand_notification;
821 contentDescriptionId = R.string.expand_button_content_description_collapsed;
822 gravity = Gravity.CENTER;
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800823 newContainer = mExpandButtonAndContentContainer;
824 newContainerHeight = LayoutParams.MATCH_PARENT;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500825 } else {
826 drawableId = R.drawable.ic_collapse_notification;
827 contentDescriptionId = R.string.expand_button_content_description_expanded;
828 gravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP;
829 topMargin = mExpandButtonExpandedTopMargin;
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800830 newContainer = this;
831 newContainerHeight = mExpandButtonExpandedSize;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500832 }
833 mExpandButton.setImageDrawable(getContext().getDrawable(drawableId));
834 mExpandButton.setColorFilter(mExpandButton.getOriginalNotificationColor());
835
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800836 // We need to make sure that the expand button is in the linearlayout pushing over the
837 // content when collapsed, but allows the content to flow under it when expanded.
838 if (newContainer != mExpandButtonContainer.getParent()) {
839 ((ViewGroup) mExpandButtonContainer.getParent()).removeView(mExpandButtonContainer);
840 newContainer.addView(mExpandButtonContainer);
841 MarginLayoutParams layoutParams =
842 (MarginLayoutParams) mExpandButtonContainer.getLayoutParams();
843 layoutParams.height = newContainerHeight;
844 mExpandButtonContainer.setLayoutParams(layoutParams);
845 }
846
Selim Cinek20d1ee22020-02-03 16:04:26 -0500847 // update if the expand button is centered
848 FrameLayout.LayoutParams layoutParams = (LayoutParams) mExpandButton.getLayoutParams();
849 layoutParams.gravity = gravity;
850 layoutParams.topMargin = topMargin;
851 mExpandButton.setLayoutParams(layoutParams);
852
853 mExpandButtonContainer.setContentDescription(mContext.getText(contentDescriptionId));
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800854
Selim Cinek20d1ee22020-02-03 16:04:26 -0500855 }
856
857 public void updateExpandability(boolean expandable, @Nullable OnClickListener onClickListener) {
858 if (expandable) {
859 mExpandButtonContainer.setVisibility(VISIBLE);
860 mExpandButtonContainer.setOnClickListener(onClickListener);
861 } else {
862 // TODO: handle content paddings to end of layout
863 mExpandButtonContainer.setVisibility(GONE);
864 }
865 }
866}