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
17package com.android.internal.widget;
18
19import android.annotation.AttrRes;
20import android.annotation.NonNull;
21import android.annotation.Nullable;
22import android.annotation.StyleRes;
23import android.app.Notification;
24import android.app.Person;
25import android.content.Context;
26import android.graphics.Bitmap;
27import android.graphics.Canvas;
28import android.graphics.Color;
29import android.graphics.Paint;
30import android.graphics.Rect;
31import android.graphics.drawable.Icon;
32import android.os.Bundle;
33import android.os.Parcelable;
34import android.text.TextUtils;
35import android.util.ArrayMap;
36import android.util.AttributeSet;
37import android.util.DisplayMetrics;
38import android.view.RemotableViewMethod;
39import android.view.ViewTreeObserver;
40import android.view.animation.Interpolator;
41import android.view.animation.PathInterpolator;
42import android.widget.FrameLayout;
43import android.widget.RemoteViews;
44import android.widget.TextView;
45
46import com.android.internal.R;
47import com.android.internal.graphics.ColorUtils;
48import com.android.internal.util.NotificationColorUtil;
49
50import java.util.ArrayList;
51import java.util.List;
52import java.util.function.Consumer;
53import 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
60public 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