1/*
2 * Copyright (C) 2013 Google Inc.
3 * Licensed to The Android Open Source Project.
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 *      http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17package com.android.mail.ui;
18
19import android.app.LoaderManager;
20import android.app.LoaderManager.LoaderCallbacks;
21import android.content.Context;
22import android.content.Loader;
23import android.content.res.Resources;
24import android.net.Uri;
25import android.os.Bundle;
26import android.support.v4.text.BidiFormatter;
27import android.support.v4.util.SparseArrayCompat;
28import android.text.TextUtils;
29import android.util.AttributeSet;
30import android.view.LayoutInflater;
31import android.view.View;
32import android.view.ViewGroup;
33import android.widget.ImageView;
34import android.widget.LinearLayout;
35import android.widget.TextView;
36
37import com.android.emailcommon.mail.Address;
38import com.android.mail.R;
39import com.android.mail.browse.ConversationCursor;
40import com.android.mail.content.ObjectCursor;
41import com.android.mail.content.ObjectCursorLoader;
42import com.android.mail.providers.Account;
43import com.android.mail.providers.Conversation;
44import com.android.mail.providers.Folder;
45import com.android.mail.providers.ParticipantInfo;
46import com.android.mail.providers.UIProvider;
47import com.android.mail.providers.UIProvider.AccountCapabilities;
48import com.android.mail.providers.UIProvider.ConversationListQueryParameters;
49import com.android.mail.utils.LogUtils;
50import com.android.mail.utils.Utils;
51import com.google.common.collect.ImmutableList;
52import com.google.common.collect.ImmutableSortedSet;
53import com.google.common.collect.Lists;
54import com.google.common.collect.Maps;
55
56import java.util.ArrayList;
57import java.util.Collections;
58import java.util.Comparator;
59import java.util.List;
60import java.util.Map;
61
62/**
63 * The teaser list item in the conversation list that shows nested folders.
64 */
65public class NestedFolderTeaserView extends LinearLayout implements ConversationSpecialItemView {
66    private static final String LOG_TAG = "NestedFolderTeaserView";
67
68    private boolean mShouldDisplayInList = false;
69
70    private Account mAccount;
71    private Uri mFolderListUri;
72    private FolderSelector mListener;
73
74    private LoaderManager mLoaderManager = null;
75    private AnimatedAdapter mAdapter = null;
76
77    private final SparseArrayCompat<FolderHolder> mFolderHolders =
78            new SparseArrayCompat<FolderHolder>();
79    private ImmutableSortedSet<FolderHolder> mSortedFolderHolders;
80
81    private final int mFolderItemUpdateDelayMs;
82
83    private final LayoutInflater mInflater;
84    private ViewGroup mNestedFolderContainer;
85
86    private View mShowMoreFoldersRow;
87    private ImageView mShowMoreFoldersIcon;
88    private TextView mShowMoreFoldersTextView;
89    private TextView mShowMoreFoldersCountTextView;
90
91    /**
92     * If <code>true</code> we show a limited set of folders, and a means to show all folders. If
93     * <code>false</code>, we show all folders.
94     */
95    private boolean mCollapsed = true;
96
97    /** If <code>true</code>, the list of folders has updated since the view was last shown. */
98    private boolean mListUpdated;
99
100    // Each folder's loader will be this value plus the folder id
101    private static final int LOADER_FOLDER_LIST =
102            AbstractActivityController.LAST_FRAGMENT_LOADER_ID + 100000;
103
104    /**
105     * The maximum number of senders to show in the sender snippet.
106     */
107    private static final String MAX_SENDERS = "20";
108
109    /**
110     * The number of folders to show when the teaser is collapsed.
111     */
112    private static int sCollapsedFolderThreshold = -1;
113
114    private static class FolderHolder {
115        private final View mItemView;
116        private final TextView mSendersTextView;
117        private final TextView mCountTextView;
118        private final ImageView mFolderIconImageView;
119        private Folder mFolder;
120        private List<String> mUnreadSenders = ImmutableList.of();
121
122        public FolderHolder(final View itemView, final TextView sendersTextView,
123                final TextView countTextView, final ImageView folderIconImageView) {
124            mItemView = itemView;
125            mSendersTextView = sendersTextView;
126            mCountTextView = countTextView;
127            mFolderIconImageView = folderIconImageView;
128        }
129
130        public void setFolder(final Folder folder) {
131            mFolder = folder;
132        }
133
134        public View getItemView() {
135            return mItemView;
136        }
137
138        public TextView getSendersTextView() {
139            return mSendersTextView;
140        }
141
142        public TextView getCountTextView() {
143            return mCountTextView;
144        }
145
146        public ImageView getFolderIconImageView() { return mFolderIconImageView; }
147
148        public Folder getFolder() {
149            return mFolder;
150        }
151
152        /**
153         * @return a {@link List} of senders of unread messages
154         */
155        public List<String> getUnreadSenders() {
156            return mUnreadSenders;
157        }
158
159        public void setUnreadSenders(final List<String> unreadSenders) {
160            mUnreadSenders = unreadSenders;
161        }
162
163        public static final Comparator<FolderHolder> NAME_COMPARATOR =
164                new Comparator<FolderHolder>() {
165            @Override
166            public int compare(final FolderHolder lhs, final FolderHolder rhs) {
167                return lhs.getFolder().name.compareTo(rhs.getFolder().name);
168            }
169        };
170    }
171
172    public NestedFolderTeaserView(final Context context) {
173        this(context, null);
174    }
175
176    public NestedFolderTeaserView(final Context context, final AttributeSet attrs) {
177        this(context, attrs, -1);
178    }
179
180    public NestedFolderTeaserView(
181            final Context context, final AttributeSet attrs, final int defStyle) {
182        super(context, attrs, defStyle);
183
184        final Resources resources = context.getResources();
185
186        if (sCollapsedFolderThreshold < 0) {
187            sCollapsedFolderThreshold =
188                    resources.getInteger(R.integer.nested_folders_collapse_threshold);
189        }
190
191        mFolderItemUpdateDelayMs = resources.getInteger(R.integer.folder_item_refresh_delay_ms);
192        mInflater = LayoutInflater.from(context);
193    }
194
195    @Override
196    protected void onFinishInflate() {
197        mNestedFolderContainer = (ViewGroup) findViewById(R.id.nested_folder_container);
198
199        mShowMoreFoldersRow = findViewById(R.id.show_more_folders_row);
200        mShowMoreFoldersRow.setOnClickListener(mShowMoreOnClickListener);
201
202        mShowMoreFoldersIcon =
203                (ImageView) mShowMoreFoldersRow.findViewById(R.id.show_more_folders_icon);
204        mShowMoreFoldersTextView =
205                (TextView) mShowMoreFoldersRow.findViewById(R.id.show_more_folders_textView);
206        mShowMoreFoldersCountTextView =
207                (TextView) mShowMoreFoldersRow.findViewById(R.id.show_more_folders_count_textView);
208    }
209
210    public void bind(final Account account, final FolderSelector listener) {
211        mAccount = account;
212        mListener = listener;
213    }
214
215    /**
216     * Creates a {@link FolderHolder}.
217     */
218    private FolderHolder createFolderHolder(final CharSequence folderName) {
219        final View itemView = mInflater.inflate(R.layout.folder_teaser_item, mNestedFolderContainer,
220                false /* attachToRoot */);
221
222        ((TextView) itemView.findViewById(R.id.folder_textView)).setText(folderName);
223        final TextView sendersTextView = (TextView) itemView.findViewById(R.id.senders_textView);
224        final TextView countTextView = (TextView) itemView.findViewById(R.id.unread_count_textView);
225        final ImageView folderIconImageView =
226                (ImageView) itemView.findViewById(R.id.nested_folder_icon);
227        final FolderHolder holder = new FolderHolder(itemView, sendersTextView, countTextView,
228                folderIconImageView);
229        countTextView.setVisibility(View.VISIBLE);
230        attachOnClickListener(itemView, holder);
231
232        return holder;
233    }
234
235    private void attachOnClickListener(final View view, final FolderHolder holder) {
236        view.setOnClickListener(new OnClickListener() {
237            @Override
238            public void onClick(final View v) {
239                mListener.onFolderSelected(holder.getFolder());
240            }
241        });
242    }
243
244    @Override
245    public void onUpdate(final Folder folder, final ConversationCursor cursor) {
246        mShouldDisplayInList = false; // Assume disabled
247
248        if (folder == null) {
249            return;
250        }
251
252        final Uri folderListUri = folder.childFoldersListUri;
253        if (folderListUri == null) {
254            return;
255        }
256
257        // If we don't support nested folders, don't show this view
258        if (!mAccount.supportsCapability(AccountCapabilities.NESTED_FOLDERS)) {
259            return;
260        }
261
262        if (mFolderListUri == null || !mFolderListUri.equals(folder.childFoldersListUri)) {
263            // We have a new uri
264            mFolderListUri = folderListUri;
265
266            // Restart the loader
267            mLoaderManager.destroyLoader(LOADER_FOLDER_LIST);
268            mLoaderManager.initLoader(LOADER_FOLDER_LIST, null, mFolderListLoaderCallbacks);
269        }
270
271        mShouldDisplayInList = true; // Now we know we have something to display
272    }
273
274    @Override
275    public void onGetView() {
276        if (mListUpdated) {
277            // Clear out the folder views
278            mNestedFolderContainer.removeAllViews();
279
280            // We either show all folders if it's not over the threshold, or we show none.
281            if (mSortedFolderHolders.size() <= sCollapsedFolderThreshold || !mCollapsed) {
282                for (final FolderHolder folderHolder : mSortedFolderHolders) {
283                    mNestedFolderContainer.addView(folderHolder.getItemView());
284                }
285            }
286
287            updateShowMoreView();
288            mListUpdated = false;
289        }
290    }
291
292    private final OnClickListener mShowMoreOnClickListener = new OnClickListener() {
293        @Override
294        public void onClick(final View v) {
295            mCollapsed = !mCollapsed;
296            mListUpdated = true;
297            mAdapter.notifyDataSetChanged();
298        }
299    };
300
301    private void updateShowMoreView() {
302        final int total = mFolderHolders.size();
303        final int displayed = mNestedFolderContainer.getChildCount();
304
305        if (displayed == 0) {
306            // We are not displaying all the folders
307            mShowMoreFoldersRow.setVisibility(VISIBLE);
308            mShowMoreFoldersIcon.setImageResource(R.drawable.ic_drawer_folder_24dp);
309            mShowMoreFoldersTextView.setText(String.format(
310                    getContext().getString(R.string.show_n_more_folders), total));
311            mShowMoreFoldersCountTextView.setVisibility(VISIBLE);
312
313            // Get a count of unread messages in other folders
314            int unreadCount = 0;
315            for (int i = 0; i < mFolderHolders.size(); i++) {
316                final FolderHolder holder = mFolderHolders.valueAt(i);
317                // TODO(skennedy) We want a "nested" unread count, that includes the unread
318                // count of nested folders
319                unreadCount += holder.getFolder().unreadCount;
320            }
321            mShowMoreFoldersCountTextView.setText(Integer.toString(unreadCount));
322        } else if (displayed > sCollapsedFolderThreshold) {
323            // We are expanded
324            mShowMoreFoldersRow.setVisibility(VISIBLE);
325            mShowMoreFoldersIcon.setImageResource(R.drawable.ic_collapse_24dp);
326            mShowMoreFoldersTextView.setText(R.string.hide_folders);
327            mShowMoreFoldersCountTextView.setVisibility(GONE);
328        } else {
329            // We don't need to collapse the folders
330            mShowMoreFoldersRow.setVisibility(GONE);
331        }
332    }
333
334    private void updateViews(final FolderHolder folderHolder) {
335        final Folder folder = folderHolder.getFolder();
336
337        // Update unread count
338        final String unreadText = Utils.getUnreadCountString(getContext(), folder.unreadCount);
339        folderHolder.getCountTextView().setText(unreadText.isEmpty() ? "0" : unreadText);
340
341        // Update unread senders
342        final String sendersText = TextUtils.join(
343                getResources().getString(R.string.enumeration_comma),
344                folderHolder.getUnreadSenders());
345        final TextView sendersTextView = folderHolder.getSendersTextView();
346        if (!TextUtils.isEmpty(sendersText)) {
347            sendersTextView.setVisibility(VISIBLE);
348            sendersTextView.setText(sendersText);
349        } else {
350            sendersTextView.setVisibility(GONE);
351        }
352    }
353
354    @Override
355    public boolean getShouldDisplayInList() {
356        return mShouldDisplayInList;
357    }
358
359    @Override
360    public int getPosition() {
361        return 0;
362    }
363
364    @Override
365    public void setAdapter(final AnimatedAdapter adapter) {
366        mAdapter = adapter;
367    }
368
369    @Override
370    public void bindFragment(final LoaderManager loaderManager, final Bundle savedInstanceState) {
371        if (mLoaderManager != null) {
372            throw new IllegalStateException("This view has already been bound to a LoaderManager.");
373        }
374
375        mLoaderManager = loaderManager;
376    }
377
378    @Override
379    public void cleanup() {
380        // Do nothing
381    }
382
383    @Override
384    public void onConversationSelected() {
385        // Do nothing
386    }
387
388    @Override
389    public void onCabModeEntered() {
390        // Do nothing
391    }
392
393    @Override
394    public void onCabModeExited() {
395        // Do nothing
396    }
397
398    @Override
399    public void onConversationListVisibilityChanged(final boolean visible) {
400        // Do nothing
401    }
402
403    @Override
404    public void saveInstanceState(final Bundle outState) {
405        // Do nothing
406    }
407
408    @Override
409    public boolean acceptsUserTaps() {
410        // The teaser does not allow user tap in the list.
411        return false;
412    }
413
414    private static int getLoaderId(final int folderId) {
415        return folderId + LOADER_FOLDER_LIST;
416    }
417
418    private static int getFolderId(final int loaderId) {
419        return loaderId - LOADER_FOLDER_LIST;
420    }
421
422    private final LoaderCallbacks<ObjectCursor<Folder>> mFolderListLoaderCallbacks =
423            new LoaderCallbacks<ObjectCursor<Folder>>() {
424        @Override
425        public void onLoaderReset(final Loader<ObjectCursor<Folder>> loader) {
426            // Do nothing
427        }
428
429        @Override
430        public void onLoadFinished(final Loader<ObjectCursor<Folder>> loader,
431                final ObjectCursor<Folder> data) {
432            if (data != null) {
433                // We need to keep track of all current folders in case one has been removed
434                final List<Integer> oldFolderIds = new ArrayList<Integer>(mFolderHolders.size());
435                for (int i = 0; i < mFolderHolders.size(); i++) {
436                    oldFolderIds.add(mFolderHolders.keyAt(i));
437                }
438
439                if (data.moveToFirst()) {
440                    do {
441                        final Folder folder = data.getModel();
442                        FolderHolder holder = mFolderHolders.get(folder.id);
443
444                        if (holder != null) {
445                            final Folder oldFolder = holder.getFolder();
446                            holder.setFolder(folder);
447
448                            /*
449                             * We only need to change anything if the old Folder was null, or the
450                             * unread count has changed.
451                             */
452                            if (oldFolder == null || oldFolder.unreadCount != folder.unreadCount) {
453                                populateUnreadSenders(holder, folder.unreadSenders);
454                                updateViews(holder);
455                            }
456                        } else {
457                            // Create the holder, and init a loader
458                            holder = createFolderHolder(folder.name);
459                            holder.setFolder(folder);
460                            mFolderHolders.put(folder.id, holder);
461
462                            // We can not support displaying sender info with nested folders
463                            // because it doesn't scale. Disabling it for now, until we can
464                            // optimize it.
465                            // initFolderLoader(getLoaderId(folder.id));
466                            populateUnreadSenders(holder, folder.unreadSenders);
467
468                            updateViews(holder);
469
470                            mListUpdated = true;
471                        }
472
473                        if (folder.hasChildren) {
474                            holder.getFolderIconImageView().setImageDrawable(
475                                    getResources().getDrawable(R.drawable.ic_folder_parent_24dp));
476                        }
477
478                        // Note: #remove(int) removes from that POSITION
479                        //       #remove(Integer) removes that OBJECT
480                        oldFolderIds.remove(Integer.valueOf(folder.id));
481                    } while (data.moveToNext());
482                }
483
484                // Sort the folders by name
485                // TODO(skennedy) recents? starred?
486                final ImmutableSortedSet.Builder<FolderHolder> folderHoldersBuilder =
487                        new ImmutableSortedSet.Builder<FolderHolder>(FolderHolder.NAME_COMPARATOR);
488                for (int i = 0; i < mFolderHolders.size(); i++) {
489                    folderHoldersBuilder.add(mFolderHolders.valueAt(i));
490                }
491                mSortedFolderHolders = folderHoldersBuilder.build();
492
493                for (final int folderId : oldFolderIds) {
494                    // We have a folder that no longer exists
495                    mFolderHolders.remove(folderId);
496                    mLoaderManager.destroyLoader(getLoaderId(folderId));
497                    mListUpdated = true;
498                }
499
500                // If the list has not changed, we've already updated the counts, etc.
501                // If the list has changed, we need to rebuild it
502                if (mListUpdated) {
503                    mAdapter.notifyDataSetChanged();
504                }
505            } else {
506                LogUtils.w(LOG_TAG, "Problem with folder list cursor returned from loader");
507            }
508        }
509
510        private void initFolderLoader(final int loaderId) {
511            LogUtils.d(LOG_TAG, "Initializing folder loader %d", loaderId);
512            mLoaderManager.initLoader(loaderId, null, mFolderLoaderCallbacks);
513        }
514
515        @Override
516        public Loader<ObjectCursor<Folder>> onCreateLoader(final int id, final Bundle args) {
517            final ObjectCursorLoader<Folder> loader = new ObjectCursorLoader<Folder>(getContext(),
518                    mFolderListUri, UIProvider.FOLDERS_PROJECTION_WITH_UNREAD_SENDERS,
519                    Folder.FACTORY);
520            loader.setUpdateThrottle(mFolderItemUpdateDelayMs);
521            return loader;
522        }
523    };
524
525    /**
526     * This code is intended to roughly duplicate the FolderLoaderCallback's onLoadFinished
527     */
528    private void populateUnreadSenders(final FolderHolder folderHolder,
529            final String unreadSenders) {
530        if (TextUtils.isEmpty(unreadSenders)) {
531            folderHolder.setUnreadSenders(Collections.<String>emptyList());
532            return;
533        }
534        // Use a LinkedHashMap here to maintain ordering
535        final Map<String, String> emailtoNameMap = Maps.newLinkedHashMap();
536
537        final Address[] senderAddresses = Address.parse(unreadSenders);
538
539        final BidiFormatter bidiFormatter = mAdapter.getBidiFormatter();
540        for (final Address senderAddress : senderAddresses) {
541            String sender = senderAddress.getPersonal();
542            sender = (sender != null) ? bidiFormatter.unicodeWrap(sender) : null;
543            final String senderEmail = senderAddress.getAddress();
544
545            if (!TextUtils.isEmpty(sender)) {
546                final String existingSender = emailtoNameMap.get(senderEmail);
547                if (!TextUtils.isEmpty(existingSender)) {
548                    // Prefer longer names
549                    if (existingSender.length() >= sender.length()) {
550                        // old name is longer
551                        sender = existingSender;
552                    }
553                }
554                emailtoNameMap.put(senderEmail, sender);
555            }
556            if (emailtoNameMap.size() >= 20) {
557                break;
558            }
559        }
560
561        final List<String> senders = Lists.newArrayList(emailtoNameMap.values());
562        folderHolder.setUnreadSenders(senders);
563    }
564
565    private final LoaderCallbacks<ObjectCursor<Conversation>> mFolderLoaderCallbacks =
566            new LoaderCallbacks<ObjectCursor<Conversation>>() {
567        @Override
568        public void onLoaderReset(final Loader<ObjectCursor<Conversation>> loader) {
569            // Do nothing
570        }
571
572        @Override
573        public void onLoadFinished(final Loader<ObjectCursor<Conversation>> loader,
574                final ObjectCursor<Conversation> data) {
575            // Sometimes names are condensed to just the first name.
576            // This data structure keeps a map of emails to names
577            final Map<String, String> emailToNameMap = Maps.newHashMap();
578            final List<String> senders = Lists.newArrayList();
579
580            final int folderId = getFolderId(loader.getId());
581
582            final FolderHolder folderHolder = mFolderHolders.get(folderId);
583            final int maxSenders = folderHolder.mFolder.unreadCount;
584
585            if (maxSenders > 0 && data != null && data.moveToFirst()) {
586                LogUtils.d(LOG_TAG, "Folder id %d loader finished", folderId);
587
588                // Look through all conversations until we find 'maxSenders' unread
589                int sendersFound = 0;
590
591                do {
592                    final Conversation conversation = data.getModel();
593
594                    if (!conversation.read) {
595                        String sender = null;
596                        String senderEmail = null;
597                        int priority = Integer.MIN_VALUE;
598
599                        // Find the highest priority participant
600                        for (final ParticipantInfo p :
601                                conversation.conversationInfo.participantInfos) {
602                            if (sender == null || priority < p.priority) {
603                                sender = p.name;
604                                senderEmail = p.email;
605                                priority = p.priority;
606                            }
607                        }
608
609                        if (sender != null) {
610                            sendersFound++;
611                            final String existingSender = emailToNameMap.get(senderEmail);
612                            if (existingSender != null) {
613                                // Prefer longer names
614                                if (existingSender.length() >= sender.length()) {
615                                    // old name is longer
616                                    sender = existingSender;
617                                } else {
618                                    // new name is longer
619                                    int index = senders.indexOf(existingSender);
620                                    senders.set(index, sender);
621                                }
622                            } else {
623                                senders.add(sender);
624                            }
625                            emailToNameMap.put(senderEmail, sender);
626                        }
627                    }
628                } while (data.moveToNext() && sendersFound < maxSenders);
629            } else {
630                LogUtils.w(LOG_TAG, "Problem with folder cursor returned from loader");
631            }
632
633            folderHolder.setUnreadSenders(senders);
634
635            /*
636             * Just update the views in place. We don't need to call notifyDataSetChanged()
637             * because we aren't changing the teaser's visibility or position.
638             */
639            updateViews(folderHolder);
640        }
641
642        @Override
643        public Loader<ObjectCursor<Conversation>> onCreateLoader(final int id, final Bundle args) {
644            final int folderId = getFolderId(id);
645            final Uri uri = mFolderHolders.get(folderId).mFolder.conversationListUri
646                    .buildUpon()
647                    .appendQueryParameter(ConversationListQueryParameters.USE_NETWORK,
648                            Boolean.FALSE.toString())
649                    .appendQueryParameter(ConversationListQueryParameters.LIMIT, MAX_SENDERS)
650                    .build();
651            return new ObjectCursorLoader<Conversation>(getContext(), uri,
652                    UIProvider.CONVERSATION_PROJECTION, Conversation.FACTORY);
653        }
654    };
655
656    @Override
657    public boolean commitLeaveBehindItem() {
658        // This view has no leave-behind
659        return false;
660    }
661}
662