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 { 121 void onStartComposeMessage(); 122 void onConversationMetadataUpdated(); 123 boolean shouldResumeComposeMessage(); 124 void onFinishCurrentConversation(); 125 void invalidateActionBar(); 126 ActionMode startActionMode(ActionMode.Callback callback); 127 void dismissActionMode(); 128 ActionMode getActionMode(); 129 void onConversationMessagesUpdated(int numberOfMessages); 130 void onConversationParticipantDataLoaded(int numberOfParticipants); 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 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 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 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 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 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 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 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 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 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 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 659 public void displayPhoto(final Uri photoUri, final Rect imageBounds, final boolean isDraft) { 660 displayPhoto(photoUri, imageBounds, isDraft, mConversationId, getActivity()); 661 } 662 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 672 private void selectMessage(final ConversationMessageView messageView) { 673 selectMessage(messageView, null /* attachment */); 674 } 675 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 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 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 728 void setConversationFocus() { 729 if (mHost.isActiveAndFocused()) { 730 mBinding.getData().setFocus(); 731 } 732 } 733 734 @Override 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 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 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 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 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 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 975 void suppressWriteDraft() { 976 mSuppressWriteDraft = true; 977 } 978 979 @Override 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 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. 1000 public boolean isBound() { 1001 return mBinding.isBound(); 1002 } 1003 1004 private FragmentManager getFragmentManagerToUse() { 1005 return OsUtil.isAtLeastJB_MR1() ? getChildFragmentManager() : getFragmentManager(); 1006 } 1007 1008 public MediaPicker getMediaPicker() { 1009 return (MediaPicker) getFragmentManagerToUse().findFragmentByTag( 1010 MediaPicker.FRAGMENT_TAG); 1011 } 1012 1013 @Override 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 1036 public void setHost(final ConversationFragmentHost host) { 1037 mHost = host; 1038 } 1039 1040 public String getConversationName() { 1041 return mBinding.getData().getConversationName(); 1042 } 1043 1044 @Override 1045 public void onComposeEditTextFocused() { 1046 mHost.onStartComposeMessage(); 1047 } 1048 1049 @Override 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 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 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 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 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 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 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 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 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 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 1208 public void onSubscriptionListDataLoaded(final ConversationData data) { 1209 mBinding.ensureBound(data); 1210 mAdapter.notifyDataSetChanged(); 1211 } 1212 1213 @Override 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 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 1235 public boolean hasMessages() { 1236 return mAdapter != null && mAdapter.getItemCount() > 0; 1237 } 1238 1239 public boolean onBackPressed() { 1240 if (mComposeMessageView.onBackPressed()) { 1241 return true; 1242 } 1243 return false; 1244 } 1245 1246 public boolean onNavigationUpPressed() { 1247 return mComposeMessageView.onNavigationUpPressed(); 1248 } 1249 1250 @Override 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 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 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 1313 public SaveAttachmentTask(final Context context, final Uri contentUri, 1314 final String contentType) { 1315 mContext = context; 1316 addAttachmentToSave(contentUri, contentType); 1317 } 1318 1319 public SaveAttachmentTask(final Context context) { 1320 mContext = context; 1321 } 1322 1323 public void addAttachmentToSave(final Uri contentUri, final String contentType) { 1324 mAttachmentsToSave.add(new AttachmentToSave(contentUri, contentType)); 1325 } 1326 1327 public int getAttachmentCount() { 1328 return mAttachmentsToSave.size(); 1329 } 1330 1331 @Override 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 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 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 1429 public void setOptionsMenuVisibility(final boolean visible) { 1430 setHasOptionsMenu(visible); 1431 } 1432 1433 @Override 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 1443 public void invalidateActionBar() { 1444 mHost.invalidateActionBar(); 1445 } 1446 1447 @Override 1448 public void dismissActionMode() { 1449 mHost.dismissActionMode(); 1450 } 1451 1452 @Override 1453 public void selectSim(final SubscriptionListEntry subscriptionData) { 1454 mComposeMessageView.selectSim(subscriptionData); 1455 mHost.onStartComposeMessage(); 1456 } 1457 1458 @Override 1459 public void onStartComposeMessage() { 1460 mHost.onStartComposeMessage(); 1461 } 1462 1463 @Override 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 1473 public SimSelectorView getSimSelectorView() { 1474 return (SimSelectorView) getView().findViewById(R.id.sim_selector); 1475 } 1476 1477 @Override 1478 public MediaPicker createMediaPicker() { 1479 return new MediaPicker(getActivity()); 1480 } 1481 1482 @Override 1483 public void notifyOfAttachmentLoadFailed() { 1484 UiUtils.showToastAtBottom(R.string.attachment_load_failed_dialog_message); 1485 } 1486 1487 @Override 1488 public void warnOfExceedingMessageLimit(final boolean sending, final boolean tooManyVideos) { 1489 warnOfExceedingMessageLimit(sending, mComposeMessageView, mConversationId, 1490 getActivity(), tooManyVideos); 1491 } 1492 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 1527 public void showAttachmentChooser() { 1528 showAttachmentChooser(mConversationId, getActivity()); 1529 } 1530 1531 public static void showAttachmentChooser(final String conversationId, 1532 final Activity activity) { 1533 UIIntents.get().launchAttachmentChooserActivity(activity, 1534 conversationId, REQUEST_CHOOSE_ATTACHMENTS); 1535 } 1536 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 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 1603 public boolean shouldShowSubjectEditor() { 1604 return true; 1605 } 1606 1607 @Override 1608 public boolean shouldHideAttachmentsWhenSimSelectorShown() { 1609 return false; 1610 } 1611 1612 @Override 1613 public void showHideSimSelector(final boolean show) { 1614 // no-op for now 1615 } 1616 1617 @Override 1618 public int getSimSelectorItemLayoutId() { 1619 return R.layout.sim_selector_item_view; 1620 } 1621 1622 @Override 1623 public Uri getSelfSendButtonIconUri() { 1624 return null; // use default button icon uri 1625 } 1626 1627 @Override 1628 public int overrideCounterColor() { 1629 return -1; // don't override the color 1630 } 1631 1632 @Override 1633 public void onAttachmentsChanged(final boolean haveAttachments) { 1634 // no-op for now 1635 } 1636 1637 @Override 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 1649 public void onDraftAttachmentLimitReached(final DraftMessageData data) { 1650 // no-op for now 1651 } 1652 1653 @Override 1654 public void onDraftAttachmentLoadFailed() { 1655 // no-op for now 1656 } 1657 1658 @Override 1659 public int getAttachmentsClearedFlags() { 1660 return DraftMessageData.ATTACHMENTS_CHANGED; 1661 } 1662 } 1663