package com.simplemobiletools.smsmessenger.adapters import android.annotation.SuppressLint import android.content.Intent import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.util.Size import android.util.TypedValue import android.view.Menu import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import android.widget.RelativeLayout import androidx.appcompat.content.res.AppCompatResources import androidx.constraintlayout.widget.ConstraintSet import androidx.core.view.updateLayoutParams import androidx.recyclerview.widget.DiffUtil import androidx.viewbinding.ViewBinding import com.bumptech.glide.Glide import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.FitCenter import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.target.Target import com.simplemobiletools.commons.adapters.MyRecyclerViewListAdapter import com.simplemobiletools.commons.dialogs.ConfirmationDialog import com.simplemobiletools.commons.extensions.* import com.simplemobiletools.commons.helpers.SimpleContactsHelper import com.simplemobiletools.commons.helpers.ensureBackgroundThread import com.simplemobiletools.commons.views.MyRecyclerView import com.simplemobiletools.smsmessenger.R import com.simplemobiletools.smsmessenger.activities.NewConversationActivity import com.simplemobiletools.smsmessenger.activities.SimpleActivity import com.simplemobiletools.smsmessenger.activities.ThreadActivity import com.simplemobiletools.smsmessenger.activities.VCardViewerActivity import com.simplemobiletools.smsmessenger.databinding.* import com.simplemobiletools.smsmessenger.dialogs.DeleteConfirmationDialog import com.simplemobiletools.smsmessenger.dialogs.MessageDetailsDialog import com.simplemobiletools.smsmessenger.dialogs.SelectTextDialog import com.simplemobiletools.smsmessenger.extensions.* import com.simplemobiletools.smsmessenger.helpers.* import com.simplemobiletools.smsmessenger.models.Attachment import com.simplemobiletools.smsmessenger.models.Message import com.simplemobiletools.smsmessenger.models.ThreadItem import com.simplemobiletools.smsmessenger.models.ThreadItem.* class ThreadAdapter( activity: SimpleActivity, recyclerView: MyRecyclerView, itemClick: (Any) -> Unit, val isRecycleBin: Boolean, val deleteMessages: (messages: List, toRecycleBin: Boolean, fromRecycleBin: Boolean) -> Unit ) : MyRecyclerViewListAdapter(activity, recyclerView, ThreadItemDiffCallback(), itemClick) { private var fontSize = activity.getTextSize() @SuppressLint("MissingPermission") private val hasMultipleSIMCards = (activity.subscriptionManagerCompat().activeSubscriptionInfoList?.size ?: 0) > 1 private val maxChatBubbleWidth = activity.usableScreenSize.x * 0.8f init { setupDragListener(true) setHasStableIds(true) } override fun getActionMenuId() = R.menu.cab_thread override fun prepareActionMode(menu: Menu) { val isOneItemSelected = isOneItemSelected() val selectedItem = getSelectedItems().firstOrNull() as? Message val hasText = selectedItem?.body != null && selectedItem.body != "" menu.apply { findItem(R.id.cab_copy_to_clipboard).isVisible = isOneItemSelected && hasText findItem(R.id.cab_save_as).isVisible = isOneItemSelected && selectedItem?.attachment?.attachments?.size == 1 findItem(R.id.cab_share).isVisible = isOneItemSelected && hasText findItem(R.id.cab_forward_message).isVisible = isOneItemSelected findItem(R.id.cab_select_text).isVisible = isOneItemSelected && hasText findItem(R.id.cab_properties).isVisible = isOneItemSelected findItem(R.id.cab_restore).isVisible = isRecycleBin } } override fun actionItemPressed(id: Int) { if (selectedKeys.isEmpty()) { return } when (id) { R.id.cab_copy_to_clipboard -> copyToClipboard() R.id.cab_save_as -> saveAs() R.id.cab_share -> shareText() R.id.cab_forward_message -> forwardMessage() R.id.cab_select_text -> selectText() R.id.cab_delete -> askConfirmDelete() R.id.cab_restore -> askConfirmRestore() R.id.cab_select_all -> selectAll() R.id.cab_properties -> showMessageDetails() } } override fun getSelectableItemCount() = currentList.filterIsInstance().size override fun getIsItemSelectable(position: Int) = !isThreadDateTime(position) override fun getItemSelectionKey(position: Int) = (currentList.getOrNull(position) as? Message)?.hashCode() override fun getItemKeyPosition(key: Int) = currentList.indexOfFirst { (it as? Message)?.hashCode() == key } override fun onActionModeCreated() {} override fun onActionModeDestroyed() {} override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val binding = when (viewType) { THREAD_LOADING -> ItemThreadLoadingBinding.inflate(layoutInflater, parent, false) THREAD_DATE_TIME -> ItemThreadDateTimeBinding.inflate(layoutInflater, parent, false) THREAD_SENT_MESSAGE_ERROR -> ItemThreadErrorBinding.inflate(layoutInflater, parent, false) THREAD_SENT_MESSAGE_SENT -> ItemThreadSuccessBinding.inflate(layoutInflater, parent, false) THREAD_SENT_MESSAGE_SENDING -> ItemThreadSendingBinding.inflate(layoutInflater, parent, false) else -> ItemMessageBinding.inflate(layoutInflater, parent, false) } return ThreadViewHolder(binding) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { val item = getItem(position) val isClickable = item is ThreadError || item is Message val isLongClickable = item is Message holder.bindView(item, isClickable, isLongClickable) { itemView, _ -> when (item) { is ThreadLoading -> setupThreadLoading(itemView) is ThreadDateTime -> setupDateTime(itemView, item) is ThreadError -> setupThreadError(itemView) is ThreadSent -> setupThreadSuccess(itemView, item.delivered) is ThreadSending -> setupThreadSending(itemView) is Message -> setupView(holder, itemView, item) } } bindViewHolder(holder) } override fun getItemId(position: Int): Long { return when (val item = getItem(position)) { is Message -> Message.getStableId(item) else -> item.hashCode().toLong() } } override fun getItemViewType(position: Int): Int { return when (val item = getItem(position)) { is ThreadLoading -> THREAD_LOADING is ThreadDateTime -> THREAD_DATE_TIME is ThreadError -> THREAD_SENT_MESSAGE_ERROR is ThreadSent -> THREAD_SENT_MESSAGE_SENT is ThreadSending -> THREAD_SENT_MESSAGE_SENDING is Message -> if (item.isReceivedMessage()) THREAD_RECEIVED_MESSAGE else THREAD_SENT_MESSAGE } } private fun copyToClipboard() { val firstItem = getSelectedItems().firstOrNull() as? Message ?: return activity.copyToClipboard(firstItem.body) } private fun saveAs() { val firstItem = getSelectedItems().firstOrNull() as? Message ?: return val attachment = firstItem.attachment?.attachments?.first() ?: return (activity as ThreadActivity).saveMMS(attachment.mimetype, attachment.uriString) } private fun shareText() { val firstItem = getSelectedItems().firstOrNull() as? Message ?: return activity.shareTextIntent(firstItem.body) } private fun selectText() { val firstItem = getSelectedItems().firstOrNull() as? Message ?: return if (firstItem.body.trim().isNotEmpty()) { SelectTextDialog(activity, firstItem.body) } } private fun showMessageDetails() { val message = getSelectedItems().firstOrNull() as? Message ?: return MessageDetailsDialog(activity, message) } private fun askConfirmDelete() { val itemsCnt = selectedKeys.size // not sure how we can get UnknownFormatConversionException here, so show the error and hope that someone reports it val items = try { resources.getQuantityString(R.plurals.delete_messages, itemsCnt, itemsCnt) } catch (e: Exception) { activity.showErrorToast(e) return } val baseString = if (activity.config.useRecycleBin && !isRecycleBin) { com.simplemobiletools.commons.R.string.move_to_recycle_bin_confirmation } else { com.simplemobiletools.commons.R.string.deletion_confirmation } val question = String.format(resources.getString(baseString), items) DeleteConfirmationDialog(activity, question, activity.config.useRecycleBin && !isRecycleBin) { skipRecycleBin -> ensureBackgroundThread { val messagesToRemove = getSelectedItems() if (messagesToRemove.isNotEmpty()) { val toRecycleBin = !skipRecycleBin && activity.config.useRecycleBin && !isRecycleBin deleteMessages(messagesToRemove.filterIsInstance(), toRecycleBin, false) } } } } private fun askConfirmRestore() { val itemsCnt = selectedKeys.size // not sure how we can get UnknownFormatConversionException here, so show the error and hope that someone reports it val items = try { resources.getQuantityString(R.plurals.delete_messages, itemsCnt, itemsCnt) } catch (e: Exception) { activity.showErrorToast(e) return } val baseString = R.string.restore_confirmation val question = String.format(resources.getString(baseString), items) ConfirmationDialog(activity, question) { ensureBackgroundThread { val messagesToRestore = getSelectedItems() if (messagesToRestore.isNotEmpty()) { deleteMessages(messagesToRestore.filterIsInstance(), false, true) } } } } private fun forwardMessage() { val message = getSelectedItems().firstOrNull() as? Message ?: return val attachment = message.attachment?.attachments?.firstOrNull() Intent(activity, NewConversationActivity::class.java).apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_TEXT, message.body) if (attachment != null) { putExtra(Intent.EXTRA_STREAM, attachment.getUri()) } activity.startActivity(this) } } private fun getSelectedItems() = currentList.filter { selectedKeys.contains((it as? Message)?.hashCode() ?: 0) } as ArrayList private fun isThreadDateTime(position: Int) = currentList.getOrNull(position) is ThreadDateTime fun updateMessages(newMessages: ArrayList, scrollPosition: Int = -1) { val latestMessages = newMessages.toMutableList() submitList(latestMessages) { if (scrollPosition != -1) { recyclerView.scrollToPosition(scrollPosition) } } } private fun setupView(holder: ViewHolder, view: View, message: Message) { ItemMessageBinding.bind(view).apply { threadMessageHolder.isSelected = selectedKeys.contains(message.hashCode()) threadMessageBody.apply { text = message.body setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize) beVisibleIf(message.body.isNotEmpty()) setOnLongClickListener { holder.viewLongClicked() true } setOnClickListener { holder.viewClicked(message) } } if (message.isReceivedMessage()) { setupReceivedMessageView(messageBinding = this, message = message) } else { setupSentMessageView(messageBinding = this, message = message) } if (message.attachment?.attachments?.isNotEmpty() == true) { threadMessageAttachmentsHolder.beVisible() threadMessageAttachmentsHolder.removeAllViews() for (attachment in message.attachment.attachments) { val mimetype = attachment.mimetype when { mimetype.isImageMimeType() || mimetype.isVideoMimeType() -> setupImageView(holder, binding = this, message, attachment) mimetype.isVCardMimeType() -> setupVCardView(holder, threadMessageAttachmentsHolder, message, attachment) else -> setupFileView(holder, threadMessageAttachmentsHolder, message, attachment) } threadMessagePlayOutline.beVisibleIf(mimetype.startsWith("video/")) } } else { threadMessageAttachmentsHolder.beGone() threadMessagePlayOutline.beGone() } } } private fun setupReceivedMessageView(messageBinding: ItemMessageBinding, message: Message) { messageBinding.apply { with(ConstraintSet()) { clone(threadMessageHolder) clear(threadMessageWrapper.id, ConstraintSet.END) connect(threadMessageWrapper.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START) applyTo(threadMessageHolder) } threadMessageSenderPhoto.beVisible() threadMessageSenderPhoto.setOnClickListener { val contact = message.getSender()!! activity.getContactFromAddress(contact.phoneNumbers.first().normalizedNumber) { if (it != null) { activity.startContactDetailsIntent(it) } } } threadMessageBody.apply { background = AppCompatResources.getDrawable(activity, R.drawable.item_received_background) setTextColor(textColor) setLinkTextColor(activity.getProperPrimaryColor()) } if (!activity.isFinishing && !activity.isDestroyed) { val contactLetterIcon = SimpleContactsHelper(activity).getContactLetterIcon(message.senderName) val placeholder = BitmapDrawable(activity.resources, contactLetterIcon) val options = RequestOptions() .diskCacheStrategy(DiskCacheStrategy.RESOURCE) .error(placeholder) .centerCrop() Glide.with(activity) .load(message.senderPhotoUri) .placeholder(placeholder) .apply(options) .apply(RequestOptions.circleCropTransform()) .into(threadMessageSenderPhoto) } } } private fun setupSentMessageView(messageBinding: ItemMessageBinding, message: Message) { messageBinding.apply { with(ConstraintSet()) { clone(threadMessageHolder) clear(threadMessageWrapper.id, ConstraintSet.START) connect(threadMessageWrapper.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END) applyTo(threadMessageHolder) } val primaryColor = activity.getProperPrimaryColor() val contrastColor = primaryColor.getContrastColor() threadMessageBody.apply { updateLayoutParams { removeRule(RelativeLayout.END_OF) addRule(RelativeLayout.ALIGN_PARENT_END) } background = AppCompatResources.getDrawable(activity, R.drawable.item_sent_background) background.applyColorFilter(primaryColor) setTextColor(contrastColor) setLinkTextColor(contrastColor) if (message.isScheduled) { typeface = Typeface.create(Typeface.DEFAULT, Typeface.ITALIC) val scheduledDrawable = AppCompatResources.getDrawable(activity, com.simplemobiletools.commons.R.drawable.ic_clock_vector)?.apply { applyColorFilter(contrastColor) val size = lineHeight setBounds(0, 0, size, size) } setCompoundDrawables(null, null, scheduledDrawable, null) } else { typeface = Typeface.DEFAULT setCompoundDrawables(null, null, null, null) } } } } private fun setupImageView(holder: ViewHolder, binding: ItemMessageBinding, message: Message, attachment: Attachment) = binding.apply { val mimetype = attachment.mimetype val uri = attachment.getUri() val imageView = ItemAttachmentImageBinding.inflate(layoutInflater) threadMessageAttachmentsHolder.addView(imageView.root) val placeholderDrawable = ColorDrawable(Color.TRANSPARENT) val isTallImage = attachment.height > attachment.width val transformation = if (isTallImage) CenterCrop() else FitCenter() val options = RequestOptions() .diskCacheStrategy(DiskCacheStrategy.RESOURCE) .placeholder(placeholderDrawable) .transform(transformation) var builder = Glide.with(root.context) .load(uri) .apply(options) .listener(object : RequestListener { override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean): Boolean { threadMessagePlayOutline.beGone() threadMessageAttachmentsHolder.removeView(imageView.root) return false } override fun onResourceReady(dr: Drawable?, a: Any?, t: Target?, d: DataSource?, i: Boolean) = false }) // limit attachment sizes to avoid causing OOM var wantedAttachmentSize = Size(attachment.width, attachment.height) if (wantedAttachmentSize.width > maxChatBubbleWidth) { val newHeight = wantedAttachmentSize.height / (wantedAttachmentSize.width / maxChatBubbleWidth) wantedAttachmentSize = Size(maxChatBubbleWidth.toInt(), newHeight.toInt()) } builder = if (isTallImage) { builder.override(wantedAttachmentSize.width, wantedAttachmentSize.width) } else { builder.override(wantedAttachmentSize.width, wantedAttachmentSize.height) } try { builder.into(imageView.attachmentImage) } catch (ignore: Exception) { } imageView.attachmentImage.setOnClickListener { if (actModeCallback.isSelectable) { holder.viewClicked(message) } else { activity.launchViewIntent(uri, mimetype, attachment.filename) } } imageView.root.setOnLongClickListener { holder.viewLongClicked() true } } private fun setupVCardView(holder: ViewHolder, parent: LinearLayout, message: Message, attachment: Attachment) { val uri = attachment.getUri() val vCardView = ItemAttachmentVcardBinding.inflate(layoutInflater).apply { setupVCardPreview( activity = activity, uri = uri, onClick = { if (actModeCallback.isSelectable) { holder.viewClicked(message) } else { val intent = Intent(activity, VCardViewerActivity::class.java).also { it.putExtra(EXTRA_VCARD_URI, uri) } activity.startActivity(intent) } }, onLongClick = { holder.viewLongClicked() } ) }.root parent.addView(vCardView) } private fun setupFileView(holder: ViewHolder, parent: LinearLayout, message: Message, attachment: Attachment) { val mimetype = attachment.mimetype val uri = attachment.getUri() val attachmentView = ItemAttachmentDocumentBinding.inflate(layoutInflater).apply { setupDocumentPreview( uri = uri, title = attachment.filename, mimeType = attachment.mimetype, onClick = { if (actModeCallback.isSelectable) { holder.viewClicked(message) } else { activity.launchViewIntent(uri, mimetype, attachment.filename) } }, onLongClick = { holder.viewLongClicked() } ) }.root parent.addView(attachmentView) } private fun setupDateTime(view: View, dateTime: ThreadDateTime) { ItemThreadDateTimeBinding.bind(view).apply { threadDateTime.apply { text = dateTime.date.formatDateOrTime(context, hideTimeAtOtherDays = false, showYearEvenIfCurrent = false) setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize) } threadDateTime.setTextColor(textColor) threadSimIcon.beVisibleIf(hasMultipleSIMCards) threadSimNumber.beVisibleIf(hasMultipleSIMCards) if (hasMultipleSIMCards) { threadSimNumber.text = dateTime.simID threadSimNumber.setTextColor(textColor.getContrastColor()) threadSimIcon.applyColorFilter(textColor) } } } private fun setupThreadSuccess(view: View, isDelivered: Boolean) { ItemThreadSuccessBinding.bind(view).apply { threadSuccess.setImageResource(if (isDelivered) R.drawable.ic_check_double_vector else com.simplemobiletools.commons.R.drawable.ic_check_vector) threadSuccess.applyColorFilter(textColor) } } private fun setupThreadError(view: View) { val binding = ItemThreadErrorBinding.bind(view) binding.threadError.setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize - 4) } private fun setupThreadSending(view: View) { ItemThreadSendingBinding.bind(view).threadSending.apply { setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize) setTextColor(textColor) } } private fun setupThreadLoading(view: View) { val binding = ItemThreadLoadingBinding.bind(view) binding.threadLoading.setIndicatorColor(properPrimaryColor) } override fun onViewRecycled(holder: ViewHolder) { super.onViewRecycled(holder) if (!activity.isDestroyed && !activity.isFinishing) { val binding = (holder as ThreadViewHolder).binding if (binding is ItemMessageBinding) { Glide.with(activity).clear(binding.threadMessageSenderPhoto) } } } inner class ThreadViewHolder(val binding: ViewBinding) : ViewHolder(binding.root) } private class ThreadItemDiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: ThreadItem, newItem: ThreadItem): Boolean { if (oldItem::class.java != newItem::class.java) return false return when (oldItem) { is ThreadLoading -> oldItem.id == (newItem as ThreadLoading).id is ThreadDateTime -> oldItem.date == (newItem as ThreadDateTime).date is ThreadError -> oldItem.messageId == (newItem as ThreadError).messageId is ThreadSent -> oldItem.messageId == (newItem as ThreadSent).messageId is ThreadSending -> oldItem.messageId == (newItem as ThreadSending).messageId is Message -> Message.areItemsTheSame(oldItem, newItem as Message) } } override fun areContentsTheSame(oldItem: ThreadItem, newItem: ThreadItem): Boolean { if (oldItem::class.java != newItem::class.java) return false return when (oldItem) { is ThreadLoading, is ThreadSending -> true is ThreadDateTime -> oldItem.simID == (newItem as ThreadDateTime).simID is ThreadError -> oldItem.messageText == (newItem as ThreadError).messageText is ThreadSent -> oldItem.delivered == (newItem as ThreadSent).delivered is Message -> Message.areContentsTheSame(oldItem, newItem as Message) } } }