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