| /* |
| * Copyright (C) 2013 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.ui; |
| |
| import android.app.LoaderManager; |
| import android.app.LoaderManager.LoaderCallbacks; |
| import android.content.Context; |
| import android.content.Loader; |
| import android.content.res.Resources; |
| import android.graphics.Color; |
| import android.net.Uri; |
| import android.os.Bundle; |
| import android.support.v4.util.SparseArrayCompat; |
| import android.text.TextUtils; |
| import android.util.AttributeSet; |
| import android.view.LayoutInflater; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.widget.ImageView; |
| import android.widget.LinearLayout; |
| import android.widget.TextView; |
| |
| import com.android.mail.R; |
| import com.android.mail.browse.ConversationCursor; |
| import com.android.mail.content.ObjectCursor; |
| import com.android.mail.content.ObjectCursorLoader; |
| import com.android.mail.providers.Account; |
| import com.android.mail.providers.Conversation; |
| import com.android.mail.providers.Folder; |
| import com.android.mail.providers.MessageInfo; |
| import com.android.mail.providers.UIProvider; |
| import com.android.mail.providers.UIProvider.AccountCapabilities; |
| import com.android.mail.providers.UIProvider.ConversationListQueryParameters; |
| import com.android.mail.utils.LogUtils; |
| import com.android.mail.utils.Utils; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.ImmutableSortedSet; |
| import com.google.common.collect.Lists; |
| import com.google.common.collect.Maps; |
| |
| import java.util.ArrayList; |
| import java.util.Comparator; |
| import java.util.List; |
| import java.util.Map; |
| |
| /** |
| * The teaser list item in the conversation list that shows nested folders. |
| */ |
| public class NestedFolderTeaserView extends LinearLayout implements ConversationSpecialItemView { |
| private static final String LOG_TAG = "NestedFolderTeaserView"; |
| |
| private boolean mShouldDisplayInList = false; |
| |
| private Account mAccount; |
| private Uri mFolderListUri; |
| private FolderSelector mListener; |
| |
| private LoaderManager mLoaderManager = null; |
| private AnimatedAdapter mAdapter = null; |
| |
| private final SparseArrayCompat<FolderHolder> mFolderHolders = |
| new SparseArrayCompat<FolderHolder>(); |
| |
| private final int mFolderItemUpdateDelayMs; |
| |
| private int mAnimatedHeight = -1; |
| |
| private ViewGroup mNestedFolderContainer; |
| |
| private View mShowMoreFoldersRow; |
| private TextView mShowMoreFoldersTextView; |
| private TextView mShowMoreFoldersCountTextView; |
| |
| /** |
| * If <code>true</code> we show a limited set of folders, and a means to show all folders. If |
| * <code>false</code>, we show all folders. |
| */ |
| private boolean mCollapsed = true; |
| |
| private View mTeaserRightEdge; |
| /** Whether we are on a tablet device or not */ |
| private final boolean mTabletDevice; |
| /** When in conversation mode, true if the list is hidden */ |
| private final boolean mListCollapsible; |
| |
| /** If <code>true</code>, the list of folders has updated since the view was last shown. */ |
| private boolean mListUpdated; |
| |
| // Each folder's loader will be this value plus the folder id |
| private static final int LOADER_FOLDER_LIST = |
| AbstractActivityController.LAST_FRAGMENT_LOADER_ID + 100000; |
| |
| /** |
| * The maximum number of senders to show in the sender snippet. |
| */ |
| private static final String MAX_SENDERS = "20"; |
| |
| /** |
| * The number of folders to show when the teaser is collapsed. |
| */ |
| private static int sCollapsedFolderThreshold = -1; |
| |
| private static class FolderHolder { |
| private final View mItemView; |
| private final TextView mSendersTextView; |
| private final TextView mCountTextView; |
| private Folder mFolder; |
| private List<String> mUnreadSenders = ImmutableList.of(); |
| |
| public FolderHolder(final View itemView, final TextView sendersTextView, |
| final TextView countTextView) { |
| mItemView = itemView; |
| mSendersTextView = sendersTextView; |
| mCountTextView = countTextView; |
| } |
| |
| public void setFolder(final Folder folder) { |
| mFolder = folder; |
| } |
| |
| public View getItemView() { |
| return mItemView; |
| } |
| |
| public TextView getSendersTextView() { |
| return mSendersTextView; |
| } |
| |
| public TextView getCountTextView() { |
| return mCountTextView; |
| } |
| |
| public Folder getFolder() { |
| return mFolder; |
| } |
| |
| /** |
| * @return a {@link List} of senders of unread messages |
| */ |
| public List<String> getUnreadSenders() { |
| return mUnreadSenders; |
| } |
| |
| public void setUnreadSenders(final List<String> unreadSenders) { |
| mUnreadSenders = unreadSenders; |
| } |
| |
| public static final Comparator<FolderHolder> NAME_COMPARATOR = |
| new Comparator<FolderHolder>() { |
| @Override |
| public int compare(final FolderHolder lhs, final FolderHolder rhs) { |
| return lhs.getFolder().name.compareTo(rhs.getFolder().name); |
| } |
| }; |
| } |
| |
| public NestedFolderTeaserView(final Context context) { |
| this(context, null); |
| } |
| |
| public NestedFolderTeaserView(final Context context, final AttributeSet attrs) { |
| this(context, attrs, -1); |
| } |
| |
| public NestedFolderTeaserView( |
| final Context context, final AttributeSet attrs, final int defStyle) { |
| super(context, attrs, defStyle); |
| |
| final Resources resources = context.getResources(); |
| |
| if (sCollapsedFolderThreshold < 0) { |
| sCollapsedFolderThreshold = |
| resources.getInteger(R.integer.nested_folders_collapse_threshold); |
| } |
| |
| mFolderItemUpdateDelayMs = |
| resources.getInteger(R.integer.folder_item_refresh_delay_ms); |
| |
| mTabletDevice = com.android.mail.utils.Utils.useTabletUI(resources); |
| mListCollapsible = resources.getBoolean(R.bool.list_collapsible); |
| } |
| |
| @Override |
| protected void onFinishInflate() { |
| mNestedFolderContainer = (ViewGroup) findViewById(R.id.nested_folder_container); |
| mTeaserRightEdge = findViewById(R.id.teaser_right_edge); |
| |
| mShowMoreFoldersRow = findViewById(R.id.show_more_folders_row); |
| mShowMoreFoldersRow.setOnClickListener(mShowMoreOnClickListener); |
| |
| mShowMoreFoldersTextView = (TextView) findViewById(R.id.show_more_folders_textView); |
| mShowMoreFoldersCountTextView = |
| (TextView) findViewById(R.id.show_more_folders_count_textView); |
| } |
| |
| public void bind(final Account account, final FolderSelector listener) { |
| mAccount = account; |
| mListener = listener; |
| } |
| |
| /** |
| * Creates a {@link FolderHolder}. |
| */ |
| private FolderHolder createFolderHolder(final CharSequence folderName) { |
| final View itemView = |
| LayoutInflater.from(getContext()).inflate(R.layout.folder_teaser_item, null); |
| |
| final ImageView imageView = (ImageView) itemView.findViewById(R.id.folder_imageView); |
| imageView.setImageResource(R.drawable.ic_menu_folders_holo_light); |
| // Remove background |
| imageView.setBackgroundColor(Color.TRANSPARENT); |
| |
| ((TextView) itemView.findViewById(R.id.folder_textView)).setText(folderName); |
| final TextView sendersTextView = (TextView) itemView.findViewById(R.id.senders_textView); |
| final TextView countTextView = (TextView) itemView.findViewById(R.id.count_textView); |
| final FolderHolder holder = new FolderHolder(itemView, sendersTextView, countTextView); |
| |
| attachOnClickListener(itemView, holder); |
| |
| return holder; |
| } |
| |
| private void attachOnClickListener(final View view, final FolderHolder holder) { |
| view.setOnClickListener(new OnClickListener() { |
| @Override |
| public void onClick(final View v) { |
| mListener.onFolderSelected(holder.getFolder()); |
| } |
| }); |
| } |
| |
| @Override |
| public void onUpdate(final Folder folder, final ConversationCursor cursor) { |
| mShouldDisplayInList = false; // Assume disabled |
| |
| if (folder == null) { |
| return; |
| } |
| |
| final Uri folderListUri = folder.childFoldersListUri; |
| if (folderListUri == null) { |
| return; |
| } |
| |
| // If we don't support nested folders, don't show this view |
| if (!mAccount.supportsCapability(AccountCapabilities.NESTED_FOLDERS)) { |
| return; |
| } |
| |
| if (mFolderListUri == null || !mFolderListUri.equals(folder.childFoldersListUri)) { |
| // We have a new uri |
| mFolderListUri = folderListUri; |
| |
| // Restart the loader |
| mLoaderManager.destroyLoader(LOADER_FOLDER_LIST); |
| mLoaderManager.initLoader(LOADER_FOLDER_LIST, null, mFolderListLoaderCallbacks); |
| } |
| |
| mShouldDisplayInList = true; // Now we know we have something to display |
| } |
| |
| @Override |
| public void onGetView() { |
| if (mListUpdated) { |
| // Clear out the folder views |
| mNestedFolderContainer.removeAllViews(); |
| |
| // Sort the folders by name |
| // TODO(skennedy) recents? starred? |
| final ImmutableSortedSet.Builder<FolderHolder> folderHoldersBuilder = |
| new ImmutableSortedSet.Builder<FolderHolder>(FolderHolder.NAME_COMPARATOR); |
| |
| for (int i = 0; i < mFolderHolders.size(); i++) { |
| folderHoldersBuilder.add(mFolderHolders.valueAt(i)); |
| } |
| |
| final ImmutableSortedSet<FolderHolder> folderHolders = folderHoldersBuilder.build(); |
| |
| // Add all folder views to the teaser |
| int added = 0; |
| for (final FolderHolder folderHolder : folderHolders) { |
| mNestedFolderContainer.addView(folderHolder.getItemView()); |
| added++; |
| |
| if (added >= sCollapsedFolderThreshold && mCollapsed) { |
| // We will display the rest when "Show more" is clicked |
| break; |
| } |
| } |
| |
| updateShowMoreView(); |
| |
| mListUpdated = false; |
| } |
| } |
| |
| private final OnClickListener mShowMoreOnClickListener = new OnClickListener() { |
| @Override |
| public void onClick(final View v) { |
| mCollapsed = !mCollapsed; |
| mListUpdated = true; |
| mAdapter.notifyDataSetChanged(); |
| } |
| }; |
| |
| private void updateShowMoreView() { |
| final int total = mFolderHolders.size(); |
| final int displayed = mNestedFolderContainer.getChildCount(); |
| final int notShown = total - displayed; |
| |
| if (notShown > 0) { |
| // We are not displaying all the folders |
| mShowMoreFoldersRow.setVisibility(VISIBLE); |
| mShowMoreFoldersTextView.setText(String.format( |
| getContext().getString(R.string.show_n_more_folders), notShown)); |
| mShowMoreFoldersCountTextView.setVisibility(VISIBLE); |
| |
| // Get a count of unread messages in other folders |
| int unreadCount = 0; |
| for (int i = 0; i < mFolderHolders.size(); i++) { |
| final FolderHolder holder = mFolderHolders.valueAt(i); |
| |
| if (holder.getItemView().getParent() == null) { |
| // This view is not shown, so we want to use its unread count |
| // TODO(skennedy) We want a "nested" unread count, that includes the unread |
| // count of nested folders |
| unreadCount += holder.getFolder().unreadCount; |
| } |
| } |
| |
| mShowMoreFoldersCountTextView.setText(Integer.toString(unreadCount)); |
| } else if (displayed > sCollapsedFolderThreshold) { |
| // We are expanded |
| mShowMoreFoldersRow.setVisibility(VISIBLE); |
| mShowMoreFoldersTextView.setText(R.string.hide_folders); |
| mShowMoreFoldersCountTextView.setVisibility(GONE); |
| } else { |
| // We don't need to collapse the folders |
| mShowMoreFoldersRow.setVisibility(GONE); |
| } |
| } |
| |
| private void updateViews(final FolderHolder folderHolder) { |
| final Folder folder = folderHolder.getFolder(); |
| |
| final String unreadText = Utils.getUnreadCountString(getContext(), folder.unreadCount); |
| folderHolder.getCountTextView().setText(unreadText.isEmpty() ? "0" : unreadText); |
| |
| final String sendersText = TextUtils.join(", ", folderHolder.getUnreadSenders()); |
| folderHolder.getSendersTextView().setText(sendersText); |
| } |
| |
| @Override |
| public boolean getShouldDisplayInList() { |
| return mShouldDisplayInList; |
| } |
| |
| @Override |
| public int getPosition() { |
| return 0; |
| } |
| |
| @Override |
| public void setAdapter(final AnimatedAdapter adapter) { |
| mAdapter = adapter; |
| } |
| |
| @Override |
| public void bindFragment(final LoaderManager loaderManager, final Bundle savedInstanceState) { |
| if (mLoaderManager != null) { |
| throw new IllegalStateException("This view has already been bound to a LoaderManager."); |
| } |
| |
| mLoaderManager = loaderManager; |
| } |
| |
| @Override |
| public void cleanup() { |
| // Do nothing |
| } |
| |
| @Override |
| public void onConversationSelected() { |
| // Do nothing |
| } |
| |
| @Override |
| public void onCabModeEntered() { |
| // Do nothing |
| } |
| |
| @Override |
| public void onCabModeExited() { |
| // Do nothing |
| } |
| |
| @Override |
| public void onConversationListVisibilityChanged(final boolean visible) { |
| // Do nothing |
| } |
| |
| @Override |
| public void saveInstanceState(final Bundle outState) { |
| // Do nothing |
| } |
| |
| @Override |
| public boolean acceptsUserTaps() { |
| // The teaser does not allow user tap in the list. |
| return false; |
| } |
| |
| @Override |
| protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { |
| if (com.android.mail.utils.Utils.getDisplayListRightEdgeEffect(mTabletDevice, |
| mListCollapsible, mAdapter.getViewMode())) { |
| mTeaserRightEdge.setVisibility(VISIBLE); |
| } else { |
| mTeaserRightEdge.setVisibility(GONE); |
| } |
| |
| if (mAnimatedHeight == -1) { |
| super.onMeasure(widthMeasureSpec, heightMeasureSpec); |
| } else { |
| setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), mAnimatedHeight); |
| } |
| } |
| |
| private static int getLoaderId(final int folderId) { |
| return folderId + LOADER_FOLDER_LIST; |
| } |
| |
| private static int getFolderId(final int loaderId) { |
| return loaderId - LOADER_FOLDER_LIST; |
| } |
| |
| private final LoaderCallbacks<ObjectCursor<Folder>> mFolderListLoaderCallbacks = |
| new LoaderCallbacks<ObjectCursor<Folder>>() { |
| @Override |
| public void onLoaderReset(final Loader<ObjectCursor<Folder>> loader) { |
| // Do nothing |
| } |
| |
| @Override |
| public void onLoadFinished(final Loader<ObjectCursor<Folder>> loader, |
| final ObjectCursor<Folder> data) { |
| if (data != null) { |
| // We need to keep track of all current folders in case one has been removed |
| final List<Integer> oldFolderIds = new ArrayList<Integer>(mFolderHolders.size()); |
| for (int i = 0; i < mFolderHolders.size(); i++) { |
| oldFolderIds.add(mFolderHolders.keyAt(i)); |
| } |
| |
| if (data.moveToFirst()) { |
| do { |
| final Folder folder = data.getModel(); |
| final FolderHolder holder = mFolderHolders.get(folder.id); |
| |
| if (holder != null) { |
| final Folder oldFolder = holder.getFolder(); |
| holder.setFolder(folder); |
| |
| /* |
| * We only need to change anything if the old Folder was null, or the |
| * unread count has changed. |
| */ |
| if (oldFolder == null || oldFolder.unreadCount != folder.unreadCount) { |
| updateViews(holder); |
| } |
| } else { |
| // Create the holder, and init a loader |
| final FolderHolder newHolder = createFolderHolder(folder.name); |
| newHolder.setFolder(folder); |
| mFolderHolders.put(folder.id, newHolder); |
| |
| initFolderLoader(getLoaderId(folder.id)); |
| |
| updateViews(newHolder); |
| |
| mListUpdated = true; |
| } |
| |
| // Note: #remove(int) removes from that POSITION |
| // #remove(Integer) removes that OBJECT |
| oldFolderIds.remove(Integer.valueOf(folder.id)); |
| } while (data.moveToNext()); |
| } |
| |
| for (final int folderId : oldFolderIds) { |
| // We have a folder that no longer exists |
| mFolderHolders.remove(folderId); |
| mLoaderManager.destroyLoader(getLoaderId(folderId)); |
| mListUpdated = true; |
| } |
| |
| // If the list has not changed, we've already updated the counts, etc. |
| // If the list has changed, we need to rebuild it |
| if (mListUpdated) { |
| mAdapter.notifyDataSetChanged(); |
| } |
| } else { |
| LogUtils.w(LOG_TAG, "Problem with folder list cursor returned from loader"); |
| } |
| } |
| |
| private void initFolderLoader(final int loaderId) { |
| LogUtils.d(LOG_TAG, "Initializing folder loader %d", loaderId); |
| mLoaderManager.initLoader(loaderId, null, mFolderLoaderCallbacks); |
| } |
| |
| @Override |
| public Loader<ObjectCursor<Folder>> onCreateLoader(final int id, final Bundle args) { |
| final ObjectCursorLoader<Folder> loader = new ObjectCursorLoader<Folder>(getContext(), |
| mFolderListUri, UIProvider.FOLDERS_PROJECTION, Folder.FACTORY); |
| loader.setUpdateThrottle(mFolderItemUpdateDelayMs); |
| return loader; |
| } |
| }; |
| |
| private final LoaderCallbacks<ObjectCursor<Conversation>> mFolderLoaderCallbacks = |
| new LoaderCallbacks<ObjectCursor<Conversation>>() { |
| @Override |
| public void onLoaderReset(final Loader<ObjectCursor<Conversation>> loader) { |
| // Do nothing |
| } |
| |
| @Override |
| public void onLoadFinished(final Loader<ObjectCursor<Conversation>> loader, |
| final ObjectCursor<Conversation> data) { |
| // Sometimes names are condensed to just the first name. |
| // This data structure keeps a map of emails to names |
| final Map<String, String> emailToNameMap = Maps.newHashMap(); |
| final List<String> senders = Lists.newArrayList(); |
| |
| final int folderId = getFolderId(loader.getId()); |
| |
| final FolderHolder folderHolder = mFolderHolders.get(folderId); |
| final int maxSenders = folderHolder.mFolder.unreadCount; |
| |
| if (maxSenders > 0 && data != null && data.moveToFirst()) { |
| LogUtils.d(LOG_TAG, "Folder id %d loader finished", folderId); |
| |
| // Look through all conversations until we find 'maxSenders' unread |
| int sendersFound = 0; |
| |
| do { |
| final Conversation conversation = data.getModel(); |
| |
| if (!conversation.read) { |
| String sender = null; |
| String senderEmail = null; |
| int priority = Integer.MIN_VALUE; |
| |
| // Find the highest priority sender |
| for (final MessageInfo messageInfo : |
| conversation.conversationInfo.messageInfos) { |
| if (sender == null || priority < messageInfo.priority) { |
| sender = messageInfo.sender; |
| senderEmail = messageInfo.senderEmail; |
| priority = messageInfo.priority; |
| } |
| } |
| |
| if (sender != null) { |
| sendersFound++; |
| final String existingSender = emailToNameMap.get(senderEmail); |
| if (existingSender != null) { |
| // Prefer longer names |
| if (existingSender.length() >= sender.length()) { |
| // old name is longer |
| sender = existingSender; |
| } else { |
| // new name is longer |
| int index = senders.indexOf(existingSender); |
| senders.set(index, sender); |
| } |
| } else { |
| senders.add(sender); |
| } |
| emailToNameMap.put(senderEmail, sender); |
| } |
| } |
| } while (data.moveToNext() && sendersFound < maxSenders); |
| } else { |
| LogUtils.w(LOG_TAG, "Problem with folder cursor returned from loader"); |
| } |
| |
| folderHolder.setUnreadSenders(senders); |
| |
| /* |
| * Just update the views in place. We don't need to call notifyDataSetChanged() |
| * because we aren't changing the teaser's visibility or position. |
| */ |
| updateViews(folderHolder); |
| } |
| |
| @Override |
| public Loader<ObjectCursor<Conversation>> onCreateLoader(final int id, final Bundle args) { |
| final int folderId = getFolderId(id); |
| final Uri uri = mFolderHolders.get(folderId).mFolder.conversationListUri |
| .buildUpon() |
| .appendQueryParameter(ConversationListQueryParameters.USE_NETWORK, |
| Boolean.FALSE.toString()) |
| .appendQueryParameter(ConversationListQueryParameters.LIMIT, MAX_SENDERS) |
| .build(); |
| return new ObjectCursorLoader<Conversation>(getContext(), uri, |
| UIProvider.CONVERSATION_PROJECTION, Conversation.FACTORY); |
| } |
| }; |
| |
| @Override |
| public boolean commitLeaveBehindItem() { |
| // This view has no leave-behind |
| return false; |
| } |
| } |