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