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