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