Home | History | Annotate | Download | only in widget
      1 /*
      2  * Copyright (C) 2017 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 android.annotation.AttrRes;
     20 import android.annotation.NonNull;
     21 import android.annotation.Nullable;
     22 import android.annotation.StyleRes;
     23 import android.app.Notification;
     24 import android.app.Person;
     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.Rect;
     31 import android.graphics.drawable.Icon;
     32 import android.os.Bundle;
     33 import android.os.Parcelable;
     34 import android.text.TextUtils;
     35 import android.util.ArrayMap;
     36 import android.util.AttributeSet;
     37 import android.util.DisplayMetrics;
     38 import android.view.RemotableViewMethod;
     39 import android.view.ViewTreeObserver;
     40 import android.view.animation.Interpolator;
     41 import android.view.animation.PathInterpolator;
     42 import android.widget.FrameLayout;
     43 import android.widget.RemoteViews;
     44 import android.widget.TextView;
     45 
     46 import com.android.internal.R;
     47 import com.android.internal.graphics.ColorUtils;
     48 import com.android.internal.util.NotificationColorUtil;
     49 
     50 import java.util.ArrayList;
     51 import java.util.List;
     52 import java.util.function.Consumer;
     53 import java.util.regex.Pattern;
     54 
     55 /**
     56  * A custom-built layout for the Notification.MessagingStyle allows dynamic addition and removal
     57  * messages and adapts the layout accordingly.
     58  */
     59 @RemoteViews.RemoteView
     60 public class MessagingLayout extends FrameLayout {
     61 
     62     private static final float COLOR_SHIFT_AMOUNT = 60;
     63     private static final Pattern SPECIAL_CHAR_PATTERN
     64             = Pattern.compile ("[!@#$%&*()_+=|<>?{}\\[\\]~-]");
     65     private static final Consumer<MessagingMessage> REMOVE_MESSAGE
     66             = MessagingMessage::removeMessage;
     67     public static final Interpolator LINEAR_OUT_SLOW_IN = new PathInterpolator(0f, 0f, 0.2f, 1f);
     68     public static final Interpolator FAST_OUT_LINEAR_IN = new PathInterpolator(0.4f, 0f, 1f, 1f);
     69     public static final Interpolator FAST_OUT_SLOW_IN = new PathInterpolator(0.4f, 0f, 0.2f, 1f);
     70     public static final OnLayoutChangeListener MESSAGING_PROPERTY_ANIMATOR
     71             = new MessagingPropertyAnimator();
     72     private List<MessagingMessage> mMessages = new ArrayList<>();
     73     private List<MessagingMessage> mHistoricMessages = new ArrayList<>();
     74     private MessagingLinearLayout mMessagingLinearLayout;
     75     private boolean mShowHistoricMessages;
     76     private ArrayList<MessagingGroup> mGroups = new ArrayList<>();
     77     private TextView mTitleView;
     78     private int mLayoutColor;
     79     private int mSenderTextColor;
     80     private int mMessageTextColor;
     81     private int mAvatarSize;
     82     private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
     83     private Paint mTextPaint = new Paint();
     84     private CharSequence mConversationTitle;
     85     private Icon mAvatarReplacement;
     86     private boolean mIsOneToOne;
     87     private ArrayList<MessagingGroup> mAddedGroups = new ArrayList<>();
     88     private Person mUser;
     89     private CharSequence mNameReplacement;
     90     private boolean mDisplayImagesAtEnd;
     91 
     92     public MessagingLayout(@NonNull Context context) {
     93         super(context);
     94     }
     95 
     96     public MessagingLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
     97         super(context, attrs);
     98     }
     99 
    100     public MessagingLayout(@NonNull Context context, @Nullable AttributeSet attrs,
    101             @AttrRes int defStyleAttr) {
    102         super(context, attrs, defStyleAttr);
    103     }
    104 
    105     public MessagingLayout(@NonNull Context context, @Nullable AttributeSet attrs,
    106             @AttrRes int defStyleAttr, @StyleRes int defStyleRes) {
    107         super(context, attrs, defStyleAttr, defStyleRes);
    108     }
    109 
    110     @Override
    111     protected void onFinishInflate() {
    112         super.onFinishInflate();
    113         mMessagingLinearLayout = findViewById(R.id.notification_messaging);
    114         mMessagingLinearLayout.setMessagingLayout(this);
    115         // We still want to clip, but only on the top, since views can temporarily out of bounds
    116         // during transitions.
    117         DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
    118         int size = Math.max(displayMetrics.widthPixels, displayMetrics.heightPixels);
    119         Rect rect = new Rect(0, 0, size, size);
    120         mMessagingLinearLayout.setClipBounds(rect);
    121         mTitleView = findViewById(R.id.title);
    122         mAvatarSize = getResources().getDimensionPixelSize(R.dimen.messaging_avatar_size);
    123         mTextPaint.setTextAlign(Paint.Align.CENTER);
    124         mTextPaint.setAntiAlias(true);
    125     }
    126 
    127     @RemotableViewMethod
    128     public void setAvatarReplacement(Icon icon) {
    129         mAvatarReplacement = icon;
    130     }
    131 
    132     @RemotableViewMethod
    133     public void setNameReplacement(CharSequence nameReplacement) {
    134         mNameReplacement = nameReplacement;
    135     }
    136 
    137     @RemotableViewMethod
    138     public void setDisplayImagesAtEnd(boolean atEnd) {
    139         mDisplayImagesAtEnd = atEnd;
    140     }
    141 
    142     @RemotableViewMethod
    143     public void setData(Bundle extras) {
    144         Parcelable[] messages = extras.getParcelableArray(Notification.EXTRA_MESSAGES);
    145         List<Notification.MessagingStyle.Message> newMessages
    146                 = Notification.MessagingStyle.Message.getMessagesFromBundleArray(messages);
    147         Parcelable[] histMessages = extras.getParcelableArray(Notification.EXTRA_HISTORIC_MESSAGES);
    148         List<Notification.MessagingStyle.Message> newHistoricMessages
    149                 = Notification.MessagingStyle.Message.getMessagesFromBundleArray(histMessages);
    150         setUser(extras.getParcelable(Notification.EXTRA_MESSAGING_PERSON));
    151         mConversationTitle = null;
    152         TextView headerText = findViewById(R.id.header_text);
    153         if (headerText != null) {
    154             mConversationTitle = headerText.getText();
    155         }
    156         addRemoteInputHistoryToMessages(newMessages,
    157                 extras.getCharSequenceArray(Notification.EXTRA_REMOTE_INPUT_HISTORY));
    158         boolean showSpinner =
    159                 extras.getBoolean(Notification.EXTRA_SHOW_REMOTE_INPUT_SPINNER, false);
    160         bind(newMessages, newHistoricMessages, showSpinner);
    161     }
    162 
    163     private void addRemoteInputHistoryToMessages(
    164             List<Notification.MessagingStyle.Message> newMessages,
    165             CharSequence[] remoteInputHistory) {
    166         if (remoteInputHistory == null || remoteInputHistory.length == 0) {
    167             return;
    168         }
    169         for (int i = remoteInputHistory.length - 1; i >= 0; i--) {
    170             CharSequence message = remoteInputHistory[i];
    171             newMessages.add(new Notification.MessagingStyle.Message(
    172                     message, 0, (Person) null, true /* remoteHistory */));
    173         }
    174     }
    175 
    176     private void bind(List<Notification.MessagingStyle.Message> newMessages,
    177             List<Notification.MessagingStyle.Message> newHistoricMessages,
    178             boolean showSpinner) {
    179 
    180         List<MessagingMessage> historicMessages = createMessages(newHistoricMessages,
    181                 true /* isHistoric */);
    182         List<MessagingMessage> messages = createMessages(newMessages, false /* isHistoric */);
    183 
    184         ArrayList<MessagingGroup> oldGroups = new ArrayList<>(mGroups);
    185         addMessagesToGroups(historicMessages, messages, showSpinner);
    186 
    187         // Let's first check which groups were removed altogether and remove them in one animation
    188         removeGroups(oldGroups);
    189 
    190         // Let's remove the remaining messages
    191         mMessages.forEach(REMOVE_MESSAGE);
    192         mHistoricMessages.forEach(REMOVE_MESSAGE);
    193 
    194         mMessages = messages;
    195         mHistoricMessages = historicMessages;
    196 
    197         updateHistoricMessageVisibility();
    198         updateTitleAndNamesDisplay();
    199     }
    200 
    201     private void removeGroups(ArrayList<MessagingGroup> oldGroups) {
    202         int size = oldGroups.size();
    203         for (int i = 0; i < size; i++) {
    204             MessagingGroup group = oldGroups.get(i);
    205             if (!mGroups.contains(group)) {
    206                 List<MessagingMessage> messages = group.getMessages();
    207                 Runnable endRunnable = () -> {
    208                     mMessagingLinearLayout.removeTransientView(group);
    209                     group.recycle();
    210                 };
    211 
    212                 boolean wasShown = group.isShown();
    213                 mMessagingLinearLayout.removeView(group);
    214                 if (wasShown && !MessagingLinearLayout.isGone(group)) {
    215                     mMessagingLinearLayout.addTransientView(group, 0);
    216                     group.removeGroupAnimated(endRunnable);
    217                 } else {
    218                     endRunnable.run();
    219                 }
    220                 mMessages.removeAll(messages);
    221                 mHistoricMessages.removeAll(messages);
    222             }
    223         }
    224     }
    225 
    226     private void updateTitleAndNamesDisplay() {
    227         ArrayMap<CharSequence, String> uniqueNames = new ArrayMap<>();
    228         ArrayMap<Character, CharSequence> uniqueCharacters = new ArrayMap<>();
    229         for (int i = 0; i < mGroups.size(); i++) {
    230             MessagingGroup group = mGroups.get(i);
    231             CharSequence senderName = group.getSenderName();
    232             if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) {
    233                 continue;
    234             }
    235             if (!uniqueNames.containsKey(senderName)) {
    236                 char c = senderName.charAt(0);
    237                 if (uniqueCharacters.containsKey(c)) {
    238                     // this character was already used, lets make it more unique. We first need to
    239                     // resolve the existing character if it exists
    240                     CharSequence existingName = uniqueCharacters.get(c);
    241                     if (existingName != null) {
    242                         uniqueNames.put(existingName, findNameSplit((String) existingName));
    243                         uniqueCharacters.put(c, null);
    244                     }
    245                     uniqueNames.put(senderName, findNameSplit((String) senderName));
    246                 } else {
    247                     uniqueNames.put(senderName, Character.toString(c));
    248                     uniqueCharacters.put(c, senderName);
    249                 }
    250             }
    251         }
    252 
    253         // Now that we have the correct symbols, let's look what we have cached
    254         ArrayMap<CharSequence, Icon> cachedAvatars = new ArrayMap<>();
    255         for (int i = 0; i < mGroups.size(); i++) {
    256             // Let's now set the avatars
    257             MessagingGroup group = mGroups.get(i);
    258             boolean isOwnMessage = group.getSender() == mUser;
    259             CharSequence senderName = group.getSenderName();
    260             if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)
    261                     || (mIsOneToOne && mAvatarReplacement != null && !isOwnMessage)) {
    262                 continue;
    263             }
    264             String symbol = uniqueNames.get(senderName);
    265             Icon cachedIcon = group.getAvatarSymbolIfMatching(senderName,
    266                     symbol, mLayoutColor);
    267             if (cachedIcon != null) {
    268                 cachedAvatars.put(senderName, cachedIcon);
    269             }
    270         }
    271 
    272         for (int i = 0; i < mGroups.size(); i++) {
    273             // Let's now set the avatars
    274             MessagingGroup group = mGroups.get(i);
    275             CharSequence senderName = group.getSenderName();
    276             if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) {
    277                 continue;
    278             }
    279             if (mIsOneToOne && mAvatarReplacement != null && group.getSender() != mUser) {
    280                 group.setAvatar(mAvatarReplacement);
    281             } else {
    282                 Icon cachedIcon = cachedAvatars.get(senderName);
    283                 if (cachedIcon == null) {
    284                     cachedIcon = createAvatarSymbol(senderName, uniqueNames.get(senderName),
    285                             mLayoutColor);
    286                     cachedAvatars.put(senderName, cachedIcon);
    287                 }
    288                 group.setCreatedAvatar(cachedIcon, senderName, uniqueNames.get(senderName),
    289                         mLayoutColor);
    290             }
    291         }
    292     }
    293 
    294     public Icon createAvatarSymbol(CharSequence senderName, String symbol, int layoutColor) {
    295         if (symbol.isEmpty() || TextUtils.isDigitsOnly(symbol) ||
    296                 SPECIAL_CHAR_PATTERN.matcher(symbol).find()) {
    297             Icon avatarIcon = Icon.createWithResource(getContext(),
    298                     com.android.internal.R.drawable.messaging_user);
    299             avatarIcon.setTint(findColor(senderName, layoutColor));
    300             return avatarIcon;
    301         } else {
    302             Bitmap bitmap = Bitmap.createBitmap(mAvatarSize, mAvatarSize, Bitmap.Config.ARGB_8888);
    303             Canvas canvas = new Canvas(bitmap);
    304             float radius = mAvatarSize / 2.0f;
    305             int color = findColor(senderName, layoutColor);
    306             mPaint.setColor(color);
    307             canvas.drawCircle(radius, radius, radius, mPaint);
    308             boolean needDarkText = ColorUtils.calculateLuminance(color) > 0.5f;
    309             mTextPaint.setColor(needDarkText ? Color.BLACK : Color.WHITE);
    310             mTextPaint.setTextSize(symbol.length() == 1 ? mAvatarSize * 0.5f : mAvatarSize * 0.3f);
    311             int yPos = (int) (radius - ((mTextPaint.descent() + mTextPaint.ascent()) / 2));
    312             canvas.drawText(symbol, radius, yPos, mTextPaint);
    313             return Icon.createWithBitmap(bitmap);
    314         }
    315     }
    316 
    317     private int findColor(CharSequence senderName, int layoutColor) {
    318         double luminance = NotificationColorUtil.calculateLuminance(layoutColor);
    319         float shift = Math.abs(senderName.hashCode()) % 5 / 4.0f - 0.5f;
    320 
    321         // we need to offset the range if the luminance is too close to the borders
    322         shift += Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - luminance, 0);
    323         shift -= Math.max(COLOR_SHIFT_AMOUNT / 2.0f / 100 - (1.0f - luminance), 0);
    324         return NotificationColorUtil.getShiftedColor(layoutColor,
    325                 (int) (shift * COLOR_SHIFT_AMOUNT));
    326     }
    327 
    328     private String findNameSplit(String existingName) {
    329         String[] split = existingName.split(" ");
    330         if (split.length > 1) {
    331             return Character.toString(split[0].charAt(0))
    332                     + Character.toString(split[1].charAt(0));
    333         }
    334         return existingName.substring(0, 1);
    335     }
    336 
    337     @RemotableViewMethod
    338     public void setLayoutColor(int color) {
    339         mLayoutColor = color;
    340     }
    341 
    342     @RemotableViewMethod
    343     public void setIsOneToOne(boolean oneToOne) {
    344         mIsOneToOne = oneToOne;
    345     }
    346 
    347     @RemotableViewMethod
    348     public void setSenderTextColor(int color) {
    349         mSenderTextColor = color;
    350     }
    351 
    352     @RemotableViewMethod
    353     public void setMessageTextColor(int color) {
    354         mMessageTextColor = color;
    355     }
    356 
    357     public void setUser(Person user) {
    358         mUser = user;
    359         if (mUser.getIcon() == null) {
    360             Icon userIcon = Icon.createWithResource(getContext(),
    361                     com.android.internal.R.drawable.messaging_user);
    362             userIcon.setTint(mLayoutColor);
    363             mUser = mUser.toBuilder().setIcon(userIcon).build();
    364         }
    365     }
    366 
    367     private void addMessagesToGroups(List<MessagingMessage> historicMessages,
    368             List<MessagingMessage> messages, boolean showSpinner) {
    369         // Let's first find our groups!
    370         List<List<MessagingMessage>> groups = new ArrayList<>();
    371         List<Person> senders = new ArrayList<>();
    372 
    373         // Lets first find the groups
    374         findGroups(historicMessages, messages, groups, senders);
    375 
    376         // Let's now create the views and reorder them accordingly
    377         createGroupViews(groups, senders, showSpinner);
    378     }
    379 
    380     private void createGroupViews(List<List<MessagingMessage>> groups,
    381             List<Person> senders, boolean showSpinner) {
    382         mGroups.clear();
    383         for (int groupIndex = 0; groupIndex < groups.size(); groupIndex++) {
    384             List<MessagingMessage> group = groups.get(groupIndex);
    385             MessagingGroup newGroup = null;
    386             // we'll just take the first group that exists or create one there is none
    387             for (int messageIndex = group.size() - 1; messageIndex >= 0; messageIndex--) {
    388                 MessagingMessage message = group.get(messageIndex);
    389                 newGroup = message.getGroup();
    390                 if (newGroup != null) {
    391                     break;
    392                 }
    393             }
    394             if (newGroup == null) {
    395                 newGroup = MessagingGroup.createGroup(mMessagingLinearLayout);
    396                 mAddedGroups.add(newGroup);
    397             }
    398             newGroup.setDisplayImagesAtEnd(mDisplayImagesAtEnd);
    399             newGroup.setLayoutColor(mLayoutColor);
    400             newGroup.setTextColors(mSenderTextColor, mMessageTextColor);
    401             Person sender = senders.get(groupIndex);
    402             CharSequence nameOverride = null;
    403             if (sender != mUser && mNameReplacement != null) {
    404                 nameOverride = mNameReplacement;
    405             }
    406             newGroup.setSender(sender, nameOverride);
    407             newGroup.setSending(groupIndex == (groups.size() - 1) && showSpinner);
    408             mGroups.add(newGroup);
    409 
    410             if (mMessagingLinearLayout.indexOfChild(newGroup) != groupIndex) {
    411                 mMessagingLinearLayout.removeView(newGroup);
    412                 mMessagingLinearLayout.addView(newGroup, groupIndex);
    413             }
    414             newGroup.setMessages(group);
    415         }
    416     }
    417 
    418     private void findGroups(List<MessagingMessage> historicMessages,
    419             List<MessagingMessage> messages, List<List<MessagingMessage>> groups,
    420             List<Person> senders) {
    421         CharSequence currentSenderKey = null;
    422         List<MessagingMessage> currentGroup = null;
    423         int histSize = historicMessages.size();
    424         for (int i = 0; i < histSize + messages.size(); i++) {
    425             MessagingMessage message;
    426             if (i < histSize) {
    427                 message = historicMessages.get(i);
    428             } else {
    429                 message = messages.get(i - histSize);
    430             }
    431             boolean isNewGroup = currentGroup == null;
    432             Person sender = message.getMessage().getSenderPerson();
    433             CharSequence key = sender == null ? null
    434                     : sender.getKey() == null ? sender.getName() : sender.getKey();
    435             isNewGroup |= !TextUtils.equals(key, currentSenderKey);
    436             if (isNewGroup) {
    437                 currentGroup = new ArrayList<>();
    438                 groups.add(currentGroup);
    439                 if (sender == null) {
    440                     sender = mUser;
    441                 }
    442                 senders.add(sender);
    443                 currentSenderKey = key;
    444             }
    445             currentGroup.add(message);
    446         }
    447     }
    448 
    449     /**
    450      * Creates new messages, reusing existing ones if they are available.
    451      *
    452      * @param newMessages the messages to parse.
    453      */
    454     private List<MessagingMessage> createMessages(
    455             List<Notification.MessagingStyle.Message> newMessages, boolean historic) {
    456         List<MessagingMessage> result = new ArrayList<>();;
    457         for (int i = 0; i < newMessages.size(); i++) {
    458             Notification.MessagingStyle.Message m = newMessages.get(i);
    459             MessagingMessage message = findAndRemoveMatchingMessage(m);
    460             if (message == null) {
    461                 message = MessagingMessage.createMessage(this, m);
    462             }
    463             message.setIsHistoric(historic);
    464             result.add(message);
    465         }
    466         return result;
    467     }
    468 
    469     private MessagingMessage findAndRemoveMatchingMessage(Notification.MessagingStyle.Message m) {
    470         for (int i = 0; i < mMessages.size(); i++) {
    471             MessagingMessage existing = mMessages.get(i);
    472             if (existing.sameAs(m)) {
    473                 mMessages.remove(i);
    474                 return existing;
    475             }
    476         }
    477         for (int i = 0; i < mHistoricMessages.size(); i++) {
    478             MessagingMessage existing = mHistoricMessages.get(i);
    479             if (existing.sameAs(m)) {
    480                 mHistoricMessages.remove(i);
    481                 return existing;
    482             }
    483         }
    484         return null;
    485     }
    486 
    487     public void showHistoricMessages(boolean show) {
    488         mShowHistoricMessages = show;
    489         updateHistoricMessageVisibility();
    490     }
    491 
    492     private void updateHistoricMessageVisibility() {
    493         int numHistoric = mHistoricMessages.size();
    494         for (int i = 0; i < numHistoric; i++) {
    495             MessagingMessage existing = mHistoricMessages.get(i);
    496             existing.setVisibility(mShowHistoricMessages ? VISIBLE : GONE);
    497         }
    498         int numGroups = mGroups.size();
    499         for (int i = 0; i < numGroups; i++) {
    500             MessagingGroup group = mGroups.get(i);
    501             int visibleChildren = 0;
    502             List<MessagingMessage> messages = group.getMessages();
    503             int numGroupMessages = messages.size();
    504             for (int j = 0; j < numGroupMessages; j++) {
    505                 MessagingMessage message = messages.get(j);
    506                 if (message.getVisibility() != GONE) {
    507                     visibleChildren++;
    508                 }
    509             }
    510             if (visibleChildren > 0 && group.getVisibility() == GONE) {
    511                 group.setVisibility(VISIBLE);
    512             } else if (visibleChildren == 0 && group.getVisibility() != GONE)   {
    513                 group.setVisibility(GONE);
    514             }
    515         }
    516     }
    517 
    518     @Override
    519     protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    520         super.onLayout(changed, left, top, right, bottom);
    521         if (!mAddedGroups.isEmpty()) {
    522             getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
    523                 @Override
    524                 public boolean onPreDraw() {
    525                     for (MessagingGroup group : mAddedGroups) {
    526                         if (!group.isShown()) {
    527                             continue;
    528                         }
    529                         MessagingPropertyAnimator.fadeIn(group.getAvatar());
    530                         MessagingPropertyAnimator.fadeIn(group.getSenderView());
    531                         MessagingPropertyAnimator.startLocalTranslationFrom(group,
    532                                 group.getHeight(), LINEAR_OUT_SLOW_IN);
    533                     }
    534                     mAddedGroups.clear();
    535                     getViewTreeObserver().removeOnPreDrawListener(this);
    536                     return true;
    537                 }
    538             });
    539         }
    540     }
    541 
    542     public MessagingLinearLayout getMessagingLinearLayout() {
    543         return mMessagingLinearLayout;
    544     }
    545 
    546     public ArrayList<MessagingGroup> getMessagingGroups() {
    547         return mGroups;
    548     }
    549 }
    550