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