| /* |
| * Copyright (C) 2015 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.messaging.ui.conversation; |
| |
| import android.Manifest; |
| import android.app.Activity; |
| import android.app.AlertDialog; |
| import android.app.DownloadManager; |
| import android.app.Fragment; |
| import android.app.FragmentManager; |
| import android.app.FragmentTransaction; |
| import android.content.BroadcastReceiver; |
| import android.content.ClipData; |
| import android.content.ClipboardManager; |
| import android.content.Context; |
| import android.content.DialogInterface; |
| import android.content.DialogInterface.OnCancelListener; |
| import android.content.DialogInterface.OnClickListener; |
| import android.content.DialogInterface.OnDismissListener; |
| import android.content.Intent; |
| import android.content.IntentFilter; |
| import android.content.res.Configuration; |
| import android.database.Cursor; |
| import android.graphics.Point; |
| import android.graphics.Rect; |
| import android.graphics.drawable.ColorDrawable; |
| import android.net.Uri; |
| import android.os.Bundle; |
| import android.os.Environment; |
| import android.os.Handler; |
| import android.os.Parcelable; |
| import android.support.v4.content.LocalBroadcastManager; |
| import android.support.v4.text.BidiFormatter; |
| import android.support.v4.text.TextDirectionHeuristicsCompat; |
| import android.support.v7.app.ActionBar; |
| import android.support.v7.widget.DefaultItemAnimator; |
| import android.support.v7.widget.LinearLayoutManager; |
| import android.support.v7.widget.RecyclerView; |
| import android.support.v7.widget.RecyclerView.ViewHolder; |
| import android.text.TextUtils; |
| import android.view.ActionMode; |
| import android.view.Display; |
| import android.view.LayoutInflater; |
| import android.view.Menu; |
| import android.view.MenuInflater; |
| import android.view.MenuItem; |
| import android.view.View; |
| import android.view.ViewConfiguration; |
| import android.view.ViewGroup; |
| import android.widget.TextView; |
| |
| import com.android.messaging.R; |
| import com.android.messaging.datamodel.DataModel; |
| import com.android.messaging.datamodel.MessagingContentProvider; |
| import com.android.messaging.datamodel.action.InsertNewMessageAction; |
| import com.android.messaging.datamodel.binding.Binding; |
| import com.android.messaging.datamodel.binding.BindingBase; |
| import com.android.messaging.datamodel.binding.ImmutableBindingRef; |
| import com.android.messaging.datamodel.data.ConversationData; |
| import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; |
| import com.android.messaging.datamodel.data.ConversationMessageData; |
| import com.android.messaging.datamodel.data.ConversationParticipantsData; |
| import com.android.messaging.datamodel.data.DraftMessageData; |
| import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener; |
| import com.android.messaging.datamodel.data.MessageData; |
| import com.android.messaging.datamodel.data.MessagePartData; |
| import com.android.messaging.datamodel.data.ParticipantData; |
| import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; |
| import com.android.messaging.ui.AttachmentPreview; |
| import com.android.messaging.ui.BugleActionBarActivity; |
| import com.android.messaging.ui.ConversationDrawables; |
| import com.android.messaging.ui.SnackBar; |
| import com.android.messaging.ui.UIIntents; |
| import com.android.messaging.ui.animation.PopupTransitionAnimation; |
| import com.android.messaging.ui.contact.AddContactsConfirmationDialog; |
| import com.android.messaging.ui.conversation.ComposeMessageView.IComposeMessageViewHost; |
| import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputHost; |
| import com.android.messaging.ui.conversation.ConversationMessageView.ConversationMessageViewHost; |
| import com.android.messaging.ui.mediapicker.MediaPicker; |
| import com.android.messaging.util.AccessibilityUtil; |
| import com.android.messaging.util.Assert; |
| import com.android.messaging.util.AvatarUriUtil; |
| import com.android.messaging.util.ChangeDefaultSmsAppHelper; |
| import com.android.messaging.util.ContentType; |
| import com.android.messaging.util.ImeUtil; |
| import com.android.messaging.util.LogUtil; |
| import com.android.messaging.util.OsUtil; |
| import com.android.messaging.util.PhoneUtils; |
| import com.android.messaging.util.SafeAsyncTask; |
| import com.android.messaging.util.TextUtil; |
| import com.android.messaging.util.UiUtils; |
| import com.android.messaging.util.UriUtil; |
| import com.google.common.annotations.VisibleForTesting; |
| |
| import java.io.File; |
| import java.util.ArrayList; |
| import java.util.List; |
| |
| /** |
| * Shows a list of messages/parts comprising a conversation. |
| */ |
| public class ConversationFragment extends Fragment implements ConversationDataListener, |
| IComposeMessageViewHost, ConversationMessageViewHost, ConversationInputHost, |
| DraftMessageDataListener { |
| |
| public interface ConversationFragmentHost extends ImeUtil.ImeStateHost { |
| void onStartComposeMessage(); |
| void onConversationMetadataUpdated(); |
| boolean shouldResumeComposeMessage(); |
| void onFinishCurrentConversation(); |
| void invalidateActionBar(); |
| ActionMode startActionMode(ActionMode.Callback callback); |
| void dismissActionMode(); |
| ActionMode getActionMode(); |
| void onConversationMessagesUpdated(int numberOfMessages); |
| void onConversationParticipantDataLoaded(int numberOfParticipants); |
| boolean isActiveAndFocused(); |
| } |
| |
| public static final String FRAGMENT_TAG = "conversation"; |
| |
| static final int REQUEST_CHOOSE_ATTACHMENTS = 2; |
| private static final int JUMP_SCROLL_THRESHOLD = 15; |
| // We animate the message from draft to message list, if we the message doesn't show up in the |
| // list within this time limit, then we just do a fade in animation instead |
| public static final int MESSAGE_ANIMATION_MAX_WAIT = 500; |
| |
| private ComposeMessageView mComposeMessageView; |
| private RecyclerView mRecyclerView; |
| private ConversationMessageAdapter mAdapter; |
| private ConversationFastScroller mFastScroller; |
| |
| private View mConversationComposeDivider; |
| private ChangeDefaultSmsAppHelper mChangeDefaultSmsAppHelper; |
| |
| private String mConversationId; |
| // If the fragment receives a draft as part of the invocation this is set |
| private MessageData mIncomingDraft; |
| |
| // This binding keeps track of our associated ConversationData instance |
| // A binding should have the lifetime of the owning component, |
| // don't recreate, unbind and bind if you need new data |
| @VisibleForTesting |
| final Binding<ConversationData> mBinding = BindingBase.createBinding(this); |
| |
| // Saved Instance State Data - only for temporal data which is nice to maintain but not |
| // critical for correctness. |
| private static final String SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY = "conversationViewState"; |
| private Parcelable mListState; |
| |
| private ConversationFragmentHost mHost; |
| |
| protected List<Integer> mFilterResults; |
| |
| // The minimum scrolling distance between RecyclerView's scroll change event beyong which |
| // a fling motion is considered fast, in which case we'll delay load image attachments for |
| // perf optimization. |
| private int mFastFlingThreshold; |
| |
| // ConversationMessageView that is currently selected |
| private ConversationMessageView mSelectedMessage; |
| |
| // Attachment data for the attachment within the selected message that was long pressed |
| private MessagePartData mSelectedAttachment; |
| |
| // Normally, as soon as draft message is loaded, we trust the UI state held in |
| // ComposeMessageView to be the only source of truth (incl. the conversation self id). However, |
| // there can be external events that forces the UI state to change, such as SIM state changes |
| // or SIM auto-switching on receiving a message. This receiver is used to receive such |
| // local broadcast messages and reflect the change in the UI. |
| private final BroadcastReceiver mConversationSelfIdChangeReceiver = new BroadcastReceiver() { |
| @Override |
| public void onReceive(final Context context, final Intent intent) { |
| final String conversationId = |
| intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); |
| final String selfId = |
| intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_SELF_ID); |
| Assert.notNull(conversationId); |
| Assert.notNull(selfId); |
| if (TextUtils.equals(mBinding.getData().getConversationId(), conversationId)) { |
| mComposeMessageView.updateConversationSelfIdOnExternalChange(selfId); |
| } |
| } |
| }; |
| |
| // Flag to prevent writing draft to DB on pause |
| private boolean mSuppressWriteDraft; |
| |
| // Indicates whether local draft should be cleared due to external draft changes that must |
| // be reloaded from db |
| private boolean mClearLocalDraft; |
| private ImmutableBindingRef<DraftMessageData> mDraftMessageDataModel; |
| |
| private boolean isScrolledToBottom() { |
| if (mRecyclerView.getChildCount() == 0) { |
| return true; |
| } |
| final View lastView = mRecyclerView.getChildAt(mRecyclerView.getChildCount() - 1); |
| int lastVisibleItem = ((LinearLayoutManager) mRecyclerView |
| .getLayoutManager()).findLastVisibleItemPosition(); |
| if (lastVisibleItem < 0) { |
| // If the recyclerView height is 0, then the last visible item position is -1 |
| // Try to compute the position of the last item, even though it's not visible |
| final long id = mRecyclerView.getChildItemId(lastView); |
| final RecyclerView.ViewHolder holder = mRecyclerView.findViewHolderForItemId(id); |
| if (holder != null) { |
| lastVisibleItem = holder.getAdapterPosition(); |
| } |
| } |
| final int totalItemCount = mRecyclerView.getAdapter().getItemCount(); |
| final boolean isAtBottom = (lastVisibleItem + 1 == totalItemCount); |
| return isAtBottom && lastView.getBottom() <= mRecyclerView.getHeight(); |
| } |
| |
| private void scrollToBottom(final boolean smoothScroll) { |
| if (mAdapter.getItemCount() > 0) { |
| scrollToPosition(mAdapter.getItemCount() - 1, smoothScroll); |
| } |
| } |
| |
| private int mScrollToDismissThreshold; |
| private final RecyclerView.OnScrollListener mListScrollListener = |
| new RecyclerView.OnScrollListener() { |
| // Keeps track of cumulative scroll delta during a scroll event, which we may use to |
| // hide the media picker & co. |
| private int mCumulativeScrollDelta; |
| private boolean mScrollToDismissHandled; |
| private boolean mWasScrolledToBottom = true; |
| private int mScrollState = RecyclerView.SCROLL_STATE_IDLE; |
| |
| @Override |
| public void onScrollStateChanged(final RecyclerView view, final int newState) { |
| if (newState == RecyclerView.SCROLL_STATE_IDLE) { |
| // Reset scroll states. |
| mCumulativeScrollDelta = 0; |
| mScrollToDismissHandled = false; |
| } else if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { |
| mRecyclerView.getItemAnimator().endAnimations(); |
| } |
| mScrollState = newState; |
| } |
| |
| @Override |
| public void onScrolled(final RecyclerView view, final int dx, final int dy) { |
| if (mScrollState == RecyclerView.SCROLL_STATE_DRAGGING && |
| !mScrollToDismissHandled) { |
| mCumulativeScrollDelta += dy; |
| // Dismiss the keyboard only when the user scroll up (into the past). |
| if (mCumulativeScrollDelta < -mScrollToDismissThreshold) { |
| mComposeMessageView.hideAllComposeInputs(false /* animate */); |
| mScrollToDismissHandled = true; |
| } |
| } |
| if (mWasScrolledToBottom != isScrolledToBottom()) { |
| mConversationComposeDivider.animate().alpha(isScrolledToBottom() ? 0 : 1); |
| mWasScrolledToBottom = isScrolledToBottom(); |
| } |
| } |
| }; |
| |
| private final ActionMode.Callback mMessageActionModeCallback = new ActionMode.Callback() { |
| @Override |
| public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) { |
| if (mSelectedMessage == null) { |
| return false; |
| } |
| final ConversationMessageData data = mSelectedMessage.getData(); |
| final MenuInflater menuInflater = getActivity().getMenuInflater(); |
| menuInflater.inflate(R.menu.conversation_fragment_select_menu, menu); |
| menu.findItem(R.id.action_download).setVisible(data.getShowDownloadMessage()); |
| menu.findItem(R.id.action_send).setVisible(data.getShowResendMessage()); |
| |
| // ShareActionProvider does not work with ActionMode. So we use a normal menu item. |
| menu.findItem(R.id.share_message_menu).setVisible(data.getCanForwardMessage()); |
| menu.findItem(R.id.save_attachment).setVisible(mSelectedAttachment != null); |
| menu.findItem(R.id.forward_message_menu).setVisible(data.getCanForwardMessage()); |
| |
| // TODO: We may want to support copying attachments in the future, but it's |
| // unclear which attachment to pick when we make this context menu at the message level |
| // instead of the part level |
| menu.findItem(R.id.copy_text).setVisible(data.getCanCopyMessageToClipboard()); |
| |
| return true; |
| } |
| |
| @Override |
| public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) { |
| return true; |
| } |
| |
| @Override |
| public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) { |
| final ConversationMessageData data = mSelectedMessage.getData(); |
| final String messageId = data.getMessageId(); |
| switch (menuItem.getItemId()) { |
| case R.id.save_attachment: |
| if (OsUtil.hasStoragePermission()) { |
| final SaveAttachmentTask saveAttachmentTask = new SaveAttachmentTask( |
| getActivity()); |
| for (final MessagePartData part : data.getAttachments()) { |
| saveAttachmentTask.addAttachmentToSave(part.getContentUri(), |
| part.getContentType()); |
| } |
| if (saveAttachmentTask.getAttachmentCount() > 0) { |
| saveAttachmentTask.executeOnThreadPool(); |
| mHost.dismissActionMode(); |
| } |
| } else { |
| getActivity().requestPermissions( |
| new String[] { Manifest.permission.WRITE_EXTERNAL_STORAGE }, 0); |
| } |
| return true; |
| case R.id.action_delete_message: |
| if (mSelectedMessage != null) { |
| deleteMessage(messageId); |
| } |
| return true; |
| case R.id.action_download: |
| if (mSelectedMessage != null) { |
| retryDownload(messageId); |
| mHost.dismissActionMode(); |
| } |
| return true; |
| case R.id.action_send: |
| if (mSelectedMessage != null) { |
| retrySend(messageId); |
| mHost.dismissActionMode(); |
| } |
| return true; |
| case R.id.copy_text: |
| Assert.isTrue(data.hasText()); |
| final ClipboardManager clipboard = (ClipboardManager) getActivity() |
| .getSystemService(Context.CLIPBOARD_SERVICE); |
| clipboard.setPrimaryClip( |
| ClipData.newPlainText(null /* label */, data.getText())); |
| mHost.dismissActionMode(); |
| return true; |
| case R.id.details_menu: |
| MessageDetailsDialog.show( |
| getActivity(), data, mBinding.getData().getParticipants(), |
| mBinding.getData().getSelfParticipantById(data.getSelfParticipantId())); |
| mHost.dismissActionMode(); |
| return true; |
| case R.id.share_message_menu: |
| shareMessage(data); |
| mHost.dismissActionMode(); |
| return true; |
| case R.id.forward_message_menu: |
| // TODO: Currently we are forwarding one part at a time, instead of |
| // the entire message. Change this to forwarding the entire message when we |
| // use message-based cursor in conversation. |
| final MessageData message = mBinding.getData().createForwardedMessage(data); |
| UIIntents.get().launchForwardMessageActivity(getActivity(), message); |
| mHost.dismissActionMode(); |
| return true; |
| } |
| return false; |
| } |
| |
| private void shareMessage(final ConversationMessageData data) { |
| // Figure out what to share. |
| MessagePartData attachmentToShare = mSelectedAttachment; |
| // If the user long-pressed on the background, we will share the text (if any) |
| // or the first attachment. |
| if (mSelectedAttachment == null |
| && TextUtil.isAllWhitespace(data.getText())) { |
| final List<MessagePartData> attachments = data.getAttachments(); |
| if (attachments.size() > 0) { |
| attachmentToShare = attachments.get(0); |
| } |
| } |
| |
| final Intent shareIntent = new Intent(); |
| shareIntent.setAction(Intent.ACTION_SEND); |
| if (attachmentToShare == null) { |
| shareIntent.putExtra(Intent.EXTRA_TEXT, data.getText()); |
| shareIntent.setType("text/plain"); |
| } else { |
| shareIntent.putExtra( |
| Intent.EXTRA_STREAM, attachmentToShare.getContentUri()); |
| shareIntent.setType(attachmentToShare.getContentType()); |
| } |
| final CharSequence title = getResources().getText(R.string.action_share); |
| startActivity(Intent.createChooser(shareIntent, title)); |
| } |
| |
| @Override |
| public void onDestroyActionMode(final ActionMode actionMode) { |
| selectMessage(null); |
| } |
| }; |
| |
| /** |
| * {@inheritDoc} from Fragment |
| */ |
| @Override |
| public void onCreate(final Bundle savedInstanceState) { |
| super.onCreate(savedInstanceState); |
| mFastFlingThreshold = getResources().getDimensionPixelOffset( |
| R.dimen.conversation_fast_fling_threshold); |
| mAdapter = new ConversationMessageAdapter(getActivity(), null, this, |
| null, |
| // Sets the item click listener on the Recycler item views. |
| new View.OnClickListener() { |
| @Override |
| public void onClick(final View v) { |
| final ConversationMessageView messageView = (ConversationMessageView) v; |
| handleMessageClick(messageView); |
| } |
| }, |
| new View.OnLongClickListener() { |
| @Override |
| public boolean onLongClick(final View view) { |
| selectMessage((ConversationMessageView) view); |
| return true; |
| } |
| } |
| ); |
| } |
| |
| /** |
| * setConversationInfo() may be called before or after onCreate(). When a user initiate a |
| * conversation from compose, the ConversationActivity creates this fragment and calls |
| * setConversationInfo(), so it happens before onCreate(). However, when the activity is |
| * restored from saved instance state, the ConversationFragment is created automatically by |
| * the fragment, before ConversationActivity has a chance to call setConversationInfo(). Since |
| * the ability to start loading data depends on both methods being called, we need to start |
| * loading when onActivityCreated() is called, which is guaranteed to happen after both. |
| */ |
| @Override |
| public void onActivityCreated(final Bundle savedInstanceState) { |
| super.onActivityCreated(savedInstanceState); |
| // Delay showing the message list until the participant list is loaded. |
| mRecyclerView.setVisibility(View.INVISIBLE); |
| mBinding.ensureBound(); |
| mBinding.getData().init(getLoaderManager(), mBinding); |
| |
| // Build the input manager with all its required dependencies and pass it along to the |
| // compose message view. |
| final ConversationInputManager inputManager = new ConversationInputManager( |
| getActivity(), this, mComposeMessageView, mHost, getFragmentManagerToUse(), |
| mBinding, mComposeMessageView.getDraftDataModel(), savedInstanceState); |
| mComposeMessageView.setInputManager(inputManager); |
| mComposeMessageView.setConversationDataModel(BindingBase.createBindingReference(mBinding)); |
| mHost.invalidateActionBar(); |
| |
| mDraftMessageDataModel = |
| BindingBase.createBindingReference(mComposeMessageView.getDraftDataModel()); |
| mDraftMessageDataModel.getData().addListener(this); |
| } |
| |
| public void onAttachmentChoosen() { |
| // Attachment has been choosen in the AttachmentChooserActivity, so clear local draft |
| // and reload draft on resume. |
| mClearLocalDraft = true; |
| } |
| |
| private int getScrollToMessagePosition() { |
| final Activity activity = getActivity(); |
| if (activity == null) { |
| return -1; |
| } |
| |
| final Intent intent = activity.getIntent(); |
| if (intent == null) { |
| return -1; |
| } |
| |
| return intent.getIntExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, -1); |
| } |
| |
| private void clearScrollToMessagePosition() { |
| final Activity activity = getActivity(); |
| if (activity == null) { |
| return; |
| } |
| |
| final Intent intent = activity.getIntent(); |
| if (intent == null) { |
| return; |
| } |
| intent.putExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, -1); |
| } |
| |
| private final Handler mHandler = new Handler(); |
| |
| /** |
| * {@inheritDoc} from Fragment |
| */ |
| @Override |
| public View onCreateView(final LayoutInflater inflater, final ViewGroup container, |
| final Bundle savedInstanceState) { |
| final View view = inflater.inflate(R.layout.conversation_fragment, container, false); |
| mRecyclerView = (RecyclerView) view.findViewById(android.R.id.list); |
| final LinearLayoutManager manager = new LinearLayoutManager(getActivity()); |
| manager.setStackFromEnd(true); |
| manager.setReverseLayout(false); |
| mRecyclerView.setHasFixedSize(true); |
| mRecyclerView.setLayoutManager(manager); |
| mRecyclerView.setItemAnimator(new DefaultItemAnimator() { |
| private final List<ViewHolder> mAddAnimations = new ArrayList<ViewHolder>(); |
| private PopupTransitionAnimation mPopupTransitionAnimation; |
| |
| @Override |
| public boolean animateAdd(final ViewHolder holder) { |
| final ConversationMessageView view = |
| (ConversationMessageView) holder.itemView; |
| final ConversationMessageData data = view.getData(); |
| endAnimation(holder); |
| final long timeSinceSend = System.currentTimeMillis() - data.getReceivedTimeStamp(); |
| if (data.getReceivedTimeStamp() == |
| InsertNewMessageAction.getLastSentMessageTimestamp() && |
| !data.getIsIncoming() && |
| timeSinceSend < MESSAGE_ANIMATION_MAX_WAIT) { |
| final ConversationMessageBubbleView messageBubble = |
| (ConversationMessageBubbleView) view |
| .findViewById(R.id.message_content); |
| final Rect startRect = UiUtils.getMeasuredBoundsOnScreen(mComposeMessageView); |
| final View composeBubbleView = mComposeMessageView.findViewById( |
| R.id.compose_message_text); |
| final Rect composeBubbleRect = |
| UiUtils.getMeasuredBoundsOnScreen(composeBubbleView); |
| final AttachmentPreview attachmentView = |
| (AttachmentPreview) mComposeMessageView.findViewById( |
| R.id.attachment_draft_view); |
| final Rect attachmentRect = UiUtils.getMeasuredBoundsOnScreen(attachmentView); |
| if (attachmentView.getVisibility() == View.VISIBLE) { |
| startRect.top = attachmentRect.top; |
| } else { |
| startRect.top = composeBubbleRect.top; |
| } |
| startRect.top -= view.getPaddingTop(); |
| startRect.bottom = |
| composeBubbleRect.bottom; |
| startRect.left += view.getPaddingRight(); |
| |
| view.setAlpha(0); |
| mPopupTransitionAnimation = new PopupTransitionAnimation(startRect, view); |
| mPopupTransitionAnimation.setOnStartCallback(new Runnable() { |
| @Override |
| public void run() { |
| final int startWidth = composeBubbleRect.width(); |
| attachmentView.onMessageAnimationStart(); |
| messageBubble.kickOffMorphAnimation(startWidth, |
| messageBubble.findViewById(R.id.message_text_and_info) |
| .getMeasuredWidth()); |
| } |
| }); |
| mPopupTransitionAnimation.setOnStopCallback(new Runnable() { |
| @Override |
| public void run() { |
| view.setAlpha(1); |
| } |
| }); |
| mPopupTransitionAnimation.startAfterLayoutComplete(); |
| mAddAnimations.add(holder); |
| return true; |
| } else { |
| return super.animateAdd(holder); |
| } |
| } |
| |
| @Override |
| public void endAnimation(final ViewHolder holder) { |
| if (mAddAnimations.remove(holder)) { |
| holder.itemView.clearAnimation(); |
| } |
| super.endAnimation(holder); |
| } |
| |
| @Override |
| public void endAnimations() { |
| for (final ViewHolder holder : mAddAnimations) { |
| holder.itemView.clearAnimation(); |
| } |
| mAddAnimations.clear(); |
| if (mPopupTransitionAnimation != null) { |
| mPopupTransitionAnimation.cancel(); |
| } |
| super.endAnimations(); |
| } |
| }); |
| mRecyclerView.setAdapter(mAdapter); |
| |
| if (savedInstanceState != null) { |
| mListState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY); |
| } |
| |
| mConversationComposeDivider = view.findViewById(R.id.conversation_compose_divider); |
| mScrollToDismissThreshold = ViewConfiguration.get(getActivity()).getScaledTouchSlop(); |
| mRecyclerView.addOnScrollListener(mListScrollListener); |
| mFastScroller = ConversationFastScroller.addTo(mRecyclerView, |
| UiUtils.isRtlMode() ? ConversationFastScroller.POSITION_LEFT_SIDE : |
| ConversationFastScroller.POSITION_RIGHT_SIDE); |
| |
| mComposeMessageView = (ComposeMessageView) |
| view.findViewById(R.id.message_compose_view_container); |
| // Bind the compose message view to the DraftMessageData |
| mComposeMessageView.bind(DataModel.get().createDraftMessageData( |
| mBinding.getData().getConversationId()), this); |
| |
| return view; |
| } |
| |
| private void scrollToPosition(final int targetPosition, final boolean smoothScroll) { |
| if (smoothScroll) { |
| final int maxScrollDelta = JUMP_SCROLL_THRESHOLD; |
| |
| final LinearLayoutManager layoutManager = |
| (LinearLayoutManager) mRecyclerView.getLayoutManager(); |
| final int firstVisibleItemPosition = |
| layoutManager.findFirstVisibleItemPosition(); |
| final int delta = targetPosition - firstVisibleItemPosition; |
| final int intermediatePosition; |
| |
| if (delta > maxScrollDelta) { |
| intermediatePosition = Math.max(0, targetPosition - maxScrollDelta); |
| } else if (delta < -maxScrollDelta) { |
| final int count = layoutManager.getItemCount(); |
| intermediatePosition = Math.min(count - 1, targetPosition + maxScrollDelta); |
| } else { |
| intermediatePosition = -1; |
| } |
| if (intermediatePosition != -1) { |
| mRecyclerView.scrollToPosition(intermediatePosition); |
| } |
| mRecyclerView.smoothScrollToPosition(targetPosition); |
| } else { |
| mRecyclerView.scrollToPosition(targetPosition); |
| } |
| } |
| |
| private int getScrollPositionFromBottom() { |
| final LinearLayoutManager layoutManager = |
| (LinearLayoutManager) mRecyclerView.getLayoutManager(); |
| final int lastVisibleItem = |
| layoutManager.findLastVisibleItemPosition(); |
| return Math.max(mAdapter.getItemCount() - 1 - lastVisibleItem, 0); |
| } |
| |
| /** |
| * Display a photo using the Photoviewer component. |
| */ |
| @Override |
| public void displayPhoto(final Uri photoUri, final Rect imageBounds, final boolean isDraft) { |
| displayPhoto(photoUri, imageBounds, isDraft, mConversationId, getActivity()); |
| } |
| |
| public static void displayPhoto(final Uri photoUri, final Rect imageBounds, |
| final boolean isDraft, final String conversationId, final Activity activity) { |
| final Uri imagesUri = |
| isDraft ? MessagingContentProvider.buildDraftImagesUri(conversationId) |
| : MessagingContentProvider.buildConversationImagesUri(conversationId); |
| UIIntents.get().launchFullScreenPhotoViewer( |
| activity, photoUri, imageBounds, imagesUri); |
| } |
| |
| private void selectMessage(final ConversationMessageView messageView) { |
| selectMessage(messageView, null /* attachment */); |
| } |
| |
| private void selectMessage(final ConversationMessageView messageView, |
| final MessagePartData attachment) { |
| mSelectedMessage = messageView; |
| if (mSelectedMessage == null) { |
| mAdapter.setSelectedMessage(null); |
| mHost.dismissActionMode(); |
| mSelectedAttachment = null; |
| return; |
| } |
| mSelectedAttachment = attachment; |
| mAdapter.setSelectedMessage(messageView.getData().getMessageId()); |
| mHost.startActionMode(mMessageActionModeCallback); |
| } |
| |
| @Override |
| public void onSaveInstanceState(final Bundle outState) { |
| super.onSaveInstanceState(outState); |
| if (mListState != null) { |
| outState.putParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY, mListState); |
| } |
| mComposeMessageView.saveInputState(outState); |
| } |
| |
| @Override |
| public void onResume() { |
| super.onResume(); |
| |
| if (mIncomingDraft == null) { |
| mComposeMessageView.requestDraftMessage(mClearLocalDraft); |
| } else { |
| mComposeMessageView.setDraftMessage(mIncomingDraft); |
| mIncomingDraft = null; |
| } |
| mClearLocalDraft = false; |
| |
| // On resume, check if there's a pending request for resuming message compose. This |
| // may happen when the user commits the contact selection for a group conversation and |
| // goes from compose back to the conversation fragment. |
| if (mHost.shouldResumeComposeMessage()) { |
| mComposeMessageView.resumeComposeMessage(); |
| } |
| |
| setConversationFocus(); |
| |
| // On resume, invalidate all message views to show the updated timestamp. |
| mAdapter.notifyDataSetChanged(); |
| |
| LocalBroadcastManager.getInstance(getActivity()).registerReceiver( |
| mConversationSelfIdChangeReceiver, |
| new IntentFilter(UIIntents.CONVERSATION_SELF_ID_CHANGE_BROADCAST_ACTION)); |
| } |
| |
| void setConversationFocus() { |
| if (mHost.isActiveAndFocused()) { |
| mBinding.getData().setFocus(); |
| } |
| } |
| |
| @Override |
| public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { |
| if (mHost.getActionMode() != null) { |
| return; |
| } |
| |
| inflater.inflate(R.menu.conversation_menu, menu); |
| |
| final ConversationData data = mBinding.getData(); |
| |
| // Disable the "people & options" item if we haven't loaded participants yet. |
| menu.findItem(R.id.action_people_and_options).setEnabled(data.getParticipantsLoaded()); |
| |
| // See if we can show add contact action. |
| final ParticipantData participant = data.getOtherParticipant(); |
| final boolean addContactActionVisible = (participant != null |
| && TextUtils.isEmpty(participant.getLookupKey())); |
| menu.findItem(R.id.action_add_contact).setVisible(addContactActionVisible); |
| |
| // See if we should show archive or unarchive. |
| final boolean isArchived = data.getIsArchived(); |
| menu.findItem(R.id.action_archive).setVisible(!isArchived); |
| menu.findItem(R.id.action_unarchive).setVisible(isArchived); |
| |
| // Conditionally enable the phone call button. |
| final boolean supportCallAction = (PhoneUtils.getDefault().isVoiceCapable() && |
| data.getParticipantPhoneNumber() != null); |
| menu.findItem(R.id.action_call).setVisible(supportCallAction); |
| } |
| |
| @Override |
| public boolean onOptionsItemSelected(final MenuItem item) { |
| switch (item.getItemId()) { |
| case R.id.action_people_and_options: |
| Assert.isTrue(mBinding.getData().getParticipantsLoaded()); |
| UIIntents.get().launchPeopleAndOptionsActivity(getActivity(), mConversationId); |
| return true; |
| |
| case R.id.action_call: |
| final String phoneNumber = mBinding.getData().getParticipantPhoneNumber(); |
| Assert.notNull(phoneNumber); |
| final View targetView = getActivity().findViewById(R.id.action_call); |
| Point centerPoint; |
| if (targetView != null) { |
| final int screenLocation[] = new int[2]; |
| targetView.getLocationOnScreen(screenLocation); |
| final int centerX = screenLocation[0] + targetView.getWidth() / 2; |
| final int centerY = screenLocation[1] + targetView.getHeight() / 2; |
| centerPoint = new Point(centerX, centerY); |
| } else { |
| // In the overflow menu, just use the center of the screen. |
| final Display display = getActivity().getWindowManager().getDefaultDisplay(); |
| centerPoint = new Point(display.getWidth() / 2, display.getHeight() / 2); |
| } |
| UIIntents.get().launchPhoneCallActivity(getActivity(), phoneNumber, centerPoint); |
| return true; |
| |
| case R.id.action_archive: |
| mBinding.getData().archiveConversation(mBinding); |
| closeConversation(mConversationId); |
| return true; |
| |
| case R.id.action_unarchive: |
| mBinding.getData().unarchiveConversation(mBinding); |
| return true; |
| |
| case R.id.action_settings: |
| return true; |
| |
| case R.id.action_add_contact: |
| final ParticipantData participant = mBinding.getData().getOtherParticipant(); |
| Assert.notNull(participant); |
| final String destination = participant.getNormalizedDestination(); |
| final Uri avatarUri = AvatarUriUtil.createAvatarUri(participant); |
| (new AddContactsConfirmationDialog(getActivity(), avatarUri, destination)).show(); |
| return true; |
| |
| case R.id.action_delete: |
| if (isReadyForAction()) { |
| new AlertDialog.Builder(getActivity()) |
| .setTitle(getResources().getQuantityString( |
| R.plurals.delete_conversations_confirmation_dialog_title, 1)) |
| .setPositiveButton(R.string.delete_conversation_confirmation_button, |
| new DialogInterface.OnClickListener() { |
| @Override |
| public void onClick(final DialogInterface dialog, |
| final int button) { |
| deleteConversation(); |
| } |
| }) |
| .setNegativeButton(R.string.delete_conversation_decline_button, null) |
| .show(); |
| } else { |
| warnOfMissingActionConditions(false /*sending*/, |
| null /*commandToRunAfterActionConditionResolved*/); |
| } |
| return true; |
| } |
| return super.onOptionsItemSelected(item); |
| } |
| |
| /** |
| * {@inheritDoc} from ConversationDataListener |
| */ |
| @Override |
| public void onConversationMessagesCursorUpdated(final ConversationData data, |
| final Cursor cursor, final ConversationMessageData newestMessage, |
| final boolean isSync) { |
| mBinding.ensureBound(data); |
| |
| // This needs to be determined before swapping cursor, which may change the scroll state. |
| final boolean scrolledToBottom = isScrolledToBottom(); |
| final int positionFromBottom = getScrollPositionFromBottom(); |
| |
| // If participants not loaded, assume 1:1 since that's the 99% case |
| final boolean oneOnOne = |
| !data.getParticipantsLoaded() || data.getOtherParticipant() != null; |
| mAdapter.setOneOnOne(oneOnOne, false /* invalidate */); |
| |
| // Ensure that the action bar is updated with the current data. |
| invalidateOptionsMenu(); |
| final Cursor oldCursor = mAdapter.swapCursor(cursor); |
| |
| if (cursor != null && oldCursor == null) { |
| if (mListState != null) { |
| mRecyclerView.getLayoutManager().onRestoreInstanceState(mListState); |
| // RecyclerView restores scroll states without triggering scroll change events, so |
| // we need to manually ensure that they are correctly handled. |
| mListScrollListener.onScrolled(mRecyclerView, 0, 0); |
| } |
| } |
| |
| if (isSync) { |
| // This is a message sync. Syncing messages changes cursor item count, which would |
| // implicitly change RV's scroll position. We'd like the RV to keep scrolled to the same |
| // relative position from the bottom (because RV is stacked from bottom), so that it |
| // stays relatively put as we sync. |
| final int position = Math.max(mAdapter.getItemCount() - 1 - positionFromBottom, 0); |
| scrollToPosition(position, false /* smoothScroll */); |
| } else if (newestMessage != null) { |
| // Show a snack bar notification if we are not scrolled to the bottom and the new |
| // message is an incoming message. |
| if (!scrolledToBottom && newestMessage.getIsIncoming()) { |
| // If the conversation activity is started but not resumed (if another dialog |
| // activity was in the foregrond), we will show a system notification instead of |
| // the snack bar. |
| if (mBinding.getData().isFocused()) { |
| UiUtils.showSnackBarWithCustomAction(getActivity(), |
| getView().getRootView(), |
| getString(R.string.in_conversation_notify_new_message_text), |
| SnackBar.Action.createCustomAction(new Runnable() { |
| @Override |
| public void run() { |
| scrollToBottom(true /* smoothScroll */); |
| mComposeMessageView.hideAllComposeInputs(false /* animate */); |
| } |
| }, |
| getString(R.string.in_conversation_notify_new_message_action)), |
| null /* interactions */, |
| SnackBar.Placement.above(mComposeMessageView)); |
| } |
| } else { |
| // We are either already scrolled to the bottom or this is an outgoing message, |
| // scroll to the bottom to reveal it. |
| // Don't smooth scroll if we were already at the bottom; instead, we scroll |
| // immediately so RecyclerView's view animation will take place. |
| scrollToBottom(!scrolledToBottom); |
| } |
| } |
| |
| if (cursor != null) { |
| mHost.onConversationMessagesUpdated(cursor.getCount()); |
| |
| // Are we coming from a widget click where we're told to scroll to a particular item? |
| final int scrollToPos = getScrollToMessagePosition(); |
| if (scrollToPos >= 0) { |
| if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { |
| LogUtil.v(LogUtil.BUGLE_TAG, "onConversationMessagesCursorUpdated " + |
| " scrollToPos: " + scrollToPos + |
| " cursorCount: " + cursor.getCount()); |
| } |
| scrollToPosition(scrollToPos, true /*smoothScroll*/); |
| clearScrollToMessagePosition(); |
| } |
| } |
| |
| mHost.invalidateActionBar(); |
| } |
| |
| /** |
| * {@inheritDoc} from ConversationDataListener |
| */ |
| @Override |
| public void onConversationMetadataUpdated(final ConversationData conversationData) { |
| mBinding.ensureBound(conversationData); |
| |
| if (mSelectedMessage != null && mSelectedAttachment != null) { |
| // We may have just sent a message and the temp attachment we selected is now gone. |
| // and it was replaced with some new attachment. Since we don't know which one it |
| // is we shouldn't reselect it (unless there is just one) In the multi-attachment |
| // case we would just deselect the message and allow the user to reselect, otherwise we |
| // may act on old temp data and may crash. |
| final List<MessagePartData> currentAttachments = mSelectedMessage.getData().getAttachments(); |
| if (currentAttachments.size() == 1) { |
| mSelectedAttachment = currentAttachments.get(0); |
| } else if (!currentAttachments.contains(mSelectedAttachment)) { |
| selectMessage(null); |
| } |
| } |
| // Ensure that the action bar is updated with the current data. |
| invalidateOptionsMenu(); |
| mHost.onConversationMetadataUpdated(); |
| mAdapter.notifyDataSetChanged(); |
| } |
| |
| public void setConversationInfo(final Context context, final String conversationId, |
| final MessageData draftData) { |
| // TODO: Eventually I would like the Factory to implement |
| // Factory.get().bindConversationData(mBinding, getActivity(), this, conversationId)); |
| if (!mBinding.isBound()) { |
| mConversationId = conversationId; |
| mIncomingDraft = draftData; |
| mBinding.bind(DataModel.get().createConversationData(context, this, conversationId)); |
| } else { |
| Assert.isTrue(TextUtils.equals(mBinding.getData().getConversationId(), conversationId)); |
| } |
| } |
| |
| @Override |
| public void onDestroy() { |
| super.onDestroy(); |
| // Unbind all the views that we bound to data |
| if (mComposeMessageView != null) { |
| mComposeMessageView.unbind(); |
| } |
| |
| // And unbind this fragment from its data |
| mBinding.unbind(); |
| mConversationId = null; |
| } |
| |
| void suppressWriteDraft() { |
| mSuppressWriteDraft = true; |
| } |
| |
| @Override |
| public void onPause() { |
| super.onPause(); |
| if (mComposeMessageView != null && !mSuppressWriteDraft) { |
| mComposeMessageView.writeDraftMessage(); |
| } |
| mSuppressWriteDraft = false; |
| mBinding.getData().unsetFocus(); |
| mListState = mRecyclerView.getLayoutManager().onSaveInstanceState(); |
| |
| LocalBroadcastManager.getInstance(getActivity()) |
| .unregisterReceiver(mConversationSelfIdChangeReceiver); |
| } |
| |
| @Override |
| public void onConfigurationChanged(final Configuration newConfig) { |
| super.onConfigurationChanged(newConfig); |
| mRecyclerView.getItemAnimator().endAnimations(); |
| } |
| |
| // TODO: Remove isBound and replace it with ensureBound after b/15704674. |
| public boolean isBound() { |
| return mBinding.isBound(); |
| } |
| |
| private FragmentManager getFragmentManagerToUse() { |
| return OsUtil.isAtLeastJB_MR1() ? getChildFragmentManager() : getFragmentManager(); |
| } |
| |
| public MediaPicker getMediaPicker() { |
| return (MediaPicker) getFragmentManagerToUse().findFragmentByTag( |
| MediaPicker.FRAGMENT_TAG); |
| } |
| |
| @Override |
| public void sendMessage(final MessageData message) { |
| if (isReadyForAction()) { |
| if (ensureKnownRecipients()) { |
| // Merge the caption text from attachments into the text body of the messages |
| message.consolidateText(); |
| |
| mBinding.getData().sendMessage(mBinding, message); |
| mComposeMessageView.resetMediaPickerState(); |
| } else { |
| LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: conv participants not loaded"); |
| } |
| } else { |
| warnOfMissingActionConditions(true /*sending*/, |
| new Runnable() { |
| @Override |
| public void run() { |
| sendMessage(message); |
| } |
| }); |
| } |
| } |
| |
| public void setHost(final ConversationFragmentHost host) { |
| mHost = host; |
| } |
| |
| public String getConversationName() { |
| return mBinding.getData().getConversationName(); |
| } |
| |
| @Override |
| public void onComposeEditTextFocused() { |
| mHost.onStartComposeMessage(); |
| } |
| |
| @Override |
| public void onAttachmentsCleared() { |
| // When attachments are removed, reset transient media picker state such as image selection. |
| mComposeMessageView.resetMediaPickerState(); |
| } |
| |
| /** |
| * Called to check if all conditions are nominal and a "go" for some action, such as deleting |
| * a message, that requires this app to be the default app. This is also a precondition |
| * required for sending a draft. |
| * @return true if all conditions are nominal and we're ready to send a message |
| */ |
| @Override |
| public boolean isReadyForAction() { |
| return UiUtils.isReadyForAction(); |
| } |
| |
| /** |
| * When there's some condition that prevents an operation, such as sending a message, |
| * call warnOfMissingActionConditions to put up a snackbar and allow the user to repair |
| * that condition. |
| * @param sending - true if we're called during a sending operation |
| * @param commandToRunAfterActionConditionResolved - a runnable to run after the user responds |
| * positively to the condition prompt and resolves the condition. If null, |
| * the user will be shown a toast to tap the send button again. |
| */ |
| @Override |
| public void warnOfMissingActionConditions(final boolean sending, |
| final Runnable commandToRunAfterActionConditionResolved) { |
| if (mChangeDefaultSmsAppHelper == null) { |
| mChangeDefaultSmsAppHelper = new ChangeDefaultSmsAppHelper(); |
| } |
| mChangeDefaultSmsAppHelper.warnOfMissingActionConditions(sending, |
| commandToRunAfterActionConditionResolved, mComposeMessageView, |
| getView().getRootView(), |
| getActivity(), this); |
| } |
| |
| private boolean ensureKnownRecipients() { |
| final ConversationData conversationData = mBinding.getData(); |
| |
| if (!conversationData.getParticipantsLoaded()) { |
| // We can't tell yet whether or not we have an unknown recipient |
| return false; |
| } |
| |
| final ConversationParticipantsData participants = conversationData.getParticipants(); |
| for (final ParticipantData participant : participants) { |
| |
| |
| if (participant.isUnknownSender()) { |
| UiUtils.showToast(R.string.unknown_sender); |
| return false; |
| } |
| } |
| |
| return true; |
| } |
| |
| public void retryDownload(final String messageId) { |
| if (isReadyForAction()) { |
| mBinding.getData().downloadMessage(mBinding, messageId); |
| } else { |
| warnOfMissingActionConditions(false /*sending*/, |
| null /*commandToRunAfterActionConditionResolved*/); |
| } |
| } |
| |
| public void retrySend(final String messageId) { |
| if (isReadyForAction()) { |
| if (ensureKnownRecipients()) { |
| mBinding.getData().resendMessage(mBinding, messageId); |
| } |
| } else { |
| warnOfMissingActionConditions(true /*sending*/, |
| new Runnable() { |
| @Override |
| public void run() { |
| retrySend(messageId); |
| } |
| |
| }); |
| } |
| } |
| |
| void deleteMessage(final String messageId) { |
| if (isReadyForAction()) { |
| final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()) |
| .setTitle(R.string.delete_message_confirmation_dialog_title) |
| .setMessage(R.string.delete_message_confirmation_dialog_text) |
| .setPositiveButton(R.string.delete_message_confirmation_button, |
| new OnClickListener() { |
| @Override |
| public void onClick(final DialogInterface dialog, final int which) { |
| mBinding.getData().deleteMessage(mBinding, messageId); |
| } |
| }) |
| .setNegativeButton(android.R.string.cancel, null); |
| if (OsUtil.isAtLeastJB_MR1()) { |
| builder.setOnDismissListener(new OnDismissListener() { |
| @Override |
| public void onDismiss(final DialogInterface dialog) { |
| mHost.dismissActionMode(); |
| } |
| }); |
| } else { |
| builder.setOnCancelListener(new OnCancelListener() { |
| @Override |
| public void onCancel(final DialogInterface dialog) { |
| mHost.dismissActionMode(); |
| } |
| }); |
| } |
| builder.create().show(); |
| } else { |
| warnOfMissingActionConditions(false /*sending*/, |
| null /*commandToRunAfterActionConditionResolved*/); |
| mHost.dismissActionMode(); |
| } |
| } |
| |
| public void deleteConversation() { |
| if (isReadyForAction()) { |
| final Context context = getActivity(); |
| mBinding.getData().deleteConversation(mBinding); |
| closeConversation(mConversationId); |
| } else { |
| warnOfMissingActionConditions(false /*sending*/, |
| null /*commandToRunAfterActionConditionResolved*/); |
| } |
| } |
| |
| @Override |
| public void closeConversation(final String conversationId) { |
| if (TextUtils.equals(conversationId, mConversationId)) { |
| mHost.onFinishCurrentConversation(); |
| // TODO: Explicitly transition to ConversationList (or just go back)? |
| } |
| } |
| |
| @Override |
| public void onConversationParticipantDataLoaded(final ConversationData data) { |
| mBinding.ensureBound(data); |
| if (mBinding.getData().getParticipantsLoaded()) { |
| final boolean oneOnOne = mBinding.getData().getOtherParticipant() != null; |
| mAdapter.setOneOnOne(oneOnOne, true /* invalidate */); |
| |
| // refresh the options menu which will enable the "people & options" item. |
| invalidateOptionsMenu(); |
| |
| mHost.invalidateActionBar(); |
| |
| mRecyclerView.setVisibility(View.VISIBLE); |
| mHost.onConversationParticipantDataLoaded |
| (mBinding.getData().getNumberOfParticipantsExcludingSelf()); |
| } |
| } |
| |
| @Override |
| public void onSubscriptionListDataLoaded(final ConversationData data) { |
| mBinding.ensureBound(data); |
| mAdapter.notifyDataSetChanged(); |
| } |
| |
| @Override |
| public void promptForSelfPhoneNumber() { |
| if (mComposeMessageView != null) { |
| // Avoid bug in system which puts soft keyboard over dialog after orientation change |
| ImeUtil.hideSoftInput(getActivity(), mComposeMessageView); |
| } |
| |
| final FragmentTransaction ft = getActivity().getFragmentManager().beginTransaction(); |
| final EnterSelfPhoneNumberDialog dialog = EnterSelfPhoneNumberDialog |
| .newInstance(getConversationSelfSubId()); |
| dialog.setTargetFragment(this, 0/*requestCode*/); |
| dialog.show(ft, null/*tag*/); |
| } |
| |
| @Override |
| public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { |
| if (mChangeDefaultSmsAppHelper == null) { |
| mChangeDefaultSmsAppHelper = new ChangeDefaultSmsAppHelper(); |
| } |
| mChangeDefaultSmsAppHelper.handleChangeDefaultSmsResult(requestCode, resultCode, null); |
| } |
| |
| public boolean hasMessages() { |
| return mAdapter != null && mAdapter.getItemCount() > 0; |
| } |
| |
| public boolean onBackPressed() { |
| if (mComposeMessageView.onBackPressed()) { |
| return true; |
| } |
| return false; |
| } |
| |
| public boolean onNavigationUpPressed() { |
| return mComposeMessageView.onNavigationUpPressed(); |
| } |
| |
| @Override |
| public boolean onAttachmentClick(final ConversationMessageView messageView, |
| final MessagePartData attachment, final Rect imageBounds, final boolean longPress) { |
| if (longPress) { |
| selectMessage(messageView, attachment); |
| return true; |
| } else if (messageView.getData().getOneClickResendMessage()) { |
| handleMessageClick(messageView); |
| return true; |
| } |
| |
| if (attachment.isImage()) { |
| displayPhoto(attachment.getContentUri(), imageBounds, false /* isDraft */); |
| } |
| |
| if (attachment.isVCard()) { |
| UIIntents.get().launchVCardDetailActivity(getActivity(), attachment.getContentUri()); |
| } |
| |
| return false; |
| } |
| |
| private void handleMessageClick(final ConversationMessageView messageView) { |
| if (messageView != mSelectedMessage) { |
| final ConversationMessageData data = messageView.getData(); |
| final boolean isReadyToSend = isReadyForAction(); |
| if (data.getOneClickResendMessage()) { |
| // Directly resend the message on tap if it's failed |
| retrySend(data.getMessageId()); |
| selectMessage(null); |
| } else if (data.getShowResendMessage() && isReadyToSend) { |
| // Select the message to show the resend/download/delete options |
| selectMessage(messageView); |
| } else if (data.getShowDownloadMessage() && isReadyToSend) { |
| // Directly download the message on tap |
| retryDownload(data.getMessageId()); |
| } else { |
| // Let the toast from warnOfMissingActionConditions show and skip |
| // selecting |
| warnOfMissingActionConditions(false /*sending*/, |
| null /*commandToRunAfterActionConditionResolved*/); |
| selectMessage(null); |
| } |
| } else { |
| selectMessage(null); |
| } |
| } |
| |
| private static class AttachmentToSave { |
| public final Uri uri; |
| public final String contentType; |
| public Uri persistedUri; |
| |
| AttachmentToSave(final Uri uri, final String contentType) { |
| this.uri = uri; |
| this.contentType = contentType; |
| } |
| } |
| |
| public static class SaveAttachmentTask extends SafeAsyncTask<Void, Void, Void> { |
| private final Context mContext; |
| private final List<AttachmentToSave> mAttachmentsToSave = new ArrayList<>(); |
| |
| public SaveAttachmentTask(final Context context, final Uri contentUri, |
| final String contentType) { |
| mContext = context; |
| addAttachmentToSave(contentUri, contentType); |
| } |
| |
| public SaveAttachmentTask(final Context context) { |
| mContext = context; |
| } |
| |
| public void addAttachmentToSave(final Uri contentUri, final String contentType) { |
| mAttachmentsToSave.add(new AttachmentToSave(contentUri, contentType)); |
| } |
| |
| public int getAttachmentCount() { |
| return mAttachmentsToSave.size(); |
| } |
| |
| @Override |
| protected Void doInBackgroundTimed(final Void... arg) { |
| final File appDir = new File(Environment.getExternalStoragePublicDirectory( |
| Environment.DIRECTORY_PICTURES), |
| mContext.getResources().getString(R.string.app_name)); |
| final File downloadDir = Environment.getExternalStoragePublicDirectory( |
| Environment.DIRECTORY_DOWNLOADS); |
| for (final AttachmentToSave attachment : mAttachmentsToSave) { |
| final boolean isImageOrVideo = ContentType.isImageType(attachment.contentType) |
| || ContentType.isVideoType(attachment.contentType); |
| attachment.persistedUri = UriUtil.persistContent(attachment.uri, |
| isImageOrVideo ? appDir : downloadDir, attachment.contentType); |
| } |
| return null; |
| } |
| |
| @Override |
| protected void onPostExecute(final Void result) { |
| int failCount = 0; |
| int imageCount = 0; |
| int videoCount = 0; |
| int otherCount = 0; |
| for (final AttachmentToSave attachment : mAttachmentsToSave) { |
| if (attachment.persistedUri == null) { |
| failCount++; |
| continue; |
| } |
| |
| // Inform MediaScanner about the new file |
| final Intent scanFileIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); |
| scanFileIntent.setData(attachment.persistedUri); |
| mContext.sendBroadcast(scanFileIntent); |
| |
| if (ContentType.isImageType(attachment.contentType)) { |
| imageCount++; |
| } else if (ContentType.isVideoType(attachment.contentType)) { |
| videoCount++; |
| } else { |
| otherCount++; |
| // Inform DownloadManager of the file so it will show in the "downloads" app |
| final DownloadManager downloadManager = |
| (DownloadManager) mContext.getSystemService( |
| Context.DOWNLOAD_SERVICE); |
| final String filePath = attachment.persistedUri.getPath(); |
| final File file = new File(filePath); |
| |
| if (file.exists()) { |
| downloadManager.addCompletedDownload( |
| file.getName() /* title */, |
| mContext.getString( |
| R.string.attachment_file_description) /* description */, |
| true /* isMediaScannerScannable */, |
| attachment.contentType, |
| file.getAbsolutePath(), |
| file.length(), |
| false /* showNotification */); |
| } |
| } |
| } |
| |
| String message; |
| if (failCount > 0) { |
| message = mContext.getResources().getQuantityString( |
| R.plurals.attachment_save_error, failCount, failCount); |
| } else { |
| int messageId = R.plurals.attachments_saved; |
| if (otherCount > 0) { |
| if (imageCount + videoCount == 0) { |
| messageId = R.plurals.attachments_saved_to_downloads; |
| } |
| } else { |
| if (videoCount == 0) { |
| messageId = R.plurals.photos_saved_to_album; |
| } else if (imageCount == 0) { |
| messageId = R.plurals.videos_saved_to_album; |
| } else { |
| messageId = R.plurals.attachments_saved_to_album; |
| } |
| } |
| final String appName = mContext.getResources().getString(R.string.app_name); |
| final int count = imageCount + videoCount + otherCount; |
| message = mContext.getResources().getQuantityString( |
| messageId, count, count, appName); |
| } |
| UiUtils.showToastAtBottom(message); |
| } |
| } |
| |
| private void invalidateOptionsMenu() { |
| final Activity activity = getActivity(); |
| // TODO: Add the supportInvalidateOptionsMenu call to the host activity. |
| if (activity == null || !(activity instanceof BugleActionBarActivity)) { |
| return; |
| } |
| ((BugleActionBarActivity) activity).supportInvalidateOptionsMenu(); |
| } |
| |
| @Override |
| public void setOptionsMenuVisibility(final boolean visible) { |
| setHasOptionsMenu(visible); |
| } |
| |
| @Override |
| public int getConversationSelfSubId() { |
| final String selfParticipantId = mComposeMessageView.getConversationSelfId(); |
| final ParticipantData self = mBinding.getData().getSelfParticipantById(selfParticipantId); |
| // If the self id or the self participant data hasn't been loaded yet, fallback to |
| // the default setting. |
| return self == null ? ParticipantData.DEFAULT_SELF_SUB_ID : self.getSubId(); |
| } |
| |
| @Override |
| public void invalidateActionBar() { |
| mHost.invalidateActionBar(); |
| } |
| |
| @Override |
| public void dismissActionMode() { |
| mHost.dismissActionMode(); |
| } |
| |
| @Override |
| public void selectSim(final SubscriptionListEntry subscriptionData) { |
| mComposeMessageView.selectSim(subscriptionData); |
| mHost.onStartComposeMessage(); |
| } |
| |
| @Override |
| public void onStartComposeMessage() { |
| mHost.onStartComposeMessage(); |
| } |
| |
| @Override |
| public SubscriptionListEntry getSubscriptionEntryForSelfParticipant( |
| final String selfParticipantId, final boolean excludeDefault) { |
| // TODO: ConversationMessageView is the only one using this. We should probably |
| // inject this into the view during binding in the ConversationMessageAdapter. |
| return mBinding.getData().getSubscriptionEntryForSelfParticipant(selfParticipantId, |
| excludeDefault); |
| } |
| |
| @Override |
| public SimSelectorView getSimSelectorView() { |
| return (SimSelectorView) getView().findViewById(R.id.sim_selector); |
| } |
| |
| @Override |
| public MediaPicker createMediaPicker() { |
| return new MediaPicker(getActivity()); |
| } |
| |
| @Override |
| public void notifyOfAttachmentLoadFailed() { |
| UiUtils.showToastAtBottom(R.string.attachment_load_failed_dialog_message); |
| } |
| |
| @Override |
| public void warnOfExceedingMessageLimit(final boolean sending, final boolean tooManyVideos) { |
| warnOfExceedingMessageLimit(sending, mComposeMessageView, mConversationId, |
| getActivity(), tooManyVideos); |
| } |
| |
| public static void warnOfExceedingMessageLimit(final boolean sending, |
| final ComposeMessageView composeMessageView, final String conversationId, |
| final Activity activity, final boolean tooManyVideos) { |
| final AlertDialog.Builder builder = |
| new AlertDialog.Builder(activity) |
| .setTitle(R.string.mms_attachment_limit_reached); |
| |
| if (sending) { |
| if (tooManyVideos) { |
| builder.setMessage(R.string.video_attachment_limit_exceeded_when_sending); |
| } else { |
| builder.setMessage(R.string.attachment_limit_reached_dialog_message_when_sending) |
| .setNegativeButton(R.string.attachment_limit_reached_send_anyway, |
| new OnClickListener() { |
| @Override |
| public void onClick(final DialogInterface dialog, |
| final int which) { |
| composeMessageView.sendMessageIgnoreMessageSizeLimit(); |
| } |
| }); |
| } |
| builder.setPositiveButton(android.R.string.ok, new OnClickListener() { |
| @Override |
| public void onClick(final DialogInterface dialog, final int which) { |
| showAttachmentChooser(conversationId, activity); |
| }}); |
| } else { |
| builder.setMessage(R.string.attachment_limit_reached_dialog_message_when_composing) |
| .setPositiveButton(android.R.string.ok, null); |
| } |
| builder.show(); |
| } |
| |
| @Override |
| public void showAttachmentChooser() { |
| showAttachmentChooser(mConversationId, getActivity()); |
| } |
| |
| public static void showAttachmentChooser(final String conversationId, |
| final Activity activity) { |
| UIIntents.get().launchAttachmentChooserActivity(activity, |
| conversationId, REQUEST_CHOOSE_ATTACHMENTS); |
| } |
| |
| private void updateActionAndStatusBarColor(final ActionBar actionBar) { |
| final int themeColor = ConversationDrawables.get().getConversationThemeColor(); |
| actionBar.setBackgroundDrawable(new ColorDrawable(themeColor)); |
| UiUtils.setStatusBarColor(getActivity(), themeColor); |
| } |
| |
| public void updateActionBar(final ActionBar actionBar) { |
| if (mComposeMessageView == null || !mComposeMessageView.updateActionBar(actionBar)) { |
| updateActionAndStatusBarColor(actionBar); |
| // We update this regardless of whether or not the action bar is showing so that we |
| // don't get a race when it reappears. |
| actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_CUSTOM); |
| actionBar.setDisplayHomeAsUpEnabled(true); |
| // Reset the back arrow to its default |
| actionBar.setHomeAsUpIndicator(0); |
| View customView = actionBar.getCustomView(); |
| if (customView == null || customView.getId() != R.id.conversation_title_container) { |
| final LayoutInflater inflator = (LayoutInflater) |
| getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE); |
| customView = inflator.inflate(R.layout.action_bar_conversation_name, null); |
| customView.setOnClickListener(new View.OnClickListener() { |
| @Override |
| public void onClick(final View v) { |
| onBackPressed(); |
| } |
| }); |
| actionBar.setCustomView(customView); |
| } |
| |
| final TextView conversationNameView = |
| (TextView) customView.findViewById(R.id.conversation_title); |
| final String conversationName = getConversationName(); |
| if (!TextUtils.isEmpty(conversationName)) { |
| // RTL : To format conversation title if it happens to be phone numbers. |
| final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); |
| final String formattedName = bidiFormatter.unicodeWrap( |
| UiUtils.commaEllipsize( |
| conversationName, |
| conversationNameView.getPaint(), |
| conversationNameView.getWidth(), |
| getString(R.string.plus_one), |
| getString(R.string.plus_n)).toString(), |
| TextDirectionHeuristicsCompat.LTR); |
| conversationNameView.setText(formattedName); |
| // In case phone numbers are mixed in the conversation name, we need to vocalize it. |
| final String vocalizedConversationName = |
| AccessibilityUtil.getVocalizedPhoneNumber(getResources(), conversationName); |
| conversationNameView.setContentDescription(vocalizedConversationName); |
| getActivity().setTitle(conversationName); |
| } else { |
| final String appName = getString(R.string.app_name); |
| conversationNameView.setText(appName); |
| getActivity().setTitle(appName); |
| } |
| |
| // When conversation is showing and media picker is not showing, then hide the action |
| // bar only when we are in landscape mode, with IME open. |
| if (mHost.isImeOpen() && UiUtils.isLandscapeMode()) { |
| actionBar.hide(); |
| } else { |
| actionBar.show(); |
| } |
| } |
| } |
| |
| @Override |
| public boolean shouldShowSubjectEditor() { |
| return true; |
| } |
| |
| @Override |
| public boolean shouldHideAttachmentsWhenSimSelectorShown() { |
| return false; |
| } |
| |
| @Override |
| public void showHideSimSelector(final boolean show) { |
| // no-op for now |
| } |
| |
| @Override |
| public int getSimSelectorItemLayoutId() { |
| return R.layout.sim_selector_item_view; |
| } |
| |
| @Override |
| public Uri getSelfSendButtonIconUri() { |
| return null; // use default button icon uri |
| } |
| |
| @Override |
| public int overrideCounterColor() { |
| return -1; // don't override the color |
| } |
| |
| @Override |
| public void onAttachmentsChanged(final boolean haveAttachments) { |
| // no-op for now |
| } |
| |
| @Override |
| public void onDraftChanged(final DraftMessageData data, final int changeFlags) { |
| mDraftMessageDataModel.ensureBound(data); |
| // We're specifically only interested in ATTACHMENTS_CHANGED from the widget. Ignore |
| // other changes. When the widget changes an attachment, we need to reload the draft. |
| if (changeFlags == |
| (DraftMessageData.WIDGET_CHANGED | DraftMessageData.ATTACHMENTS_CHANGED)) { |
| mClearLocalDraft = true; // force a reload of the draft in onResume |
| } |
| } |
| |
| @Override |
| public void onDraftAttachmentLimitReached(final DraftMessageData data) { |
| // no-op for now |
| } |
| |
| @Override |
| public void onDraftAttachmentLoadFailed() { |
| // no-op for now |
| } |
| |
| @Override |
| public int getAttachmentsClearedFlags() { |
| return DraftMessageData.ATTACHMENTS_CHANGED; |
| } |
| } |