diff options
Diffstat (limited to 'src/com/android/messaging/ui/conversation/ConversationFragment.java')
-rw-r--r-- | src/com/android/messaging/ui/conversation/ConversationFragment.java | 1662 |
1 files changed, 1662 insertions, 0 deletions
diff --git a/src/com/android/messaging/ui/conversation/ConversationFragment.java b/src/com/android/messaging/ui/conversation/ConversationFragment.java new file mode 100644 index 0000000..a6a191a --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationFragment.java @@ -0,0 +1,1662 @@ +/* + * 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; + } +} |