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