1/* 2 * Copyright (C) 2017 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.content.Context; 26import android.graphics.Bitmap; 27import android.graphics.Canvas; 28import android.graphics.Color; 29import android.graphics.Paint; 30import android.graphics.Rect; 31import android.graphics.drawable.Icon; 32import android.os.Bundle; 33import android.os.Parcelable; 34import android.text.TextUtils; 35import android.util.ArrayMap; 36import android.util.AttributeSet; 37import android.util.DisplayMetrics; 38import android.view.RemotableViewMethod; 39import android.view.ViewTreeObserver; 40import android.view.animation.Interpolator; 41import android.view.animation.PathInterpolator; 42import android.widget.FrameLayout; 43import android.widget.RemoteViews; 44import android.widget.TextView; 45 46import com.android.internal.R; 47import com.android.internal.graphics.ColorUtils; 48import com.android.internal.util.NotificationColorUtil; 49 50import java.util.ArrayList; 51import java.util.List; 52import java.util.function.Consumer; 53import java.util.regex.Pattern; 54 55/** 56 * A custom-built layout for the Notification.MessagingStyle allows dynamic addition and removal 57 * messages and adapts the layout accordingly. 58 */ 59@RemoteViews.RemoteView 60public class MessagingLayout extends FrameLayout { 61 62 private static final float COLOR_SHIFT_AMOUNT = 60; 63 private static final Pattern SPECIAL_CHAR_PATTERN 64 = Pattern.compile ("[!@#$%&*()_+=|<>?{}\\[\\]~-]"); 65 private static final Consumer<MessagingMessage> REMOVE_MESSAGE 66 = MessagingMessage::removeMessage; 67 public static final Interpolator LINEAR_OUT_SLOW_IN = new PathInterpolator(0f, 0f, 0.2f, 1f); 68 public static final Interpolator FAST_OUT_LINEAR_IN = new PathInterpolator(0.4f, 0f, 1f, 1f); 69 public static final Interpolator FAST_OUT_SLOW_IN = new PathInterpolator(0.4f, 0f, 0.2f, 1f); 70 public static final OnLayoutChangeListener MESSAGING_PROPERTY_ANIMATOR 71 = new MessagingPropertyAnimator(); 72 private List<MessagingMessage> mMessages = new ArrayList<>(); 73 private List<MessagingMessage> mHistoricMessages = new ArrayList<>(); 74 private MessagingLinearLayout mMessagingLinearLayout; 75 private boolean mShowHistoricMessages; 76 private ArrayList<MessagingGroup> mGroups = new ArrayList<>(); 77 private TextView mTitleView; 78 private int mLayoutColor; 79 private int mSenderTextColor; 80 private int mMessageTextColor; 81 private int mAvatarSize; 82 private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); 83 private Paint mTextPaint = new Paint(); 84 private CharSequence mConversationTitle; 85 private Icon mAvatarReplacement; 86 private boolean mIsOneToOne; 87 private ArrayList<MessagingGroup> mAddedGroups = new ArrayList<>(); 88 private Person mUser; 89 private CharSequence mNameReplacement; 90 private boolean mDisplayImagesAtEnd; 91 92 public MessagingLayout(@NonNull Context context) { 93 super(context); 94 } 95 96 public MessagingLayout(@NonNull Context context, @Nullable AttributeSet attrs) { 97 super(context, attrs); 98 } 99 100 public MessagingLayout(@NonNull Context context, @Nullable AttributeSet attrs, 101 @AttrRes int defStyleAttr) { 102 super(context, attrs, defStyleAttr); 103 } 104 105 public MessagingLayout(@NonNull Context context, @Nullable AttributeSet attrs, 106 @AttrRes int defStyleAttr, @StyleRes int defStyleRes) { 107 super(context, attrs, defStyleAttr, defStyleRes); 108 } 109 110 @Override 111 protected void onFinishInflate() { 112 super.onFinishInflate(); 113 mMessagingLinearLayout = findViewById(R.id.notification_messaging); 114 mMessagingLinearLayout.setMessagingLayout(this); 115 // We still want to clip, but only on the top, since views can temporarily out of bounds 116 // during transitions. 117 DisplayMetrics displayMetrics = getResources().getDisplayMetrics(); 118 int size = Math.max(displayMetrics.widthPixels, displayMetrics.heightPixels); 119 Rect rect = new Rect(0, 0, size, size); 120 mMessagingLinearLayout.setClipBounds(rect); 121 mTitleView = findViewById(R.id.title); 122 mAvatarSize = getResources().getDimensionPixelSize(R.dimen.messaging_avatar_size); 123 mTextPaint.setTextAlign(Paint.Align.CENTER); 124 mTextPaint.setAntiAlias(true); 125 } 126 127 @RemotableViewMethod 128 public void setAvatarReplacement(Icon icon) { 129 mAvatarReplacement = icon; 130 } 131 132 @RemotableViewMethod 133 public void setNameReplacement(CharSequence nameReplacement) { 134 mNameReplacement = nameReplacement; 135 } 136 137 @RemotableViewMethod 138 public void setDisplayImagesAtEnd(boolean atEnd) { 139 mDisplayImagesAtEnd = atEnd; 140 } 141 142 @RemotableViewMethod 143 public void setData(Bundle extras) { 144 Parcelable[] messages = extras.getParcelableArray(Notification.EXTRA_MESSAGES); 145 List<Notification.MessagingStyle.Message> newMessages 146 = Notification.MessagingStyle.Message.getMessagesFromBundleArray(messages); 147 Parcelable[] histMessages = extras.getParcelableArray(Notification.EXTRA_HISTORIC_MESSAGES); 148 List<Notification.MessagingStyle.Message> newHistoricMessages 149 = Notification.MessagingStyle.Message.getMessagesFromBundleArray(histMessages); 150 setUser(extras.getParcelable(Notification.EXTRA_MESSAGING_PERSON)); 151 mConversationTitle = null; 152 TextView headerText = findViewById(R.id.header_text); 153 if (headerText != null) { 154 mConversationTitle = headerText.getText(); 155 } 156 addRemoteInputHistoryToMessages(newMessages, 157 extras.getCharSequenceArray(Notification.EXTRA_REMOTE_INPUT_HISTORY)); 158 boolean showSpinner = 159 extras.getBoolean(Notification.EXTRA_SHOW_REMOTE_INPUT_SPINNER, false); 160 bind(newMessages, newHistoricMessages, showSpinner); 161 } 162 163 private void addRemoteInputHistoryToMessages( 164 List<Notification.MessagingStyle.Message> newMessages, 165 CharSequence[] remoteInputHistory) { 166 if (remoteInputHistory == null || remoteInputHistory.length == 0) { 167 return; 168 } 169 for (int i = remoteInputHistory.length - 1; i >= 0; i--) { 170 CharSequence message = remoteInputHistory[i]; 171 newMessages.add(new Notification.MessagingStyle.Message( 172 message, 0, (Person) null, true /* remoteHistory */)); 173 } 174 } 175 176 private void bind(List<Notification.MessagingStyle.Message> newMessages, 177 List<Notification.MessagingStyle.Message> newHistoricMessages, 178 boolean showSpinner) { 179 180 List<MessagingMessage> historicMessages = createMessages(newHistoricMessages, 181 true /* isHistoric */); 182 List<MessagingMessage> messages = createMessages(newMessages, false /* isHistoric */); 183 184 ArrayList<MessagingGroup> oldGroups = new ArrayList<>(mGroups); 185 addMessagesToGroups(historicMessages, messages, showSpinner); 186 187 // Let's first check which groups were removed altogether and remove them in one animation 188 removeGroups(oldGroups); 189 190 // Let's remove the remaining messages 191 mMessages.forEach(REMOVE_MESSAGE); 192 mHistoricMessages.forEach(REMOVE_MESSAGE); 193 194 mMessages = messages; 195 mHistoricMessages = historicMessages; 196 197 updateHistoricMessageVisibility(); 198 updateTitleAndNamesDisplay(); 199 } 200 201 private void removeGroups(ArrayList<MessagingGroup> oldGroups) { 202 int size = oldGroups.size(); 203 for (int i = 0; i < size; i++) { 204 MessagingGroup group = oldGroups.get(i); 205 if (!mGroups.contains(group)) { 206 List<MessagingMessage> messages = group.getMessages(); 207 Runnable endRunnable = () -> { 208 mMessagingLinearLayout.removeTransientView(group); 209 group.recycle(); 210 }; 211 212 boolean wasShown = group.isShown(); 213 mMessagingLinearLayout.removeView(group); 214 if (wasShown && !MessagingLinearLayout.isGone(group)) { 215 mMessagingLinearLayout.addTransientView(group, 0); 216 group.removeGroupAnimated(endRunnable); 217 } else { 218 endRunnable.run(); 219 } 220 mMessages.removeAll(messages); 221 mHistoricMessages.removeAll(messages); 222 } 223 } 224 } 225 226 private void updateTitleAndNamesDisplay() { 227 ArrayMap<CharSequence, String> uniqueNames = new ArrayMap<>(); 228 ArrayMap<Character, CharSequence> uniqueCharacters = new ArrayMap<>(); 229 for (int i = 0; i < mGroups.size(); i++) { 230 MessagingGroup group = mGroups.get(i); 231 CharSequence senderName = group.getSenderName(); 232 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) { 233 continue; 234 } 235 if (!uniqueNames.containsKey(senderName)) { 236 char c = senderName.charAt(0); 237 if (uniqueCharacters.containsKey(c)) { 238 // this character was already used, lets make it more unique. We first need to 239 // resolve the existing character if it exists 240 CharSequence existingName = uniqueCharacters.get(c); 241 if (existingName != null) { 242 uniqueNames.put(existingName, findNameSplit((String) existingName)); 243 uniqueCharacters.put(c, null); 244 } 245 uniqueNames.put(senderName, findNameSplit((String) senderName)); 246 } else { 247 uniqueNames.put(senderName, Character.toString(c)); 248 uniqueCharacters.put(c, senderName); 249 } 250 } 251 } 252 253 // Now that we have the correct symbols, let's look what we have cached 254 ArrayMap<CharSequence, Icon> cachedAvatars = new ArrayMap<>(); 255 for (int i = 0; i < mGroups.size(); i++) { 256 // Let's now set the avatars 257 MessagingGroup group = mGroups.get(i); 258 boolean isOwnMessage = group.getSender() == mUser; 259 CharSequence senderName = group.getSenderName(); 260 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName) 261 || (mIsOneToOne && mAvatarReplacement != null && !isOwnMessage)) { 262 continue; 263 } 264 String symbol = uniqueNames.get(senderName); 265 Icon cachedIcon = group.getAvatarSymbolIfMatching(senderName, 266 symbol, mLayoutColor); 267 if (cachedIcon != null) { 268 cachedAvatars.put(senderName, cachedIcon); 269 } 270 } 271 272 for (int i = 0; i < mGroups.size(); i++) { 273 // Let's now set the avatars 274 MessagingGroup group = mGroups.get(i); 275 CharSequence senderName = group.getSenderName(); 276 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) { 277 continue; 278 } 279 if (mIsOneToOne && mAvatarReplacement != null && group.getSender() != mUser) { 280 group.setAvatar(mAvatarReplacement); 281 } else { 282 Icon cachedIcon = cachedAvatars.get(senderName); 283 if (cachedIcon == null) { 284 cachedIcon = createAvatarSymbol(senderName, uniqueNames.get(senderName), 285 mLayoutColor); 286 cachedAvatars.put(senderName, cachedIcon); 287 } 288 group.setCreatedAvatar(cachedIcon, senderName, uniqueNames.get(senderName), 289 mLayoutColor); 290 } 291 } 292 } 293 294 public Icon createAvatarSymbol(CharSequence senderName, String symbol, int layoutColor) { 295 if (symbol.isEmpty() || TextUtils.isDigitsOnly(symbol) || 296 SPECIAL_CHAR_PATTERN.matcher(symbol).find()) { 297 Icon avatarIcon = Icon.createWithResource(getContext(), 298 com.android.internal.R.drawable.messaging_user); 299 avatarIcon.setTint(findColor(senderName, layoutColor)); 300 return avatarIcon; 301 } else { 302 Bitmap bitmap = Bitmap.createBitmap(mAvatarSize, mAvatarSize, Bitmap.Config.ARGB_8888); 303 Canvas canvas = new Canvas(bitmap); 304 float radius = mAvatarSize / 2.0f; 305 int color = findColor(senderName, layoutColor); 306 mPaint.setColor(color); 307 canvas.drawCircle(radius, radius, radius, mPaint); 308 boolean needDarkText = ColorUtils.calculateLuminance(color) > 0.5f; 309 mTextPaint.setColor(needDarkText ? Color.BLACK : Color.WHITE); 310 mTextPaint.setTextSize(symbol.length() == 1 ? mAvatarSize * 0.5f : mAvatarSize * 0.3f); 311 int yPos = (int) (radius - ((mTextPaint.descent() + mTextPaint.ascent()) / 2)); 312 canvas.drawText(symbol, radius, yPos, mTextPaint); 313 return Icon.createWithBitmap(bitmap); 314 } 315 } 316 317 private int findColor(CharSequence senderName, int layoutColor) { 318 double luminance = NotificationColorUtil.calculateLuminance(layoutColor); 319 float shift = Math.abs(senderName.hashCode()) % 5 / 4.0f - 0.5f; 320 321 // we need to offset the range if the luminance is too close to the borders 322 shift += Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - luminance, 0); 323 shift -= Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - (1.0f - luminance), 0); 324 return NotificationColorUtil.getShiftedColor(layoutColor, 325 (int) (shift * COLOR_SHIFT_AMOUNT)); 326 } 327 328 private String findNameSplit(String existingName) { 329 String[] split = existingName.split(" "); 330 if (split.length > 1) { 331 return Character.toString(split[0].charAt(0)) 332 + Character.toString(split[1].charAt(0)); 333 } 334 return existingName.substring(0, 1); 335 } 336 337 @RemotableViewMethod 338 public void setLayoutColor(int color) { 339 mLayoutColor = color; 340 } 341 342 @RemotableViewMethod 343 public void setIsOneToOne(boolean oneToOne) { 344 mIsOneToOne = oneToOne; 345 } 346 347 @RemotableViewMethod 348 public void setSenderTextColor(int color) { 349 mSenderTextColor = color; 350 } 351 352 @RemotableViewMethod 353 public void setMessageTextColor(int color) { 354 mMessageTextColor = color; 355 } 356 357 public void setUser(Person user) { 358 mUser = user; 359 if (mUser.getIcon() == null) { 360 Icon userIcon = Icon.createWithResource(getContext(), 361 com.android.internal.R.drawable.messaging_user); 362 userIcon.setTint(mLayoutColor); 363 mUser = mUser.toBuilder().setIcon(userIcon).build(); 364 } 365 } 366 367 private void addMessagesToGroups(List<MessagingMessage> historicMessages, 368 List<MessagingMessage> messages, boolean showSpinner) { 369 // Let's first find our groups! 370 List<List<MessagingMessage>> groups = new ArrayList<>(); 371 List<Person> senders = new ArrayList<>(); 372 373 // Lets first find the groups 374 findGroups(historicMessages, messages, groups, senders); 375 376 // Let's now create the views and reorder them accordingly 377 createGroupViews(groups, senders, showSpinner); 378 } 379 380 private void createGroupViews(List<List<MessagingMessage>> groups, 381 List<Person> senders, boolean showSpinner) { 382 mGroups.clear(); 383 for (int groupIndex = 0; groupIndex < groups.size(); groupIndex++) { 384 List<MessagingMessage> group = groups.get(groupIndex); 385 MessagingGroup newGroup = null; 386 // we'll just take the first group that exists or create one there is none 387 for (int messageIndex = group.size() - 1; messageIndex >= 0; messageIndex--) { 388 MessagingMessage message = group.get(messageIndex); 389 newGroup = message.getGroup(); 390 if (newGroup != null) { 391 break; 392 } 393 } 394 if (newGroup == null) { 395 newGroup = MessagingGroup.createGroup(mMessagingLinearLayout); 396 mAddedGroups.add(newGroup); 397 } 398 newGroup.setDisplayImagesAtEnd(mDisplayImagesAtEnd); 399 newGroup.setLayoutColor(mLayoutColor); 400 newGroup.setTextColors(mSenderTextColor, mMessageTextColor); 401 Person sender = senders.get(groupIndex); 402 CharSequence nameOverride = null; 403 if (sender != mUser && mNameReplacement != null) { 404 nameOverride = mNameReplacement; 405 } 406 newGroup.setSender(sender, nameOverride); 407 newGroup.setSending(groupIndex == (groups.size() - 1) && showSpinner); 408 mGroups.add(newGroup); 409 410 if (mMessagingLinearLayout.indexOfChild(newGroup) != groupIndex) { 411 mMessagingLinearLayout.removeView(newGroup); 412 mMessagingLinearLayout.addView(newGroup, groupIndex); 413 } 414 newGroup.setMessages(group); 415 } 416 } 417 418 private void findGroups(List<MessagingMessage> historicMessages, 419 List<MessagingMessage> messages, List<List<MessagingMessage>> groups, 420 List<Person> senders) { 421 CharSequence currentSenderKey = null; 422 List<MessagingMessage> currentGroup = null; 423 int histSize = historicMessages.size(); 424 for (int i = 0; i < histSize + messages.size(); i++) { 425 MessagingMessage message; 426 if (i < histSize) { 427 message = historicMessages.get(i); 428 } else { 429 message = messages.get(i - histSize); 430 } 431 boolean isNewGroup = currentGroup == null; 432 Person sender = message.getMessage().getSenderPerson(); 433 CharSequence key = sender == null ? null 434 : sender.getKey() == null ? sender.getName() : sender.getKey(); 435 isNewGroup |= !TextUtils.equals(key, currentSenderKey); 436 if (isNewGroup) { 437 currentGroup = new ArrayList<>(); 438 groups.add(currentGroup); 439 if (sender == null) { 440 sender = mUser; 441 } 442 senders.add(sender); 443 currentSenderKey = key; 444 } 445 currentGroup.add(message); 446 } 447 } 448 449 /** 450 * Creates new messages, reusing existing ones if they are available. 451 * 452 * @param newMessages the messages to parse. 453 */ 454 private List<MessagingMessage> createMessages( 455 List<Notification.MessagingStyle.Message> newMessages, boolean historic) { 456 List<MessagingMessage> result = new ArrayList<>();; 457 for (int i = 0; i < newMessages.size(); i++) { 458 Notification.MessagingStyle.Message m = newMessages.get(i); 459 MessagingMessage message = findAndRemoveMatchingMessage(m); 460 if (message == null) { 461 message = MessagingMessage.createMessage(this, m); 462 } 463 message.setIsHistoric(historic); 464 result.add(message); 465 } 466 return result; 467 } 468 469 private MessagingMessage findAndRemoveMatchingMessage(Notification.MessagingStyle.Message m) { 470 for (int i = 0; i < mMessages.size(); i++) { 471 MessagingMessage existing = mMessages.get(i); 472 if (existing.sameAs(m)) { 473 mMessages.remove(i); 474 return existing; 475 } 476 } 477 for (int i = 0; i < mHistoricMessages.size(); i++) { 478 MessagingMessage existing = mHistoricMessages.get(i); 479 if (existing.sameAs(m)) { 480 mHistoricMessages.remove(i); 481 return existing; 482 } 483 } 484 return null; 485 } 486 487 public void showHistoricMessages(boolean show) { 488 mShowHistoricMessages = show; 489 updateHistoricMessageVisibility(); 490 } 491 492 private void updateHistoricMessageVisibility() { 493 int numHistoric = mHistoricMessages.size(); 494 for (int i = 0; i < numHistoric; i++) { 495 MessagingMessage existing = mHistoricMessages.get(i); 496 existing.setVisibility(mShowHistoricMessages ? VISIBLE : GONE); 497 } 498 int numGroups = mGroups.size(); 499 for (int i = 0; i < numGroups; i++) { 500 MessagingGroup group = mGroups.get(i); 501 int visibleChildren = 0; 502 List<MessagingMessage> messages = group.getMessages(); 503 int numGroupMessages = messages.size(); 504 for (int j = 0; j < numGroupMessages; j++) { 505 MessagingMessage message = messages.get(j); 506 if (message.getVisibility() != GONE) { 507 visibleChildren++; 508 } 509 } 510 if (visibleChildren > 0 && group.getVisibility() == GONE) { 511 group.setVisibility(VISIBLE); 512 } else if (visibleChildren == 0 && group.getVisibility() != GONE) { 513 group.setVisibility(GONE); 514 } 515 } 516 } 517 518 @Override 519 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 520 super.onLayout(changed, left, top, right, bottom); 521 if (!mAddedGroups.isEmpty()) { 522 getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { 523 @Override 524 public boolean onPreDraw() { 525 for (MessagingGroup group : mAddedGroups) { 526 if (!group.isShown()) { 527 continue; 528 } 529 MessagingPropertyAnimator.fadeIn(group.getAvatar()); 530 MessagingPropertyAnimator.fadeIn(group.getSenderView()); 531 MessagingPropertyAnimator.startLocalTranslationFrom(group, 532 group.getHeight(), LINEAR_OUT_SLOW_IN); 533 } 534 mAddedGroups.clear(); 535 getViewTreeObserver().removeOnPreDrawListener(this); 536 return true; 537 } 538 }); 539 } 540 } 541 542 public MessagingLinearLayout getMessagingLinearLayout() { 543 return mMessagingLinearLayout; 544 } 545 546 public ArrayList<MessagingGroup> getMessagingGroups() { 547 return mGroups; 548 } 549} 550