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