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