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