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