/* * Copyright (C) 2017 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.MessagingGroup.IMAGE_DISPLAY_LOCATION_EXTERNAL; import static com.android.internal.widget.MessagingGroup.IMAGE_DISPLAY_LOCATION_INLINE; import android.annotation.AttrRes; import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.StyleRes; import android.app.Notification; import android.app.Person; import android.app.RemoteInputHistoryItem; import android.content.Context; import android.graphics.Rect; import android.graphics.drawable.Icon; import android.os.Bundle; import android.os.Parcelable; import android.text.TextUtils; import android.util.ArrayMap; import android.util.AttributeSet; import android.util.DisplayMetrics; import android.view.RemotableViewMethod; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.view.animation.Interpolator; import android.view.animation.PathInterpolator; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.RemoteViews; import com.android.internal.R; import com.android.internal.util.ContrastColorUtil; import java.util.ArrayList; import java.util.List; import java.util.Map; /** * A custom-built layout for the Notification.MessagingStyle allows dynamic addition and removal * messages and adapts the layout accordingly. */ @RemoteViews.RemoteView public class MessagingLayout extends FrameLayout implements ImageMessageConsumer, IMessagingLayout { private static final float COLOR_SHIFT_AMOUNT = 60; public static final Interpolator LINEAR_OUT_SLOW_IN = new PathInterpolator(0f, 0f, 0.2f, 1f); public static final Interpolator FAST_OUT_LINEAR_IN = new PathInterpolator(0.4f, 0f, 1f, 1f); public static final Interpolator FAST_OUT_SLOW_IN = new PathInterpolator(0.4f, 0f, 0.2f, 1f); public static final OnLayoutChangeListener MESSAGING_PROPERTY_ANIMATOR = new MessagingPropertyAnimator(); private final PeopleHelper mPeopleHelper = new PeopleHelper(); private List mMessages = new ArrayList<>(); private List mHistoricMessages = new ArrayList<>(); private MessagingLinearLayout mMessagingLinearLayout; private boolean mShowHistoricMessages; private ArrayList mGroups = new ArrayList<>(); private MessagingLinearLayout mImageMessageContainer; private ImageView mRightIconView; private Rect mMessagingClipRect; private int mLayoutColor; private int mSenderTextColor; private int mMessageTextColor; private Icon mAvatarReplacement; private boolean mIsOneToOne; private ArrayList mAddedGroups = new ArrayList<>(); private Person mUser; private CharSequence mNameReplacement; private boolean mIsCollapsed; private ImageResolver mImageResolver; private CharSequence mConversationTitle; private ArrayList mToRecycle = new ArrayList<>(); private boolean mPrecomputedTextEnabled = false; public MessagingLayout(@NonNull Context context) { super(context); } public MessagingLayout(@NonNull Context context, @Nullable AttributeSet attrs) { super(context, attrs); } public MessagingLayout(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) { super(context, attrs, defStyleAttr); } public MessagingLayout(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr, @StyleRes int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } @Override protected void onFinishInflate() { super.onFinishInflate(); mPeopleHelper.init(getContext()); mMessagingLinearLayout = findViewById(R.id.notification_messaging); mImageMessageContainer = findViewById(R.id.conversation_image_message_container); mRightIconView = findViewById(R.id.right_icon); // We still want to clip, but only on the top, since views can temporarily out of bounds // during transitions. DisplayMetrics displayMetrics = getResources().getDisplayMetrics(); int size = Math.max(displayMetrics.widthPixels, displayMetrics.heightPixels); mMessagingClipRect = new Rect(0, 0, size, size); setMessagingClippingDisabled(false); } @RemotableViewMethod public void setAvatarReplacement(Icon icon) { mAvatarReplacement = icon; } @RemotableViewMethod public void setNameReplacement(CharSequence nameReplacement) { mNameReplacement = nameReplacement; } /** * Set this layout to show the collapsed representation. * * @param isCollapsed is it collapsed */ @RemotableViewMethod public void setIsCollapsed(boolean isCollapsed) { mIsCollapsed = isCollapsed; } @RemotableViewMethod public void setLargeIcon(Icon largeIcon) { // Unused } /** * Sets the conversation title of this conversation. * * @param conversationTitle the conversation title */ @RemotableViewMethod public void setConversationTitle(CharSequence conversationTitle) { mConversationTitle = conversationTitle; } /** * Set Messaging data * @param extras Bundle contains messaging data */ @RemotableViewMethod(asyncImpl = "setDataAsync") public void setData(Bundle extras) { bind(parseMessagingData(extras, /* usePrecomputedText= */false)); } @NonNull private MessagingData parseMessagingData(Bundle extras, boolean usePrecomputedText) { Parcelable[] messages = extras.getParcelableArray(Notification.EXTRA_MESSAGES); List newMessages = Notification.MessagingStyle.Message.getMessagesFromBundleArray(messages); Parcelable[] histMessages = extras.getParcelableArray(Notification.EXTRA_HISTORIC_MESSAGES); List newHistoricMessages = Notification.MessagingStyle.Message.getMessagesFromBundleArray(histMessages); setUser(extras.getParcelable(Notification.EXTRA_MESSAGING_PERSON, Person.class)); RemoteInputHistoryItem[] history = (RemoteInputHistoryItem[]) extras.getParcelableArray( Notification.EXTRA_REMOTE_INPUT_HISTORY_ITEMS, RemoteInputHistoryItem.class); addRemoteInputHistoryToMessages(newMessages, history); final Person user = extras.getParcelable(Notification.EXTRA_MESSAGING_PERSON, Person.class); boolean showSpinner = extras.getBoolean(Notification.EXTRA_SHOW_REMOTE_INPUT_SPINNER, false); final List historicMessagingMessages = createMessages(newHistoricMessages, /* isHistoric= */true, usePrecomputedText); final List newMessagingMessages = createMessages(newMessages, /* isHistoric */false, usePrecomputedText); // Let's first find our groups! List> groups = new ArrayList<>(); List senders = new ArrayList<>(); // Lets first find the groups findGroups(historicMessagingMessages, newMessagingMessages, groups, senders); return new MessagingData(user, showSpinner, historicMessagingMessages, newMessagingMessages, groups, senders); } /** * RemotableViewMethod's asyncImpl of {@link #setData(Bundle)}. * This should be called on a background thread, and returns a Runnable which is then must be * called on the main thread to complete the operation and set text. * @param extras Bundle contains messaging data * @hide */ @NonNull public Runnable setDataAsync(Bundle extras) { if (!mPrecomputedTextEnabled) { return () -> setData(extras); } final MessagingData messagingData = parseMessagingData(extras, /* usePrecomputedText= */true); return () -> { finalizeInflate(messagingData.getHistoricMessagingMessages()); finalizeInflate(messagingData.getNewMessagingMessages()); bind(messagingData); }; } /** * enable/disable precomputed text usage * @hide */ public void setPrecomputedTextEnabled(boolean precomputedTextEnabled) { mPrecomputedTextEnabled = precomputedTextEnabled; } private void finalizeInflate(List historicMessagingMessages) { for (MessagingMessage messagingMessage: historicMessagingMessages) { messagingMessage.finalizeInflate(); } } @Override public void setImageResolver(ImageResolver resolver) { mImageResolver = resolver; } private void addRemoteInputHistoryToMessages( List newMessages, RemoteInputHistoryItem[] remoteInputHistory) { if (remoteInputHistory == null || remoteInputHistory.length == 0) { return; } for (int i = remoteInputHistory.length - 1; i >= 0; i--) { RemoteInputHistoryItem historyMessage = remoteInputHistory[i]; Notification.MessagingStyle.Message message = new Notification.MessagingStyle.Message( historyMessage.getText(), 0, (Person) null, true /* remoteHistory */); if (historyMessage.getUri() != null) { message.setData(historyMessage.getMimeType(), historyMessage.getUri()); } newMessages.add(message); } } private void bind(MessagingData messagingData) { setUser(messagingData.getUser()); // Let's now create the views and reorder them accordingly ArrayList oldGroups = new ArrayList<>(mGroups); createGroupViews(messagingData.getGroups(), messagingData.getSenders(), messagingData.getShowSpinner()); // Let's first check which groups were removed altogether and remove them in one animation removeGroups(oldGroups); // Let's remove the remaining messages for (MessagingMessage message : mMessages) { message.removeMessage(mToRecycle); } for (MessagingMessage historicMessage : mHistoricMessages) { historicMessage.removeMessage(mToRecycle); } mMessages = messagingData.getNewMessagingMessages(); mHistoricMessages = messagingData.getHistoricMessagingMessages(); updateHistoricMessageVisibility(); updateTitleAndNamesDisplay(); // after groups are finalized, hide the first sender name if it's showing as the title mPeopleHelper.maybeHideFirstSenderName(mGroups, mIsOneToOne, mConversationTitle); updateImageMessages(); // Recycle everything at the end of the update, now that we know it's no longer needed. for (MessagingLinearLayout.MessagingChild child : mToRecycle) { child.recycle(); } mToRecycle.clear(); } private void updateImageMessages() { View newMessage = null; if (mImageMessageContainer == null) { return; } if (mIsCollapsed && !mGroups.isEmpty()) { // When collapsed, we're displaying the image message in a dedicated container // on the right of the layout instead of inline. Let's add the isolated image there MessagingGroup messagingGroup = mGroups.get(mGroups.size() - 1); MessagingImageMessage isolatedMessage = messagingGroup.getIsolatedMessage(); if (isolatedMessage != null) { newMessage = isolatedMessage.getView(); } } // Remove all messages that don't belong into the image layout View previousMessage = mImageMessageContainer.getChildAt(0); if (previousMessage != newMessage) { mImageMessageContainer.removeView(previousMessage); if (newMessage != null) { mImageMessageContainer.addView(newMessage); } } mImageMessageContainer.setVisibility(newMessage != null ? VISIBLE : GONE); // When showing an image message, do not show the large icon. Removing the drawable // prevents it from being shown in the left_icon view (by the grouping util). if (newMessage != null && mRightIconView != null && mRightIconView.getDrawable() != null) { mRightIconView.setImageDrawable(null); mRightIconView.setVisibility(GONE); } } private void removeGroups(ArrayList oldGroups) { int size = oldGroups.size(); for (int i = 0; i < size; i++) { MessagingGroup group = oldGroups.get(i); if (!mGroups.contains(group)) { List messages = group.getMessages(); boolean wasShown = group.isShown(); mMessagingLinearLayout.removeView(group); if (wasShown && !MessagingLinearLayout.isGone(group)) { mMessagingLinearLayout.addTransientView(group, 0); group.removeGroupAnimated(() -> { mMessagingLinearLayout.removeTransientView(group); group.recycle(); }); } else { mToRecycle.add(group); } mMessages.removeAll(messages); mHistoricMessages.removeAll(messages); } } } private void updateTitleAndNamesDisplay() { Map uniqueNames = mPeopleHelper.mapUniqueNamesToPrefix(mGroups); // Now that we have the correct symbols, let's look what we have cached ArrayMap cachedAvatars = new ArrayMap<>(); for (int i = 0; i < mGroups.size(); i++) { // Let's now set the avatars MessagingGroup group = mGroups.get(i); boolean isOwnMessage = group.getSender() == mUser; CharSequence senderName = group.getSenderName(); if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName) || (mIsOneToOne && mAvatarReplacement != null && !isOwnMessage)) { continue; } String symbol = uniqueNames.get(senderName); Icon cachedIcon = group.getAvatarSymbolIfMatching(senderName, symbol, mLayoutColor); if (cachedIcon != null) { cachedAvatars.put(senderName, cachedIcon); } } for (int i = 0; i < mGroups.size(); i++) { // Let's now set the avatars MessagingGroup group = mGroups.get(i); CharSequence senderName = group.getSenderName(); if (!group.needsGeneratedAvatar() || TextUtils.isEmpty(senderName)) { continue; } if (mIsOneToOne && mAvatarReplacement != null && group.getSender() != mUser) { group.setAvatar(mAvatarReplacement); } else { Icon cachedIcon = cachedAvatars.get(senderName); if (cachedIcon == null) { cachedIcon = createAvatarSymbol(senderName, uniqueNames.get(senderName), mLayoutColor); cachedAvatars.put(senderName, cachedIcon); } group.setCreatedAvatar(cachedIcon, senderName, uniqueNames.get(senderName), mLayoutColor); } } } public Icon createAvatarSymbol(CharSequence senderName, String symbol, int layoutColor) { return mPeopleHelper.createAvatarSymbol(senderName, symbol, layoutColor); } private int findColor(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)); } private String findNameSplit(String existingName) { String[] split = existingName.split(" "); if (split.length > 1) { return Character.toString(split[0].charAt(0)) + Character.toString(split[1].charAt(0)); } return existingName.substring(0, 1); } @RemotableViewMethod public void setLayoutColor(int color) { mLayoutColor = color; } @RemotableViewMethod public void setIsOneToOne(boolean oneToOne) { mIsOneToOne = oneToOne; } @RemotableViewMethod public void setSenderTextColor(int color) { mSenderTextColor = color; } /** * @param color the color of the notification background */ @RemotableViewMethod public void setNotificationBackgroundColor(int color) { // Nothing to do with this } @RemotableViewMethod public void setMessageTextColor(int color) { mMessageTextColor = color; } public void setUser(Person user) { mUser = user; if (mUser.getIcon() == null) { Icon userIcon = Icon.createWithResource(getContext(), com.android.internal.R.drawable.messaging_user); userIcon.setTint(mLayoutColor); mUser = mUser.toBuilder().setIcon(userIcon).build(); } } private void createGroupViews(List> groups, List senders, boolean showSpinner) { mGroups.clear(); for (int groupIndex = 0; groupIndex < groups.size(); groupIndex++) { List group = groups.get(groupIndex); MessagingGroup newGroup = null; // we'll just take the first group that exists or create one there is none for (int messageIndex = group.size() - 1; messageIndex >= 0; messageIndex--) { MessagingMessage message = group.get(messageIndex); newGroup = message.getGroup(); if (newGroup != null) { break; } } if (newGroup == null) { newGroup = MessagingGroup.createGroup(mMessagingLinearLayout); mAddedGroups.add(newGroup); } else if (newGroup.getParent() != mMessagingLinearLayout) { throw new IllegalStateException( "group parent was " + newGroup.getParent() + " but expected " + mMessagingLinearLayout); } newGroup.setImageDisplayLocation(mIsCollapsed ? IMAGE_DISPLAY_LOCATION_EXTERNAL : IMAGE_DISPLAY_LOCATION_INLINE); newGroup.setIsInConversation(false); newGroup.setLayoutColor(mLayoutColor); newGroup.setTextColors(mSenderTextColor, mMessageTextColor); Person sender = senders.get(groupIndex); CharSequence nameOverride = null; if (sender != mUser && mNameReplacement != null) { nameOverride = mNameReplacement; } newGroup.setSingleLine(mIsCollapsed); newGroup.setShowingAvatar(!mIsCollapsed); newGroup.setSender(sender, nameOverride); newGroup.setSending(groupIndex == (groups.size() - 1) && showSpinner); mGroups.add(newGroup); if (mMessagingLinearLayout.indexOfChild(newGroup) != groupIndex) { mMessagingLinearLayout.removeView(newGroup); mMessagingLinearLayout.addView(newGroup, groupIndex); } newGroup.setMessages(group); } } private void findGroups(List historicMessages, List messages, List> groups, List senders) { CharSequence currentSenderKey = null; List currentGroup = null; int histSize = historicMessages.size(); for (int i = 0; i < histSize + messages.size(); i++) { MessagingMessage message; if (i < histSize) { message = historicMessages.get(i); } else { message = messages.get(i - histSize); } boolean isNewGroup = currentGroup == null; Person sender = message.getMessage() == null ? null : message.getMessage().getSenderPerson(); CharSequence key = sender == null ? null : sender.getKey() == null ? sender.getName() : sender.getKey(); isNewGroup |= !TextUtils.equals(key, currentSenderKey); if (isNewGroup) { currentGroup = new ArrayList<>(); groups.add(currentGroup); if (sender == null) { sender = mUser; } senders.add(sender); currentSenderKey = key; } currentGroup.add(message); } } /** * Creates new messages, reusing existing ones if they are available. * * @param newMessages the messages to parse. */ private List createMessages( List newMessages, boolean isHistoric, boolean usePrecomputedText) { List result = new ArrayList<>(); for (int i = 0; i < newMessages.size(); i++) { Notification.MessagingStyle.Message m = newMessages.get(i); MessagingMessage message = findAndRemoveMatchingMessage(m); if (message == null) { message = MessagingMessage.createMessage(this, m, mImageResolver, usePrecomputedText); } message.setIsHistoric(isHistoric); result.add(message); } return result; } private MessagingMessage findAndRemoveMatchingMessage(Notification.MessagingStyle.Message m) { for (int i = 0; i < mMessages.size(); i++) { MessagingMessage existing = mMessages.get(i); if (existing.sameAs(m)) { mMessages.remove(i); return existing; } } for (int i = 0; i < mHistoricMessages.size(); i++) { MessagingMessage existing = mHistoricMessages.get(i); if (existing.sameAs(m)) { mHistoricMessages.remove(i); return existing; } } return null; } public void showHistoricMessages(boolean show) { mShowHistoricMessages = show; updateHistoricMessageVisibility(); } private void updateHistoricMessageVisibility() { int numHistoric = mHistoricMessages.size(); for (int i = 0; i < numHistoric; i++) { MessagingMessage existing = mHistoricMessages.get(i); existing.setVisibility(mShowHistoricMessages ? VISIBLE : GONE); } int numGroups = mGroups.size(); for (int i = 0; i < numGroups; i++) { MessagingGroup group = mGroups.get(i); int visibleChildren = 0; List messages = group.getMessages(); int numGroupMessages = messages.size(); for (int j = 0; j < numGroupMessages; j++) { MessagingMessage message = messages.get(j); if (message.getVisibility() != GONE) { visibleChildren++; } } if (visibleChildren > 0 && group.getVisibility() == GONE) { group.setVisibility(VISIBLE); } else if (visibleChildren == 0 && group.getVisibility() != GONE) { group.setVisibility(GONE); } } } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); if (!mAddedGroups.isEmpty()) { getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { @Override public boolean onPreDraw() { for (MessagingGroup group : mAddedGroups) { if (!group.isShown()) { continue; } MessagingPropertyAnimator.fadeIn(group.getAvatar()); MessagingPropertyAnimator.fadeIn(group.getSenderView()); MessagingPropertyAnimator.startLocalTranslationFrom(group, group.getHeight(), LINEAR_OUT_SLOW_IN); } mAddedGroups.clear(); getViewTreeObserver().removeOnPreDrawListener(this); return true; } }); } } public MessagingLinearLayout getMessagingLinearLayout() { return mMessagingLinearLayout; } @Nullable public ViewGroup getImageMessageContainer() { return mImageMessageContainer; } public ArrayList getMessagingGroups() { return mGroups; } @Override public void setMessagingClippingDisabled(boolean clippingDisabled) { mMessagingLinearLayout.setClipBounds(clippingDisabled ? null : mMessagingClipRect); } }