package com.moez.QKSMS.ui.messagelist; import android.content.Intent; import android.content.SharedPreferences; import android.content.res.Resources; import android.database.Cursor; import android.graphics.PorterDuff; import android.graphics.Typeface; import android.net.Uri; import android.os.Handler; import android.provider.ContactsContract; import android.provider.Telephony; import android.provider.Telephony.TextBasedSmsColumns; import android.telephony.TelephonyManager; import android.text.SpannableString; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.text.style.StyleSpan; import android.text.style.UnderlineSpan; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.RelativeLayout; import com.android.mms.transaction.Transaction; import com.android.mms.transaction.TransactionBundle; import com.android.mms.transaction.TransactionService; import com.android.mms.util.DownloadManager; import com.google.android.mms.ContentType; import com.google.android.mms.pdu_alt.PduHeaders; import com.koushikdutta.ion.Ion; import com.moez.QKSMS.QKSMSApp; import com.moez.QKSMS.R; import com.moez.QKSMS.common.LiveViewManager; import com.moez.QKSMS.common.emoji.EmojiRegistry; import com.moez.QKSMS.common.utils.CursorUtils; import com.moez.QKSMS.common.utils.LinkifyUtils; import com.moez.QKSMS.common.utils.MessageUtils; import com.moez.QKSMS.data.Contact; import com.moez.QKSMS.enums.QKPreference; import com.moez.QKSMS.transaction.SmsHelper; import com.moez.QKSMS.ui.ThemeManager; import com.moez.QKSMS.ui.base.QKActivity; import com.moez.QKSMS.ui.base.RecyclerCursorAdapter; import com.moez.QKSMS.ui.mms.MmsThumbnailPresenter; import com.moez.QKSMS.ui.settings.SettingsFragment; import com.moez.QKSMS.ui.view.AvatarView; import ezvcard.Ezvcard; import ezvcard.VCard; import java.util.ArrayList; import java.util.regex.Matcher; import java.util.regex.Pattern; public class MessageListAdapter extends RecyclerCursorAdapter<MessageListViewHolder, MessageItem> { private final String TAG = "MessageListAdapter"; public static final int INCOMING_ITEM = 0; public static final int OUTGOING_ITEM = 1; private ArrayList<Long> mSelectedConversations = new ArrayList<>(); private static final Pattern urlPattern = Pattern.compile( "\\b(https?:\\/\\/\\S+(?:png|jpe?g|gif)\\S*)\\b", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL); private MessageItemCache mMessageItemCache; private MessageColumns.ColumnsMap mColumnsMap; private final Resources mRes; private final SharedPreferences mPrefs; // Configuration options. private long mThreadId = -1; private long mRowId = -1; private Pattern mSearchHighlighter = null; private boolean mIsGroupConversation = false; private Handler mMessageListItemHandler = null; // TODO this isn't quite the same as the others private String mSelection = null; public MessageListAdapter(QKActivity context) { super(context); mRes = mContext.getResources(); mPrefs = mContext.getPrefs(); } protected MessageItem getItem(int position) { mCursor.moveToPosition(position); String type = mCursor.getString(mColumnsMap.mColumnMsgType); long msgId = mCursor.getLong(mColumnsMap.mColumnMsgId); return mMessageItemCache.get(type, msgId, mCursor); } public Cursor getCursorForItem(MessageItem item) { if (CursorUtils.isValid(mCursor) && mCursor.moveToFirst()) { do { long id = mCursor.getLong(mColumnsMap.mColumnMsgId); String type = mCursor.getString(mColumnsMap.mColumnMsgType); if (id == item.mMsgId && type != null && type.equals(item.mType)) { return mCursor; } } while (mCursor.moveToNext()); } return null; } public MessageColumns.ColumnsMap getColumnsMap() { return mColumnsMap; } public void setIsGroupConversation(boolean b) { mIsGroupConversation = b; } @Override public MessageListViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { LayoutInflater inflater = LayoutInflater.from(mContext); int resource; boolean sent; if (viewType == INCOMING_ITEM) { resource = R.layout.list_item_message_in; sent = false; } else { resource = R.layout.list_item_message_out; sent = true; } View view = inflater.inflate(resource, parent, false); return setupViewHolder(view, sent); } private MessageListViewHolder setupViewHolder(View view, boolean sent) { MessageListViewHolder holder = new MessageListViewHolder(mContext, view); if (sent) { // set up colors holder.mBodyTextView.setOnColorBackground(ThemeManager.getSentBubbleColor() != ThemeManager.getNeutralBubbleColor()); holder.mDateView.setOnColorBackground(false); holder.mDeliveredIndicator.setColorFilter(ThemeManager.getTextOnBackgroundSecondary(), PorterDuff.Mode.SRC_ATOP); holder.mLockedIndicator.setColorFilter(ThemeManager.getTextOnBackgroundSecondary(), PorterDuff.Mode.SRC_ATOP); // set up avatar holder.mAvatarView.setImageDrawable(Contact.getMe(true).getAvatar(mContext, null)); holder.mAvatarView.setContactName(AvatarView.ME); holder.mAvatarView.assignContactUri(ContactsContract.Profile.CONTENT_URI); if (mPrefs.getBoolean(SettingsFragment.HIDE_AVATAR_SENT, true)) { ((RelativeLayout.LayoutParams) holder.mMessageBlock.getLayoutParams()).setMargins(0, 0, 0, 0); holder.mAvatarView.setVisibility(View.GONE); } } else { // set up colors holder.mBodyTextView.setOnColorBackground(ThemeManager.getReceivedBubbleColor() != ThemeManager.getNeutralBubbleColor()); holder.mDateView.setOnColorBackground(false); holder.mDeliveredIndicator.setColorFilter(ThemeManager.getTextOnBackgroundSecondary(), PorterDuff.Mode.SRC_ATOP); holder.mLockedIndicator.setColorFilter(ThemeManager.getTextOnBackgroundSecondary(), PorterDuff.Mode.SRC_ATOP); // set up avatar if (mPrefs.getBoolean(SettingsFragment.HIDE_AVATAR_RECEIVED, false)) { ((RelativeLayout.LayoutParams) holder.mMessageBlock.getLayoutParams()).setMargins(0, 0, 0, 0); holder.mAvatarView.setVisibility(View.GONE); } } LiveViewManager.registerView(QKPreference.BACKGROUND, this, key -> { holder.mRoot.setBackgroundDrawable(ThemeManager.getRippleBackground()); holder.mSlideShowButton.setBackgroundDrawable(ThemeManager.getRippleBackground()); holder.mMmsView.getForeground().setColorFilter(ThemeManager.getBackgroundColor(), PorterDuff.Mode.SRC_ATOP); }); return holder; } @Override public void onBindViewHolder(MessageListViewHolder holder, int position) { MessageItem messageItem = getItem(position); holder.mData = messageItem; holder.mContext = mContext; holder.mClickListener = mItemClickListener; holder.mRoot.setOnClickListener(holder); holder.mRoot.setOnLongClickListener(holder); holder.mPresenter = null; // Here we're avoiding reseting the avatar to the empty avatar when we're rebinding // to the same item. This happens when there's a DB change which causes the message item // cache in the MessageListAdapter to get cleared. When an mms MessageItem is newly // created, it has no info in it except the message id. The info is eventually loaded // and bindCommonMessage is called again (see onPduLoaded below). When we haven't loaded // the pdu, we don't want to call updateContactView because it // will set the avatar to the generic avatar then when this method is called again // from onPduLoaded, it will reset to the real avatar. This test is to avoid that flash. boolean pduLoaded = messageItem.isSms() || messageItem.mSlideshow != null; bindGrouping(holder, messageItem); bindTimestamp(holder, messageItem); if (pduLoaded) { bindAvatar(holder, messageItem); } bindMmsView(holder, messageItem); bindBody(holder, messageItem); bindIndicators(holder, messageItem); bindVcard(holder, messageItem); if (messageItem.mMessageType == PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND) { bindNotifInd(holder, messageItem); } else { if (holder.mDownloadButton != null) { holder.mDownloadButton.setVisibility(View.GONE); holder.mDownloadingLabel.setVisibility(View.GONE); } } } /** * Binds a MessageItem that hasn't been downloaded yet */ private void bindNotifInd(final MessageListViewHolder holder, final MessageItem messageItem) { holder.showMmsView(false); switch (messageItem.getMmsDownloadStatus()) { case DownloadManager.STATE_PRE_DOWNLOADING: case DownloadManager.STATE_DOWNLOADING: showDownloadingAttachment(holder); break; case DownloadManager.STATE_UNKNOWN: case DownloadManager.STATE_UNSTARTED: DownloadManager downloadManager = DownloadManager.getInstance(); boolean autoDownload = downloadManager.isAuto(); boolean dataSuspended = (QKSMSApp.getApplication().getTelephonyManager() .getDataState() == TelephonyManager.DATA_SUSPENDED); // If we're going to automatically start downloading the mms attachment, then // don't bother showing the download button for an instant before the actual // download begins. Instead, show downloading as taking place. if (autoDownload && !dataSuspended) { showDownloadingAttachment(holder); break; } case DownloadManager.STATE_TRANSIENT_FAILURE: case DownloadManager.STATE_PERMANENT_FAILURE: case DownloadManager.STATE_SKIP_RETRYING: default: holder.inflateDownloadControls(); holder.mDownloadingLabel.setVisibility(View.GONE); holder.mDownloadButton.setVisibility(View.VISIBLE); holder.mDownloadButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { holder.mDownloadingLabel.setVisibility(View.VISIBLE); holder.mDownloadButton.setVisibility(View.GONE); Intent intent = new Intent(mContext, TransactionService.class); intent.putExtra(TransactionBundle.URI, messageItem.mMessageUri.toString()); intent.putExtra(TransactionBundle.TRANSACTION_TYPE, Transaction.RETRIEVE_TRANSACTION); mContext.startService(intent); DownloadManager.getInstance().markState(messageItem.mMessageUri, DownloadManager.STATE_PRE_DOWNLOADING); } }); break; } // Hide the indicators. holder.mLockedIndicator.setVisibility(View.GONE); holder.mDeliveredIndicator.setVisibility(View.GONE); holder.mDetailsIndicator.setVisibility(View.GONE); } private void showDownloadingAttachment(MessageListViewHolder holder) { holder.inflateDownloadControls(); holder.mDownloadingLabel.setVisibility(View.VISIBLE); holder.mDownloadButton.setVisibility(View.GONE); } private boolean shouldShowTimestamp(MessageItem messageItem, int position) { if (position == mCursor.getCount() - 1) { return true; } MessageItem messageItem2 = getItem(position + 1); if (mPrefs.getBoolean(SettingsFragment.FORCE_TIMESTAMPS, false)) { return true; } else if (messageItem.mDeliveryStatus != MessageItem.DeliveryStatus.NONE) { return true; } else if (messageItem.isFailedMessage()) { return true; } else if (messageItem.isSending()) { return true; } else if (messagesFromDifferentPeople(messageItem, messageItem2)) { return true; } else { int MAX_DURATION = Integer.parseInt(mPrefs.getString(SettingsFragment.SHOW_NEW_TIMESTAMP_DELAY, "5")) * 60 * 1000; return (messageItem2.mDate - messageItem.mDate >= MAX_DURATION); } } private boolean shouldShowAvatar(MessageItem messageItem, int position) { if (position == 0) { return true; } MessageItem messageItem2 = getItem(position - 1); if (messagesFromDifferentPeople(messageItem, messageItem2)) { // If the messages are from different people, then we don't care about any of the other checks, // we need to show the avatar/timestamp. This is used for group chats, which is why we want // both to be incoming messages return true; } else { int MAX_DURATION = 60 * 60 * 1000; return (messageItem.getBoxId() != messageItem2.getBoxId() || messageItem.mDate - messageItem2.mDate >= MAX_DURATION); } } private boolean messagesFromDifferentPeople(MessageItem a, MessageItem b) { return (a.mAddress != null && b.mAddress != null && !a.mAddress.equals(b.mAddress) && !a.isOutgoingMessage( ) && !b.isOutgoingMessage()); } private int getBubbleBackgroundResource(boolean showAvatar, boolean isMine) { if (showAvatar && isMine) return ThemeManager.getSentBubbleRes(); else if (showAvatar && !isMine) return ThemeManager.getReceivedBubbleRes(); else if (!showAvatar && isMine) return ThemeManager.getSentBubbleAltRes(); else if (!showAvatar && !isMine) return ThemeManager.getReceivedBubbleAltRes(); else return -1; } private void bindGrouping(MessageListViewHolder holder, MessageItem messageItem) { int position = mCursor.getPosition(); boolean showAvatar = shouldShowAvatar(messageItem, position); boolean showTimestamp = shouldShowTimestamp(messageItem, position); holder.mDateView.setVisibility(showTimestamp ? View.VISIBLE : View.GONE); holder.mSpace.setVisibility(showAvatar ? View.VISIBLE : View.GONE); holder.mBodyTextView.setBackgroundResource(getBubbleBackgroundResource(showAvatar, messageItem.isMe())); holder.setLiveViewCallback(key -> { if (messageItem.isMe()) { holder.mBodyTextView.getBackground().setColorFilter(ThemeManager.getSentBubbleColor(), PorterDuff.Mode.SRC_ATOP); } else { holder.mBodyTextView.getBackground().setColorFilter(ThemeManager.getReceivedBubbleColor(), PorterDuff.Mode.SRC_ATOP); } }); if (messageItem.isMe() && !mPrefs.getBoolean(SettingsFragment.HIDE_AVATAR_SENT, true)) { holder.mAvatarView.setVisibility(showAvatar ? View.VISIBLE : View.GONE); } else if (!messageItem.isMe() && !mPrefs.getBoolean(SettingsFragment.HIDE_AVATAR_RECEIVED, false)) { holder.mAvatarView.setVisibility(showAvatar ? View.VISIBLE : View.GONE); } } private void bindBody(MessageListViewHolder holder, MessageItem messageItem) { holder.mBodyTextView.setAutoLinkMask(0); SpannableStringBuilder buf = new SpannableStringBuilder(); String body = messageItem.mBody; if (messageItem.mMessageType == PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND) { String msgSizeText = mContext.getString(R.string.message_size_label) + String.valueOf((messageItem.mMessageSize + 1023) / 1024) + mContext.getString(R.string.kilobyte); body = msgSizeText; } // Cleanse the subject String subject = MessageUtils.cleanseMmsSubject(mContext, messageItem.mSubject, body); boolean hasSubject = !TextUtils.isEmpty(subject); if (hasSubject) { buf.append(mContext.getResources().getString(R.string.inline_subject, subject)); } if (!TextUtils.isEmpty(body)) { if (mPrefs.getBoolean(SettingsFragment.AUTO_EMOJI, false)) { body = EmojiRegistry.parseEmojis(body); } buf.append(body); } if (messageItem.mHighlight != null) { Matcher m = messageItem.mHighlight.matcher(buf.toString()); while (m.find()) { buf.setSpan(new StyleSpan(Typeface.BOLD), m.start(), m.end(), 0); } } if (!TextUtils.isEmpty(buf)) { holder.mBodyTextView.setText(buf); Matcher matcher = urlPattern.matcher(holder.mBodyTextView.getText()); if (matcher.find()) { //only find the image to the first link int matchStart = matcher.start(1); int matchEnd = matcher.end(); String imageUrl = buf.subSequence(matchStart, matchEnd).toString(); Ion.with(mContext).load(imageUrl).withBitmap().asBitmap().setCallback((e, result) -> { try { holder.setImage("url_img" + holder.getItemId(), result); holder.mImageView.setOnClickListener(v -> { Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(imageUrl)); mContext.startActivity(i); }); } catch (NullPointerException imageException) { imageException.printStackTrace(); } }); } LinkifyUtils.addLinks(holder.mBodyTextView); } holder.mBodyTextView.setVisibility(TextUtils.isEmpty(buf) ? View.GONE : View.VISIBLE); holder.mBodyTextView.setOnClickListener(v -> holder.mRoot.callOnClick()); holder.mBodyTextView.setOnLongClickListener(v -> holder.mRoot.performLongClick()); } private void bindTimestamp(MessageListViewHolder holder, MessageItem messageItem) { String timestamp; if (messageItem.isSending()) { timestamp = mContext.getString(R.string.status_sending); } else if (messageItem.mTimestamp != null && !messageItem.mTimestamp.equals("")) { timestamp = messageItem.mTimestamp; } else if (messageItem.isOutgoingMessage() && messageItem.isFailedMessage()) { timestamp = mContext.getResources().getString(R.string.status_failed); } else if (messageItem.isMms()) { timestamp = mContext.getString(R.string.loading); } else { timestamp = ""; } if (!mIsGroupConversation || messageItem.isMe() || TextUtils.isEmpty(messageItem.mContact)) { holder.mDateView.setText(timestamp); } else { holder.mDateView.setText(mContext.getString(R.string.message_timestamp_format, timestamp, messageItem.mContact)); } } private void bindAvatar(MessageListViewHolder holder, MessageItem messageItem) { if (!messageItem.isMe()) { Contact contact = Contact.get(messageItem.mAddress, true); holder.mAvatarView.setImageDrawable(contact.getAvatar(mContext, null)); holder.mAvatarView.setContactName(contact.getName()); if (contact.existsInDatabase()) { holder.mAvatarView.assignContactUri(contact.getUri()); } else { holder.mAvatarView.assignContactFromPhone(contact.getNumber(), true); } } } private void bindMmsView(final MessageListViewHolder holder, MessageItem messageItem) { if (messageItem.isSms()) { holder.showMmsView(false); messageItem.setOnPduLoaded(null); } else { if (messageItem.mAttachmentType != SmsHelper.TEXT) { if (holder.mImageView == null) { holder.setImage(null, null); } setImageViewOnClickListener(holder, messageItem); drawPlaybackButton(holder, messageItem); } else { holder.showMmsView(false); } if (messageItem.mSlideshow == null) { messageItem.setOnPduLoaded(messageItem1 -> { if (mCursor == null) { // The pdu has probably loaded after shutting down the fragment. Don't try to bind anything now return; } if (messageItem1 != null && messageItem1.getMessageId() == messageItem1.getMessageId()) { messageItem1.setCachedFormattedMessage(null); bindGrouping(holder, messageItem); bindBody(holder, messageItem); bindTimestamp(holder, messageItem); bindAvatar(holder, messageItem); bindMmsView(holder, messageItem); bindIndicators(holder, messageItem); bindVcard(holder, messageItem); } }); } else { if (holder.mPresenter == null) { holder.mPresenter = new MmsThumbnailPresenter(mContext, holder, messageItem.mSlideshow); } else { holder.mPresenter.setModel(messageItem.mSlideshow); holder.mPresenter.setView(holder); } if (holder.mImageLoadedCallback == null) { holder.mImageLoadedCallback = new MessageListViewHolder.ImageLoadedCallback(holder); } else { holder.mImageLoadedCallback.reset(holder); } holder.mPresenter.present(holder.mImageLoadedCallback); } } } private void bindIndicators(MessageListViewHolder holder, MessageItem messageItem) { // Locked icon if (messageItem.mLocked) { holder.mLockedIndicator.setVisibility(View.VISIBLE); } else { holder.mLockedIndicator.setVisibility(View.GONE); } // Delivery icon - we can show a failed icon for both sms and mms, but for an actual // delivery, we only show the icon for sms. We don't have the information here in mms to // know whether the message has been delivered. For mms, msgItem.mDeliveryStatus set // to MessageItem.DeliveryStatus.RECEIVED simply means the setting requesting a // delivery report was turned on when the message was sent. Yes, it's confusing! if ((messageItem.isOutgoingMessage() && messageItem.isFailedMessage()) || messageItem.mDeliveryStatus == MessageItem.DeliveryStatus.FAILED) { holder.mDeliveredIndicator.setVisibility(View.VISIBLE); } else if (messageItem.isSms() && messageItem.mDeliveryStatus == MessageItem.DeliveryStatus.RECEIVED) { holder.mDeliveredIndicator.setVisibility(View.VISIBLE); } else { holder.mDeliveredIndicator.setVisibility(View.GONE); } // Message details icon - this icon is shown both for sms and mms messages. For mms, // we show the icon if the read report or delivery report setting was set when the // message was sent. Showing the icon tells the user there's more information // by selecting the "View report" menu. if (messageItem.mDeliveryStatus == MessageItem.DeliveryStatus.INFO || messageItem.mReadReport || (messageItem.isMms() && messageItem.mDeliveryStatus == MessageItem.DeliveryStatus.RECEIVED)) { holder.mDetailsIndicator.setVisibility(View.VISIBLE); } else { holder.mDetailsIndicator.setVisibility(View.GONE); } } private void bindVcard(MessageListViewHolder holder, MessageItem messageItem) { if (!ContentType.TEXT_VCARD.equals(messageItem.mTextContentType)) { return; } VCard vCard = Ezvcard.parse(messageItem.mBody).first(); SpannableString name = new SpannableString(vCard.getFormattedName().getValue()); name.setSpan(new UnderlineSpan(), 0, name.length(), 0); holder.mBodyTextView.setText(name); } private void setImageViewOnClickListener(MessageListViewHolder holder, final MessageItem msgItem) { if (holder.mImageView != null) { switch (msgItem.mAttachmentType) { case SmsHelper.IMAGE: case SmsHelper.VIDEO: holder.mImageView.setOnClickListener(holder); holder.mImageView.setOnLongClickListener(holder); break; default: holder.mImageView.setOnClickListener(null); break; } } } private void drawPlaybackButton(MessageListViewHolder holder, MessageItem msgItem) { if (holder.mSlideShowButton != null) { switch (msgItem.mAttachmentType) { case SmsHelper.SLIDESHOW: case SmsHelper.AUDIO: case SmsHelper.VIDEO: // Show the 'Play' button and bind message info on it. holder.mSlideShowButton.setTag(msgItem); // Set call-back for the 'Play' button. holder.mSlideShowButton.setOnClickListener(holder); holder.mSlideShowButton.setVisibility(View.VISIBLE); break; default: holder.mSlideShowButton.setVisibility(View.GONE); break; } } } @Override public void changeCursor(Cursor cursor) { if (CursorUtils.isValid(cursor)) { mColumnsMap = new MessageColumns.ColumnsMap(cursor); mMessageItemCache = new MessageItemCache(mContext, mColumnsMap, mSearchHighlighter, MessageColumns.CACHE_SIZE); } super.changeCursor(cursor); } @Override public int getItemViewType(int position) { // This method shouldn't be called if our cursor is null, since the framework should know // that there aren't any items to look at in that case MessageItem item = getItem(position); int boxId = item.getBoxId(); if (item.isSms()) { if (boxId == TextBasedSmsColumns.MESSAGE_TYPE_INBOX || boxId == TextBasedSmsColumns.MESSAGE_TYPE_ALL) { return INCOMING_ITEM; } else { return OUTGOING_ITEM; } } else { if (boxId == Telephony.Mms.MESSAGE_BOX_ALL || boxId == Telephony.Mms.MESSAGE_BOX_INBOX) { return INCOMING_ITEM; } else { return OUTGOING_ITEM; } } } }