diff --git a/CHANGELOG.md b/CHANGELOG.md index 3db7a9cd..45ff5cfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed +- Optimized loading messages in conversations ## [1.4.0] - 2025-10-12 ### Added diff --git a/app/src/main/kotlin/org/fossify/messages/App.kt b/app/src/main/kotlin/org/fossify/messages/App.kt index 1ebacea2..705cb6b1 100644 --- a/app/src/main/kotlin/org/fossify/messages/App.kt +++ b/app/src/main/kotlin/org/fossify/messages/App.kt @@ -1,7 +1,38 @@ package org.fossify.messages +import android.database.ContentObserver +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.provider.ContactsContract import org.fossify.commons.FossifyApp +import org.fossify.commons.extensions.hasPermission +import org.fossify.commons.helpers.PERMISSION_READ_CONTACTS +import org.fossify.messages.helpers.MessagingCache class App : FossifyApp() { override val isAppLockFeatureAvailable = true + + override fun onCreate() { + super.onCreate() + if (hasPermission(PERMISSION_READ_CONTACTS)) { + listOf( + ContactsContract.Contacts.CONTENT_URI, + ContactsContract.Data.CONTENT_URI, + ContactsContract.DisplayPhoto.CONTENT_URI + ).forEach { + try { + contentResolver.registerContentObserver(it, true, contactsObserver) + } catch (_: Exception){ + } + } + } + } + + private val contactsObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean, uri: Uri?) { + MessagingCache.namePhoto.evictAll() + MessagingCache.participantsCache.evictAll() + } + } } diff --git a/app/src/main/kotlin/org/fossify/messages/activities/MainActivity.kt b/app/src/main/kotlin/org/fossify/messages/activities/MainActivity.kt index 49a7148d..8ba59004 100644 --- a/app/src/main/kotlin/org/fossify/messages/activities/MainActivity.kt +++ b/app/src/main/kotlin/org/fossify/messages/activities/MainActivity.kt @@ -380,11 +380,7 @@ class MainActivity : SimpleActivity() { if (config.appRunCount == 1) { conversations.map { it.threadId }.forEach { threadId -> - val messages = getMessages( - threadId = threadId, - getImageResolutions = false, - includeScheduledMessages = false - ) + val messages = getMessages(threadId, includeScheduledMessages = false) messages.chunked(30).forEach { currentMessages -> messagesDB.insertMessages(*currentMessages.toTypedArray()) } diff --git a/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt b/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt index 4239e2d4..dde35ad3 100644 --- a/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt +++ b/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt @@ -8,9 +8,7 @@ import android.content.Intent import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION import android.content.res.ColorStateList -import android.graphics.BitmapFactory import android.graphics.drawable.LayerDrawable -import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Bundle import android.provider.ContactsContract @@ -49,7 +47,6 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams import androidx.documentfile.provider.DocumentFile import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import com.google.gson.Gson import com.google.gson.reflect.TypeToken import org.fossify.commons.dialogs.ConfirmationDialog @@ -87,7 +84,6 @@ import org.fossify.commons.extensions.openRequestExactAlarmSettings import org.fossify.commons.extensions.realScreenSize import org.fossify.commons.extensions.showErrorToast import org.fossify.commons.extensions.showKeyboard -import org.fossify.commons.extensions.toInt import org.fossify.commons.extensions.toast import org.fossify.commons.extensions.updateTextColors import org.fossify.commons.extensions.value @@ -105,7 +101,6 @@ import org.fossify.commons.helpers.isSPlus import org.fossify.commons.models.PhoneNumber import org.fossify.commons.models.RadioItem import org.fossify.commons.models.SimpleContact -import org.fossify.commons.views.MyRecyclerView import org.fossify.messages.BuildConfig import org.fossify.messages.R import org.fossify.messages.adapters.AttachmentsAdapter @@ -143,6 +138,7 @@ import org.fossify.messages.extensions.markMessageRead import org.fossify.messages.extensions.markThreadMessagesUnread import org.fossify.messages.extensions.messagesDB import org.fossify.messages.extensions.moveMessageToRecycleBin +import org.fossify.messages.extensions.onScroll import org.fossify.messages.extensions.removeDiacriticsIfNeeded import org.fossify.messages.extensions.renameConversation import org.fossify.messages.extensions.restoreAllMessagesFromRecycleBinForConversation @@ -192,7 +188,6 @@ import org.fossify.messages.models.SIMCard import org.fossify.messages.models.ThreadItem import org.fossify.messages.models.ThreadItem.ThreadDateTime import org.fossify.messages.models.ThreadItem.ThreadError -import org.fossify.messages.models.ThreadItem.ThreadLoading import org.fossify.messages.models.ThreadItem.ThreadSending import org.fossify.messages.models.ThreadItem.ThreadSent import org.greenrobot.eventbus.EventBus @@ -200,16 +195,11 @@ import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.joda.time.DateTime import java.io.File +import androidx.core.net.toUri +import androidx.recyclerview.widget.RecyclerView +import org.fossify.messages.extensions.filterNotInByKey class ThreadActivity : SimpleActivity() { - private val MIN_DATE_TIME_DIFF_SECS = 300 - - private val TYPE_EDIT = 14 - private val TYPE_SEND = 15 - private val TYPE_DELETE = 16 - - private val SCROLL_TO_BOTTOM_FAB_LIMIT = 20 - private var threadId = 0L private var currentSIMCardIndex = 0 private var isActivityVisible = false @@ -489,12 +479,10 @@ class ThreadActivity : SimpleActivity() { val cachedMessagesCode = messages.clone().hashCode() if (!isRecycleBin) { - messages = getMessages(threadId, true) + messages = getMessages(threadId) if (config.useRecycleBin) { - val recycledMessages = - messagesDB.getThreadMessagesFromRecycleBin(threadId).map { it.id } - messages = messages.filter { !recycledMessages.contains(it.id) } - .toMutableList() as ArrayList + val recycledMessages = messagesDB.getThreadMessagesFromRecycleBin(threadId) + messages = messages.filterNotInByKey(recycledMessages) { it.getStableId() } } } @@ -560,7 +548,6 @@ class ThreadActivity : SimpleActivity() { } } - setupAttachmentSizes() setupAdapter() runOnUiThread { setupThreadTitle() @@ -587,14 +574,6 @@ class ThreadActivity : SimpleActivity() { ) binding.threadMessagesList.adapter = currAdapter - binding.threadMessagesList.endlessScrollListener = - object : MyRecyclerView.EndlessScrollListener { - override fun updateBottom() {} - - override fun updateTop() { - fetchNextMessages() - } - } } return currAdapter as ThreadAdapter } @@ -663,21 +642,21 @@ class ThreadActivity : SimpleActivity() { } } - private fun setupScrollFab() { - binding.threadMessagesList.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy) + private fun setupScrollListener() { + binding.threadMessagesList.onScroll( + onScrolled = { dx, dy -> + tryLoadMoreMessages() val layoutManager = binding.threadMessagesList.layoutManager as LinearLayoutManager val lastVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition() val isCloseToBottom = lastVisibleItemPosition >= getOrCreateThreadAdapter().itemCount - SCROLL_TO_BOTTOM_FAB_LIMIT - if (isCloseToBottom) { - binding.scrollToBottomFab.hide() - } else { - binding.scrollToBottomFab.show() - } + val fab = binding.scrollToBottomFab + if (isCloseToBottom) fab.hide() else fab.show() + }, + onScrollStateChanged = { newState -> + if (newState == RecyclerView.SCROLL_STATE_IDLE) tryLoadMoreMessages() } - }) + ) } private fun handleItemClick(any: Any) { @@ -737,35 +716,25 @@ class ThreadActivity : SimpleActivity() { } } - private fun fetchNextMessages() { - if (messages.isEmpty() || allMessagesFetched || loadingOlderMessages) { - if (allMessagesFetched) { - getOrCreateThreadAdapter().apply { - val newList = currentList.toMutableList().apply { - removeAll { it is ThreadLoading } - } - updateMessages( - newMessages = newList as ArrayList, - scrollPosition = 0 - ) - } - } - return + private fun tryLoadMoreMessages() { + val layoutManager = binding.threadMessagesList.layoutManager as LinearLayoutManager + if (layoutManager.findFirstVisibleItemPosition() <= PREFETCH_THRESHOLD) { + loadMoreMessages() } + } + + private fun loadMoreMessages() { + if (messages.isEmpty() || allMessagesFetched || loadingOlderMessages) return val firstItem = messages.first() val dateOfFirstItem = firstItem.date - if (oldestMessageDate == dateOfFirstItem) { - allMessagesFetched = true - return - } oldestMessageDate = dateOfFirstItem loadingOlderMessages = true ensureBackgroundThread { - val olderMessages = getMessages(threadId, true, oldestMessageDate) - .filter { message -> !messages.contains(message) } + val olderMessages = getMessages(threadId, oldestMessageDate) + .filterNotInByKey(messages) { it.getStableId() } messages.addAll(0, olderMessages) allMessagesFetched = olderMessages.isEmpty() @@ -773,8 +742,7 @@ class ThreadActivity : SimpleActivity() { runOnUiThread { loadingOlderMessages = false - val itemAtRefreshIndex = threadItems.indexOfFirst { it == firstItem } - getOrCreateThreadAdapter().updateMessages(threadItems, itemAtRefreshIndex) + getOrCreateThreadAdapter().updateMessages(threadItems) } } } @@ -796,7 +764,7 @@ class ThreadActivity : SimpleActivity() { } setupThread() - setupScrollFab() + setupScrollListener() } } else { finish() @@ -913,7 +881,7 @@ class ThreadActivity : SimpleActivity() { } if (intent.extras?.containsKey(THREAD_ATTACHMENT_URI) == true) { - val uri = Uri.parse(intent.getStringExtra(THREAD_ATTACHMENT_URI)) + val uri = intent.getStringExtra(THREAD_ATTACHMENT_URI)!!.toUri() addAttachment(uri) } else if (intent.extras?.containsKey(THREAD_ATTACHMENT_URIS) == true) { (intent.getSerializableExtra(THREAD_ATTACHMENT_URIS) as? ArrayList)?.forEach { @@ -949,44 +917,6 @@ class ThreadActivity : SimpleActivity() { } } - private fun setupAttachmentSizes() { - messages.filter { it.attachment != null }.forEach { message -> - message.attachment!!.attachments.forEach { - try { - if (it.mimetype.startsWith("image/")) { - val fileOptions = BitmapFactory.Options() - fileOptions.inJustDecodeBounds = true - BitmapFactory.decodeStream( - contentResolver.openInputStream(it.getUri()), - null, - fileOptions - ) - it.width = fileOptions.outWidth - it.height = fileOptions.outHeight - } else if (it.mimetype.startsWith("video/")) { - val metaRetriever = MediaMetadataRetriever() - metaRetriever.setDataSource(this, it.getUri()) - it.width = metaRetriever.extractMetadata( - MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH - )!!.toInt() - it.height = metaRetriever.extractMetadata( - MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT - )!!.toInt() - } - - if (it.width < 0) { - it.width = 0 - } - - if (it.height < 0) { - it.height = 0 - } - } catch (ignored: Exception) { - } - } - } - } - private fun setupParticipants() { if (participants.isEmpty()) { participants = if (messages.isEmpty()) { @@ -1346,11 +1276,6 @@ class ThreadActivity : SimpleActivity() { bus?.post(Events.RefreshMessages()) } - if (!allMessagesFetched && messages.size >= MESSAGES_LIMIT) { - val threadLoading = ThreadLoading(generateRandomId()) - items.add(0, threadLoading) - } - return items } @@ -1615,13 +1540,8 @@ class ThreadActivity : SimpleActivity() { refreshedSinceSent = false sendMessageCompat(text, addresses, subscriptionId, attachments, messageToResend) ensureBackgroundThread { - val messageIds = messages.map { it.id } - val messages = getMessages( - threadId = threadId, - getImageResolutions = true, - limit = maxOf(1, attachments.size) - ) - .filter { it.id !in messageIds } + val messages = getMessages(threadId, limit = maxOf(1, attachments.size)) + .filterNotInByKey(messages) { it.getStableId() } for (message in messages) { insertOrUpdateMessage(message) } @@ -1805,9 +1725,7 @@ class ThreadActivity : SimpleActivity() { val lastMaxId = messages.filterNot { it.isScheduled }.maxByOrNull { it.id }?.id ?: 0L val newThreadId = getThreadId(participants.getAddresses().toSet()) - val newMessages = - getMessages(newThreadId, getImageResolutions = true, includeScheduledMessages = false) - + val newMessages = getMessages(newThreadId, includeScheduledMessages = false) if (messages.isNotEmpty() && messages.all { it.isScheduled } && newMessages.isNotEmpty()) { // update scheduled messages with real thread id threadId = newThreadId @@ -2145,4 +2063,13 @@ class ThreadActivity : SimpleActivity() { } else { getBottomNavigationBackgroundColor() } + + companion object { + private const val TYPE_EDIT = 14 + private const val TYPE_SEND = 15 + private const val TYPE_DELETE = 16 + private const val MIN_DATE_TIME_DIFF_SECS = 300 + private const val SCROLL_TO_BOTTOM_FAB_LIMIT = 20 + private const val PREFETCH_THRESHOLD = 50 + } } diff --git a/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt b/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt index 7b9226a7..86d13fbe 100644 --- a/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt +++ b/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt @@ -5,7 +5,6 @@ import android.content.Intent import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.Drawable -import android.util.Size import android.util.TypedValue import android.view.Menu import android.view.View @@ -14,21 +13,34 @@ import android.widget.LinearLayout import android.widget.RelativeLayout import androidx.appcompat.content.res.AppCompatResources import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.graphics.drawable.toDrawable import androidx.core.view.updateLayoutParams import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.SimpleItemAnimator 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.DownsampleStrategy 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 org.fossify.commons.adapters.MyRecyclerViewListAdapter import org.fossify.commons.dialogs.ConfirmationDialog -import org.fossify.commons.extensions.* +import org.fossify.commons.extensions.applyColorFilter +import org.fossify.commons.extensions.beGone +import org.fossify.commons.extensions.beVisible +import org.fossify.commons.extensions.beVisibleIf +import org.fossify.commons.extensions.copyToClipboard +import org.fossify.commons.extensions.formatDateOrTime +import org.fossify.commons.extensions.getContrastColor +import org.fossify.commons.extensions.getProperPrimaryColor +import org.fossify.commons.extensions.getTextSize +import org.fossify.commons.extensions.shareTextIntent +import org.fossify.commons.extensions.showErrorToast +import org.fossify.commons.extensions.usableScreenSize import org.fossify.commons.helpers.SimpleContactsHelper import org.fossify.commons.helpers.ensureBackgroundThread import org.fossify.commons.views.MyRecyclerView @@ -37,17 +49,42 @@ import org.fossify.messages.activities.NewConversationActivity import org.fossify.messages.activities.SimpleActivity import org.fossify.messages.activities.ThreadActivity import org.fossify.messages.activities.VCardViewerActivity -import org.fossify.messages.databinding.* +import org.fossify.messages.databinding.ItemAttachmentDocumentBinding +import org.fossify.messages.databinding.ItemAttachmentImageBinding +import org.fossify.messages.databinding.ItemAttachmentVcardBinding +import org.fossify.messages.databinding.ItemMessageBinding +import org.fossify.messages.databinding.ItemThreadDateTimeBinding +import org.fossify.messages.databinding.ItemThreadErrorBinding +import org.fossify.messages.databinding.ItemThreadSendingBinding +import org.fossify.messages.databinding.ItemThreadSuccessBinding import org.fossify.messages.dialogs.DeleteConfirmationDialog import org.fossify.messages.dialogs.MessageDetailsDialog import org.fossify.messages.dialogs.SelectTextDialog -import org.fossify.messages.extensions.* -import org.fossify.messages.helpers.* +import org.fossify.messages.extensions.config +import org.fossify.messages.extensions.getContactFromAddress +import org.fossify.messages.extensions.isImageMimeType +import org.fossify.messages.extensions.isVCardMimeType +import org.fossify.messages.extensions.isVideoMimeType +import org.fossify.messages.extensions.launchViewIntent +import org.fossify.messages.extensions.startContactDetailsIntent +import org.fossify.messages.extensions.subscriptionManagerCompat +import org.fossify.messages.helpers.EXTRA_VCARD_URI +import org.fossify.messages.helpers.THREAD_DATE_TIME +import org.fossify.messages.helpers.THREAD_RECEIVED_MESSAGE +import org.fossify.messages.helpers.THREAD_SENT_MESSAGE +import org.fossify.messages.helpers.THREAD_SENT_MESSAGE_ERROR +import org.fossify.messages.helpers.THREAD_SENT_MESSAGE_SENDING +import org.fossify.messages.helpers.THREAD_SENT_MESSAGE_SENT +import org.fossify.messages.helpers.generateStableId +import org.fossify.messages.helpers.setupDocumentPreview +import org.fossify.messages.helpers.setupVCardPreview import org.fossify.messages.models.Attachment import org.fossify.messages.models.Message import org.fossify.messages.models.ThreadItem -import org.fossify.messages.models.ThreadItem.* -import androidx.core.graphics.drawable.toDrawable +import org.fossify.messages.models.ThreadItem.ThreadDateTime +import org.fossify.messages.models.ThreadItem.ThreadError +import org.fossify.messages.models.ThreadItem.ThreadSending +import org.fossify.messages.models.ThreadItem.ThreadSent class ThreadAdapter( activity: SimpleActivity, @@ -60,11 +97,18 @@ class ThreadAdapter( @SuppressLint("MissingPermission") private val hasMultipleSIMCards = (activity.subscriptionManagerCompat().activeSubscriptionInfoList?.size ?: 0) > 1 - private val maxChatBubbleWidth = activity.usableScreenSize.x * 0.8f + private val maxChatBubbleWidth = (activity.usableScreenSize.x * 0.8f).toInt() + + companion object { + private const val MAX_MEDIA_HEIGHT_RATIO = 3 + private const val SIM_BITS = 21 + private const val SIM_MASK = (1L shl SIM_BITS) - 1 + } init { setupDragListener(true) setHasStableIds(true) + (recyclerView.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false } override fun getActionMenuId() = R.menu.cab_thread @@ -110,9 +154,13 @@ class ThreadAdapter( override fun getIsItemSelectable(position: Int) = !isThreadDateTime(position) - override fun getItemSelectionKey(position: Int) = (currentList.getOrNull(position) as? Message)?.hashCode() + override fun getItemSelectionKey(position: Int): Int? { + return (currentList.getOrNull(position) as? Message)?.getSelectionKey() + } - override fun getItemKeyPosition(key: Int) = currentList.indexOfFirst { (it as? Message)?.hashCode() == key } + override fun getItemKeyPosition(key: Int): Int { + return currentList.indexOfFirst { (it as? Message)?.getSelectionKey() == key } + } override fun onActionModeCreated() {} @@ -120,7 +168,6 @@ class ThreadAdapter( 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) @@ -137,7 +184,6 @@ class ThreadAdapter( 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) @@ -150,14 +196,20 @@ class ThreadAdapter( override fun getItemId(position: Int): Long { return when (val item = getItem(position)) { - is Message -> Message.getStableId(item) - else -> item.hashCode().toLong() + is Message -> item.getStableId() + is ThreadDateTime -> { + val sim = (item.simID.hashCode().toLong() and SIM_MASK) + val key = (item.date.toLong() shl SIM_BITS) or sim + generateStableId(THREAD_DATE_TIME, key) + } + is ThreadError -> generateStableId(THREAD_SENT_MESSAGE_ERROR, item.messageId) + is ThreadSending -> generateStableId(THREAD_SENT_MESSAGE_SENDING, item.messageId) + is ThreadSent -> generateStableId(THREAD_SENT_MESSAGE_SENT, item.messageId) } } 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 @@ -268,7 +320,11 @@ class ThreadAdapter( } } - private fun getSelectedItems() = currentList.filter { selectedKeys.contains((it as? Message)?.hashCode() ?: 0) } as ArrayList + private fun getSelectedItems(): ArrayList { + return currentList.filter { + selectedKeys.contains((it as? Message)?.getSelectionKey() ?: 0) + } as ArrayList + } private fun isThreadDateTime(position: Int) = currentList.getOrNull(position) is ThreadDateTime @@ -283,7 +339,7 @@ class ThreadAdapter( private fun setupView(holder: ViewHolder, view: View, message: Message) { ItemMessageBinding.bind(view).apply { - threadMessageHolder.isSelected = selectedKeys.contains(message.hashCode()) + threadMessageHolder.isSelected = selectedKeys.contains(message.getSelectionKey()) threadMessageBody.apply { text = message.body setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize) @@ -416,16 +472,17 @@ class ThreadAdapter( threadMessageAttachmentsHolder.addView(imageView.root) val placeholderDrawable = Color.TRANSPARENT.toDrawable() - val isTallImage = attachment.height > attachment.width - val transformation = if (isTallImage) CenterCrop() else FitCenter() val options = RequestOptions() .diskCacheStrategy(DiskCacheStrategy.RESOURCE) .placeholder(placeholderDrawable) - .transform(transformation) + .transform(FitCenter()) - var builder = Glide.with(root.context) + Glide.with(root.context) .load(uri) .apply(options) + .dontAnimate() + .override(maxChatBubbleWidth, maxChatBubbleWidth * MAX_MEDIA_HEIGHT_RATIO) + .downsample(DownsampleStrategy.AT_MOST) .listener(object : RequestListener { override fun onLoadFailed(e: GlideException?, model: Any?, target: Target, isFirstResource: Boolean): Boolean { threadMessagePlayOutline.beGone() @@ -435,23 +492,11 @@ class ThreadAdapter( override fun onResourceReady(dr: Drawable, a: Any, t: Target, d: DataSource, i: Boolean) = false }) + .into(imageView.attachmentImage) - // 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 (_: Exception) { + imageView.attachmentImage.updateLayoutParams { + width = maxChatBubbleWidth + height = ViewGroup.LayoutParams.WRAP_CONTENT } imageView.attachmentImage.setOnClickListener { @@ -553,11 +598,6 @@ class ThreadAdapter( } } - 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) { @@ -576,19 +616,21 @@ 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) + is ThreadDateTime -> { + val new = newItem as ThreadDateTime + oldItem.date == new.date && oldItem.simID == new.simID + } } } 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 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 diff --git a/app/src/main/kotlin/org/fossify/messages/extensions/Collections.kt b/app/src/main/kotlin/org/fossify/messages/extensions/Collections.kt index 117e70d8..a71aab35 100644 --- a/app/src/main/kotlin/org/fossify/messages/extensions/Collections.kt +++ b/app/src/main/kotlin/org/fossify/messages/extensions/Collections.kt @@ -32,3 +32,20 @@ fun Map.toContentValues(): ContentValues { } fun Collection.toArrayList() = ArrayList(this) + +inline fun Collection.filterNotInByKey( + existing: List, + crossinline key: (T) -> Long +): ArrayList { + if (isEmpty()) return arrayListOf() + if (existing.isEmpty()) { + return ArrayList(this) + } + + val seen = HashSet(existing.size * 2) + for (item in existing) { + seen.add(key(item)) + } + + return filter { seen.add(key(it)) }.toArrayList() +} diff --git a/app/src/main/kotlin/org/fossify/messages/extensions/Context.kt b/app/src/main/kotlin/org/fossify/messages/extensions/Context.kt index 35ffae2e..d0872a1a 100644 --- a/app/src/main/kotlin/org/fossify/messages/extensions/Context.kt +++ b/app/src/main/kotlin/org/fossify/messages/extensions/Context.kt @@ -8,7 +8,6 @@ import android.content.Context import android.database.Cursor import android.database.sqlite.SQLiteException import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.net.Uri import android.os.Handler import android.os.Looper @@ -57,6 +56,7 @@ import org.fossify.messages.helpers.Config import org.fossify.messages.helpers.FILE_SIZE_NONE import org.fossify.messages.helpers.MAX_MESSAGE_LENGTH import org.fossify.messages.helpers.MESSAGES_LIMIT +import org.fossify.messages.helpers.MessagingCache import org.fossify.messages.helpers.NotificationHelper import org.fossify.messages.helpers.ShortcutHelper import org.fossify.messages.helpers.generateRandomId @@ -111,7 +111,6 @@ val Context.shortcutHelper get() = ShortcutHelper(this) fun Context.getMessages( threadId: Long, - getImageResolutions: Boolean, dateFrom: Int = -1, includeScheduledMessages: Boolean = true, limit: Int = MESSAGES_LIMIT, @@ -139,15 +138,7 @@ fun Context.getMessages( var messages = ArrayList() queryCursor(uri, projection, selection, selectionArgs, sortOrder, showErrors = true) { cursor -> val senderNumber = cursor.getStringValue(Sms.ADDRESS) ?: return@queryCursor - - val isNumberBlocked = if (blockStatus.containsKey(senderNumber)) { - blockStatus[senderNumber]!! - } else { - val isBlocked = isNumberBlocked(senderNumber, blockedNumbers) - blockStatus[senderNumber] = isBlocked - isBlocked - } - + val isNumberBlocked = blockStatus.getOrPut(senderNumber) { isNumberBlocked(senderNumber, blockedNumbers) } if (isNumberBlocked) { return@queryCursor } @@ -201,7 +192,7 @@ fun Context.getMessages( messages.add(message) } - messages.addAll(getMMS(threadId, getImageResolutions, sortOrder, dateFrom)) + messages.addAll(getMMS(threadId, sortOrder, dateFrom)) if (includeScheduledMessages) { try { @@ -225,7 +216,6 @@ fun Context.getMessages( // as soon as a message contains multiple recipients it counts as an MMS instead of SMS fun Context.getMMS( threadId: Long? = null, - getImageResolutions: Boolean = false, sortOrder: String? = null, dateFrom: Int = -1, ): ArrayList { @@ -256,7 +246,6 @@ fun Context.getMMS( val messages = ArrayList() val contactsMap = HashMap() - val threadParticipants = HashMap>() queryCursor(uri, projection, selection, selectionArgs, sortOrder, showErrors = true) { cursor -> val mmsId = cursor.getLongValue(Mms._ID) val type = cursor.getIntValue(Mms.MESSAGE_BOX) @@ -265,16 +254,10 @@ fun Context.getMMS( val threadId = cursor.getLongValue(Mms.THREAD_ID) val subscriptionId = cursor.getIntValue(Mms.SUBSCRIPTION_ID) val status = cursor.getIntValue(Mms.STATUS) - val participants = if (threadParticipants.containsKey(threadId)) { - threadParticipants[threadId]!! - } else { - val parts = getThreadParticipants(threadId, contactsMap) - threadParticipants[threadId] = parts - parts - } + val participants = getThreadParticipants(threadId, contactsMap) val isMMS = true - val attachment = getMmsAttachment(mmsId, getImageResolutions) + val attachment = getMmsAttachment(mmsId) val body = attachment.text var senderNumber = "" var senderName = "" @@ -478,7 +461,7 @@ fun Context.getConversationIds(): List { // based on https://stackoverflow.com/a/6446831/1967672 @SuppressLint("NewApi") -fun Context.getMmsAttachment(id: Long, getImageResolutions: Boolean): MessageAttachment { +fun Context.getMmsAttachment(id: Long): MessageAttachment { val uri = if (isQPlus()) { Mms.Part.CONTENT_URI } else { @@ -506,32 +489,14 @@ fun Context.getMmsAttachment(id: Long, getImageResolutions: Boolean): MessageAtt .orEmpty() } else if (mimetype.startsWith("image/") || mimetype.startsWith("video/")) { val fileUri = Uri.withAppendedPath(uri, partId.toString()) - var width = 0 - var height = 0 - - if (getImageResolutions) { - try { - val options = BitmapFactory.Options() - options.inJustDecodeBounds = true - BitmapFactory.decodeStream( - contentResolver.openInputStream(fileUri), - null, - options - ) - width = options.outWidth - height = options.outHeight - } catch (_: Exception) { - } - } - messageAttachment.attachments.add( Attachment( id = partId, messageId = id, uriString = fileUri.toString(), mimetype = mimetype, - width = width, - height = height, + width = 0, + height = 0, filename = "" ) ) @@ -569,7 +534,7 @@ fun Context.getLatestMMS(): Message? { fun Context.getThreadSnippet(threadId: Long): String { val sortOrder = "${Mms.DATE} DESC LIMIT 1" - val latestMms = getMMS(threadId, false, sortOrder).firstOrNull() + val latestMms = getMMS(threadId, sortOrder).firstOrNull() var snippet = latestMms?.body ?: "" val uri = Sms.CONTENT_URI @@ -620,6 +585,16 @@ fun Context.getThreadParticipants( threadId: Long, contactsMap: HashMap?, ): ArrayList { + MessagingCache.participantsCache.get(threadId)?.let { + return it.map { contact -> + contact.copy( + phoneNumbers = contact.phoneNumbers.toArrayList(), + birthdays = contact.birthdays.toArrayList(), + anniversaries = contact.anniversaries.toArrayList() + ) + }.toArrayList() + } + val uri = "${MmsSms.CONTENT_CONVERSATIONS_URI}?simple=true".toUri() val projection = arrayOf( ThreadsColumns.RECIPIENT_IDS @@ -660,6 +635,8 @@ fun Context.getThreadParticipants( } catch (e: Exception) { showErrorToast(e) } + + MessagingCache.participantsCache.put(threadId, participants) return participants } @@ -768,6 +745,7 @@ fun Context.getSuggestedContacts( } fun Context.getNameAndPhotoFromPhoneNumber(number: String): NamePhoto { + MessagingCache.namePhoto.get(number)?.let { return it } if (!hasPermission(PERMISSION_READ_CONTACTS)) { return NamePhoto(number, null) } @@ -778,19 +756,23 @@ fun Context.getNameAndPhotoFromPhoneNumber(number: String): NamePhoto { PhoneLookup.PHOTO_URI ) - try { + val result = try { val cursor = contentResolver.query(uri, projection, null, null, null) cursor.use { if (cursor?.moveToFirst() == true) { val name = cursor.getStringValue(PhoneLookup.DISPLAY_NAME) val photoUri = cursor.getStringValue(PhoneLookup.PHOTO_URI) - return NamePhoto(name, photoUri) + NamePhoto(name, photoUri) + } else { + NamePhoto(number, null) } } } catch (_: Exception) { + NamePhoto(number, null) } - return NamePhoto(number, null) + MessagingCache.namePhoto.put(number, result) + return result } fun Context.insertNewSMS( @@ -856,6 +838,7 @@ fun Context.deleteConversation(threadId: Long) { conversationsDB.deleteThreadId(threadId) messagesDB.deleteThreadMessages(threadId) + MessagingCache.participantsCache.remove(threadId) if (config.customNotifications.contains(threadId.toString())) { config.removeCustomNotificationsByThreadId(threadId) diff --git a/app/src/main/kotlin/org/fossify/messages/extensions/RecyclerView.kt b/app/src/main/kotlin/org/fossify/messages/extensions/RecyclerView.kt new file mode 100644 index 00000000..6ab3db65 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/messages/extensions/RecyclerView.kt @@ -0,0 +1,18 @@ +package org.fossify.messages.extensions + +import androidx.recyclerview.widget.RecyclerView + +fun RecyclerView.onScroll( + onScrolled: ((dx: Int, dy: Int) -> Unit), + onScrollStateChanged: ((newState: Int) -> Unit) +) { + addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + onScrolled.invoke(dx, dy) + } + + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + onScrollStateChanged.invoke(newState) + } + }) +} diff --git a/app/src/main/kotlin/org/fossify/messages/helpers/Constants.kt b/app/src/main/kotlin/org/fossify/messages/helpers/Constants.kt index bb656c12..77213758 100644 --- a/app/src/main/kotlin/org/fossify/messages/helpers/Constants.kt +++ b/app/src/main/kotlin/org/fossify/messages/helpers/Constants.kt @@ -60,7 +60,10 @@ const val THREAD_SENT_MESSAGE = 3 const val THREAD_SENT_MESSAGE_ERROR = 4 const val THREAD_SENT_MESSAGE_SENT = 5 const val THREAD_SENT_MESSAGE_SENDING = 6 -const val THREAD_LOADING = 7 +const val THREAD_TYPE_BITS = 3 +const val THREAD_KEY_BITS = Long.SIZE_BITS - THREAD_TYPE_BITS +const val THREAD_TYPE_SHIFT = THREAD_KEY_BITS +const val THREAD_KEY_MASK = (1L shl THREAD_KEY_BITS) - 1 // view types for attachment list const val ATTACHMENT_DOCUMENT = 7 @@ -80,7 +83,7 @@ const val FILE_SIZE_600_KB = 614_400L const val FILE_SIZE_1_MB = 1_048_576L const val FILE_SIZE_2_MB = 2_097_152L -const val MESSAGES_LIMIT = 30 +const val MESSAGES_LIMIT = 50 const val MAX_MESSAGE_LENGTH = 5000 // intent launch request codes @@ -107,3 +110,8 @@ fun generateRandomId(length: Int = 9): Long { val random = abs(Random(millis).nextLong()) return random.toString().takeLast(length).toLong() } + +fun generateStableId(type: Int, key: Long): Long { + require(type in 0 until (1 shl THREAD_TYPE_BITS)) + return (type.toLong() shl THREAD_TYPE_SHIFT) or (key and THREAD_KEY_MASK) +} diff --git a/app/src/main/kotlin/org/fossify/messages/helpers/MessagingCache.kt b/app/src/main/kotlin/org/fossify/messages/helpers/MessagingCache.kt new file mode 100644 index 00000000..4a893775 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/messages/helpers/MessagingCache.kt @@ -0,0 +1,12 @@ +package org.fossify.messages.helpers + +import android.util.LruCache +import org.fossify.commons.models.SimpleContact +import org.fossify.messages.models.NamePhoto + +private const val CACHE_SIZE = 512 + +object MessagingCache { + val namePhoto = LruCache(CACHE_SIZE) + val participantsCache = LruCache>(CACHE_SIZE) +} diff --git a/app/src/main/kotlin/org/fossify/messages/models/Message.kt b/app/src/main/kotlin/org/fossify/messages/models/Message.kt index 8a00e53f..4ac987b8 100644 --- a/app/src/main/kotlin/org/fossify/messages/models/Message.kt +++ b/app/src/main/kotlin/org/fossify/messages/models/Message.kt @@ -5,6 +5,9 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import org.fossify.commons.models.SimpleContact +import org.fossify.messages.helpers.THREAD_RECEIVED_MESSAGE +import org.fossify.messages.helpers.THREAD_SENT_MESSAGE +import org.fossify.messages.helpers.generateStableId @Entity(tableName = "messages") data class Message( @@ -34,22 +37,18 @@ data class Message( ?: participants.firstOrNull { it.name == senderName } ?: participants.firstOrNull() + fun getStableId(): Long { + val providerBit = if (isMMS) 1L else 0L + val key = (id shl 1) or providerBit + val type = if (isReceivedMessage()) THREAD_RECEIVED_MESSAGE else THREAD_SENT_MESSAGE + return generateStableId(type, key) + } + + fun getSelectionKey(): Int { + return (id xor (id ushr Int.SIZE_BITS)).toInt() + } + companion object { - - fun getStableId(message: Message): Long { - var result = message.id.hashCode() - result = 31 * result + message.body.hashCode() - result = 31 * result + message.date.hashCode() - result = 31 * result + message.threadId.hashCode() - result = 31 * result + message.isMMS.hashCode() - result = 31 * result + (message.attachment?.hashCode() ?: 0) - result = 31 * result + message.senderPhoneNumber.hashCode() - result = 31 * result + message.senderName.hashCode() - result = 31 * result + message.senderPhotoUri.hashCode() - result = 31 * result + message.isScheduled.hashCode() - return result.toLong() - } - fun areItemsTheSame(old: Message, new: Message): Boolean { return old.id == new.id } diff --git a/app/src/main/kotlin/org/fossify/messages/models/ThreadItems.kt b/app/src/main/kotlin/org/fossify/messages/models/ThreadItems.kt index 48cbd103..8453edaf 100644 --- a/app/src/main/kotlin/org/fossify/messages/models/ThreadItems.kt +++ b/app/src/main/kotlin/org/fossify/messages/models/ThreadItems.kt @@ -4,7 +4,6 @@ package org.fossify.messages.models * Thread item representations for the main thread recyclerview. [Message] is also a [ThreadItem] */ sealed class ThreadItem { - data class ThreadLoading(val id: Long) : ThreadItem() data class ThreadDateTime(val date: Int, val simID: String) : ThreadItem() data class ThreadError(val messageId: Long, val messageText: String) : ThreadItem() data class ThreadSent(val messageId: Long, val delivered: Boolean) : ThreadItem() diff --git a/app/src/main/kotlin/org/fossify/messages/receivers/DirectReplyReceiver.kt b/app/src/main/kotlin/org/fossify/messages/receivers/DirectReplyReceiver.kt index 292c4d1e..a8b11695 100644 --- a/app/src/main/kotlin/org/fossify/messages/receivers/DirectReplyReceiver.kt +++ b/app/src/main/kotlin/org/fossify/messages/receivers/DirectReplyReceiver.kt @@ -40,7 +40,9 @@ class DirectReplyReceiver : BroadcastReceiver() { var messageId = 0L try { context.sendMessageCompat(body, listOf(address), subscriptionId, emptyList()) - val message = context.getMessages(threadId, getImageResolutions = false, includeScheduledMessages = false, limit = 1).lastOrNull() + val message = context.getMessages( + threadId = threadId, includeScheduledMessages = false, limit = 1 + ).lastOrNull() if (message != null) { context.messagesDB.insertOrUpdate(message) messageId = message.id @@ -54,7 +56,15 @@ class DirectReplyReceiver : BroadcastReceiver() { val photoUri = SimpleContactsHelper(context).getPhotoUriFromPhoneNumber(address) val bitmap = context.getNotificationBitmap(photoUri) Handler(Looper.getMainLooper()).post { - context.notificationHelper.showMessageNotification(messageId, address, body, threadId, bitmap, sender = null, alertOnlyOnce = true) + context.notificationHelper.showMessageNotification( + messageId = messageId, + address = address, + body = body, + threadId = threadId, + bitmap = bitmap, + sender = null, + alertOnlyOnce = true + ) } context.markThreadMessagesRead(threadId) diff --git a/app/src/main/res/layout/activity_thread.xml b/app/src/main/res/layout/activity_thread.xml index f4af118e..6b2105ef 100644 --- a/app/src/main/res/layout/activity_thread.xml +++ b/app/src/main/res/layout/activity_thread.xml @@ -116,6 +116,7 @@ android:layout_height="match_parent" android:clipToPadding="false" android:overScrollMode="ifContentScrolls" + android:paddingTop="@dimen/big_margin" android:paddingBottom="@dimen/medium_margin" android:scrollbars="none" app:layoutManager="org.fossify.commons.views.MyLinearLayoutManager" diff --git a/app/src/main/res/layout/item_thread_loading.xml b/app/src/main/res/layout/item_thread_loading.xml deleted file mode 100644 index 244cea63..00000000 --- a/app/src/main/res/layout/item_thread_loading.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - diff --git a/detekt.yml b/detekt.yml index 6472c5a3..73ddc0c1 100644 --- a/detekt.yml +++ b/detekt.yml @@ -40,6 +40,11 @@ style: maxLineLength: 120 excludePackageStatements: true excludeImportStatements: true + ReturnCount: + active: true + max: 4 + excludeGuardClauses: true + excludes: ["**/test/**", "**/androidTest/**"] naming: FunctionNaming: