blob: 06613e36fcb8e2b8f8b39e66688c98274614c6e8 [file] [log] [blame]
/*
* Copyright (C) 2012 Google Inc.
* Licensed to 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.mail.browse;
import android.animation.Animator;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.content.ClipData;
import android.content.ClipData.Item;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.Point;
import android.graphics.Rect;
import android.graphics.Shader;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.text.Layout.Alignment;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.StaticLayout;
import android.text.TextPaint;
import android.text.TextUtils;
import android.text.TextUtils.TruncateAt;
import android.text.format.DateUtils;
import android.text.style.CharacterStyle;
import android.text.style.ForegroundColorSpan;
import android.text.style.TextAppearanceSpan;
import android.text.util.Rfc822Token;
import android.text.util.Rfc822Tokenizer;
import android.util.SparseArray;
import android.util.TypedValue;
import android.view.DragEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.animation.DecelerateInterpolator;
import android.widget.AbsListView;
import android.widget.AbsListView.OnScrollListener;
import android.widget.TextView;
import com.android.mail.R;
import com.android.mail.R.drawable;
import com.android.mail.R.integer;
import com.android.mail.R.string;
import com.android.mail.analytics.Analytics;
import com.android.mail.bitmap.AttachmentDrawable;
import com.android.mail.bitmap.AttachmentGridDrawable;
import com.android.mail.bitmap.ContactCheckableGridDrawable;
import com.android.mail.bitmap.ContactDrawable;
import com.android.mail.browse.ConversationItemViewModel.SenderFragment;
import com.android.mail.perf.Timer;
import com.android.mail.providers.Address;
import com.android.mail.providers.Attachment;
import com.android.mail.providers.Conversation;
import com.android.mail.providers.Folder;
import com.android.mail.providers.UIProvider;
import com.android.mail.providers.UIProvider.AttachmentRendition;
import com.android.mail.providers.UIProvider.ConversationColumns;
import com.android.mail.providers.UIProvider.ConversationListIcon;
import com.android.mail.providers.UIProvider.FolderType;
import com.android.mail.ui.AnimatedAdapter;
import com.android.mail.ui.ControllableActivity;
import com.android.mail.ui.ConversationSelectionSet;
import com.android.mail.ui.ConversationSetObserver;
import com.android.mail.ui.DividedImageCanvas;
import com.android.mail.ui.DividedImageCanvas.InvalidateCallback;
import com.android.mail.ui.FolderDisplayer;
import com.android.mail.ui.SwipeableItemView;
import com.android.mail.ui.SwipeableListView;
import com.android.mail.ui.ViewMode;
import com.android.mail.utils.FolderUri;
import com.android.mail.utils.HardwareLayerEnabler;
import com.android.mail.utils.LogTag;
import com.android.mail.utils.LogUtils;
import com.android.mail.utils.Utils;
import com.google.common.annotations.VisibleForTesting;
import java.util.ArrayList;
public class ConversationItemView extends View
implements SwipeableItemView, ToggleableItem, InvalidateCallback, OnScrollListener,
ConversationSetObserver {
// Timer.
private static int sLayoutCount = 0;
private static Timer sTimer; // Create the sTimer here if you need to do
// perf analysis.
private static final int PERF_LAYOUT_ITERATIONS = 50;
private static final String PERF_TAG_LAYOUT = "CCHV.layout";
private static final String PERF_TAG_CALCULATE_TEXTS_BITMAPS = "CCHV.txtsbmps";
private static final String PERF_TAG_CALCULATE_SENDER_SUBJECT = "CCHV.sendersubj";
private static final String PERF_TAG_CALCULATE_FOLDERS = "CCHV.folders";
private static final String PERF_TAG_CALCULATE_COORDINATES = "CCHV.coordinates";
private static final String LOG_TAG = LogTag.getLogTag();
// Static bitmaps.
private static Bitmap STAR_OFF;
private static Bitmap STAR_ON;
private static Bitmap ATTACHMENT;
private static Bitmap ONLY_TO_ME;
private static Bitmap TO_ME_AND_OTHERS;
private static Bitmap IMPORTANT_ONLY_TO_ME;
private static Bitmap IMPORTANT_TO_ME_AND_OTHERS;
private static Bitmap IMPORTANT_TO_OTHERS;
private static Bitmap STATE_REPLIED;
private static Bitmap STATE_FORWARDED;
private static Bitmap STATE_REPLIED_AND_FORWARDED;
private static Bitmap STATE_CALENDAR_INVITE;
private static Bitmap VISIBLE_CONVERSATION_CARET;
private static Drawable RIGHT_EDGE_TABLET;
private static Drawable PLACEHOLDER;
private static Drawable PROGRESS_BAR;
private static String sSendersSplitToken;
private static String sElidedPaddingToken;
private static String sOverflowCountFormat;
// Static colors.
private static int sSendersTextColorRead;
private static int sSendersTextColorUnread;
private static int sDateTextColor;
private static int sStarTouchSlop;
private static int sSenderImageTouchSlop;
private static int sShrinkAnimationDuration;
private static int sSlideAnimationDuration;
private static int sOverflowCountMax;
private static int sCabAnimationDuration;
// Static paints.
private static final TextPaint sPaint = new TextPaint();
private static final TextPaint sFoldersPaint = new TextPaint();
private static final Paint sCheckBackgroundPaint = new Paint();
// Backgrounds for different states.
private final SparseArray<Drawable> mBackgrounds = new SparseArray<Drawable>();
// Dimensions and coordinates.
private int mViewWidth = -1;
/** The view mode at which we calculated mViewWidth previously. */
private int mPreviousMode;
private int mInfoIconX;
private int mDateX;
private int mPaperclipX;
private int mSendersWidth;
/** Whether we are on a tablet device or not */
private final boolean mTabletDevice;
/** Whether we are on an expansive tablet */
private final boolean mIsExpansiveTablet;
/** When in conversation mode, true if the list is hidden */
private final boolean mListCollapsible;
@VisibleForTesting
ConversationItemViewCoordinates mCoordinates;
private ConversationItemViewCoordinates.Config mConfig;
private final Context mContext;
public ConversationItemViewModel mHeader;
private boolean mDownEvent;
private boolean mSelected = false;
private ConversationSelectionSet mSelectedConversationSet;
private Folder mDisplayedFolder;
private boolean mStarEnabled;
private boolean mSwipeEnabled;
private int mLastTouchX;
private int mLastTouchY;
private AnimatedAdapter mAdapter;
private float mAnimatedHeightFraction = 1.0f;
private final String mAccount;
private ControllableActivity mActivity;
private final TextView mSubjectTextView;
private final TextView mSendersTextView;
private int mGadgetMode;
private boolean mAttachmentPreviewsEnabled;
private boolean mParallaxSpeedAlternative;
private boolean mParallaxDirectionAlternative;
private static int sFoldersLeftPadding;
private static TextAppearanceSpan sSubjectTextUnreadSpan;
private static TextAppearanceSpan sSubjectTextReadSpan;
private static ForegroundColorSpan sSnippetTextUnreadSpan;
private static ForegroundColorSpan sSnippetTextReadSpan;
private static int sScrollSlop;
private static CharacterStyle sActivatedTextSpan;
private final ContactCheckableGridDrawable mSendersImageView;
private final AttachmentGridDrawable mAttachmentsView;
/** The resource id of the color to use to override the background. */
private int mBackgroundOverrideResId = -1;
/** The bitmap to use, or <code>null</code> for the default */
private Bitmap mPhotoBitmap = null;
private Rect mPhotoRect = null;
/**
* A listener for clicks on the various areas of a conversation item.
*/
public interface ConversationItemAreaClickListener {
/** Called when the info icon is clicked. */
void onInfoIconClicked();
/** Called when the star is clicked. */
void onStarClicked();
}
/** If set, it will steal all clicks for which the interface has a click method. */
private ConversationItemAreaClickListener mConversationItemAreaClickListener = null;
static {
sPaint.setAntiAlias(true);
sFoldersPaint.setAntiAlias(true);
sCheckBackgroundPaint.setColor(Color.GRAY);
}
/**
* Handles displaying folders in a conversation header view.
*/
static class ConversationItemFolderDisplayer extends FolderDisplayer {
private int mFoldersCount;
public ConversationItemFolderDisplayer(Context context) {
super(context);
}
@Override
public void loadConversationFolders(Conversation conv, final FolderUri ignoreFolderUri,
final int ignoreFolderType) {
super.loadConversationFolders(conv, ignoreFolderUri, ignoreFolderType);
mFoldersCount = mFoldersSortedSet.size();
}
@Override
public void reset() {
super.reset();
mFoldersCount = 0;
}
public boolean hasVisibleFolders() {
return mFoldersCount > 0;
}
private int measureFolders(int availableSpace, int cellSize) {
int totalWidth = 0;
boolean firstTime = true;
for (Folder f : mFoldersSortedSet) {
final String folderString = f.name;
int width = (int) sFoldersPaint.measureText(folderString) + cellSize;
if (firstTime) {
firstTime = false;
} else {
width += sFoldersLeftPadding;
}
totalWidth += width;
if (totalWidth > availableSpace) {
break;
}
}
return totalWidth;
}
public void drawFolders(Canvas canvas, ConversationItemViewCoordinates coordinates) {
if (mFoldersCount == 0) {
return;
}
final int xMinStart = coordinates.foldersX;
final int xEnd = coordinates.foldersXEnd;
final int y = coordinates.foldersY;
final int height = coordinates.foldersHeight;
int textBottomPadding = coordinates.foldersTextBottomPadding;
sFoldersPaint.setTextSize(coordinates.foldersFontSize);
sFoldersPaint.setTypeface(coordinates.foldersTypeface);
// Initialize space and cell size based on the current mode.
int availableSpace = xEnd - xMinStart;
int maxFoldersCount = availableSpace / coordinates.getFolderMinimumWidth();
int foldersCount = Math.min(mFoldersCount, maxFoldersCount);
int averageWidth = availableSpace / foldersCount;
int cellSize = coordinates.getFolderCellWidth();
// TODO(ath): sFoldersPaint.measureText() is done 3x in this method. stop that.
// Extra credit: maybe cache results across items as long as font size doesn't change.
final int totalWidth = measureFolders(availableSpace, cellSize);
int xStart = xEnd - Math.min(availableSpace, totalWidth);
final boolean overflow = totalWidth > availableSpace;
// Second pass to draw folders.
int i = 0;
for (Folder f : mFoldersSortedSet) {
if (availableSpace <= 0) {
break;
}
final String folderString = f.name;
final int fgColor = f.getForegroundColor(mDefaultFgColor);
final int bgColor = f.getBackgroundColor(mDefaultBgColor);
boolean labelTooLong = false;
final int textW = (int) sFoldersPaint.measureText(folderString);
int width = textW + cellSize + sFoldersLeftPadding;
if (overflow && width > averageWidth) {
if (i < foldersCount - 1) {
width = averageWidth;
} else {
// allow the last label to take all remaining space
// (and don't let it make room for padding)
width = availableSpace + sFoldersLeftPadding;
}
labelTooLong = true;
}
// TODO (mindyp): how to we get this?
final boolean isMuted = false;
// labelValues.folderId ==
// sGmail.getFolderMap(mAccount).getFolderIdIgnored();
// Draw the box.
sFoldersPaint.setColor(bgColor);
sFoldersPaint.setStyle(Paint.Style.FILL);
canvas.drawRect(xStart, y, xStart + width - sFoldersLeftPadding,
y + height, sFoldersPaint);
// Draw the text.
final int padding = cellSize / 2;
sFoldersPaint.setColor(fgColor);
sFoldersPaint.setStyle(Paint.Style.FILL);
if (labelTooLong) {
final int rightBorder = xStart + width - sFoldersLeftPadding - padding;
final Shader shader = new LinearGradient(rightBorder - padding, y, rightBorder,
y, fgColor, Utils.getTransparentColor(fgColor), Shader.TileMode.CLAMP);
sFoldersPaint.setShader(shader);
}
canvas.drawText(folderString, xStart + padding, y + height - textBottomPadding,
sFoldersPaint);
if (labelTooLong) {
sFoldersPaint.setShader(null);
}
availableSpace -= width;
xStart += width;
i++;
}
}
}
public ConversationItemView(Context context, String account) {
super(context);
Utils.traceBeginSection("CIVC constructor");
setClickable(true);
setLongClickable(true);
mContext = context.getApplicationContext();
final Resources res = mContext.getResources();
mTabletDevice = Utils.useTabletUI(res);
mIsExpansiveTablet =
mTabletDevice ? res.getBoolean(R.bool.use_expansive_tablet_ui) : false;
mListCollapsible = res.getBoolean(R.bool.list_collapsible);
mAccount = account;
if (STAR_OFF == null) {
// Initialize static bitmaps.
STAR_OFF = BitmapFactory.decodeResource(res, R.drawable.ic_btn_star_off);
STAR_ON = BitmapFactory.decodeResource(res, R.drawable.ic_btn_star_on);
ATTACHMENT = BitmapFactory.decodeResource(res, R.drawable.ic_attachment_holo_light);
ONLY_TO_ME = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_double);
TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_single);
IMPORTANT_ONLY_TO_ME = BitmapFactory.decodeResource(res,
R.drawable.ic_email_caret_double_important_unread);
IMPORTANT_TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res,
R.drawable.ic_email_caret_single_important_unread);
IMPORTANT_TO_OTHERS = BitmapFactory.decodeResource(res,
R.drawable.ic_email_caret_none_important_unread);
STATE_REPLIED =
BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_holo_light);
STATE_FORWARDED =
BitmapFactory.decodeResource(res, R.drawable.ic_badge_forward_holo_light);
STATE_REPLIED_AND_FORWARDED =
BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_forward_holo_light);
STATE_CALENDAR_INVITE =
BitmapFactory.decodeResource(res, R.drawable.ic_badge_invite_holo_light);
VISIBLE_CONVERSATION_CARET = BitmapFactory.decodeResource(res, R.drawable.caret_grey);
RIGHT_EDGE_TABLET = res.getDrawable(R.drawable.list_edge_tablet);
PLACEHOLDER = res.getDrawable(drawable.ic_attachment_load);
PROGRESS_BAR = res.getDrawable(drawable.progress_holo);
// Initialize colors.
sActivatedTextSpan = CharacterStyle.wrap(new ForegroundColorSpan(
res.getColor(R.color.senders_text_color_read)));
sSendersTextColorRead = res.getColor(R.color.senders_text_color_read);
sSendersTextColorUnread = res.getColor(R.color.senders_text_color_unread);
sSubjectTextUnreadSpan = new TextAppearanceSpan(mContext,
R.style.SubjectAppearanceUnreadStyle);
sSubjectTextReadSpan = new TextAppearanceSpan(mContext,
R.style.SubjectAppearanceReadStyle);
sSnippetTextUnreadSpan =
new ForegroundColorSpan(res.getColor(R.color.snippet_text_color_unread));
sSnippetTextReadSpan =
new ForegroundColorSpan(res.getColor(R.color.snippet_text_color_read));
sDateTextColor = res.getColor(R.color.date_text_color);
sStarTouchSlop = res.getDimensionPixelSize(R.dimen.star_touch_slop);
sSenderImageTouchSlop = res.getDimensionPixelSize(R.dimen.sender_image_touch_slop);
sShrinkAnimationDuration = res.getInteger(R.integer.shrink_animation_duration);
sSlideAnimationDuration = res.getInteger(R.integer.slide_animation_duration);
// Initialize static color.
sSendersSplitToken = res.getString(R.string.senders_split_token);
sElidedPaddingToken = res.getString(R.string.elided_padding_token);
sOverflowCountFormat = res.getString(string.ap_overflow_format);
sScrollSlop = res.getInteger(R.integer.swipeScrollSlop);
sFoldersLeftPadding = res.getDimensionPixelOffset(R.dimen.folders_left_padding);
sOverflowCountMax = res.getInteger(integer.ap_overflow_max_count);
sCabAnimationDuration = res.getInteger(R.integer.conv_item_view_cab_anim_duration);
}
mSendersTextView = new TextView(mContext);
mSendersTextView.setIncludeFontPadding(false);
mSubjectTextView = new TextView(mContext);
mSubjectTextView.setEllipsize(TextUtils.TruncateAt.END);
mSubjectTextView.setIncludeFontPadding(false);
mAttachmentsView = new AttachmentGridDrawable(res, PLACEHOLDER, PROGRESS_BAR);
mAttachmentsView.setCallback(this);
mSendersImageView = new ContactCheckableGridDrawable(res, sCabAnimationDuration);
mSendersImageView.setCallback(this);
Utils.traceEndSection();
}
public void bind(final Conversation conversation, final ControllableActivity activity,
final ConversationSelectionSet set, final Folder folder,
final int checkboxOrSenderImage, final boolean showAttachmentPreviews,
final boolean parallaxSpeedAlternative, final boolean parallaxDirectionAlternative,
final boolean swipeEnabled, final boolean priorityArrowEnabled,
final AnimatedAdapter adapter) {
Utils.traceBeginSection("CIVC.bind");
bind(ConversationItemViewModel.forConversation(mAccount, conversation), activity,
null /* conversationItemAreaClickListener */,
set, folder, checkboxOrSenderImage, showAttachmentPreviews,
parallaxSpeedAlternative, parallaxDirectionAlternative, swipeEnabled,
priorityArrowEnabled, adapter, -1 /* backgroundOverrideResId */, null /* photoBitmap */);
Utils.traceEndSection();
}
public void bindAd(final ConversationItemViewModel conversationItemViewModel,
final ControllableActivity activity,
final ConversationItemAreaClickListener conversationItemAreaClickListener,
final Folder folder, final int checkboxOrSenderImage, final AnimatedAdapter adapter,
final int backgroundOverrideResId, final Bitmap photoBitmap) {
Utils.traceBeginSection("CIVC.bindAd");
bind(conversationItemViewModel, activity, conversationItemAreaClickListener, null /* set */,
folder, checkboxOrSenderImage, false /* attachment previews */,
false /* parallax */, false /* parallax */, true /* swipeEnabled */,
false /* priorityArrowEnabled */,
adapter, backgroundOverrideResId, photoBitmap);
Utils.traceEndSection();
}
private void bind(final ConversationItemViewModel header, final ControllableActivity activity,
final ConversationItemAreaClickListener conversationItemAreaClickListener,
final ConversationSelectionSet set, final Folder folder,
final int checkboxOrSenderImage, final boolean showAttachmentPreviews,
final boolean parallaxSpeedAlternative, final boolean parallaxDirectionAlternative,
boolean swipeEnabled, final boolean priorityArrowEnabled, final AnimatedAdapter adapter,
final int backgroundOverrideResId, final Bitmap photoBitmap) {
mBackgroundOverrideResId = backgroundOverrideResId;
mPhotoBitmap = photoBitmap;
mConversationItemAreaClickListener = conversationItemAreaClickListener;
if (mHeader != null) {
Utils.traceBeginSection("unbind");
final boolean newlyBound = header.conversation.id != mHeader.conversation.id;
// If this was previously bound to a different conversation, remove any contact photo
// manager requests.
if (newlyBound || (mHeader.displayableSenderNames != null && !mHeader
.displayableSenderNames.equals(
header.displayableSenderNames))) {
for (int i = 0; i < mSendersImageView.getCount(); i++) {
mSendersImageView.getOrCreateDrawable(i).unbind();
}
mSendersImageView.setCount(0);
}
// If this was previously bound to a different conversation,
// remove any attachment preview manager requests.
if (newlyBound || header.conversation.attachmentPreviewsCount
!= mHeader.conversation.attachmentPreviewsCount || !header.conversation
.getAttachmentPreviewUris().equals(
mHeader.conversation.getAttachmentPreviewUris())) {
// unbind the attachments view (releasing bitmap references)
// (this also cancels all async tasks)
for (int i = 0, len = mAttachmentsView.getCount(); i < len; i++) {
mAttachmentsView.getOrCreateDrawable(i).unbind();
}
// reset the grid, as the newly bound item may have a different attachment count
mAttachmentsView.setCount(0);
}
if (newlyBound) {
// Stop the photo flip animation
final boolean showSenders = !isSelected();
mSendersImageView.reset(showSenders);
}
Utils.traceEndSection();
}
mCoordinates = null;
mHeader = header;
mActivity = activity;
mSelectedConversationSet = set;
mSelectedConversationSet.addObserver(this);
mDisplayedFolder = folder;
mStarEnabled = folder != null && !folder.isTrash();
mSwipeEnabled = swipeEnabled;
mAdapter = adapter;
Utils.traceBeginSection("drawables");
mAttachmentsView.setBitmapCache(mAdapter.getAttachmentPreviewsCache());
mAttachmentsView.setDecodeAggregator(mAdapter.getAttachmentPreviewsDecodeAggregator());
mSendersImageView.setBitmapCache(mAdapter.getSendersImagesCache());
mSendersImageView.setContactResolver(mAdapter.getContactResolver());
Utils.traceEndSection();
if (checkboxOrSenderImage == ConversationListIcon.SENDER_IMAGE) {
mGadgetMode = ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO;
} else {
mGadgetMode = ConversationItemViewCoordinates.GADGET_NONE;
}
mAttachmentPreviewsEnabled = showAttachmentPreviews;
mParallaxSpeedAlternative = parallaxSpeedAlternative;
mParallaxDirectionAlternative = parallaxDirectionAlternative;
Utils.traceBeginSection("folder displayer");
// Initialize folder displayer.
if (mHeader.folderDisplayer == null) {
mHeader.folderDisplayer = new ConversationItemFolderDisplayer(mContext);
} else {
mHeader.folderDisplayer.reset();
}
Utils.traceEndSection();
final int ignoreFolderType;
if (mDisplayedFolder.isInbox()) {
ignoreFolderType = FolderType.INBOX;
} else {
ignoreFolderType = -1;
}
Utils.traceBeginSection("load folders");
mHeader.folderDisplayer.loadConversationFolders(mHeader.conversation,
mDisplayedFolder.folderUri, ignoreFolderType);
Utils.traceEndSection();
if (mHeader.dateOverrideText == null) {
Utils.traceBeginSection("relative time");
mHeader.dateText = DateUtils.getRelativeTimeSpanString(mContext,
mHeader.conversation.dateMs);
Utils.traceEndSection();
} else {
mHeader.dateText = mHeader.dateOverrideText;
}
Utils.traceBeginSection("config setup");
mConfig = new ConversationItemViewCoordinates.Config()
.withGadget(mGadgetMode)
.withAttachmentPreviews(getAttachmentPreviewsMode());
if (header.folderDisplayer.hasVisibleFolders()) {
mConfig.showFolders();
}
if (header.hasBeenForwarded || header.hasBeenRepliedTo || header.isInvite) {
mConfig.showReplyState();
}
if (mHeader.conversation.color != 0) {
mConfig.showColorBlock();
}
// Personal level.
mHeader.personalLevelBitmap = null;
if (true) { // TODO: hook this up to a setting
final int personalLevel = mHeader.conversation.personalLevel;
final boolean isImportant =
mHeader.conversation.priority == UIProvider.ConversationPriority.IMPORTANT;
final boolean useImportantMarkers = isImportant && priorityArrowEnabled;
if (personalLevel == UIProvider.ConversationPersonalLevel.ONLY_TO_ME) {
mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_ONLY_TO_ME
: ONLY_TO_ME;
} else if (personalLevel == UIProvider.ConversationPersonalLevel.TO_ME_AND_OTHERS) {
mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_TO_ME_AND_OTHERS
: TO_ME_AND_OTHERS;
} else if (useImportantMarkers) {
mHeader.personalLevelBitmap = IMPORTANT_TO_OTHERS;
}
}
if (mHeader.personalLevelBitmap != null) {
mConfig.showPersonalIndicator();
}
Utils.traceEndSection();
Utils.traceBeginSection("overflow");
final int overflowCount = Math.min(getOverflowCount(), sOverflowCountMax);
mHeader.overflowText = (overflowCount > 0) ?
String.format(sOverflowCountFormat, overflowCount) : null;
mAttachmentsView.setOverflowText(mHeader.overflowText);
Utils.traceEndSection();
Utils.traceBeginSection("content description");
setContentDescription();
Utils.traceEndSection();
requestLayout();
}
@Override
public void invalidateDrawable(final Drawable who) {
boolean handled = false;
if (mCoordinates != null) {
if (mAttachmentsView.equals(who)) {
final Rect r = new Rect(who.getBounds());
r.offset(mCoordinates.attachmentPreviewsX, mCoordinates.attachmentPreviewsY);
ConversationItemView.this.invalidate(r.left, r.top, r.right, r.bottom);
handled = true;
} else if (mSendersImageView.equals(who)) {
final Rect r = new Rect(who.getBounds());
r.offset(mCoordinates.contactImagesX, mCoordinates.contactImagesY);
ConversationItemView.this.invalidate(r.left, r.top, r.right, r.bottom);
handled = true;
}
}
if (!handled) {
super.invalidateDrawable(who);
}
}
/**
* Get the Conversation object associated with this view.
*/
public Conversation getConversation() {
return mHeader.conversation;
}
private static void startTimer(String tag) {
if (sTimer != null) {
sTimer.start(tag);
}
}
private static void pauseTimer(String tag) {
if (sTimer != null) {
sTimer.pause(tag);
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
Utils.traceBeginSection("CIVC.measure");
final int wSize = MeasureSpec.getSize(widthMeasureSpec);
final int currentMode = mActivity.getViewMode().getMode();
if (wSize != mViewWidth || mPreviousMode != currentMode) {
mViewWidth = wSize;
mPreviousMode = currentMode;
}
mHeader.viewWidth = mViewWidth;
mConfig.updateWidth(wSize).setViewMode(currentMode);
Resources res = getResources();
mHeader.standardScaledDimen = res.getDimensionPixelOffset(R.dimen.standard_scaled_dimen);
mCoordinates = ConversationItemViewCoordinates.forConfig(mContext, mConfig,
mAdapter.getCoordinatesCache());
if (mPhotoBitmap != null) {
mPhotoRect = new Rect(0, 0, mCoordinates.contactImagesWidth,
mCoordinates.contactImagesHeight);
}
final int h = (mAnimatedHeightFraction != 1.0f) ?
Math.round(mAnimatedHeightFraction * mCoordinates.height) : mCoordinates.height;
setMeasuredDimension(mConfig.getWidth(), h);
Utils.traceEndSection();
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
startTimer(PERF_TAG_LAYOUT);
Utils.traceBeginSection("CIVC.layout");
super.onLayout(changed, left, top, right, bottom);
Utils.traceBeginSection("text and bitmaps");
calculateTextsAndBitmaps();
Utils.traceEndSection();
Utils.traceBeginSection("coordinates");
calculateCoordinates();
Utils.traceEndSection();
// Subject.
Utils.traceBeginSection("subject");
createSubject(mHeader.unread);
if (!mHeader.isLayoutValid()) {
setContentDescription();
}
mHeader.validate();
Utils.traceEndSection();
pauseTimer(PERF_TAG_LAYOUT);
if (sTimer != null && ++sLayoutCount >= PERF_LAYOUT_ITERATIONS) {
sTimer.dumpResults();
sTimer = new Timer();
sLayoutCount = 0;
}
Utils.traceEndSection();
}
private void setContentDescription() {
if (mActivity.isAccessibilityEnabled()) {
mHeader.resetContentDescription();
setContentDescription(mHeader.getContentDescription(mContext));
}
}
@Override
public void setBackgroundResource(int resourceId) {
Utils.traceBeginSection("set background resource");
Drawable drawable = mBackgrounds.get(resourceId);
if (drawable == null) {
drawable = getResources().getDrawable(resourceId);
mBackgrounds.put(resourceId, drawable);
}
if (getBackground() != drawable) {
super.setBackgroundDrawable(drawable);
}
Utils.traceEndSection();
}
private void calculateTextsAndBitmaps() {
startTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS);
if (mSelectedConversationSet != null) {
mSelected = mSelectedConversationSet.contains(mHeader.conversation);
}
setSelected(mSelected);
mHeader.gadgetMode = mGadgetMode;
final boolean isUnread = mHeader.unread;
updateBackground(isUnread);
mHeader.sendersDisplayText = new SpannableStringBuilder();
mHeader.styledSendersString = null;
mHeader.hasDraftMessage = mHeader.conversation.numDrafts() > 0;
// Parse senders fragments.
if (mHeader.preserveSendersText) {
// This is a special view that doesn't need special sender formatting
mHeader.sendersDisplayText = new SpannableStringBuilder(mHeader.sendersText);
loadSenderImages();
} else if (mHeader.conversation.conversationInfo != null) {
// This is Gmail
Context context = getContext();
mHeader.messageInfoString = SendersView
.createMessageInfo(context, mHeader.conversation, true);
int maxChars = ConversationItemViewCoordinates.getSendersLength(context,
mCoordinates.getMode(), mHeader.conversation.hasAttachments);
mHeader.displayableSenderEmails = new ArrayList<String>();
mHeader.displayableSenderNames = new ArrayList<String>();
mHeader.styledSenders = new ArrayList<SpannableString>();
SendersView.format(context, mHeader.conversation.conversationInfo,
mHeader.messageInfoString.toString(), maxChars, mHeader.styledSenders,
mHeader.displayableSenderNames, mHeader.displayableSenderEmails, mAccount,
true);
if (mHeader.displayableSenderEmails.isEmpty() && mHeader.hasDraftMessage) {
mHeader.displayableSenderEmails.add(mAccount);
mHeader.displayableSenderNames.add(mAccount);
}
// If we have displayable senders, load their thumbnails
loadSenderImages();
} else {
// This is Email
SendersView.formatSenders(mHeader, getContext(), true);
if (!TextUtils.isEmpty(mHeader.conversation.senders)) {
mHeader.displayableSenderEmails = new ArrayList<String>();
mHeader.displayableSenderNames = new ArrayList<String>();
final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(mHeader.conversation.senders);
for (int i = 0; i < tokens.length;i++) {
final Rfc822Token token = tokens[i];
final String senderName = Address.decodeAddressName(token.getName());
final String senderAddress = token.getAddress();
mHeader.displayableSenderEmails.add(senderAddress);
mHeader.displayableSenderNames.add(
!TextUtils.isEmpty(senderName) ? senderName : senderAddress);
}
loadSenderImages();
}
}
if (isAttachmentPreviewsEnabled()) {
loadAttachmentPreviews();
}
if (mHeader.isLayoutValid()) {
pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS);
return;
}
startTimer(PERF_TAG_CALCULATE_FOLDERS);
pauseTimer(PERF_TAG_CALCULATE_FOLDERS);
// Paper clip icon.
mHeader.paperclip = null;
if (mHeader.conversation.hasAttachments) {
mHeader.paperclip = ATTACHMENT;
}
startTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT);
pauseTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT);
pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS);
}
private boolean isAttachmentPreviewsEnabled() {
return mAttachmentPreviewsEnabled && !mHeader.conversation.getAttachmentPreviewUris()
.isEmpty();
}
private int getOverflowCount() {
return mHeader.conversation.attachmentPreviewsCount - mHeader.conversation
.getAttachmentPreviewUris().size();
}
private int getAttachmentPreviewsMode() {
if (isAttachmentPreviewsEnabled()) {
return mHeader.conversation.read
? ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_READ
: ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_UNREAD;
} else {
return ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_NONE;
}
}
private float getParallaxSpeedMultiplier() {
return mParallaxSpeedAlternative
? SwipeableListView.ATTACHMENT_PARALLAX_MULTIPLIER_ALTERNATIVE
: SwipeableListView.ATTACHMENT_PARALLAX_MULTIPLIER_NORMAL;
}
// FIXME(ath): maybe move this to bind(). the only dependency on layout is on tile W/H, which
// is immutable.
private void loadSenderImages() {
if (mGadgetMode != ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO
|| mHeader.displayableSenderEmails == null
|| mHeader.displayableSenderEmails.size() <= 0) {
return;
}
if (mCoordinates.contactImagesWidth <= 0 || mCoordinates.contactImagesHeight <= 0) {
LogUtils.w(LOG_TAG,
"Contact image width(%d) or height(%d) is 0 for mode: (%d).",
mCoordinates.contactImagesWidth, mCoordinates.contactImagesHeight,
mCoordinates.getMode());
return;
}
Utils.traceBeginSection("load sender images");
final int count = mHeader.displayableSenderEmails.size();
mSendersImageView.setCount(count);
mSendersImageView
.setBounds(0, 0, mCoordinates.contactImagesWidth, mCoordinates.contactImagesHeight);
for (int i = 0; i < DividedImageCanvas.MAX_DIVISIONS && i < count; i++) {
Utils.traceBeginSection("load single sender image");
final ContactDrawable drawable = mSendersImageView.getOrCreateDrawable(i);
drawable.setDecodeDimensions(mCoordinates.contactImagesWidth,
mCoordinates.contactImagesHeight);
drawable.bind(mHeader.displayableSenderNames.get(i),
mHeader.displayableSenderEmails.get(i));
Utils.traceEndSection();
}
Utils.traceEndSection();
}
private void loadAttachmentPreviews() {
if (mCoordinates.attachmentPreviewsWidth <= 0
|| mCoordinates.attachmentPreviewsHeight <= 0) {
LogUtils.w(LOG_TAG,
"Attachment preview width(%d) or height(%d) is 0 for mode: (%d,%d).",
mCoordinates.attachmentPreviewsWidth, mCoordinates.attachmentPreviewsHeight,
mCoordinates.getMode(), getAttachmentPreviewsMode());
return;
}
Utils.traceBeginSection("attachment previews");
Utils.traceBeginSection("Setup load attachment previews");
LogUtils.d(LOG_TAG,
"loadAttachmentPreviews: Loading attachment previews for conversation %s",
mHeader.conversation);
// Get list of attachments and states from conversation
final ArrayList<String> attachmentUris = mHeader.conversation.getAttachmentPreviewUris();
final int previewStates = mHeader.conversation.attachmentPreviewStates;
final int displayCount = Math.min(
attachmentUris.size(), AttachmentGridDrawable.MAX_VISIBLE_ATTACHMENT_COUNT);
Utils.traceEndSection();
mAttachmentsView.setCoordinates(mCoordinates);
mAttachmentsView.setCount(displayCount);
final int decodeHeight;
// if parallax is enabled, increase the desired vertical size of attachment bitmaps
// so we have extra pixels to scroll within
if (SwipeableListView.ENABLE_ATTACHMENT_PARALLAX) {
decodeHeight = Math.round(mCoordinates.attachmentPreviewsDecodeHeight
* getParallaxSpeedMultiplier());
} else {
decodeHeight = mCoordinates.attachmentPreviewsDecodeHeight;
}
// set the bounds before binding inner drawables so they can decode right away
// (they need the their bounds set to know whether to decode to 1x1 or 2x1 dimens)
mAttachmentsView.setBounds(0, 0, mCoordinates.attachmentPreviewsWidth,
mCoordinates.attachmentPreviewsHeight);
for (int i = 0; i < displayCount; i++) {
Utils.traceBeginSection("setup single attachment preview");
final String uri = attachmentUris.get(i);
// Find the rendition to load based on availability.
LogUtils.v(LOG_TAG, "loadAttachmentPreviews: state [BEST, SIMPLE] is [%s, %s] for %s ",
Attachment.getPreviewState(previewStates, i, AttachmentRendition.BEST),
Attachment.getPreviewState(previewStates, i, AttachmentRendition.SIMPLE),
uri);
int bestAvailableRendition = -1;
// BEST first, else use less preferred renditions
for (final int rendition : AttachmentRendition.PREFERRED_RENDITIONS) {
if (Attachment.getPreviewState(previewStates, i, rendition)) {
bestAvailableRendition = rendition;
break;
}
}
LogUtils.d(LOG_TAG,
"creating/setting drawable region in CIV=%s canvas=%s rend=%s uri=%s",
this, mAttachmentsView, bestAvailableRendition, uri);
final AttachmentDrawable drawable = mAttachmentsView.getOrCreateDrawable(i);
drawable.setDecodeDimensions(mCoordinates.attachmentPreviewsWidth, decodeHeight);
drawable.setParallaxSpeedMultiplier(getParallaxSpeedMultiplier());
if (bestAvailableRendition != -1) {
drawable.bind(getContext(), uri, bestAvailableRendition);
} else {
drawable.showStaticPlaceholder();
}
Utils.traceEndSection();
}
Utils.traceEndSection();
}
private static int makeExactSpecForSize(int size) {
return MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY);
}
private static void layoutViewExactly(View v, int w, int h) {
v.measure(makeExactSpecForSize(w), makeExactSpecForSize(h));
v.layout(0, 0, w, h);
}
private void layoutSenders() {
if (mHeader.styledSendersString != null) {
if (isActivated() && showActivatedText()) {
mHeader.styledSendersString.setSpan(sActivatedTextSpan, 0,
mHeader.styledMessageInfoStringOffset, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
} else {
mHeader.styledSendersString.removeSpan(sActivatedTextSpan);
}
final int w = mSendersWidth;
final int h = mCoordinates.sendersHeight;
mSendersTextView.setLayoutParams(new ViewGroup.LayoutParams(w, h));
mSendersTextView.setMaxLines(mCoordinates.sendersLineCount);
mSendersTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mCoordinates.sendersFontSize);
layoutViewExactly(mSendersTextView, w, h);
mSendersTextView.setText(mHeader.styledSendersString);
}
}
private void createSubject(final boolean isUnread) {
final String subject = filterTag(mHeader.conversation.subject);
final String snippet = mHeader.conversation.getSnippet();
final Spannable displayedStringBuilder = new SpannableString(
Conversation.getSubjectAndSnippetForDisplay(mContext, subject, snippet));
// since spans affect text metrics, add spans to the string before measure/layout or fancy
// ellipsizing
final int subjectTextLength = (subject != null) ? subject.length() : 0;
if (!TextUtils.isEmpty(subject)) {
displayedStringBuilder.setSpan(TextAppearanceSpan.wrap(
isUnread ? sSubjectTextUnreadSpan : sSubjectTextReadSpan), 0, subjectTextLength,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (!TextUtils.isEmpty(snippet)) {
final int startOffset = subjectTextLength;
// Start after the end of the subject text; since the subject may be
// "" or null, this could start at the 0th character in the subjectText string
displayedStringBuilder.setSpan(ForegroundColorSpan.wrap(
isUnread ? sSnippetTextUnreadSpan : sSnippetTextReadSpan), startOffset,
displayedStringBuilder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (isActivated() && showActivatedText()) {
displayedStringBuilder.setSpan(sActivatedTextSpan, 0, displayedStringBuilder.length(),
Spannable.SPAN_INCLUSIVE_INCLUSIVE);
}
final int subjectWidth = mCoordinates.subjectWidth;
final int subjectHeight = mCoordinates.subjectHeight;
mSubjectTextView.setLayoutParams(new ViewGroup.LayoutParams(subjectWidth, subjectHeight));
mSubjectTextView.setMaxLines(mCoordinates.subjectLineCount);
mSubjectTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mCoordinates.subjectFontSize);
layoutViewExactly(mSubjectTextView, subjectWidth, subjectHeight);
mSubjectTextView.setText(displayedStringBuilder);
}
private boolean showActivatedText() {
// For activated elements in tablet in conversation mode, we show an activated color, since
// the background is dark blue for activated versus gray for non-activated.
return mTabletDevice && !mListCollapsible;
}
private boolean canFitFragment(int width, int line, int fixedWidth) {
if (line == mCoordinates.sendersLineCount) {
return width + fixedWidth <= mSendersWidth;
} else {
return width <= mSendersWidth;
}
}
private void calculateCoordinates() {
startTimer(PERF_TAG_CALCULATE_COORDINATES);
sPaint.setTextSize(mCoordinates.dateFontSize);
sPaint.setTypeface(Typeface.DEFAULT);
if (mHeader.infoIcon != null) {
mInfoIconX = mCoordinates.infoIconXEnd - mHeader.infoIcon.getWidth();
// If we have an info icon, we start drawing the date text:
// At the end of the date TextView minus the width of the date text
mDateX = mCoordinates.dateXEnd - (int) sPaint.measureText(
mHeader.dateText != null ? mHeader.dateText.toString() : "");
} else {
// If there is no info icon, we start drawing the date text:
// At the end of the info icon ImageView minus the width of the date text
// We use the info icon ImageView for positioning, since we want the date text to be
// at the right, since there is no info icon
mDateX = mCoordinates.infoIconXEnd - (int) sPaint.measureText(
mHeader.dateText != null ? mHeader.dateText.toString() : "");
}
mPaperclipX = mDateX - ATTACHMENT.getWidth() - mCoordinates.datePaddingLeft;
if (mCoordinates.isWide()) {
// In wide mode, the end of the senders should align with
// the start of the subject and is based on a max width.
mSendersWidth = mCoordinates.sendersWidth;
} else {
// In normal mode, the width is based on where the date/attachment icon start.
final int dateAttachmentStart;
// Have this end near the paperclip or date, not the folders.
if (mHeader.paperclip != null) {
dateAttachmentStart = mPaperclipX - mCoordinates.paperclipPaddingLeft;
} else {
dateAttachmentStart = mDateX - mCoordinates.datePaddingLeft;
}
mSendersWidth = dateAttachmentStart - mCoordinates.sendersX;
}
// Second pass to layout each fragment.
sPaint.setTextSize(mCoordinates.sendersFontSize);
sPaint.setTypeface(Typeface.DEFAULT);
if (mHeader.styledSenders != null) {
ellipsizeStyledSenders();
layoutSenders();
} else {
// First pass to calculate width of each fragment.
int totalWidth = 0;
int fixedWidth = 0;
for (SenderFragment senderFragment : mHeader.senderFragments) {
CharacterStyle style = senderFragment.style;
int start = senderFragment.start;
int end = senderFragment.end;
style.updateDrawState(sPaint);
senderFragment.width = (int) sPaint.measureText(mHeader.sendersText, start, end);
boolean isFixed = senderFragment.isFixed;
if (isFixed) {
fixedWidth += senderFragment.width;
}
totalWidth += senderFragment.width;
}
if (mSendersWidth < 0) {
mSendersWidth = 0;
}
totalWidth = ellipsize(fixedWidth);
mHeader.sendersDisplayLayout = new StaticLayout(mHeader.sendersDisplayText, sPaint,
mSendersWidth, Alignment.ALIGN_NORMAL, 1, 0, true);
}
if (mSendersWidth < 0) {
mSendersWidth = 0;
}
pauseTimer(PERF_TAG_CALCULATE_COORDINATES);
}
// The rules for displaying ellipsized senders are as follows:
// 1) If there is message info (either a COUNT or DRAFT info to display), it MUST be shown
// 2) If senders do not fit, ellipsize the last one that does fit, and stop
// appending new senders
private int ellipsizeStyledSenders() {
SpannableStringBuilder builder = new SpannableStringBuilder();
float totalWidth = 0;
boolean ellipsize = false;
float width;
SpannableStringBuilder messageInfoString = mHeader.messageInfoString;
if (messageInfoString.length() > 0) {
CharacterStyle[] spans = messageInfoString.getSpans(0, messageInfoString.length(),
CharacterStyle.class);
// There is only 1 character style span; make sure we apply all the
// styles to the paint object before measuring.
if (spans.length > 0) {
spans[0].updateDrawState(sPaint);
}
// Paint the message info string to see if we lose space.
float messageInfoWidth = sPaint.measureText(messageInfoString.toString());
totalWidth += messageInfoWidth;
}
SpannableString prevSender = null;
SpannableString ellipsizedText;
for (SpannableString sender : mHeader.styledSenders) {
// There may be null sender strings if there were dupes we had to remove.
if (sender == null) {
continue;
}
// No more width available, we'll only show fixed fragments.
if (ellipsize) {
break;
}
CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class);
// There is only 1 character style span.
if (spans.length > 0) {
spans[0].updateDrawState(sPaint);
}
// If there are already senders present in this string, we need to
// make sure we prepend the dividing token
if (SendersView.sElidedString.equals(sender.toString())) {
prevSender = sender;
sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken);
} else if (builder.length() > 0
&& (prevSender == null || !SendersView.sElidedString.equals(prevSender
.toString()))) {
prevSender = sender;
sender = copyStyles(spans, sSendersSplitToken + sender);
} else {
prevSender = sender;
}
if (spans.length > 0) {
spans[0].updateDrawState(sPaint);
}
// Measure the width of the current sender and make sure we have space
width = (int) sPaint.measureText(sender.toString());
if (width + totalWidth > mSendersWidth) {
// The text is too long, new line won't help. We have to
// ellipsize text.
ellipsize = true;
width = mSendersWidth - totalWidth; // ellipsis width?
ellipsizedText = copyStyles(spans,
TextUtils.ellipsize(sender, sPaint, width, TruncateAt.END));
width = (int) sPaint.measureText(ellipsizedText.toString());
} else {
ellipsizedText = null;
}
totalWidth += width;
final CharSequence fragmentDisplayText;
if (ellipsizedText != null) {
fragmentDisplayText = ellipsizedText;
} else {
fragmentDisplayText = sender;
}
builder.append(fragmentDisplayText);
}
mHeader.styledMessageInfoStringOffset = builder.length();
builder.append(messageInfoString);
mHeader.styledSendersString = builder;
return (int)totalWidth;
}
private static SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) {
SpannableString s = new SpannableString(newText);
if (spans != null && spans.length > 0) {
s.setSpan(spans[0], 0, s.length(), 0);
}
return s;
}
private int ellipsize(int fixedWidth) {
int totalWidth = 0;
int currentLine = 1;
boolean ellipsize = false;
for (SenderFragment senderFragment : mHeader.senderFragments) {
CharacterStyle style = senderFragment.style;
int start = senderFragment.start;
int end = senderFragment.end;
int width = senderFragment.width;
boolean isFixed = senderFragment.isFixed;
style.updateDrawState(sPaint);
// No more width available, we'll only show fixed fragments.
if (ellipsize && !isFixed) {
senderFragment.shouldDisplay = false;
continue;
}
// New line and ellipsize text if needed.
senderFragment.ellipsizedText = null;
if (isFixed) {
fixedWidth -= width;
}
if (!canFitFragment(totalWidth + width, currentLine, fixedWidth)) {
// The text is too long, new line won't help. We have to
// ellipsize text.
if (totalWidth == 0) {
ellipsize = true;
} else {
// New line.
if (currentLine < mCoordinates.sendersLineCount) {
currentLine++;
totalWidth = 0;
// The text is still too long, we have to ellipsize
// text.
if (totalWidth + width > mSendersWidth) {
ellipsize = true;
}
} else {
ellipsize = true;
}
}
if (ellipsize) {
width = mSendersWidth - totalWidth;
// No more new line, we have to reserve width for fixed
// fragments.
if (currentLine == mCoordinates.sendersLineCount) {
width -= fixedWidth;
}
senderFragment.ellipsizedText = TextUtils.ellipsize(
mHeader.sendersText.substring(start, end), sPaint, width,
TruncateAt.END).toString();
width = (int) sPaint.measureText(senderFragment.ellipsizedText);
}
}
senderFragment.shouldDisplay = true;
totalWidth += width;
final CharSequence fragmentDisplayText;
if (senderFragment.ellipsizedText != null) {
fragmentDisplayText = senderFragment.ellipsizedText;
} else {
fragmentDisplayText = mHeader.sendersText.substring(start, end);
}
final int spanStart = mHeader.sendersDisplayText.length();
mHeader.sendersDisplayText.append(fragmentDisplayText);
mHeader.sendersDisplayText.setSpan(senderFragment.style, spanStart,
mHeader.sendersDisplayText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
return totalWidth;
}
/**
* If the subject contains the tag of a mailing-list (text surrounded with
* []), return the subject with that tag ellipsized, e.g.
* "[android-gmail-team] Hello" -> "[andr...] Hello"
*/
private String filterTag(String subject) {
String result = subject;
String formatString = getContext().getResources().getString(R.string.filtered_tag);
if (!TextUtils.isEmpty(subject) && subject.charAt(0) == '[') {
int end = subject.indexOf(']');
if (end > 0) {
String tag = subject.substring(1, end);
result = String.format(formatString, Utils.ellipsize(tag, 7),
subject.substring(end + 1));
}
}
return result;
}
@Override
public final void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
int totalItemCount) {
if (SwipeableListView.ENABLE_ATTACHMENT_PARALLAX) {
if (mHeader == null || mCoordinates == null || !isAttachmentPreviewsEnabled()) {
return;
}
invalidate(mCoordinates.attachmentPreviewsX, mCoordinates.attachmentPreviewsY,
mCoordinates.attachmentPreviewsX + mCoordinates.attachmentPreviewsWidth,
mCoordinates.attachmentPreviewsY + mCoordinates.attachmentPreviewsHeight);
}
}
@Override
public void onScrollStateChanged(AbsListView view, int scrollState) {
}
@Override
protected void onDraw(Canvas canvas) {
Utils.traceBeginSection("CIVC.draw");
// Contact photo
if (mGadgetMode == ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO) {
canvas.save();
Utils.traceBeginSection("draw senders image");
drawSendersImage(canvas);
Utils.traceEndSection();
canvas.restore();
}
// Senders.
boolean isUnread = mHeader.unread;
// Old style senders; apply text colors/ sizes/ styling.
canvas.save();
if (mHeader.sendersDisplayLayout != null) {
sPaint.setTextSize(mCoordinates.sendersFontSize);
sPaint.setTypeface(SendersView.getTypeface(isUnread));
sPaint.setColor(isUnread ? sSendersTextColorUnread : sSendersTextColorRead);
canvas.translate(mCoordinates.sendersX, mCoordinates.sendersY
+ mHeader.sendersDisplayLayout.getTopPadding());
mHeader.sendersDisplayLayout.draw(canvas);
} else {
drawSenders(canvas);
}
canvas.restore();
// Subject.
sPaint.setTypeface(Typeface.DEFAULT);
canvas.save();
drawSubject(canvas);
canvas.restore();
// Folders.
if (mConfig.areFoldersVisible()) {
mHeader.folderDisplayer.drawFolders(canvas, mCoordinates);
}
// If this folder has a color (combined view/Email), show it here
if (mConfig.isColorBlockVisible()) {
sFoldersPaint.setColor(mHeader.conversation.color);
sFoldersPaint.setStyle(Paint.Style.FILL);
canvas.drawRect(mCoordinates.colorBlockX, mCoordinates.colorBlockY,
mCoordinates.colorBlockX + mCoordinates.colorBlockWidth,
mCoordinates.colorBlockY + mCoordinates.colorBlockHeight, sFoldersPaint);
}
// Draw the reply state. Draw nothing if neither replied nor forwarded.
if (mConfig.isReplyStateVisible()) {
if (mHeader.hasBeenRepliedTo && mHeader.hasBeenForwarded) {
canvas.drawBitmap(STATE_REPLIED_AND_FORWARDED, mCoordinates.replyStateX,
mCoordinates.replyStateY, null);
} else if (mHeader.hasBeenRepliedTo) {
canvas.drawBitmap(STATE_REPLIED, mCoordinates.replyStateX,
mCoordinates.replyStateY, null);
} else if (mHeader.hasBeenForwarded) {
canvas.drawBitmap(STATE_FORWARDED, mCoordinates.replyStateX,
mCoordinates.replyStateY, null);
} else if (mHeader.isInvite) {
canvas.drawBitmap(STATE_CALENDAR_INVITE, mCoordinates.replyStateX,
mCoordinates.replyStateY, null);
}
}
if (mConfig.isPersonalIndicatorVisible()) {
canvas.drawBitmap(mHeader.personalLevelBitmap, mCoordinates.personalIndicatorX,
mCoordinates.personalIndicatorY, null);
}
// Info icon
if (mHeader.infoIcon != null) {
canvas.drawBitmap(mHeader.infoIcon, mInfoIconX, mCoordinates.infoIconY, sPaint);
}
// Date.
sPaint.setTextSize(mCoordinates.dateFontSize);
sPaint.setTypeface(Typeface.DEFAULT);
sPaint.setColor(sDateTextColor);
drawText(canvas, mHeader.dateText, mDateX, mCoordinates.dateYBaseline,
sPaint);
// Paper clip icon.
if (mHeader.paperclip != null) {
canvas.drawBitmap(mHeader.paperclip, mPaperclipX, mCoordinates.paperclipY, sPaint);
}
if (mStarEnabled) {
// Star.
canvas.drawBitmap(getStarBitmap(), mCoordinates.starX, mCoordinates.starY, sPaint);
}
// Attachment previews
if (isAttachmentPreviewsEnabled()) {
canvas.save();
drawAttachmentPreviews(canvas);
canvas.restore();
}
// right-side edge effect when in tablet conversation mode and the list is not collapsed
if (Utils.getDisplayListRightEdgeEffect(mTabletDevice, mListCollapsible,
mConfig.getViewMode())) {
RIGHT_EDGE_TABLET.setBounds(getWidth() - RIGHT_EDGE_TABLET.getIntrinsicWidth(), 0,
getWidth(), getHeight());
RIGHT_EDGE_TABLET.draw(canvas);
if (isActivated()) {
// draw caret on the right, centered vertically
final int x = getWidth() - VISIBLE_CONVERSATION_CARET.getWidth();
final int y = (getHeight() - VISIBLE_CONVERSATION_CARET.getHeight()) / 2;
canvas.drawBitmap(VISIBLE_CONVERSATION_CARET, x, y, null);
}
}
Utils.traceEndSection();
}
private void drawSendersImage(final Canvas canvas) {
if (!mSendersImageView.isFlipping()) {
final boolean showSenders = !isSelected();
mSendersImageView.reset(showSenders);
}
canvas.translate(mCoordinates.contactImagesX, mCoordinates.contactImagesY);
if (mPhotoBitmap == null) {
mSendersImageView.draw(canvas);
} else {
canvas.drawBitmap(mPhotoBitmap, null, mPhotoRect, sPaint);
}
}
private void drawAttachmentPreviews(Canvas canvas) {
canvas.translate(mCoordinates.attachmentPreviewsX, mCoordinates.attachmentPreviewsY);
final float fraction;
if (SwipeableListView.ENABLE_ATTACHMENT_PARALLAX) {
final View listView = getListView();
final View listItemView = unwrap();
if (mParallaxDirectionAlternative) {
fraction = 1 - (float) listItemView.getBottom()
/ (listView.getHeight() + listItemView.getHeight());
} else {
fraction = (float) listItemView.getBottom()
/ (listView.getHeight() + listItemView.getHeight());
}
} else {
// Vertically center the preview crop, which has already been decoded at 1/3.
fraction = 0.5f;
}
mAttachmentsView.setParallaxFraction(fraction);
mAttachmentsView.draw(canvas);
}
private void drawSubject(Canvas canvas) {
canvas.translate(mCoordinates.subjectX, mCoordinates.subjectY);
mSubjectTextView.draw(canvas);
}
private void drawSenders(Canvas canvas) {
canvas.translate(mCoordinates.sendersX, mCoordinates.sendersY);
mSendersTextView.draw(canvas);
}
private Bitmap getStarBitmap() {
return mHeader.conversation.starred ? STAR_ON : STAR_OFF;
}
private static void drawText(Canvas canvas, CharSequence s, int x, int y, TextPaint paint) {
canvas.drawText(s, 0, s.length(), x, y, paint);
}
/**
* Set the background for this item based on:
* 1. Read / Unread (unread messages have a lighter background)
* 2. Tablet / Phone
* 3. Checkbox checked / Unchecked (controls CAB color for item)
* 4. Activated / Not activated (controls the blue highlight on tablet)
* @param isUnread
*/
private void updateBackground(boolean isUnread) {
final int background;
if (mBackgroundOverrideResId > 0) {
background = mBackgroundOverrideResId;
} else if (isUnread) {
background = R.drawable.conversation_unread_selector;
} else {
background = R.drawable.conversation_read_selector;
}
setBackgroundResource(background);
}
/**
* Toggle the check mark on this view and update the conversation or begin
* drag, if drag is enabled.
*/
@Override
public boolean toggleSelectedStateOrBeginDrag() {
ViewMode mode = mActivity.getViewMode();
if (mIsExpansiveTablet && mode.isListMode()) {
return beginDragMode();
} else {
return toggleSelectedState("long_press");
}
}
@Override
public boolean toggleSelectedState() {
return toggleSelectedState(null);
}
private boolean toggleSelectedState(final String sourceOpt) {
if (mHeader != null && mHeader.conversation != null && mSelectedConversationSet != null) {
mSelected = !mSelected;
setSelected(mSelected);
final Conversation conv = mHeader.conversation;
// Set the list position of this item in the conversation
final SwipeableListView listView = getListView();
try {
conv.position = mSelected && listView != null ? listView.getPositionForView(this)
: Conversation.NO_POSITION;
} catch (final NullPointerException e) {
// TODO(skennedy) Remove this if we find the root cause b/9527863
}
if (mSelectedConversationSet.isEmpty()) {
final String source = (sourceOpt != null) ? sourceOpt : "checkbox";
Analytics.getInstance().sendEvent("enter_cab_mode", source, null, 0);
}
mSelectedConversationSet.toggle(conv);
if (mSelectedConversationSet.isEmpty()) {
listView.commitDestructiveActions(true);
}
final boolean front = !mSelected;
mSendersImageView.flipTo(front);
// We update the background after the checked state has changed
// now that we have a selected background asset. Setting the background
// usually waits for a layout pass, but we don't need a full layout,
// just an update to the background.
requestLayout();
return true;
}
return false;
}
@Override
public void onSetEmpty() {
mSendersImageView.flipTo(true);
}
@Override
public void onSetPopulated(final ConversationSelectionSet set) { }
@Override
public void onSetChanged(final ConversationSelectionSet set) { }
/**
* Toggle the star on this view and update the conversation.
*/
public void toggleStar() {
mHeader.conversation.starred = !mHeader.conversation.starred;
Bitmap starBitmap = getStarBitmap();
postInvalidate(mCoordinates.starX, mCoordinates.starY, mCoordinates.starX
+ starBitmap.getWidth(),
mCoordinates.starY + starBitmap.getHeight());
ConversationCursor cursor = (ConversationCursor) mAdapter.getCursor();
if (cursor != null) {
// TODO(skennedy) What about ads?
cursor.updateBoolean(mHeader.conversation, ConversationColumns.STARRED,
mHeader.conversation.starred);
}
}
private boolean isTouchInContactPhoto(float x, float y) {
// Everything before the right edge of contact photo
final int threshold = mCoordinates.contactImagesX + mCoordinates.contactImagesWidth
+ sSenderImageTouchSlop;
// Allow touching a little right of the contact photo when we're already in selection mode
final float extra;
if (mSelectedConversationSet == null || mSelectedConversationSet.isEmpty()) {
extra = 0;
} else {
extra = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16,
getResources().getDisplayMetrics());
}
return mHeader.gadgetMode == ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO
&& x < (threshold + extra)
&& (!isAttachmentPreviewsEnabled() || y < mCoordinates.attachmentPreviewsY);
}
private boolean isTouchInInfoIcon(final float x, final float y) {
if (mHeader.infoIcon == null) {
// We have no info icon
return false;
}
// Regardless of device, we always want to be right of the date's left touch slop
if (x < mDateX - sStarTouchSlop) {
return false;
}
if (mStarEnabled) {
if (mIsExpansiveTablet) {
// Just check that we're left of the star's touch area
if (x >= mCoordinates.starX - sStarTouchSlop) {
return false;
}
} else {
// We're on a phone or non-expansive tablet
// We allow touches all the way to the right edge, so no x check is necessary
// We need to be above the star's touch area, which ends at the top of the subject
// text
return y < mCoordinates.subjectY;
}
}
// With no star below the info icon, we allow touches anywhere from the top edge to the
// bottom edge, or to the top of the attachment previews, whichever is higher
return !isAttachmentPreviewsEnabled() || y < mCoordinates.attachmentPreviewsY;
}
private boolean isTouchInStar(float x, float y) {
if (mHeader.infoIcon != null && !mIsExpansiveTablet) {
// We have an info icon, and it's above the star
// We allow touches everywhere below the top of the subject text
if (y < mCoordinates.subjectY) {
return false;
}
}
// Everything after the star and include a touch slop.
return mStarEnabled
&& x > mCoordinates.starX - sStarTouchSlop
&& (!isAttachmentPreviewsEnabled() || y < mCoordinates.attachmentPreviewsY);
}
@Override
public boolean canChildBeDismissed() {
return true;
}
@Override
public void dismiss() {
SwipeableListView listView = getListView();
if (listView != null) {
getListView().dismissChild(this);
}
}
private boolean onTouchEventNoSwipe(MotionEvent event) {
Utils.traceBeginSection("on touch event no swipe");
boolean handled = false;
int x = (int) event.getX();
int y = (int) event.getY();
mLastTouchX = x;
mLastTouchY = y;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (isTouchInContactPhoto(x, y) || isTouchInInfoIcon(x, y) || isTouchInStar(x, y)) {
mDownEvent = true;
handled = true;
}
break;
case MotionEvent.ACTION_CANCEL:
mDownEvent = false;
break;
case MotionEvent.ACTION_UP:
if (mDownEvent) {
if (isTouchInContactPhoto(x, y)) {
// Touch on the check mark
toggleSelectedState();
} else if (isTouchInInfoIcon(x, y)) {
if (mConversationItemAreaClickListener != null) {
mConversationItemAreaClickListener.onInfoIconClicked();
}
} else if (isTouchInStar(x, y)) {
// Touch on the star
if (mConversationItemAreaClickListener == null) {
toggleStar();
} else {
mConversationItemAreaClickListener.onStarClicked();
}
}
handled = true;
}
break;
}
if (!handled) {
handled = super.onTouchEvent(event);
}
Utils.traceEndSection();
return handled;
}
/**
* ConversationItemView is given the first chance to handle touch events.
*/
@Override
public boolean onTouchEvent(MotionEvent event) {
Utils.traceBeginSection("on touch event");
int x = (int) event.getX();
int y = (int) event.getY();
mLastTouchX = x;
mLastTouchY = y;
if (!mSwipeEnabled) {
Utils.traceEndSection();
return onTouchEventNoSwipe(event);
}
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (isTouchInContactPhoto(x, y) || isTouchInInfoIcon(x, y) || isTouchInStar(x, y)) {
mDownEvent = true;
Utils.traceEndSection();
return true;
}
break;
case MotionEvent.ACTION_UP:
if (mDownEvent) {
if (isTouchInContactPhoto(x, y)) {
// Touch on the check mark
Utils.traceEndSection();
mDownEvent = false;
toggleSelectedState();
Utils.traceEndSection();
return true;
} else if (isTouchInInfoIcon(x, y)) {
// Touch on the info icon
mDownEvent = false;
if (mConversationItemAreaClickListener != null) {
mConversationItemAreaClickListener.onInfoIconClicked();
}
Utils.traceEndSection();
return true;
} else if (isTouchInStar(x, y)) {
// Touch on the star
mDownEvent = false;
if (mConversationItemAreaClickListener == null) {
toggleStar();
} else {
mConversationItemAreaClickListener.onStarClicked();
}
Utils.traceEndSection();
return true;
}
}
break;
}
// Let View try to handle it as well.
boolean handled = super.onTouchEvent(event);
if (event.getAction() == MotionEvent.ACTION_DOWN) {
Utils.traceEndSection();
return true;
}
Utils.traceEndSection();
return handled;
}
@Override
public boolean performClick() {
final boolean handled = super.performClick();
final SwipeableListView list = getListView();
if (!handled && list != null && list.getAdapter() != null) {
final int pos = list.findConversation(this, mHeader.conversation);
list.performItemClick(this, pos, mHeader.conversation.id);
}
return handled;
}
private View unwrap() {
final ViewParent vp = getParent();
if (vp == null || !(vp instanceof View)) {
return null;
}
return (View) vp;
}
private SwipeableListView getListView() {
SwipeableListView v = null;
final View wrapper = unwrap();
if (wrapper != null && wrapper instanceof SwipeableConversationItemView) {
v = (SwipeableListView) ((SwipeableConversationItemView) wrapper).getListView();
}
if (v == null) {
v = mAdapter.getListView();
}
return v;
}
/**
* Reset any state associated with this conversation item view so that it
* can be reused.
*/
public void reset() {
Utils.traceBeginSection("reset");
setAlpha(1f);
setTranslationX(0f);
mAnimatedHeightFraction = 1.0f;
Utils.traceEndSection();
}
@SuppressWarnings("deprecation")
@Override
public void setTranslationX(float translationX) {
super.setTranslationX(translationX);
// When a list item is being swiped or animated, ensure that the hosting view has a
// background color set. We only enable the background during the X-translation effect to
// reduce overdraw during normal list scrolling.
final View parent = (View) getParent();
if (parent == null) {
LogUtils.w(LOG_TAG, "CIV.setTranslationX null ConversationItemView parent x=%s",
translationX);
}
if (parent instanceof SwipeableConversationItemView) {
if (translationX != 0f) {
parent.setBackgroundResource(R.color.swiped_bg_color);
} else {
parent.setBackgroundDrawable(null);
}
}
}
/**
* Grow the height of the item and fade it in when bringing a conversation
* back from a destructive action.
*/
public Animator createSwipeUndoAnimation() {
ObjectAnimator undoAnimator = createTranslateXAnimation(true);
return undoAnimator;
}
/**
* Grow the height of the item and fade it in when bringing a conversation
* back from a destructive action.
*/
public Animator createUndoAnimation() {
ObjectAnimator height = createHeightAnimation(true);
Animator fade = ObjectAnimator.ofFloat(this, "alpha", 0, 1.0f);
fade.setDuration(sShrinkAnimationDuration);
fade.setInterpolator(new DecelerateInterpolator(2.0f));
AnimatorSet transitionSet = new AnimatorSet();
transitionSet.playTogether(height, fade);
transitionSet.addListener(new HardwareLayerEnabler(this));
return transitionSet;
}
/**
* Grow the height of the item and fade it in when bringing a conversation
* back from a destructive action.
*/
public Animator createDestroyWithSwipeAnimation() {
ObjectAnimator slide = createTranslateXAnimation(false);
ObjectAnimator height = createHeightAnimation(false);
AnimatorSet transitionSet = new AnimatorSet();
transitionSet.playSequentially(slide, height);
return transitionSet;
}
private ObjectAnimator createTranslateXAnimation(boolean show) {
SwipeableListView parent = getListView();
// If we can't get the parent...we have bigger problems.
int width = parent != null ? parent.getMeasuredWidth() : 0;
final float start = show ? width : 0f;
final float end = show ? 0f : width;
ObjectAnimator slide = ObjectAnimator.ofFloat(this, "translationX", start, end);
slide.setInterpolator(new DecelerateInterpolator(2.0f));
slide.setDuration(sSlideAnimationDuration);
return slide;
}
public Animator createDestroyAnimation() {
return createHeightAnimation(false);
}
private ObjectAnimator createHeightAnimation(boolean show) {
final float start = show ? 0f : 1.0f;
final float end = show ? 1.0f : 0f;
ObjectAnimator height = ObjectAnimator.ofFloat(this, "animatedHeightFraction", start, end);
height.setInterpolator(new DecelerateInterpolator(2.0f));
height.setDuration(sShrinkAnimationDuration);
return height;
}
// Used by animator
public void setAnimatedHeightFraction(float height) {
mAnimatedHeightFraction = height;
requestLayout();
}
@Override
public SwipeableView getSwipeableView() {
return SwipeableView.from(this);
}
/**
* Begin drag mode. Keep the conversation selected (NOT toggle selection) and start drag.
*/
private boolean beginDragMode() {
if (mLastTouchX < 0 || mLastTouchY < 0 || mSelectedConversationSet == null) {
return false;
}
// If this is already checked, don't bother unchecking it!
if (!mSelected) {
toggleSelectedState();
}
// Clip data has form: [conversations_uri, conversationId1,
// maxMessageId1, label1, conversationId2, maxMessageId2, label2, ...]
final int count = mSelectedConversationSet.size();
String description = Utils.formatPlural(mContext, R.plurals.move_conversation, count);
final ClipData data = ClipData.newUri(mContext.getContentResolver(), description,
Conversation.MOVE_CONVERSATIONS_URI);
for (Conversation conversation : mSelectedConversationSet.values()) {
data.addItem(new Item(String.valueOf(conversation.position)));
}
// Protect against non-existent views: only happens for monkeys
final int width = this.getWidth();
final int height = this.getHeight();
final boolean isDimensionNegative = (width < 0) || (height < 0);
if (isDimensionNegative) {
LogUtils.e(LOG_TAG, "ConversationItemView: dimension is negative: "
+ "width=%d, height=%d", width, height);
return false;
}
mActivity.startDragMode();
// Start drag mode
startDrag(data, new ShadowBuilder(this, count, mLastTouchX, mLastTouchY), null, 0);
return true;
}
/**
* Handles the drag event.
*
* @param event the drag event to be handled
*/
@Override
public boolean onDragEvent(DragEvent event) {
switch (event.getAction()) {
case DragEvent.ACTION_DRAG_ENDED:
mActivity.stopDragMode();
return true;
}
return false;
}
private class ShadowBuilder extends DragShadowBuilder {
private final Drawable mBackground;
private final View mView;
private final String mDragDesc;
private final int mTouchX;
private final int mTouchY;
private int mDragDescX;
private int mDragDescY;
public ShadowBuilder(View view, int count, int touchX, int touchY) {
super(view);
mView = view;
mBackground = mView.getResources().getDrawable(R.drawable.list_pressed_holo);
mDragDesc = Utils.formatPlural(mView.getContext(), R.plurals.move_conversation, count);
mTouchX = touchX;
mTouchY = touchY;
}
@Override
public void onProvideShadowMetrics(Point shadowSize, Point shadowTouchPoint) {
final int width = mView.getWidth();
final int height = mView.getHeight();
sPaint.setTextSize(mCoordinates.subjectFontSize);
mDragDescX = mCoordinates.sendersX;
mDragDescY = (height - (int) mCoordinates.subjectFontSize) / 2 ;
shadowSize.set(width, height);
shadowTouchPoint.set(mTouchX, mTouchY);
}
@Override
public void onDrawShadow(Canvas canvas) {
mBackground.setBounds(0, 0, mView.getWidth(), mView.getHeight());
mBackground.draw(canvas);
sPaint.setTextSize(mCoordinates.subjectFontSize);
canvas.drawText(mDragDesc, mDragDescX, mDragDescY - sPaint.ascent(), sPaint);
}
}
@Override
public float getMinAllowScrollDistance() {
return sScrollSlop;
}
public String getAccount() {
return mAccount;
}
}