blob: 4028fda6c1df6f68fbe362193431cf5cc61090bf [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
Selim Cinek514b52c2020-03-17 18:42:12 -070019import static com.android.internal.widget.MessagingGroup.IMAGE_DISPLAY_LOCATION_EXTERNAL;
20import static com.android.internal.widget.MessagingGroup.IMAGE_DISPLAY_LOCATION_INLINE;
21
Selim Cinek20d1ee22020-02-03 16:04:26 -050022import android.annotation.AttrRes;
23import android.annotation.NonNull;
24import android.annotation.Nullable;
25import android.annotation.StyleRes;
26import android.app.Notification;
27import android.app.Person;
28import android.app.RemoteInputHistoryItem;
29import android.content.Context;
Selim Cineke9714eb2020-03-09 11:21:49 -070030import android.content.res.ColorStateList;
Selim Cinek20d1ee22020-02-03 16:04:26 -050031import android.graphics.Bitmap;
32import android.graphics.Canvas;
33import android.graphics.Color;
34import android.graphics.Paint;
35import android.graphics.Rect;
36import android.graphics.drawable.Icon;
37import android.os.Bundle;
38import android.os.Parcelable;
39import android.text.TextUtils;
40import android.util.ArrayMap;
Selim Cinek514b52c2020-03-17 18:42:12 -070041import android.util.ArraySet;
Selim Cinek20d1ee22020-02-03 16:04:26 -050042import android.util.AttributeSet;
43import android.util.DisplayMetrics;
44import android.view.Gravity;
45import android.view.RemotableViewMethod;
46import android.view.View;
47import android.view.ViewGroup;
48import android.view.ViewTreeObserver;
49import android.view.animation.Interpolator;
50import android.view.animation.PathInterpolator;
51import android.widget.FrameLayout;
52import android.widget.ImageView;
Selim Cinek514b52c2020-03-17 18:42:12 -070053import android.widget.LinearLayout;
Selim Cinek20d1ee22020-02-03 16:04:26 -050054import android.widget.RemoteViews;
55import android.widget.TextView;
56
57import com.android.internal.R;
58import com.android.internal.graphics.ColorUtils;
59import com.android.internal.util.ContrastColorUtil;
60
61import java.util.ArrayList;
62import java.util.List;
63import java.util.function.Consumer;
64import java.util.regex.Pattern;
65
66/**
67 * A custom-built layout for the Notification.MessagingStyle allows dynamic addition and removal
68 * messages and adapts the layout accordingly.
69 */
70@RemoteViews.RemoteView
71public class ConversationLayout extends FrameLayout
72 implements ImageMessageConsumer, IMessagingLayout {
73
Selim Cinek20d1ee22020-02-03 16:04:26 -050074 private static final float COLOR_SHIFT_AMOUNT = 60;
75 /**
76 * Pattren for filter some ingonable characters.
77 * p{Z} for any kind of whitespace or invisible separator.
78 * p{C} for any kind of punctuation character.
79 */
80 private static final Pattern IGNORABLE_CHAR_PATTERN
81 = Pattern.compile("[\\p{C}\\p{Z}]");
82 private static final Pattern SPECIAL_CHAR_PATTERN
83 = Pattern.compile ("[!@#$%&*()_+=|<>?{}\\[\\]~-]");
84 private static final Consumer<MessagingMessage> REMOVE_MESSAGE
85 = MessagingMessage::removeMessage;
86 public static final Interpolator LINEAR_OUT_SLOW_IN = new PathInterpolator(0f, 0f, 0.2f, 1f);
87 public static final Interpolator FAST_OUT_LINEAR_IN = new PathInterpolator(0.4f, 0f, 1f, 1f);
88 public static final Interpolator FAST_OUT_SLOW_IN = new PathInterpolator(0.4f, 0f, 0.2f, 1f);
89 public static final OnLayoutChangeListener MESSAGING_PROPERTY_ANIMATOR
90 = new MessagingPropertyAnimator();
91 private List<MessagingMessage> mMessages = new ArrayList<>();
92 private List<MessagingMessage> mHistoricMessages = new ArrayList<>();
93 private MessagingLinearLayout mMessagingLinearLayout;
94 private boolean mShowHistoricMessages;
95 private ArrayList<MessagingGroup> mGroups = new ArrayList<>();
96 private TextView mTitleView;
97 private int mLayoutColor;
98 private int mSenderTextColor;
99 private int mMessageTextColor;
100 private int mAvatarSize;
101 private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
102 private Paint mTextPaint = new Paint();
103 private Icon mAvatarReplacement;
104 private boolean mIsOneToOne;
105 private ArrayList<MessagingGroup> mAddedGroups = new ArrayList<>();
106 private Person mUser;
107 private CharSequence mNameReplacement;
108 private boolean mIsCollapsed;
109 private ImageResolver mImageResolver;
110 private ImageView mConversationIcon;
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700111 private TextView mConversationText;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500112 private View mConversationIconBadge;
113 private Icon mLargeIcon;
114 private View mExpandButtonContainer;
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800115 private ViewGroup mExpandButtonAndContentContainer;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500116 private NotificationExpandButton mExpandButton;
Selim Cinek514b52c2020-03-17 18:42:12 -0700117 private MessagingLinearLayout mImageMessageContainer;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500118 private int mExpandButtonExpandedTopMargin;
119 private int mBadgedSideMargins;
120 private int mIconSizeBadged;
121 private int mIconSizeCentered;
Selim Cinekf2b8aa72020-03-05 15:29:02 -0800122 private CachingIconView mIcon;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500123 private int mExpandedGroupTopMargin;
Selim Cinek8baa70f2020-03-09 20:09:35 -0700124 private View mConversationFacePile;
125 private int mNotificationBackgroundColor;
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700126 private CharSequence mFallbackChatName;
127 private CharSequence mFallbackGroupChatName;
128 private CharSequence mConversationTitle;
Selim Cinekafc20582020-03-13 20:15:38 -0700129 private int mNotificationHeaderExpandedPadding;
130 private View mConversationHeader;
131 private View mContentContainer;
132 private boolean mExpandable = true;
133 private int mContentMarginEnd;
Selim Cinek514b52c2020-03-17 18:42:12 -0700134 private Rect mMessagingClipRect;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500135
136 public ConversationLayout(@NonNull Context context) {
137 super(context);
138 }
139
140 public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
141 super(context, attrs);
142 }
143
144 public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs,
145 @AttrRes int defStyleAttr) {
146 super(context, attrs, defStyleAttr);
147 }
148
149 public ConversationLayout(@NonNull Context context, @Nullable AttributeSet attrs,
150 @AttrRes int defStyleAttr, @StyleRes int defStyleRes) {
151 super(context, attrs, defStyleAttr, defStyleRes);
152 }
153
154 @Override
155 protected void onFinishInflate() {
156 super.onFinishInflate();
157 mMessagingLinearLayout = findViewById(R.id.notification_messaging);
158 mMessagingLinearLayout.setMessagingLayout(this);
Selim Cinek514b52c2020-03-17 18:42:12 -0700159 mImageMessageContainer = findViewById(R.id.conversation_image_message_container);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500160 // We still want to clip, but only on the top, since views can temporarily out of bounds
161 // during transitions.
162 DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
163 int size = Math.max(displayMetrics.widthPixels, displayMetrics.heightPixels);
Selim Cinek514b52c2020-03-17 18:42:12 -0700164 mMessagingClipRect = new Rect(0, 0, size, size);
165 setMessagingClippingDisabled(false);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500166 mTitleView = findViewById(R.id.title);
167 mAvatarSize = getResources().getDimensionPixelSize(R.dimen.messaging_avatar_size);
168 mTextPaint.setTextAlign(Paint.Align.CENTER);
169 mTextPaint.setAntiAlias(true);
170 mConversationIcon = findViewById(R.id.conversation_icon);
171 mIcon = findViewById(R.id.icon);
172 mConversationIconBadge = findViewById(R.id.conversation_icon_badge);
Selim Cinekf2b8aa72020-03-05 15:29:02 -0800173 mIcon.setOnVisibilityChangedListener((visibility) -> {
174 // Always keep the badge visibility in sync with the icon. This is necessary in cases
175 // Where the icon is being hidden externally like in group children.
176 mConversationIconBadge.setVisibility(visibility);
177 });
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700178 mConversationText = findViewById(R.id.conversation_text);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500179 mExpandButtonContainer = findViewById(R.id.expand_button_container);
Selim Cinekafc20582020-03-13 20:15:38 -0700180 mConversationHeader = findViewById(R.id.conversation_header);
181 mContentContainer = findViewById(R.id.notification_action_list_margin_target);
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800182 mExpandButtonAndContentContainer = findViewById(R.id.expand_button_and_content_container);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500183 mExpandButton = findViewById(R.id.expand_button);
184 mExpandButtonExpandedTopMargin = getResources().getDimensionPixelSize(
185 R.dimen.conversation_expand_button_top_margin_expanded);
Selim Cinekafc20582020-03-13 20:15:38 -0700186 mNotificationHeaderExpandedPadding = getResources().getDimensionPixelSize(
187 R.dimen.conversation_header_expanded_padding_end);
188 mContentMarginEnd = getResources().getDimensionPixelSize(
189 R.dimen.notification_content_margin_end);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500190 mBadgedSideMargins = getResources().getDimensionPixelSize(
191 R.dimen.conversation_badge_side_margin);
192 mIconSizeBadged = getResources().getDimensionPixelSize(
193 R.dimen.conversation_icon_size_badged);
194 mIconSizeCentered = getResources().getDimensionPixelSize(
195 R.dimen.conversation_icon_size_centered);
196 mExpandedGroupTopMargin = getResources().getDimensionPixelSize(
197 R.dimen.conversation_icon_margin_top_centered);
Selim Cinek8baa70f2020-03-09 20:09:35 -0700198 mConversationFacePile = findViewById(R.id.conversation_face_pile);
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700199 mFallbackChatName = getResources().getString(
200 R.string.conversation_title_fallback_one_to_one);
201 mFallbackGroupChatName = getResources().getString(
202 R.string.conversation_title_fallback_group_chat);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500203 }
204
205 @RemotableViewMethod
206 public void setAvatarReplacement(Icon icon) {
207 mAvatarReplacement = icon;
208 }
209
210 @RemotableViewMethod
211 public void setNameReplacement(CharSequence nameReplacement) {
212 mNameReplacement = nameReplacement;
213 }
214
215 /**
216 * Set this layout to show the collapsed representation.
217 *
218 * @param isCollapsed is it collapsed
219 */
220 @RemotableViewMethod
221 public void setIsCollapsed(boolean isCollapsed) {
222 mIsCollapsed = isCollapsed;
223 mMessagingLinearLayout.setMaxDisplayedLines(isCollapsed ? 1 : Integer.MAX_VALUE);
224 updateExpandButton();
Selim Cinekafc20582020-03-13 20:15:38 -0700225 updateContentPaddings();
Selim Cinek20d1ee22020-02-03 16:04:26 -0500226 }
227
228 @RemotableViewMethod
229 public void setData(Bundle extras) {
230 Parcelable[] messages = extras.getParcelableArray(Notification.EXTRA_MESSAGES);
231 List<Notification.MessagingStyle.Message> newMessages
232 = Notification.MessagingStyle.Message.getMessagesFromBundleArray(messages);
233 Parcelable[] histMessages = extras.getParcelableArray(Notification.EXTRA_HISTORIC_MESSAGES);
234 List<Notification.MessagingStyle.Message> newHistoricMessages
235 = Notification.MessagingStyle.Message.getMessagesFromBundleArray(histMessages);
236
237 // mUser now set (would be nice to avoid the side effect but WHATEVER)
238 setUser(extras.getParcelable(Notification.EXTRA_MESSAGING_PERSON));
239
240
241 // Append remote input history to newMessages (again, side effect is lame but WHATEVS)
242 RemoteInputHistoryItem[] history = (RemoteInputHistoryItem[])
243 extras.getParcelableArray(Notification.EXTRA_REMOTE_INPUT_HISTORY_ITEMS);
244 addRemoteInputHistoryToMessages(newMessages, history);
245
246 boolean showSpinner =
247 extras.getBoolean(Notification.EXTRA_SHOW_REMOTE_INPUT_SPINNER, false);
248
249 // bind it, baby
250 bind(newMessages, newHistoricMessages, showSpinner);
251 }
252
253 @Override
254 public void setImageResolver(ImageResolver resolver) {
255 mImageResolver = resolver;
256 }
257
258 private void addRemoteInputHistoryToMessages(
259 List<Notification.MessagingStyle.Message> newMessages,
260 RemoteInputHistoryItem[] remoteInputHistory) {
261 if (remoteInputHistory == null || remoteInputHistory.length == 0) {
262 return;
263 }
264 for (int i = remoteInputHistory.length - 1; i >= 0; i--) {
265 RemoteInputHistoryItem historyMessage = remoteInputHistory[i];
266 Notification.MessagingStyle.Message message = new Notification.MessagingStyle.Message(
267 historyMessage.getText(), 0, (Person) null, true /* remoteHistory */);
268 if (historyMessage.getUri() != null) {
269 message.setData(historyMessage.getMimeType(), historyMessage.getUri());
270 }
271 newMessages.add(message);
272 }
273 }
274
275 private void bind(List<Notification.MessagingStyle.Message> newMessages,
276 List<Notification.MessagingStyle.Message> newHistoricMessages,
277 boolean showSpinner) {
278 // convert MessagingStyle.Message to MessagingMessage, re-using ones from a previous binding
279 // if they exist
280 List<MessagingMessage> historicMessages = createMessages(newHistoricMessages,
281 true /* isHistoric */);
282 List<MessagingMessage> messages = createMessages(newMessages, false /* isHistoric */);
283
284 // Copy our groups, before they get clobbered
285 ArrayList<MessagingGroup> oldGroups = new ArrayList<>(mGroups);
286
287 // Add our new MessagingMessages to groups
288 List<List<MessagingMessage>> groups = new ArrayList<>();
289 List<Person> senders = new ArrayList<>();
290
291 // Lets first find the groups (populate `groups` and `senders`)
292 findGroups(historicMessages, messages, groups, senders);
293
294 // Let's now create the views and reorder them accordingly
295 // side-effect: updates mGroups, mAddedGroups
296 createGroupViews(groups, senders, showSpinner);
297
298 // Let's first check which groups were removed altogether and remove them in one animation
299 removeGroups(oldGroups);
300
301 // Let's remove the remaining messages
302 mMessages.forEach(REMOVE_MESSAGE);
303 mHistoricMessages.forEach(REMOVE_MESSAGE);
304
305 mMessages = messages;
306 mHistoricMessages = historicMessages;
307
308 updateHistoricMessageVisibility();
309 updateTitleAndNamesDisplay();
310
Selim Cinek857f2792020-03-03 19:06:21 -0800311 updateConversationLayout();
Selim Cinek20d1ee22020-02-03 16:04:26 -0500312
313 }
314
Selim Cinek857f2792020-03-03 19:06:21 -0800315 /**
316 * Update the layout according to the data provided (i.e mIsOneToOne, expanded etc);
317 */
318 private void updateConversationLayout() {
Selim Cinek20d1ee22020-02-03 16:04:26 -0500319 // TODO: resolve this from shortcuts
320 // Set avatar and name
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700321 CharSequence conversationText = mConversationTitle;
322 // TODO: display the secondary text somewhere
Selim Cinek20d1ee22020-02-03 16:04:26 -0500323 if (mIsOneToOne) {
324 // Let's resolve the icon / text from the last sender
325 mConversationIcon.setVisibility(VISIBLE);
Selim Cinek8baa70f2020-03-09 20:09:35 -0700326 mConversationFacePile.setVisibility(GONE);
Selim Cinek857f2792020-03-03 19:06:21 -0800327 CharSequence userKey = getKey(mUser);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500328 for (int i = mGroups.size() - 1; i >= 0; i--) {
329 MessagingGroup messagingGroup = mGroups.get(i);
330 Person messageSender = messagingGroup.getSender();
Selim Cinek857f2792020-03-03 19:06:21 -0800331 if ((messageSender != null && !TextUtils.equals(userKey, getKey(messageSender)))
332 || i == 0) {
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700333 if (TextUtils.isEmpty(conversationText)) {
334 // We use the sendername as header text if no conversation title is provided
335 // (This usually happens for most 1:1 conversations)
336 conversationText = messagingGroup.getSenderName();
337 }
338 Icon avatarIcon = messagingGroup.getAvatarIcon();
339 if (avatarIcon == null) {
340 avatarIcon = createAvatarSymbol(conversationText, "", mLayoutColor);
341 }
342 mConversationIcon.setImageIcon(avatarIcon);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500343 break;
344 }
345 }
Selim Cinek20d1ee22020-02-03 16:04:26 -0500346 } else {
Selim Cinek20d1ee22020-02-03 16:04:26 -0500347 if (mIsCollapsed) {
Selim Cinek20d1ee22020-02-03 16:04:26 -0500348 if (mLargeIcon != null) {
Selim Cinek8baa70f2020-03-09 20:09:35 -0700349 mConversationIcon.setVisibility(VISIBLE);
350 mConversationFacePile.setVisibility(GONE);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500351 mConversationIcon.setImageIcon(mLargeIcon);
352 } else {
Selim Cinek8baa70f2020-03-09 20:09:35 -0700353 mConversationIcon.setVisibility(GONE);
354 // This will also inflate it!
355 mConversationFacePile.setVisibility(VISIBLE);
356 mConversationFacePile = findViewById(R.id.conversation_face_pile);
357 bindFacePile();
Selim Cinek20d1ee22020-02-03 16:04:26 -0500358 }
359 } else {
Selim Cinek8baa70f2020-03-09 20:09:35 -0700360 mConversationFacePile.setVisibility(GONE);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500361 mConversationIcon.setVisibility(GONE);
362 }
363 }
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700364 if (TextUtils.isEmpty(conversationText)) {
365 conversationText = mIsOneToOne ? mFallbackChatName : mFallbackGroupChatName;
366 }
367 mConversationText.setText(conversationText);
Selim Cinek857f2792020-03-03 19:06:21 -0800368 // Update if the groups can hide the sender if they are first (applies to 1:1 conversations)
369 // This needs to happen after all of the above o update all of the groups
370 for (int i = mGroups.size() - 1; i >= 0; i--) {
371 MessagingGroup messagingGroup = mGroups.get(i);
372 CharSequence messageSender = messagingGroup.getSenderName();
373 boolean canHide = mIsOneToOne
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700374 && TextUtils.equals(conversationText, messageSender);
Selim Cinek857f2792020-03-03 19:06:21 -0800375 messagingGroup.setCanHideSenderIfFirst(canHide);
376 }
377 updateIconPositionAndSize();
Selim Cinek514b52c2020-03-17 18:42:12 -0700378 updateImageMessages();
379 }
380
381 private void updateImageMessages() {
382 boolean displayExternalImage = false;
383 ArraySet<View> newMessages = new ArraySet<>();
384 if (mIsCollapsed) {
385
386 // When collapsed, we're displaying all image messages in a dedicated container
387 // on the right of the layout instead of inline. Let's add all isolated images there
388 int imageIndex = 0;
389 for (int i = 0; i < mGroups.size(); i++) {
390 MessagingGroup messagingGroup = mGroups.get(i);
391 MessagingImageMessage isolatedMessage = messagingGroup.getIsolatedMessage();
392 if (isolatedMessage != null) {
393 newMessages.add(isolatedMessage.getView());
394 displayExternalImage = true;
395 if (imageIndex
396 != mImageMessageContainer.indexOfChild(isolatedMessage.getView())) {
397 mImageMessageContainer.removeView(isolatedMessage.getView());
398 mImageMessageContainer.addView(isolatedMessage.getView(), imageIndex);
399 }
400 imageIndex++;
401 }
402 }
403 }
404 // Remove all messages that don't belong into the image layout
405 for (int i = 0; i < mImageMessageContainer.getChildCount(); i++) {
406 View child = mImageMessageContainer.getChildAt(i);
407 if (!newMessages.contains(child)) {
408 mImageMessageContainer.removeView(child);
409 i--;
410 }
411 }
412 mImageMessageContainer.setVisibility(displayExternalImage ? VISIBLE : GONE);
Selim Cinek857f2792020-03-03 19:06:21 -0800413 }
414
Selim Cinek8baa70f2020-03-09 20:09:35 -0700415 private void bindFacePile() {
416 // Let's bind the face pile
417 View bottomBackground = mConversationFacePile.findViewById(
418 R.id.conversation_face_pile_bottom_background);
419 applyNotificationBackgroundColor(bottomBackground);
420 ImageView bottomView = mConversationFacePile.findViewById(
421 R.id.conversation_face_pile_bottom);
422 ImageView topView = mConversationFacePile.findViewById(
423 R.id.conversation_face_pile_top);
424 // Let's find the two last conversations:
425 Icon secondLastIcon = null;
426 CharSequence lastKey = null;
427 Icon lastIcon = null;
428 CharSequence userKey = getKey(mUser);
429 for (int i = mGroups.size() - 1; i >= 0; i--) {
430 MessagingGroup messagingGroup = mGroups.get(i);
431 Person messageSender = messagingGroup.getSender();
432 boolean notUser = messageSender != null
433 && !TextUtils.equals(userKey, getKey(messageSender));
434 boolean notIncluded = messageSender != null
435 && !TextUtils.equals(lastKey, getKey(messageSender));
436 if ((notUser && notIncluded)
437 || (i == 0 && lastKey == null)) {
438 if (lastIcon == null) {
439 lastIcon = messagingGroup.getAvatarIcon();
440 lastKey = getKey(messageSender);
441 } else {
442 secondLastIcon = messagingGroup.getAvatarIcon();
443 break;
444 }
445 }
446 }
447 if (lastIcon == null) {
448 lastIcon = createAvatarSymbol(" ", "", mLayoutColor);
449 }
450 bottomView.setImageIcon(lastIcon);
451 if (secondLastIcon == null) {
452 secondLastIcon = createAvatarSymbol("", "", mLayoutColor);
453 }
454 topView.setImageIcon(secondLastIcon);
455 }
456
Selim Cinek857f2792020-03-03 19:06:21 -0800457 /**
458 * update the icon position and sizing
459 */
460 private void updateIconPositionAndSize() {
Selim Cinek20d1ee22020-02-03 16:04:26 -0500461 int gravity;
462 int marginStart;
463 int marginTop;
464 int iconSize;
465 if (mIsOneToOne || mIsCollapsed) {
466 // Baded format
467 gravity = Gravity.LEFT;
468 marginStart = mBadgedSideMargins;
469 marginTop = mBadgedSideMargins;
470 iconSize = mIconSizeBadged;
471 } else {
472 gravity = Gravity.CENTER_HORIZONTAL;
473 marginStart = 0;
474 marginTop = mExpandedGroupTopMargin;
475 iconSize = mIconSizeCentered;
476 }
Selim Cinek857f2792020-03-03 19:06:21 -0800477 LayoutParams layoutParams =
Selim Cinek20d1ee22020-02-03 16:04:26 -0500478 (LayoutParams) mConversationIconBadge.getLayoutParams();
479 layoutParams.gravity = gravity;
480 layoutParams.topMargin = marginTop;
481 layoutParams.setMarginStart(marginStart);
482 mConversationIconBadge.setLayoutParams(layoutParams);
483 ViewGroup.LayoutParams iconParams = mIcon.getLayoutParams();
484 iconParams.width = iconSize;
485 iconParams.height = iconSize;
486 mIcon.setLayoutParams(iconParams);
487 }
488
489 @RemotableViewMethod
490 public void setLargeIcon(Icon largeIcon) {
491 mLargeIcon = largeIcon;
492 }
493
Selim Cinek2f7f7b82020-03-10 15:41:13 -0700494 /**
495 * Sets the conversation title of this conversation.
496 *
497 * @param conversationTitle the conversation title
498 */
499 @RemotableViewMethod
500 public void setConversationTitle(CharSequence conversationTitle) {
501 mConversationTitle = conversationTitle;
502 }
503
Selim Cinek20d1ee22020-02-03 16:04:26 -0500504 private void removeGroups(ArrayList<MessagingGroup> oldGroups) {
505 int size = oldGroups.size();
506 for (int i = 0; i < size; i++) {
507 MessagingGroup group = oldGroups.get(i);
508 if (!mGroups.contains(group)) {
509 List<MessagingMessage> messages = group.getMessages();
510 Runnable endRunnable = () -> {
511 mMessagingLinearLayout.removeTransientView(group);
512 group.recycle();
513 };
514
515 boolean wasShown = group.isShown();
516 mMessagingLinearLayout.removeView(group);
517 if (wasShown && !MessagingLinearLayout.isGone(group)) {
518 mMessagingLinearLayout.addTransientView(group, 0);
519 group.removeGroupAnimated(endRunnable);
520 } else {
521 endRunnable.run();
522 }
523 mMessages.removeAll(messages);
524 mHistoricMessages.removeAll(messages);
525 }
526 }
527 }
528
529 private void updateTitleAndNamesDisplay() {
530 ArrayMap<CharSequence, String> uniqueNames = new ArrayMap<>();
531 ArrayMap<Character, CharSequence> uniqueCharacters = new ArrayMap<>();
532 for (int i = 0; i < mGroups.size(); i++) {
533 MessagingGroup group = mGroups.get(i);
534 CharSequence senderName = group.getSenderName();
535 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) {
536 continue;
537 }
538 if (!uniqueNames.containsKey(senderName)) {
539 // Only use visible characters to get uniqueNames
540 String pureSenderName = IGNORABLE_CHAR_PATTERN
541 .matcher(senderName).replaceAll("" /* replacement */);
542 char c = pureSenderName.charAt(0);
543 if (uniqueCharacters.containsKey(c)) {
544 // this character was already used, lets make it more unique. We first need to
545 // resolve the existing character if it exists
546 CharSequence existingName = uniqueCharacters.get(c);
547 if (existingName != null) {
548 uniqueNames.put(existingName, findNameSplit((String) existingName));
549 uniqueCharacters.put(c, null);
550 }
551 uniqueNames.put(senderName, findNameSplit((String) senderName));
552 } else {
553 uniqueNames.put(senderName, Character.toString(c));
554 uniqueCharacters.put(c, pureSenderName);
555 }
556 }
557 }
558
559 // Now that we have the correct symbols, let's look what we have cached
560 ArrayMap<CharSequence, Icon> cachedAvatars = new ArrayMap<>();
561 for (int i = 0; i < mGroups.size(); i++) {
562 // Let's now set the avatars
563 MessagingGroup group = mGroups.get(i);
564 boolean isOwnMessage = group.getSender() == mUser;
565 CharSequence senderName = group.getSenderName();
566 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)
567 || (mIsOneToOne && mAvatarReplacement != null && !isOwnMessage)) {
568 continue;
569 }
570 String symbol = uniqueNames.get(senderName);
571 Icon cachedIcon = group.getAvatarSymbolIfMatching(senderName,
572 symbol, mLayoutColor);
573 if (cachedIcon != null) {
574 cachedAvatars.put(senderName, cachedIcon);
575 }
576 }
577
578 for (int i = 0; i < mGroups.size(); i++) {
579 // Let's now set the avatars
580 MessagingGroup group = mGroups.get(i);
581 CharSequence senderName = group.getSenderName();
582 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) {
583 continue;
584 }
585 if (mIsOneToOne && mAvatarReplacement != null && group.getSender() != mUser) {
586 group.setAvatar(mAvatarReplacement);
587 } else {
588 Icon cachedIcon = cachedAvatars.get(senderName);
589 if (cachedIcon == null) {
590 cachedIcon = createAvatarSymbol(senderName, uniqueNames.get(senderName),
591 mLayoutColor);
592 cachedAvatars.put(senderName, cachedIcon);
593 }
594 group.setCreatedAvatar(cachedIcon, senderName, uniqueNames.get(senderName),
595 mLayoutColor);
596 }
597 }
598 }
599
600 private Icon createAvatarSymbol(CharSequence senderName, String symbol, int layoutColor) {
601 if (symbol.isEmpty() || TextUtils.isDigitsOnly(symbol) ||
602 SPECIAL_CHAR_PATTERN.matcher(symbol).find()) {
603 Icon avatarIcon = Icon.createWithResource(getContext(),
604 R.drawable.messaging_user);
605 avatarIcon.setTint(findColor(senderName, layoutColor));
606 return avatarIcon;
607 } else {
608 Bitmap bitmap = Bitmap.createBitmap(mAvatarSize, mAvatarSize, Bitmap.Config.ARGB_8888);
609 Canvas canvas = new Canvas(bitmap);
610 float radius = mAvatarSize / 2.0f;
611 int color = findColor(senderName, layoutColor);
612 mPaint.setColor(color);
613 canvas.drawCircle(radius, radius, radius, mPaint);
614 boolean needDarkText = ColorUtils.calculateLuminance(color) > 0.5f;
615 mTextPaint.setColor(needDarkText ? Color.BLACK : Color.WHITE);
616 mTextPaint.setTextSize(symbol.length() == 1 ? mAvatarSize * 0.5f : mAvatarSize * 0.3f);
617 int yPos = (int) (radius - ((mTextPaint.descent() + mTextPaint.ascent()) / 2));
618 canvas.drawText(symbol, radius, yPos, mTextPaint);
619 return Icon.createWithBitmap(bitmap);
620 }
621 }
622
623 private int findColor(CharSequence senderName, int layoutColor) {
624 double luminance = ContrastColorUtil.calculateLuminance(layoutColor);
625 float shift = Math.abs(senderName.hashCode()) % 5 / 4.0f - 0.5f;
626
627 // we need to offset the range if the luminance is too close to the borders
628 shift += Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - luminance, 0);
629 shift -= Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - (1.0f - luminance), 0);
630 return ContrastColorUtil.getShiftedColor(layoutColor,
631 (int) (shift * COLOR_SHIFT_AMOUNT));
632 }
633
634 private String findNameSplit(String existingName) {
635 String[] split = existingName.split(" ");
636 if (split.length > 1) {
637 return Character.toString(split[0].charAt(0))
638 + Character.toString(split[1].charAt(0));
639 }
640 return existingName.substring(0, 1);
641 }
642
643 @RemotableViewMethod
644 public void setLayoutColor(int color) {
645 mLayoutColor = color;
646 }
647
648 @RemotableViewMethod
649 public void setIsOneToOne(boolean oneToOne) {
650 mIsOneToOne = oneToOne;
651 }
652
653 @RemotableViewMethod
654 public void setSenderTextColor(int color) {
655 mSenderTextColor = color;
656 }
657
Selim Cineke9714eb2020-03-09 11:21:49 -0700658 /**
659 * @param color the color of the notification background
660 */
661 @RemotableViewMethod
662 public void setNotificationBackgroundColor(int color) {
Selim Cinek8baa70f2020-03-09 20:09:35 -0700663 mNotificationBackgroundColor = color;
664 applyNotificationBackgroundColor(mConversationIconBadge);
665 }
666
667 private void applyNotificationBackgroundColor(View view) {
668 view.setBackgroundTintList(ColorStateList.valueOf(mNotificationBackgroundColor));
Selim Cineke9714eb2020-03-09 11:21:49 -0700669 }
670
Selim Cinek20d1ee22020-02-03 16:04:26 -0500671 @RemotableViewMethod
672 public void setMessageTextColor(int color) {
673 mMessageTextColor = color;
674 }
675
676 private void setUser(Person user) {
677 mUser = user;
678 if (mUser.getIcon() == null) {
679 Icon userIcon = Icon.createWithResource(getContext(),
680 R.drawable.messaging_user);
681 userIcon.setTint(mLayoutColor);
682 mUser = mUser.toBuilder().setIcon(userIcon).build();
683 }
684 }
685
686 private void createGroupViews(List<List<MessagingMessage>> groups,
687 List<Person> senders, boolean showSpinner) {
688 mGroups.clear();
689 for (int groupIndex = 0; groupIndex < groups.size(); groupIndex++) {
690 List<MessagingMessage> group = groups.get(groupIndex);
691 MessagingGroup newGroup = null;
692 // we'll just take the first group that exists or create one there is none
693 for (int messageIndex = group.size() - 1; messageIndex >= 0; messageIndex--) {
694 MessagingMessage message = group.get(messageIndex);
695 newGroup = message.getGroup();
696 if (newGroup != null) {
697 break;
698 }
699 }
700 // Create a new group, adding it to the linear layout as well
701 if (newGroup == null) {
702 newGroup = MessagingGroup.createGroup(mMessagingLinearLayout);
703 mAddedGroups.add(newGroup);
704 }
Selim Cinek514b52c2020-03-17 18:42:12 -0700705 newGroup.setImageDisplayLocation(mIsCollapsed
706 ? IMAGE_DISPLAY_LOCATION_EXTERNAL
707 : IMAGE_DISPLAY_LOCATION_INLINE);
Selim Cineka91778a32020-03-13 17:30:34 -0700708 newGroup.setIsInConversation(true);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500709 newGroup.setLayoutColor(mLayoutColor);
710 newGroup.setTextColors(mSenderTextColor, mMessageTextColor);
711 Person sender = senders.get(groupIndex);
712 CharSequence nameOverride = null;
713 if (sender != mUser && mNameReplacement != null) {
714 nameOverride = mNameReplacement;
715 }
716 newGroup.setShowingAvatar(!mIsOneToOne && !mIsCollapsed);
717 newGroup.setSingleLine(mIsCollapsed);
718 newGroup.setSender(sender, nameOverride);
719 newGroup.setSending(groupIndex == (groups.size() - 1) && showSpinner);
720 mGroups.add(newGroup);
721
722 // Reposition to the correct place (if we're re-using a group)
723 if (mMessagingLinearLayout.indexOfChild(newGroup) != groupIndex) {
724 mMessagingLinearLayout.removeView(newGroup);
725 mMessagingLinearLayout.addView(newGroup, groupIndex);
726 }
727 newGroup.setMessages(group);
728 }
729 }
730
731 private void findGroups(List<MessagingMessage> historicMessages,
732 List<MessagingMessage> messages, List<List<MessagingMessage>> groups,
733 List<Person> senders) {
734 CharSequence currentSenderKey = null;
735 List<MessagingMessage> currentGroup = null;
736 int histSize = historicMessages.size();
737 for (int i = 0; i < histSize + messages.size(); i++) {
738 MessagingMessage message;
739 if (i < histSize) {
740 message = historicMessages.get(i);
741 } else {
742 message = messages.get(i - histSize);
743 }
744 boolean isNewGroup = currentGroup == null;
745 Person sender = message.getMessage().getSenderPerson();
Selim Cinek857f2792020-03-03 19:06:21 -0800746 CharSequence key = getKey(sender);
Selim Cinek20d1ee22020-02-03 16:04:26 -0500747 isNewGroup |= !TextUtils.equals(key, currentSenderKey);
748 if (isNewGroup) {
749 currentGroup = new ArrayList<>();
750 groups.add(currentGroup);
751 if (sender == null) {
752 sender = mUser;
753 }
754 senders.add(sender);
755 currentSenderKey = key;
756 }
757 currentGroup.add(message);
758 }
759 }
760
Selim Cinek857f2792020-03-03 19:06:21 -0800761 private CharSequence getKey(Person person) {
762 return person == null ? null : person.getKey() == null ? person.getName() : person.getKey();
763 }
764
Selim Cinek20d1ee22020-02-03 16:04:26 -0500765 /**
766 * Creates new messages, reusing existing ones if they are available.
767 *
768 * @param newMessages the messages to parse.
769 */
770 private List<MessagingMessage> createMessages(
771 List<Notification.MessagingStyle.Message> newMessages, boolean historic) {
772 List<MessagingMessage> result = new ArrayList<>();
773 for (int i = 0; i < newMessages.size(); i++) {
774 Notification.MessagingStyle.Message m = newMessages.get(i);
775 MessagingMessage message = findAndRemoveMatchingMessage(m);
776 if (message == null) {
777 message = MessagingMessage.createMessage(this, m, mImageResolver);
778 }
779 message.setIsHistoric(historic);
780 result.add(message);
781 }
782 return result;
783 }
784
785 private MessagingMessage findAndRemoveMatchingMessage(Notification.MessagingStyle.Message m) {
786 for (int i = 0; i < mMessages.size(); i++) {
787 MessagingMessage existing = mMessages.get(i);
788 if (existing.sameAs(m)) {
789 mMessages.remove(i);
790 return existing;
791 }
792 }
793 for (int i = 0; i < mHistoricMessages.size(); i++) {
794 MessagingMessage existing = mHistoricMessages.get(i);
795 if (existing.sameAs(m)) {
796 mHistoricMessages.remove(i);
797 return existing;
798 }
799 }
800 return null;
801 }
802
803 public void showHistoricMessages(boolean show) {
804 mShowHistoricMessages = show;
805 updateHistoricMessageVisibility();
806 }
807
808 private void updateHistoricMessageVisibility() {
809 int numHistoric = mHistoricMessages.size();
810 for (int i = 0; i < numHistoric; i++) {
811 MessagingMessage existing = mHistoricMessages.get(i);
812 existing.setVisibility(mShowHistoricMessages ? VISIBLE : GONE);
813 }
814 int numGroups = mGroups.size();
815 for (int i = 0; i < numGroups; i++) {
816 MessagingGroup group = mGroups.get(i);
817 int visibleChildren = 0;
818 List<MessagingMessage> messages = group.getMessages();
819 int numGroupMessages = messages.size();
820 for (int j = 0; j < numGroupMessages; j++) {
821 MessagingMessage message = messages.get(j);
822 if (message.getVisibility() != GONE) {
823 visibleChildren++;
824 }
825 }
826 if (visibleChildren > 0 && group.getVisibility() == GONE) {
827 group.setVisibility(VISIBLE);
828 } else if (visibleChildren == 0 && group.getVisibility() != GONE) {
829 group.setVisibility(GONE);
830 }
831 }
832 }
833
834 @Override
835 protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
836 super.onLayout(changed, left, top, right, bottom);
837 if (!mAddedGroups.isEmpty()) {
838 getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
839 @Override
840 public boolean onPreDraw() {
841 for (MessagingGroup group : mAddedGroups) {
842 if (!group.isShown()) {
843 continue;
844 }
845 MessagingPropertyAnimator.fadeIn(group.getAvatar());
846 MessagingPropertyAnimator.fadeIn(group.getSenderView());
847 MessagingPropertyAnimator.startLocalTranslationFrom(group,
848 group.getHeight(), LINEAR_OUT_SLOW_IN);
849 }
850 mAddedGroups.clear();
851 getViewTreeObserver().removeOnPreDrawListener(this);
852 return true;
853 }
854 });
855 }
856 }
857
858 public MessagingLinearLayout getMessagingLinearLayout() {
859 return mMessagingLinearLayout;
860 }
861
Selim Cinek514b52c2020-03-17 18:42:12 -0700862 public @NonNull ViewGroup getImageMessageContainer() {
863 return mImageMessageContainer;
864 }
865
Selim Cinek20d1ee22020-02-03 16:04:26 -0500866 public ArrayList<MessagingGroup> getMessagingGroups() {
867 return mGroups;
868 }
869
870 private void updateExpandButton() {
871 int drawableId;
872 int contentDescriptionId;
873 int gravity;
874 int topMargin = 0;
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800875 ViewGroup newContainer;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500876 if (mIsCollapsed) {
877 drawableId = R.drawable.ic_expand_notification;
878 contentDescriptionId = R.string.expand_button_content_description_collapsed;
879 gravity = Gravity.CENTER;
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800880 newContainer = mExpandButtonAndContentContainer;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500881 } else {
882 drawableId = R.drawable.ic_collapse_notification;
883 contentDescriptionId = R.string.expand_button_content_description_expanded;
884 gravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP;
885 topMargin = mExpandButtonExpandedTopMargin;
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800886 newContainer = this;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500887 }
888 mExpandButton.setImageDrawable(getContext().getDrawable(drawableId));
889 mExpandButton.setColorFilter(mExpandButton.getOriginalNotificationColor());
890
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800891 // We need to make sure that the expand button is in the linearlayout pushing over the
892 // content when collapsed, but allows the content to flow under it when expanded.
893 if (newContainer != mExpandButtonContainer.getParent()) {
894 ((ViewGroup) mExpandButtonContainer.getParent()).removeView(mExpandButtonContainer);
895 newContainer.addView(mExpandButtonContainer);
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800896 }
897
Selim Cinek20d1ee22020-02-03 16:04:26 -0500898 // update if the expand button is centered
Selim Cinek514b52c2020-03-17 18:42:12 -0700899 LinearLayout.LayoutParams layoutParams =
900 (LinearLayout.LayoutParams) mExpandButton.getLayoutParams();
Selim Cinek20d1ee22020-02-03 16:04:26 -0500901 layoutParams.gravity = gravity;
902 layoutParams.topMargin = topMargin;
903 mExpandButton.setLayoutParams(layoutParams);
904
905 mExpandButtonContainer.setContentDescription(mContext.getText(contentDescriptionId));
Selim Cinekafc20582020-03-13 20:15:38 -0700906 }
Selim Cinek54fe9ad2020-03-04 13:56:42 -0800907
Selim Cinekafc20582020-03-13 20:15:38 -0700908 private void updateContentPaddings() {
909
910 // Let's make sure the conversation header can't run into the expand button when we're
911 // collapsed and update the paddings of the content
912 int headerPaddingEnd;
913 int contentPaddingEnd;
914 if (!mExpandable) {
915 headerPaddingEnd = 0;
916 contentPaddingEnd = mContentMarginEnd;
917 } else if (mIsCollapsed) {
918 headerPaddingEnd = 0;
919 contentPaddingEnd = 0;
920 } else {
921 headerPaddingEnd = mNotificationHeaderExpandedPadding;
922 contentPaddingEnd = mContentMarginEnd;
923 }
924 mConversationHeader.setPaddingRelative(
925 mConversationHeader.getPaddingStart(),
926 mConversationHeader.getPaddingTop(),
927 headerPaddingEnd,
928 mConversationHeader.getPaddingBottom());
929
930 mContentContainer.setPaddingRelative(
931 mContentContainer.getPaddingStart(),
932 mContentContainer.getPaddingTop(),
933 contentPaddingEnd,
934 mContentContainer.getPaddingBottom());
Selim Cinek20d1ee22020-02-03 16:04:26 -0500935 }
936
937 public void updateExpandability(boolean expandable, @Nullable OnClickListener onClickListener) {
Selim Cinekafc20582020-03-13 20:15:38 -0700938 mExpandable = expandable;
Selim Cinek20d1ee22020-02-03 16:04:26 -0500939 if (expandable) {
940 mExpandButtonContainer.setVisibility(VISIBLE);
941 mExpandButtonContainer.setOnClickListener(onClickListener);
942 } else {
943 // TODO: handle content paddings to end of layout
944 mExpandButtonContainer.setVisibility(GONE);
945 }
Selim Cinekafc20582020-03-13 20:15:38 -0700946 updateContentPaddings();
Selim Cinek20d1ee22020-02-03 16:04:26 -0500947 }
Selim Cinek514b52c2020-03-17 18:42:12 -0700948
949 @Override
950 public void setMessagingClippingDisabled(boolean clippingDisabled) {
951 mMessagingLinearLayout.setClipBounds(clippingDisabled ? null : mMessagingClipRect);
952 }
Selim Cinek20d1ee22020-02-03 16:04:26 -0500953}