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