MessagingLayout.java revision afeed29bdc80bd4c7542e1177e418a3bfb6682d8
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.content.Context; 25import android.graphics.Bitmap; 26import android.graphics.Canvas; 27import android.graphics.Color; 28import android.graphics.Paint; 29import android.graphics.Rect; 30import android.graphics.drawable.Icon; 31import android.os.Bundle; 32import android.os.Parcelable; 33import android.text.TextUtils; 34import android.util.ArrayMap; 35import android.util.AttributeSet; 36import android.util.DisplayMetrics; 37import android.view.RemotableViewMethod; 38import android.view.View; 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; 53 54/** 55 * A custom-built layout for the Notification.MessagingStyle allows dynamic addition and removal 56 * messages and adapts the layout accordingly. 57 */ 58@RemoteViews.RemoteView 59public class MessagingLayout extends FrameLayout { 60 61 private static final float COLOR_SHIFT_AMOUNT = 60; 62 private static final Consumer<MessagingMessage> REMOVE_MESSAGE 63 = MessagingMessage::removeMessage; 64 public static final Interpolator LINEAR_OUT_SLOW_IN = new PathInterpolator(0f, 0f, 0.2f, 1f); 65 public static final Interpolator FAST_OUT_LINEAR_IN = new PathInterpolator(0.4f, 0f, 1f, 1f); 66 public static final Interpolator FAST_OUT_SLOW_IN = new PathInterpolator(0.4f, 0f, 0.2f, 1f); 67 public static final OnLayoutChangeListener MESSAGING_PROPERTY_ANIMATOR 68 = new MessagingPropertyAnimator(); 69 private List<MessagingMessage> mMessages = new ArrayList<>(); 70 private List<MessagingMessage> mHistoricMessages = new ArrayList<>(); 71 private MessagingLinearLayout mMessagingLinearLayout; 72 private boolean mShowHistoricMessages; 73 private ArrayList<MessagingGroup> mGroups = new ArrayList<>(); 74 private TextView mTitleView; 75 private int mLayoutColor; 76 private int mAvatarSize; 77 private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); 78 private Paint mTextPaint = new Paint(); 79 private CharSequence mConversationTitle; 80 private Icon mLargeIcon; 81 private boolean mIsOneToOne; 82 private ArrayList<MessagingGroup> mAddedGroups = new ArrayList<>(); 83 private Notification.Person mUser; 84 85 public MessagingLayout(@NonNull Context context) { 86 super(context); 87 } 88 89 public MessagingLayout(@NonNull Context context, @Nullable AttributeSet attrs) { 90 super(context, attrs); 91 } 92 93 public MessagingLayout(@NonNull Context context, @Nullable AttributeSet attrs, 94 @AttrRes int defStyleAttr) { 95 super(context, attrs, defStyleAttr); 96 } 97 98 public MessagingLayout(@NonNull Context context, @Nullable AttributeSet attrs, 99 @AttrRes int defStyleAttr, @StyleRes int defStyleRes) { 100 super(context, attrs, defStyleAttr, defStyleRes); 101 } 102 103 @Override 104 protected void onFinishInflate() { 105 super.onFinishInflate(); 106 mMessagingLinearLayout = findViewById(R.id.notification_messaging); 107 mMessagingLinearLayout.setMessagingLayout(this); 108 // We still want to clip, but only on the top, since views can temporarily out of bounds 109 // during transitions. 110 DisplayMetrics displayMetrics = getResources().getDisplayMetrics(); 111 Rect rect = new Rect(0, 0, displayMetrics.widthPixels, displayMetrics.heightPixels); 112 mMessagingLinearLayout.setClipBounds(rect); 113 mTitleView = findViewById(R.id.title); 114 mAvatarSize = getResources().getDimensionPixelSize(R.dimen.messaging_avatar_size); 115 mTextPaint.setTextAlign(Paint.Align.CENTER); 116 mTextPaint.setAntiAlias(true); 117 } 118 119 @RemotableViewMethod 120 public void setLargeIcon(Icon icon) { 121 mLargeIcon = icon; 122 } 123 124 @RemotableViewMethod 125 public void setData(Bundle extras) { 126 Parcelable[] messages = extras.getParcelableArray(Notification.EXTRA_MESSAGES); 127 List<Notification.MessagingStyle.Message> newMessages 128 = Notification.MessagingStyle.Message.getMessagesFromBundleArray(messages); 129 Parcelable[] histMessages = extras.getParcelableArray(Notification.EXTRA_HISTORIC_MESSAGES); 130 List<Notification.MessagingStyle.Message> newHistoricMessages 131 = Notification.MessagingStyle.Message.getMessagesFromBundleArray(histMessages); 132 setUser(extras.getParcelable(Notification.EXTRA_MESSAGING_PERSON)); 133 mConversationTitle = null; 134 TextView headerText = findViewById(R.id.header_text); 135 if (headerText != null) { 136 mConversationTitle = headerText.getText(); 137 } 138 bind(newMessages, newHistoricMessages); 139 } 140 141 private void bind(List<Notification.MessagingStyle.Message> newMessages, 142 List<Notification.MessagingStyle.Message> newHistoricMessages) { 143 144 List<MessagingMessage> historicMessages = createMessages(newHistoricMessages, 145 true /* isHistoric */); 146 List<MessagingMessage> messages = createMessages(newMessages, false /* isHistoric */); 147 addMessagesToGroups(historicMessages, messages); 148 149 // Let's remove the remaining messages 150 mMessages.forEach(REMOVE_MESSAGE); 151 mHistoricMessages.forEach(REMOVE_MESSAGE); 152 153 mMessages = messages; 154 mHistoricMessages = historicMessages; 155 156 updateHistoricMessageVisibility(); 157 updateTitleAndNamesDisplay(); 158 } 159 160 private void updateTitleAndNamesDisplay() { 161 ArrayMap<CharSequence, String> uniqueNames = new ArrayMap<>(); 162 ArrayMap<Character, CharSequence> uniqueCharacters = new ArrayMap<>(); 163 for (int i = 0; i < mGroups.size(); i++) { 164 MessagingGroup group = mGroups.get(i); 165 CharSequence senderName = group.getSenderName(); 166 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) { 167 continue; 168 } 169 if (!uniqueNames.containsKey(senderName)) { 170 char c = senderName.charAt(0); 171 if (uniqueCharacters.containsKey(c)) { 172 // this character was already used, lets make it more unique. We first need to 173 // resolve the existing character if it exists 174 CharSequence existingName = uniqueCharacters.get(c); 175 if (existingName != null) { 176 uniqueNames.put(existingName, findNameSplit((String) existingName)); 177 uniqueCharacters.put(c, null); 178 } 179 uniqueNames.put(senderName, findNameSplit((String) senderName)); 180 } else { 181 uniqueNames.put(senderName, Character.toString(c)); 182 uniqueCharacters.put(c, senderName); 183 } 184 } 185 } 186 187 // Now that we have the correct symbols, let's look what we have cached 188 ArrayMap<CharSequence, Icon> cachedAvatars = new ArrayMap<>(); 189 for (int i = 0; i < mGroups.size(); i++) { 190 // Let's now set the avatars 191 MessagingGroup group = mGroups.get(i); 192 CharSequence senderName = group.getSenderName(); 193 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName) 194 || (mIsOneToOne && mLargeIcon != null)) { 195 continue; 196 } 197 String symbol = uniqueNames.get(senderName); 198 Icon cachedIcon = group.getAvatarSymbolIfMatching(senderName, 199 symbol, mLayoutColor); 200 if (cachedIcon != null) { 201 cachedAvatars.put(senderName, cachedIcon); 202 } 203 } 204 205 for (int i = 0; i < mGroups.size(); i++) { 206 // Let's now set the avatars 207 MessagingGroup group = mGroups.get(i); 208 CharSequence senderName = group.getSenderName(); 209 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) { 210 continue; 211 } 212 if (mIsOneToOne && mLargeIcon != null) { 213 group.setAvatar(mLargeIcon); 214 } else { 215 Icon cachedIcon = cachedAvatars.get(senderName); 216 if (cachedIcon == null) { 217 cachedIcon = createAvatarSymbol(senderName, uniqueNames.get(senderName), 218 mLayoutColor); 219 cachedAvatars.put(senderName, cachedIcon); 220 } 221 group.setCreatedAvatar(cachedIcon, senderName, uniqueNames.get(senderName), 222 mLayoutColor); 223 } 224 } 225 } 226 227 public Icon createAvatarSymbol(CharSequence senderName, String symbol, int layoutColor) { 228 Bitmap bitmap = Bitmap.createBitmap(mAvatarSize, mAvatarSize, Bitmap.Config.ARGB_8888); 229 Canvas canvas = new Canvas(bitmap); 230 float radius = mAvatarSize / 2.0f; 231 int color = findColor(senderName, layoutColor); 232 mPaint.setColor(color); 233 canvas.drawCircle(radius, radius, radius, mPaint); 234 boolean needDarkText = ColorUtils.calculateLuminance(color) > 0.5f; 235 mTextPaint.setColor(needDarkText ? Color.BLACK : Color.WHITE); 236 mTextPaint.setTextSize(symbol.length() == 1 ? mAvatarSize * 0.5f : mAvatarSize * 0.3f); 237 int yPos = (int) (radius - ((mTextPaint.descent() + mTextPaint.ascent()) / 2)) ; 238 canvas.drawText(symbol, radius, yPos, mTextPaint); 239 return Icon.createWithBitmap(bitmap); 240 } 241 242 private int findColor(CharSequence senderName, int layoutColor) { 243 double luminance = NotificationColorUtil.calculateLuminance(layoutColor); 244 float shift = Math.abs(senderName.hashCode()) % 5 / 4.0f - 0.5f; 245 246 // we need to offset the range if the luminance is too close to the borders 247 shift += Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - luminance, 0); 248 shift -= Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - (1.0f - luminance), 0); 249 return NotificationColorUtil.getShiftedColor(layoutColor, 250 (int) (shift * COLOR_SHIFT_AMOUNT)); 251 } 252 253 private String findNameSplit(String existingName) { 254 String[] split = existingName.split(" "); 255 if (split.length > 1) { 256 return Character.toString(split[0].charAt(0)) 257 + Character.toString(split[1].charAt(0)); 258 } 259 return existingName.substring(0, 1); 260 } 261 262 @RemotableViewMethod 263 public void setLayoutColor(int color) { 264 mLayoutColor = color; 265 } 266 267 @RemotableViewMethod 268 public void setIsOneToOne(boolean oneToOne) { 269 mIsOneToOne = oneToOne; 270 } 271 272 public void setUser(Notification.Person user) { 273 mUser = user; 274 } 275 276 private void addMessagesToGroups(List<MessagingMessage> historicMessages, 277 List<MessagingMessage> messages) { 278 // Let's first find our groups! 279 List<List<MessagingMessage>> groups = new ArrayList<>(); 280 List<Notification.Person> senders = new ArrayList<>(); 281 282 // Lets first find the groups 283 findGroups(historicMessages, messages, groups, senders); 284 285 // Let's now create the views and reorder them accordingly 286 createGroupViews(groups, senders); 287 } 288 289 private void createGroupViews(List<List<MessagingMessage>> groups, 290 List<Notification.Person> senders) { 291 mGroups.clear(); 292 for (int groupIndex = 0; groupIndex < groups.size(); groupIndex++) { 293 List<MessagingMessage> group = groups.get(groupIndex); 294 MessagingGroup newGroup = null; 295 // we'll just take the first group that exists or create one there is none 296 for (int messageIndex = group.size() - 1; messageIndex >= 0; messageIndex--) { 297 MessagingMessage message = group.get(messageIndex); 298 newGroup = message.getGroup(); 299 if (newGroup != null) { 300 break; 301 } 302 } 303 if (newGroup == null) { 304 newGroup = MessagingGroup.createGroup(mMessagingLinearLayout); 305 mAddedGroups.add(newGroup); 306 } 307 newGroup.setLayoutColor(mLayoutColor); 308 newGroup.setSender(senders.get(groupIndex)); 309 mGroups.add(newGroup); 310 311 if (mMessagingLinearLayout.indexOfChild(newGroup) != groupIndex) { 312 mMessagingLinearLayout.removeView(newGroup); 313 mMessagingLinearLayout.addView(newGroup, groupIndex); 314 } 315 newGroup.setMessages(group); 316 } 317 } 318 319 private void findGroups(List<MessagingMessage> historicMessages, 320 List<MessagingMessage> messages, List<List<MessagingMessage>> groups, 321 List<Notification.Person> senders) { 322 CharSequence currentSenderKey = null; 323 List<MessagingMessage> currentGroup = null; 324 int histSize = historicMessages.size(); 325 for (int i = 0; i < histSize + messages.size(); i++) { 326 MessagingMessage message; 327 if (i < histSize) { 328 message = historicMessages.get(i); 329 } else { 330 message = messages.get(i - histSize); 331 } 332 boolean isNewGroup = currentGroup == null; 333 Notification.Person sender = message.getMessage().getSenderPerson(); 334 CharSequence key = sender == null ? null 335 : sender.getKey() == null ? sender.getName() : sender.getKey(); 336 isNewGroup |= !TextUtils.equals(key, currentSenderKey); 337 if (isNewGroup) { 338 currentGroup = new ArrayList<>(); 339 groups.add(currentGroup); 340 if (sender == null) { 341 sender = mUser; 342 } 343 senders.add(sender); 344 currentSenderKey = key; 345 } 346 currentGroup.add(message); 347 } 348 } 349 350 /** 351 * Creates new messages, reusing existing ones if they are available. 352 * 353 * @param newMessages the messages to parse. 354 */ 355 private List<MessagingMessage> createMessages( 356 List<Notification.MessagingStyle.Message> newMessages, boolean historic) { 357 List<MessagingMessage> result = new ArrayList<>();; 358 for (int i = 0; i < newMessages.size(); i++) { 359 Notification.MessagingStyle.Message m = newMessages.get(i); 360 MessagingMessage message = findAndRemoveMatchingMessage(m); 361 if (message == null) { 362 message = MessagingMessage.createMessage(this, m); 363 message.addOnLayoutChangeListener(MESSAGING_PROPERTY_ANIMATOR); 364 } 365 message.setIsHistoric(historic); 366 result.add(message); 367 } 368 return result; 369 } 370 371 private MessagingMessage findAndRemoveMatchingMessage(Notification.MessagingStyle.Message m) { 372 for (int i = 0; i < mMessages.size(); i++) { 373 MessagingMessage existing = mMessages.get(i); 374 if (existing.sameAs(m)) { 375 mMessages.remove(i); 376 return existing; 377 } 378 } 379 for (int i = 0; i < mHistoricMessages.size(); i++) { 380 MessagingMessage existing = mHistoricMessages.get(i); 381 if (existing.sameAs(m)) { 382 mHistoricMessages.remove(i); 383 return existing; 384 } 385 } 386 return null; 387 } 388 389 public void showHistoricMessages(boolean show) { 390 mShowHistoricMessages = show; 391 updateHistoricMessageVisibility(); 392 } 393 394 private void updateHistoricMessageVisibility() { 395 for (int i = 0; i < mHistoricMessages.size(); i++) { 396 MessagingMessage existing = mHistoricMessages.get(i); 397 existing.setVisibility(mShowHistoricMessages ? VISIBLE : GONE); 398 } 399 } 400 401 @Override 402 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 403 super.onLayout(changed, left, top, right, bottom); 404 if (!mAddedGroups.isEmpty()) { 405 getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { 406 @Override 407 public boolean onPreDraw() { 408 for (MessagingGroup group : mAddedGroups) { 409 if (!group.isShown()) { 410 continue; 411 } 412 MessagingPropertyAnimator.fadeIn(group.getAvatar()); 413 MessagingPropertyAnimator.fadeIn(group.getSender()); 414 MessagingPropertyAnimator.startLocalTranslationFrom(group, 415 group.getHeight(), LINEAR_OUT_SLOW_IN); 416 } 417 mAddedGroups.clear(); 418 getViewTreeObserver().removeOnPreDrawListener(this); 419 return true; 420 } 421 }); 422 } 423 } 424 425 public MessagingLinearLayout getMessagingLinearLayout() { 426 return mMessagingLinearLayout; 427 } 428 429 public ArrayList<MessagingGroup> getMessagingGroups() { 430 return mGroups; 431 } 432} 433