1/*
2 * Copyright (C) 2012 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 */
17
18package com.android.mail.ui;
19
20import android.app.Activity;
21import android.app.Fragment;
22import android.app.LoaderManager;
23import android.content.Context;
24import android.content.Loader;
25import android.database.Cursor;
26import android.net.Uri;
27import android.os.Bundle;
28import android.os.Handler;
29import android.view.Menu;
30import android.view.MenuInflater;
31import android.view.MenuItem;
32
33import com.android.mail.R;
34import com.android.mail.analytics.Analytics;
35import com.android.mail.browse.ConversationAccountController;
36import com.android.mail.browse.ConversationMessage;
37import com.android.mail.browse.ConversationViewHeader.ConversationViewHeaderCallbacks;
38import com.android.mail.browse.MessageCursor;
39import com.android.mail.browse.MessageCursor.ConversationController;
40import com.android.mail.content.ObjectCursor;
41import com.android.mail.content.ObjectCursorLoader;
42import com.android.mail.providers.Account;
43import com.android.mail.providers.AccountObserver;
44import com.android.mail.providers.Address;
45import com.android.mail.providers.Conversation;
46import com.android.mail.providers.ListParams;
47import com.android.mail.providers.UIProvider;
48import com.android.mail.providers.UIProvider.CursorStatus;
49import com.android.mail.utils.LogTag;
50import com.android.mail.utils.LogUtils;
51import com.android.mail.utils.Utils;
52
53import java.util.Arrays;
54import java.util.Collections;
55import java.util.HashMap;
56import java.util.Map;
57
58
59public abstract class AbstractConversationViewFragment extends Fragment implements
60        ConversationController, ConversationAccountController,
61        ConversationViewHeaderCallbacks {
62
63    protected static final String ARG_ACCOUNT = "account";
64    public static final String ARG_CONVERSATION = "conversation";
65    private static final String LOG_TAG = LogTag.getLogTag();
66    protected static final int MESSAGE_LOADER = 0;
67    protected static final int CONTACT_LOADER = 1;
68    protected ControllableActivity mActivity;
69    private final MessageLoaderCallbacks mMessageLoaderCallbacks = new MessageLoaderCallbacks();
70    private ContactLoaderCallbacks mContactLoaderCallbacks;
71    private MenuItem mChangeFoldersMenuItem;
72    protected Conversation mConversation;
73    protected String mBaseUri;
74    protected Account mAccount;
75
76    /**
77     * Must be instantiated in a derived class's onCreate.
78     */
79    protected AbstractConversationWebViewClient mWebViewClient;
80
81    /**
82     * Cache of email address strings to parsed Address objects.
83     * <p>
84     * Remember to synchronize on the map when reading or writing to this cache, because some
85     * instances use it off the UI thread (e.g. from WebView).
86     */
87    protected final Map<String, Address> mAddressCache = Collections.synchronizedMap(
88            new HashMap<String, Address>());
89    private MessageCursor mCursor;
90    private Context mContext;
91    /**
92     * A backwards-compatible version of {{@link #getUserVisibleHint()}. Like the framework flag,
93     * this flag is saved and restored.
94     */
95    private boolean mUserVisible;
96
97    private final Handler mHandler = new Handler();
98    /** True if we want to avoid marking the conversation as viewed and read. */
99    private boolean mSuppressMarkingViewed;
100    /**
101     * Parcelable state of the conversation view. Can safely be used without null checking any time
102     * after {@link #onCreate(Bundle)}.
103     */
104    protected ConversationViewState mViewState;
105
106    private boolean mIsDetached;
107
108    private boolean mHasConversationBeenTransformed;
109    private boolean mHasConversationTransformBeenReverted;
110
111    private final AccountObserver mAccountObserver = new AccountObserver() {
112        @Override
113        public void onChanged(Account newAccount) {
114            final Account oldAccount = mAccount;
115            mAccount = newAccount;
116            mWebViewClient.setAccount(mAccount);
117            onAccountChanged(newAccount, oldAccount);
118        }
119    };
120
121    private static final String BUNDLE_VIEW_STATE =
122            AbstractConversationViewFragment.class.getName() + "viewstate";
123    /**
124     * We save the user visible flag so the various transitions that occur during rotation do not
125     * cause unnecessary visibility change.
126     */
127    private static final String BUNDLE_USER_VISIBLE =
128            AbstractConversationViewFragment.class.getName() + "uservisible";
129
130    private static final String BUNDLE_DETACHED =
131            AbstractConversationViewFragment.class.getName() + "detached";
132
133    private static final String BUNDLE_KEY_HAS_CONVERSATION_BEEN_TRANSFORMED =
134            AbstractConversationViewFragment.class.getName() + "conversationtransformed";
135    private static final String BUNDLE_KEY_HAS_CONVERSATION_BEEN_REVERTED =
136            AbstractConversationViewFragment.class.getName() + "conversationreverted";
137
138    public static Bundle makeBasicArgs(Account account) {
139        Bundle args = new Bundle();
140        args.putParcelable(ARG_ACCOUNT, account);
141        return args;
142    }
143
144    /**
145     * Constructor needs to be public to handle orientation changes and activity
146     * lifecycle events.
147     */
148    public AbstractConversationViewFragment() {
149        super();
150    }
151
152    /**
153     * Subclasses must override, since this depends on how many messages are
154     * shown in the conversation view.
155     */
156    protected void markUnread() {
157        // Do not automatically mark this conversation viewed and read.
158        mSuppressMarkingViewed = true;
159    }
160
161    /**
162     * Subclasses must override this, since they may want to display a single or
163     * many messages related to this conversation.
164     */
165    protected abstract void onMessageCursorLoadFinished(
166            Loader<ObjectCursor<ConversationMessage>> loader,
167            MessageCursor newCursor, MessageCursor oldCursor);
168
169    /**
170     * Subclasses must override this, since they may want to display a single or
171     * many messages related to this conversation.
172     */
173    @Override
174    public abstract void onConversationViewHeaderHeightChange(int newHeight);
175
176    public abstract void onUserVisibleHintChanged();
177
178    /**
179     * Subclasses must override this.
180     */
181    protected abstract void onAccountChanged(Account newAccount, Account oldAccount);
182
183    @Override
184    public void onCreate(Bundle savedState) {
185        super.onCreate(savedState);
186
187        parseArguments();
188        setBaseUri();
189
190        LogUtils.d(LOG_TAG, "onCreate in ConversationViewFragment (this=%s)", this);
191        // Not really, we just want to get a crack to store a reference to the change_folder item
192        setHasOptionsMenu(true);
193
194        if (savedState != null) {
195            mViewState = savedState.getParcelable(BUNDLE_VIEW_STATE);
196            mUserVisible = savedState.getBoolean(BUNDLE_USER_VISIBLE);
197            mIsDetached = savedState.getBoolean(BUNDLE_DETACHED, false);
198            mHasConversationBeenTransformed =
199                    savedState.getBoolean(BUNDLE_KEY_HAS_CONVERSATION_BEEN_TRANSFORMED, false);
200            mHasConversationTransformBeenReverted =
201                    savedState.getBoolean(BUNDLE_KEY_HAS_CONVERSATION_BEEN_REVERTED, false);
202        } else {
203            mViewState = getNewViewState();
204            mHasConversationBeenTransformed = false;
205            mHasConversationTransformBeenReverted = false;
206        }
207    }
208
209    /**
210     * Can be overridden in case a subclass needs to get additional arguments.
211     */
212    protected void parseArguments() {
213        final Bundle args = getArguments();
214        mAccount = args.getParcelable(ARG_ACCOUNT);
215        mConversation = args.getParcelable(ARG_CONVERSATION);
216    }
217
218    /**
219     * Can be overridden in case a subclass needs a different uri format
220     * (such as one that does not rely on account and/or conversation.
221     */
222    protected void setBaseUri() {
223        // Since the uri specified in the conversation base uri may not be unique, we specify a
224        // base uri that us guaranteed to be unique for this conversation.
225        mBaseUri = "x-thread://" + mAccount.getEmailAddress().hashCode() + "/" + mConversation.id;
226    }
227
228    @Override
229    public String toString() {
230        // log extra info at DEBUG level or finer
231        final String s = super.toString();
232        if (!LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG) || mConversation == null) {
233            return s;
234        }
235        return "(" + s + " conv=" + mConversation + ")";
236    }
237
238    @Override
239    public void onActivityCreated(Bundle savedInstanceState) {
240        super.onActivityCreated(savedInstanceState);
241        final Activity activity = getActivity();
242        if (!(activity instanceof ControllableActivity)) {
243            LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to"
244                    + "create it. Cannot proceed.");
245        }
246        if (activity == null || activity.isFinishing()) {
247            // Activity is finishing, just bail.
248            return;
249        }
250        mActivity = (ControllableActivity) activity;
251        mContext = activity.getApplicationContext();
252        mWebViewClient.setActivity(activity);
253        mAccount = mAccountObserver.initialize(mActivity.getAccountController());
254        mWebViewClient.setAccount(mAccount);
255    }
256
257    @Override
258    public ConversationUpdater getListController() {
259        final ControllableActivity activity = (ControllableActivity) getActivity();
260        return activity != null ? activity.getConversationUpdater() : null;
261    }
262
263    public Context getContext() {
264        return mContext;
265    }
266
267    @Override
268    public Conversation getConversation() {
269        return mConversation;
270    }
271
272    @Override
273    public MessageCursor getMessageCursor() {
274        return mCursor;
275    }
276
277    public Handler getHandler() {
278        return mHandler;
279    }
280
281    public MessageLoaderCallbacks getMessageLoaderCallbacks() {
282        return mMessageLoaderCallbacks;
283    }
284
285    public ContactLoaderCallbacks getContactInfoSource() {
286        if (mContactLoaderCallbacks == null) {
287            mContactLoaderCallbacks = new ContactLoaderCallbacks(mActivity.getActivityContext());
288        }
289        return mContactLoaderCallbacks;
290    }
291
292    @Override
293    public Account getAccount() {
294        return mAccount;
295    }
296
297    @Override
298    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
299        super.onCreateOptionsMenu(menu, inflater);
300        mChangeFoldersMenuItem = menu.findItem(R.id.change_folders);
301    }
302
303    @Override
304    public boolean onOptionsItemSelected(MenuItem item) {
305        if (!isUserVisible()) {
306            // Unclear how this is happening. Current theory is that this fragment was scheduled
307            // to be removed, but the remove transaction failed. When the Activity is later
308            // restored, the FragmentManager restores this fragment, but Fragment.mMenuVisible is
309            // stuck at its initial value (true), which makes this zombie fragment eligible for
310            // menu item clicks.
311            //
312            // Work around this by relying on the (properly restored) extra user visible hint.
313            LogUtils.e(LOG_TAG,
314                    "ACVF ignoring onOptionsItemSelected b/c userVisibleHint is false. f=%s", this);
315            if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) {
316                LogUtils.e(LOG_TAG, Utils.dumpFragment(this));  // the dump has '%' chars in it...
317            }
318            return false;
319        }
320
321        boolean handled = false;
322        final int itemId = item.getItemId();
323        if (itemId == R.id.inside_conversation_unread) {
324            markUnread();
325            handled = true;
326        } else if (itemId == R.id.show_original) {
327            showUntransformedConversation();
328            handled = true;
329        }
330        return handled;
331    }
332
333    @Override
334    public void onPrepareOptionsMenu(Menu menu) {
335        // Only show option if we support message transforms and message has been transformed.
336        Utils.setMenuItemVisibility(menu, R.id.show_original, supportsMessageTransforms() &&
337                mHasConversationBeenTransformed && !mHasConversationTransformBeenReverted);
338    }
339
340    abstract boolean supportsMessageTransforms();
341
342    // BEGIN conversation header callbacks
343    @Override
344    public void onFoldersClicked() {
345        if (mChangeFoldersMenuItem == null) {
346            LogUtils.e(LOG_TAG, "unable to open 'change folders' dialog for a conversation");
347            return;
348        }
349        mActivity.onOptionsItemSelected(mChangeFoldersMenuItem);
350    }
351    // END conversation header callbacks
352
353    @Override
354    public void onStart() {
355        super.onStart();
356
357        Analytics.getInstance().sendView(getClass().getName());
358    }
359
360    @Override
361    public void onSaveInstanceState(Bundle outState) {
362        if (mViewState != null) {
363            outState.putParcelable(BUNDLE_VIEW_STATE, mViewState);
364        }
365        outState.putBoolean(BUNDLE_USER_VISIBLE, mUserVisible);
366        outState.putBoolean(BUNDLE_DETACHED, mIsDetached);
367        outState.putBoolean(BUNDLE_KEY_HAS_CONVERSATION_BEEN_TRANSFORMED,
368                mHasConversationBeenTransformed);
369        outState.putBoolean(BUNDLE_KEY_HAS_CONVERSATION_BEEN_REVERTED,
370                mHasConversationTransformBeenReverted);
371    }
372
373    @Override
374    public void onDestroyView() {
375        super.onDestroyView();
376        mAccountObserver.unregisterAndDestroy();
377    }
378
379    /**
380     * {@link #setUserVisibleHint(boolean)} only works on API >= 15, so implement our own for
381     * reliability on older platforms.
382     */
383    public void setExtraUserVisibleHint(boolean isVisibleToUser) {
384        LogUtils.v(LOG_TAG, "in CVF.setHint, val=%s (%s)", isVisibleToUser, this);
385        if (mUserVisible != isVisibleToUser) {
386            mUserVisible = isVisibleToUser;
387            MessageCursor cursor = getMessageCursor();
388            if (mUserVisible && (cursor != null && cursor.isLoaded() && cursor.getCount() == 0)) {
389                // Pop back to conversation list and show error.
390                onError();
391                return;
392            }
393            onUserVisibleHintChanged();
394        }
395    }
396
397    public boolean isUserVisible() {
398        return mUserVisible;
399    }
400
401    protected void timerMark(String msg) {
402        if (isUserVisible()) {
403            Utils.sConvLoadTimer.mark(msg);
404        }
405    }
406
407    private class MessageLoaderCallbacks
408            implements LoaderManager.LoaderCallbacks<ObjectCursor<ConversationMessage>> {
409
410        @Override
411        public Loader<ObjectCursor<ConversationMessage>> onCreateLoader(int id, Bundle args) {
412            return new MessageLoader(mActivity.getActivityContext(), mConversation.messageListUri);
413        }
414
415        @Override
416        public void onLoadFinished(Loader<ObjectCursor<ConversationMessage>> loader,
417                    ObjectCursor<ConversationMessage> data) {
418            // ignore truly duplicate results
419            // this can happen when restoring after rotation
420            if (mCursor == data) {
421                return;
422            } else {
423                final MessageCursor messageCursor = (MessageCursor) data;
424
425                // bind the cursor to this fragment so it can access to the current list controller
426                messageCursor.setController(AbstractConversationViewFragment.this);
427
428                if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) {
429                    LogUtils.d(LOG_TAG, "LOADED CONVERSATION= %s", messageCursor.getDebugDump());
430                }
431
432                // We have no messages: exit conversation view.
433                if (messageCursor.getCount() == 0
434                        && (!CursorStatus.isWaitingForResults(messageCursor.getStatus())
435                                || mIsDetached)) {
436                    if (mUserVisible) {
437                        onError();
438                    } else {
439                        // we expect that the pager adapter will remove this
440                        // conversation fragment on its own due to a separate
441                        // conversation cursor update (we might get here if the
442                        // message list update fires first. nothing to do
443                        // because we expect to be torn down soon.)
444                        LogUtils.i(LOG_TAG, "CVF: offscreen conv has no messages, ignoring update"
445                                + " in anticipation of conv cursor update. c=%s",
446                                mConversation.uri);
447                    }
448                    // existing mCursor will imminently be closed, must stop referencing it
449                    // since we expect to be kicked out soon, it doesn't matter what mCursor
450                    // becomes
451                    mCursor = null;
452                    return;
453                }
454
455                // ignore cursors that are still loading results
456                if (!messageCursor.isLoaded()) {
457                    // existing mCursor will imminently be closed, must stop referencing it
458                    // in this case, the new cursor is also no good, and since don't expect to get
459                    // here except in initial load situations, it's safest to just ensure the
460                    // reference is null
461                    mCursor = null;
462                    return;
463                }
464                final MessageCursor oldCursor = mCursor;
465                mCursor = messageCursor;
466                onMessageCursorLoadFinished(loader, mCursor, oldCursor);
467            }
468        }
469
470        @Override
471        public void onLoaderReset(Loader<ObjectCursor<ConversationMessage>>  loader) {
472            mCursor = null;
473        }
474
475    }
476
477    private void onError() {
478        // need to exit this view- conversation may have been
479        // deleted, or for whatever reason is now invalid (e.g.
480        // discard single draft)
481        //
482        // N.B. this may involve a fragment transaction, which
483        // FragmentManager will refuse to execute directly
484        // within onLoadFinished. Make sure the controller knows.
485        LogUtils.i(LOG_TAG, "CVF: visible conv has no messages, exiting conv mode");
486        // TODO(mindyp): handle ERROR status by showing an error
487        // message to the user that there are no messages in
488        // this conversation
489        popOut();
490    }
491
492    private void popOut() {
493        mHandler.post(new FragmentRunnable("popOut", this) {
494            @Override
495            public void go() {
496                if (mActivity != null) {
497                    mActivity.getListHandler()
498                            .onConversationSelected(null, true /* inLoaderCallbacks */);
499                }
500            }
501        });
502    }
503
504    protected void onConversationSeen() {
505        LogUtils.d(LOG_TAG, "AbstractConversationViewFragment#onConversationSeen()");
506
507        // Ignore unsafe calls made after a fragment is detached from an activity
508        final ControllableActivity activity = (ControllableActivity) getActivity();
509        if (activity == null) {
510            LogUtils.w(LOG_TAG, "ignoring onConversationSeen for conv=%s", mConversation.id);
511            return;
512        }
513
514        mViewState.setInfoForConversation(mConversation);
515
516        LogUtils.d(LOG_TAG, "onConversationSeen() - mSuppressMarkingViewed = %b",
517                mSuppressMarkingViewed);
518        // In most circumstances we want to mark the conversation as viewed and read, since the
519        // user has read it.  However, if the user has already marked the conversation unread, we
520        // do not want a  later mark-read operation to undo this.  So we check this variable which
521        // is set in #markUnread() which suppresses automatic mark-read.
522        if (!mSuppressMarkingViewed) {
523            // mark viewed/read if not previously marked viewed by this conversation view,
524            // or if unread messages still exist in the message list cursor
525            // we don't want to keep marking viewed on rotation or restore
526            // but we do want future re-renders to mark read (e.g. "New message from X" case)
527            final MessageCursor cursor = getMessageCursor();
528            LogUtils.d(LOG_TAG, "onConversationSeen() - mConversation.isViewed() = %b, "
529                    + "cursor null = %b, cursor.isConversationRead() = %b",
530                    mConversation.isViewed(), cursor == null,
531                    cursor != null && cursor.isConversationRead());
532            if (!mConversation.isViewed() || (cursor != null && !cursor.isConversationRead())) {
533                // Mark the conversation viewed and read.
534                activity.getConversationUpdater()
535                        .markConversationsRead(Arrays.asList(mConversation), true, true);
536
537                // and update the Message objects in the cursor so the next time a cursor update
538                // happens with these messages marked read, we know to ignore it
539                if (cursor != null && !cursor.isClosed()) {
540                    cursor.markMessagesRead();
541                }
542            }
543        }
544        activity.getListHandler().onConversationSeen();
545    }
546
547    protected ConversationViewState getNewViewState() {
548        return new ConversationViewState();
549    }
550
551    private static class MessageLoader extends ObjectCursorLoader<ConversationMessage> {
552        private boolean mDeliveredFirstResults = false;
553
554        public MessageLoader(Context c, Uri messageListUri) {
555            super(c, messageListUri, UIProvider.MESSAGE_PROJECTION, ConversationMessage.FACTORY);
556        }
557
558        @Override
559        public void deliverResult(ObjectCursor<ConversationMessage> result) {
560            // We want to deliver these results, and then we want to make sure
561            // that any subsequent
562            // queries do not hit the network
563            super.deliverResult(result);
564
565            if (!mDeliveredFirstResults) {
566                mDeliveredFirstResults = true;
567                Uri uri = getUri();
568
569                // Create a ListParams that tells the provider to not hit the
570                // network
571                final ListParams listParams = new ListParams(ListParams.NO_LIMIT,
572                        false /* useNetwork */);
573
574                // Build the new uri with this additional parameter
575                uri = uri
576                        .buildUpon()
577                        .appendQueryParameter(UIProvider.LIST_PARAMS_QUERY_PARAMETER,
578                                listParams.serialize()).build();
579                setUri(uri);
580            }
581        }
582
583        @Override
584        protected ObjectCursor<ConversationMessage> getObjectCursor(Cursor inner) {
585            return new MessageCursor(inner);
586        }
587    }
588
589    public abstract void onConversationUpdated(Conversation conversation);
590
591    public void onDetachedModeEntered() {
592        // If we have no messages, then we have nothing to display, so leave this view.
593        // Otherwise, just set the detached flag.
594        final Cursor messageCursor = getMessageCursor();
595
596        if (messageCursor == null || messageCursor.getCount() == 0) {
597            popOut();
598        } else {
599            mIsDetached = true;
600        }
601    }
602
603    /**
604     * Called when the JavaScript reports that it transformed a message.
605     * Sets a flag to true and invalidates the options menu so it will
606     * include the "Revert auto-sizing" menu option.
607     */
608    public void onConversationTransformed() {
609        mHasConversationBeenTransformed = true;
610        mHandler.post(new FragmentRunnable("invalidateOptionsMenu", this) {
611            @Override
612            public void go() {
613                mActivity.invalidateOptionsMenu();
614            }
615        });
616    }
617
618    /**
619     * Called when the "Revert auto-sizing" option is selected. Default
620     * implementation simply sets a value on whether transforms should be
621     * applied. Derived classes should override this class and force a
622     * re-render so that the conversation renders without
623     */
624    public void showUntransformedConversation() {
625        // must set the value to true so we don't show the options menu item again
626        mHasConversationTransformBeenReverted = true;
627    }
628
629    /**
630     * Returns {@code true} if the conversation should be transformed. {@code false}, otherwise.
631     * @return {@code true} if the conversation should be transformed. {@code false}, otherwise.
632     */
633    public boolean shouldApplyTransforms() {
634        return (mAccount.enableMessageTransforms > 0) &&
635                !mHasConversationTransformBeenReverted;
636    }
637}
638