/* * 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 android.annotation.AttrRes; import android.annotation.IntDef; import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.StyleRes; import android.app.Flags; import android.app.Person; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.Resources; import android.graphics.Color; import android.graphics.Point; import android.graphics.Rect; import android.graphics.drawable.Icon; import android.text.TextUtils; import android.util.AttributeSet; import android.util.DisplayMetrics; import android.util.TypedValue; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; import android.view.ViewTreeObserver; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.ProgressBar; import android.widget.RemoteViews; import android.widget.TextView; import com.android.internal.R; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.List; /** * A message of a {@link MessagingLayout}. */ @RemoteViews.RemoteView public class MessagingGroup extends NotificationOptimizedLinearLayout implements MessagingLinearLayout.MessagingChild { private static final MessagingPool sInstancePool = new MessagingPool<>(10); /** * Images are displayed inline. */ public static final int IMAGE_DISPLAY_LOCATION_INLINE = 0; /** * Images are displayed at the end of the group. */ public static final int IMAGE_DISPLAY_LOCATION_AT_END = 1; /** * Images are displayed externally. */ public static final int IMAGE_DISPLAY_LOCATION_EXTERNAL = 2; private MessagingLinearLayout mMessageContainer; ImageFloatingTextView mSenderView; private ImageView mAvatarView; private View mAvatarContainer; private String mAvatarSymbol = ""; private int mLayoutColor; private CharSequence mAvatarName = ""; private Icon mAvatarIcon; private int mTextColor; private int mSendingTextColor; private List mMessages; private ArrayList mAddedMessages = new ArrayList<>(); private boolean mFirstLayout; private boolean mIsHidingAnimated; private boolean mNeedsGeneratedAvatar; private Person mSender; private @ImageDisplayLocation int mImageDisplayLocation; private ViewGroup mImageContainer; private MessagingImageMessage mIsolatedMessage; private boolean mClippingDisabled; private Point mDisplaySize = new Point(); private ProgressBar mSendingSpinner; private View mSendingSpinnerContainer; private boolean mShowingAvatar = true; private CharSequence mSenderName; private boolean mSingleLine = false; private LinearLayout mContentContainer; private int mRequestedMaxDisplayedLines = Integer.MAX_VALUE; private int mSenderTextPaddingSingleLine; private boolean mIsFirstGroupInLayout = true; private boolean mCanHideSenderIfFirst; private boolean mIsInConversation = true; private ViewGroup mMessagingIconContainer; private int mConversationContentStart; private int mNonConversationContentStart; private int mNonConversationPaddingStart; private int mConversationAvatarSize; private int mNonConversationAvatarSize; private int mNotificationTextMarginTop; public MessagingGroup(@NonNull Context context) { super(context); } public MessagingGroup(@NonNull Context context, @Nullable AttributeSet attrs) { super(context, attrs); } public MessagingGroup(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) { super(context, attrs, defStyleAttr); } public MessagingGroup(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr, @StyleRes int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } @Override protected void onFinishInflate() { super.onFinishInflate(); mMessageContainer = findViewById(R.id.group_message_container); mSenderView = findViewById(R.id.message_name); mAvatarView = findViewById(R.id.message_icon); mImageContainer = findViewById(R.id.messaging_group_icon_container); mSendingSpinner = findViewById(R.id.messaging_group_sending_progress); mMessagingIconContainer = findViewById(R.id.message_icon_container); mContentContainer = findViewById(R.id.messaging_group_content_container); mSendingSpinnerContainer = findViewById(R.id.messaging_group_sending_progress_container); Resources res = getResources(); DisplayMetrics displayMetrics = res.getDisplayMetrics(); mDisplaySize.x = displayMetrics.widthPixels; mDisplaySize.y = displayMetrics.heightPixels; mSenderTextPaddingSingleLine = res.getDimensionPixelSize( R.dimen.messaging_group_singleline_sender_padding_end); mConversationContentStart = res.getDimensionPixelSize(R.dimen.conversation_content_start); mNonConversationContentStart = res.getDimensionPixelSize( R.dimen.notification_content_margin_start); mNonConversationPaddingStart = res.getDimensionPixelSize( R.dimen.messaging_layout_icon_padding_start); mConversationAvatarSize = res.getDimensionPixelSize(R.dimen.messaging_avatar_size); mNonConversationAvatarSize = res.getDimensionPixelSize( R.dimen.notification_icon_circle_size); mNotificationTextMarginTop = res.getDimensionPixelSize( R.dimen.notification_text_margin_top); } public void updateClipRect() { // We want to clip to the senderName if it's available, otherwise our images will come // from a weird position Rect clipRect; if (mSenderView.getVisibility() != View.GONE && !mClippingDisabled) { int top; if (mSingleLine) { top = 0; } else { top = getDistanceFromParent(mSenderView, mContentContainer) - getDistanceFromParent(mMessageContainer, mContentContainer) + mSenderView.getHeight(); } int size = Math.max(mDisplaySize.x, mDisplaySize.y); clipRect = new Rect(-size, top, size, size); } else { clipRect = null; } mMessageContainer.setClipBounds(clipRect); } private int getDistanceFromParent(View searchedView, ViewGroup parent) { int position = 0; View view = searchedView; while(view != parent) { position += view.getTop() + view.getTranslationY(); view = (View) view.getParent(); } return position; } public void setSender(Person sender, CharSequence nameOverride) { mSender = sender; if (nameOverride == null) { nameOverride = sender.getName(); } if (Flags.cleanUpSpansAndNewLines() && nameOverride != null) { // remove formatting from sender name nameOverride = nameOverride.toString(); } mSenderName = nameOverride; if (mSingleLine && !TextUtils.isEmpty(nameOverride)) { nameOverride = mContext.getResources().getString( R.string.conversation_single_line_name_display, nameOverride); } mSenderView.setText(nameOverride); mNeedsGeneratedAvatar = sender.getIcon() == null; if (!mNeedsGeneratedAvatar) { setAvatar(sender.getIcon()); } updateSenderVisibility(); } /** * Should the avatar be shown for this view. * * @param showingAvatar should it be shown */ public void setShowingAvatar(boolean showingAvatar) { mAvatarView.setVisibility(showingAvatar ? VISIBLE : GONE); mShowingAvatar = showingAvatar; } public void setSending(boolean sending) { int visibility = sending ? VISIBLE : GONE; if (mSendingSpinnerContainer.getVisibility() != visibility) { mSendingSpinnerContainer.setVisibility(visibility); updateMessageColor(); } } private int calculateSendingTextColor() { TypedValue alphaValue = new TypedValue(); mContext.getResources().getValue( R.dimen.notification_secondary_text_disabled_alpha, alphaValue, true); float alpha = alphaValue.getFloat(); return Color.valueOf( Color.red(mTextColor), Color.green(mTextColor), Color.blue(mTextColor), alpha).toArgb(); } public void setAvatar(Icon icon) { mAvatarIcon = icon; if (mShowingAvatar || icon == null) { mAvatarView.setImageIcon(icon); } mAvatarSymbol = ""; mAvatarName = ""; } static MessagingGroup createGroup(MessagingLinearLayout layout) {; MessagingGroup createdGroup = sInstancePool.acquire(); if (createdGroup == null) { createdGroup = (MessagingGroup) LayoutInflater.from(layout.getContext()).inflate( R.layout.notification_template_messaging_group, layout, false); createdGroup.addOnLayoutChangeListener(MessagingLayout.MESSAGING_PROPERTY_ANIMATOR); } layout.addView(createdGroup); return createdGroup; } public void removeMessage(MessagingMessage messagingMessage, ArrayList toRecycle) { View view = messagingMessage.getView(); boolean wasShown = view.isShown(); ViewGroup messageParent = (ViewGroup) view.getParent(); if (messageParent == null) { return; } messageParent.removeView(view); if (wasShown && !MessagingLinearLayout.isGone(view)) { messageParent.addTransientView(view, 0); performRemoveAnimation(view, () -> { messageParent.removeTransientView(view); messagingMessage.recycle(); }); } else { toRecycle.add(messagingMessage); } } public void recycle() { if (mIsolatedMessage != null) { mImageContainer.removeView(mIsolatedMessage); } for (int i = 0; i < mMessages.size(); i++) { MessagingMessage message = mMessages.get(i); mMessageContainer.removeView(message.getView()); message.recycle(); } setAvatar(null); mAvatarView.setAlpha(1.0f); mAvatarView.setTranslationY(0.0f); mSenderView.setAlpha(1.0f); mSenderView.setTranslationY(0.0f); setAlpha(1.0f); mIsolatedMessage = null; mMessages = null; mSenderName = null; mAddedMessages.clear(); mFirstLayout = true; setCanHideSenderIfFirst(false); setIsFirstInLayout(true); setMaxDisplayedLines(Integer.MAX_VALUE); setSingleLine(false); setShowingAvatar(true); MessagingPropertyAnimator.recycle(this); sInstancePool.release(MessagingGroup.this); } public void removeGroupAnimated(Runnable endAction) { performRemoveAnimation(this, () -> { setAlpha(1.0f); MessagingPropertyAnimator.setToLaidOutPosition(this); if (endAction != null) { endAction.run(); } }); } public void performRemoveAnimation(View message, Runnable endAction) { performRemoveAnimation(message, -message.getHeight(), endAction); } private void performRemoveAnimation(View view, int disappearTranslation, Runnable endAction) { MessagingPropertyAnimator.startLocalTranslationTo(view, disappearTranslation, MessagingLayout.FAST_OUT_LINEAR_IN); MessagingPropertyAnimator.fadeOut(view, endAction); } public CharSequence getSenderName() { return mSenderName; } public static void dropCache() { sInstancePool.clear(); } @Override public int getMeasuredType() { if (mIsolatedMessage != null) { // We only want to show one group if we have an inline image, so let's return shortened // to avoid displaying the other ones. return MEASURED_SHORTENED; } boolean hasNormal = false; for (int i = mMessageContainer.getChildCount() - 1; i >= 0; i--) { View child = mMessageContainer.getChildAt(i); if (child.getVisibility() == GONE) { continue; } if (child instanceof MessagingLinearLayout.MessagingChild) { int type = ((MessagingLinearLayout.MessagingChild) child).getMeasuredType(); boolean tooSmall = type == MEASURED_TOO_SMALL; final MessagingLinearLayout.LayoutParams lp = (MessagingLinearLayout.LayoutParams) child.getLayoutParams(); tooSmall |= lp.hide; if (tooSmall) { if (hasNormal) { return MEASURED_SHORTENED; } else { return MEASURED_TOO_SMALL; } } else if (type == MEASURED_SHORTENED) { return MEASURED_SHORTENED; } else { hasNormal = true; } } } return MEASURED_NORMAL; } @Override public int getConsumedLines() { int result = 0; for (int i = 0; i < mMessageContainer.getChildCount(); i++) { View child = mMessageContainer.getChildAt(i); if (child instanceof MessagingLinearLayout.MessagingChild) { result += ((MessagingLinearLayout.MessagingChild) child).getConsumedLines(); } } result = mIsolatedMessage != null ? Math.max(result, 1) : result; // A group is usually taking up quite some space with the padding and the name, let's add 1 return result + 1; } @Override public void setMaxDisplayedLines(int lines) { mRequestedMaxDisplayedLines = lines; updateMaxDisplayedLines(); } private void updateMaxDisplayedLines() { mMessageContainer.setMaxDisplayedLines(mSingleLine ? 1 : mRequestedMaxDisplayedLines); } @Override public void hideAnimated() { setIsHidingAnimated(true); removeGroupAnimated(() -> setIsHidingAnimated(false)); } @Override public boolean isHidingAnimated() { return mIsHidingAnimated; } @Override public void setIsFirstInLayout(boolean first) { if (first != mIsFirstGroupInLayout) { mIsFirstGroupInLayout = first; updateSenderVisibility(); } } /** * @param canHide true if the sender can be hidden if it is first */ public void setCanHideSenderIfFirst(boolean canHide) { if (mCanHideSenderIfFirst != canHide) { mCanHideSenderIfFirst = canHide; updateSenderVisibility(); } } private void updateSenderVisibility() { boolean hidden = (mIsFirstGroupInLayout || mSingleLine) && mCanHideSenderIfFirst || TextUtils.isEmpty(mSenderName); mSenderView.setVisibility(hidden ? GONE : VISIBLE); } @Override public boolean hasDifferentHeightWhenFirst() { return mCanHideSenderIfFirst && !mSingleLine && !TextUtils.isEmpty(mSenderName); } private void setIsHidingAnimated(boolean isHiding) { ViewParent parent = getParent(); mIsHidingAnimated = isHiding; invalidate(); if (parent instanceof ViewGroup) { ((ViewGroup) parent).invalidate(); } } @Override public boolean hasOverlappingRendering() { return false; } public Icon getAvatarSymbolIfMatching(CharSequence avatarName, String avatarSymbol, int layoutColor) { if (mAvatarName.equals(avatarName) && mAvatarSymbol.equals(avatarSymbol) && layoutColor == mLayoutColor) { return mAvatarIcon; } return null; } public void setCreatedAvatar(Icon cachedIcon, CharSequence avatarName, String avatarSymbol, int layoutColor) { if (!mAvatarName.equals(avatarName) || !mAvatarSymbol.equals(avatarSymbol) || layoutColor != mLayoutColor) { setAvatar(cachedIcon); mAvatarSymbol = avatarSymbol; setLayoutColor(layoutColor); mAvatarName = avatarName; } } public void setTextColors(int senderTextColor, int messageTextColor) { mTextColor = messageTextColor; mSendingTextColor = calculateSendingTextColor(); updateMessageColor(); mSenderView.setTextColor(senderTextColor); } public void setLayoutColor(int layoutColor) { if (layoutColor != mLayoutColor){ mLayoutColor = layoutColor; mSendingSpinner.setIndeterminateTintList(ColorStateList.valueOf(mLayoutColor)); } } private void updateMessageColor() { if (mMessages != null) { int color = mSendingSpinnerContainer.getVisibility() == View.VISIBLE ? mSendingTextColor : mTextColor; for (MessagingMessage message : mMessages) { final boolean isRemoteInputHistory = message.getMessage() != null && message.getMessage().isRemoteInputHistory(); message.setColor(isRemoteInputHistory ? color : mTextColor); } } } public void setMessages(List group) { // Let's now make sure all children are added and in the correct order int textMessageIndex = 0; MessagingImageMessage isolatedMessage = null; for (int messageIndex = 0; messageIndex < group.size(); messageIndex++) { MessagingMessage message = group.get(messageIndex); if (message.getGroup() != this) { message.setMessagingGroup(this); mAddedMessages.add(message); } boolean isImage = message instanceof MessagingImageMessage; if (mImageDisplayLocation != IMAGE_DISPLAY_LOCATION_INLINE && isImage) { isolatedMessage = (MessagingImageMessage) message; } else { if (removeFromParentIfDifferent(message, mMessageContainer)) { ViewGroup.LayoutParams layoutParams = message.getView().getLayoutParams(); if (layoutParams != null && !(layoutParams instanceof MessagingLinearLayout.LayoutParams)) { message.getView().setLayoutParams( mMessageContainer.generateDefaultLayoutParams()); } mMessageContainer.addView(message.getView(), textMessageIndex); } if (isImage) { ((MessagingImageMessage) message).setIsolated(false); } // Let's sort them properly if (textMessageIndex != mMessageContainer.indexOfChild(message.getView())) { mMessageContainer.removeView(message.getView()); mMessageContainer.addView(message.getView(), textMessageIndex); } textMessageIndex++; } } if (isolatedMessage != null) { if (mImageDisplayLocation == IMAGE_DISPLAY_LOCATION_AT_END && removeFromParentIfDifferent(isolatedMessage, mImageContainer)) { mImageContainer.removeAllViews(); mImageContainer.addView(isolatedMessage.getView()); } else if (mImageDisplayLocation == IMAGE_DISPLAY_LOCATION_EXTERNAL) { mImageContainer.removeAllViews(); } isolatedMessage.setIsolated(true); } else if (mIsolatedMessage != null) { mImageContainer.removeAllViews(); } mIsolatedMessage = isolatedMessage; updateImageContainerVisibility(); mMessages = group; updateMessageColor(); } private void updateImageContainerVisibility() { mImageContainer.setVisibility(mIsolatedMessage != null && mImageDisplayLocation == IMAGE_DISPLAY_LOCATION_AT_END ? View.VISIBLE : View.GONE); } /** * Remove the message from the parent if the parent isn't the one provided * @return whether the message was removed */ private boolean removeFromParentIfDifferent(MessagingMessage message, ViewGroup newParent) { ViewParent parent = message.getView().getParent(); if (parent != newParent) { if (parent instanceof ViewGroup) { ((ViewGroup) parent).removeView(message.getView()); } return true; } return false; } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); if (!mAddedMessages.isEmpty()) { final boolean firstLayout = mFirstLayout; getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { @Override public boolean onPreDraw() { for (MessagingMessage message : mAddedMessages) { if (!message.getView().isShown()) { continue; } MessagingPropertyAnimator.fadeIn(message.getView()); if (!firstLayout) { MessagingPropertyAnimator.startLocalTranslationFrom(message.getView(), message.getView().getHeight(), MessagingLayout.LINEAR_OUT_SLOW_IN); } } mAddedMessages.clear(); getViewTreeObserver().removeOnPreDrawListener(this); return true; } }); } mFirstLayout = false; updateClipRect(); } /** * Calculates the group compatibility between this and another group. * * @param otherGroup the other group to compare it with * * @return 0 if the groups are totally incompatible or 1 + the number of matching messages if * they match. */ public int calculateGroupCompatibility(MessagingGroup otherGroup) { if (TextUtils.equals(getSenderName(),otherGroup.getSenderName())) { int result = 1; for (int i = 0; i < mMessages.size() && i < otherGroup.mMessages.size(); i++) { MessagingMessage ownMessage = mMessages.get(mMessages.size() - 1 - i); MessagingMessage otherMessage = otherGroup.mMessages.get( otherGroup.mMessages.size() - 1 - i); if (!ownMessage.sameAs(otherMessage)) { return result; } result++; } return result; } return 0; } public TextView getSenderView() { return mSenderView; } public View getAvatar() { return mAvatarView; } public Icon getAvatarIcon() { return mAvatarIcon; } public MessagingLinearLayout getMessageContainer() { return mMessageContainer; } public MessagingImageMessage getIsolatedMessage() { return mIsolatedMessage; } public boolean needsGeneratedAvatar() { return mNeedsGeneratedAvatar; } public Person getSender() { return mSender; } public void setClippingDisabled(boolean disabled) { mClippingDisabled = disabled; } public void setImageDisplayLocation(@ImageDisplayLocation int displayLocation) { if (mImageDisplayLocation != displayLocation) { mImageDisplayLocation = displayLocation; updateImageContainerVisibility(); } } public List getMessages() { return mMessages; } /** * Set this layout to be single line and therefore displaying both the sender and the text on * the same line. * * @param singleLine should be layout be single line */ public void setSingleLine(boolean singleLine) { if (singleLine != mSingleLine) { mSingleLine = singleLine; MarginLayoutParams p = (MarginLayoutParams) mMessageContainer.getLayoutParams(); p.topMargin = singleLine ? 0 : mNotificationTextMarginTop; mMessageContainer.setLayoutParams(p); mContentContainer.setOrientation( singleLine ? LinearLayout.HORIZONTAL : LinearLayout.VERTICAL); MarginLayoutParams layoutParams = (MarginLayoutParams) mSenderView.getLayoutParams(); layoutParams.setMarginEnd(singleLine ? mSenderTextPaddingSingleLine : 0); mSenderView.setSingleLine(singleLine); updateMaxDisplayedLines(); updateClipRect(); updateSenderVisibility(); } } public boolean isSingleLine() { return mSingleLine; } /** * Set this group to be displayed in a conversation and adjust the visual appearance * * @param isInConversation is this in a conversation */ public void setIsInConversation(boolean isInConversation) { if (mIsInConversation != isInConversation) { mIsInConversation = isInConversation; MarginLayoutParams layoutParams = (MarginLayoutParams) mMessagingIconContainer.getLayoutParams(); layoutParams.width = mIsInConversation ? mConversationContentStart : mNonConversationContentStart; mMessagingIconContainer.setLayoutParams(layoutParams); int imagePaddingStart = isInConversation ? 0 : mNonConversationPaddingStart; mMessagingIconContainer.setPaddingRelative(imagePaddingStart, 0, 0, 0); ViewGroup.LayoutParams avatarLayoutParams = mAvatarView.getLayoutParams(); int size = mIsInConversation ? mConversationAvatarSize : mNonConversationAvatarSize; avatarLayoutParams.height = size; avatarLayoutParams.width = size; mAvatarView.setLayoutParams(avatarLayoutParams); } } @IntDef(prefix = {"IMAGE_DISPLAY_LOCATION_"}, value = { IMAGE_DISPLAY_LOCATION_INLINE, IMAGE_DISPLAY_LOCATION_AT_END, IMAGE_DISPLAY_LOCATION_EXTERNAL }) @Retention(RetentionPolicy.SOURCE) private @interface ImageDisplayLocation { } }