• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 (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 
isScrolledToBottom()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 
scrollToBottom(final boolean smoothScroll)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
onCreate(final Bundle savedInstanceState)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
onActivityCreated(final Bundle savedInstanceState)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 
onAttachmentChoosen()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 
getScrollToMessagePosition()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 
clearScrollToMessagePosition()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
onCreateView(final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState)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 
scrollToPosition(final int targetPosition, final boolean smoothScroll)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 
getScrollPositionFromBottom()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
displayPhoto(final Uri photoUri, final Rect imageBounds, final boolean isDraft)659     public void displayPhoto(final Uri photoUri, final Rect imageBounds, final boolean isDraft) {
660         displayPhoto(photoUri, imageBounds, isDraft, mConversationId, getActivity());
661     }
662 
displayPhoto(final Uri photoUri, final Rect imageBounds, final boolean isDraft, final String conversationId, final Activity activity)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 
selectMessage(final ConversationMessageView messageView)672     private void selectMessage(final ConversationMessageView messageView) {
673         selectMessage(messageView, null /* attachment */);
674     }
675 
selectMessage(final ConversationMessageView messageView, final MessagePartData attachment)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
onSaveInstanceState(final Bundle outState)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
onResume()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 
setConversationFocus()728     void setConversationFocus() {
729         if (mHost.isActiveAndFocused()) {
730             mBinding.getData().setFocus();
731         }
732     }
733 
734     @Override
onCreateOptionsMenu(final Menu menu, final MenuInflater inflater)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
onOptionsItemSelected(final MenuItem item)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
onConversationMessagesCursorUpdated(final ConversationData data, final Cursor cursor, final ConversationMessageData newestMessage, final boolean isSync)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
onConversationMetadataUpdated(final ConversationData conversationData)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 
setConversationInfo(final Context context, final String conversationId, final MessageData draftData)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
onDestroy()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 
suppressWriteDraft()975     void suppressWriteDraft() {
976         mSuppressWriteDraft = true;
977     }
978 
979     @Override
onPause()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
onConfigurationChanged(final Configuration newConfig)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.
isBound()1000     public boolean isBound() {
1001         return mBinding.isBound();
1002     }
1003 
getFragmentManagerToUse()1004     private FragmentManager getFragmentManagerToUse() {
1005         return OsUtil.isAtLeastJB_MR1() ? getChildFragmentManager() : getFragmentManager();
1006     }
1007 
getMediaPicker()1008     public MediaPicker getMediaPicker() {
1009         return (MediaPicker) getFragmentManagerToUse().findFragmentByTag(
1010                 MediaPicker.FRAGMENT_TAG);
1011     }
1012 
1013     @Override
sendMessage(final MessageData message)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 
setHost(final ConversationFragmentHost host)1036     public void setHost(final ConversationFragmentHost host) {
1037         mHost = host;
1038     }
1039 
getConversationName()1040     public String getConversationName() {
1041         return mBinding.getData().getConversationName();
1042     }
1043 
1044     @Override
onComposeEditTextFocused()1045     public void onComposeEditTextFocused() {
1046         mHost.onStartComposeMessage();
1047     }
1048 
1049     @Override
onAttachmentsCleared()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
isReadyForAction()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
warnOfMissingActionConditions(final boolean sending, final Runnable commandToRunAfterActionConditionResolved)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 
ensureKnownRecipients()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 
retryDownload(final String messageId)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 
retrySend(final String messageId)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 
deleteMessage(final String messageId)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 
deleteConversation()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
closeConversation(final String conversationId)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
onConversationParticipantDataLoaded(final ConversationData data)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
onSubscriptionListDataLoaded(final ConversationData data)1208     public void onSubscriptionListDataLoaded(final ConversationData data) {
1209         mBinding.ensureBound(data);
1210         mAdapter.notifyDataSetChanged();
1211     }
1212 
1213     @Override
promptForSelfPhoneNumber()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
onActivityResult(final int requestCode, final int resultCode, final Intent data)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 
hasMessages()1235     public boolean hasMessages() {
1236         return mAdapter != null && mAdapter.getItemCount() > 0;
1237     }
1238 
onBackPressed()1239     public boolean onBackPressed() {
1240         if (mComposeMessageView.onBackPressed()) {
1241             return true;
1242         }
1243         return false;
1244     }
1245 
onNavigationUpPressed()1246     public boolean onNavigationUpPressed() {
1247         return mComposeMessageView.onNavigationUpPressed();
1248     }
1249 
1250     @Override
onAttachmentClick(final ConversationMessageView messageView, final MessagePartData attachment, final Rect imageBounds, final boolean longPress)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 
handleMessageClick(final ConversationMessageView messageView)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 
AttachmentToSave(final Uri uri, final String contentType)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 
SaveAttachmentTask(final Context context, final Uri contentUri, final String contentType)1313         public SaveAttachmentTask(final Context context, final Uri contentUri,
1314                 final String contentType) {
1315             mContext = context;
1316             addAttachmentToSave(contentUri, contentType);
1317         }
1318 
SaveAttachmentTask(final Context context)1319         public SaveAttachmentTask(final Context context) {
1320             mContext = context;
1321         }
1322 
addAttachmentToSave(final Uri contentUri, final String contentType)1323         public void addAttachmentToSave(final Uri contentUri, final String contentType) {
1324             mAttachmentsToSave.add(new AttachmentToSave(contentUri, contentType));
1325         }
1326 
getAttachmentCount()1327         public int getAttachmentCount() {
1328             return mAttachmentsToSave.size();
1329         }
1330 
1331         @Override
doInBackgroundTimed(final Void... arg)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
onPostExecute(final Void result)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 
invalidateOptionsMenu()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
setOptionsMenuVisibility(final boolean visible)1429     public void setOptionsMenuVisibility(final boolean visible) {
1430         setHasOptionsMenu(visible);
1431     }
1432 
1433     @Override
getConversationSelfSubId()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
invalidateActionBar()1443     public void invalidateActionBar() {
1444         mHost.invalidateActionBar();
1445     }
1446 
1447     @Override
dismissActionMode()1448     public void dismissActionMode() {
1449         mHost.dismissActionMode();
1450     }
1451 
1452     @Override
selectSim(final SubscriptionListEntry subscriptionData)1453     public void selectSim(final SubscriptionListEntry subscriptionData) {
1454         mComposeMessageView.selectSim(subscriptionData);
1455         mHost.onStartComposeMessage();
1456     }
1457 
1458     @Override
onStartComposeMessage()1459     public void onStartComposeMessage() {
1460         mHost.onStartComposeMessage();
1461     }
1462 
1463     @Override
getSubscriptionEntryForSelfParticipant( final String selfParticipantId, final boolean excludeDefault)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
getSimSelectorView()1473     public SimSelectorView getSimSelectorView() {
1474         return (SimSelectorView) getView().findViewById(R.id.sim_selector);
1475     }
1476 
1477     @Override
createMediaPicker()1478     public MediaPicker createMediaPicker() {
1479         return new MediaPicker(getActivity());
1480     }
1481 
1482     @Override
notifyOfAttachmentLoadFailed()1483     public void notifyOfAttachmentLoadFailed() {
1484         UiUtils.showToastAtBottom(R.string.attachment_load_failed_dialog_message);
1485     }
1486 
1487     @Override
warnOfExceedingMessageLimit(final boolean sending, final boolean tooManyVideos)1488     public void warnOfExceedingMessageLimit(final boolean sending, final boolean tooManyVideos) {
1489         warnOfExceedingMessageLimit(sending, mComposeMessageView, mConversationId,
1490                 getActivity(), tooManyVideos);
1491     }
1492 
warnOfExceedingMessageLimit(final boolean sending, final ComposeMessageView composeMessageView, final String conversationId, final Activity activity, final boolean tooManyVideos)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
showAttachmentChooser()1527     public void showAttachmentChooser() {
1528         showAttachmentChooser(mConversationId, getActivity());
1529     }
1530 
showAttachmentChooser(final String conversationId, final Activity activity)1531     public static void showAttachmentChooser(final String conversationId,
1532             final Activity activity) {
1533         UIIntents.get().launchAttachmentChooserActivity(activity,
1534                 conversationId, REQUEST_CHOOSE_ATTACHMENTS);
1535     }
1536 
updateActionAndStatusBarColor(final ActionBar actionBar)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 
updateActionBar(final ActionBar actionBar)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
shouldShowSubjectEditor()1603     public boolean shouldShowSubjectEditor() {
1604         return true;
1605     }
1606 
1607     @Override
shouldHideAttachmentsWhenSimSelectorShown()1608     public boolean shouldHideAttachmentsWhenSimSelectorShown() {
1609         return false;
1610     }
1611 
1612     @Override
showHideSimSelector(final boolean show)1613     public void showHideSimSelector(final boolean show) {
1614         // no-op for now
1615     }
1616 
1617     @Override
getSimSelectorItemLayoutId()1618     public int getSimSelectorItemLayoutId() {
1619         return R.layout.sim_selector_item_view;
1620     }
1621 
1622     @Override
getSelfSendButtonIconUri()1623     public Uri getSelfSendButtonIconUri() {
1624         return null;    // use default button icon uri
1625     }
1626 
1627     @Override
overrideCounterColor()1628     public int overrideCounterColor() {
1629         return -1;      // don't override the color
1630     }
1631 
1632     @Override
onAttachmentsChanged(final boolean haveAttachments)1633     public void onAttachmentsChanged(final boolean haveAttachments) {
1634         // no-op for now
1635     }
1636 
1637     @Override
onDraftChanged(final DraftMessageData data, final int changeFlags)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
onDraftAttachmentLimitReached(final DraftMessageData data)1649     public void onDraftAttachmentLimitReached(final DraftMessageData data) {
1650         // no-op for now
1651     }
1652 
1653     @Override
onDraftAttachmentLoadFailed()1654     public void onDraftAttachmentLoadFailed() {
1655         // no-op for now
1656     }
1657 
1658     @Override
getAttachmentsClearedFlags()1659     public int getAttachmentsClearedFlags() {
1660         return DraftMessageData.ATTACHMENTS_CHANGED;
1661     }
1662 }
1663