306 lines
13 KiB
Java
306 lines
13 KiB
Java
/*
|
|
* Copyright (C) 2021 The Android Open Source Project
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
package com.android.internal.widget;
|
|
|
|
import static com.android.internal.widget.MessagingPropertyAnimator.ALPHA_IN;
|
|
import static com.android.internal.widget.MessagingPropertyAnimator.ALPHA_OUT;
|
|
|
|
import android.annotation.ColorInt;
|
|
import android.annotation.NonNull;
|
|
import android.annotation.Nullable;
|
|
import android.app.Notification;
|
|
import android.app.Person;
|
|
import android.content.Context;
|
|
import android.graphics.Bitmap;
|
|
import android.graphics.Canvas;
|
|
import android.graphics.Color;
|
|
import android.graphics.Paint;
|
|
import android.graphics.drawable.Icon;
|
|
import android.text.TextUtils;
|
|
import android.util.ArrayMap;
|
|
import android.view.View;
|
|
|
|
import com.android.internal.R;
|
|
import com.android.internal.graphics.ColorUtils;
|
|
import com.android.internal.util.ContrastColorUtil;
|
|
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.regex.Pattern;
|
|
|
|
/**
|
|
* This class provides some methods used by both the {@link ConversationLayout} and
|
|
* {@link CallLayout} which both use the visual design originally created for conversations in R.
|
|
*/
|
|
public class PeopleHelper {
|
|
|
|
private static final float COLOR_SHIFT_AMOUNT = 60;
|
|
/**
|
|
* Pattern for filter some ignorable characters.
|
|
* p{Z} for any kind of whitespace or invisible separator.
|
|
* p{C} for any kind of punctuation character.
|
|
*/
|
|
private static final Pattern IGNORABLE_CHAR_PATTERN = Pattern.compile("[\\p{C}\\p{Z}]");
|
|
private static final Pattern SPECIAL_CHAR_PATTERN =
|
|
Pattern.compile("[!@#$%&*()_+=|<>?{}\\[\\]~-]");
|
|
|
|
private Context mContext;
|
|
private int mAvatarSize;
|
|
private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
|
private Paint mTextPaint = new Paint();
|
|
|
|
/**
|
|
* Call this when the view is inflated to provide a context and initialize the helper
|
|
*/
|
|
public void init(Context context) {
|
|
mContext = context;
|
|
mAvatarSize = context.getResources().getDimensionPixelSize(R.dimen.messaging_avatar_size);
|
|
mTextPaint.setTextAlign(Paint.Align.CENTER);
|
|
mTextPaint.setAntiAlias(true);
|
|
}
|
|
|
|
/**
|
|
* A utility for animating CachingIconViews away when hidden.
|
|
*/
|
|
public void animateViewForceHidden(CachingIconView view, boolean forceHidden) {
|
|
boolean nowForceHidden = view.willBeForceHidden() || view.isForceHidden();
|
|
if (forceHidden == nowForceHidden) {
|
|
// We are either already forceHidden or will be
|
|
return;
|
|
}
|
|
view.animate().cancel();
|
|
view.setWillBeForceHidden(forceHidden);
|
|
view.animate()
|
|
.scaleX(forceHidden ? 0.5f : 1.0f)
|
|
.scaleY(forceHidden ? 0.5f : 1.0f)
|
|
.alpha(forceHidden ? 0.0f : 1.0f)
|
|
.setInterpolator(forceHidden ? ALPHA_OUT : ALPHA_IN)
|
|
.setDuration(160);
|
|
if (view.getVisibility() != View.VISIBLE) {
|
|
view.setForceHidden(forceHidden);
|
|
} else {
|
|
view.animate().withEndAction(() -> view.setForceHidden(forceHidden));
|
|
}
|
|
view.animate().start();
|
|
}
|
|
|
|
/**
|
|
* This creates an avatar symbol for the given person or group
|
|
*
|
|
* @param name the name of the person or group
|
|
* @param symbol a pre-chosen symbol for the person or group. See
|
|
* {@link #findNamePrefix(CharSequence, String)} or
|
|
* {@link #findNameSplit(CharSequence)}
|
|
* @param layoutColor the background color of the layout
|
|
*/
|
|
@NonNull
|
|
public Icon createAvatarSymbol(@NonNull CharSequence name, @NonNull String symbol,
|
|
@ColorInt int layoutColor) {
|
|
if (symbol.isEmpty() || TextUtils.isDigitsOnly(symbol)
|
|
|| SPECIAL_CHAR_PATTERN.matcher(symbol).find()) {
|
|
Icon avatarIcon = Icon.createWithResource(mContext, R.drawable.messaging_user);
|
|
avatarIcon.setTint(findColor(name, layoutColor));
|
|
return avatarIcon;
|
|
} else {
|
|
Bitmap bitmap = Bitmap.createBitmap(mAvatarSize, mAvatarSize, Bitmap.Config.ARGB_8888);
|
|
Canvas canvas = new Canvas(bitmap);
|
|
float radius = mAvatarSize / 2.0f;
|
|
int color = findColor(name, layoutColor);
|
|
mPaint.setColor(color);
|
|
canvas.drawCircle(radius, radius, radius, mPaint);
|
|
boolean needDarkText = ColorUtils.calculateLuminance(color) > 0.5f;
|
|
mTextPaint.setColor(needDarkText ? Color.BLACK : Color.WHITE);
|
|
mTextPaint.setTextSize(symbol.length() == 1 ? mAvatarSize * 0.5f : mAvatarSize * 0.3f);
|
|
int yPos = (int) (radius - ((mTextPaint.descent() + mTextPaint.ascent()) / 2));
|
|
canvas.drawText(symbol, radius, yPos, mTextPaint);
|
|
return Icon.createWithBitmap(bitmap);
|
|
}
|
|
}
|
|
|
|
private int findColor(@NonNull CharSequence senderName, int layoutColor) {
|
|
double luminance = ContrastColorUtil.calculateLuminance(layoutColor);
|
|
float shift = Math.abs(senderName.hashCode()) % 5 / 4.0f - 0.5f;
|
|
|
|
// we need to offset the range if the luminance is too close to the borders
|
|
shift += Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - luminance, 0);
|
|
shift -= Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - (1.0f - luminance), 0);
|
|
return ContrastColorUtil.getShiftedColor(layoutColor,
|
|
(int) (shift * COLOR_SHIFT_AMOUNT));
|
|
}
|
|
|
|
/**
|
|
* Get the name with whitespace and punctuation characters removed
|
|
*/
|
|
private String getPureName(@NonNull CharSequence name) {
|
|
return IGNORABLE_CHAR_PATTERN.matcher(name).replaceAll("" /* replacement */);
|
|
}
|
|
|
|
/**
|
|
* Gets a single character string prefix name for the person or group
|
|
*
|
|
* @param name the name of the person or group
|
|
* @param fallback the string to return if the name has no usable characters
|
|
*/
|
|
public String findNamePrefix(@NonNull CharSequence name, String fallback) {
|
|
String pureName = getPureName(name);
|
|
if (pureName.isEmpty()) {
|
|
return fallback;
|
|
}
|
|
try {
|
|
return new String(Character.toChars(pureName.codePointAt(0)));
|
|
} catch (RuntimeException ignore) {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find a 1 or 2 character prefix name for the person or group
|
|
*/
|
|
public String findNameSplit(@NonNull CharSequence name) {
|
|
String nameString = name instanceof String ? ((String) name) : name.toString();
|
|
String[] split = nameString.trim().split("[ ]+");
|
|
if (split.length > 1) {
|
|
String first = findNamePrefix(split[0], null);
|
|
String second = findNamePrefix(split[1], null);
|
|
if (first != null && second != null) {
|
|
return first + second;
|
|
}
|
|
}
|
|
return findNamePrefix(name, "");
|
|
}
|
|
|
|
/**
|
|
* Creates a mapping of the unique sender names in the groups to the string 1- or 2-character
|
|
* prefix strings for the names, which are extracted as the initials, and should be used for
|
|
* generating the avatar. Senders not requiring a generated avatar, or with an empty name are
|
|
* omitted.
|
|
*/
|
|
public Map<CharSequence, String> mapUniqueNamesToPrefix(List<MessagingGroup> groups) {
|
|
// Map of unique names to their prefix
|
|
ArrayMap<CharSequence, String> uniqueNames = new ArrayMap<>();
|
|
// Map of single-character string prefix to the only name which uses it, or null if multiple
|
|
ArrayMap<String, CharSequence> uniqueCharacters = new ArrayMap<>();
|
|
for (int i = 0; i < groups.size(); i++) {
|
|
MessagingGroup group = groups.get(i);
|
|
CharSequence senderName = group.getSenderName();
|
|
if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) {
|
|
continue;
|
|
}
|
|
if (!uniqueNames.containsKey(senderName)) {
|
|
String charPrefix = findNamePrefix(senderName, null);
|
|
if (charPrefix == null) {
|
|
continue;
|
|
}
|
|
if (uniqueCharacters.containsKey(charPrefix)) {
|
|
// this character was already used, lets make it more unique. We first need to
|
|
// resolve the existing character if it exists
|
|
CharSequence existingName = uniqueCharacters.get(charPrefix);
|
|
if (existingName != null) {
|
|
uniqueNames.put(existingName, findNameSplit(existingName));
|
|
uniqueCharacters.put(charPrefix, null);
|
|
}
|
|
uniqueNames.put(senderName, findNameSplit(senderName));
|
|
} else {
|
|
uniqueNames.put(senderName, charPrefix);
|
|
uniqueCharacters.put(charPrefix, senderName);
|
|
}
|
|
}
|
|
}
|
|
return uniqueNames;
|
|
}
|
|
|
|
/**
|
|
* A class that represents a map from unique sender names in the groups to the string 1- or
|
|
* 2-character prefix strings for the names. This class uses the String value of the
|
|
* CharSequence Names as the key.
|
|
*/
|
|
public class NameToPrefixMap {
|
|
Map<String, String> mMap;
|
|
NameToPrefixMap(Map<String, String> map) {
|
|
this.mMap = map;
|
|
}
|
|
|
|
/**
|
|
* @param name the name
|
|
* @return the prefix of the given name
|
|
*/
|
|
public String getPrefix(CharSequence name) {
|
|
return mMap.get(name.toString());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Same functionality as mapUniqueNamesToPrefix, but takes list-represented message groups as
|
|
* the input. This method is better when inflating MessagingGroup from the UI thread is not
|
|
* an option.
|
|
* @param groups message groups represented by lists. A message group is some consecutive
|
|
* messages (>=3) from the same sender in a conversation.
|
|
*/
|
|
public NameToPrefixMap mapUniqueNamesToPrefixWithGroupList(
|
|
List<List<Notification.MessagingStyle.Message>> groups) {
|
|
// Map of unique names to their prefix
|
|
ArrayMap<String, String> uniqueNames = new ArrayMap<>();
|
|
// Map of single-character string prefix to the only name which uses it, or null if multiple
|
|
ArrayMap<String, CharSequence> uniqueCharacters = new ArrayMap<>();
|
|
for (int i = 0; i < groups.size(); i++) {
|
|
List<Notification.MessagingStyle.Message> group = groups.get(i);
|
|
if (group.isEmpty()) continue;
|
|
Person sender = group.get(0).getSenderPerson();
|
|
if (sender == null) continue;
|
|
CharSequence senderName = sender.getName();
|
|
if (sender.getIcon() != null || TextUtils.isEmpty(senderName)) {
|
|
continue;
|
|
}
|
|
String senderNameString = senderName.toString();
|
|
if (!uniqueNames.containsKey(senderNameString)) {
|
|
String charPrefix = findNamePrefix(senderName, null);
|
|
if (charPrefix == null) {
|
|
continue;
|
|
}
|
|
if (uniqueCharacters.containsKey(charPrefix)) {
|
|
// this character was already used, lets make it more unique. We first need to
|
|
// resolve the existing character if it exists
|
|
CharSequence existingName = uniqueCharacters.get(charPrefix);
|
|
if (existingName != null) {
|
|
uniqueNames.put(existingName.toString(), findNameSplit(existingName));
|
|
uniqueCharacters.put(charPrefix, null);
|
|
}
|
|
uniqueNames.put(senderNameString, findNameSplit(senderName));
|
|
} else {
|
|
uniqueNames.put(senderNameString, charPrefix);
|
|
uniqueCharacters.put(charPrefix, senderName);
|
|
}
|
|
}
|
|
}
|
|
return new NameToPrefixMap(uniqueNames);
|
|
}
|
|
|
|
/**
|
|
* Update whether the groups can hide the sender if they are first
|
|
* (happens only for 1:1 conversations where the given title matches the sender's name)
|
|
*/
|
|
public void maybeHideFirstSenderName(@NonNull List<MessagingGroup> groups,
|
|
boolean isOneToOne, @Nullable CharSequence conversationTitle) {
|
|
for (int i = groups.size() - 1; i >= 0; i--) {
|
|
MessagingGroup messagingGroup = groups.get(i);
|
|
CharSequence messageSender = messagingGroup.getSenderName();
|
|
boolean canHide = isOneToOne && TextUtils.equals(conversationTitle, messageSender);
|
|
messagingGroup.setCanHideSenderIfFirst(canHide);
|
|
}
|
|
}
|
|
}
|