1 /* 2 * Copyright (C) 2021 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 17 package com.android.internal.widget; 18 19 import static com.android.internal.widget.MessagingPropertyAnimator.ALPHA_IN; 20 import static com.android.internal.widget.MessagingPropertyAnimator.ALPHA_OUT; 21 22 import android.annotation.ColorInt; 23 import android.annotation.NonNull; 24 import android.annotation.Nullable; 25 import android.app.Notification; 26 import android.app.Person; 27 import android.content.Context; 28 import android.graphics.Bitmap; 29 import android.graphics.Canvas; 30 import android.graphics.Color; 31 import android.graphics.Paint; 32 import android.graphics.drawable.Icon; 33 import android.text.TextUtils; 34 import android.util.ArrayMap; 35 import android.view.View; 36 37 import com.android.internal.R; 38 import com.android.internal.graphics.ColorUtils; 39 import com.android.internal.util.ContrastColorUtil; 40 41 import java.util.List; 42 import java.util.Map; 43 import java.util.regex.Pattern; 44 45 /** 46 * This class provides some methods used by both the {@link ConversationLayout} and 47 * {@link CallLayout} which both use the visual design originally created for conversations in R. 48 */ 49 public class PeopleHelper { 50 51 private static final float COLOR_SHIFT_AMOUNT = 60; 52 /** 53 * Pattern for filter some ignorable characters. 54 * p{Z} for any kind of whitespace or invisible separator. 55 * p{C} for any kind of punctuation character. 56 */ 57 private static final Pattern IGNORABLE_CHAR_PATTERN = Pattern.compile("[\\p{C}\\p{Z}]"); 58 private static final Pattern SPECIAL_CHAR_PATTERN = 59 Pattern.compile("[!@#$%&*()_+=|<>?{}\\[\\]~-]"); 60 61 private Context mContext; 62 private int mAvatarSize; 63 private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); 64 private Paint mTextPaint = new Paint(); 65 66 /** 67 * Call this when the view is inflated to provide a context and initialize the helper 68 */ init(Context context)69 public void init(Context context) { 70 mContext = context; 71 mAvatarSize = context.getResources().getDimensionPixelSize(R.dimen.messaging_avatar_size); 72 mTextPaint.setTextAlign(Paint.Align.CENTER); 73 mTextPaint.setAntiAlias(true); 74 } 75 76 /** 77 * A utility for animating CachingIconViews away when hidden. 78 */ animateViewForceHidden(CachingIconView view, boolean forceHidden)79 public void animateViewForceHidden(CachingIconView view, boolean forceHidden) { 80 boolean nowForceHidden = view.willBeForceHidden() || view.isForceHidden(); 81 if (forceHidden == nowForceHidden) { 82 // We are either already forceHidden or will be 83 return; 84 } 85 view.animate().cancel(); 86 view.setWillBeForceHidden(forceHidden); 87 view.animate() 88 .scaleX(forceHidden ? 0.5f : 1.0f) 89 .scaleY(forceHidden ? 0.5f : 1.0f) 90 .alpha(forceHidden ? 0.0f : 1.0f) 91 .setInterpolator(forceHidden ? ALPHA_OUT : ALPHA_IN) 92 .setDuration(160); 93 if (view.getVisibility() != View.VISIBLE) { 94 view.setForceHidden(forceHidden); 95 } else { 96 view.animate().withEndAction(() -> view.setForceHidden(forceHidden)); 97 } 98 view.animate().start(); 99 } 100 101 /** 102 * This creates an avatar symbol for the given person or group 103 * 104 * @param name the name of the person or group 105 * @param symbol a pre-chosen symbol for the person or group. See 106 * {@link #findNamePrefix(CharSequence, String)} or 107 * {@link #findNameSplit(CharSequence)} 108 * @param layoutColor the background color of the layout 109 */ 110 @NonNull createAvatarSymbol(@onNull CharSequence name, @NonNull String symbol, @ColorInt int layoutColor)111 public Icon createAvatarSymbol(@NonNull CharSequence name, @NonNull String symbol, 112 @ColorInt int layoutColor) { 113 if (symbol.isEmpty() || TextUtils.isDigitsOnly(symbol) 114 || SPECIAL_CHAR_PATTERN.matcher(symbol).find()) { 115 Icon avatarIcon = Icon.createWithResource(mContext, R.drawable.messaging_user); 116 avatarIcon.setTint(findColor(name, layoutColor)); 117 return avatarIcon; 118 } else { 119 Bitmap bitmap = Bitmap.createBitmap(mAvatarSize, mAvatarSize, Bitmap.Config.ARGB_8888); 120 Canvas canvas = new Canvas(bitmap); 121 float radius = mAvatarSize / 2.0f; 122 int color = findColor(name, layoutColor); 123 mPaint.setColor(color); 124 canvas.drawCircle(radius, radius, radius, mPaint); 125 boolean needDarkText = ColorUtils.calculateLuminance(color) > 0.5f; 126 mTextPaint.setColor(needDarkText ? Color.BLACK : Color.WHITE); 127 mTextPaint.setTextSize(symbol.length() == 1 ? mAvatarSize * 0.5f : mAvatarSize * 0.3f); 128 int yPos = (int) (radius - ((mTextPaint.descent() + mTextPaint.ascent()) / 2)); 129 canvas.drawText(symbol, radius, yPos, mTextPaint); 130 return Icon.createWithBitmap(bitmap); 131 } 132 } 133 findColor(@onNull CharSequence senderName, int layoutColor)134 private int findColor(@NonNull CharSequence senderName, int layoutColor) { 135 double luminance = ContrastColorUtil.calculateLuminance(layoutColor); 136 float shift = Math.abs(senderName.hashCode()) % 5 / 4.0f - 0.5f; 137 138 // we need to offset the range if the luminance is too close to the borders 139 shift += Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - luminance, 0); 140 shift -= Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - (1.0f - luminance), 0); 141 return ContrastColorUtil.getShiftedColor(layoutColor, 142 (int) (shift * COLOR_SHIFT_AMOUNT)); 143 } 144 145 /** 146 * Get the name with whitespace and punctuation characters removed 147 */ getPureName(@onNull CharSequence name)148 private String getPureName(@NonNull CharSequence name) { 149 return IGNORABLE_CHAR_PATTERN.matcher(name).replaceAll("" /* replacement */); 150 } 151 152 /** 153 * Gets a single character string prefix name for the person or group 154 * 155 * @param name the name of the person or group 156 * @param fallback the string to return if the name has no usable characters 157 */ findNamePrefix(@onNull CharSequence name, String fallback)158 public String findNamePrefix(@NonNull CharSequence name, String fallback) { 159 String pureName = getPureName(name); 160 if (pureName.isEmpty()) { 161 return fallback; 162 } 163 try { 164 return new String(Character.toChars(pureName.codePointAt(0))); 165 } catch (RuntimeException ignore) { 166 return fallback; 167 } 168 } 169 170 /** 171 * Find a 1 or 2 character prefix name for the person or group 172 */ findNameSplit(@onNull CharSequence name)173 public String findNameSplit(@NonNull CharSequence name) { 174 String nameString = name instanceof String ? ((String) name) : name.toString(); 175 String[] split = nameString.trim().split("[ ]+"); 176 if (split.length > 1) { 177 String first = findNamePrefix(split[0], null); 178 String second = findNamePrefix(split[1], null); 179 if (first != null && second != null) { 180 return first + second; 181 } 182 } 183 return findNamePrefix(name, ""); 184 } 185 186 /** 187 * Creates a mapping of the unique sender names in the groups to the string 1- or 2-character 188 * prefix strings for the names, which are extracted as the initials, and should be used for 189 * generating the avatar. Senders not requiring a generated avatar, or with an empty name are 190 * omitted. 191 */ mapUniqueNamesToPrefix(List<MessagingGroup> groups)192 public Map<CharSequence, String> mapUniqueNamesToPrefix(List<MessagingGroup> groups) { 193 // Map of unique names to their prefix 194 ArrayMap<CharSequence, String> uniqueNames = new ArrayMap<>(); 195 // Map of single-character string prefix to the only name which uses it, or null if multiple 196 ArrayMap<String, CharSequence> uniqueCharacters = new ArrayMap<>(); 197 for (int i = 0; i < groups.size(); i++) { 198 MessagingGroup group = groups.get(i); 199 CharSequence senderName = group.getSenderName(); 200 if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) { 201 continue; 202 } 203 if (!uniqueNames.containsKey(senderName)) { 204 String charPrefix = findNamePrefix(senderName, null); 205 if (charPrefix == null) { 206 continue; 207 } 208 if (uniqueCharacters.containsKey(charPrefix)) { 209 // this character was already used, lets make it more unique. We first need to 210 // resolve the existing character if it exists 211 CharSequence existingName = uniqueCharacters.get(charPrefix); 212 if (existingName != null) { 213 uniqueNames.put(existingName, findNameSplit(existingName)); 214 uniqueCharacters.put(charPrefix, null); 215 } 216 uniqueNames.put(senderName, findNameSplit(senderName)); 217 } else { 218 uniqueNames.put(senderName, charPrefix); 219 uniqueCharacters.put(charPrefix, senderName); 220 } 221 } 222 } 223 return uniqueNames; 224 } 225 226 /** 227 * A class that represents a map from unique sender names in the groups to the string 1- or 228 * 2-character prefix strings for the names. This class uses the String value of the 229 * CharSequence Names as the key. 230 */ 231 public class NameToPrefixMap { 232 Map<String, String> mMap; NameToPrefixMap(Map<String, String> map)233 NameToPrefixMap(Map<String, String> map) { 234 this.mMap = map; 235 } 236 237 /** 238 * @param name the name 239 * @return the prefix of the given name 240 */ getPrefix(CharSequence name)241 public String getPrefix(CharSequence name) { 242 return mMap.get(name.toString()); 243 } 244 } 245 246 /** 247 * Same functionality as mapUniqueNamesToPrefix, but takes list-represented message groups as 248 * the input. This method is better when inflating MessagingGroup from the UI thread is not 249 * an option. 250 * @param groups message groups represented by lists. A message group is some consecutive 251 * messages (>=3) from the same sender in a conversation. 252 */ mapUniqueNamesToPrefixWithGroupList( List<List<Notification.MessagingStyle.Message>> groups)253 public NameToPrefixMap mapUniqueNamesToPrefixWithGroupList( 254 List<List<Notification.MessagingStyle.Message>> groups) { 255 // Map of unique names to their prefix 256 ArrayMap<String, String> uniqueNames = new ArrayMap<>(); 257 // Map of single-character string prefix to the only name which uses it, or null if multiple 258 ArrayMap<String, CharSequence> uniqueCharacters = new ArrayMap<>(); 259 for (int i = 0; i < groups.size(); i++) { 260 List<Notification.MessagingStyle.Message> group = groups.get(i); 261 if (group.isEmpty()) continue; 262 Person sender = group.get(0).getSenderPerson(); 263 if (sender == null) continue; 264 CharSequence senderName = sender.getName(); 265 if (sender.getIcon() != null || TextUtils.isEmpty(senderName)) { 266 continue; 267 } 268 String senderNameString = senderName.toString(); 269 if (!uniqueNames.containsKey(senderNameString)) { 270 String charPrefix = findNamePrefix(senderName, null); 271 if (charPrefix == null) { 272 continue; 273 } 274 if (uniqueCharacters.containsKey(charPrefix)) { 275 // this character was already used, lets make it more unique. We first need to 276 // resolve the existing character if it exists 277 CharSequence existingName = uniqueCharacters.get(charPrefix); 278 if (existingName != null) { 279 uniqueNames.put(existingName.toString(), findNameSplit(existingName)); 280 uniqueCharacters.put(charPrefix, null); 281 } 282 uniqueNames.put(senderNameString, findNameSplit(senderName)); 283 } else { 284 uniqueNames.put(senderNameString, charPrefix); 285 uniqueCharacters.put(charPrefix, senderName); 286 } 287 } 288 } 289 return new NameToPrefixMap(uniqueNames); 290 } 291 292 /** 293 * Update whether the groups can hide the sender if they are first 294 * (happens only for 1:1 conversations where the given title matches the sender's name) 295 */ maybeHideFirstSenderName(@onNull List<MessagingGroup> groups, boolean isOneToOne, @Nullable CharSequence conversationTitle)296 public void maybeHideFirstSenderName(@NonNull List<MessagingGroup> groups, 297 boolean isOneToOne, @Nullable CharSequence conversationTitle) { 298 for (int i = groups.size() - 1; i >= 0; i--) { 299 MessagingGroup messagingGroup = groups.get(i); 300 CharSequence messageSender = messagingGroup.getSenderName(); 301 boolean canHide = isOneToOne && TextUtils.equals(conversationTitle, messageSender); 302 messagingGroup.setCanHideSenderIfFirst(canHide); 303 } 304 } 305 } 306