/* * 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.app.FragmentManager; import android.app.LoaderManager; import android.content.Context; import android.support.annotation.IntDef; import android.support.v4.text.BidiFormatter; import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; import android.widget.BaseAdapter; import com.android.emailcommon.mail.Address; import com.android.mail.ContactInfoSource; import com.android.mail.FormattedDateBuilder; import com.android.mail.R; import com.android.mail.browse.ConversationFooterView.ConversationFooterCallbacks; import com.android.mail.browse.ConversationViewHeader.ConversationViewHeaderCallbacks; import com.android.mail.browse.MessageFooterView.MessageFooterCallbacks; import com.android.mail.browse.MessageHeaderView.MessageHeaderViewCallbacks; import com.android.mail.browse.SuperCollapsedBlock.OnClickListener; import com.android.mail.providers.Conversation; import com.android.mail.providers.UIProvider; import com.android.mail.ui.ControllableActivity; import com.android.mail.ui.ConversationUpdater; import com.android.mail.utils.LogTag; import com.android.mail.utils.LogUtils; import com.android.mail.utils.VeiledAddressMatcher; import com.google.common.base.Objects; import com.google.common.collect.Lists; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Set; /** * A specialized adapter that contains overlay views to draw on top of the underlying conversation * WebView. Each independently drawn overlay view gets its own item in this adapter, and indices * in this adapter do not necessarily line up with cursor indices. For example, an expanded * message may have a header and footer, and since they are not drawn coupled together, they each * get an adapter item. *

* Each item in this adapter is a {@link ConversationOverlayItem} to expose enough information * to {@link ConversationContainer} so that it can position overlays properly. * */ public class ConversationViewAdapter extends BaseAdapter { private static final String LOG_TAG = LogTag.getLogTag(); private static final String OVERLAY_ITEM_ROOT_TAG = "overlay_item_root"; private final Context mContext; private final FormattedDateBuilder mDateBuilder; private final ConversationAccountController mAccountController; private final LoaderManager mLoaderManager; private final FragmentManager mFragmentManager; private final MessageHeaderViewCallbacks mMessageCallbacks; private final MessageFooterCallbacks mFooterCallbacks; private final ContactInfoSource mContactInfoSource; private final ConversationViewHeaderCallbacks mConversationCallbacks; private final ConversationFooterCallbacks mConversationFooterCallbacks; private final ConversationUpdater mConversationUpdater; private final OnClickListener mSuperCollapsedListener; private final Map mAddressCache; private final LayoutInflater mInflater; private final List mItems; private final VeiledAddressMatcher mMatcher; @Retention(RetentionPolicy.SOURCE) @IntDef({ VIEW_TYPE_CONVERSATION_HEADER, VIEW_TYPE_CONVERSATION_FOOTER, VIEW_TYPE_MESSAGE_HEADER, VIEW_TYPE_MESSAGE_FOOTER, VIEW_TYPE_SUPER_COLLAPSED_BLOCK, VIEW_TYPE_AD_HEADER, VIEW_TYPE_AD_SENDER_HEADER, VIEW_TYPE_AD_FOOTER }) public @interface ConversationViewType {} public static final int VIEW_TYPE_CONVERSATION_HEADER = 0; public static final int VIEW_TYPE_CONVERSATION_FOOTER = 1; public static final int VIEW_TYPE_MESSAGE_HEADER = 2; public static final int VIEW_TYPE_MESSAGE_FOOTER = 3; public static final int VIEW_TYPE_SUPER_COLLAPSED_BLOCK = 4; public static final int VIEW_TYPE_AD_HEADER = 5; public static final int VIEW_TYPE_AD_SENDER_HEADER = 6; public static final int VIEW_TYPE_AD_FOOTER = 7; public static final int VIEW_TYPE_COUNT = 8; private final BidiFormatter mBidiFormatter; private final View.OnKeyListener mOnKeyListener; public class ConversationHeaderItem extends ConversationOverlayItem { public final Conversation mConversation; private ConversationHeaderItem(Conversation conv) { mConversation = conv; } @Override public @ConversationViewType int getType() { return VIEW_TYPE_CONVERSATION_HEADER; } @Override public View createView(Context context, LayoutInflater inflater, ViewGroup parent) { final ConversationViewHeader v = (ConversationViewHeader) inflater.inflate( R.layout.conversation_view_header, parent, false); v.setCallbacks( mConversationCallbacks, mAccountController, mConversationUpdater); v.setSubject(mConversation.subject); if (mAccountController.getAccount().supportsCapability( UIProvider.AccountCapabilities.MULTIPLE_FOLDERS_PER_CONV)) { v.setFolders(mConversation); } v.setStarred(mConversation.starred); v.setTag(OVERLAY_ITEM_ROOT_TAG); return v; } @Override public void bindView(View v, boolean measureOnly) { ConversationViewHeader header = (ConversationViewHeader) v; header.bind(this); } @Override public boolean isContiguous() { return true; } @Override public View.OnKeyListener getOnKeyListener() { return mOnKeyListener; } public ConversationViewAdapter getAdapter() { return ConversationViewAdapter.this; } } public class ConversationFooterItem extends ConversationOverlayItem { private MessageHeaderItem mLastMessageHeaderItem; public ConversationFooterItem(MessageHeaderItem lastMessageHeaderItem) { setLastMessageHeaderItem(lastMessageHeaderItem); } @Override public @ConversationViewType int getType() { return VIEW_TYPE_CONVERSATION_FOOTER; } @Override public View createView(Context context, LayoutInflater inflater, ViewGroup parent) { final ConversationFooterView v = (ConversationFooterView) inflater.inflate(R.layout.conversation_footer, parent, false); v.setAccountController(mAccountController); v.setConversationFooterCallbacks(mConversationFooterCallbacks); v.setTag(OVERLAY_ITEM_ROOT_TAG); // Register the onkey listener for all relevant views registerOnKeyListeners(v, v.findViewById(R.id.reply_button), v.findViewById(R.id.reply_all_button), v.findViewById(R.id.forward_button)); return v; } @Override public void bindView(View v, boolean measureOnly) { ((ConversationFooterView) v).bind(this); mRootView = v; } @Override public void rebindView(View view) { ((ConversationFooterView) view).rebind(this); mRootView = view; } @Override public View getFocusableView() { return mRootView.findViewById(R.id.reply_button); } @Override public boolean isContiguous() { return true; } @Override public View.OnKeyListener getOnKeyListener() { return mOnKeyListener; } public MessageHeaderItem getLastMessageHeaderItem() { return mLastMessageHeaderItem; } public void setLastMessageHeaderItem(MessageHeaderItem lastMessageHeaderItem) { mLastMessageHeaderItem = lastMessageHeaderItem; } } public static class MessageHeaderItem extends ConversationOverlayItem { private final ConversationViewAdapter mAdapter; private ConversationMessage mMessage; // view state variables private boolean mExpanded; public boolean detailsExpanded; private boolean mShowImages; // cached values to speed up re-rendering during view recycling private CharSequence mTimestampShort; private CharSequence mTimestampLong; private CharSequence mTimestampFull; private long mTimestampMs; private final FormattedDateBuilder mDateBuilder; public CharSequence recipientSummaryText; MessageHeaderItem(ConversationViewAdapter adapter, FormattedDateBuilder dateBuilder, ConversationMessage message, boolean expanded, boolean showImages) { mAdapter = adapter; mDateBuilder = dateBuilder; mMessage = message; mExpanded = expanded; mShowImages = showImages; detailsExpanded = false; } public ConversationMessage getMessage() { return mMessage; } @Override public @ConversationViewType int getType() { return VIEW_TYPE_MESSAGE_HEADER; } @Override public View createView(Context context, LayoutInflater inflater, ViewGroup parent) { final MessageHeaderView v = (MessageHeaderView) inflater.inflate( R.layout.conversation_message_header, parent, false); v.initialize(mAdapter.mAccountController, mAdapter.mAddressCache); v.setCallbacks(mAdapter.mMessageCallbacks); v.setContactInfoSource(mAdapter.mContactInfoSource); v.setVeiledMatcher(mAdapter.mMatcher); v.setTag(OVERLAY_ITEM_ROOT_TAG); // Register the onkey listener for all relevant views registerOnKeyListeners(v, v.findViewById(R.id.upper_header), v.findViewById(R.id.hide_details), v.findViewById(R.id.edit_draft), v.findViewById(R.id.reply), v.findViewById(R.id.reply_all), v.findViewById(R.id.overflow), v.findViewById(R.id.send_date)); return v; } @Override public void bindView(View v, boolean measureOnly) { final MessageHeaderView header = (MessageHeaderView) v; header.bind(this, measureOnly); mRootView = v; } @Override public View getFocusableView() { return mRootView.findViewById(R.id.upper_header); } @Override public void onModelUpdated(View v) { final MessageHeaderView header = (MessageHeaderView) v; header.refresh(); } @Override public boolean isContiguous() { return !isExpanded(); } @Override public View.OnKeyListener getOnKeyListener() { return mAdapter.getOnKeyListener(); } @Override public boolean isExpanded() { return mExpanded; } public void setExpanded(boolean expanded) { if (mExpanded != expanded) { mExpanded = expanded; } } public boolean getShowImages() { return mShowImages; } public void setShowImages(boolean showImages) { mShowImages = showImages; } @Override public boolean canBecomeSnapHeader() { return isExpanded(); } @Override public boolean canPushSnapHeader() { return true; } @Override public boolean belongsToMessage(ConversationMessage message) { return Objects.equal(mMessage, message); } @Override public void setMessage(ConversationMessage message) { mMessage = message; // setMessage signifies an in-place update to the message, so let's clear out recipient // summary text so the view will refresh it on the next render. recipientSummaryText = null; } public CharSequence getTimestampShort() { ensureTimestamps(); return mTimestampShort; } public CharSequence getTimestampLong() { ensureTimestamps(); return mTimestampLong; } public CharSequence getTimestampFull() { ensureTimestamps(); return mTimestampFull; } private void ensureTimestamps() { if (mMessage.dateReceivedMs != mTimestampMs) { mTimestampMs = mMessage.dateReceivedMs; mTimestampShort = mDateBuilder.formatShortDateTime(mTimestampMs); mTimestampLong = mDateBuilder.formatLongDateTime(mTimestampMs); mTimestampFull = mDateBuilder.formatFullDateTime(mTimestampMs); } } public ConversationViewAdapter getAdapter() { return mAdapter; } @Override public void rebindView(View view) { final MessageHeaderView header = (MessageHeaderView) view; header.rebind(this); mRootView = view; } } public static class MessageFooterItem extends ConversationOverlayItem { private final ConversationViewAdapter mAdapter; /** * A footer can only exist if there is a matching header. Requiring a header allows a * footer to stay in sync with the expanded state of the header. */ private final MessageHeaderItem mHeaderItem; private MessageFooterItem(ConversationViewAdapter adapter, MessageHeaderItem item) { mAdapter = adapter; mHeaderItem = item; } @Override public @ConversationViewType int getType() { return VIEW_TYPE_MESSAGE_FOOTER; } @Override public View createView(Context context, LayoutInflater inflater, ViewGroup parent) { final MessageFooterView v = (MessageFooterView) inflater.inflate( R.layout.conversation_message_footer, parent, false); v.initialize(mAdapter.mLoaderManager, mAdapter.mFragmentManager, mAdapter.mAccountController, mAdapter.mFooterCallbacks); v.setTag(OVERLAY_ITEM_ROOT_TAG); // Register the onkey listener for all relevant views registerOnKeyListeners(v, v.findViewById(R.id.view_entire_message_prompt)); return v; } @Override public void bindView(View v, boolean measureOnly) { final MessageFooterView attachmentsView = (MessageFooterView) v; attachmentsView.bind(mHeaderItem, measureOnly); mRootView = v; } @Override public boolean isContiguous() { return true; } @Override public View.OnKeyListener getOnKeyListener() { return mAdapter.getOnKeyListener(); } @Override public boolean isExpanded() { return mHeaderItem.isExpanded(); } @Override public int getGravity() { // attachments are top-aligned within their spacer area // Attachments should stay near the body they belong to, even when zoomed far in. return Gravity.TOP; } @Override public int getHeight() { // a footer may change height while its view does not exist because it is offscreen // (but the header is onscreen and thus collapsible) if (!mHeaderItem.isExpanded()) { return 0; } return super.getHeight(); } public MessageHeaderItem getHeaderItem() { return mHeaderItem; } } public class SuperCollapsedBlockItem extends ConversationOverlayItem { private final int mStart; private final int mEnd; private final boolean mHasDraft; private SuperCollapsedBlockItem(int start, int end, boolean hasDraft) { mStart = start; mEnd = end; mHasDraft = hasDraft; } @Override public @ConversationViewType int getType() { return VIEW_TYPE_SUPER_COLLAPSED_BLOCK; } @Override public View createView(Context context, LayoutInflater inflater, ViewGroup parent) { final SuperCollapsedBlock v = (SuperCollapsedBlock) inflater.inflate( R.layout.super_collapsed_block, parent, false); v.initialize(mSuperCollapsedListener); v.setOnKeyListener(mOnKeyListener); v.setTag(OVERLAY_ITEM_ROOT_TAG); // Register the onkey listener for all relevant views registerOnKeyListeners(v); return v; } @Override public void bindView(View v, boolean measureOnly) { final SuperCollapsedBlock scb = (SuperCollapsedBlock) v; scb.bind(this); mRootView = v; } @Override public boolean isContiguous() { return true; } @Override public View.OnKeyListener getOnKeyListener() { return mOnKeyListener; } @Override public boolean isExpanded() { return false; } public int getStart() { return mStart; } public int getEnd() { return mEnd; } public boolean hasDraft() { return mHasDraft; } @Override public boolean canPushSnapHeader() { return true; } } public ConversationViewAdapter(ControllableActivity controllableActivity, ConversationAccountController accountController, LoaderManager loaderManager, MessageHeaderViewCallbacks messageCallbacks, MessageFooterCallbacks footerCallbacks, ContactInfoSource contactInfoSource, ConversationViewHeaderCallbacks convCallbacks, ConversationFooterCallbacks convFooterCallbacks, ConversationUpdater conversationUpdater, OnClickListener scbListener, Map addressCache, FormattedDateBuilder dateBuilder, BidiFormatter bidiFormatter, View.OnKeyListener onKeyListener) { mContext = controllableActivity.getActivityContext(); mDateBuilder = dateBuilder; mAccountController = accountController; mLoaderManager = loaderManager; mFragmentManager = controllableActivity.getFragmentManager(); mMessageCallbacks = messageCallbacks; mFooterCallbacks = footerCallbacks; mContactInfoSource = contactInfoSource; mConversationCallbacks = convCallbacks; mConversationFooterCallbacks = convFooterCallbacks; mConversationUpdater = conversationUpdater; mSuperCollapsedListener = scbListener; mAddressCache = addressCache; mInflater = LayoutInflater.from(mContext); mItems = Lists.newArrayList(); mMatcher = controllableActivity.getAccountController().getVeiledAddressMatcher(); mBidiFormatter = bidiFormatter; mOnKeyListener = onKeyListener; } @Override public int getCount() { return mItems.size(); } @Override public @ConversationViewType int getItemViewType(int position) { return mItems.get(position).getType(); } @Override public int getViewTypeCount() { return VIEW_TYPE_COUNT; } @Override public ConversationOverlayItem getItem(int position) { return mItems.get(position); } @Override public long getItemId(int position) { return position; // TODO: ensure this works well enough } @Override public View getView(int position, View convertView, ViewGroup parent) { return getView(getItem(position), convertView, parent, false /* measureOnly */); } public View getView(ConversationOverlayItem item, View convertView, ViewGroup parent, boolean measureOnly) { final View v; if (convertView == null) { v = item.createView(mContext, mInflater, parent); } else { v = convertView; } item.bindView(v, measureOnly); return v; } public LayoutInflater getLayoutInflater() { return mInflater; } public FormattedDateBuilder getDateBuilder() { return mDateBuilder; } public int addItem(ConversationOverlayItem item) { final int pos = mItems.size(); item.setPosition(pos); mItems.add(item); return pos; } public void clear() { mItems.clear(); notifyDataSetChanged(); } public int addConversationHeader(Conversation conv) { return addItem(new ConversationHeaderItem(conv)); } public int addConversationFooter(MessageHeaderItem headerItem) { return addItem(new ConversationFooterItem(headerItem)); } public int addMessageHeader(ConversationMessage msg, boolean expanded, boolean showImages) { return addItem(new MessageHeaderItem(this, mDateBuilder, msg, expanded, showImages)); } public int addMessageFooter(MessageHeaderItem headerItem) { return addItem(new MessageFooterItem(this, headerItem)); } public static MessageHeaderItem newMessageHeaderItem(ConversationViewAdapter adapter, FormattedDateBuilder dateBuilder, ConversationMessage message, boolean expanded, boolean showImages) { return new MessageHeaderItem(adapter, dateBuilder, message, expanded, showImages); } public static MessageFooterItem newMessageFooterItem( ConversationViewAdapter adapter, MessageHeaderItem headerItem) { return new MessageFooterItem(adapter, headerItem); } public int addSuperCollapsedBlock(int start, int end, boolean hasDraft) { return addItem(new SuperCollapsedBlockItem(start, end, hasDraft)); } public void replaceSuperCollapsedBlock(SuperCollapsedBlockItem blockToRemove, Collection replacements) { final int pos = mItems.indexOf(blockToRemove); if (pos == -1) { return; } mItems.remove(pos); mItems.addAll(pos, replacements); // update position for all items for (int i = 0, size = mItems.size(); i < size; i++) { mItems.get(i).setPosition(i); } } public void updateItemsForMessage(ConversationMessage message, List affectedPositions) { for (int i = 0, len = mItems.size(); i < len; i++) { final ConversationOverlayItem item = mItems.get(i); if (item.belongsToMessage(message)) { item.setMessage(message); affectedPositions.add(i); } } } /** * Remove and return the {@link ConversationFooterItem} from the adapter. */ public ConversationFooterItem removeFooterItem() { final int count = mItems.size(); if (count < 4) { LogUtils.e(LOG_TAG, "not enough items in the adapter. count: %s", count); return null; } final ConversationFooterItem item = (ConversationFooterItem) mItems.remove(count - 1); if (item == null) { LogUtils.e(LOG_TAG, "removed wrong overlay item: %s", item); return null; } return item; } public ConversationFooterItem getFooterItem() { final int count = mItems.size(); if (count < 4) { LogUtils.e(LOG_TAG, "not enough items in the adapter. count: %s", count); return null; } final ConversationOverlayItem item = mItems.get(count - 1); try { return (ConversationFooterItem) item; } catch (ClassCastException e) { LogUtils.e(LOG_TAG, "Last item is not a conversation footer. type: %s", item.getType()); return null; } } /** * Returns true if the item before this one is of type * {@link #VIEW_TYPE_SUPER_COLLAPSED_BLOCK}. */ public boolean isPreviousItemSuperCollapsed(ConversationOverlayItem item) { // super-collapsed will be the item just before the header final int position = item.getPosition() - 1; final int count = mItems.size(); return !(position < 0 || position >= count) && mItems.get(position).getType() == VIEW_TYPE_SUPER_COLLAPSED_BLOCK; } // This should be a safe call since all containers should have at least a conv header and a // message header. public boolean focusFirstMessageHeader() { if (mItems.size() > 1) { final View v = mItems.get(1).getFocusableView(); if (v != null && v.isShown() && v.isFocusable()) { v.requestFocus(); return true; } } return false; } /** * Find the next view that should grab focus with respect to the current position. */ public View getNextOverlayView(View curr, boolean isDown, Set scraps) { // First find the root view of the overlay item while (curr.getTag() != OVERLAY_ITEM_ROOT_TAG) { final ViewParent parent = curr.getParent(); if (parent != null && parent instanceof View) { curr = (View) parent; } else { return null; } } // Find the position of the root view for (int i = 0; i < mItems.size(); i++) { if (mItems.get(i).mRootView == curr) { // Found view, now find the next applicable view if (isDown && i >= 0) { while (++i < mItems.size()) { final ConversationOverlayItem item = mItems.get(i); final View next = item.getFocusableView(); if (item.mRootView != null && !scraps.contains(item.mRootView) && next != null && next.isFocusable()) { return next; } } } else { while (--i >= 0) { final ConversationOverlayItem item = mItems.get(i); final View next = item.getFocusableView(); if (item.mRootView != null && !scraps.contains(item.mRootView) && next != null && next.isFocusable()) { return next; } } } return null; } } return null; } public BidiFormatter getBidiFormatter() { return mBidiFormatter; } public View.OnKeyListener getOnKeyListener() { return mOnKeyListener; } }