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