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