/* * 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 androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.core.text.BidiFormatter; import androidx.core.text.TextDirectionHeuristicsCompat; import androidx.appcompat.app.ActionBar; import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.ViewHolder; import android.telephony.PhoneNumberUtils; 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 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 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 (isBound() && 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 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 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 mAddAnimations = new ArrayList(); 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); dispatchAddFinished(holder); } }); 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); // Can't make a call to emergency numbers using ACTION_CALL. if (PhoneNumberUtils.isEmergencyNumber(phoneNumber)) { UiUtils.showToast(R.string.disallow_emergency_call); } else { 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 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 { private final Context mContext; private final List 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; } }