1/*
2 * Copyright (C) 2015 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.messaging.ui.conversation;
18
19import android.Manifest;
20import android.app.Activity;
21import android.app.AlertDialog;
22import android.app.DownloadManager;
23import android.app.Fragment;
24import android.app.FragmentManager;
25import android.app.FragmentTransaction;
26import android.content.BroadcastReceiver;
27import android.content.ClipData;
28import android.content.ClipboardManager;
29import android.content.Context;
30import android.content.DialogInterface;
31import android.content.DialogInterface.OnCancelListener;
32import android.content.DialogInterface.OnClickListener;
33import android.content.DialogInterface.OnDismissListener;
34import android.content.Intent;
35import android.content.IntentFilter;
36import android.content.res.Configuration;
37import android.database.Cursor;
38import android.graphics.Point;
39import android.graphics.Rect;
40import android.graphics.drawable.ColorDrawable;
41import android.net.Uri;
42import android.os.Bundle;
43import android.os.Environment;
44import android.os.Handler;
45import android.os.Parcelable;
46import android.support.v4.content.LocalBroadcastManager;
47import android.support.v4.text.BidiFormatter;
48import android.support.v4.text.TextDirectionHeuristicsCompat;
49import android.support.v7.app.ActionBar;
50import android.support.v7.widget.DefaultItemAnimator;
51import android.support.v7.widget.LinearLayoutManager;
52import android.support.v7.widget.RecyclerView;
53import android.support.v7.widget.RecyclerView.ViewHolder;
54import android.text.TextUtils;
55import android.view.ActionMode;
56import android.view.Display;
57import android.view.LayoutInflater;
58import android.view.Menu;
59import android.view.MenuInflater;
60import android.view.MenuItem;
61import android.view.View;
62import android.view.ViewConfiguration;
63import android.view.ViewGroup;
64import android.widget.TextView;
65
66import com.android.messaging.R;
67import com.android.messaging.datamodel.DataModel;
68import com.android.messaging.datamodel.MessagingContentProvider;
69import com.android.messaging.datamodel.action.InsertNewMessageAction;
70import com.android.messaging.datamodel.binding.Binding;
71import com.android.messaging.datamodel.binding.BindingBase;
72import com.android.messaging.datamodel.binding.ImmutableBindingRef;
73import com.android.messaging.datamodel.data.ConversationData;
74import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener;
75import com.android.messaging.datamodel.data.ConversationMessageData;
76import com.android.messaging.datamodel.data.ConversationParticipantsData;
77import com.android.messaging.datamodel.data.DraftMessageData;
78import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener;
79import com.android.messaging.datamodel.data.MessageData;
80import com.android.messaging.datamodel.data.MessagePartData;
81import com.android.messaging.datamodel.data.ParticipantData;
82import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry;
83import com.android.messaging.ui.AttachmentPreview;
84import com.android.messaging.ui.BugleActionBarActivity;
85import com.android.messaging.ui.ConversationDrawables;
86import com.android.messaging.ui.SnackBar;
87import com.android.messaging.ui.UIIntents;
88import com.android.messaging.ui.animation.PopupTransitionAnimation;
89import com.android.messaging.ui.contact.AddContactsConfirmationDialog;
90import com.android.messaging.ui.conversation.ComposeMessageView.IComposeMessageViewHost;
91import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputHost;
92import com.android.messaging.ui.conversation.ConversationMessageView.ConversationMessageViewHost;
93import com.android.messaging.ui.mediapicker.MediaPicker;
94import com.android.messaging.util.AccessibilityUtil;
95import com.android.messaging.util.Assert;
96import com.android.messaging.util.AvatarUriUtil;
97import com.android.messaging.util.ChangeDefaultSmsAppHelper;
98import com.android.messaging.util.ContentType;
99import com.android.messaging.util.ImeUtil;
100import com.android.messaging.util.LogUtil;
101import com.android.messaging.util.OsUtil;
102import com.android.messaging.util.PhoneUtils;
103import com.android.messaging.util.SafeAsyncTask;
104import com.android.messaging.util.TextUtil;
105import com.android.messaging.util.UiUtils;
106import com.android.messaging.util.UriUtil;
107import com.google.common.annotations.VisibleForTesting;
108
109import java.io.File;
110import java.util.ArrayList;
111import java.util.List;
112
113/**
114 * Shows a list of messages/parts comprising a conversation.
115 */
116public class ConversationFragment extends Fragment implements ConversationDataListener,
117        IComposeMessageViewHost, ConversationMessageViewHost, ConversationInputHost,
118        DraftMessageDataListener {
119
120    public interface ConversationFragmentHost extends ImeUtil.ImeStateHost {
121        void onStartComposeMessage();
122        void onConversationMetadataUpdated();
123        boolean shouldResumeComposeMessage();
124        void onFinishCurrentConversation();
125        void invalidateActionBar();
126        ActionMode startActionMode(ActionMode.Callback callback);
127        void dismissActionMode();
128        ActionMode getActionMode();
129        void onConversationMessagesUpdated(int numberOfMessages);
130        void onConversationParticipantDataLoaded(int numberOfParticipants);
131        boolean isActiveAndFocused();
132    }
133
134    public static final String FRAGMENT_TAG = "conversation";
135
136    static final int REQUEST_CHOOSE_ATTACHMENTS = 2;
137    private static final int JUMP_SCROLL_THRESHOLD = 15;
138    // We animate the message from draft to message list, if we the message doesn't show up in the
139    // list within this time limit, then we just do a fade in animation instead
140    public static final int MESSAGE_ANIMATION_MAX_WAIT = 500;
141
142    private ComposeMessageView mComposeMessageView;
143    private RecyclerView mRecyclerView;
144    private ConversationMessageAdapter mAdapter;
145    private ConversationFastScroller mFastScroller;
146
147    private View mConversationComposeDivider;
148    private ChangeDefaultSmsAppHelper mChangeDefaultSmsAppHelper;
149
150    private String mConversationId;
151    // If the fragment receives a draft as part of the invocation this is set
152    private MessageData mIncomingDraft;
153
154    // This binding keeps track of our associated ConversationData instance
155    // A binding should have the lifetime of the owning component,
156    //  don't recreate, unbind and bind if you need new data
157    @VisibleForTesting
158    final Binding<ConversationData> mBinding = BindingBase.createBinding(this);
159
160    // Saved Instance State Data - only for temporal data which is nice to maintain but not
161    // critical for correctness.
162    private static final String SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY = "conversationViewState";
163    private Parcelable mListState;
164
165    private ConversationFragmentHost mHost;
166
167    protected List<Integer> mFilterResults;
168
169    // The minimum scrolling distance between RecyclerView's scroll change event beyong which
170    // a fling motion is considered fast, in which case we'll delay load image attachments for
171    // perf optimization.
172    private int mFastFlingThreshold;
173
174    // ConversationMessageView that is currently selected
175    private ConversationMessageView mSelectedMessage;
176
177    // Attachment data for the attachment within the selected message that was long pressed
178    private MessagePartData mSelectedAttachment;
179
180    // Normally, as soon as draft message is loaded, we trust the UI state held in
181    // ComposeMessageView to be the only source of truth (incl. the conversation self id). However,
182    // there can be external events that forces the UI state to change, such as SIM state changes
183    // or SIM auto-switching on receiving a message. This receiver is used to receive such
184    // local broadcast messages and reflect the change in the UI.
185    private final BroadcastReceiver mConversationSelfIdChangeReceiver = new BroadcastReceiver() {
186        @Override
187        public void onReceive(final Context context, final Intent intent) {
188            final String conversationId =
189                    intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID);
190            final String selfId =
191                    intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_SELF_ID);
192            Assert.notNull(conversationId);
193            Assert.notNull(selfId);
194            if (TextUtils.equals(mBinding.getData().getConversationId(), conversationId)) {
195                mComposeMessageView.updateConversationSelfIdOnExternalChange(selfId);
196            }
197        }
198    };
199
200    // Flag to prevent writing draft to DB on pause
201    private boolean mSuppressWriteDraft;
202
203    // Indicates whether local draft should be cleared due to external draft changes that must
204    // be reloaded from db
205    private boolean mClearLocalDraft;
206    private ImmutableBindingRef<DraftMessageData> mDraftMessageDataModel;
207
208    private boolean isScrolledToBottom() {
209        if (mRecyclerView.getChildCount() == 0) {
210            return true;
211        }
212        final View lastView = mRecyclerView.getChildAt(mRecyclerView.getChildCount() - 1);
213        int lastVisibleItem = ((LinearLayoutManager) mRecyclerView
214                .getLayoutManager()).findLastVisibleItemPosition();
215        if (lastVisibleItem < 0) {
216            // If the recyclerView height is 0, then the last visible item position is -1
217            // Try to compute the position of the last item, even though it's not visible
218            final long id = mRecyclerView.getChildItemId(lastView);
219            final RecyclerView.ViewHolder holder = mRecyclerView.findViewHolderForItemId(id);
220            if (holder != null) {
221                lastVisibleItem = holder.getAdapterPosition();
222            }
223        }
224        final int totalItemCount = mRecyclerView.getAdapter().getItemCount();
225        final boolean isAtBottom = (lastVisibleItem + 1 == totalItemCount);
226        return isAtBottom && lastView.getBottom() <= mRecyclerView.getHeight();
227    }
228
229    private void scrollToBottom(final boolean smoothScroll) {
230        if (mAdapter.getItemCount() > 0) {
231            scrollToPosition(mAdapter.getItemCount() - 1, smoothScroll);
232        }
233    }
234
235    private int mScrollToDismissThreshold;
236    private final RecyclerView.OnScrollListener mListScrollListener =
237        new RecyclerView.OnScrollListener() {
238            // Keeps track of cumulative scroll delta during a scroll event, which we may use to
239            // hide the media picker & co.
240            private int mCumulativeScrollDelta;
241            private boolean mScrollToDismissHandled;
242            private boolean mWasScrolledToBottom = true;
243            private int mScrollState = RecyclerView.SCROLL_STATE_IDLE;
244
245            @Override
246            public void onScrollStateChanged(final RecyclerView view, final int newState) {
247                if (newState == RecyclerView.SCROLL_STATE_IDLE) {
248                    // Reset scroll states.
249                    mCumulativeScrollDelta = 0;
250                    mScrollToDismissHandled = false;
251                } else if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
252                    mRecyclerView.getItemAnimator().endAnimations();
253                }
254                mScrollState = newState;
255            }
256
257            @Override
258            public void onScrolled(final RecyclerView view, final int dx, final int dy) {
259                if (mScrollState == RecyclerView.SCROLL_STATE_DRAGGING &&
260                        !mScrollToDismissHandled) {
261                    mCumulativeScrollDelta += dy;
262                    // Dismiss the keyboard only when the user scroll up (into the past).
263                    if (mCumulativeScrollDelta < -mScrollToDismissThreshold) {
264                        mComposeMessageView.hideAllComposeInputs(false /* animate */);
265                        mScrollToDismissHandled = true;
266                    }
267                }
268                if (mWasScrolledToBottom != isScrolledToBottom()) {
269                    mConversationComposeDivider.animate().alpha(isScrolledToBottom() ? 0 : 1);
270                    mWasScrolledToBottom = isScrolledToBottom();
271                }
272            }
273    };
274
275    private final ActionMode.Callback mMessageActionModeCallback = new ActionMode.Callback() {
276        @Override
277        public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) {
278            if (mSelectedMessage == null) {
279                return false;
280            }
281            final ConversationMessageData data = mSelectedMessage.getData();
282            final MenuInflater menuInflater = getActivity().getMenuInflater();
283            menuInflater.inflate(R.menu.conversation_fragment_select_menu, menu);
284            menu.findItem(R.id.action_download).setVisible(data.getShowDownloadMessage());
285            menu.findItem(R.id.action_send).setVisible(data.getShowResendMessage());
286
287            // ShareActionProvider does not work with ActionMode. So we use a normal menu item.
288            menu.findItem(R.id.share_message_menu).setVisible(data.getCanForwardMessage());
289            menu.findItem(R.id.save_attachment).setVisible(mSelectedAttachment != null);
290            menu.findItem(R.id.forward_message_menu).setVisible(data.getCanForwardMessage());
291
292            // TODO: We may want to support copying attachments in the future, but it's
293            // unclear which attachment to pick when we make this context menu at the message level
294            // instead of the part level
295            menu.findItem(R.id.copy_text).setVisible(data.getCanCopyMessageToClipboard());
296
297            return true;
298        }
299
300        @Override
301        public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) {
302            return true;
303        }
304
305        @Override
306        public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) {
307            final ConversationMessageData data = mSelectedMessage.getData();
308            final String messageId = data.getMessageId();
309            switch (menuItem.getItemId()) {
310                case R.id.save_attachment:
311                    if (OsUtil.hasStoragePermission()) {
312                        final SaveAttachmentTask saveAttachmentTask = new SaveAttachmentTask(
313                                getActivity());
314                        for (final MessagePartData part : data.getAttachments()) {
315                            saveAttachmentTask.addAttachmentToSave(part.getContentUri(),
316                                    part.getContentType());
317                        }
318                        if (saveAttachmentTask.getAttachmentCount() > 0) {
319                            saveAttachmentTask.executeOnThreadPool();
320                            mHost.dismissActionMode();
321                        }
322                    } else {
323                        getActivity().requestPermissions(
324                                new String[] { Manifest.permission.WRITE_EXTERNAL_STORAGE }, 0);
325                    }
326                    return true;
327                case R.id.action_delete_message:
328                    if (mSelectedMessage != null) {
329                        deleteMessage(messageId);
330                    }
331                    return true;
332                case R.id.action_download:
333                    if (mSelectedMessage != null) {
334                        retryDownload(messageId);
335                        mHost.dismissActionMode();
336                    }
337                    return true;
338                case R.id.action_send:
339                    if (mSelectedMessage != null) {
340                        retrySend(messageId);
341                        mHost.dismissActionMode();
342                    }
343                    return true;
344                case R.id.copy_text:
345                    Assert.isTrue(data.hasText());
346                    final ClipboardManager clipboard = (ClipboardManager) getActivity()
347                            .getSystemService(Context.CLIPBOARD_SERVICE);
348                    clipboard.setPrimaryClip(
349                            ClipData.newPlainText(null /* label */, data.getText()));
350                    mHost.dismissActionMode();
351                    return true;
352                case R.id.details_menu:
353                    MessageDetailsDialog.show(
354                            getActivity(), data, mBinding.getData().getParticipants(),
355                            mBinding.getData().getSelfParticipantById(data.getSelfParticipantId()));
356                    mHost.dismissActionMode();
357                    return true;
358                case R.id.share_message_menu:
359                    shareMessage(data);
360                    mHost.dismissActionMode();
361                    return true;
362                case R.id.forward_message_menu:
363                    // TODO: Currently we are forwarding one part at a time, instead of
364                    // the entire message. Change this to forwarding the entire message when we
365                    // use message-based cursor in conversation.
366                    final MessageData message = mBinding.getData().createForwardedMessage(data);
367                    UIIntents.get().launchForwardMessageActivity(getActivity(), message);
368                    mHost.dismissActionMode();
369                    return true;
370            }
371            return false;
372        }
373
374        private void shareMessage(final ConversationMessageData data) {
375            // Figure out what to share.
376            MessagePartData attachmentToShare = mSelectedAttachment;
377            // If the user long-pressed on the background, we will share the text (if any)
378            // or the first attachment.
379            if (mSelectedAttachment == null
380                    && TextUtil.isAllWhitespace(data.getText())) {
381                final List<MessagePartData> attachments = data.getAttachments();
382                if (attachments.size() > 0) {
383                    attachmentToShare = attachments.get(0);
384                }
385            }
386
387            final Intent shareIntent = new Intent();
388            shareIntent.setAction(Intent.ACTION_SEND);
389            if (attachmentToShare == null) {
390                shareIntent.putExtra(Intent.EXTRA_TEXT, data.getText());
391                shareIntent.setType("text/plain");
392            } else {
393                shareIntent.putExtra(
394                        Intent.EXTRA_STREAM, attachmentToShare.getContentUri());
395                shareIntent.setType(attachmentToShare.getContentType());
396            }
397            final CharSequence title = getResources().getText(R.string.action_share);
398            startActivity(Intent.createChooser(shareIntent, title));
399        }
400
401        @Override
402        public void onDestroyActionMode(final ActionMode actionMode) {
403            selectMessage(null);
404        }
405    };
406
407    /**
408     * {@inheritDoc} from Fragment
409     */
410    @Override
411    public void onCreate(final Bundle savedInstanceState) {
412        super.onCreate(savedInstanceState);
413        mFastFlingThreshold = getResources().getDimensionPixelOffset(
414                R.dimen.conversation_fast_fling_threshold);
415        mAdapter = new ConversationMessageAdapter(getActivity(), null, this,
416                null,
417                // Sets the item click listener on the Recycler item views.
418                new View.OnClickListener() {
419                    @Override
420                    public void onClick(final View v) {
421                        final ConversationMessageView messageView = (ConversationMessageView) v;
422                        handleMessageClick(messageView);
423                    }
424                },
425                new View.OnLongClickListener() {
426                    @Override
427                    public boolean onLongClick(final View view) {
428                        selectMessage((ConversationMessageView) view);
429                        return true;
430                    }
431                }
432        );
433    }
434
435    /**
436     * setConversationInfo() may be called before or after onCreate(). When a user initiate a
437     * conversation from compose, the ConversationActivity creates this fragment and calls
438     * setConversationInfo(), so it happens before onCreate(). However, when the activity is
439     * restored from saved instance state, the ConversationFragment is created automatically by
440     * the fragment, before ConversationActivity has a chance to call setConversationInfo(). Since
441     * the ability to start loading data depends on both methods being called, we need to start
442     * loading when onActivityCreated() is called, which is guaranteed to happen after both.
443     */
444    @Override
445    public void onActivityCreated(final Bundle savedInstanceState) {
446        super.onActivityCreated(savedInstanceState);
447        // Delay showing the message list until the participant list is loaded.
448        mRecyclerView.setVisibility(View.INVISIBLE);
449        mBinding.ensureBound();
450        mBinding.getData().init(getLoaderManager(), mBinding);
451
452        // Build the input manager with all its required dependencies and pass it along to the
453        // compose message view.
454        final ConversationInputManager inputManager = new ConversationInputManager(
455                getActivity(), this, mComposeMessageView, mHost, getFragmentManagerToUse(),
456                mBinding, mComposeMessageView.getDraftDataModel(), savedInstanceState);
457        mComposeMessageView.setInputManager(inputManager);
458        mComposeMessageView.setConversationDataModel(BindingBase.createBindingReference(mBinding));
459        mHost.invalidateActionBar();
460
461        mDraftMessageDataModel =
462                BindingBase.createBindingReference(mComposeMessageView.getDraftDataModel());
463        mDraftMessageDataModel.getData().addListener(this);
464    }
465
466    public void onAttachmentChoosen() {
467        // Attachment has been choosen in the AttachmentChooserActivity, so clear local draft
468        // and reload draft on resume.
469        mClearLocalDraft = true;
470    }
471
472    private int getScrollToMessagePosition() {
473        final Activity activity = getActivity();
474        if (activity == null) {
475            return -1;
476        }
477
478        final Intent intent = activity.getIntent();
479        if (intent == null) {
480            return -1;
481        }
482
483        return intent.getIntExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, -1);
484    }
485
486    private void clearScrollToMessagePosition() {
487        final Activity activity = getActivity();
488        if (activity == null) {
489            return;
490        }
491
492        final Intent intent = activity.getIntent();
493        if (intent == null) {
494            return;
495        }
496        intent.putExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, -1);
497    }
498
499    private final Handler mHandler = new Handler();
500
501    /**
502     * {@inheritDoc} from Fragment
503     */
504    @Override
505    public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
506            final Bundle savedInstanceState) {
507        final View view = inflater.inflate(R.layout.conversation_fragment, container, false);
508        mRecyclerView = (RecyclerView) view.findViewById(android.R.id.list);
509        final LinearLayoutManager manager = new LinearLayoutManager(getActivity());
510        manager.setStackFromEnd(true);
511        manager.setReverseLayout(false);
512        mRecyclerView.setHasFixedSize(true);
513        mRecyclerView.setLayoutManager(manager);
514        mRecyclerView.setItemAnimator(new DefaultItemAnimator() {
515            private final List<ViewHolder> mAddAnimations = new ArrayList<ViewHolder>();
516            private PopupTransitionAnimation mPopupTransitionAnimation;
517
518            @Override
519            public boolean animateAdd(final ViewHolder holder) {
520                final ConversationMessageView view =
521                        (ConversationMessageView) holder.itemView;
522                final ConversationMessageData data = view.getData();
523                endAnimation(holder);
524                final long timeSinceSend = System.currentTimeMillis() - data.getReceivedTimeStamp();
525                if (data.getReceivedTimeStamp() ==
526                                InsertNewMessageAction.getLastSentMessageTimestamp() &&
527                        !data.getIsIncoming() &&
528                        timeSinceSend < MESSAGE_ANIMATION_MAX_WAIT) {
529                    final ConversationMessageBubbleView messageBubble =
530                            (ConversationMessageBubbleView) view
531                                    .findViewById(R.id.message_content);
532                    final Rect startRect = UiUtils.getMeasuredBoundsOnScreen(mComposeMessageView);
533                    final View composeBubbleView = mComposeMessageView.findViewById(
534                            R.id.compose_message_text);
535                    final Rect composeBubbleRect =
536                            UiUtils.getMeasuredBoundsOnScreen(composeBubbleView);
537                    final AttachmentPreview attachmentView =
538                            (AttachmentPreview) mComposeMessageView.findViewById(
539                                    R.id.attachment_draft_view);
540                    final Rect attachmentRect = UiUtils.getMeasuredBoundsOnScreen(attachmentView);
541                    if (attachmentView.getVisibility() == View.VISIBLE) {
542                        startRect.top = attachmentRect.top;
543                    } else {
544                        startRect.top = composeBubbleRect.top;
545                    }
546                    startRect.top -= view.getPaddingTop();
547                    startRect.bottom =
548                            composeBubbleRect.bottom;
549                    startRect.left += view.getPaddingRight();
550
551                    view.setAlpha(0);
552                    mPopupTransitionAnimation = new PopupTransitionAnimation(startRect, view);
553                    mPopupTransitionAnimation.setOnStartCallback(new Runnable() {
554                            @Override
555                            public void run() {
556                                final int startWidth = composeBubbleRect.width();
557                                attachmentView.onMessageAnimationStart();
558                                messageBubble.kickOffMorphAnimation(startWidth,
559                                        messageBubble.findViewById(R.id.message_text_and_info)
560                                        .getMeasuredWidth());
561                            }
562                        });
563                    mPopupTransitionAnimation.setOnStopCallback(new Runnable() {
564                            @Override
565                            public void run() {
566                                view.setAlpha(1);
567                            }
568                        });
569                    mPopupTransitionAnimation.startAfterLayoutComplete();
570                    mAddAnimations.add(holder);
571                    return true;
572                } else {
573                    return super.animateAdd(holder);
574                }
575            }
576
577            @Override
578            public void endAnimation(final ViewHolder holder) {
579                if (mAddAnimations.remove(holder)) {
580                    holder.itemView.clearAnimation();
581                }
582                super.endAnimation(holder);
583            }
584
585            @Override
586            public void endAnimations() {
587                for (final ViewHolder holder : mAddAnimations) {
588                    holder.itemView.clearAnimation();
589                }
590                mAddAnimations.clear();
591                if (mPopupTransitionAnimation != null) {
592                    mPopupTransitionAnimation.cancel();
593                }
594                super.endAnimations();
595            }
596        });
597        mRecyclerView.setAdapter(mAdapter);
598
599        if (savedInstanceState != null) {
600            mListState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY);
601        }
602
603        mConversationComposeDivider = view.findViewById(R.id.conversation_compose_divider);
604        mScrollToDismissThreshold = ViewConfiguration.get(getActivity()).getScaledTouchSlop();
605        mRecyclerView.addOnScrollListener(mListScrollListener);
606        mFastScroller = ConversationFastScroller.addTo(mRecyclerView,
607                UiUtils.isRtlMode() ? ConversationFastScroller.POSITION_LEFT_SIDE :
608                    ConversationFastScroller.POSITION_RIGHT_SIDE);
609
610        mComposeMessageView = (ComposeMessageView)
611                view.findViewById(R.id.message_compose_view_container);
612        // Bind the compose message view to the DraftMessageData
613        mComposeMessageView.bind(DataModel.get().createDraftMessageData(
614                mBinding.getData().getConversationId()), this);
615
616        return view;
617    }
618
619    private void scrollToPosition(final int targetPosition, final boolean smoothScroll) {
620        if (smoothScroll) {
621            final int maxScrollDelta = JUMP_SCROLL_THRESHOLD;
622
623            final LinearLayoutManager layoutManager =
624                    (LinearLayoutManager) mRecyclerView.getLayoutManager();
625            final int firstVisibleItemPosition =
626                    layoutManager.findFirstVisibleItemPosition();
627            final int delta = targetPosition - firstVisibleItemPosition;
628            final int intermediatePosition;
629
630            if (delta > maxScrollDelta) {
631                intermediatePosition = Math.max(0, targetPosition - maxScrollDelta);
632            } else if (delta < -maxScrollDelta) {
633                final int count = layoutManager.getItemCount();
634                intermediatePosition = Math.min(count - 1, targetPosition + maxScrollDelta);
635            } else {
636                intermediatePosition = -1;
637            }
638            if (intermediatePosition != -1) {
639                mRecyclerView.scrollToPosition(intermediatePosition);
640            }
641            mRecyclerView.smoothScrollToPosition(targetPosition);
642        } else {
643            mRecyclerView.scrollToPosition(targetPosition);
644        }
645    }
646
647    private int getScrollPositionFromBottom() {
648        final LinearLayoutManager layoutManager =
649                (LinearLayoutManager) mRecyclerView.getLayoutManager();
650        final int lastVisibleItem =
651                layoutManager.findLastVisibleItemPosition();
652        return Math.max(mAdapter.getItemCount() - 1 - lastVisibleItem, 0);
653    }
654
655    /**
656     * Display a photo using the Photoviewer component.
657     */
658    @Override
659    public void displayPhoto(final Uri photoUri, final Rect imageBounds, final boolean isDraft) {
660        displayPhoto(photoUri, imageBounds, isDraft, mConversationId, getActivity());
661    }
662
663    public static void displayPhoto(final Uri photoUri, final Rect imageBounds,
664            final boolean isDraft, final String conversationId, final Activity activity) {
665        final Uri imagesUri =
666                isDraft ? MessagingContentProvider.buildDraftImagesUri(conversationId)
667                        : MessagingContentProvider.buildConversationImagesUri(conversationId);
668        UIIntents.get().launchFullScreenPhotoViewer(
669                activity, photoUri, imageBounds, imagesUri);
670    }
671
672    private void selectMessage(final ConversationMessageView messageView) {
673        selectMessage(messageView, null /* attachment */);
674    }
675
676    private void selectMessage(final ConversationMessageView messageView,
677            final MessagePartData attachment) {
678        mSelectedMessage = messageView;
679        if (mSelectedMessage == null) {
680            mAdapter.setSelectedMessage(null);
681            mHost.dismissActionMode();
682            mSelectedAttachment = null;
683            return;
684        }
685        mSelectedAttachment = attachment;
686        mAdapter.setSelectedMessage(messageView.getData().getMessageId());
687        mHost.startActionMode(mMessageActionModeCallback);
688    }
689
690    @Override
691    public void onSaveInstanceState(final Bundle outState) {
692        super.onSaveInstanceState(outState);
693        if (mListState != null) {
694            outState.putParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY, mListState);
695        }
696        mComposeMessageView.saveInputState(outState);
697    }
698
699    @Override
700    public void onResume() {
701        super.onResume();
702
703        if (mIncomingDraft == null) {
704            mComposeMessageView.requestDraftMessage(mClearLocalDraft);
705        } else {
706            mComposeMessageView.setDraftMessage(mIncomingDraft);
707            mIncomingDraft = null;
708        }
709        mClearLocalDraft = false;
710
711        // On resume, check if there's a pending request for resuming message compose. This
712        // may happen when the user commits the contact selection for a group conversation and
713        // goes from compose back to the conversation fragment.
714        if (mHost.shouldResumeComposeMessage()) {
715            mComposeMessageView.resumeComposeMessage();
716        }
717
718        setConversationFocus();
719
720        // On resume, invalidate all message views to show the updated timestamp.
721        mAdapter.notifyDataSetChanged();
722
723        LocalBroadcastManager.getInstance(getActivity()).registerReceiver(
724                mConversationSelfIdChangeReceiver,
725                new IntentFilter(UIIntents.CONVERSATION_SELF_ID_CHANGE_BROADCAST_ACTION));
726    }
727
728    void setConversationFocus() {
729        if (mHost.isActiveAndFocused()) {
730            mBinding.getData().setFocus();
731        }
732    }
733
734    @Override
735    public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
736        if (mHost.getActionMode() != null) {
737            return;
738        }
739
740        inflater.inflate(R.menu.conversation_menu, menu);
741
742        final ConversationData data = mBinding.getData();
743
744        // Disable the "people & options" item if we haven't loaded participants yet.
745        menu.findItem(R.id.action_people_and_options).setEnabled(data.getParticipantsLoaded());
746
747        // See if we can show add contact action.
748        final ParticipantData participant = data.getOtherParticipant();
749        final boolean addContactActionVisible = (participant != null
750                && TextUtils.isEmpty(participant.getLookupKey()));
751        menu.findItem(R.id.action_add_contact).setVisible(addContactActionVisible);
752
753        // See if we should show archive or unarchive.
754        final boolean isArchived = data.getIsArchived();
755        menu.findItem(R.id.action_archive).setVisible(!isArchived);
756        menu.findItem(R.id.action_unarchive).setVisible(isArchived);
757
758        // Conditionally enable the phone call button.
759        final boolean supportCallAction = (PhoneUtils.getDefault().isVoiceCapable() &&
760                data.getParticipantPhoneNumber() != null);
761        menu.findItem(R.id.action_call).setVisible(supportCallAction);
762    }
763
764    @Override
765    public boolean onOptionsItemSelected(final MenuItem item) {
766        switch (item.getItemId()) {
767            case R.id.action_people_and_options:
768                Assert.isTrue(mBinding.getData().getParticipantsLoaded());
769                UIIntents.get().launchPeopleAndOptionsActivity(getActivity(), mConversationId);
770                return true;
771
772            case R.id.action_call:
773                final String phoneNumber = mBinding.getData().getParticipantPhoneNumber();
774                Assert.notNull(phoneNumber);
775                final View targetView = getActivity().findViewById(R.id.action_call);
776                Point centerPoint;
777                if (targetView != null) {
778                    final int screenLocation[] = new int[2];
779                    targetView.getLocationOnScreen(screenLocation);
780                    final int centerX = screenLocation[0] + targetView.getWidth() / 2;
781                    final int centerY = screenLocation[1] + targetView.getHeight() / 2;
782                    centerPoint = new Point(centerX, centerY);
783                } else {
784                    // In the overflow menu, just use the center of the screen.
785                    final Display display = getActivity().getWindowManager().getDefaultDisplay();
786                    centerPoint = new Point(display.getWidth() / 2, display.getHeight() / 2);
787                }
788                UIIntents.get().launchPhoneCallActivity(getActivity(), phoneNumber, centerPoint);
789                return true;
790
791            case R.id.action_archive:
792                mBinding.getData().archiveConversation(mBinding);
793                closeConversation(mConversationId);
794                return true;
795
796            case R.id.action_unarchive:
797                mBinding.getData().unarchiveConversation(mBinding);
798                return true;
799
800            case R.id.action_settings:
801                return true;
802
803            case R.id.action_add_contact:
804                final ParticipantData participant = mBinding.getData().getOtherParticipant();
805                Assert.notNull(participant);
806                final String destination = participant.getNormalizedDestination();
807                final Uri avatarUri = AvatarUriUtil.createAvatarUri(participant);
808                (new AddContactsConfirmationDialog(getActivity(), avatarUri, destination)).show();
809                return true;
810
811            case R.id.action_delete:
812                if (isReadyForAction()) {
813                    new AlertDialog.Builder(getActivity())
814                            .setTitle(getResources().getQuantityString(
815                                    R.plurals.delete_conversations_confirmation_dialog_title, 1))
816                            .setPositiveButton(R.string.delete_conversation_confirmation_button,
817                                    new DialogInterface.OnClickListener() {
818                                        @Override
819                                        public void onClick(final DialogInterface dialog,
820                                                final int button) {
821                                            deleteConversation();
822                                        }
823                            })
824                            .setNegativeButton(R.string.delete_conversation_decline_button, null)
825                            .show();
826                } else {
827                    warnOfMissingActionConditions(false /*sending*/,
828                            null /*commandToRunAfterActionConditionResolved*/);
829                }
830                return true;
831        }
832        return super.onOptionsItemSelected(item);
833    }
834
835    /**
836     * {@inheritDoc} from ConversationDataListener
837     */
838    @Override
839    public void onConversationMessagesCursorUpdated(final ConversationData data,
840            final Cursor cursor, final ConversationMessageData newestMessage,
841            final boolean isSync) {
842        mBinding.ensureBound(data);
843
844        // This needs to be determined before swapping cursor, which may change the scroll state.
845        final boolean scrolledToBottom = isScrolledToBottom();
846        final int positionFromBottom = getScrollPositionFromBottom();
847
848        // If participants not loaded, assume 1:1 since that's the 99% case
849        final boolean oneOnOne =
850                !data.getParticipantsLoaded() || data.getOtherParticipant() != null;
851        mAdapter.setOneOnOne(oneOnOne, false /* invalidate */);
852
853        // Ensure that the action bar is updated with the current data.
854        invalidateOptionsMenu();
855        final Cursor oldCursor = mAdapter.swapCursor(cursor);
856
857        if (cursor != null && oldCursor == null) {
858            if (mListState != null) {
859                mRecyclerView.getLayoutManager().onRestoreInstanceState(mListState);
860                // RecyclerView restores scroll states without triggering scroll change events, so
861                // we need to manually ensure that they are correctly handled.
862                mListScrollListener.onScrolled(mRecyclerView, 0, 0);
863            }
864        }
865
866        if (isSync) {
867            // This is a message sync. Syncing messages changes cursor item count, which would
868            // implicitly change RV's scroll position. We'd like the RV to keep scrolled to the same
869            // relative position from the bottom (because RV is stacked from bottom), so that it
870            // stays relatively put as we sync.
871            final int position = Math.max(mAdapter.getItemCount() - 1 - positionFromBottom, 0);
872            scrollToPosition(position, false /* smoothScroll */);
873        } else if (newestMessage != null) {
874            // Show a snack bar notification if we are not scrolled to the bottom and the new
875            // message is an incoming message.
876            if (!scrolledToBottom && newestMessage.getIsIncoming()) {
877                // If the conversation activity is started but not resumed (if another dialog
878                // activity was in the foregrond), we will show a system notification instead of
879                // the snack bar.
880                if (mBinding.getData().isFocused()) {
881                    UiUtils.showSnackBarWithCustomAction(getActivity(),
882                            getView().getRootView(),
883                            getString(R.string.in_conversation_notify_new_message_text),
884                            SnackBar.Action.createCustomAction(new Runnable() {
885                                @Override
886                                public void run() {
887                                    scrollToBottom(true /* smoothScroll */);
888                                    mComposeMessageView.hideAllComposeInputs(false /* animate */);
889                                }
890                            },
891                            getString(R.string.in_conversation_notify_new_message_action)),
892                            null /* interactions */,
893                            SnackBar.Placement.above(mComposeMessageView));
894                }
895            } else {
896                // We are either already scrolled to the bottom or this is an outgoing message,
897                // scroll to the bottom to reveal it.
898                // Don't smooth scroll if we were already at the bottom; instead, we scroll
899                // immediately so RecyclerView's view animation will take place.
900                scrollToBottom(!scrolledToBottom);
901            }
902        }
903
904        if (cursor != null) {
905            mHost.onConversationMessagesUpdated(cursor.getCount());
906
907            // Are we coming from a widget click where we're told to scroll to a particular item?
908            final int scrollToPos = getScrollToMessagePosition();
909            if (scrollToPos >= 0) {
910                if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) {
911                    LogUtil.v(LogUtil.BUGLE_TAG, "onConversationMessagesCursorUpdated " +
912                            " scrollToPos: " + scrollToPos +
913                            " cursorCount: " + cursor.getCount());
914                }
915                scrollToPosition(scrollToPos, true /*smoothScroll*/);
916                clearScrollToMessagePosition();
917            }
918        }
919
920        mHost.invalidateActionBar();
921    }
922
923    /**
924     * {@inheritDoc} from ConversationDataListener
925     */
926    @Override
927    public void onConversationMetadataUpdated(final ConversationData conversationData) {
928        mBinding.ensureBound(conversationData);
929
930        if (mSelectedMessage != null && mSelectedAttachment != null) {
931            // We may have just sent a message and the temp attachment we selected is now gone.
932            // and it was replaced with some new attachment.  Since we don't know which one it
933            // is we shouldn't reselect it (unless there is just one) In the multi-attachment
934            // case we would just deselect the message and allow the user to reselect, otherwise we
935            // may act on old temp data and may crash.
936            final List<MessagePartData> currentAttachments = mSelectedMessage.getData().getAttachments();
937            if (currentAttachments.size() == 1) {
938                mSelectedAttachment = currentAttachments.get(0);
939            } else if (!currentAttachments.contains(mSelectedAttachment)) {
940                selectMessage(null);
941            }
942        }
943        // Ensure that the action bar is updated with the current data.
944        invalidateOptionsMenu();
945        mHost.onConversationMetadataUpdated();
946        mAdapter.notifyDataSetChanged();
947    }
948
949    public void setConversationInfo(final Context context, final String conversationId,
950            final MessageData draftData) {
951        // TODO: Eventually I would like the Factory to implement
952        // Factory.get().bindConversationData(mBinding, getActivity(), this, conversationId));
953        if (!mBinding.isBound()) {
954            mConversationId = conversationId;
955            mIncomingDraft = draftData;
956            mBinding.bind(DataModel.get().createConversationData(context, this, conversationId));
957        } else {
958            Assert.isTrue(TextUtils.equals(mBinding.getData().getConversationId(), conversationId));
959        }
960    }
961
962    @Override
963    public void onDestroy() {
964        super.onDestroy();
965        // Unbind all the views that we bound to data
966        if (mComposeMessageView != null) {
967            mComposeMessageView.unbind();
968        }
969
970        // And unbind this fragment from its data
971        mBinding.unbind();
972        mConversationId = null;
973    }
974
975    void suppressWriteDraft() {
976        mSuppressWriteDraft = true;
977    }
978
979    @Override
980    public void onPause() {
981        super.onPause();
982        if (mComposeMessageView != null && !mSuppressWriteDraft) {
983            mComposeMessageView.writeDraftMessage();
984        }
985        mSuppressWriteDraft = false;
986        mBinding.getData().unsetFocus();
987        mListState = mRecyclerView.getLayoutManager().onSaveInstanceState();
988
989        LocalBroadcastManager.getInstance(getActivity())
990                .unregisterReceiver(mConversationSelfIdChangeReceiver);
991    }
992
993    @Override
994    public void onConfigurationChanged(final Configuration newConfig) {
995        super.onConfigurationChanged(newConfig);
996        mRecyclerView.getItemAnimator().endAnimations();
997    }
998
999    // TODO: Remove isBound and replace it with ensureBound after b/15704674.
1000    public boolean isBound() {
1001        return mBinding.isBound();
1002    }
1003
1004    private FragmentManager getFragmentManagerToUse() {
1005        return OsUtil.isAtLeastJB_MR1() ? getChildFragmentManager() : getFragmentManager();
1006    }
1007
1008    public MediaPicker getMediaPicker() {
1009        return (MediaPicker) getFragmentManagerToUse().findFragmentByTag(
1010                MediaPicker.FRAGMENT_TAG);
1011    }
1012
1013    @Override
1014    public void sendMessage(final MessageData message) {
1015        if (isReadyForAction()) {
1016            if (ensureKnownRecipients()) {
1017                // Merge the caption text from attachments into the text body of the messages
1018                message.consolidateText();
1019
1020                mBinding.getData().sendMessage(mBinding, message);
1021                mComposeMessageView.resetMediaPickerState();
1022            } else {
1023                LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: conv participants not loaded");
1024            }
1025        } else {
1026            warnOfMissingActionConditions(true /*sending*/,
1027                    new Runnable() {
1028                        @Override
1029                        public void run() {
1030                            sendMessage(message);
1031                        }
1032            });
1033        }
1034    }
1035
1036    public void setHost(final ConversationFragmentHost host) {
1037        mHost = host;
1038    }
1039
1040    public String getConversationName() {
1041        return mBinding.getData().getConversationName();
1042    }
1043
1044    @Override
1045    public void onComposeEditTextFocused() {
1046        mHost.onStartComposeMessage();
1047    }
1048
1049    @Override
1050    public void onAttachmentsCleared() {
1051        // When attachments are removed, reset transient media picker state such as image selection.
1052        mComposeMessageView.resetMediaPickerState();
1053    }
1054
1055    /**
1056     * Called to check if all conditions are nominal and a "go" for some action, such as deleting
1057     * a message, that requires this app to be the default app. This is also a precondition
1058     * required for sending a draft.
1059     * @return true if all conditions are nominal and we're ready to send a message
1060     */
1061    @Override
1062    public boolean isReadyForAction() {
1063        return UiUtils.isReadyForAction();
1064    }
1065
1066    /**
1067     * When there's some condition that prevents an operation, such as sending a message,
1068     * call warnOfMissingActionConditions to put up a snackbar and allow the user to repair
1069     * that condition.
1070     * @param sending - true if we're called during a sending operation
1071     * @param commandToRunAfterActionConditionResolved - a runnable to run after the user responds
1072     *                  positively to the condition prompt and resolves the condition. If null,
1073     *                  the user will be shown a toast to tap the send button again.
1074     */
1075    @Override
1076    public void warnOfMissingActionConditions(final boolean sending,
1077            final Runnable commandToRunAfterActionConditionResolved) {
1078        if (mChangeDefaultSmsAppHelper == null) {
1079            mChangeDefaultSmsAppHelper = new ChangeDefaultSmsAppHelper();
1080        }
1081        mChangeDefaultSmsAppHelper.warnOfMissingActionConditions(sending,
1082                commandToRunAfterActionConditionResolved, mComposeMessageView,
1083                getView().getRootView(),
1084                getActivity(), this);
1085    }
1086
1087    private boolean ensureKnownRecipients() {
1088        final ConversationData conversationData = mBinding.getData();
1089
1090        if (!conversationData.getParticipantsLoaded()) {
1091            // We can't tell yet whether or not we have an unknown recipient
1092            return false;
1093        }
1094
1095        final ConversationParticipantsData participants = conversationData.getParticipants();
1096        for (final ParticipantData participant : participants) {
1097
1098
1099            if (participant.isUnknownSender()) {
1100                UiUtils.showToast(R.string.unknown_sender);
1101                return false;
1102            }
1103        }
1104
1105        return true;
1106    }
1107
1108    public void retryDownload(final String messageId) {
1109        if (isReadyForAction()) {
1110            mBinding.getData().downloadMessage(mBinding, messageId);
1111        } else {
1112            warnOfMissingActionConditions(false /*sending*/,
1113                    null /*commandToRunAfterActionConditionResolved*/);
1114        }
1115    }
1116
1117    public void retrySend(final String messageId) {
1118        if (isReadyForAction()) {
1119            if (ensureKnownRecipients()) {
1120                mBinding.getData().resendMessage(mBinding, messageId);
1121            }
1122        } else {
1123            warnOfMissingActionConditions(true /*sending*/,
1124                    new Runnable() {
1125                        @Override
1126                        public void run() {
1127                            retrySend(messageId);
1128                        }
1129
1130                    });
1131        }
1132    }
1133
1134    void deleteMessage(final String messageId) {
1135        if (isReadyForAction()) {
1136            final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity())
1137                    .setTitle(R.string.delete_message_confirmation_dialog_title)
1138                    .setMessage(R.string.delete_message_confirmation_dialog_text)
1139                    .setPositiveButton(R.string.delete_message_confirmation_button,
1140                            new OnClickListener() {
1141                        @Override
1142                        public void onClick(final DialogInterface dialog, final int which) {
1143                            mBinding.getData().deleteMessage(mBinding, messageId);
1144                        }
1145                    })
1146                    .setNegativeButton(android.R.string.cancel, null);
1147            if (OsUtil.isAtLeastJB_MR1()) {
1148                builder.setOnDismissListener(new OnDismissListener() {
1149                    @Override
1150                    public void onDismiss(final DialogInterface dialog) {
1151                        mHost.dismissActionMode();
1152                    }
1153                });
1154            } else {
1155                builder.setOnCancelListener(new OnCancelListener() {
1156                    @Override
1157                    public void onCancel(final DialogInterface dialog) {
1158                        mHost.dismissActionMode();
1159                    }
1160                });
1161            }
1162            builder.create().show();
1163        } else {
1164            warnOfMissingActionConditions(false /*sending*/,
1165                    null /*commandToRunAfterActionConditionResolved*/);
1166            mHost.dismissActionMode();
1167        }
1168    }
1169
1170    public void deleteConversation() {
1171        if (isReadyForAction()) {
1172            final Context context = getActivity();
1173            mBinding.getData().deleteConversation(mBinding);
1174            closeConversation(mConversationId);
1175        } else {
1176            warnOfMissingActionConditions(false /*sending*/,
1177                    null /*commandToRunAfterActionConditionResolved*/);
1178        }
1179    }
1180
1181    @Override
1182    public void closeConversation(final String conversationId) {
1183        if (TextUtils.equals(conversationId, mConversationId)) {
1184            mHost.onFinishCurrentConversation();
1185            // TODO: Explicitly transition to ConversationList (or just go back)?
1186        }
1187    }
1188
1189    @Override
1190    public void onConversationParticipantDataLoaded(final ConversationData data) {
1191        mBinding.ensureBound(data);
1192        if (mBinding.getData().getParticipantsLoaded()) {
1193            final boolean oneOnOne = mBinding.getData().getOtherParticipant() != null;
1194            mAdapter.setOneOnOne(oneOnOne, true /* invalidate */);
1195
1196            // refresh the options menu which will enable the "people & options" item.
1197            invalidateOptionsMenu();
1198
1199            mHost.invalidateActionBar();
1200
1201            mRecyclerView.setVisibility(View.VISIBLE);
1202            mHost.onConversationParticipantDataLoaded
1203                (mBinding.getData().getNumberOfParticipantsExcludingSelf());
1204        }
1205    }
1206
1207    @Override
1208    public void onSubscriptionListDataLoaded(final ConversationData data) {
1209        mBinding.ensureBound(data);
1210        mAdapter.notifyDataSetChanged();
1211    }
1212
1213    @Override
1214    public void promptForSelfPhoneNumber() {
1215        if (mComposeMessageView != null) {
1216            // Avoid bug in system which puts soft keyboard over dialog after orientation change
1217            ImeUtil.hideSoftInput(getActivity(), mComposeMessageView);
1218        }
1219
1220        final FragmentTransaction ft = getActivity().getFragmentManager().beginTransaction();
1221        final EnterSelfPhoneNumberDialog dialog = EnterSelfPhoneNumberDialog
1222                .newInstance(getConversationSelfSubId());
1223        dialog.setTargetFragment(this, 0/*requestCode*/);
1224        dialog.show(ft, null/*tag*/);
1225    }
1226
1227    @Override
1228    public void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
1229        if (mChangeDefaultSmsAppHelper == null) {
1230            mChangeDefaultSmsAppHelper = new ChangeDefaultSmsAppHelper();
1231        }
1232        mChangeDefaultSmsAppHelper.handleChangeDefaultSmsResult(requestCode, resultCode, null);
1233    }
1234
1235    public boolean hasMessages() {
1236        return mAdapter != null && mAdapter.getItemCount() > 0;
1237    }
1238
1239    public boolean onBackPressed() {
1240        if (mComposeMessageView.onBackPressed()) {
1241            return true;
1242        }
1243        return false;
1244    }
1245
1246    public boolean onNavigationUpPressed() {
1247        return mComposeMessageView.onNavigationUpPressed();
1248    }
1249
1250    @Override
1251    public boolean onAttachmentClick(final ConversationMessageView messageView,
1252            final MessagePartData attachment, final Rect imageBounds, final boolean longPress) {
1253        if (longPress) {
1254            selectMessage(messageView, attachment);
1255            return true;
1256        } else if (messageView.getData().getOneClickResendMessage()) {
1257            handleMessageClick(messageView);
1258            return true;
1259        }
1260
1261        if (attachment.isImage()) {
1262            displayPhoto(attachment.getContentUri(), imageBounds, false /* isDraft */);
1263        }
1264
1265        if (attachment.isVCard()) {
1266            UIIntents.get().launchVCardDetailActivity(getActivity(), attachment.getContentUri());
1267        }
1268
1269        return false;
1270    }
1271
1272    private void handleMessageClick(final ConversationMessageView messageView) {
1273        if (messageView != mSelectedMessage) {
1274            final ConversationMessageData data = messageView.getData();
1275            final boolean isReadyToSend = isReadyForAction();
1276            if (data.getOneClickResendMessage()) {
1277                // Directly resend the message on tap if it's failed
1278                retrySend(data.getMessageId());
1279                selectMessage(null);
1280            } else if (data.getShowResendMessage() && isReadyToSend) {
1281                // Select the message to show the resend/download/delete options
1282                selectMessage(messageView);
1283            } else if (data.getShowDownloadMessage() && isReadyToSend) {
1284                // Directly download the message on tap
1285                retryDownload(data.getMessageId());
1286            } else {
1287                // Let the toast from warnOfMissingActionConditions show and skip
1288                // selecting
1289                warnOfMissingActionConditions(false /*sending*/,
1290                        null /*commandToRunAfterActionConditionResolved*/);
1291                selectMessage(null);
1292            }
1293        } else {
1294            selectMessage(null);
1295        }
1296    }
1297
1298    private static class AttachmentToSave {
1299        public final Uri uri;
1300        public final String contentType;
1301        public Uri persistedUri;
1302
1303        AttachmentToSave(final Uri uri, final String contentType) {
1304            this.uri = uri;
1305            this.contentType = contentType;
1306        }
1307    }
1308
1309    public static class SaveAttachmentTask extends SafeAsyncTask<Void, Void, Void> {
1310        private final Context mContext;
1311        private final List<AttachmentToSave> mAttachmentsToSave = new ArrayList<>();
1312
1313        public SaveAttachmentTask(final Context context, final Uri contentUri,
1314                final String contentType) {
1315            mContext = context;
1316            addAttachmentToSave(contentUri, contentType);
1317        }
1318
1319        public SaveAttachmentTask(final Context context) {
1320            mContext = context;
1321        }
1322
1323        public void addAttachmentToSave(final Uri contentUri, final String contentType) {
1324            mAttachmentsToSave.add(new AttachmentToSave(contentUri, contentType));
1325        }
1326
1327        public int getAttachmentCount() {
1328            return mAttachmentsToSave.size();
1329        }
1330
1331        @Override
1332        protected Void doInBackgroundTimed(final Void... arg) {
1333            final File appDir = new File(Environment.getExternalStoragePublicDirectory(
1334                    Environment.DIRECTORY_PICTURES),
1335                    mContext.getResources().getString(R.string.app_name));
1336            final File downloadDir = Environment.getExternalStoragePublicDirectory(
1337                    Environment.DIRECTORY_DOWNLOADS);
1338            for (final AttachmentToSave attachment : mAttachmentsToSave) {
1339                final boolean isImageOrVideo = ContentType.isImageType(attachment.contentType)
1340                        || ContentType.isVideoType(attachment.contentType);
1341                attachment.persistedUri = UriUtil.persistContent(attachment.uri,
1342                        isImageOrVideo ? appDir : downloadDir, attachment.contentType);
1343           }
1344            return null;
1345        }
1346
1347        @Override
1348        protected void onPostExecute(final Void result) {
1349            int failCount = 0;
1350            int imageCount = 0;
1351            int videoCount = 0;
1352            int otherCount = 0;
1353            for (final AttachmentToSave attachment : mAttachmentsToSave) {
1354                if (attachment.persistedUri == null) {
1355                   failCount++;
1356                   continue;
1357                }
1358
1359                // Inform MediaScanner about the new file
1360                final Intent scanFileIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
1361                scanFileIntent.setData(attachment.persistedUri);
1362                mContext.sendBroadcast(scanFileIntent);
1363
1364                if (ContentType.isImageType(attachment.contentType)) {
1365                    imageCount++;
1366                } else if (ContentType.isVideoType(attachment.contentType)) {
1367                    videoCount++;
1368                } else {
1369                    otherCount++;
1370                    // Inform DownloadManager of the file so it will show in the "downloads" app
1371                    final DownloadManager downloadManager =
1372                            (DownloadManager) mContext.getSystemService(
1373                                    Context.DOWNLOAD_SERVICE);
1374                    final String filePath = attachment.persistedUri.getPath();
1375                    final File file = new File(filePath);
1376
1377                    if (file.exists()) {
1378                        downloadManager.addCompletedDownload(
1379                                file.getName() /* title */,
1380                                mContext.getString(
1381                                        R.string.attachment_file_description) /* description */,
1382                                        true /* isMediaScannerScannable */,
1383                                        attachment.contentType,
1384                                        file.getAbsolutePath(),
1385                                        file.length(),
1386                                        false /* showNotification */);
1387                    }
1388                }
1389            }
1390
1391            String message;
1392            if (failCount > 0) {
1393                message = mContext.getResources().getQuantityString(
1394                        R.plurals.attachment_save_error, failCount, failCount);
1395            } else {
1396                int messageId = R.plurals.attachments_saved;
1397                if (otherCount > 0) {
1398                    if (imageCount + videoCount == 0) {
1399                        messageId = R.plurals.attachments_saved_to_downloads;
1400                    }
1401                } else {
1402                    if (videoCount == 0) {
1403                        messageId = R.plurals.photos_saved_to_album;
1404                    } else if (imageCount == 0) {
1405                        messageId = R.plurals.videos_saved_to_album;
1406                    } else {
1407                        messageId = R.plurals.attachments_saved_to_album;
1408                    }
1409                }
1410                final String appName = mContext.getResources().getString(R.string.app_name);
1411                final int count = imageCount + videoCount + otherCount;
1412                message = mContext.getResources().getQuantityString(
1413                        messageId, count, count, appName);
1414            }
1415            UiUtils.showToastAtBottom(message);
1416        }
1417    }
1418
1419    private void invalidateOptionsMenu() {
1420        final Activity activity = getActivity();
1421        // TODO: Add the supportInvalidateOptionsMenu call to the host activity.
1422        if (activity == null || !(activity instanceof BugleActionBarActivity)) {
1423            return;
1424        }
1425        ((BugleActionBarActivity) activity).supportInvalidateOptionsMenu();
1426    }
1427
1428    @Override
1429    public void setOptionsMenuVisibility(final boolean visible) {
1430        setHasOptionsMenu(visible);
1431    }
1432
1433    @Override
1434    public int getConversationSelfSubId() {
1435        final String selfParticipantId = mComposeMessageView.getConversationSelfId();
1436        final ParticipantData self = mBinding.getData().getSelfParticipantById(selfParticipantId);
1437        // If the self id or the self participant data hasn't been loaded yet, fallback to
1438        // the default setting.
1439        return self == null ? ParticipantData.DEFAULT_SELF_SUB_ID : self.getSubId();
1440    }
1441
1442    @Override
1443    public void invalidateActionBar() {
1444        mHost.invalidateActionBar();
1445    }
1446
1447    @Override
1448    public void dismissActionMode() {
1449        mHost.dismissActionMode();
1450    }
1451
1452    @Override
1453    public void selectSim(final SubscriptionListEntry subscriptionData) {
1454        mComposeMessageView.selectSim(subscriptionData);
1455        mHost.onStartComposeMessage();
1456    }
1457
1458    @Override
1459    public void onStartComposeMessage() {
1460        mHost.onStartComposeMessage();
1461    }
1462
1463    @Override
1464    public SubscriptionListEntry getSubscriptionEntryForSelfParticipant(
1465            final String selfParticipantId, final boolean excludeDefault) {
1466        // TODO: ConversationMessageView is the only one using this. We should probably
1467        // inject this into the view during binding in the ConversationMessageAdapter.
1468        return mBinding.getData().getSubscriptionEntryForSelfParticipant(selfParticipantId,
1469                excludeDefault);
1470    }
1471
1472    @Override
1473    public SimSelectorView getSimSelectorView() {
1474        return (SimSelectorView) getView().findViewById(R.id.sim_selector);
1475    }
1476
1477    @Override
1478    public MediaPicker createMediaPicker() {
1479        return new MediaPicker(getActivity());
1480    }
1481
1482    @Override
1483    public void notifyOfAttachmentLoadFailed() {
1484        UiUtils.showToastAtBottom(R.string.attachment_load_failed_dialog_message);
1485    }
1486
1487    @Override
1488    public void warnOfExceedingMessageLimit(final boolean sending, final boolean tooManyVideos) {
1489        warnOfExceedingMessageLimit(sending, mComposeMessageView, mConversationId,
1490                getActivity(), tooManyVideos);
1491    }
1492
1493    public static void warnOfExceedingMessageLimit(final boolean sending,
1494            final ComposeMessageView composeMessageView, final String conversationId,
1495            final Activity activity, final boolean tooManyVideos) {
1496        final AlertDialog.Builder builder =
1497                new AlertDialog.Builder(activity)
1498                    .setTitle(R.string.mms_attachment_limit_reached);
1499
1500        if (sending) {
1501            if (tooManyVideos) {
1502                builder.setMessage(R.string.video_attachment_limit_exceeded_when_sending);
1503            } else {
1504                builder.setMessage(R.string.attachment_limit_reached_dialog_message_when_sending)
1505                        .setNegativeButton(R.string.attachment_limit_reached_send_anyway,
1506                                new OnClickListener() {
1507                                    @Override
1508                                    public void onClick(final DialogInterface dialog,
1509                                            final int which) {
1510                                        composeMessageView.sendMessageIgnoreMessageSizeLimit();
1511                                    }
1512                                });
1513            }
1514            builder.setPositiveButton(android.R.string.ok, new OnClickListener() {
1515                @Override
1516                public void onClick(final DialogInterface dialog, final int which) {
1517                    showAttachmentChooser(conversationId, activity);
1518                }});
1519        } else {
1520            builder.setMessage(R.string.attachment_limit_reached_dialog_message_when_composing)
1521                    .setPositiveButton(android.R.string.ok, null);
1522        }
1523        builder.show();
1524    }
1525
1526    @Override
1527    public void showAttachmentChooser() {
1528        showAttachmentChooser(mConversationId, getActivity());
1529    }
1530
1531    public static void showAttachmentChooser(final String conversationId,
1532            final Activity activity) {
1533        UIIntents.get().launchAttachmentChooserActivity(activity,
1534                conversationId, REQUEST_CHOOSE_ATTACHMENTS);
1535    }
1536
1537    private void updateActionAndStatusBarColor(final ActionBar actionBar) {
1538        final int themeColor = ConversationDrawables.get().getConversationThemeColor();
1539        actionBar.setBackgroundDrawable(new ColorDrawable(themeColor));
1540        UiUtils.setStatusBarColor(getActivity(), themeColor);
1541    }
1542
1543    public void updateActionBar(final ActionBar actionBar) {
1544        if (mComposeMessageView == null || !mComposeMessageView.updateActionBar(actionBar)) {
1545            updateActionAndStatusBarColor(actionBar);
1546            // We update this regardless of whether or not the action bar is showing so that we
1547            // don't get a race when it reappears.
1548            actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_CUSTOM);
1549            actionBar.setDisplayHomeAsUpEnabled(true);
1550            // Reset the back arrow to its default
1551            actionBar.setHomeAsUpIndicator(0);
1552            View customView = actionBar.getCustomView();
1553            if (customView == null || customView.getId() != R.id.conversation_title_container) {
1554                final LayoutInflater inflator = (LayoutInflater)
1555                        getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
1556                customView = inflator.inflate(R.layout.action_bar_conversation_name, null);
1557                customView.setOnClickListener(new View.OnClickListener() {
1558                    @Override
1559                    public void onClick(final View v) {
1560                        onBackPressed();
1561                    }
1562                });
1563                actionBar.setCustomView(customView);
1564            }
1565
1566            final TextView conversationNameView =
1567                    (TextView) customView.findViewById(R.id.conversation_title);
1568            final String conversationName = getConversationName();
1569            if (!TextUtils.isEmpty(conversationName)) {
1570                // RTL : To format conversation title if it happens to be phone numbers.
1571                final BidiFormatter bidiFormatter = BidiFormatter.getInstance();
1572                final String formattedName = bidiFormatter.unicodeWrap(
1573                        UiUtils.commaEllipsize(
1574                                conversationName,
1575                                conversationNameView.getPaint(),
1576                                conversationNameView.getWidth(),
1577                                getString(R.string.plus_one),
1578                                getString(R.string.plus_n)).toString(),
1579                        TextDirectionHeuristicsCompat.LTR);
1580                conversationNameView.setText(formattedName);
1581                // In case phone numbers are mixed in the conversation name, we need to vocalize it.
1582                final String vocalizedConversationName =
1583                        AccessibilityUtil.getVocalizedPhoneNumber(getResources(), conversationName);
1584                conversationNameView.setContentDescription(vocalizedConversationName);
1585                getActivity().setTitle(conversationName);
1586            } else {
1587                final String appName = getString(R.string.app_name);
1588                conversationNameView.setText(appName);
1589                getActivity().setTitle(appName);
1590            }
1591
1592            // When conversation is showing and media picker is not showing, then hide the action
1593            // bar only when we are in landscape mode, with IME open.
1594            if (mHost.isImeOpen() && UiUtils.isLandscapeMode()) {
1595                actionBar.hide();
1596            } else {
1597                actionBar.show();
1598            }
1599        }
1600    }
1601
1602    @Override
1603    public boolean shouldShowSubjectEditor() {
1604        return true;
1605    }
1606
1607    @Override
1608    public boolean shouldHideAttachmentsWhenSimSelectorShown() {
1609        return false;
1610    }
1611
1612    @Override
1613    public void showHideSimSelector(final boolean show) {
1614        // no-op for now
1615    }
1616
1617    @Override
1618    public int getSimSelectorItemLayoutId() {
1619        return R.layout.sim_selector_item_view;
1620    }
1621
1622    @Override
1623    public Uri getSelfSendButtonIconUri() {
1624        return null;    // use default button icon uri
1625    }
1626
1627    @Override
1628    public int overrideCounterColor() {
1629        return -1;      // don't override the color
1630    }
1631
1632    @Override
1633    public void onAttachmentsChanged(final boolean haveAttachments) {
1634        // no-op for now
1635    }
1636
1637    @Override
1638    public void onDraftChanged(final DraftMessageData data, final int changeFlags) {
1639        mDraftMessageDataModel.ensureBound(data);
1640        // We're specifically only interested in ATTACHMENTS_CHANGED from the widget. Ignore
1641        // other changes. When the widget changes an attachment, we need to reload the draft.
1642        if (changeFlags ==
1643                (DraftMessageData.WIDGET_CHANGED | DraftMessageData.ATTACHMENTS_CHANGED)) {
1644            mClearLocalDraft = true;        // force a reload of the draft in onResume
1645        }
1646    }
1647
1648    @Override
1649    public void onDraftAttachmentLimitReached(final DraftMessageData data) {
1650        // no-op for now
1651    }
1652
1653    @Override
1654    public void onDraftAttachmentLoadFailed() {
1655        // no-op for now
1656    }
1657
1658    @Override
1659    public int getAttachmentsClearedFlags() {
1660        return DraftMessageData.ATTACHMENTS_CHANGED;
1661    }
1662}
1663